@podosoft/podokit 1.3.0 → 1.4.0
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/add.d.ts +10 -1
- package/dist/add.js +20 -7
- package/dist/api-paths.d.ts +34 -0
- package/dist/api-paths.js +103 -0
- package/dist/deploy-compose-profile.d.ts +4 -0
- package/dist/deploy-compose-profile.js +13 -2
- package/dist/deploy-compose-render.d.ts +1 -1
- package/dist/deploy-compose-render.js +23 -7
- package/dist/deploy-compose.js +2 -1
- package/dist/deploy-profile.d.ts +3 -0
- package/dist/deploy-profile.js +12 -4
- package/dist/deploy-render.js +54 -8
- package/dist/deploy.js +21 -1
- package/dist/dev.d.ts +14 -0
- package/dist/dev.js +152 -10
- package/dist/index.js +15 -2
- package/dist/lib.d.ts +2 -0
- package/dist/lib.js +4 -2
- package/dist/lockfile.d.ts +3 -0
- package/dist/lockfile.js +9 -0
- package/dist/module-api-paths.d.ts +52 -0
- package/dist/module-api-paths.js +189 -0
- package/dist/remove.d.ts +3 -0
- package/dist/remove.js +12 -3
- package/dist/templates/fullstack/AGENTS.md +17 -2
- package/dist/templates/fullstack/README.md +19 -5
- package/dist/templates/fullstack/apps/api/package.json +1 -1
- package/dist/templates/fullstack/apps/api/src/app.extensions.ts +7 -0
- package/dist/templates/fullstack/apps/api/src/app.spec.ts +20 -1
- package/dist/templates/fullstack/apps/api/src/app.ts +11 -0
- package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +3 -0
- package/dist/templates/fullstack/apps/api/src/config/redis.connection.spec.ts +48 -2
- package/dist/templates/fullstack/apps/api/src/config/redis.connection.ts +45 -0
- package/dist/templates/fullstack/apps/api/src/config/server-options.spec.ts +46 -0
- package/dist/templates/fullstack/apps/api/src/config/server-options.ts +48 -0
- package/dist/templates/fullstack/apps/api/src/core/api-contract.spec.ts +1 -0
- package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +9 -1
- package/dist/templates/fullstack/apps/api/src/core/services.spec.ts +196 -0
- package/dist/templates/fullstack/apps/api/src/core/services.ts +181 -2
- package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +8 -1
- package/dist/templates/fullstack/apps/api/src/database/database.ts +29 -10
- package/dist/templates/fullstack/apps/api/src/database/pool-guard.spec.ts +188 -0
- package/dist/templates/fullstack/apps/api/src/database/pool-guard.ts +151 -0
- package/dist/templates/fullstack/apps/api/src/health/health.plugin.spec.ts +28 -4
- package/dist/templates/fullstack/apps/api/src/health/health.plugin.ts +10 -2
- package/dist/templates/fullstack/apps/api/src/main.ts +2 -1
- package/dist/templates/fullstack/apps/web/components.json +3 -1
- package/dist/templates/fullstack/apps/web/package.json +1 -1
- package/dist/templates/fullstack/apps/web/src/lib/components/ui/textarea/index.ts +7 -0
- package/dist/templates/fullstack/apps/web/src/lib/components/ui/textarea/textarea.svelte +22 -0
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +276 -1
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +194 -9
- package/dist/templates/fullstack/apps/web/src/lib/server/body-limits.ts +15 -0
- package/dist/templates/fullstack/apps/web/src/lib/server/proxy-headers.ts +12 -0
- package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +10 -3
- package/dist/templates/fullstack/dot-claude/skills/podokit-elysia-endpoint/SKILL.md +5 -0
- package/dist/templates/fullstack/dot-claude/skills/podokit-sveltekit-route/SKILL.md +7 -1
- package/dist/templates/fullstack/dot-devcontainer/devcontainer.json +1 -0
- package/dist/templates/fullstack/{dot-env.docker → dot-env.docker.example} +7 -4
- package/dist/templates/fullstack/dot-env.example +10 -0
- package/dist/templates/fullstack/dot-gitignore +1 -0
- package/dist/templates/fullstack/dot-podokit/dev.json +2 -1
- package/dist/templates/fullstack/infra/k3s/README.md +3 -1
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/bootstrap-admin-runtime.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +27 -9
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/asset-paths.ts +11 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.test.ts +125 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.ts +107 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +2 -1
- package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +2 -1
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +10 -10
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/object-response.ts +12 -0
- package/dist/templates/modules/admin-dashboard/module.manifest.json +3 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.spec.ts +26 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +17 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +8 -4
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +2 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/database-outage.spec.ts +114 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/database-outage.ts +50 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/db.spec.ts +4 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +11 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/maintenance-command.ts +5 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/maintenance.ts +14 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/postgres-pool.spec.ts +136 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/postgres-pool.ts +40 -0
- package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +3 -2
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.spec.ts +121 -1
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +108 -10
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/jobs.module.ts +3 -1
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/queue.ts +31 -3
- package/dist/templates/modules/bullmq/module.manifest.json +5 -1
- package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/events-redis.module.ts +2 -4
- package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/redis-event.bus.spec.ts +224 -0
- package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/redis-event.bus.ts +124 -35
- package/dist/templates/modules/events-redis/module.manifest.json +4 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.routes.ts +136 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.spec.ts +636 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.ts +673 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.spec.ts +10 -4
- package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.ts +37 -8
- package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-config.ts +99 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/migrations/1792100000000-InitFileUploadSessions.ts +49 -0
- package/dist/templates/modules/file-upload/files/apps/web/src/lib/uploads.test.ts +456 -0
- package/dist/templates/modules/file-upload/files/apps/web/src/lib/uploads.ts +356 -0
- package/dist/templates/modules/file-upload/files/tests/api/chunked-uploads.api.spec.ts +147 -0
- package/dist/templates/modules/file-upload/module.manifest.json +34 -6
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/jobs-local.module.ts +1 -1
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts +23 -0
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +30 -20
- package/dist/templates/modules/jobs-local/module.manifest.json +3 -0
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.ts +20 -0
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.spec.ts +79 -12
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.ts +16 -1
- package/dist/templates/modules/object-storage-local/module.manifest.json +11 -2
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-multipart.spec.ts +115 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-multipart.ts +171 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.spec.ts +222 -9
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.ts +55 -19
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-signer.spec.ts +71 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-signer.ts +98 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.spec.ts +6 -3
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.config.ts +7 -2
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.ts +187 -44
- package/dist/templates/modules/object-storage-s3/module.manifest.json +10 -2
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +11 -22
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.spec.ts +113 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.ts +122 -0
- package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +24 -0
- package/dist/templates/modules/rate-limit/module.manifest.json +7 -0
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.spec.ts +14 -1
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.ts +9 -12
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis.module.ts +16 -4
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.spec.ts +233 -5
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.ts +134 -32
- package/dist/templates/modules/redis/module.manifest.json +4 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/event-stream.ts +11 -4
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +33 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +7 -0
- package/dist/templates/todo/AGENTS.md +9 -1
- package/dist/templates/todo/README.md +18 -5
- package/dist/update.d.ts +5 -0
- package/dist/update.js +35 -0
- package/package.json +2 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/schema-validation.ts +0 -13
- package/dist/templates/modules/auth/files/apps/api/src/auth/skip-schema-validation.ts +0 -5
|
@@ -1,48 +1,83 @@
|
|
|
1
1
|
import { RedisClient } from "bun";
|
|
2
2
|
import type { EventBus, EventHandler, Unsubscribe } from "@podosoft/podokit-runtime";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
redisCommandTimeoutMs,
|
|
5
|
+
redisConnectionUrl,
|
|
6
|
+
withReplyTimeout,
|
|
7
|
+
} from "../config/redis.connection";
|
|
8
|
+
import type { SupervisedConnection } from "../core/services";
|
|
4
9
|
|
|
5
|
-
|
|
10
|
+
/**
|
|
11
|
+
* Publishing fails at once while disconnected instead of queueing, and the
|
|
12
|
+
* supervisor reconnects instead of Bun, which gives up after a fixed number of
|
|
13
|
+
* attempts and never restores subscriptions.
|
|
14
|
+
*/
|
|
15
|
+
function redisClientFactory(env: NodeJS.ProcessEnv): () => RedisClient {
|
|
16
|
+
const url = redisConnectionUrl(env);
|
|
17
|
+
return () => new RedisClient(url, {
|
|
18
|
+
connectionTimeout: 5_000,
|
|
19
|
+
autoReconnect: false,
|
|
20
|
+
enableOfflineQueue: false,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Every replica subscribes to one Redis channel and dispatches to its local
|
|
26
|
+
* handlers by topic. Handlers are local, so they survive reconnects; the
|
|
27
|
+
* channel subscription is opened again on each reconnect.
|
|
28
|
+
*/
|
|
29
|
+
export class RedisEventBus implements EventBus, SupervisedConnection {
|
|
6
30
|
private readonly publisher: RedisClient;
|
|
7
|
-
private readonly subscriber: RedisClient;
|
|
8
31
|
private readonly channel: string;
|
|
9
32
|
private readonly maxEventBytes: number;
|
|
33
|
+
private readonly commandTimeoutMs: number;
|
|
34
|
+
private readonly createClient: () => RedisClient;
|
|
10
35
|
private readonly handlers = new Map<string, Set<EventHandler>>();
|
|
11
|
-
private
|
|
36
|
+
private readonly interruptionListeners = new Set<() => void>();
|
|
37
|
+
// Set while a connection holds the channel subscription.
|
|
38
|
+
private subscriber?: RedisClient;
|
|
39
|
+
// Whether events may have been missed since the channel was last subscribed.
|
|
40
|
+
private missedEvents = false;
|
|
41
|
+
private lostListener?: (reason: unknown) => void;
|
|
42
|
+
private closed = false;
|
|
12
43
|
|
|
13
|
-
constructor(
|
|
14
|
-
|
|
15
|
-
|
|
44
|
+
constructor(
|
|
45
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
46
|
+
createClient: () => RedisClient = redisClientFactory(env),
|
|
47
|
+
) {
|
|
48
|
+
this.createClient = createClient;
|
|
49
|
+
this.publisher = createClient();
|
|
50
|
+
this.publisher.onclose = (error) => this.report(error);
|
|
16
51
|
this.channel = env.EVENTS_REDIS_CHANNEL?.trim() || "podokit:events";
|
|
17
52
|
this.maxEventBytes = Number(env.SSE_MAX_EVENT_BYTES ?? 65_536);
|
|
18
53
|
if (!Number.isSafeInteger(this.maxEventBytes) || this.maxEventBytes < 1) {
|
|
19
54
|
throw new Error("SSE_MAX_EVENT_BYTES must be a positive integer");
|
|
20
55
|
}
|
|
56
|
+
this.commandTimeoutMs = redisCommandTimeoutMs(env);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
onLost(listener: (reason: unknown) => void): void {
|
|
60
|
+
this.lostListener = listener;
|
|
21
61
|
}
|
|
22
62
|
|
|
63
|
+
/** Opens whichever connection is closed and subscribes the channel again. */
|
|
23
64
|
async connect(): Promise<void> {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
void Promise.resolve(handler(record.event)).catch(() => undefined);
|
|
35
|
-
}
|
|
36
|
-
} catch {
|
|
37
|
-
process.stderr.write("Discard malformed event transport message\n");
|
|
38
|
-
}
|
|
39
|
-
};
|
|
40
|
-
await this.subscriber.subscribe(this.channel, this.listener);
|
|
65
|
+
try {
|
|
66
|
+
if (!this.publisher.connected) await this.publisher.connect();
|
|
67
|
+
this.subscriber ??= await this.openSubscriber();
|
|
68
|
+
} catch (error) {
|
|
69
|
+
if (!this.subscriber) this.missedEvents = true;
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
if (!this.missedEvents) return;
|
|
73
|
+
this.missedEvents = false;
|
|
74
|
+
for (const listener of [...this.interruptionListeners]) listener();
|
|
41
75
|
}
|
|
42
76
|
|
|
77
|
+
/** Rejects until the channel is subscribed and while the publisher cannot reach Redis. */
|
|
43
78
|
async ready(): Promise<void> {
|
|
44
|
-
|
|
45
|
-
await this.publisher.ping();
|
|
79
|
+
if (!this.subscriber) throw new Error("Event transport is not subscribed");
|
|
80
|
+
await this.send(() => this.publisher.ping());
|
|
46
81
|
}
|
|
47
82
|
|
|
48
83
|
async publish(topic: string, event: unknown): Promise<void> {
|
|
@@ -51,25 +86,79 @@ export class RedisEventBus implements EventBus {
|
|
|
51
86
|
if (Buffer.byteLength(message, "utf8") > this.maxEventBytes) {
|
|
52
87
|
throw new Error(`Event exceeds maxEventBytes (${this.maxEventBytes})`);
|
|
53
88
|
}
|
|
54
|
-
await this.publisher.publish(this.channel, message);
|
|
89
|
+
await this.send(() => this.publisher.publish(this.channel, message));
|
|
55
90
|
}
|
|
56
91
|
|
|
57
|
-
|
|
58
|
-
await this.connect();
|
|
92
|
+
subscribe(topic: string, handler: EventHandler): Promise<Unsubscribe> {
|
|
59
93
|
const handlers = this.handlers.get(topic) ?? new Set<EventHandler>();
|
|
60
94
|
handlers.add(handler);
|
|
61
95
|
this.handlers.set(topic, handlers);
|
|
62
|
-
return () => {
|
|
96
|
+
return Promise.resolve(() => {
|
|
63
97
|
handlers.delete(handler);
|
|
64
|
-
if (handlers.size === 0) this.handlers.delete(topic);
|
|
98
|
+
if (handlers.size === 0 && this.handlers.get(topic) === handlers) this.handlers.delete(topic);
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
onInterruption(listener: () => void): () => void {
|
|
103
|
+
this.interruptionListeners.add(listener);
|
|
104
|
+
return () => {
|
|
105
|
+
this.interruptionListeners.delete(listener);
|
|
65
106
|
};
|
|
66
107
|
}
|
|
67
108
|
|
|
68
|
-
|
|
69
|
-
|
|
109
|
+
close(): void {
|
|
110
|
+
this.closed = true;
|
|
70
111
|
this.handlers.clear();
|
|
71
|
-
this.
|
|
112
|
+
this.interruptionListeners.clear();
|
|
113
|
+
this.subscriber?.close();
|
|
114
|
+
this.subscriber = undefined;
|
|
72
115
|
this.publisher.close();
|
|
73
|
-
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Fails a publisher command Redis does not answer in time and closes the connection for the supervisor to reopen. */
|
|
119
|
+
private async send<T>(command: () => Promise<T>): Promise<T> {
|
|
120
|
+
return withReplyTimeout(command(), this.commandTimeoutMs, () => this.publisher.close());
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private readonly receive = (message: string): void => {
|
|
124
|
+
if (Buffer.byteLength(message, "utf8") > this.maxEventBytes) return;
|
|
125
|
+
try {
|
|
126
|
+
const envelope = JSON.parse(message) as unknown;
|
|
127
|
+
if (!envelope || typeof envelope !== "object" || Array.isArray(envelope)) return;
|
|
128
|
+
const record = envelope as Record<string, unknown>;
|
|
129
|
+
if (typeof record.topic !== "string") return;
|
|
130
|
+
for (const handler of this.handlers.get(record.topic) ?? []) {
|
|
131
|
+
void Promise.resolve(handler(record.event)).catch(() => undefined);
|
|
132
|
+
}
|
|
133
|
+
} catch {
|
|
134
|
+
process.stderr.write("Discard malformed event transport message\n");
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
/** A fresh connection, because Bun keeps the listeners of a dropped one without the server subscription. */
|
|
139
|
+
private async openSubscriber(): Promise<RedisClient> {
|
|
140
|
+
const subscriber = this.createClient();
|
|
141
|
+
subscriber.onclose = (error) => {
|
|
142
|
+
if (this.subscriber !== subscriber) return;
|
|
143
|
+
this.subscriber = undefined;
|
|
144
|
+
this.missedEvents = true;
|
|
145
|
+
this.report(error);
|
|
146
|
+
};
|
|
147
|
+
try {
|
|
148
|
+
await subscriber.connect();
|
|
149
|
+
await withReplyTimeout(
|
|
150
|
+
subscriber.subscribe(this.channel, this.receive),
|
|
151
|
+
this.commandTimeoutMs,
|
|
152
|
+
() => subscriber.close(),
|
|
153
|
+
);
|
|
154
|
+
} catch (error) {
|
|
155
|
+
subscriber.close();
|
|
156
|
+
throw error;
|
|
157
|
+
}
|
|
158
|
+
return subscriber;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private report(reason: unknown): void {
|
|
162
|
+
if (!this.closed) this.lostListener?.(reason);
|
|
74
163
|
}
|
|
75
164
|
}
|
|
@@ -7,6 +7,10 @@
|
|
|
7
7
|
"apps/api/src/config/providers.ts",
|
|
8
8
|
"apps/api/src/config/redis.connection.ts"
|
|
9
9
|
],
|
|
10
|
+
"requiredProjectContents": {
|
|
11
|
+
"apps/api/src/core/services.ts": ["supervise(name: string, connection: SupervisedConnection): void {"],
|
|
12
|
+
"apps/api/src/config/redis.connection.ts": ["export async function withReplyTimeout<T>("]
|
|
13
|
+
},
|
|
10
14
|
"managedGlobs": [
|
|
11
15
|
"apps/api/src/events-provider-redis/**"
|
|
12
16
|
],
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { AppException } from "@podosoft/podokit-contracts";
|
|
2
|
+
import { Elysia, t } from "elysia";
|
|
3
|
+
import type { ElysiaPlugin, RequestIdentity } from "../core/services";
|
|
4
|
+
import type { ChunkedUploads } from "./chunked-uploads";
|
|
5
|
+
|
|
6
|
+
const JSON_TYPE = "application/json";
|
|
7
|
+
const BINARY_TYPE = "application/octet-stream";
|
|
8
|
+
const CONTENT_TYPE = "^[A-Za-z0-9!#$&^_.+-]+/[A-Za-z0-9!#$&^_.+-]+(\\s*;.*)?$";
|
|
9
|
+
|
|
10
|
+
const uploadId = t.Object({ id: t.String({ minLength: 1, maxLength: 64 }) });
|
|
11
|
+
|
|
12
|
+
/** The origins in CORS_ORIGIN, which a browser's Origin header must match. */
|
|
13
|
+
export function allowedOrigins(corsOrigin: string | undefined): string[] {
|
|
14
|
+
return (corsOrigin ?? "")
|
|
15
|
+
.split(",")
|
|
16
|
+
.map((origin) => origin.trim())
|
|
17
|
+
.filter((origin) => origin.length > 0)
|
|
18
|
+
.map((origin) => {
|
|
19
|
+
try {
|
|
20
|
+
return new URL(origin).origin;
|
|
21
|
+
} catch {
|
|
22
|
+
return origin;
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function mediaType(request: Request): string {
|
|
28
|
+
return (request.headers.get("content-type") ?? "").split(";")[0]?.trim().toLowerCase() ?? "";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Upload routes may be served on a direct API path, which skips the web
|
|
33
|
+
* server's cross-site form check. Requiring a JSON or binary content type makes
|
|
34
|
+
* every cross-origin browser request preflighted, which CORS refuses for other
|
|
35
|
+
* origins, and a browser Origin outside CORS_ORIGIN is rejected outright.
|
|
36
|
+
*/
|
|
37
|
+
function guard(origins: readonly string[], type?: string): (context: { request: Request }) => void {
|
|
38
|
+
return ({ request }) => {
|
|
39
|
+
const origin = request.headers.get("origin");
|
|
40
|
+
if (origins.length > 0 && origin !== null && !origins.includes(origin)) {
|
|
41
|
+
throw new AppException("ORIGIN_NOT_ALLOWED", "Requests from this origin cannot change uploads", 403);
|
|
42
|
+
}
|
|
43
|
+
if (type !== undefined && mediaType(request) !== type) {
|
|
44
|
+
throw new AppException("UNSUPPORTED_MEDIA_TYPE", `Send this request as ${type}`, 415);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function declaredLength(request: Request): number | null {
|
|
50
|
+
const value = request.headers.get("content-length");
|
|
51
|
+
if (value === null) return null;
|
|
52
|
+
return /^\d+$/.test(value.trim()) ? Number(value) : Number.NaN;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Resumable uploads: create a session, PUT its parts in any order (again to
|
|
57
|
+
* replace one), read the status to resume, then complete or delete it. Only the
|
|
58
|
+
* owner sees a session; others receive 404 so session ids cannot be probed.
|
|
59
|
+
*/
|
|
60
|
+
export function chunkedUploadRoutes(
|
|
61
|
+
uploads: ChunkedUploads,
|
|
62
|
+
identity: RequestIdentity,
|
|
63
|
+
origins: readonly string[],
|
|
64
|
+
): ElysiaPlugin {
|
|
65
|
+
return new Elysia({ name: "podokit.files.uploads" })
|
|
66
|
+
.post("/files/uploads", async ({ request, body, set }) => {
|
|
67
|
+
const created = await uploads.create(await identity.userId(request), body);
|
|
68
|
+
set.status = 201;
|
|
69
|
+
return created;
|
|
70
|
+
}, {
|
|
71
|
+
transform: guard(origins, JSON_TYPE),
|
|
72
|
+
body: t.Object({
|
|
73
|
+
filename: t.String({ minLength: 1, maxLength: 255 }),
|
|
74
|
+
size: t.Integer({ minimum: 1 }),
|
|
75
|
+
contentType: t.Optional(t.String({ maxLength: 255, pattern: CONTENT_TYPE })),
|
|
76
|
+
}),
|
|
77
|
+
detail: {
|
|
78
|
+
tags: ["files"],
|
|
79
|
+
summary: "Start a resumable upload",
|
|
80
|
+
description:
|
|
81
|
+
"Returns the session id, the part size, and the part count. Send every part with PUT /files/uploads/{id}/parts/{partNumber}, then complete the upload.",
|
|
82
|
+
},
|
|
83
|
+
})
|
|
84
|
+
.get("/files/uploads/:id", async ({ request, params }) =>
|
|
85
|
+
uploads.status(await identity.userId(request), params.id), {
|
|
86
|
+
params: uploadId,
|
|
87
|
+
detail: {
|
|
88
|
+
tags: ["files"],
|
|
89
|
+
summary: "Read a resumable upload",
|
|
90
|
+
description: "Lists the stored part numbers, so a client can resume by sending the others.",
|
|
91
|
+
},
|
|
92
|
+
})
|
|
93
|
+
.put("/files/uploads/:id/parts/:partNumber", async ({ request, params }) =>
|
|
94
|
+
uploads.putPart(
|
|
95
|
+
await identity.userId(request),
|
|
96
|
+
params.id,
|
|
97
|
+
params.partNumber,
|
|
98
|
+
declaredLength(request),
|
|
99
|
+
request.body,
|
|
100
|
+
), {
|
|
101
|
+
// The body is streamed to storage as it arrives, never parsed or buffered here.
|
|
102
|
+
parse: "none",
|
|
103
|
+
transform: guard(origins, BINARY_TYPE),
|
|
104
|
+
params: t.Object({ id: t.String({ minLength: 1, maxLength: 64 }), partNumber: t.Numeric() }),
|
|
105
|
+
detail: {
|
|
106
|
+
tags: ["files"],
|
|
107
|
+
summary: "Store one part of a resumable upload",
|
|
108
|
+
description:
|
|
109
|
+
"Send the raw bytes as application/octet-stream with a Content-Length equal to the part size (the last part holds the remainder). Sending a part again replaces it.",
|
|
110
|
+
requestBody: {
|
|
111
|
+
required: true,
|
|
112
|
+
content: { [BINARY_TYPE]: { schema: { type: "string", format: "binary" } } },
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
})
|
|
116
|
+
.post("/files/uploads/:id/complete", async ({ request, params }) =>
|
|
117
|
+
uploads.complete(await identity.userId(request), params.id), {
|
|
118
|
+
transform: guard(origins, JSON_TYPE),
|
|
119
|
+
params: uploadId,
|
|
120
|
+
body: t.Optional(t.Object({})),
|
|
121
|
+
detail: {
|
|
122
|
+
tags: ["files"],
|
|
123
|
+
summary: "Complete a resumable upload",
|
|
124
|
+
description:
|
|
125
|
+
"Joins the parts into the stored file once every part is present. Repeating the call returns the same result.",
|
|
126
|
+
},
|
|
127
|
+
})
|
|
128
|
+
.delete("/files/uploads/:id", async ({ request, params, set }) => {
|
|
129
|
+
await uploads.abort(await identity.userId(request), params.id);
|
|
130
|
+
set.status = 204;
|
|
131
|
+
}, {
|
|
132
|
+
transform: guard(origins),
|
|
133
|
+
params: uploadId,
|
|
134
|
+
detail: { tags: ["files"], summary: "Cancel a resumable upload and discard its parts" },
|
|
135
|
+
});
|
|
136
|
+
}
|