@gigamusic/admin 0.1.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/README.md +18 -0
- package/package.json +69 -0
- package/src/client.ts +18 -0
- package/src/components/AdminLoginForm.tsx +63 -0
- package/src/components/AdminNav.tsx +50 -0
- package/src/components/ReleaseForm.tsx +556 -0
- package/src/handlers/auth.ts +58 -0
- package/src/handlers/links.ts +117 -0
- package/src/handlers/orders.ts +21 -0
- package/src/handlers/releases.ts +209 -0
- package/src/handlers/settings.ts +54 -0
- package/src/handlers/upload.ts +286 -0
- package/src/index.ts +24 -0
- package/src/lib/rate-limit.ts +31 -0
- package/src/lib/responses.ts +19 -0
- package/src/lib/session.ts +89 -0
- package/src/lib/types.ts +45 -0
- package/src/pages/AdminEditReleasePage.tsx +39 -0
- package/src/pages/AdminHomePage.tsx +38 -0
- package/src/pages/AdminLayout.tsx +20 -0
- package/src/pages/AdminLinksPage.tsx +187 -0
- package/src/pages/AdminLoginPage.tsx +11 -0
- package/src/pages/AdminNewReleasePage.tsx +12 -0
- package/src/pages/AdminOrdersPage.tsx +127 -0
- package/src/pages/AdminReleasesPage.tsx +87 -0
- package/src/pages/AdminSettingsPage.tsx +84 -0
- package/src/server.ts +36 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { AdminDeps, RouteHandler } from "../lib/types";
|
|
2
|
+
import type { LinkInput } from "@gigamusic/db";
|
|
3
|
+
import { badRequest, json, notFound, unauthorized } from "../lib/responses";
|
|
4
|
+
import { isAdminAuthenticated } from "../lib/session";
|
|
5
|
+
|
|
6
|
+
interface LinkBody {
|
|
7
|
+
id?: unknown;
|
|
8
|
+
title?: unknown;
|
|
9
|
+
url?: unknown;
|
|
10
|
+
position?: unknown;
|
|
11
|
+
isVisible?: unknown;
|
|
12
|
+
showOnHero?: unknown;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface ReorderBody {
|
|
16
|
+
orderedIds?: unknown;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function toLinkInput(body: LinkBody): LinkInput | null {
|
|
20
|
+
const title = typeof body.title === "string" ? body.title : null;
|
|
21
|
+
const url = typeof body.url === "string" ? body.url : null;
|
|
22
|
+
if (!title || !url) return null;
|
|
23
|
+
return {
|
|
24
|
+
id: typeof body.id === "number" && Number.isFinite(body.id) ? body.id : undefined,
|
|
25
|
+
title,
|
|
26
|
+
url,
|
|
27
|
+
position: typeof body.position === "number" ? body.position : undefined,
|
|
28
|
+
isVisible: typeof body.isVisible === "boolean" ? body.isVisible : undefined,
|
|
29
|
+
showOnHero: typeof body.showOnHero === "boolean" ? body.showOnHero : undefined,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* GET/POST/PUT/DELETE /api/admin/links — manages the global `/links` page
|
|
35
|
+
* link list. (Per-release link pages live in `@gigamusic/links`.)
|
|
36
|
+
*
|
|
37
|
+
* PUT accepts either a partial single-link update or `{ orderedIds: [...] }`
|
|
38
|
+
* to reorder the whole list in one round-trip.
|
|
39
|
+
*
|
|
40
|
+
* DELETE expects `?id=...`.
|
|
41
|
+
*/
|
|
42
|
+
export function createAdminLinksHandlers(deps: AdminDeps): {
|
|
43
|
+
GET: RouteHandler;
|
|
44
|
+
POST: RouteHandler;
|
|
45
|
+
PUT: RouteHandler;
|
|
46
|
+
DELETE: RouteHandler;
|
|
47
|
+
} {
|
|
48
|
+
return {
|
|
49
|
+
GET: async () => {
|
|
50
|
+
if (!(await isAdminAuthenticated(deps.adminSessionSecret))) {
|
|
51
|
+
return unauthorized();
|
|
52
|
+
}
|
|
53
|
+
return json(await deps.queries.listAllLinks());
|
|
54
|
+
},
|
|
55
|
+
POST: async (req) => {
|
|
56
|
+
if (!(await isAdminAuthenticated(deps.adminSessionSecret))) {
|
|
57
|
+
return unauthorized();
|
|
58
|
+
}
|
|
59
|
+
const body = (await safeJson(req)) as LinkBody;
|
|
60
|
+
const input = toLinkInput(body);
|
|
61
|
+
if (!input) return badRequest("title and url are required");
|
|
62
|
+
const link = await deps.queries.upsertLink(input);
|
|
63
|
+
return json(link, { status: 201 });
|
|
64
|
+
},
|
|
65
|
+
PUT: async (req) => {
|
|
66
|
+
if (!(await isAdminAuthenticated(deps.adminSessionSecret))) {
|
|
67
|
+
return unauthorized();
|
|
68
|
+
}
|
|
69
|
+
const body = (await safeJson(req)) as LinkBody & ReorderBody;
|
|
70
|
+
if (Array.isArray(body.orderedIds)) {
|
|
71
|
+
const ids = body.orderedIds
|
|
72
|
+
.map((x) => (typeof x === "number" ? x : Number(x)))
|
|
73
|
+
.filter((n) => Number.isFinite(n));
|
|
74
|
+
await deps.queries.reorderLinks(ids);
|
|
75
|
+
return json({ ok: true });
|
|
76
|
+
}
|
|
77
|
+
const id = typeof body.id === "number" && Number.isFinite(body.id) ? body.id : null;
|
|
78
|
+
if (id == null) return badRequest("id is required");
|
|
79
|
+
// upsertLink handles partial updates when given an id; pass through
|
|
80
|
+
// every supplied field.
|
|
81
|
+
const link = await deps.queries.upsertLink({
|
|
82
|
+
id,
|
|
83
|
+
title: typeof body.title === "string" ? body.title : "",
|
|
84
|
+
url: typeof body.url === "string" ? body.url : "",
|
|
85
|
+
position: typeof body.position === "number" ? body.position : undefined,
|
|
86
|
+
isVisible: typeof body.isVisible === "boolean" ? body.isVisible : undefined,
|
|
87
|
+
showOnHero: typeof body.showOnHero === "boolean" ? body.showOnHero : undefined,
|
|
88
|
+
});
|
|
89
|
+
return json(link);
|
|
90
|
+
},
|
|
91
|
+
DELETE: async (req) => {
|
|
92
|
+
if (!(await isAdminAuthenticated(deps.adminSessionSecret))) {
|
|
93
|
+
return unauthorized();
|
|
94
|
+
}
|
|
95
|
+
const url = new URL(req.url);
|
|
96
|
+
const idRaw = url.searchParams.get("id");
|
|
97
|
+
if (!idRaw) return badRequest("id is required");
|
|
98
|
+
const id = Number(idRaw);
|
|
99
|
+
if (!Number.isFinite(id)) return badRequest("id is required");
|
|
100
|
+
try {
|
|
101
|
+
await deps.queries.deleteLink(id);
|
|
102
|
+
} catch (err) {
|
|
103
|
+
deps.logger?.warn("deleteLink failed", { error: String(err) });
|
|
104
|
+
return notFound("Link not found");
|
|
105
|
+
}
|
|
106
|
+
return json({ ok: true });
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function safeJson(req: Request): Promise<unknown> {
|
|
112
|
+
try {
|
|
113
|
+
return await req.json();
|
|
114
|
+
} catch {
|
|
115
|
+
return {};
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { AdminDeps, RouteHandler } from "../lib/types";
|
|
2
|
+
import { badRequest, json, unauthorized } from "../lib/responses";
|
|
3
|
+
import { isAdminAuthenticated } from "../lib/session";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* GET /api/admin/orders?email=… — returns the order history for a given email
|
|
7
|
+
* address. The plan only requires an admin "view" surface; this is the small
|
|
8
|
+
* read endpoint that backs the OrdersPage filter.
|
|
9
|
+
*/
|
|
10
|
+
export function createAdminOrdersHandler(deps: AdminDeps): RouteHandler {
|
|
11
|
+
return async (req) => {
|
|
12
|
+
if (!(await isAdminAuthenticated(deps.adminSessionSecret))) {
|
|
13
|
+
return unauthorized();
|
|
14
|
+
}
|
|
15
|
+
const url = new URL(req.url);
|
|
16
|
+
const email = url.searchParams.get("email");
|
|
17
|
+
if (!email) return badRequest("email query param is required");
|
|
18
|
+
const orders = await deps.queries.listOrdersByEmail(email);
|
|
19
|
+
return json(orders);
|
|
20
|
+
};
|
|
21
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import {
|
|
3
|
+
ReleaseInputSchema,
|
|
4
|
+
TrackInputSchema,
|
|
5
|
+
type ReleaseInput,
|
|
6
|
+
} from "@gigamusic/core";
|
|
7
|
+
import type { AdminDeps, RouteHandler } from "../lib/types";
|
|
8
|
+
import { badRequest, json, notFound, unauthorized } from "../lib/responses";
|
|
9
|
+
import { isAdminAuthenticated } from "../lib/session";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Draft releases skip the strict published-validation rules so an admin can
|
|
13
|
+
* save in-progress work. We coerce missing numerics to 0 and missing track
|
|
14
|
+
* arrays to `[]` so the persistence call sees a complete shape.
|
|
15
|
+
*/
|
|
16
|
+
const DraftReleaseSchema = ReleaseInputSchema.extend({
|
|
17
|
+
isPublished: z.literal(false),
|
|
18
|
+
price: z.number().int().nonnegative().optional(),
|
|
19
|
+
slug: z.string().optional(),
|
|
20
|
+
type: z.enum(["album", "single"]).optional(),
|
|
21
|
+
tracks: z.array(TrackInputSchema.partial({ files: true, price: true })).optional(),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const ReleasePayloadSchema = z.union([ReleaseInputSchema, DraftReleaseSchema]);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Flatten zod issues into `{ "tracks[0].artist": ["msg", …] }` shape — matches
|
|
28
|
+
* the field-error format the admin form expects.
|
|
29
|
+
*/
|
|
30
|
+
function flattenIssues(error: z.ZodError): Record<string, string[]> {
|
|
31
|
+
const out: Record<string, string[]> = {};
|
|
32
|
+
for (const issue of error.issues) {
|
|
33
|
+
const key = issue.path
|
|
34
|
+
.map((seg: PropertyKey) => (typeof seg === "number" ? `[${seg}]` : `.${String(seg)}`))
|
|
35
|
+
.join("")
|
|
36
|
+
.replace(/^\./, "");
|
|
37
|
+
if (!key) continue;
|
|
38
|
+
(out[key] ??= []).push(issue.message);
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function requireAuth(deps: Pick<AdminDeps, "adminSessionSecret">) {
|
|
44
|
+
return isAdminAuthenticated(deps.adminSessionSecret);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** GET /api/admin/releases and POST /api/admin/releases. */
|
|
48
|
+
export function createAdminReleasesHandlers(
|
|
49
|
+
deps: AdminDeps,
|
|
50
|
+
): { GET: RouteHandler; POST: RouteHandler } {
|
|
51
|
+
return {
|
|
52
|
+
GET: async () => {
|
|
53
|
+
if (!(await requireAuth(deps))) return unauthorized();
|
|
54
|
+
const releases = await deps.queries.listAllReleases();
|
|
55
|
+
return json(releases);
|
|
56
|
+
},
|
|
57
|
+
POST: async (req) => {
|
|
58
|
+
if (!(await requireAuth(deps))) return unauthorized();
|
|
59
|
+
const body = await readJson(req);
|
|
60
|
+
if (!body.ok) return badRequest("Invalid JSON");
|
|
61
|
+
|
|
62
|
+
const parsed = ReleasePayloadSchema.safeParse(body.value);
|
|
63
|
+
if (!parsed.success) {
|
|
64
|
+
return badRequest("Validation failed", {
|
|
65
|
+
fieldErrors: flattenIssues(parsed.error),
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
const release = await deps.queries.createRelease(parsed.data as ReleaseInput);
|
|
70
|
+
return json(release, { status: 201 });
|
|
71
|
+
} catch (err) {
|
|
72
|
+
if (isUniqueConstraintError(err)) {
|
|
73
|
+
return badRequest("Slug already exists", {
|
|
74
|
+
fieldErrors: { slug: ["Slug already exists"] },
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
deps.logger?.error("createRelease failed", { error: String(err) });
|
|
78
|
+
throw err;
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** GET/PUT/DELETE /api/admin/releases/[id]. */
|
|
85
|
+
export function createAdminReleaseByIdHandlers(
|
|
86
|
+
deps: AdminDeps,
|
|
87
|
+
): { GET: RouteHandler; PUT: RouteHandler; DELETE: RouteHandler } {
|
|
88
|
+
return {
|
|
89
|
+
GET: async (_req, ctx) => {
|
|
90
|
+
if (!(await requireAuth(deps))) return unauthorized();
|
|
91
|
+
const id = await resolveIdParam(ctx);
|
|
92
|
+
if (id == null) return badRequest("Missing id");
|
|
93
|
+
const all = await deps.queries.listAllReleases();
|
|
94
|
+
const found = all.find((r) => r.id === id);
|
|
95
|
+
return found ? json(found) : notFound("Release not found");
|
|
96
|
+
},
|
|
97
|
+
PUT: async (req, ctx) => {
|
|
98
|
+
if (!(await requireAuth(deps))) return unauthorized();
|
|
99
|
+
const id = await resolveIdParam(ctx);
|
|
100
|
+
if (id == null) return badRequest("Missing id");
|
|
101
|
+
|
|
102
|
+
const body = await readJson(req);
|
|
103
|
+
if (!body.ok) return badRequest("Invalid JSON");
|
|
104
|
+
const parsed = ReleasePayloadSchema.safeParse(body.value);
|
|
105
|
+
if (!parsed.success) {
|
|
106
|
+
return badRequest("Validation failed", {
|
|
107
|
+
fieldErrors: flattenIssues(parsed.error),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
const updated = await deps.queries.updateRelease(
|
|
113
|
+
id,
|
|
114
|
+
parsed.data as Partial<ReleaseInput>,
|
|
115
|
+
);
|
|
116
|
+
return json(updated);
|
|
117
|
+
} catch (err) {
|
|
118
|
+
if (isUniqueConstraintError(err)) {
|
|
119
|
+
return badRequest("Slug already exists", {
|
|
120
|
+
fieldErrors: { slug: ["Slug already exists"] },
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
deps.logger?.error("updateRelease failed", { error: String(err) });
|
|
124
|
+
throw err;
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
DELETE: async (_req, ctx) => {
|
|
128
|
+
if (!(await requireAuth(deps))) return unauthorized();
|
|
129
|
+
const id = await resolveIdParam(ctx);
|
|
130
|
+
if (id == null) return badRequest("Missing id");
|
|
131
|
+
|
|
132
|
+
// Snapshot storage keys before the cascade nukes the rows, so we can
|
|
133
|
+
// clean up the bucket without an orphaned-file scan.
|
|
134
|
+
const all = await deps.queries.listAllReleases();
|
|
135
|
+
const release = all.find((r) => r.id === id);
|
|
136
|
+
if (!release) return notFound("Release not found");
|
|
137
|
+
|
|
138
|
+
const keys = collectStorageKeys(release, deps.storage);
|
|
139
|
+
await deps.queries.deleteRelease(id);
|
|
140
|
+
await Promise.all(
|
|
141
|
+
keys.map((key) =>
|
|
142
|
+
deps.storage
|
|
143
|
+
.deleteFile(key)
|
|
144
|
+
.catch((err) =>
|
|
145
|
+
deps.logger?.warn("storage.deleteFile failed", {
|
|
146
|
+
key,
|
|
147
|
+
error: String(err),
|
|
148
|
+
}),
|
|
149
|
+
),
|
|
150
|
+
),
|
|
151
|
+
);
|
|
152
|
+
return json({ ok: true });
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
interface ReleaseWithFiles {
|
|
158
|
+
coverImageUrl: string | null;
|
|
159
|
+
tracks: Array<{ files: Array<{ storageKey: string }> }>;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function collectStorageKeys(
|
|
163
|
+
release: ReleaseWithFiles,
|
|
164
|
+
storage: { keyFromPublicUrl: (u: string) => string },
|
|
165
|
+
): string[] {
|
|
166
|
+
const keys: string[] = [];
|
|
167
|
+
if (release.coverImageUrl) {
|
|
168
|
+
try {
|
|
169
|
+
// Cover image is stored as a public URL — round-trip to the key.
|
|
170
|
+
keys.push(storage.keyFromPublicUrl(release.coverImageUrl));
|
|
171
|
+
} catch {
|
|
172
|
+
// Non-public URL (e.g. external cover) — skip cleanup.
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
for (const track of release.tracks) {
|
|
176
|
+
for (const file of track.files) {
|
|
177
|
+
keys.push(file.storageKey);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return keys;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function isUniqueConstraintError(err: unknown): boolean {
|
|
184
|
+
if (typeof err !== "object" || err === null) return false;
|
|
185
|
+
const code = (err as { code?: unknown }).code;
|
|
186
|
+
if (code === "P2002") return true;
|
|
187
|
+
// Some Queries implementations may surface plain Errors with this prefix.
|
|
188
|
+
const message = (err as { message?: unknown }).message;
|
|
189
|
+
return typeof message === "string" && /slug.*(already|exists|unique)/i.test(message);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function resolveIdParam(
|
|
193
|
+
ctx?: { params: Promise<Record<string, string>> } | undefined,
|
|
194
|
+
): Promise<number | null> {
|
|
195
|
+
if (!ctx) return null;
|
|
196
|
+
const params = await ctx.params;
|
|
197
|
+
if (params.id == null) return null;
|
|
198
|
+
const id = Number(params.id);
|
|
199
|
+
return Number.isFinite(id) ? id : null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function readJson(req: Request): Promise<{ ok: true; value: unknown } | { ok: false }> {
|
|
203
|
+
try {
|
|
204
|
+
return { ok: true, value: await req.json() };
|
|
205
|
+
} catch {
|
|
206
|
+
return { ok: false };
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { AdminDeps, RouteHandler } from "../lib/types";
|
|
2
|
+
import { badRequest, json, unauthorized } from "../lib/responses";
|
|
3
|
+
import { isAdminAuthenticated } from "../lib/session";
|
|
4
|
+
|
|
5
|
+
interface SettingPayload {
|
|
6
|
+
key?: unknown;
|
|
7
|
+
value?: unknown;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* GET/PUT /api/admin/settings.
|
|
12
|
+
*
|
|
13
|
+
* GET returns all known settings as a `Record<string, unknown>`. PUT upserts a
|
|
14
|
+
* single key/value pair; the consumer's `queries.setSetting` is the source of
|
|
15
|
+
* truth for serialization (JSON stringification happens inside the db layer).
|
|
16
|
+
*
|
|
17
|
+
* Per-key validation is intentionally not done here — settings are
|
|
18
|
+
* heterogeneous and the admin form for each setting validates before
|
|
19
|
+
* submitting.
|
|
20
|
+
*/
|
|
21
|
+
export function createAdminSettingsHandlers(
|
|
22
|
+
deps: AdminDeps,
|
|
23
|
+
): { GET: RouteHandler; PUT: RouteHandler } {
|
|
24
|
+
return {
|
|
25
|
+
GET: async (req) => {
|
|
26
|
+
if (!(await isAdminAuthenticated(deps.adminSessionSecret))) {
|
|
27
|
+
return unauthorized();
|
|
28
|
+
}
|
|
29
|
+
const url = new URL(req.url);
|
|
30
|
+
const key = url.searchParams.get("key");
|
|
31
|
+
if (key) {
|
|
32
|
+
const value = await deps.queries.getSetting(key);
|
|
33
|
+
return json({ key, value });
|
|
34
|
+
}
|
|
35
|
+
return json({});
|
|
36
|
+
},
|
|
37
|
+
PUT: async (req) => {
|
|
38
|
+
if (!(await isAdminAuthenticated(deps.adminSessionSecret))) {
|
|
39
|
+
return unauthorized();
|
|
40
|
+
}
|
|
41
|
+
let body: SettingPayload;
|
|
42
|
+
try {
|
|
43
|
+
body = (await req.json()) as SettingPayload;
|
|
44
|
+
} catch {
|
|
45
|
+
return badRequest("Invalid JSON");
|
|
46
|
+
}
|
|
47
|
+
if (typeof body.key !== "string" || body.key.length === 0) {
|
|
48
|
+
return badRequest("key is required");
|
|
49
|
+
}
|
|
50
|
+
await deps.queries.setSetting(body.key, body.value);
|
|
51
|
+
return json({ ok: true });
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { writeFile, readFile, unlink, stat } from "node:fs/promises";
|
|
5
|
+
import { createReadStream } from "node:fs";
|
|
6
|
+
import type { AdminDeps, RouteHandler } from "../lib/types";
|
|
7
|
+
import { badRequest, json, serverError, unauthorized } from "../lib/responses";
|
|
8
|
+
import { isAdminAuthenticated } from "../lib/session";
|
|
9
|
+
|
|
10
|
+
// Path segments allow the punctuation common in real-world track filenames
|
|
11
|
+
// alongside alphanumerics, dashes, dots, underscores, spaces, parens, and
|
|
12
|
+
// slashes. Must start with a known top-level prefix and end with an allowed
|
|
13
|
+
// extension (case-insensitive). Path traversal is blocked separately.
|
|
14
|
+
const KEY_RE =
|
|
15
|
+
/^(audio|images|uploads)\/[A-Za-z0-9_.\-() /'&,!+[\]=]+\.(wav|mp3|jpg|jpeg|png|webp)$/i;
|
|
16
|
+
|
|
17
|
+
const ALLOWED_CONTENT_TYPES = new Set([
|
|
18
|
+
"audio/wav",
|
|
19
|
+
"audio/x-wav",
|
|
20
|
+
"audio/mpeg",
|
|
21
|
+
"image/jpeg",
|
|
22
|
+
"image/png",
|
|
23
|
+
"image/webp",
|
|
24
|
+
// Some browsers serve .wav as octet-stream when the OS has no handler.
|
|
25
|
+
"application/octet-stream",
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
interface ProcessBody {
|
|
29
|
+
key?: unknown;
|
|
30
|
+
trackId?: unknown;
|
|
31
|
+
metadata?: unknown;
|
|
32
|
+
artDataUrl?: unknown;
|
|
33
|
+
coverImageUrl?: unknown;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Build POST /api/admin/upload/presign. */
|
|
37
|
+
export function createAdminUploadPresignHandler(deps: AdminDeps): RouteHandler {
|
|
38
|
+
return async (req) => {
|
|
39
|
+
if (!(await isAdminAuthenticated(deps.adminSessionSecret))) {
|
|
40
|
+
return unauthorized();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const body = (await safeJson(req)) as {
|
|
44
|
+
key?: unknown;
|
|
45
|
+
contentType?: unknown;
|
|
46
|
+
};
|
|
47
|
+
const key = typeof body.key === "string" ? body.key : "";
|
|
48
|
+
const contentType =
|
|
49
|
+
typeof body.contentType === "string" ? body.contentType : "";
|
|
50
|
+
|
|
51
|
+
if (!key || !contentType) {
|
|
52
|
+
return badRequest("key and contentType are required");
|
|
53
|
+
}
|
|
54
|
+
if (key.includes("..") || !KEY_RE.test(key)) {
|
|
55
|
+
return badRequest(
|
|
56
|
+
"Invalid key — must be under audio/, images/, or uploads/ with an allowed extension",
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
if (!ALLOWED_CONTENT_TYPES.has(contentType)) {
|
|
60
|
+
return badRequest(`Unsupported contentType: ${contentType}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const result = await deps.storage.getPresignedUploadUrl(key, { contentType });
|
|
64
|
+
return json(result);
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface AudioMetadata {
|
|
69
|
+
title?: string;
|
|
70
|
+
artist?: string;
|
|
71
|
+
album?: string;
|
|
72
|
+
genre?: string;
|
|
73
|
+
trackNumber?: number;
|
|
74
|
+
trackTotal?: number;
|
|
75
|
+
year?: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Coerce arbitrary client input into a clean metadata object. String fields
|
|
80
|
+
* are trimmed: stray whitespace embeds into the file and trips up players
|
|
81
|
+
* like Apple Music that treat otherwise-identical tracks as separate albums.
|
|
82
|
+
*/
|
|
83
|
+
function parseMetadata(raw: unknown): AudioMetadata | undefined {
|
|
84
|
+
if (!raw || typeof raw !== "object") return undefined;
|
|
85
|
+
const r = raw as Record<string, unknown>;
|
|
86
|
+
const out: AudioMetadata = {};
|
|
87
|
+
if (typeof r.title === "string" && r.title.trim()) out.title = r.title.trim();
|
|
88
|
+
if (typeof r.artist === "string" && r.artist.trim()) out.artist = r.artist.trim();
|
|
89
|
+
if (typeof r.album === "string" && r.album.trim()) out.album = r.album.trim();
|
|
90
|
+
if (typeof r.genre === "string" && r.genre.trim()) out.genre = r.genre.trim();
|
|
91
|
+
if (typeof r.trackNumber === "number") out.trackNumber = r.trackNumber;
|
|
92
|
+
if (typeof r.trackTotal === "number") out.trackTotal = r.trackTotal;
|
|
93
|
+
if (typeof r.year === "number") out.year = r.year;
|
|
94
|
+
return Object.keys(out).length ? out : undefined;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Decode a `data:<mime>;base64,<data>` URL into a Buffer, or null. */
|
|
98
|
+
function bufferFromDataUrl(value: unknown): Buffer | null {
|
|
99
|
+
if (typeof value !== "string") return null;
|
|
100
|
+
const match = /^data:[^;,]*;base64,([A-Za-z0-9+/=]+)$/.exec(value);
|
|
101
|
+
if (!match) return null;
|
|
102
|
+
try {
|
|
103
|
+
return Buffer.from(match[1] ?? "", "base64");
|
|
104
|
+
} catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Build POST /api/admin/upload/process. After the client direct-uploads a
|
|
111
|
+
* WAV to R2 via the presigned URL, this handler:
|
|
112
|
+
* 1. fetches the WAV stream out of storage
|
|
113
|
+
* 2. transcodes to 320kbps MP3 and embeds ID3 tags (via `@gigamusic/audio`)
|
|
114
|
+
* 3. uploads the MP3 back to storage at the sibling `.mp3` key
|
|
115
|
+
* 4. tags the original WAV in place
|
|
116
|
+
* 5. records both files against `trackId` via `queries.upsertTrackFile`
|
|
117
|
+
* (if `trackId` is supplied; new releases call this without an id
|
|
118
|
+
* because the track row isn't created yet — the release POST handler
|
|
119
|
+
* persists the files alongside the row).
|
|
120
|
+
*/
|
|
121
|
+
export function createAdminUploadProcessHandler(deps: AdminDeps): RouteHandler {
|
|
122
|
+
return async (req) => {
|
|
123
|
+
if (!(await isAdminAuthenticated(deps.adminSessionSecret))) {
|
|
124
|
+
return unauthorized();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const body = (await safeJson(req)) as ProcessBody;
|
|
128
|
+
const key = typeof body.key === "string" ? body.key : "";
|
|
129
|
+
if (!key.endsWith(".wav")) {
|
|
130
|
+
return badRequest("A .wav storage key is required");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const metadata = parseMetadata(body.metadata);
|
|
134
|
+
const mp3Key = key.replace(/\.wav$/i, ".mp3");
|
|
135
|
+
const trackId =
|
|
136
|
+
typeof body.trackId === "number" && Number.isFinite(body.trackId)
|
|
137
|
+
? body.trackId
|
|
138
|
+
: null;
|
|
139
|
+
|
|
140
|
+
let artBuffer = bufferFromDataUrl(body.artDataUrl);
|
|
141
|
+
if (
|
|
142
|
+
!artBuffer &&
|
|
143
|
+
typeof body.coverImageUrl === "string" &&
|
|
144
|
+
body.coverImageUrl
|
|
145
|
+
) {
|
|
146
|
+
try {
|
|
147
|
+
artBuffer = await deps.storage.getFileBuffer(
|
|
148
|
+
deps.storage.keyFromPublicUrl(body.coverImageUrl),
|
|
149
|
+
);
|
|
150
|
+
} catch (err) {
|
|
151
|
+
deps.logger?.warn("cover-image fetch failed; proceeding without art", {
|
|
152
|
+
error: String(err),
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const tmpId = randomUUID();
|
|
158
|
+
const wavPath = join(tmpdir(), `${tmpId}.wav`);
|
|
159
|
+
const taggedWavPath = join(tmpdir(), `${tmpId}.tagged.wav`);
|
|
160
|
+
const mp3Path = join(tmpdir(), `${tmpId}.mp3`);
|
|
161
|
+
const coverPath = artBuffer ? join(tmpdir(), `${tmpId}.img`) : undefined;
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
if (artBuffer && coverPath) await writeFile(coverPath, artBuffer);
|
|
165
|
+
|
|
166
|
+
// 1. Pull the WAV down so the tag step can rewrite it in place.
|
|
167
|
+
const wavBuf = await deps.storage.getFileBuffer(key);
|
|
168
|
+
await writeFile(wavPath, wavBuf);
|
|
169
|
+
|
|
170
|
+
const tags = metadataToTags(metadata);
|
|
171
|
+
|
|
172
|
+
// 2. Tag the WAV.
|
|
173
|
+
await deps.audio.tagWav({
|
|
174
|
+
inputPath: wavPath,
|
|
175
|
+
outputPath: taggedWavPath,
|
|
176
|
+
tags,
|
|
177
|
+
});
|
|
178
|
+
await deps.storage.uploadBuffer(
|
|
179
|
+
await readFile(taggedWavPath),
|
|
180
|
+
key,
|
|
181
|
+
"audio/wav",
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
// 3. Generate 320kbps MP3 from the tagged WAV.
|
|
185
|
+
// We piggy-back on `generatePreview` for the ffmpeg call but request a
|
|
186
|
+
// ~24h duration so the full track is encoded (ffmpeg stops at EOF). The
|
|
187
|
+
// post-tag step then attaches ID3 frames + artwork — `generatePreview`
|
|
188
|
+
// intentionally strips metadata for the public 30s preview case.
|
|
189
|
+
await deps.audio.generatePreview({
|
|
190
|
+
inputPath: taggedWavPath,
|
|
191
|
+
outputPath: mp3Path,
|
|
192
|
+
startSeconds: 0,
|
|
193
|
+
durationSeconds: 24 * 60 * 60,
|
|
194
|
+
bitrateKbps: 320,
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
await deps.audio.tagMp3({
|
|
198
|
+
inputPath: mp3Path,
|
|
199
|
+
outputPath: mp3Path,
|
|
200
|
+
tags,
|
|
201
|
+
coverArt: artBuffer ?? undefined,
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const mp3Stream = createReadStream(mp3Path);
|
|
205
|
+
const mp3Upload = await deps.storage.uploadStream(
|
|
206
|
+
mp3Stream,
|
|
207
|
+
mp3Key,
|
|
208
|
+
"audio/mpeg",
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
let wavFileSize = wavBuf.length;
|
|
212
|
+
try {
|
|
213
|
+
wavFileSize = (await stat(taggedWavPath)).size;
|
|
214
|
+
} catch {
|
|
215
|
+
// Best-effort; fall back to the pre-tag buffer length.
|
|
216
|
+
}
|
|
217
|
+
let mp3FileSize = 0;
|
|
218
|
+
try {
|
|
219
|
+
mp3FileSize = (await stat(mp3Path)).size;
|
|
220
|
+
} catch {
|
|
221
|
+
// Best-effort.
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// 4. Persist file rows when the consumer supplied a trackId.
|
|
225
|
+
if (trackId != null) {
|
|
226
|
+
await deps.queries.upsertTrackFile({
|
|
227
|
+
trackId,
|
|
228
|
+
format: "wav",
|
|
229
|
+
fileName: basename(key),
|
|
230
|
+
storageKey: deps.storage.publicUrlFromKey(key),
|
|
231
|
+
fileSize: wavFileSize,
|
|
232
|
+
});
|
|
233
|
+
await deps.queries.upsertTrackFile({
|
|
234
|
+
trackId,
|
|
235
|
+
format: "mp3",
|
|
236
|
+
fileName: basename(mp3Key),
|
|
237
|
+
storageKey: mp3Upload.url,
|
|
238
|
+
fileSize: mp3FileSize,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return json({
|
|
243
|
+
wavUrl: deps.storage.publicUrlFromKey(key),
|
|
244
|
+
mp3Url: mp3Upload.url,
|
|
245
|
+
wavFileSize,
|
|
246
|
+
mp3FileSize,
|
|
247
|
+
});
|
|
248
|
+
} catch (err) {
|
|
249
|
+
deps.logger?.error("upload/process failed", { error: String(err) });
|
|
250
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
251
|
+
return serverError("Transcoding failed", { mp3Error: message });
|
|
252
|
+
} finally {
|
|
253
|
+
await Promise.allSettled([
|
|
254
|
+
unlink(wavPath),
|
|
255
|
+
unlink(taggedWavPath),
|
|
256
|
+
unlink(mp3Path),
|
|
257
|
+
coverPath ? unlink(coverPath) : Promise.resolve(),
|
|
258
|
+
]);
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function basename(key: string): string {
|
|
264
|
+
const slash = key.lastIndexOf("/");
|
|
265
|
+
return slash < 0 ? key : key.slice(slash + 1);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function metadataToTags(meta: AudioMetadata | undefined) {
|
|
269
|
+
return {
|
|
270
|
+
title: meta?.title ?? "",
|
|
271
|
+
artist: meta?.artist ?? "",
|
|
272
|
+
album: meta?.album,
|
|
273
|
+
genre: meta?.genre,
|
|
274
|
+
trackNumber: meta?.trackNumber,
|
|
275
|
+
trackTotal: meta?.trackTotal,
|
|
276
|
+
date: meta?.year != null ? String(meta.year) : undefined,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async function safeJson(req: Request): Promise<unknown> {
|
|
281
|
+
try {
|
|
282
|
+
return await req.json();
|
|
283
|
+
} catch {
|
|
284
|
+
return {};
|
|
285
|
+
}
|
|
286
|
+
}
|