Merge pull request #10 from nasyarobby/cursor/s3-compatible-script-3038

feat(scripts): add s3 script for S3-compatible object storage
This commit is contained in:
2026-08-19 15:16:51 +07:00
committed by GitHub
19 changed files with 1000 additions and 41 deletions
+4 -4
View File
@@ -16,7 +16,7 @@ pnpm install
pnpm dev
```
- API: http://localhost:9000
- API: http://localhost:8700
- UI (dev): http://localhost:5173
The first account created becomes **admin**. Later accounts are created from Users.
@@ -52,9 +52,9 @@ Optional `script.meta.reads = "ctx"` documents expression hosts. `meta.input` /
|---|---|
| `pnpm dev` | Server + Vite together |
| `pnpm dev:server` | API/runner only |
| `pnpm dev:web` | UI only (proxies `/api` to :9000) |
| `pnpm dev:web` | UI only (proxies `/api` to :8700) |
| `pnpm build` | Production UI build |
| `pnpm start` | Serve API and built UI from :9000 |
| `pnpm start` | Serve API and built UI from :8700 |
| `pnpm migrate` | Apply SQLite migrations |
## Environment
@@ -67,7 +67,7 @@ Optional `script.meta.reads = "ctx"` documents expression hosts. `meta.input` /
| `JFLOW_LOG_LEVEL` | `debug` | Pino level |
| `JFLOW_RETENTION_DAYS` | `30` | Run history prune |
| `JFLOW_CORS_ORIGIN` | `http://localhost:5173` | Vite origin in dev |
| `PORT` | `9000` | HTTP port |
| `PORT` | `8700` | HTTP port |
| `NODE_ENV` | — | Set `production` for secure cookies |
## Production
+14 -21
View File
@@ -1,41 +1,34 @@
### Manual trigger (default owner)
POST http://localhost:9000/u/default/mt
POST http://localhost:8700/u/default/mt
Content-Type: application/json
0
###
POST http://localhost:9000/u/default/time-to-ntfy
POST http://localhost:8700/u/default/time-to-ntfy
Content-Type: application/json
{}
### Auth bootstrap
GET http://localhost:9000/api/auth/bootstrap
### Login
POST http://localhost:9000/api/auth/login
###
GET http://localhost:8700/api/auth/bootstrap
###
POST http://localhost:8700/api/auth/login
Content-Type: application/json
{
"username": "admin",
"password": "changeme1"
}
### Dashboard
GET http://localhost:9000/api/dashboard
### Runs
GET http://localhost:9000/api/runs?owner=default&limit=20
### Reregister
POST http://localhost:9000/api/workflows/reregister
###
GET http://localhost:8700/api/dashboard
###
GET http://localhost:8700/api/runs?owner=default&limit=20
###
POST http://localhost:8700/api/workflows/reregister
Content-Type: application/json
{}
### Run workflow manually
POST http://localhost:9000/api/workflows/default/manual-trigger.yaml/run
###
POST http://localhost:8700/api/workflows/default/manual-trigger.yaml/run
Content-Type: application/json
{}
+43
View File
@@ -0,0 +1,43 @@
const BUFFER_PREVIEW_BYTES = 16;
/**
* @param {unknown} value
*/
export function isBinary(value) {
return (
Buffer.isBuffer(value) ||
ArrayBuffer.isView(value) ||
value instanceof ArrayBuffer
);
}
/**
* Compact stand-in for JSON (Buffer.toJSON dumps every byte as a number).
* @param {Buffer | ArrayBufferView | ArrayBuffer} value
*/
export function summarizeBinary(value) {
const buf = Buffer.isBuffer(value)
? value
: value instanceof ArrayBuffer
? Buffer.from(value)
: Buffer.from(value.buffer, value.byteOffset, value.byteLength);
const take = Math.min(buf.length, BUFFER_PREVIEW_BYTES);
return {
type: "Buffer",
length: buf.length,
preview: buf.subarray(0, take).toString("hex"),
truncated: buf.length > take,
};
}
/**
* JSON.stringify replacer. Must be a real function so `this` is the holder:
* Buffer#toJSON already ran on `value`, but `this[key]` is still the Buffer.
* @param {string} key
* @param {unknown} value
*/
export function jsonPreviewReplacer(key, value) {
const raw = this[key];
if (isBinary(raw)) return summarizeBinary(raw);
return value;
}
+2
View File
@@ -10,6 +10,8 @@
"migrate": "node -e \"import('./db.js').then((m) => m.migrate().then(() => process.exit(0)))\""
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1111.0",
"@aws-sdk/s3-request-presigner": "^3.1111.0",
"@fastify/cookie": "^11.0.2",
"@fastify/cors": "^11.1.0",
"@fastify/jwt": "^9.1.0",
+1 -1
View File
@@ -174,7 +174,7 @@ async function shutdown() {
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
const port = Number(process.env.PORT ?? 9000);
const port = Number(process.env.PORT ?? 8700);
server
.listen({
+2
View File
@@ -15,6 +15,8 @@ import { getVariablePlain } from "./variables-store.js";
const hostRequire = createRequire(import.meta.url);
const ALLOWED_MODULES = new Set([
"@aws-sdk/client-s3",
"@aws-sdk/s3-request-presigner",
"axios",
"jsonata",
"mustache",
+508
View File
@@ -0,0 +1,508 @@
import {
CopyObjectCommand,
DeleteObjectCommand,
GetObjectCommand,
HeadObjectCommand,
ListObjectsV2Command,
PutObjectCommand,
S3Client,
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const ACTIONS = new Set(["list", "read", "write", "delete", "stat", "presign", "copy"]);
function passContext(ctx) {
if (ctx?.context != null && typeof ctx.context === "object" && !Array.isArray(ctx.context)) {
return { ...ctx.context };
}
return {};
}
function mergeData(data) {
if (data != null && typeof data === "object" && !Array.isArray(data)) {
return { ...data };
}
return {};
}
function resolveAction(ctx) {
const raw = ctx.config?.action ?? ctx.data?.action ?? "list";
if (typeof raw !== "string" || raw.length === 0) {
throw new Error("s3: action must be a non-empty string");
}
const action = raw.toLowerCase();
if (!ACTIONS.has(action)) {
throw new Error(`s3: unsupported action "${raw}"`);
}
return action;
}
function resolveBucket(ctx) {
const bucket = ctx.config?.bucket ?? ctx.data?.bucket;
if (typeof bucket !== "string" || bucket.length === 0) {
throw new Error("s3: bucket is required (ctx.config.bucket or ctx.data.bucket)");
}
return bucket;
}
function resolveKey(ctx, { required = false } = {}) {
const key = ctx.config?.key ?? ctx.data?.key;
if (key == null || key === "") {
if (required) throw new Error("s3: key is required for this action");
return undefined;
}
if (typeof key !== "string") {
throw new Error("s3: key must be a string");
}
return key;
}
async function resolveSecretValue(secretName, label) {
if (typeof secretName !== "string" || secretName.length === 0) {
throw new Error(`s3: ${label} is required`);
}
return $secrets.reveal(await $secrets.get(secretName));
}
async function resolveCredentials(ctx) {
const accessKeyIdSecret = ctx.config?.accessKeyIdSecret;
const secretAccessKeySecret = ctx.config?.secretAccessKeySecret;
if (
typeof accessKeyIdSecret === "string" &&
accessKeyIdSecret.length > 0 &&
typeof secretAccessKeySecret === "string" &&
secretAccessKeySecret.length > 0
) {
return {
accessKeyId: await resolveSecretValue(accessKeyIdSecret, "accessKeyIdSecret"),
secretAccessKey: await resolveSecretValue(secretAccessKeySecret, "secretAccessKeySecret"),
};
}
const accessKeyId = ctx.config?.accessKeyId ?? ctx.data?.accessKeyId;
const secretAccessKey = ctx.config?.secretAccessKey ?? ctx.data?.secretAccessKey;
if (typeof accessKeyId === "string" && typeof secretAccessKey === "string") {
return { accessKeyId, secretAccessKey };
}
throw new Error(
"s3: credentials are required (accessKeyIdSecret + secretAccessKeySecret, or accessKeyId + secretAccessKey)",
);
}
function createS3Client(ctx, credentials) {
const endpoint = ctx.config?.endpoint ?? ctx.data?.endpoint;
const region =
typeof ctx.config?.region === "string" && ctx.config.region.length > 0
? ctx.config.region
: typeof ctx.data?.region === "string" && ctx.data.region.length > 0
? ctx.data.region
: "us-east-1";
/** @type {import("@aws-sdk/client-s3").S3ClientConfig} */
const options = {
region,
credentials,
};
if (typeof endpoint === "string" && endpoint.length > 0) {
options.endpoint = endpoint;
}
if (ctx.config?.forcePathStyle === true || ctx.data?.forcePathStyle === true) {
options.forcePathStyle = true;
}
return new S3Client(options);
}
function toIsoDate(value) {
if (value == null) return null;
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return null;
return date.toISOString();
}
function normalizeListedObject(item) {
return {
key: item.Key ?? null,
size: typeof item.Size === "number" ? item.Size : null,
modified: toIsoDate(item.LastModified),
etag: item.ETag ?? null,
storageClass: item.StorageClass ?? null,
};
}
async function readObjectBody(body) {
if (body == null) return Buffer.alloc(0);
if (typeof body.transformToByteArray === "function") {
return Buffer.from(await body.transformToByteArray());
}
/** @type {Buffer[]} */
const chunks = [];
for await (const chunk of body) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
return Buffer.concat(chunks);
}
function resolveWriteBody(ctx) {
if (ctx.config != null && typeof ctx.config === "object" && "body" in ctx.config) {
return ctx.config.body;
}
if (ctx.data?.file != null) {
const file = ctx.data.file;
if (Buffer.isBuffer(file) || file instanceof Uint8Array) {
return Buffer.from(file);
}
}
if (ctx.data?.body != null) {
const body = ctx.data.body;
if (Buffer.isBuffer(body) || body instanceof Uint8Array) {
return Buffer.from(body);
}
if (typeof body === "string") {
return Buffer.from(body, "utf8");
}
return Buffer.from(JSON.stringify(body), "utf8");
}
throw new Error("s3: write requires ctx.config.body, ctx.data.body, or ctx.data.file");
}
async function s3(ctx) {
const action = resolveAction(ctx);
const bucket = resolveBucket(ctx);
const credentials = await resolveCredentials(ctx);
const client = createS3Client(ctx, credentials);
log.info({ action, bucket }, "s3: starting action");
/** @type {Record<string, unknown>} */
let result = { action, bucket };
switch (action) {
case "list": {
const prefix = ctx.config?.prefix ?? ctx.data?.prefix ?? "";
const delimiter = ctx.config?.delimiter ?? ctx.data?.delimiter;
const maxKeys = Number(ctx.config?.maxKeys ?? ctx.data?.maxKeys ?? 1000);
const response = await client.send(
new ListObjectsV2Command({
Bucket: bucket,
Prefix: typeof prefix === "string" ? prefix : "",
Delimiter: typeof delimiter === "string" && delimiter.length > 0 ? delimiter : undefined,
MaxKeys: Number.isFinite(maxKeys) && maxKeys > 0 ? maxKeys : 1000,
}),
);
const objects = (response.Contents ?? []).map(normalizeListedObject);
const prefixes = (response.CommonPrefixes ?? [])
.map((entry) => entry.Prefix)
.filter((value) => typeof value === "string");
result = {
...result,
prefix: typeof prefix === "string" ? prefix : "",
objects,
prefixes,
count: objects.length,
isTruncated: response.IsTruncated === true,
nextContinuationToken: response.NextContinuationToken ?? null,
};
break;
}
case "read": {
const key = resolveKey(ctx, { required: true });
const outputVar =
typeof ctx.config?.outputVar === "string" && ctx.config.outputVar.length > 0
? ctx.config.outputVar
: "file";
const response = await client.send(
new GetObjectCommand({
Bucket: bucket,
Key: key,
}),
);
const file = await readObjectBody(response.Body);
const contentType = response.ContentType ?? "application/octet-stream";
const encoding = ctx.config?.encoding ?? ctx.data?.encoding;
result = {
...result,
key,
[outputVar]: file,
contentType,
contentLength: file.length,
etag: response.ETag ?? null,
lastModified: toIsoDate(response.LastModified),
};
if (encoding === "utf8" || encoding === "text") {
result.text = file.toString("utf8");
} else if (encoding === "base64") {
result.base64 = file.toString("base64");
}
break;
}
case "write": {
const key = resolveKey(ctx, { required: true });
const body = resolveWriteBody(ctx);
const contentType =
ctx.config?.contentType ??
ctx.data?.contentType ??
(typeof ctx.data?.body === "string" ? "text/plain; charset=utf-8" : "application/octet-stream");
const response = await client.send(
new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: body,
ContentType: typeof contentType === "string" ? contentType : undefined,
}),
);
result = {
...result,
key,
etag: response.ETag ?? null,
contentLength: body.length,
written: true,
};
break;
}
case "delete": {
const key = resolveKey(ctx, { required: true });
await client.send(
new DeleteObjectCommand({
Bucket: bucket,
Key: key,
}),
);
result = {
...result,
key,
deleted: true,
};
break;
}
case "stat": {
const key = resolveKey(ctx, { required: true });
const response = await client.send(
new HeadObjectCommand({
Bucket: bucket,
Key: key,
}),
);
result = {
...result,
key,
contentType: response.ContentType ?? null,
contentLength: typeof response.ContentLength === "number" ? response.ContentLength : null,
etag: response.ETag ?? null,
lastModified: toIsoDate(response.LastModified),
metadata: response.Metadata ?? {},
};
break;
}
case "presign": {
const key = resolveKey(ctx, { required: true });
const method = String(ctx.config?.presignMethod ?? ctx.data?.presignMethod ?? "get").toLowerCase();
const expiresIn = Number(ctx.config?.expiresIn ?? ctx.data?.expiresIn ?? 3600);
const command =
method === "put"
? new PutObjectCommand({ Bucket: bucket, Key: key })
: new GetObjectCommand({ Bucket: bucket, Key: key });
const url = await getSignedUrl(client, command, {
expiresIn: Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn : 3600,
});
result = {
...result,
key,
method,
expiresIn: Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn : 3600,
url,
};
break;
}
case "copy": {
const key = resolveKey(ctx, { required: true });
const sourceKey = ctx.config?.sourceKey ?? ctx.data?.sourceKey;
const sourceBucket = ctx.config?.sourceBucket ?? ctx.data?.sourceBucket ?? bucket;
if (typeof sourceKey !== "string" || sourceKey.length === 0) {
throw new Error("s3: copy requires sourceKey (ctx.config.sourceKey or ctx.data.sourceKey)");
}
const response = await client.send(
new CopyObjectCommand({
Bucket: bucket,
Key: key,
CopySource: `${sourceBucket}/${sourceKey}`,
}),
);
result = {
...result,
key,
sourceBucket,
sourceKey,
etag: response.CopyObjectResult?.ETag ?? null,
copied: true,
};
break;
}
default:
throw new Error(`s3: unsupported action "${action}"`);
}
log.info({ action, bucket, key: result.key ?? null }, "s3: action complete");
return {
output: { ...mergeData(ctx.data), ...result },
context: { ...passContext(ctx), ...result },
};
}
s3.meta = {
description: "Access S3-compatible object storage (AWS S3, MinIO, Cloudflare R2, etc.)",
previewConfigKey: "action",
tags: ["S3", "storage"],
config: {
action: {
type: "string",
default: "list",
enum: ["list", "read", "write", "delete", "stat", "presign", "copy"],
description: "Operation to perform",
},
endpoint: {
type: "string",
required: false,
description: "Custom S3 endpoint URL (required for MinIO and most non-AWS providers)",
},
region: {
type: "string",
default: "us-east-1",
description: "AWS region (still required by many S3-compatible APIs)",
},
bucket: {
type: "string",
required: true,
description: "Bucket name",
},
key: {
type: "string",
required: false,
description: "Object key (required for read, write, delete, stat, presign, copy)",
},
prefix: {
type: "string",
required: false,
description: "List only keys under this prefix",
},
delimiter: {
type: "string",
required: false,
description: "List folder delimiter, usually /",
},
maxKeys: {
type: "number",
default: 1000,
description: "Maximum objects returned by list",
},
forcePathStyle: {
type: "boolean",
default: false,
description: "Use path-style URLs (often required for MinIO)",
},
accessKeyIdSecret: {
type: "string",
required: false,
description: "Named secret for the access key id",
},
secretAccessKeySecret: {
type: "string",
required: false,
description: "Named secret for the secret access key",
},
accessKeyId: {
type: "string",
required: false,
description: "Plain access key id (prefer secrets in production)",
},
secretAccessKey: {
type: "string",
required: false,
description: "Plain secret access key (prefer secrets in production)",
},
contentType: {
type: "string",
required: false,
description: "Content-Type for write",
},
body: {
type: "any",
required: false,
description: "Body for write when not passed via data",
},
outputVar: {
type: "string",
default: "file",
description: "Output key for read action bytes",
},
encoding: {
type: "string",
required: false,
enum: ["utf8", "text", "base64"],
description: "Optional read decoding helper (adds text or base64 field)",
},
expiresIn: {
type: "number",
default: 3600,
description: "Presigned URL lifetime in seconds",
},
presignMethod: {
type: "string",
default: "get",
enum: ["get", "put"],
description: "Presign a download (get) or upload (put) URL",
},
sourceBucket: {
type: "string",
required: false,
description: "Source bucket for copy (defaults to bucket)",
},
sourceKey: {
type: "string",
required: false,
description: "Source key for copy",
},
},
input: {
action: { type: "string", required: false },
bucket: { type: "string", required: false },
key: { type: "string", required: false },
prefix: { type: "string", required: false },
body: { type: "any", required: false, description: "Write payload" },
file: { type: "buffer", required: false, description: "Binary write payload" },
sourceKey: { type: "string", required: false },
sourceBucket: { type: "string", required: false },
},
output: {
action: { type: "string" },
bucket: { type: "string" },
objects: { type: "array", required: false, description: "list results" },
file: { type: "buffer", required: false, description: "read bytes (or outputVar)" },
url: { type: "string", required: false, description: "presigned URL" },
written: { type: "boolean", required: false },
deleted: { type: "boolean", required: false },
copied: { type: "boolean", required: false },
},
context: {
action: { type: "string" },
bucket: { type: "string" },
},
example: {
data: {},
config: {
action: "list",
endpoint: "https://minio.example.com",
region: "us-east-1",
bucket: "backups",
prefix: "daily/",
forcePathStyle: true,
accessKeyIdSecret: "minio_access_key",
secretAccessKeySecret: "minio_secret_key",
},
},
};
export default s3;
+1
View File
@@ -1,6 +1,7 @@
import { inspect } from "node:util";
export const REDACTED = "[secret]";
/** Short values are stored, but skipped in log redaction to avoid false positives. */
export const MIN_SECRET_LENGTH = 8;
/** @type {Set<string>} */
+3 -3
View File
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
import { db } from "./db.js";
import { assertOwner } from "./fs-store.js";
import { decryptSecret, encryptSecret } from "./secrets.js";
import { MIN_SECRET_LENGTH, registerPlaintext } from "./secret-value.js";
import { registerPlaintext } from "./secret-value.js";
const MAX_NAME_LENGTH = 128;
const SECRET_NAME_RE = /^[A-Za-z0-9._-]+$/;
@@ -68,8 +68,8 @@ export async function getSecretById(id) {
* @param {{ owner: string, name: string, value: string }} opts
*/
export async function upsertSecret({ owner, name, value }) {
if (typeof value !== "string" || value.length < MIN_SECRET_LENGTH) {
const err = new Error(`value must be at least ${MIN_SECRET_LENGTH} characters`);
if (typeof value !== "string" || value.length === 0) {
const err = new Error("value is required");
err.statusCode = 400;
throw err;
}
+4 -1
View File
@@ -1,4 +1,5 @@
import pino from "pino";
import { isBinary, summarizeBinary } from "../../json-preview.js";
import { redactString } from "../../secret-value.js";
const LEVEL_TO_NUM = {
@@ -64,7 +65,9 @@ export function safeSerialize(value) {
try {
return JSON.parse(
redactString(
JSON.stringify(value, (_key, v) => {
JSON.stringify(value, function (key, v) {
const raw = this[key];
if (isBinary(raw)) return summarizeBinary(raw);
if (typeof v === "bigint") return v.toString();
if (typeof v === "object" && v !== null) {
if (seen.has(v)) return "[Circular]";
+2 -5
View File
@@ -6,7 +6,6 @@ import {
listSecrets,
upsertSecret,
} from "../../secrets-store.js";
import { MIN_SECRET_LENGTH } from "../../secret-value.js";
/**
* @param {import("fastify").FastifyInstance} fastify
@@ -37,10 +36,8 @@ export default async function secretsPlugin(fastify) {
}
const value = String(body.value ?? "");
if (value.length < MIN_SECRET_LENGTH) {
return reply
.code(400)
.send({ error: `value must be at least ${MIN_SECRET_LENGTH} characters` });
if (value.length === 0) {
return reply.code(400).send({ error: "value is required" });
}
try {
+1 -1
View File
@@ -418,7 +418,7 @@ export default function workflowsPluginFactory(registry) {
return {
runId: result.runId,
status: result.status,
result: result.result,
result: store.toDisplayValue(result.result),
};
});
+16 -1
View File
@@ -1,5 +1,6 @@
import { randomUUID } from "node:crypto";
import { db } from "./db.js";
import { jsonPreviewReplacer } from "./json-preview.js";
import { redactString } from "./secret-value.js";
const MAX_JSON_BYTES = 64 * 1024;
@@ -12,7 +13,7 @@ export function serialize(value) {
if (value === undefined || value === null) return null;
let json;
try {
json = JSON.stringify(value);
json = JSON.stringify(value, jsonPreviewReplacer);
} catch {
json = JSON.stringify({ truncated: true, reason: "unserializable" });
}
@@ -24,6 +25,20 @@ export function serialize(value) {
});
}
/**
* Parsed JSON-safe copy for API / UI (buffers summarized, size-capped).
* @param {unknown} value
*/
export function toDisplayValue(value) {
const json = serialize(value);
if (json == null) return null;
try {
return JSON.parse(json);
} catch {
return null;
}
}
/**
* @param {string | null} value
* @returns {unknown}
@@ -0,0 +1,56 @@
import { jsonPreviewReplacer, summarizeBinary } from "../json-preview.js";
import { serialize, toDisplayValue } from "../store.js";
import { safeSerialize } from "../src/api/dry-run-logger.js";
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3]);
const summary = summarizeBinary(png);
if (summary.length !== png.length || summary.preview !== "89504e470d0a1a0a010203" || summary.truncated !== false) {
throw new Error(`summarizeBinary: ${JSON.stringify(summary)}`);
}
const long = Buffer.alloc(32, 0xff);
const longSummary = summarizeBinary(long);
if (longSummary.length !== 32 || longSummary.preview.length !== 32 || longSummary.truncated !== true) {
throw new Error(`long summarizeBinary: ${JSON.stringify(longSummary)}`);
}
const dumped = JSON.stringify({ file: png });
if (!dumped.includes('"data":[')) {
throw new Error("expected default Buffer JSON to include data array");
}
const previewed = JSON.stringify({ file: png }, jsonPreviewReplacer);
if (previewed.includes('"data":[')) {
throw new Error(`replacer still dumped bytes: ${previewed}`);
}
if (!previewed.includes('"preview":"89504e470d0a1a0a010203"')) {
throw new Error(`replacer missing hex preview: ${previewed}`);
}
const stored = serialize({ output: { file: png, filename: "test.png" } });
if (stored.includes('"data":[')) {
throw new Error(`serialize dumped bytes: ${stored.slice(0, 200)}`);
}
const display = toDisplayValue({
output: { file: png },
context: { file: png },
});
if (display.output.file.length !== png.length || display.context.file.truncated !== false) {
throw new Error(`toDisplayValue: ${JSON.stringify(display)}`);
}
if (Array.isArray(display.output.file.data)) {
throw new Error("toDisplayValue should not keep Buffer.data");
}
const dry = safeSerialize({ file: png, n: 1n });
if (dry.n !== "1" || Array.isArray(dry.file.data)) {
throw new Error(`safeSerialize: ${JSON.stringify(dry)}`);
}
const typed = safeSerialize({ file: new Uint8Array(png) });
if (typed.file.length !== png.length || typed.file.type !== "Buffer") {
throw new Error(`Uint8Array: ${JSON.stringify(typed)}`);
}
console.log("json-preview-smoke: ok");
@@ -10,3 +10,4 @@ scripts:
- test-send-gmail.yaml
- track.yaml
- rss-devto-to-ntfy.yaml
- test-minio.yaml
@@ -0,0 +1,20 @@
name: Test MinIO
scripts:
- script: fetch-binary.js
config:
outputVar: file
url: https://nsrb:error403@dav.0dev.web.id/ntfy/IyM9784UdG4S
filename: test.png
- script: s3.js
config:
action: write
endpoint: http://localhost:9000
bucket: default
forcePathStyle: true
accessKeyIdSecret: minio_user
secretAccessKeySecret: minio_pass
key: file.png
triggers:
- type: HTTP
method: POST
path: /new
+1 -1
View File
@@ -180,11 +180,11 @@ export function SecretsPage() {
value={form.value}
onChange={(e) => setForm({ ...form, value: e.target.value })}
required
minLength={8}
autoComplete="new-password"
/>
<p className="text-xs opacity-60 mt-1">
Values are encrypted at rest and never shown again after save.
Values shorter than 8 characters are not redacted from logs.
</p>
{upsert.isError ? (
<p className="text-error text-sm">{errorMessage(upsert.error)}</p>
+2 -2
View File
@@ -7,8 +7,8 @@ export default defineConfig({
server: {
port: 5173,
proxy: {
"/api": { target: "http://127.0.0.1:9000", changeOrigin: true },
"/admin": { target: "http://127.0.0.1:9000", changeOrigin: true },
"/api": { target: "http://127.0.0.1:8700", changeOrigin: true },
"/admin": { target: "http://127.0.0.1:8700", changeOrigin: true },
},
},
});
+318
View File
@@ -14,6 +14,12 @@ importers:
packages/server:
dependencies:
'@aws-sdk/client-s3':
specifier: ^3.1111.0
version: 3.1113.0
'@aws-sdk/s3-request-presigner':
specifier: ^3.1111.0
version: 3.1113.0
'@fastify/cookie':
specifier: ^11.0.2
version: 11.1.2
@@ -135,6 +141,82 @@ packages:
'@antfu/install-pkg@1.1.0':
resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==}
'@aws-sdk/checksums@3.1000.28':
resolution: {integrity: sha512-VCpnmyHQ1IH49ni3LXnQj7DPr7rmcJmzYeiCkYdCcfgNtkvOj38cdcL9lapBWoItZWFACJPFJlymqC7/gem3Gw==}
engines: {node: '>=20.0.0'}
'@aws-sdk/client-s3@3.1113.0':
resolution: {integrity: sha512-NRqdtohoMRyWkEeeznfG1KPN08dclCbl+HFuLPB2v8qPcgoNmTFlLKl9ELiR6hsGXQ4Ur3qvRnoJVEk8ND74pg==}
engines: {node: '>=20.0.0'}
'@aws-sdk/core@3.977.8':
resolution: {integrity: sha512-7+Kcrkvrk9lM/m7jRhHpT4jCdvzGHsuaSRbF8TdzzkY1mRzp/Ogwf9c7H29k4gGhey0BBWhCWr16+t0J61gwmg==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-env@3.972.69':
resolution: {integrity: sha512-AreCFzcB4kH2HF9031Ot0jSJr3KXvRg6e8uDeub20JEVdZU3Bv0sTq1plc7VsT3KiqutlzH7l0j50UcCWHUioA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-http@3.972.71':
resolution: {integrity: sha512-A8ObcqVmDMnk4F9NozZ7JwmUu9Q4xyBJkmyq1C5U+wNM9ht9J7+EuuyabsLWXZnOoTqFaJuYBYTKf5CTipkEjA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-ini@3.973.14':
resolution: {integrity: sha512-7c+Wti2LsERNWMfm7ySz3/6RPopFW3Nmn7s63Xpcq6R/tRuY5hpvkHA2xVgi5ukJbvok9l0IDtVEvqTtg+X7dw==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-login@3.972.76':
resolution: {integrity: sha512-LVixwOnEJfrrfKHeZjBA8pIMTZjNDq8ak8VpcoWUuCJDrSnBNU8POJksULMgvN089P0MXtQYH2Zs627/MK1K0g==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-node@3.972.80':
resolution: {integrity: sha512-bE2qh8ww4iClO1jHsBXdOE8FUgzDbdxbyorNjSCoPSkQd51k3jODItuPZfuwcLHZqDXsH+bI4AMHhqtuyR7mSg==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-process@3.972.69':
resolution: {integrity: sha512-9kpTNdZTrcqXTfhxM7fgl9Z68ek3Fu5oe3Yf+A/pJGibEqpgZxz2tSY7SinmyCIU2PJ+ygY4FPoBBnLpocMtrQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-sso@3.973.13':
resolution: {integrity: sha512-Oc81qauMPzUoTnAS2YKpNwY6sY/LUyQTEeaf6yP197WMxkEBQfcKLR1MFpD7+pNTubXnfkH6gwpji+Gc7iyD2Q==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-web-identity@3.972.75':
resolution: {integrity: sha512-YPN6uoGDgjjjeVFZrcOeCJqmB6zpXoeeNgIjqe+DexJaWqdjVfCCe+VAZwli9Z2h8KhFW8oxkO39emQ1tyz/Mw==}
engines: {node: '>=20.0.0'}
'@aws-sdk/middleware-sdk-s3@3.972.74':
resolution: {integrity: sha512-2lzoV2z2QO5KJZYGOCnIZ1WVQgzMECvwuzr1xb034a++8QW4U4eGrmC2u4yg1xvNv4TLL/Uv5DLyuAiw0b9z7Q==}
engines: {node: '>=20.0.0'}
'@aws-sdk/nested-clients@3.997.43':
resolution: {integrity: sha512-bit+VpqWNyi3wHxFoTsTliNXimCSL2r2OeDTm7ZrG+YsTZ2D7ofDJ6r/t9PVBn80i6/v0X2h9Tgw6QP2MAKfPw==}
engines: {node: '>=20.0.0'}
'@aws-sdk/s3-request-presigner@3.1113.0':
resolution: {integrity: sha512-FdbHboJSscXRHnqOGRLN+MvtcYNlxw1+XWMKcKi72nBPxpSTGQA+/zmxEBTlkCvTdIPtl/g383nNY0RiKYPmWQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/signature-v4-multi-region@3.996.45':
resolution: {integrity: sha512-bBuyztukzXq6plzFGHAWiQt0QXo+HL8b8lX5cFTzkez/74PtS1c0qPFCIVuHkyoT+miH2qOjAcm1/yoro2ESPA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/token-providers@3.1111.0':
resolution: {integrity: sha512-JfljgoVtl+s3Qy21n9a7Z48uCQaOXcN74KJ3TEQfPoB293GrXFSt6HSQJF1sTZ8c/5QedEvd3NjJQMO4u9qa5A==}
engines: {node: '>=20.0.0'}
'@aws-sdk/types@3.974.4':
resolution: {integrity: sha512-dSFDNG00MEz0/xl5gxL62giLd1iYyJsTxZ1I1DOj6lC+bbgLB4TRsYClJg3b62dhXT1uATzsTNXPnC+33EJV3A==}
engines: {node: '>=20.0.0'}
'@aws-sdk/xml-builder@3.972.39':
resolution: {integrity: sha512-FTti8DS5MMWXNUWiRwXAJeYS+0GHHiMy0+7XOhcwk63ILHmfS2UFy2z/HNpZCSOJJ3P3dnWY6hfYNW3DF0nXUA==}
engines: {node: '>=20.0.0'}
'@aws/lambda-invoke-store@0.3.0':
resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==}
engines: {node: '>=18.0.0'}
'@babel/code-frame@7.29.7':
resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
engines: {node: '>=6.9.0'}
@@ -627,6 +709,30 @@ packages:
cpu: [x64]
os: [win32]
'@smithy/core@3.33.2':
resolution: {integrity: sha512-CUGXpnPkVdjUCbix+83sWLW9VFgQOm44MDOx/ihITJMAnOZKvL8YYIc7DR9pP/tZ8CIRvMiON/TucvygqbHO3w==}
engines: {node: '>=18.0.0'}
'@smithy/credential-provider-imds@4.5.2':
resolution: {integrity: sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==}
engines: {node: '>=18.0.0'}
'@smithy/fetch-http-handler@5.7.2':
resolution: {integrity: sha512-nZyWTmSpJEXl6VtWVMBJve/7x12DZu6sIX1z1a+ZMaHlQQRs9Zpu6NbTe/gmxYXVRpkjxyDYpZ5gx2IM6f/Wkw==}
engines: {node: '>=18.0.0'}
'@smithy/node-http-handler@4.11.2':
resolution: {integrity: sha512-avwAh9HM3h2lcfjvP3zYIZGf+XVgLQ91wOJ2qoFbNpW1UZeZb33aGlhTZvtkANHfcGhJroRY64525OjfgOg30g==}
engines: {node: '>=18.0.0'}
'@smithy/signature-v4@5.7.2':
resolution: {integrity: sha512-P7Ki6px6OOrxVtx8K7nLmyx4SlXUW/uTKDdMG44UHefmPGSRMBKe2v+TM59WdLcpUIrBrnuCsIqiM2MbsZjmhw==}
engines: {node: '>=18.0.0'}
'@smithy/types@4.17.2':
resolution: {integrity: sha512-FOKpVZob9MPTn2znRzGrnsMHv7BOsKVw3XiP/cOyYLDVZ9qKp4nifIiSCuUU/fIj5Vu0UOAxCFr+qRAtG0NUkA==}
engines: {node: '>=18.0.0'}
'@tailwindcss/node@4.3.3':
resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==}
@@ -921,6 +1027,9 @@ packages:
boolbase@1.0.0:
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
bowser@2.14.1:
resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==}
brace-expansion@2.1.4:
resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==}
@@ -2157,6 +2266,180 @@ snapshots:
package-manager-detector: 1.8.0
tinyexec: 1.3.0
'@aws-sdk/checksums@3.1000.28':
dependencies:
'@aws-sdk/core': 3.977.8
'@aws-sdk/types': 3.974.4
'@smithy/core': 3.33.2
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/client-s3@3.1113.0':
dependencies:
'@aws-sdk/checksums': 3.1000.28
'@aws-sdk/core': 3.977.8
'@aws-sdk/credential-provider-node': 3.972.80
'@aws-sdk/middleware-sdk-s3': 3.972.74
'@aws-sdk/signature-v4-multi-region': 3.996.45
'@aws-sdk/types': 3.974.4
'@smithy/core': 3.33.2
'@smithy/fetch-http-handler': 5.7.2
'@smithy/node-http-handler': 4.11.2
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/core@3.977.8':
dependencies:
'@aws-sdk/types': 3.974.4
'@aws-sdk/xml-builder': 3.972.39
'@aws/lambda-invoke-store': 0.3.0
'@smithy/core': 3.33.2
'@smithy/signature-v4': 5.7.2
'@smithy/types': 4.17.2
bowser: 2.14.1
tslib: 2.8.1
'@aws-sdk/credential-provider-env@3.972.69':
dependencies:
'@aws-sdk/core': 3.977.8
'@aws-sdk/types': 3.974.4
'@smithy/core': 3.33.2
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/credential-provider-http@3.972.71':
dependencies:
'@aws-sdk/core': 3.977.8
'@aws-sdk/types': 3.974.4
'@smithy/core': 3.33.2
'@smithy/fetch-http-handler': 5.7.2
'@smithy/node-http-handler': 4.11.2
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/credential-provider-ini@3.973.14':
dependencies:
'@aws-sdk/core': 3.977.8
'@aws-sdk/credential-provider-env': 3.972.69
'@aws-sdk/credential-provider-http': 3.972.71
'@aws-sdk/credential-provider-login': 3.972.76
'@aws-sdk/credential-provider-process': 3.972.69
'@aws-sdk/credential-provider-sso': 3.973.13
'@aws-sdk/credential-provider-web-identity': 3.972.75
'@aws-sdk/nested-clients': 3.997.43
'@aws-sdk/types': 3.974.4
'@smithy/core': 3.33.2
'@smithy/credential-provider-imds': 4.5.2
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/credential-provider-login@3.972.76':
dependencies:
'@aws-sdk/core': 3.977.8
'@aws-sdk/nested-clients': 3.997.43
'@aws-sdk/types': 3.974.4
'@smithy/core': 3.33.2
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/credential-provider-node@3.972.80':
dependencies:
'@aws-sdk/credential-provider-env': 3.972.69
'@aws-sdk/credential-provider-http': 3.972.71
'@aws-sdk/credential-provider-ini': 3.973.14
'@aws-sdk/credential-provider-process': 3.972.69
'@aws-sdk/credential-provider-sso': 3.973.13
'@aws-sdk/credential-provider-web-identity': 3.972.75
'@aws-sdk/types': 3.974.4
'@smithy/core': 3.33.2
'@smithy/credential-provider-imds': 4.5.2
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/credential-provider-process@3.972.69':
dependencies:
'@aws-sdk/core': 3.977.8
'@aws-sdk/types': 3.974.4
'@smithy/core': 3.33.2
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/credential-provider-sso@3.973.13':
dependencies:
'@aws-sdk/core': 3.977.8
'@aws-sdk/nested-clients': 3.997.43
'@aws-sdk/token-providers': 3.1111.0
'@aws-sdk/types': 3.974.4
'@smithy/core': 3.33.2
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/credential-provider-web-identity@3.972.75':
dependencies:
'@aws-sdk/core': 3.977.8
'@aws-sdk/nested-clients': 3.997.43
'@aws-sdk/types': 3.974.4
'@smithy/core': 3.33.2
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/middleware-sdk-s3@3.972.74':
dependencies:
'@aws-sdk/core': 3.977.8
'@aws-sdk/signature-v4-multi-region': 3.996.45
'@aws-sdk/types': 3.974.4
'@smithy/core': 3.33.2
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/nested-clients@3.997.43':
dependencies:
'@aws-sdk/core': 3.977.8
'@aws-sdk/signature-v4-multi-region': 3.996.45
'@aws-sdk/types': 3.974.4
'@smithy/core': 3.33.2
'@smithy/fetch-http-handler': 5.7.2
'@smithy/node-http-handler': 4.11.2
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/s3-request-presigner@3.1113.0':
dependencies:
'@aws-sdk/core': 3.977.8
'@aws-sdk/signature-v4-multi-region': 3.996.45
'@aws-sdk/types': 3.974.4
'@smithy/core': 3.33.2
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/signature-v4-multi-region@3.996.45':
dependencies:
'@aws-sdk/types': 3.974.4
'@smithy/signature-v4': 5.7.2
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/token-providers@3.1111.0':
dependencies:
'@aws-sdk/core': 3.977.8
'@aws-sdk/nested-clients': 3.997.43
'@aws-sdk/types': 3.974.4
'@smithy/core': 3.33.2
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/types@3.974.4':
dependencies:
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/xml-builder@3.972.39':
dependencies:
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws/lambda-invoke-store@0.3.0': {}
'@babel/code-frame@7.29.7':
dependencies:
'@babel/helper-validator-identifier': 7.29.7
@@ -2572,6 +2855,39 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.62.4':
optional: true
'@smithy/core@3.33.2':
dependencies:
'@smithy/types': 4.17.2
tslib: 2.8.1
'@smithy/credential-provider-imds@4.5.2':
dependencies:
'@smithy/core': 3.33.2
'@smithy/types': 4.17.2
tslib: 2.8.1
'@smithy/fetch-http-handler@5.7.2':
dependencies:
'@smithy/core': 3.33.2
'@smithy/types': 4.17.2
tslib: 2.8.1
'@smithy/node-http-handler@4.11.2':
dependencies:
'@smithy/core': 3.33.2
'@smithy/types': 4.17.2
tslib: 2.8.1
'@smithy/signature-v4@5.7.2':
dependencies:
'@smithy/core': 3.33.2
'@smithy/types': 4.17.2
tslib: 2.8.1
'@smithy/types@4.17.2':
dependencies:
tslib: 2.8.1
'@tailwindcss/node@4.3.3':
dependencies:
'@jridgewell/remapping': 2.3.5
@@ -2880,6 +3196,8 @@ snapshots:
boolbase@1.0.0: {}
bowser@2.14.1: {}
brace-expansion@2.1.4:
dependencies:
balanced-match: 1.0.2