@podosoft/podokit 1.1.6 → 1.2.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/deploy-render.js +5 -0
- package/dist/templates/fullstack/apps/api/package.json +2 -2
- package/dist/templates/fullstack/apps/api/src/app.spec.ts +81 -0
- package/dist/templates/fullstack/apps/api/src/app.ts +53 -5
- package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +6 -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 +30 -0
- package/dist/templates/fullstack/apps/api/src/core/services.ts +19 -3
- package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +149 -4
- package/dist/templates/fullstack/apps/api/src/database/database.ts +66 -13
- package/dist/templates/fullstack/apps/api/src/health/health.plugin.spec.ts +86 -0
- package/dist/templates/fullstack/apps/api/src/health/health.plugin.ts +4 -9
- package/dist/templates/fullstack/apps/api/src/health/readiness.service.spec.ts +141 -1
- package/dist/templates/fullstack/apps/api/src/health/readiness.service.ts +86 -12
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +188 -1
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +30 -0
- package/dist/templates/fullstack/apps/web/src/routes/api/[...path]/+server.ts +3 -3
- package/dist/templates/fullstack/apps/web/src/routes/api/auth/[...all]/+server.ts +3 -3
- package/dist/templates/fullstack/compose.dev.yaml +21 -0
- package/dist/templates/fullstack/dot-env.docker +3 -0
- package/dist/templates/fullstack/dot-env.example +14 -0
- package/dist/templates/fullstack/infra/k3s/api-deployment.yaml +2 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.spec.ts +25 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.revoker.ts +26 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.routes.spec.ts +122 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.routes.ts +46 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.spec.ts +187 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.service.ts +160 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin.module.ts +17 -28
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.spec.ts +3 -5
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/profile-image/profile-image.service.ts +6 -17
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-assets/site-assets.routes.spec.ts +167 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-assets/site-assets.routes.ts +50 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.spec.ts +158 -0
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/site-settings/site-settings.service.ts +78 -28
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/app.d.ts +3 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.test.ts +124 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +19 -11
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/account-data.server.ts +6 -32
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/sessions.test.ts +74 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/sessions.ts +73 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.test.ts +75 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/users.ts +71 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/audit-log.test.ts +68 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/audit-log.ts +108 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +14 -8
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/session-idle-timeout.svelte +74 -49
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +6 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +6 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.test.ts +29 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/messages.ts +23 -6
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/oauth-query.test.ts +21 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/oauth-query.ts +15 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/capabilities.test.ts +98 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/capabilities.ts +54 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/protected-layout.ts +7 -50
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/audit/+page.svelte +88 -45
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/sessions/+page.svelte +76 -75
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.server.ts +15 -14
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.svelte +2 -2
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/users/+page.svelte +86 -38
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.server.ts +10 -17
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/+layout.ts +10 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/oauth2/consent/+page.svelte +4 -3
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/setup-2fa/+page.svelte +14 -8
- package/dist/templates/modules/admin-dashboard/files/dot-claude/skills/podokit-data-table/SKILL.md +2 -1
- package/dist/templates/modules/admin-dashboard/files/tests/api/admin-sessions.api.spec.ts +124 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +75 -12
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +34 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +7 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +72 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +36 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/audit.ui.spec.ts +41 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/data-table.ui.spec.ts +32 -13
- package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +13 -4
- package/dist/templates/modules/admin-dashboard/files/tests/ui/request-budget.ui.spec.ts +46 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +64 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +24 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/users-crud.ui.spec.ts +8 -1
- package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +20 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/README.md +46 -5
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-events.ts +19 -7
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit-hook.ts +2 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.migration.spec.ts +81 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.module.ts +13 -11
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.routes.spec.ts +115 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.routes.ts +44 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +421 -43
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +382 -35
- package/dist/templates/modules/audit-log/files/apps/api/src/migrations/1792000000000-IndexAuditLogFilters.ts +35 -0
- package/dist/templates/modules/audit-log/module.manifest.json +7 -4
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +20 -5
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +24 -29
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +28 -13
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.openapi.spec.ts +56 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.openapi.ts +22 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.spec.ts +81 -5
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +26 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +15 -9
- package/dist/templates/modules/auth/files/apps/api/src/auth/config-events.spec.ts +38 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/config-events.ts +43 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/db.spec.ts +24 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +31 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.spec.ts +58 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/feature-gate.ts +46 -57
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +7 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/config-param.spec.ts +23 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/config-param.ts +10 -0
- package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +10 -0
- package/dist/templates/modules/auth/files/apps/api/src/settings/settings.service.spec.ts +81 -0
- package/dist/templates/modules/auth/files/apps/api/src/settings/settings.service.ts +37 -34
- package/dist/templates/modules/auth/module.manifest.json +2 -2
- package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.spec.ts +136 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.ts +34 -13
- package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-keys.spec.ts +60 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-keys.ts +51 -0
- package/dist/templates/modules/file-upload/files/tests/api/files.api.spec.ts +70 -9
- package/dist/templates/modules/file-upload/module.manifest.json +9 -3
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/job-progress.module.ts +26 -32
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.spec.ts +16 -1
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.processor.ts +31 -4
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.spec.ts +145 -0
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.ts +37 -0
- package/dist/templates/modules/job-progress/files/tests/api/progress.api.spec.ts +94 -4
- package/dist/templates/modules/job-progress/module.manifest.json +3 -3
- package/dist/templates/modules/mailer/files/apps/api/src/mail/mailer.spec.ts +54 -0
- package/dist/templates/modules/mailer/files/apps/api/src/mail/mailer.ts +47 -25
- package/dist/templates/modules/mailer/module.manifest.json +1 -1
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.spec.ts +86 -0
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.ts +159 -0
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-demo.ts +38 -0
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.spec.ts +172 -4
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.ts +110 -33
- package/dist/templates/modules/object-storage-local/files/tests/api/storage-local.api.spec.ts +88 -0
- package/dist/templates/modules/object-storage-local/module.manifest.json +6 -1
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.spec.ts +80 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.ts +13 -7
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.spec.ts +156 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.ts +38 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.module.ts +39 -21
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.ts +101 -2
- package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +84 -9
- package/dist/templates/modules/object-storage-s3/module.manifest.json +8 -3
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.spec.ts +9 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.config.ts +7 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.spec.ts +139 -21
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.identity.ts +80 -11
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.spec.ts +12 -6
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +15 -2
- package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +46 -0
- package/dist/templates/modules/rate-limit/module.manifest.json +4 -3
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.spec.ts +125 -0
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.ts +27 -13
- package/dist/templates/modules/sse/files/apps/api/src/events/event-envelope.ts +123 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/event-stream.ts +191 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/event-topics.ts +64 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/events.module.spec.ts +203 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/events.module.ts +107 -14
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +372 -18
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +136 -40
- package/dist/templates/modules/sse/files/apps/web/src/lib/events.test.ts +216 -0
- package/dist/templates/modules/sse/files/apps/web/src/lib/events.ts +207 -0
- package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +181 -70
- package/dist/templates/modules/sse/module.manifest.json +17 -4
- package/package.json +2 -2
|
@@ -1,60 +1,137 @@
|
|
|
1
|
+
import { AppException } from "@podosoft/podokit-contracts";
|
|
1
2
|
import {
|
|
2
3
|
LocalObjectStore,
|
|
3
4
|
OBJECT_STORAGE,
|
|
5
|
+
objectResponse,
|
|
4
6
|
type ObjectData,
|
|
7
|
+
type ObjectStore,
|
|
5
8
|
} from "@podosoft/podokit-runtime";
|
|
6
9
|
import { Elysia, t } from "elysia";
|
|
7
|
-
import { resolve } from "node:path";
|
|
8
|
-
import
|
|
10
|
+
import { join, resolve } from "node:path";
|
|
11
|
+
import {
|
|
12
|
+
ACCESS_POLICY,
|
|
13
|
+
REQUEST_IDENTITY,
|
|
14
|
+
type AppPlugin,
|
|
15
|
+
type ElysiaPlugin,
|
|
16
|
+
type PodokitModule,
|
|
17
|
+
type RequestIdentity,
|
|
18
|
+
type ServiceKey,
|
|
19
|
+
} from "../core/services";
|
|
9
20
|
import { PROVIDERS } from "../config/providers";
|
|
21
|
+
import { DownloadUrlSigner, objectKeyFromPath, SignedDownloadStore } from "./download-url";
|
|
22
|
+
import {
|
|
23
|
+
demoKeyParams,
|
|
24
|
+
demoObjectKey,
|
|
25
|
+
requireStorageAdmin,
|
|
26
|
+
storageObjectNotFound,
|
|
27
|
+
} from "./storage-demo";
|
|
28
|
+
|
|
29
|
+
export const DOWNLOAD_BASE_URL = "/files/content";
|
|
30
|
+
export const LOCAL_DOWNLOAD_URLS = Symbol("local-download-urls") as ServiceKey<DownloadUrlSigner>;
|
|
10
31
|
|
|
11
|
-
function
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
const next = await iterator.next();
|
|
16
|
-
if (next.done) controller.close();
|
|
17
|
-
else controller.enqueue(next.value);
|
|
18
|
-
},
|
|
19
|
-
async cancel(): Promise<void> {
|
|
20
|
-
await iterator.return?.();
|
|
21
|
-
},
|
|
22
|
-
});
|
|
32
|
+
async function readObject(storage: ObjectStore, key: string): Promise<ObjectData> {
|
|
33
|
+
const object = await storage.get(key);
|
|
34
|
+
if (!object) throw storageObjectNotFound();
|
|
35
|
+
return object;
|
|
23
36
|
}
|
|
24
37
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
38
|
+
// Local objects are served from the app's own origin with the type the uploader
|
|
39
|
+
// declared. Only render types that cannot run script; everything else downloads,
|
|
40
|
+
// and the sandbox policy blocks script even if a browser renders it anyway.
|
|
41
|
+
const INLINE_CONTENT_TYPES = new Set([
|
|
42
|
+
"image/png",
|
|
43
|
+
"image/jpeg",
|
|
44
|
+
"image/gif",
|
|
45
|
+
"image/webp",
|
|
46
|
+
"image/avif",
|
|
47
|
+
"text/plain",
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
export function downloadSafetyHeaders(contentType: string | undefined): Record<string, string> {
|
|
51
|
+
const mediaType = contentType?.split(";")[0]?.trim().toLowerCase() ?? "";
|
|
52
|
+
return {
|
|
53
|
+
"x-content-type-options": "nosniff",
|
|
54
|
+
"content-security-policy": "default-src 'none'; sandbox",
|
|
55
|
+
"content-disposition": INLINE_CONTENT_TYPES.has(mediaType) ? "inline" : "attachment",
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function localStorageRoutes(
|
|
60
|
+
storage: ObjectStore,
|
|
61
|
+
signer: DownloadUrlSigner,
|
|
62
|
+
identity: RequestIdentity,
|
|
63
|
+
): ElysiaPlugin {
|
|
30
64
|
return new Elysia({ name: "podokit.storage-local" })
|
|
31
|
-
.get("/files/content/*", async ({ params,
|
|
32
|
-
const
|
|
33
|
-
if (
|
|
34
|
-
|
|
35
|
-
|
|
65
|
+
.get("/files/content/*", async ({ request, params, query }) => {
|
|
66
|
+
const key = objectKeyFromPath(params["*"]);
|
|
67
|
+
if (key === undefined || !(await signer.verify(key, query.expires, query.signature))) {
|
|
68
|
+
throw new AppException(
|
|
69
|
+
"DOWNLOAD_URL_INVALID",
|
|
70
|
+
"The download URL is invalid or has expired",
|
|
71
|
+
403,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
const object = await readObject(storage, key);
|
|
75
|
+
return objectResponse(request, object, { headers: downloadSafetyHeaders(object.contentType) });
|
|
36
76
|
}, {
|
|
37
|
-
|
|
77
|
+
query: t.Object({
|
|
78
|
+
expires: t.Optional(t.String()),
|
|
79
|
+
signature: t.Optional(t.String()),
|
|
80
|
+
}),
|
|
81
|
+
detail: { tags: ["files"], summary: "Read a local object through a signed download URL" },
|
|
38
82
|
})
|
|
39
|
-
.put("/storage/:key", async ({ params, body }) => {
|
|
40
|
-
await
|
|
83
|
+
.put("/storage/:key", async ({ request, params, body }) => {
|
|
84
|
+
await requireStorageAdmin(identity, request);
|
|
85
|
+
await storage.put(demoObjectKey(params.key), body.content, { contentType: "text/plain" });
|
|
41
86
|
return { key: params.key };
|
|
42
87
|
}, {
|
|
43
|
-
params:
|
|
88
|
+
params: demoKeyParams,
|
|
44
89
|
body: t.Object({ content: t.String() }),
|
|
45
|
-
detail: { tags: ["storage"], summary: "Store
|
|
90
|
+
detail: { tags: ["storage"], summary: "Store a demo object (administrators only)" },
|
|
91
|
+
})
|
|
92
|
+
.get("/storage/:key/presigned", async ({ request, params }) => {
|
|
93
|
+
await requireStorageAdmin(identity, request);
|
|
94
|
+
return { url: await storage.getDownloadUrl(demoObjectKey(params.key)) };
|
|
95
|
+
}, {
|
|
96
|
+
params: demoKeyParams,
|
|
97
|
+
detail: { tags: ["storage"], summary: "Sign a demo object download URL (administrators only)" },
|
|
98
|
+
})
|
|
99
|
+
.get("/storage/:key", async ({ request, params }) => {
|
|
100
|
+
await requireStorageAdmin(identity, request);
|
|
101
|
+
const object = await readObject(storage, demoObjectKey(params.key));
|
|
102
|
+
return objectResponse(request, object, { headers: downloadSafetyHeaders(object.contentType) });
|
|
103
|
+
}, {
|
|
104
|
+
params: demoKeyParams,
|
|
105
|
+
detail: { tags: ["storage"], summary: "Read a demo object (administrators only)" },
|
|
46
106
|
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const localStoragePlugin: AppPlugin = ({ services }) => {
|
|
110
|
+
if (PROVIDERS["object-storage"] !== "local") {
|
|
111
|
+
return new Elysia({ name: "podokit.storage-local.inactive" });
|
|
112
|
+
}
|
|
113
|
+
return localStorageRoutes(
|
|
114
|
+
services.resolve(OBJECT_STORAGE),
|
|
115
|
+
services.resolve(LOCAL_DOWNLOAD_URLS),
|
|
116
|
+
services.resolve(REQUEST_IDENTITY),
|
|
117
|
+
);
|
|
47
118
|
};
|
|
48
119
|
|
|
49
120
|
export const storageLocalModule: PodokitModule = {
|
|
50
121
|
name: "object-storage-local",
|
|
51
122
|
configure: (_env, services): void => {
|
|
52
123
|
if (PROVIDERS["object-storage"] !== "local") return;
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
124
|
+
const root = resolve(process.env.LOCAL_STORAGE_PATH ?? "./data/files");
|
|
125
|
+
const signer = new DownloadUrlSigner(join(root, ".podokit", "download-url.key"));
|
|
126
|
+
const storage = new SignedDownloadStore(
|
|
127
|
+
new LocalObjectStore({ rootDirectory: root, publicBaseUrl: DOWNLOAD_BASE_URL }),
|
|
128
|
+
signer,
|
|
129
|
+
DOWNLOAD_BASE_URL,
|
|
130
|
+
);
|
|
57
131
|
services.register(OBJECT_STORAGE, storage, () => storage.close());
|
|
132
|
+
services.register(LOCAL_DOWNLOAD_URLS, signer);
|
|
133
|
+
// The signature is the authorization, as with an S3 presigned URL.
|
|
134
|
+
services.resolve(ACCESS_POLICY).register("GET", `${DOWNLOAD_BASE_URL}/*`, "public");
|
|
58
135
|
},
|
|
59
136
|
plugin: localStoragePlugin,
|
|
60
137
|
};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { expect, test, type APIRequestContext, type PlaywrightWorkerArgs } from "@playwright/test";
|
|
2
|
+
|
|
3
|
+
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
4
|
+
const origin = { origin: base };
|
|
5
|
+
// The administrator seeded by the admin-dashboard Playwright setup. Inlined so this
|
|
6
|
+
// spec also compiles in apps that do not install that module.
|
|
7
|
+
const admin = {
|
|
8
|
+
email: "admin@example.com",
|
|
9
|
+
password: process.env.E2E_ADMIN_PASSWORD || "Podokit3e-Str0ng!pw",
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
type Playwright = PlaywrightWorkerArgs["playwright"];
|
|
13
|
+
|
|
14
|
+
function uniqueKey(): string {
|
|
15
|
+
return `local-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function newContext(playwright: Playwright): Promise<APIRequestContext> {
|
|
19
|
+
return playwright.request.newContext({ baseURL: base, extraHTTPHeaders: origin });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function sessionRoles(ctx: APIRequestContext): Promise<string[] | undefined> {
|
|
23
|
+
const response = await ctx.get("/api/auth/get-session");
|
|
24
|
+
if (!response.ok()) return undefined;
|
|
25
|
+
const session = await response.json() as { user?: { role?: string | null } } | null;
|
|
26
|
+
if (!session?.user) return undefined;
|
|
27
|
+
return (session.user.role ?? "").split(",").map((role) => role.trim()).filter(Boolean);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Resolves false when the auth module is missing or sign-up does not start a session. */
|
|
31
|
+
async function signUp(ctx: APIRequestContext): Promise<boolean> {
|
|
32
|
+
const email = `storage-local-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@example.com`;
|
|
33
|
+
await ctx.post("/api/auth/sign-up/email", { data: { email, password: "Podokit3e-Str0ng!pw", name: "L" } });
|
|
34
|
+
return (await sessionRoles(ctx)) !== undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Resolves false when no administrator session is available. */
|
|
38
|
+
async function signInAdmin(ctx: APIRequestContext): Promise<boolean> {
|
|
39
|
+
const response = await ctx.post("/api/auth/sign-in/email", { data: admin });
|
|
40
|
+
return response.ok() && ((await sessionRoles(ctx)) ?? []).includes("admin");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
test("local storage: signed download URLs reach only the signed object", async ({ playwright }) => {
|
|
44
|
+
const ctx = await newContext(playwright);
|
|
45
|
+
const anonymous = await newContext(playwright);
|
|
46
|
+
const key = uniqueKey();
|
|
47
|
+
try {
|
|
48
|
+
test.skip(!(await signInAdmin(ctx)), "requires the auth module and the seeded administrator");
|
|
49
|
+
const presigned = await ctx.get(`/api/storage/${key}/presigned`);
|
|
50
|
+
const { url } = await presigned.json() as { url?: string };
|
|
51
|
+
test.skip(!url?.startsWith("/files/content/"), "object-storage provider is not local");
|
|
52
|
+
if (!url) throw new Error("presign response must include a url");
|
|
53
|
+
expect(url).toContain(`/files/content/demo/${key}?`);
|
|
54
|
+
expect((await ctx.put(`/api/storage/${key}`, { data: { content: "hello local" } })).ok()).toBeTruthy();
|
|
55
|
+
|
|
56
|
+
const download = await anonymous.get(`/api${url}`);
|
|
57
|
+
expect(download.ok()).toBeTruthy();
|
|
58
|
+
expect(await download.text()).toBe("hello local");
|
|
59
|
+
|
|
60
|
+
const unsigned = await anonymous.get(`/api${url.split("?")[0]}`);
|
|
61
|
+
expect(unsigned.status()).toBe(403);
|
|
62
|
+
expect(await unsigned.json()).toMatchObject({ error: { code: "DOWNLOAD_URL_INVALID" } });
|
|
63
|
+
|
|
64
|
+
const altered = await anonymous.get(`/api${url.replace(`demo/${key}`, `demo/${key}-other`)}`);
|
|
65
|
+
expect(altered.status()).toBe(403);
|
|
66
|
+
} finally {
|
|
67
|
+
await ctx.dispose();
|
|
68
|
+
await anonymous.dispose();
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("local storage demo routes are limited to administrators", async ({ playwright }) => {
|
|
73
|
+
const user = await newContext(playwright);
|
|
74
|
+
const key = uniqueKey();
|
|
75
|
+
try {
|
|
76
|
+
test.skip(!(await signUp(user)), "requires the auth module");
|
|
77
|
+
for (const response of [
|
|
78
|
+
await user.put(`/api/storage/${key}`, { data: { content: "x" } }),
|
|
79
|
+
await user.get(`/api/storage/${key}`),
|
|
80
|
+
await user.get(`/api/storage/${key}/presigned`),
|
|
81
|
+
]) {
|
|
82
|
+
expect(response.status()).toBe(403);
|
|
83
|
+
expect(await response.json()).toMatchObject({ error: { code: "ADMIN_REQUIRED" } });
|
|
84
|
+
}
|
|
85
|
+
} finally {
|
|
86
|
+
await user.dispose();
|
|
87
|
+
}
|
|
88
|
+
});
|
|
@@ -7,6 +7,9 @@
|
|
|
7
7
|
"requiredProjectFiles": [
|
|
8
8
|
"apps/api/src/config/providers.ts"
|
|
9
9
|
],
|
|
10
|
+
"requiredProjectContents": {
|
|
11
|
+
"apps/api/src/core/services.ts": ["isAdmin(request: Request): Promise<boolean>;"]
|
|
12
|
+
},
|
|
10
13
|
"managedGlobs": [
|
|
11
14
|
"apps/api/src/storage-local/**"
|
|
12
15
|
],
|
|
@@ -29,6 +32,8 @@
|
|
|
29
32
|
],
|
|
30
33
|
"instructions": [
|
|
31
34
|
"Local object storage is single-host. Back up LOCAL_STORAGE_PATH together with the database.",
|
|
32
|
-
"Keep the API at one replica or select the S3 provider."
|
|
35
|
+
"Keep the API at one replica or select the S3 provider.",
|
|
36
|
+
"Download URLs are signed and expire after one hour by default. The signing key lives in LOCAL_STORAGE_PATH/.podokit/download-url.key; deleting it invalidates every issued URL.",
|
|
37
|
+
"The /storage demo routes need the auth module and an administrator session, and only reach keys under demo/."
|
|
33
38
|
]
|
|
34
39
|
}
|
package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.spec.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { afterAll, describe, expect, it } from "bun:test";
|
|
2
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
3
|
+
import { ReadinessService } from "../health/readiness.service";
|
|
4
|
+
import { S3ObjectStore } from "./s3-object.store";
|
|
5
|
+
import { StorageService } from "./storage.service";
|
|
6
|
+
|
|
7
|
+
// Runs against the bucket in the S3_* settings, like the storage API spec.
|
|
8
|
+
const integration = process.env.S3_ENDPOINT ? describe : describe.skip;
|
|
9
|
+
|
|
10
|
+
integration("S3ObjectStore against a live bucket", () => {
|
|
11
|
+
const storage = new StorageService(new ReadinessService());
|
|
12
|
+
const store = new S3ObjectStore(storage);
|
|
13
|
+
const prefix = `podokit-test/${randomUUID()}/`;
|
|
14
|
+
const created: string[] = [];
|
|
15
|
+
|
|
16
|
+
async function put(name: string, content: Uint8Array | string, contentType?: string): Promise<string> {
|
|
17
|
+
const key = `${prefix}${name}`;
|
|
18
|
+
created.push(key);
|
|
19
|
+
await store.put(key, content, contentType ? { contentType } : {});
|
|
20
|
+
return key;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
afterAll(async () => {
|
|
24
|
+
await Promise.all(created.map((key) => storage.delete(key)));
|
|
25
|
+
storage.close();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("describes an object and streams exactly its content", async () => {
|
|
29
|
+
const content = randomBytes(3 * 1024 * 1024);
|
|
30
|
+
const key = await put("object.bin", content, "application/octet-stream");
|
|
31
|
+
|
|
32
|
+
const info = await store.stat(key);
|
|
33
|
+
expect(info).toMatchObject({ key, size: content.byteLength, contentType: "application/octet-stream" });
|
|
34
|
+
expect(info?.etag).toMatch(/^"[^"]+"$/);
|
|
35
|
+
expect(info?.lastModified).toBeInstanceOf(Date);
|
|
36
|
+
|
|
37
|
+
const object = await store.get(key);
|
|
38
|
+
if (!object) throw new Error("expected the stored object");
|
|
39
|
+
expect(object.etag).toBe(info?.etag ?? "");
|
|
40
|
+
const streamed = Buffer.from(await new Response(object.body).arrayBuffer());
|
|
41
|
+
expect(streamed.equals(content)).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("lists keys a page at a time", async () => {
|
|
45
|
+
const names = ["list/a.txt", "list/b.txt", "list/c/d.txt", "list/e.txt", "listed.txt"];
|
|
46
|
+
for (const name of names) await put(name, name);
|
|
47
|
+
|
|
48
|
+
const pages: string[][] = [];
|
|
49
|
+
let cursor: string | undefined;
|
|
50
|
+
do {
|
|
51
|
+
const page = await store.list(`${prefix}list/`, { limit: 2, ...(cursor ? { cursor } : {}) });
|
|
52
|
+
pages.push(page.keys.map((key) => key.slice(prefix.length)));
|
|
53
|
+
cursor = page.nextCursor;
|
|
54
|
+
} while (cursor !== undefined);
|
|
55
|
+
|
|
56
|
+
expect(pages).toEqual([["list/a.txt", "list/b.txt"], ["list/c/d.txt", "list/e.txt"]]);
|
|
57
|
+
expect((await store.list(`${prefix}list`)).keys).toHaveLength(names.length);
|
|
58
|
+
await expect(store.list(prefix, { limit: 0 })).rejects.toThrow("from 1 to 1000");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("reports a missing key as null and lets an unread body be dropped", async () => {
|
|
62
|
+
expect(await store.get(`${prefix}missing`)).toBeNull();
|
|
63
|
+
expect(await store.stat(`${prefix}missing`)).toBeNull();
|
|
64
|
+
|
|
65
|
+
const key = await put("unread.txt", "unread", "text/plain");
|
|
66
|
+
const object = await store.get(key);
|
|
67
|
+
if (!object) throw new Error("expected the stored object");
|
|
68
|
+
await object.body.cancel();
|
|
69
|
+
expect(object.size).toBe(6);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("fails the stream rather than mix in content written after the metadata was read", async () => {
|
|
73
|
+
const key = await put("replaced.txt", "before", "text/plain");
|
|
74
|
+
const object = await store.get(key);
|
|
75
|
+
if (!object) throw new Error("expected the stored object");
|
|
76
|
+
await store.put(key, "after!", { contentType: "text/plain" });
|
|
77
|
+
|
|
78
|
+
await expect(new Response(object.body).text()).rejects.toThrow("changed while it was being read");
|
|
79
|
+
});
|
|
80
|
+
});
|
package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
DownloadUrlOptions,
|
|
3
|
+
ListObjectsOptions,
|
|
3
4
|
ObjectBody,
|
|
4
5
|
ObjectData,
|
|
6
|
+
ObjectInfo,
|
|
7
|
+
ObjectPage,
|
|
5
8
|
ObjectStore,
|
|
6
9
|
PutObjectOptions,
|
|
7
10
|
StoredObject,
|
|
@@ -16,10 +19,6 @@ async function bodyBytes(body: ObjectBody): Promise<Uint8Array> {
|
|
|
16
19
|
return Buffer.concat(chunks);
|
|
17
20
|
}
|
|
18
21
|
|
|
19
|
-
async function* oneChunk(body: Uint8Array): AsyncGenerator<Uint8Array> {
|
|
20
|
-
yield body;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
22
|
export class S3ObjectStore implements ObjectStore {
|
|
24
23
|
constructor(private readonly storage: StorageService) {}
|
|
25
24
|
|
|
@@ -29,9 +28,16 @@ export class S3ObjectStore implements ObjectStore {
|
|
|
29
28
|
return { key, size: bytes.byteLength, ...options };
|
|
30
29
|
}
|
|
31
30
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
31
|
+
get(key: string): Promise<ObjectData | null> {
|
|
32
|
+
return this.storage.open(key);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
stat(key: string): Promise<ObjectInfo | null> {
|
|
36
|
+
return this.storage.stat(key);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
list(prefix: string, options?: ListObjectsOptions): Promise<ObjectPage> {
|
|
40
|
+
return this.storage.list(prefix, options);
|
|
35
41
|
}
|
|
36
42
|
|
|
37
43
|
exists(key: string): Promise<boolean> {
|
package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.spec.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { AppException } from "@podosoft/podokit-contracts";
|
|
3
|
+
import { Elysia } from "elysia";
|
|
4
|
+
import type { RequestIdentity } from "../core/services";
|
|
5
|
+
import { storageDemoRoutes, type DemoStorage } from "./storage.module";
|
|
6
|
+
|
|
7
|
+
interface MemoryStorage extends DemoStorage {
|
|
8
|
+
objects: Map<string, string>;
|
|
9
|
+
presigned: string[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function memoryStorage(): MemoryStorage {
|
|
13
|
+
const objects = new Map<string, string>();
|
|
14
|
+
const presigned: string[] = [];
|
|
15
|
+
return {
|
|
16
|
+
objects,
|
|
17
|
+
presigned,
|
|
18
|
+
put: async (key, body) => {
|
|
19
|
+
if (typeof body === "string") objects.set(key, body);
|
|
20
|
+
else if (body instanceof Blob) objects.set(key, await body.text());
|
|
21
|
+
else objects.set(key, Buffer.from(body).toString("utf8"));
|
|
22
|
+
},
|
|
23
|
+
open: async (key) => {
|
|
24
|
+
const content = objects.get(key);
|
|
25
|
+
if (content === undefined) return null;
|
|
26
|
+
const bytes = Buffer.from(content);
|
|
27
|
+
return {
|
|
28
|
+
key,
|
|
29
|
+
size: bytes.byteLength,
|
|
30
|
+
contentType: "text/plain",
|
|
31
|
+
etag: `"${bytes.toString("hex")}"`,
|
|
32
|
+
lastModified: new Date("2026-01-02T03:04:05Z"),
|
|
33
|
+
body: new Blob([bytes]).stream(),
|
|
34
|
+
};
|
|
35
|
+
},
|
|
36
|
+
presignedGetUrl: async (key) => {
|
|
37
|
+
presigned.push(key);
|
|
38
|
+
return `https://storage.example.com/${key}?signature=test`;
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function caller(userId: string | undefined, admin = false): RequestIdentity {
|
|
44
|
+
return {
|
|
45
|
+
userId: async () => userId,
|
|
46
|
+
isAdmin: async () => admin,
|
|
47
|
+
roles: async () => (admin ? ["admin"] : []),
|
|
48
|
+
activeOrganizationId: async () => undefined,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function call(
|
|
53
|
+
storage: DemoStorage,
|
|
54
|
+
identity: RequestIdentity,
|
|
55
|
+
method: "GET" | "PUT",
|
|
56
|
+
path: string,
|
|
57
|
+
options: { content?: string; headers?: Record<string, string> } = {},
|
|
58
|
+
): Promise<Response> {
|
|
59
|
+
const app = new Elysia()
|
|
60
|
+
.onError(({ code, error, set }) => {
|
|
61
|
+
if (error instanceof AppException) {
|
|
62
|
+
set.status = error.statusCode;
|
|
63
|
+
return { code: error.code };
|
|
64
|
+
}
|
|
65
|
+
if (code === "VALIDATION") {
|
|
66
|
+
set.status = 400;
|
|
67
|
+
return { code: "VALIDATION_ERROR" };
|
|
68
|
+
}
|
|
69
|
+
if (code === "NOT_FOUND") {
|
|
70
|
+
set.status = 404;
|
|
71
|
+
return { code: "NOT_FOUND" };
|
|
72
|
+
}
|
|
73
|
+
return undefined;
|
|
74
|
+
})
|
|
75
|
+
.use(storageDemoRoutes(storage, identity));
|
|
76
|
+
return app.handle(new Request(`http://localhost${path}`, {
|
|
77
|
+
method,
|
|
78
|
+
headers: {
|
|
79
|
+
...(options.content === undefined ? {} : { "content-type": "application/json" }),
|
|
80
|
+
...options.headers,
|
|
81
|
+
},
|
|
82
|
+
...(options.content === undefined ? {} : { body: JSON.stringify({ content: options.content }) }),
|
|
83
|
+
}));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function reply(response: Promise<Response>): Promise<{ status: number; body: unknown }> {
|
|
87
|
+
const resolved = await response;
|
|
88
|
+
return { status: resolved.status, body: await resolved.json() };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
describe("object storage demo routes", () => {
|
|
92
|
+
it("require a signed-in administrator", async () => {
|
|
93
|
+
const storage = memoryStorage();
|
|
94
|
+
|
|
95
|
+
expect(await reply(call(storage, caller(undefined), "PUT", "/storage/hello", { content: "x" }))).toEqual({
|
|
96
|
+
status: 401,
|
|
97
|
+
body: { code: "AUTH_REQUIRED" },
|
|
98
|
+
});
|
|
99
|
+
for (const [method, path] of [
|
|
100
|
+
["PUT", "/storage/hello"],
|
|
101
|
+
["GET", "/storage/hello"],
|
|
102
|
+
["GET", "/storage/hello/presigned"],
|
|
103
|
+
] as const) {
|
|
104
|
+
const response = call(storage, caller("user-1"), method, path, method === "PUT" ? { content: "x" } : {});
|
|
105
|
+
expect(await reply(response)).toEqual({ status: 403, body: { code: "ADMIN_REQUIRED" } });
|
|
106
|
+
}
|
|
107
|
+
expect(storage.objects.size).toBe(0);
|
|
108
|
+
expect(storage.presigned).toEqual([]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("stores, streams, and presigns objects under the demo prefix for administrators", async () => {
|
|
112
|
+
const storage = memoryStorage();
|
|
113
|
+
const admin = caller("admin-1", true);
|
|
114
|
+
|
|
115
|
+
expect(await reply(call(storage, admin, "PUT", "/storage/hello", { content: "hi" }))).toEqual({
|
|
116
|
+
status: 200,
|
|
117
|
+
body: { key: "hello" },
|
|
118
|
+
});
|
|
119
|
+
expect([...storage.objects.keys()]).toEqual(["demo/hello"]);
|
|
120
|
+
|
|
121
|
+
const read = await call(storage, admin, "GET", "/storage/hello");
|
|
122
|
+
expect(read.status).toBe(200);
|
|
123
|
+
expect(read.headers.get("content-type")).toBe("text/plain");
|
|
124
|
+
expect(read.headers.get("content-length")).toBe("2");
|
|
125
|
+
expect(read.headers.get("x-content-type-options")).toBe("nosniff");
|
|
126
|
+
const etag = read.headers.get("etag");
|
|
127
|
+
expect(etag).toBe("\"6869\"");
|
|
128
|
+
expect(await read.text()).toBe("hi");
|
|
129
|
+
|
|
130
|
+
const revalidated = await call(storage, admin, "GET", "/storage/hello", {
|
|
131
|
+
headers: { "if-none-match": etag ?? "" },
|
|
132
|
+
});
|
|
133
|
+
expect(revalidated.status).toBe(304);
|
|
134
|
+
expect(await revalidated.text()).toBe("");
|
|
135
|
+
|
|
136
|
+
const presigned = await call(storage, admin, "GET", "/storage/hello/presigned");
|
|
137
|
+
expect(presigned.status).toBe(200);
|
|
138
|
+
expect(storage.presigned).toEqual(["demo/hello"]);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("reports a missing demo object as not found", async () => {
|
|
142
|
+
const response = call(memoryStorage(), caller("admin-1", true), "GET", "/storage/missing");
|
|
143
|
+
expect(await reply(response)).toEqual({ status: 404, body: { code: "STORAGE_OBJECT_NOT_FOUND" } });
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("rejects keys that would leave the demo prefix", async () => {
|
|
147
|
+
const storage = memoryStorage();
|
|
148
|
+
const admin = caller("admin-1", true);
|
|
149
|
+
|
|
150
|
+
for (const key of ["site%2Ffavicon", "profile-images%2Fa.png", "..%2Fsite%2Ffavicon", ".podokit", "%2E%2E"]) {
|
|
151
|
+
const response = await call(storage, admin, "PUT", `/storage/${key}`, { content: "x" });
|
|
152
|
+
expect([400, 404]).toContain(response.status);
|
|
153
|
+
}
|
|
154
|
+
expect(storage.objects.size).toBe(0);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { AppException } from "@podosoft/podokit-contracts";
|
|
2
|
+
import { t } from "elysia";
|
|
3
|
+
import type { RequestIdentity } from "../core/services";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The demo routes only reach objects under this prefix, so even an
|
|
7
|
+
* administrator cannot overwrite keys owned by other features such as
|
|
8
|
+
* uploads, profile images, or the site favicon.
|
|
9
|
+
*/
|
|
10
|
+
export const DEMO_KEY_PREFIX = "demo/";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* One path segment. Elysia decodes `%2F` inside `:key`, so the pattern rejects
|
|
14
|
+
* slashes and leading dots instead of trusting the router.
|
|
15
|
+
*/
|
|
16
|
+
export const demoKeyParams = t.Object({
|
|
17
|
+
key: t.String({ pattern: "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$" }),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
export function demoObjectKey(name: string): string {
|
|
21
|
+
return `${DEMO_KEY_PREFIX}${name}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function storageObjectNotFound(): AppException {
|
|
25
|
+
return new AppException("STORAGE_OBJECT_NOT_FOUND", "Object not found", 404);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function requireStorageAdmin(
|
|
29
|
+
identity: RequestIdentity,
|
|
30
|
+
request: Request,
|
|
31
|
+
): Promise<void> {
|
|
32
|
+
if (!(await identity.userId(request))) {
|
|
33
|
+
throw new AppException("AUTH_REQUIRED", "Authentication is required", 401);
|
|
34
|
+
}
|
|
35
|
+
if (!(await identity.isAdmin(request))) {
|
|
36
|
+
throw new AppException("ADMIN_REQUIRED", "Administrator access is required", 403);
|
|
37
|
+
}
|
|
38
|
+
}
|