@wervt/nuxt 0.3.8 → 0.3.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/brand.d.ts +18 -4
- package/dist/brand.js +37 -6
- package/dist/module.d.ts +3 -14
- package/dist/module.js +27 -59
- package/dist/provision.d.ts +1 -77
- package/dist/provision.js +1 -238
- package/dist/runtime/app/plugins/analytics.client.js +2 -2
- package/dist/runtime/app/plugins/feedback.client.js +11 -22
- package/dist/runtime/app/plugins/user.client.js +2 -7
- package/dist/runtime/app/plugins/webmcp.client.js +4 -41
- package/dist/runtime/app/snapshot.d.ts +2 -15
- package/dist/runtime/app/snapshot.js +1 -63
- package/dist/runtime/app/useShape.js +1 -1
- package/dist/runtime/app/useWervtFeedback.d.ts +1 -13
- package/dist/runtime/app/useWervtFeedback.js +3 -11
- package/dist/runtime/app/useWervtTrack.js +4 -8
- package/dist/runtime/app/useWervtUser.d.ts +3 -2
- package/dist/runtime/app/useWervtUser.js +3 -2
- package/dist/runtime/server/access.d.ts +3 -22
- package/dist/runtime/server/access.js +7 -24
- package/dist/runtime/server/anthropic.d.ts +2 -7
- package/dist/runtime/server/anthropic.js +6 -37
- package/dist/runtime/server/db.d.ts +2 -17
- package/dist/runtime/server/db.js +1 -35
- package/dist/runtime/server/errors.d.ts +6 -0
- package/dist/runtime/server/errors.js +21 -0
- package/dist/runtime/server/identity.d.ts +3 -9
- package/dist/runtime/server/identity.js +4 -23
- package/dist/runtime/server/index.d.ts +1 -1
- package/dist/runtime/server/index.js +1 -1
- package/dist/runtime/server/mail.js +11 -29
- package/dist/runtime/server/mcp-claims.d.ts +2 -3
- package/dist/runtime/server/mcp-claims.js +1 -17
- package/dist/runtime/server/seed.d.ts +2 -16
- package/dist/runtime/server/seed.js +1 -26
- package/dist/runtime/server/shape.d.ts +2 -2
- package/dist/runtime/server/shape.js +5 -45
- package/dist/runtime/server/task-handlers.d.ts +1 -5
- package/dist/runtime/server/task-handlers.js +3 -9
- package/dist/runtime/server/task-run-nitro2.d.ts +1 -5
- package/dist/runtime/server/task-run-nitro3.d.ts +1 -5
- package/dist/runtime/server/tasks.d.ts +2 -32
- package/dist/runtime/server/tasks.js +1 -9
- package/dist/runtime/server/tool-handlers.d.ts +1 -1
- package/dist/runtime/server/user-route.d.ts +1 -3
- package/dist/runtime/server/user.js +8 -11
- package/dist/runtime/shared/agent-token.d.ts +2 -7
- package/dist/runtime/shared/agent-token.js +1 -16
- package/dist/runtime/shared/shape.d.ts +2 -17
- package/dist/runtime/shared/shape.js +1 -15
- package/dist/runtime/shared/user.d.ts +3 -19
- package/dist/runtime/shared/user.js +1 -9
- package/package.json +5 -1
- package/dist/dev.d.ts +0 -31
- package/dist/dev.js +0 -158
- package/dist/jsdoc-imports.js +0 -25
- package/dist/seed.js +0 -51
- package/dist/storage.d.ts +0 -72
- package/dist/storage.js +0 -362
package/dist/seed.js
DELETED
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
// src/seed.ts
|
|
2
|
-
/** The conventional seed task: `server/tasks/db/seed.ts`. */
|
|
3
|
-
const SEED_TASK = "db:seed";
|
|
4
|
-
/**
|
|
5
|
-
* `nitro.scheduledTasks` without the seed task, and the crons it was listed under. Seeding fills
|
|
6
|
-
* an empty database on request; on a schedule it would run against production data.
|
|
7
|
-
*/
|
|
8
|
-
function unscheduleSeed(schedules) {
|
|
9
|
-
const kept = {};
|
|
10
|
-
const removed = [];
|
|
11
|
-
for (const [cron, entry] of Object.entries(schedules)) {
|
|
12
|
-
const tasks = [entry].flat();
|
|
13
|
-
const rest = tasks.filter((task) => task !== SEED_TASK);
|
|
14
|
-
if (rest.length < tasks.length) removed.push(cron);
|
|
15
|
-
if (rest.length > 0) kept[cron] = Array.isArray(entry) ? rest : rest[0];
|
|
16
|
-
}
|
|
17
|
-
return {
|
|
18
|
-
schedules: kept,
|
|
19
|
-
removed
|
|
20
|
-
};
|
|
21
|
-
}
|
|
22
|
-
/**
|
|
23
|
-
* Runs `db:seed` through the Nitro dev server's task route, once the server answers. The dev
|
|
24
|
-
* server is still building when it starts listening, so its task list is polled first; that
|
|
25
|
-
* also tells whether the app has a seed task at all.
|
|
26
|
-
*/
|
|
27
|
-
async function runDevSeed(baseUrl, { fetch: request = fetch, timeout = 12e4, interval = 500 } = {}) {
|
|
28
|
-
const base = baseUrl.replace(/\/+$/, "");
|
|
29
|
-
const deadline = Date.now() + timeout;
|
|
30
|
-
let tasks;
|
|
31
|
-
while (!tasks) {
|
|
32
|
-
const response = await request(`${base}/_nitro/tasks`).catch(() => void 0);
|
|
33
|
-
const body = response?.ok ? await response.json().catch(() => void 0) : void 0;
|
|
34
|
-
if (body && typeof body === "object" && "tasks" in body) tasks = body.tasks;
|
|
35
|
-
else if (Date.now() >= deadline) throw new Error("the dev server did not answer in time");
|
|
36
|
-
else await new Promise((resolve) => setTimeout(resolve, interval));
|
|
37
|
-
}
|
|
38
|
-
if (!("db:seed" in tasks)) return { status: "missing" };
|
|
39
|
-
const response = await request(`${base}/_nitro/tasks/${SEED_TASK}`, {
|
|
40
|
-
method: "POST",
|
|
41
|
-
headers: { "content-type": "application/json" },
|
|
42
|
-
body: JSON.stringify({ payload: {} })
|
|
43
|
-
});
|
|
44
|
-
const body = await response.json().catch(() => ({}));
|
|
45
|
-
if (!response.ok) throw new Error(body.message ?? `${response.status} ${response.statusText}`);
|
|
46
|
-
return {
|
|
47
|
-
status: "seeded",
|
|
48
|
-
result: body.result
|
|
49
|
-
};
|
|
50
|
-
}
|
|
51
|
-
export { SEED_TASK, runDevSeed, unscheduleSeed };
|
package/dist/storage.d.ts
DELETED
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
export interface StorageOptions {
|
|
2
|
-
/** Garage's admin API, e.g. http://garage:3903 */
|
|
3
|
-
adminUrl: string;
|
|
4
|
-
adminToken: string;
|
|
5
|
-
/** Garage's S3 API as the control plane reaches it, e.g. http://garage:3900 */
|
|
6
|
-
s3Url: string;
|
|
7
|
-
secret: string;
|
|
8
|
-
}
|
|
9
|
-
export interface StorageUsage {
|
|
10
|
-
bucket: string;
|
|
11
|
-
objects: number;
|
|
12
|
-
bytes: number;
|
|
13
|
-
/** Multipart uploads started but never completed or aborted. */
|
|
14
|
-
unfinishedUploads: number;
|
|
15
|
-
/** The bucket's size limit in bytes, null for none. */
|
|
16
|
-
maxBytes: number | null;
|
|
17
|
-
}
|
|
18
|
-
export interface StorageQuota {
|
|
19
|
-
/** Bytes the bucket may hold; null for no limit. Garage refuses uploads past it. */
|
|
20
|
-
maxBytes: number | null;
|
|
21
|
-
}
|
|
22
|
-
export declare class StorageError extends Error {}
|
|
23
|
-
export interface StoredObject {
|
|
24
|
-
key: string;
|
|
25
|
-
size: number;
|
|
26
|
-
/** ISO time. */
|
|
27
|
-
lastModified: string;
|
|
28
|
-
}
|
|
29
|
-
export interface ObjectListing {
|
|
30
|
-
prefix: string;
|
|
31
|
-
/** Prefixes one level below `prefix`, ending in `/`. */
|
|
32
|
-
folders: string[];
|
|
33
|
-
files: StoredObject[];
|
|
34
|
-
/** Pass as `token` for the next page. */
|
|
35
|
-
next?: string;
|
|
36
|
-
}
|
|
37
|
-
export interface ObjectDetails extends StoredObject {
|
|
38
|
-
contentType: string | null;
|
|
39
|
-
/** `x-amz-meta-*` headers, without the prefix. */
|
|
40
|
-
metadata: Record<string, string>;
|
|
41
|
-
}
|
|
42
|
-
/** S3's limit for object keys, in bytes. */
|
|
43
|
-
export declare const MAX_KEY_BYTES = 1024;
|
|
44
|
-
export declare function createStorage(options: StorageOptions): {
|
|
45
|
-
ensureLayout: () => Promise<void>;
|
|
46
|
-
provision: (app: string, appOrigins: string | string[], quota?: StorageQuota) => Promise<{
|
|
47
|
-
bucket: string;
|
|
48
|
-
}>;
|
|
49
|
-
setQuota: (app: string, quota: StorageQuota) => Promise<boolean>;
|
|
50
|
-
usage: (app: string) => Promise<StorageUsage | undefined>;
|
|
51
|
-
list: (app: string, { prefix, token, limit }?: {
|
|
52
|
-
prefix?: string;
|
|
53
|
-
token?: string;
|
|
54
|
-
limit?: number;
|
|
55
|
-
}) => Promise<ObjectListing | undefined>;
|
|
56
|
-
describe: (app: string, key: string) => Promise<ObjectDetails | undefined>;
|
|
57
|
-
downloadUrl: (app: string, key: string, { publicUrl, expiresIn }: {
|
|
58
|
-
publicUrl: string;
|
|
59
|
-
expiresIn?: number;
|
|
60
|
-
}) => Promise<string>;
|
|
61
|
-
remove: (app: string, key: string) => Promise<{
|
|
62
|
-
deleted: string[];
|
|
63
|
-
} | undefined>;
|
|
64
|
-
sweepAbandonedUploads: (app: string, { graceMs, now }?: {
|
|
65
|
-
graceMs?: number | undefined;
|
|
66
|
-
now?: number | undefined;
|
|
67
|
-
}) => Promise<{
|
|
68
|
-
deleted: string[];
|
|
69
|
-
}>;
|
|
70
|
-
hasBucket: (app: string) => Promise<boolean>;
|
|
71
|
-
drop: (app: string) => Promise<void>;
|
|
72
|
-
};
|
package/dist/storage.js
DELETED
|
@@ -1,362 +0,0 @@
|
|
|
1
|
-
import { storageCredentials } from "./provision.js";
|
|
2
|
-
import { AwsClient } from "aws4fetch";
|
|
3
|
-
// src/storage.ts
|
|
4
|
-
var StorageError = class extends Error {};
|
|
5
|
-
/**
|
|
6
|
-
* ablage marks bytes uploaded straight from browsers (`x-amz-meta-ablage-direct-upload`). Its
|
|
7
|
-
* metadata object (`<group>/meta/<id>`) is only written once `completeUpload()` has checked them,
|
|
8
|
-
* so marked bytes without one belong to an upload that was never completed.
|
|
9
|
-
*/
|
|
10
|
-
const DIRECT_UPLOAD_MARKER = "x-amz-meta-ablage-direct-upload";
|
|
11
|
-
/**
|
|
12
|
-
* How old abandoned direct-upload bytes must be before the sweep deletes them. The browser calls
|
|
13
|
-
* `completeUpload()` right after its `PUT` finishes, so a day leaves any upload in flight alone;
|
|
14
|
-
* it matches the lifecycle rule for multipart uploads.
|
|
15
|
-
*/
|
|
16
|
-
const ABANDONED_UPLOAD_GRACE_MS = 864e5;
|
|
17
|
-
/** ablage's S3 layout: `<group>/data/<id>` holds the bytes, `<group>/meta/<id>` the metadata. */
|
|
18
|
-
function metaKeyOf(key) {
|
|
19
|
-
const parts = key.split("/");
|
|
20
|
-
if (parts.length < 3 || parts.at(-2) !== "data" || !parts.at(-1)) return void 0;
|
|
21
|
-
return [
|
|
22
|
-
...parts.slice(0, -2),
|
|
23
|
-
"meta",
|
|
24
|
-
parts.at(-1)
|
|
25
|
-
].join("/");
|
|
26
|
-
}
|
|
27
|
-
const unescapeXml = (text) => text.replace(/&(amp|lt|gt|quot|apos|#x?[0-9a-f]+);/gi, (_, entity) => {
|
|
28
|
-
const named = {
|
|
29
|
-
amp: "&",
|
|
30
|
-
lt: "<",
|
|
31
|
-
gt: ">",
|
|
32
|
-
quot: "\"",
|
|
33
|
-
apos: "'"
|
|
34
|
-
};
|
|
35
|
-
if (entity in named) return named[entity];
|
|
36
|
-
const hex = entity[1] === "x" || entity[1] === "X";
|
|
37
|
-
return String.fromCodePoint(parseInt(entity.slice(hex ? 2 : 1), hex ? 16 : 10));
|
|
38
|
-
});
|
|
39
|
-
/**
|
|
40
|
-
* Objects of a ListObjectsV2 page, its folders (listed with a delimiter), and the token for the
|
|
41
|
-
* next page.
|
|
42
|
-
*/
|
|
43
|
-
function parseObjectList(xml) {
|
|
44
|
-
const objects = [...xml.matchAll(/<Contents>([\s\S]*?)<\/Contents>/g)].map(([, entry]) => ({
|
|
45
|
-
key: unescapeXml(/<Key>([^<]*)<\/Key>/.exec(entry)?.[1] ?? ""),
|
|
46
|
-
lastModified: Date.parse(/<LastModified>([^<]*)<\/LastModified>/.exec(entry)?.[1] ?? ""),
|
|
47
|
-
size: Number(/<Size>([^<]*)<\/Size>/.exec(entry)?.[1] ?? 0)
|
|
48
|
-
}));
|
|
49
|
-
const prefixes = [...xml.matchAll(/<CommonPrefixes>([\s\S]*?)<\/CommonPrefixes>/g)].map(([, entry]) => unescapeXml(/<Prefix>([^<]*)<\/Prefix>/.exec(entry)?.[1] ?? ""));
|
|
50
|
-
const next = /<IsTruncated>true<\/IsTruncated>/.test(xml) ? /<NextContinuationToken>([^<]*)<\/NextContinuationToken>/.exec(xml)?.[1] : void 0;
|
|
51
|
-
return {
|
|
52
|
-
objects,
|
|
53
|
-
prefixes,
|
|
54
|
-
next: next === void 0 ? void 0 : unescapeXml(next)
|
|
55
|
-
};
|
|
56
|
-
}
|
|
57
|
-
/**
|
|
58
|
-
* ablage keeps a file as bytes and metadata, which only make sense together: the metadata key
|
|
59
|
-
* for bytes, the bytes for metadata, undefined for anything else.
|
|
60
|
-
*/
|
|
61
|
-
function companionKeyOf(key) {
|
|
62
|
-
const meta = metaKeyOf(key);
|
|
63
|
-
if (meta !== void 0) return meta;
|
|
64
|
-
const parts = key.split("/");
|
|
65
|
-
if (parts.length < 3 || parts.at(-2) !== "meta" || !parts.at(-1)) return void 0;
|
|
66
|
-
return [
|
|
67
|
-
...parts.slice(0, -2),
|
|
68
|
-
"data",
|
|
69
|
-
parts.at(-1)
|
|
70
|
-
].join("/");
|
|
71
|
-
}
|
|
72
|
-
/** S3's limit for object keys, in bytes. */
|
|
73
|
-
const MAX_KEY_BYTES = 1024;
|
|
74
|
-
function createStorage(options) {
|
|
75
|
-
async function admin(path, body) {
|
|
76
|
-
const res = await fetch(`${options.adminUrl}/v2/${path}`, {
|
|
77
|
-
method: body === void 0 ? "GET" : "POST",
|
|
78
|
-
headers: {
|
|
79
|
-
authorization: `Bearer ${options.adminToken}`,
|
|
80
|
-
"content-type": "application/json"
|
|
81
|
-
},
|
|
82
|
-
body: body === void 0 ? void 0 : JSON.stringify(body)
|
|
83
|
-
});
|
|
84
|
-
if (!res.ok) throw new StorageError(`garage ${path}: ${res.status} ${await res.text()}`);
|
|
85
|
-
return await res.json();
|
|
86
|
-
}
|
|
87
|
-
async function exists(path) {
|
|
88
|
-
const res = await fetch(`${options.adminUrl}/v2/${path}`, { headers: { authorization: `Bearer ${options.adminToken}` } });
|
|
89
|
-
if (res.status === 404 || res.status === 400) return void 0;
|
|
90
|
-
if (!res.ok) throw new StorageError(`garage ${path}: ${res.status} ${await res.text()}`);
|
|
91
|
-
return await res.json();
|
|
92
|
-
}
|
|
93
|
-
/**
|
|
94
|
-
* A fresh single-node Garage has no layout and stores nothing until it gets one. Gives this
|
|
95
|
-
* node all of its disk, once; later starts find the layout in place.
|
|
96
|
-
*/
|
|
97
|
-
async function ensureLayout() {
|
|
98
|
-
const node = (await admin("GetClusterStatus")).nodes[0];
|
|
99
|
-
if (!node || node.role) return;
|
|
100
|
-
await admin("UpdateClusterLayout", { roles: [{
|
|
101
|
-
id: node.id,
|
|
102
|
-
zone: "wervt",
|
|
103
|
-
capacity: node.dataPartition?.available ?? 0xe8d4a51000,
|
|
104
|
-
tags: []
|
|
105
|
-
}] });
|
|
106
|
-
await admin("ApplyClusterLayout", { version: (await admin("GetClusterLayout")).version + 1 });
|
|
107
|
-
}
|
|
108
|
-
const s3 = async (app) => {
|
|
109
|
-
const creds = await storageCredentials(app, options.secret);
|
|
110
|
-
return {
|
|
111
|
-
creds,
|
|
112
|
-
client: new AwsClient({
|
|
113
|
-
accessKeyId: creds.accessKeyId,
|
|
114
|
-
secretAccessKey: creds.secretAccessKey,
|
|
115
|
-
region: "garage",
|
|
116
|
-
service: "s3"
|
|
117
|
-
}),
|
|
118
|
-
base: `${options.s3Url}/${creds.bucket}`
|
|
119
|
-
};
|
|
120
|
-
};
|
|
121
|
-
/**
|
|
122
|
-
* Creates the app's bucket and key if they're missing, lets the app's origins upload and
|
|
123
|
-
* download from browsers (CORS), and cleans up abandoned uploads. Safe to run on every deploy.
|
|
124
|
-
* With `quota`, sets the bucket's size limit too; without, leaves it as it is.
|
|
125
|
-
*/
|
|
126
|
-
async function provision(app, appOrigins, quota) {
|
|
127
|
-
const { creds, client, base } = await s3(app);
|
|
128
|
-
const bucket = await exists(`GetBucketInfo?globalAlias=${creds.bucket}`) ?? await admin("CreateBucket", { globalAlias: creds.bucket });
|
|
129
|
-
if (!await exists(`GetKeyInfo?id=${creds.accessKeyId}`)) await admin("ImportKey", {
|
|
130
|
-
accessKeyId: creds.accessKeyId,
|
|
131
|
-
secretAccessKey: creds.secretAccessKey,
|
|
132
|
-
name: creds.bucket
|
|
133
|
-
});
|
|
134
|
-
await admin("AllowBucketKey", {
|
|
135
|
-
bucketId: bucket.id,
|
|
136
|
-
accessKeyId: creds.accessKeyId,
|
|
137
|
-
permissions: {
|
|
138
|
-
read: true,
|
|
139
|
-
write: true,
|
|
140
|
-
owner: true
|
|
141
|
-
}
|
|
142
|
-
});
|
|
143
|
-
if (quota) await applyQuota(bucket.id, quota);
|
|
144
|
-
const cors = `<CORSConfiguration><CORSRule>
|
|
145
|
-
${[appOrigins].flat().map((origin) => `<AllowedOrigin>${origin}</AllowedOrigin>\n`).join("")}<AllowedMethod>GET</AllowedMethod><AllowedMethod>HEAD</AllowedMethod>
|
|
146
|
-
<AllowedMethod>PUT</AllowedMethod><AllowedMethod>POST</AllowedMethod>
|
|
147
|
-
<AllowedHeader>*</AllowedHeader><ExposeHeader>ETag</ExposeHeader>
|
|
148
|
-
<MaxAgeSeconds>3600</MaxAgeSeconds>
|
|
149
|
-
</CORSRule></CORSConfiguration>`;
|
|
150
|
-
const res = await client.fetch(`${base}?cors`, {
|
|
151
|
-
method: "PUT",
|
|
152
|
-
body: cors
|
|
153
|
-
});
|
|
154
|
-
if (!res.ok) throw new StorageError(`setting CORS for ${creds.bucket}: ${await res.text()}`);
|
|
155
|
-
const rules = await client.fetch(`${base}?lifecycle`, {
|
|
156
|
-
method: "PUT",
|
|
157
|
-
body: `<LifecycleConfiguration><Rule>
|
|
158
|
-
<ID>abort-incomplete-uploads</ID><Status>Enabled</Status><Filter></Filter>
|
|
159
|
-
<AbortIncompleteMultipartUpload><DaysAfterInitiation>1</DaysAfterInitiation></AbortIncompleteMultipartUpload>
|
|
160
|
-
</Rule></LifecycleConfiguration>`
|
|
161
|
-
});
|
|
162
|
-
if (!rules.ok) throw new StorageError(`setting the lifecycle for ${creds.bucket}: ${await rules.text()}`);
|
|
163
|
-
return { bucket: creds.bucket };
|
|
164
|
-
}
|
|
165
|
-
function applyQuota(bucketId, quota) {
|
|
166
|
-
return admin(`UpdateBucket?id=${bucketId}`, { quotas: {
|
|
167
|
-
maxSize: quota.maxBytes,
|
|
168
|
-
maxObjects: null
|
|
169
|
-
} });
|
|
170
|
-
}
|
|
171
|
-
/** Changes the size limit of an existing bucket. False when the app has no bucket yet. */
|
|
172
|
-
async function setQuota(app, quota) {
|
|
173
|
-
const { creds } = await s3(app);
|
|
174
|
-
const bucket = await exists(`GetBucketInfo?globalAlias=${creds.bucket}`);
|
|
175
|
-
if (!bucket) return false;
|
|
176
|
-
await applyQuota(bucket.id, quota);
|
|
177
|
-
return true;
|
|
178
|
-
}
|
|
179
|
-
async function usage(app) {
|
|
180
|
-
const { creds } = await s3(app);
|
|
181
|
-
const info = await exists(`GetBucketInfo?globalAlias=${creds.bucket}`);
|
|
182
|
-
if (!info) return void 0;
|
|
183
|
-
return {
|
|
184
|
-
bucket: creds.bucket,
|
|
185
|
-
objects: info.objects,
|
|
186
|
-
bytes: info.bytes,
|
|
187
|
-
unfinishedUploads: info.unfinishedMultipartUploads,
|
|
188
|
-
maxBytes: info.quotas?.maxSize ?? null
|
|
189
|
-
};
|
|
190
|
-
}
|
|
191
|
-
/**
|
|
192
|
-
* Deletes the bytes of direct uploads that were never completed: marked by ablage, older than
|
|
193
|
-
* `graceMs` and without a metadata object. ablage never lists them, but they count against the
|
|
194
|
-
* bucket quota. Safe to run any time and as often as wanted.
|
|
195
|
-
*/
|
|
196
|
-
async function sweepAbandonedUploads(app, { graceMs = ABANDONED_UPLOAD_GRACE_MS, now = Date.now() } = {}) {
|
|
197
|
-
const { creds, client, base } = await s3(app);
|
|
198
|
-
if (!await exists(`GetBucketInfo?globalAlias=${creds.bucket}`)) return { deleted: [] };
|
|
199
|
-
const url = (key) => `${base}/${key.split("/").map(encodeURIComponent).join("/")}`;
|
|
200
|
-
const candidates = /* @__PURE__ */ new Map();
|
|
201
|
-
let token;
|
|
202
|
-
do {
|
|
203
|
-
const query = `list-type=2&max-keys=1000${token ? `&continuation-token=${encodeURIComponent(token)}` : ""}`;
|
|
204
|
-
const res = await client.fetch(`${base}?${query}`);
|
|
205
|
-
if (!res.ok) throw new StorageError(`listing ${creds.bucket}: ${await res.text()}`);
|
|
206
|
-
const page = parseObjectList(await res.text());
|
|
207
|
-
for (const object of page.objects) {
|
|
208
|
-
candidates.delete(object.key);
|
|
209
|
-
const metaKey = metaKeyOf(object.key);
|
|
210
|
-
if (metaKey !== void 0 && now - object.lastModified >= graceMs) candidates.set(metaKey, object.key);
|
|
211
|
-
}
|
|
212
|
-
token = page.next;
|
|
213
|
-
} while (token);
|
|
214
|
-
const deleted = [];
|
|
215
|
-
for (const [metaKey, key] of candidates) {
|
|
216
|
-
const head = await client.fetch(url(key), { method: "HEAD" });
|
|
217
|
-
if (head.status === 404) continue;
|
|
218
|
-
if (!head.ok) throw new StorageError(`checking ${key} in ${creds.bucket}: ${head.status}`);
|
|
219
|
-
if (!head.headers.get(DIRECT_UPLOAD_MARKER)) continue;
|
|
220
|
-
const meta = await client.fetch(url(metaKey), { method: "HEAD" });
|
|
221
|
-
if (meta.ok) continue;
|
|
222
|
-
if (meta.status !== 404) throw new StorageError(`checking ${metaKey} in ${creds.bucket}: ${meta.status}`);
|
|
223
|
-
const res = await client.fetch(url(key), { method: "DELETE" });
|
|
224
|
-
if (!res.ok && res.status !== 404) throw new StorageError(`deleting ${key} in ${creds.bucket}: ${await res.text()}`);
|
|
225
|
-
deleted.push(key);
|
|
226
|
-
}
|
|
227
|
-
return { deleted };
|
|
228
|
-
}
|
|
229
|
-
/** Whether the app's bucket exists, e.g. left over from a deleted app that kept its data. */
|
|
230
|
-
async function hasBucket(app) {
|
|
231
|
-
const creds = await storageCredentials(app, options.secret);
|
|
232
|
-
return Boolean(await exists(`GetBucketInfo?globalAlias=${creds.bucket}`));
|
|
233
|
-
}
|
|
234
|
-
/** Deletes every object and unfinished upload, then the bucket. Only with "drop data". */
|
|
235
|
-
async function drop(app) {
|
|
236
|
-
const { creds, client, base } = await s3(app);
|
|
237
|
-
const bucket = await exists(`GetBucketInfo?globalAlias=${creds.bucket}`);
|
|
238
|
-
if (bucket) {
|
|
239
|
-
for (;;) {
|
|
240
|
-
const keys = [...(await (await client.fetch(`${base}?list-type=2&max-keys=1000`)).text()).matchAll(/<Key>([^<]*)<\/Key>/g)].map((m) => m[1]);
|
|
241
|
-
if (!keys.length) break;
|
|
242
|
-
const body = `<Delete><Quiet>true</Quiet>${keys.map((key) => `<Object><Key>${key}</Key></Object>`).join("")}</Delete>`;
|
|
243
|
-
const res = await client.fetch(`${base}?delete`, {
|
|
244
|
-
method: "POST",
|
|
245
|
-
body
|
|
246
|
-
});
|
|
247
|
-
if (!res.ok) throw new StorageError(`emptying ${creds.bucket}: ${await res.text()}`);
|
|
248
|
-
}
|
|
249
|
-
for (;;) {
|
|
250
|
-
const uploads = [...(await (await client.fetch(`${base}?uploads&max-uploads=1000`)).text()).matchAll(/<Key>([^<]*)<\/Key>\s*<UploadId>([^<]*)<\/UploadId>/g)].map((m) => ({
|
|
251
|
-
key: m[1],
|
|
252
|
-
id: m[2]
|
|
253
|
-
}));
|
|
254
|
-
if (!uploads.length) break;
|
|
255
|
-
for (const upload of uploads) {
|
|
256
|
-
const url = `${base}/${upload.key.replaceAll("&", "&").split("/").map(encodeURIComponent).join("/")}?uploadId=${encodeURIComponent(upload.id)}`;
|
|
257
|
-
const res = await client.fetch(url, { method: "DELETE" });
|
|
258
|
-
if (!res.ok && res.status !== 404) throw new StorageError(`aborting an upload in ${creds.bucket}: ${await res.text()}`);
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
await admin(`DeleteBucket?id=${String(bucket.id)}`, {});
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
const objectUrl = (base, key) => `${base}/${key.split("/").map(encodeURIComponent).join("/")}`;
|
|
265
|
-
/**
|
|
266
|
-
* One page of the bucket, one folder level at a time like a file browser: the files directly
|
|
267
|
-
* in `prefix` and the folders below it. Undefined when the app has no bucket.
|
|
268
|
-
*/
|
|
269
|
-
async function list(app, { prefix = "", token, limit = 200 } = {}) {
|
|
270
|
-
const { creds, client, base } = await s3(app);
|
|
271
|
-
const query = new URLSearchParams({
|
|
272
|
-
"list-type": "2",
|
|
273
|
-
delimiter: "/",
|
|
274
|
-
prefix,
|
|
275
|
-
"max-keys": String(Math.min(Math.max(limit, 1), 1e3))
|
|
276
|
-
});
|
|
277
|
-
if (token) query.set("continuation-token", token);
|
|
278
|
-
const res = await client.fetch(`${base}?${query.toString()}`);
|
|
279
|
-
if (res.status === 404) return void 0;
|
|
280
|
-
if (!res.ok) throw new StorageError(`listing ${creds.bucket}: ${await res.text()}`);
|
|
281
|
-
const page = parseObjectList(await res.text());
|
|
282
|
-
return {
|
|
283
|
-
prefix,
|
|
284
|
-
folders: page.prefixes,
|
|
285
|
-
files: page.objects.map((object) => ({
|
|
286
|
-
key: object.key,
|
|
287
|
-
size: object.size,
|
|
288
|
-
lastModified: new Date(object.lastModified).toISOString()
|
|
289
|
-
})),
|
|
290
|
-
next: page.next
|
|
291
|
-
};
|
|
292
|
-
}
|
|
293
|
-
/** Size, type and metadata of one object; undefined when it doesn't exist. */
|
|
294
|
-
async function describe(app, key) {
|
|
295
|
-
const { creds, client, base } = await s3(app);
|
|
296
|
-
const res = await client.fetch(objectUrl(base, key), { method: "HEAD" });
|
|
297
|
-
if (res.status === 404) return void 0;
|
|
298
|
-
if (!res.ok) throw new StorageError(`reading ${key} in ${creds.bucket}: ${res.status}`);
|
|
299
|
-
const metadata = {};
|
|
300
|
-
for (const [name, value] of res.headers) if (name.startsWith("x-amz-meta-")) metadata[name.slice(11)] = value;
|
|
301
|
-
const modified = res.headers.get("last-modified");
|
|
302
|
-
return {
|
|
303
|
-
key,
|
|
304
|
-
size: Number(res.headers.get("content-length") ?? 0),
|
|
305
|
-
lastModified: modified ? new Date(modified).toISOString() : "",
|
|
306
|
-
contentType: res.headers.get("content-type"),
|
|
307
|
-
metadata
|
|
308
|
-
};
|
|
309
|
-
}
|
|
310
|
-
/**
|
|
311
|
-
* A presigned download link on `publicUrl`, the S3 API as browsers reach it, so files go from
|
|
312
|
-
* Garage straight to the browser. Always an attachment, never shown inline: the file is the
|
|
313
|
-
* app's users' content and may well be HTML.
|
|
314
|
-
*/
|
|
315
|
-
async function downloadUrl(app, key, { publicUrl, expiresIn = 300 }) {
|
|
316
|
-
const { creds, client } = await s3(app);
|
|
317
|
-
const url = new URL(objectUrl(`${publicUrl.replace(/\/$/, "")}/${creds.bucket}`, key));
|
|
318
|
-
const name = key.split("/").at(-1) || "download";
|
|
319
|
-
const ascii = name.replace(/[^\x20-\x7e]|["\\]/g, "_");
|
|
320
|
-
url.searchParams.set("response-content-disposition", `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(name)}`);
|
|
321
|
-
url.searchParams.set("X-Amz-Expires", String(expiresIn));
|
|
322
|
-
return (await client.sign(url.toString(), {
|
|
323
|
-
method: "GET",
|
|
324
|
-
aws: { signQuery: true }
|
|
325
|
-
})).url;
|
|
326
|
-
}
|
|
327
|
-
/**
|
|
328
|
-
* Deletes one object, and its ablage companion (see companionKeyOf) so ablage never lists a
|
|
329
|
-
* file whose bytes are gone. Undefined when the object doesn't exist.
|
|
330
|
-
*/
|
|
331
|
-
async function remove(app, key) {
|
|
332
|
-
const { creds, client, base } = await s3(app);
|
|
333
|
-
const present = async (k) => {
|
|
334
|
-
const res = await client.fetch(objectUrl(base, k), { method: "HEAD" });
|
|
335
|
-
if (res.status === 404) return false;
|
|
336
|
-
if (!res.ok) throw new StorageError(`checking ${k} in ${creds.bucket}: ${res.status}`);
|
|
337
|
-
return true;
|
|
338
|
-
};
|
|
339
|
-
if (!await present(key)) return void 0;
|
|
340
|
-
const companion = companionKeyOf(key);
|
|
341
|
-
const keys = companion !== void 0 && await present(companion) ? [key, companion] : [key];
|
|
342
|
-
for (const k of keys) {
|
|
343
|
-
const res = await client.fetch(objectUrl(base, k), { method: "DELETE" });
|
|
344
|
-
if (!res.ok && res.status !== 404) throw new StorageError(`deleting ${k} in ${creds.bucket}: ${await res.text()}`);
|
|
345
|
-
}
|
|
346
|
-
return { deleted: keys };
|
|
347
|
-
}
|
|
348
|
-
return {
|
|
349
|
-
ensureLayout,
|
|
350
|
-
provision,
|
|
351
|
-
setQuota,
|
|
352
|
-
usage,
|
|
353
|
-
list,
|
|
354
|
-
describe,
|
|
355
|
-
downloadUrl,
|
|
356
|
-
remove,
|
|
357
|
-
sweepAbandonedUploads,
|
|
358
|
-
hasBucket,
|
|
359
|
-
drop
|
|
360
|
-
};
|
|
361
|
-
}
|
|
362
|
-
export { ABANDONED_UPLOAD_GRACE_MS, MAX_KEY_BYTES, StorageError, companionKeyOf, createStorage, metaKeyOf, parseObjectList };
|