@nexpress/core 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/LICENSE +21 -0
- package/README.md +69 -0
- package/dist/audit-54XLVCWD.js +14 -0
- package/dist/audit-54XLVCWD.js.map +1 -0
- package/dist/auth.d.ts +640 -0
- package/dist/auth.js +94 -0
- package/dist/auth.js.map +1 -0
- package/dist/can-YLUHRJAB.js +19 -0
- package/dist/can-YLUHRJAB.js.map +1 -0
- package/dist/chunk-2G264RCD.js +68 -0
- package/dist/chunk-2G264RCD.js.map +1 -0
- package/dist/chunk-2YDGE7YX.js +92 -0
- package/dist/chunk-2YDGE7YX.js.map +1 -0
- package/dist/chunk-473S4TER.js +538 -0
- package/dist/chunk-473S4TER.js.map +1 -0
- package/dist/chunk-4ZLMEKFX.js +18 -0
- package/dist/chunk-4ZLMEKFX.js.map +1 -0
- package/dist/chunk-55FU6WED.js +179 -0
- package/dist/chunk-55FU6WED.js.map +1 -0
- package/dist/chunk-6YI5K2TI.js +1959 -0
- package/dist/chunk-6YI5K2TI.js.map +1 -0
- package/dist/chunk-BHK3AD3Q.js +41 -0
- package/dist/chunk-BHK3AD3Q.js.map +1 -0
- package/dist/chunk-CRUQBZUF.js +39 -0
- package/dist/chunk-CRUQBZUF.js.map +1 -0
- package/dist/chunk-CTSQ7BRI.js +175 -0
- package/dist/chunk-CTSQ7BRI.js.map +1 -0
- package/dist/chunk-DK2JBJH7.js +81 -0
- package/dist/chunk-DK2JBJH7.js.map +1 -0
- package/dist/chunk-DP2PREDU.js +597 -0
- package/dist/chunk-DP2PREDU.js.map +1 -0
- package/dist/chunk-EQ2Z3KMD.js +24 -0
- package/dist/chunk-EQ2Z3KMD.js.map +1 -0
- package/dist/chunk-FZ7O6DWI.js +305 -0
- package/dist/chunk-FZ7O6DWI.js.map +1 -0
- package/dist/chunk-ISLYFQWL.js +1270 -0
- package/dist/chunk-ISLYFQWL.js.map +1 -0
- package/dist/chunk-JJL74ZPK.js +68 -0
- package/dist/chunk-JJL74ZPK.js.map +1 -0
- package/dist/chunk-JKXAPSU4.js +24 -0
- package/dist/chunk-JKXAPSU4.js.map +1 -0
- package/dist/chunk-KU5M27ZC.js +24 -0
- package/dist/chunk-KU5M27ZC.js.map +1 -0
- package/dist/chunk-LSHHRDVR.js +34 -0
- package/dist/chunk-LSHHRDVR.js.map +1 -0
- package/dist/chunk-M43PGOQY.js +715 -0
- package/dist/chunk-M43PGOQY.js.map +1 -0
- package/dist/chunk-MEJAHXIO.js +150 -0
- package/dist/chunk-MEJAHXIO.js.map +1 -0
- package/dist/chunk-NUCGHWCF.js +101 -0
- package/dist/chunk-NUCGHWCF.js.map +1 -0
- package/dist/chunk-OK5HOCQI.js +845 -0
- package/dist/chunk-OK5HOCQI.js.map +1 -0
- package/dist/chunk-OROPGO65.js +13 -0
- package/dist/chunk-OROPGO65.js.map +1 -0
- package/dist/chunk-PPAS4SZR.js +176 -0
- package/dist/chunk-PPAS4SZR.js.map +1 -0
- package/dist/chunk-PPBWRKO2.js +171 -0
- package/dist/chunk-PPBWRKO2.js.map +1 -0
- package/dist/chunk-PZ5AY32C.js +10 -0
- package/dist/chunk-PZ5AY32C.js.map +1 -0
- package/dist/chunk-QO7LAQZH.js +321 -0
- package/dist/chunk-QO7LAQZH.js.map +1 -0
- package/dist/chunk-QVJ2HCAX.js +225 -0
- package/dist/chunk-QVJ2HCAX.js.map +1 -0
- package/dist/chunk-RIPHIRPP.js +68 -0
- package/dist/chunk-RIPHIRPP.js.map +1 -0
- package/dist/chunk-S27S42QY.js +134 -0
- package/dist/chunk-S27S42QY.js.map +1 -0
- package/dist/chunk-SBCVAC2Z.js +40 -0
- package/dist/chunk-SBCVAC2Z.js.map +1 -0
- package/dist/chunk-TFJ4MKPH.js +694 -0
- package/dist/chunk-TFJ4MKPH.js.map +1 -0
- package/dist/chunk-THX3SHYA.js +75 -0
- package/dist/chunk-THX3SHYA.js.map +1 -0
- package/dist/chunk-UGQSQO5B.js +222 -0
- package/dist/chunk-UGQSQO5B.js.map +1 -0
- package/dist/chunk-V2UNHGAP.js +26 -0
- package/dist/chunk-V2UNHGAP.js.map +1 -0
- package/dist/chunk-VGTPQXNQ.js +2790 -0
- package/dist/chunk-VGTPQXNQ.js.map +1 -0
- package/dist/chunk-VNIHXQ7W.js +194 -0
- package/dist/chunk-VNIHXQ7W.js.map +1 -0
- package/dist/chunk-WV272MPW.js +31 -0
- package/dist/chunk-WV272MPW.js.map +1 -0
- package/dist/chunk-X5KKBOUS.js +26 -0
- package/dist/chunk-X5KKBOUS.js.map +1 -0
- package/dist/chunk-XANPEOJC.js +17 -0
- package/dist/chunk-XANPEOJC.js.map +1 -0
- package/dist/chunk-XPVQIHAQ.js +83 -0
- package/dist/chunk-XPVQIHAQ.js.map +1 -0
- package/dist/chunk-ZCINJSS4.js +75 -0
- package/dist/chunk-ZCINJSS4.js.map +1 -0
- package/dist/community.d.ts +1425 -0
- package/dist/community.js +206 -0
- package/dist/community.js.map +1 -0
- package/dist/config-2GDU7PCK.js +32 -0
- package/dist/config-2GDU7PCK.js.map +1 -0
- package/dist/context-MNZ4QXPC.js +16 -0
- package/dist/context-MNZ4QXPC.js.map +1 -0
- package/dist/db-schema.d.ts +4 -0
- package/dist/db-schema.js +102 -0
- package/dist/db-schema.js.map +1 -0
- package/dist/db.d.ts +7 -0
- package/dist/db.js +117 -0
- package/dist/db.js.map +1 -0
- package/dist/digest-SY42GQSU.js +17 -0
- package/dist/digest-SY42GQSU.js.map +1 -0
- package/dist/errors-5OS3S2J3.js +22 -0
- package/dist/errors-5OS3S2J3.js.map +1 -0
- package/dist/host-OBOI4MJK.js +51 -0
- package/dist/host-OBOI4MJK.js.map +1 -0
- package/dist/i18n.d.ts +301 -0
- package/dist/i18n.js +68 -0
- package/dist/i18n.js.map +1 -0
- package/dist/index-B6-_vr_m.d.ts +590 -0
- package/dist/index-CY55LC0u.d.ts +4722 -0
- package/dist/index-CeiTvwbp.d.ts +168 -0
- package/dist/index-XwP1ET8b.d.ts +61 -0
- package/dist/index.d.ts +2037 -0
- package/dist/index.js +2205 -0
- package/dist/index.js.map +1 -0
- package/dist/job-log-VZXWQUDK.js +24 -0
- package/dist/job-log-VZXWQUDK.js.map +1 -0
- package/dist/jobs.d.ts +4 -0
- package/dist/jobs.js +76 -0
- package/dist/jobs.js.map +1 -0
- package/dist/logger-DqGaOU_j.d.ts +29 -0
- package/dist/logger-S7REWDNE.js +16 -0
- package/dist/logger-S7REWDNE.js.map +1 -0
- package/dist/media.d.ts +5 -0
- package/dist/media.js +41 -0
- package/dist/media.js.map +1 -0
- package/dist/mentions-2IHFVSHW.js +23 -0
- package/dist/mentions-2IHFVSHW.js.map +1 -0
- package/dist/mutes-EWAE5FZR.js +21 -0
- package/dist/mutes-EWAE5FZR.js.map +1 -0
- package/dist/notification-prefs-VPJDU7I6.js +21 -0
- package/dist/notification-prefs-VPJDU7I6.js.map +1 -0
- package/dist/observability.d.ts +156 -0
- package/dist/observability.js +32 -0
- package/dist/observability.js.map +1 -0
- package/dist/profanity-adapter-NU2JQSLX.js +12 -0
- package/dist/profanity-adapter-NU2JQSLX.js.map +1 -0
- package/dist/queue-XE5BC75T.js +14 -0
- package/dist/queue-XE5BC75T.js.map +1 -0
- package/dist/rate-limit.d.ts +99 -0
- package/dist/rate-limit.js +14 -0
- package/dist/rate-limit.js.map +1 -0
- package/dist/registry-XIXDEPVI.js +31 -0
- package/dist/registry-XIXDEPVI.js.map +1 -0
- package/dist/reputation-JRL2YQHM.js +11 -0
- package/dist/reputation-JRL2YQHM.js.map +1 -0
- package/dist/routes.d.ts +43 -0
- package/dist/routes.js +12 -0
- package/dist/routes.js.map +1 -0
- package/dist/scheduled-CIQM57HT.js +20 -0
- package/dist/scheduled-CIQM57HT.js.map +1 -0
- package/dist/seo.d.ts +410 -0
- package/dist/seo.js +44 -0
- package/dist/seo.js.map +1 -0
- package/dist/settings-FOBIESPB.js +17 -0
- package/dist/settings-FOBIESPB.js.map +1 -0
- package/dist/spam-adapter-XX3G737Z.js +12 -0
- package/dist/spam-adapter-XX3G737Z.js.map +1 -0
- package/dist/strings-VAE47B2C.js +29 -0
- package/dist/strings-VAE47B2C.js.map +1 -0
- package/dist/templates-IFVJMCJ6.js +12 -0
- package/dist/templates-IFVJMCJ6.js.map +1 -0
- package/dist/types-TlsbXS0T.d.ts +871 -0
- package/package.json +129 -0
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
import {
|
|
2
|
+
enqueueJob
|
|
3
|
+
} from "./chunk-V2UNHGAP.js";
|
|
4
|
+
import {
|
|
5
|
+
readEnvPositiveInt
|
|
6
|
+
} from "./chunk-OROPGO65.js";
|
|
7
|
+
import {
|
|
8
|
+
getLogger
|
|
9
|
+
} from "./chunk-JJL74ZPK.js";
|
|
10
|
+
import {
|
|
11
|
+
getDb
|
|
12
|
+
} from "./chunk-XANPEOJC.js";
|
|
13
|
+
import {
|
|
14
|
+
npMedia,
|
|
15
|
+
npMediaRefs,
|
|
16
|
+
npMembers,
|
|
17
|
+
npUsers
|
|
18
|
+
} from "./chunk-M43PGOQY.js";
|
|
19
|
+
|
|
20
|
+
// src/media/processor.ts
|
|
21
|
+
import sharp from "sharp";
|
|
22
|
+
var DEFAULT_IMAGE_SIZES = [
|
|
23
|
+
{ name: "thumbnail", width: 300 },
|
|
24
|
+
{ name: "small", width: 600 },
|
|
25
|
+
{ name: "medium", width: 900 },
|
|
26
|
+
{ name: "large", width: 1400 },
|
|
27
|
+
{ name: "xlarge", width: 1920 },
|
|
28
|
+
{ name: "og", width: 1200, height: 630, crop: "center" }
|
|
29
|
+
];
|
|
30
|
+
async function processImage(inputBuffer, sizes, options = {}) {
|
|
31
|
+
const format = options.format ?? "webp";
|
|
32
|
+
const quality = options.quality ?? 80;
|
|
33
|
+
const sourceImage = sharp(inputBuffer).autoOrient();
|
|
34
|
+
const metadata = await sourceImage.metadata();
|
|
35
|
+
const variants = await Promise.all(
|
|
36
|
+
sizes.map(async (size) => {
|
|
37
|
+
const resized = size.height ? sourceImage.clone().resize({
|
|
38
|
+
width: size.width,
|
|
39
|
+
height: size.height,
|
|
40
|
+
fit: "cover",
|
|
41
|
+
position: resolveCropPosition(size.crop)
|
|
42
|
+
}) : sourceImage.clone().resize({
|
|
43
|
+
width: size.width,
|
|
44
|
+
fit: "inside",
|
|
45
|
+
withoutEnlargement: true
|
|
46
|
+
});
|
|
47
|
+
const formatted = applyFormat(resized, format, quality);
|
|
48
|
+
const { data, info } = await formatted.toBuffer({ resolveWithObject: true });
|
|
49
|
+
return {
|
|
50
|
+
name: size.name,
|
|
51
|
+
buffer: data,
|
|
52
|
+
width: info.width,
|
|
53
|
+
height: info.height,
|
|
54
|
+
size: info.size ?? data.byteLength
|
|
55
|
+
};
|
|
56
|
+
})
|
|
57
|
+
);
|
|
58
|
+
return {
|
|
59
|
+
source: {
|
|
60
|
+
width: metadata.width ?? null,
|
|
61
|
+
height: metadata.height ?? null,
|
|
62
|
+
format: metadata.format ?? null
|
|
63
|
+
},
|
|
64
|
+
variants
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function applyFormat(image, format, quality) {
|
|
68
|
+
switch (format) {
|
|
69
|
+
case "avif":
|
|
70
|
+
return image.avif({ quality });
|
|
71
|
+
case "jpeg":
|
|
72
|
+
return image.jpeg({ quality });
|
|
73
|
+
case "png":
|
|
74
|
+
return image.png({ quality });
|
|
75
|
+
case "webp":
|
|
76
|
+
default:
|
|
77
|
+
return image.webp({ quality });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function resolveCropPosition(crop) {
|
|
81
|
+
switch (crop) {
|
|
82
|
+
case "top":
|
|
83
|
+
return "top";
|
|
84
|
+
case "bottom":
|
|
85
|
+
return "bottom";
|
|
86
|
+
case "left":
|
|
87
|
+
return "left";
|
|
88
|
+
case "right":
|
|
89
|
+
return "right";
|
|
90
|
+
case "center":
|
|
91
|
+
return "centre";
|
|
92
|
+
default:
|
|
93
|
+
return sharp.strategy.attention;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// src/media/service.ts
|
|
98
|
+
import { createHash, randomUUID } from "crypto";
|
|
99
|
+
import { extname } from "path";
|
|
100
|
+
import { buffer as consumeBuffer } from "stream/consumers";
|
|
101
|
+
import { Readable } from "stream";
|
|
102
|
+
import { and, count, desc, eq, gte, ilike, inArray, isNotNull, isNull, lt, or, sql } from "drizzle-orm";
|
|
103
|
+
var MEMBER_QUOTA_WINDOW_MS = readEnvPositiveInt("NP_MEMBER_QUOTA_WINDOW_HOURS", 24) * 60 * 60 * 1e3;
|
|
104
|
+
var storageAdapter = null;
|
|
105
|
+
function setStorageAdapter(adapter) {
|
|
106
|
+
storageAdapter = adapter;
|
|
107
|
+
}
|
|
108
|
+
function getStorageAdapter() {
|
|
109
|
+
if (!storageAdapter) {
|
|
110
|
+
throw new Error("Storage adapter not initialized. Call setStorageAdapter() first.");
|
|
111
|
+
}
|
|
112
|
+
return storageAdapter;
|
|
113
|
+
}
|
|
114
|
+
async function uploadMedia(file, uploader, folderId) {
|
|
115
|
+
const resolvedUploader = typeof uploader === "string" ? { kind: "staff", userId: uploader } : uploader;
|
|
116
|
+
const id = randomUUID();
|
|
117
|
+
const extension = resolveFileExtension(file.originalFilename, file.mimeType);
|
|
118
|
+
const storageKey = `media/${id}/original.${extension}`;
|
|
119
|
+
const now = /* @__PURE__ */ new Date();
|
|
120
|
+
const insertValues = {
|
|
121
|
+
id,
|
|
122
|
+
filename: file.originalFilename,
|
|
123
|
+
originalFilename: file.originalFilename,
|
|
124
|
+
mimeType: file.mimeType,
|
|
125
|
+
filesize: file.buffer.byteLength,
|
|
126
|
+
storageKey,
|
|
127
|
+
hash: createHash("sha256").update(file.buffer).digest("hex"),
|
|
128
|
+
status: "processing",
|
|
129
|
+
folderId,
|
|
130
|
+
uploadedBy: resolvedUploader && resolvedUploader.kind === "staff" ? resolvedUploader.userId : null,
|
|
131
|
+
uploadedByMemberId: resolvedUploader && resolvedUploader.kind === "member" ? resolvedUploader.memberId : null,
|
|
132
|
+
createdAt: now,
|
|
133
|
+
updatedAt: now
|
|
134
|
+
};
|
|
135
|
+
if (resolvedUploader && resolvedUploader.kind === "member") {
|
|
136
|
+
const memberId = resolvedUploader.memberId;
|
|
137
|
+
const dbPg = getDb();
|
|
138
|
+
await dbPg.transaction(async (tx) => {
|
|
139
|
+
await tx.execute(
|
|
140
|
+
sql`SELECT pg_advisory_xact_lock(hashtextextended(${memberId}, 0))`
|
|
141
|
+
);
|
|
142
|
+
await assertMemberUploadQuota(memberId, tx);
|
|
143
|
+
await tx.insert(npMedia).values(insertValues);
|
|
144
|
+
});
|
|
145
|
+
} else {
|
|
146
|
+
const db = getDb();
|
|
147
|
+
await db.insert(npMedia).values(insertValues);
|
|
148
|
+
}
|
|
149
|
+
const adapter = getStorageAdapter();
|
|
150
|
+
try {
|
|
151
|
+
await adapter.upload(storageKey, file.buffer, {
|
|
152
|
+
contentType: file.mimeType,
|
|
153
|
+
contentLength: file.buffer.byteLength,
|
|
154
|
+
originalFilename: file.originalFilename
|
|
155
|
+
});
|
|
156
|
+
} catch (err) {
|
|
157
|
+
try {
|
|
158
|
+
const cleanupDb = getDb();
|
|
159
|
+
await cleanupDb.delete(npMedia).where(eq(npMedia.id, id));
|
|
160
|
+
} catch (cleanupErr) {
|
|
161
|
+
getLogger().error("media upload cleanup failed", {
|
|
162
|
+
mediaId: id,
|
|
163
|
+
storageKey,
|
|
164
|
+
error: cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr)
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
throw err;
|
|
168
|
+
}
|
|
169
|
+
await enqueueJob("media:processImage", { mediaId: id });
|
|
170
|
+
return { id, status: "processing" };
|
|
171
|
+
}
|
|
172
|
+
async function assertMemberUploadQuota(memberId, txDb) {
|
|
173
|
+
const { getCommunitySettings } = await import("./settings-FOBIESPB.js");
|
|
174
|
+
const { NpRateLimitError } = await import("./errors-5OS3S2J3.js");
|
|
175
|
+
const settings = await getCommunitySettings();
|
|
176
|
+
const { perDay, total } = settings.memberUploadQuota;
|
|
177
|
+
if (perDay === null && total === null) return;
|
|
178
|
+
const db = txDb ?? getDb();
|
|
179
|
+
if (total !== null) {
|
|
180
|
+
const [row] = await db.select({ value: count() }).from(npMedia).where(
|
|
181
|
+
and(
|
|
182
|
+
eq(npMedia.uploadedByMemberId, memberId),
|
|
183
|
+
isNull(npMedia.deletedAt)
|
|
184
|
+
)
|
|
185
|
+
);
|
|
186
|
+
const used = row?.value ?? 0;
|
|
187
|
+
if (used >= total) {
|
|
188
|
+
throw new NpRateLimitError(
|
|
189
|
+
`Upload quota exceeded \u2014 this account has reached its lifetime cap of ${total} uploads.`
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (perDay !== null) {
|
|
194
|
+
const since = new Date(Date.now() - MEMBER_QUOTA_WINDOW_MS);
|
|
195
|
+
const [row] = await db.select({ value: count() }).from(npMedia).where(
|
|
196
|
+
and(
|
|
197
|
+
eq(npMedia.uploadedByMemberId, memberId),
|
|
198
|
+
isNull(npMedia.deletedAt),
|
|
199
|
+
gte(npMedia.createdAt, since)
|
|
200
|
+
)
|
|
201
|
+
);
|
|
202
|
+
const recent = row?.value ?? 0;
|
|
203
|
+
if (recent >= perDay) {
|
|
204
|
+
throw new NpRateLimitError(
|
|
205
|
+
`Upload rate limit exceeded \u2014 try again later (max ${perDay} uploads per 24 hours).`
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
async function processMediaImage(mediaId, config) {
|
|
211
|
+
const db = getDb();
|
|
212
|
+
const adapter = getStorageAdapter();
|
|
213
|
+
const media = await getMediaRecordById(mediaId);
|
|
214
|
+
if (!media) {
|
|
215
|
+
throw new Error(`Media '${mediaId}' not found.`);
|
|
216
|
+
}
|
|
217
|
+
try {
|
|
218
|
+
const originalStream = await adapter.getStream(media.storageKey);
|
|
219
|
+
const originalBuffer = await consumeBuffer(Readable.fromWeb(originalStream));
|
|
220
|
+
const processed = await processImage(
|
|
221
|
+
originalBuffer,
|
|
222
|
+
config.sizes ?? DEFAULT_IMAGE_SIZES,
|
|
223
|
+
{ format: config.format, quality: config.quality }
|
|
224
|
+
);
|
|
225
|
+
const format = config.format ?? "webp";
|
|
226
|
+
const mimeType = getFormatMimeType(format);
|
|
227
|
+
const sizes = await uploadImageVariants(adapter, media.id, processed, format, mimeType);
|
|
228
|
+
await db.update(npMedia).set({
|
|
229
|
+
sizes,
|
|
230
|
+
width: processed.source.width,
|
|
231
|
+
height: processed.source.height,
|
|
232
|
+
status: "ready",
|
|
233
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
234
|
+
}).where(eq(npMedia.id, media.id)).returning();
|
|
235
|
+
} catch (error) {
|
|
236
|
+
await db.update(npMedia).set({
|
|
237
|
+
status: "error",
|
|
238
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
239
|
+
}).where(eq(npMedia.id, media.id)).returning();
|
|
240
|
+
throw error;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
async function getMediaById(id) {
|
|
244
|
+
const db = getDb();
|
|
245
|
+
const [media] = await db.select().from(npMedia).where(and(eq(npMedia.id, id), isNull(npMedia.deletedAt))).limit(1);
|
|
246
|
+
return media ? toRecord(media) : null;
|
|
247
|
+
}
|
|
248
|
+
async function deleteMedia(id) {
|
|
249
|
+
const db = getDb();
|
|
250
|
+
const references = await db.select().from(npMediaRefs).where(eq(npMediaRefs.mediaId, id));
|
|
251
|
+
if (references.length > 0) {
|
|
252
|
+
return { deleted: false, references };
|
|
253
|
+
}
|
|
254
|
+
const [media] = await db.select().from(npMedia).where(and(eq(npMedia.id, id), isNull(npMedia.deletedAt))).limit(1);
|
|
255
|
+
if (!media) {
|
|
256
|
+
return { deleted: false };
|
|
257
|
+
}
|
|
258
|
+
await db.update(npMedia).set({
|
|
259
|
+
deletedAt: /* @__PURE__ */ new Date(),
|
|
260
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
261
|
+
}).where(eq(npMedia.id, id)).returning();
|
|
262
|
+
return { deleted: true };
|
|
263
|
+
}
|
|
264
|
+
async function listMedia(options) {
|
|
265
|
+
const db = getDb();
|
|
266
|
+
const page = normalizePage(options.page);
|
|
267
|
+
const limit = normalizeLimit(options.limit);
|
|
268
|
+
const offset = (page - 1) * limit;
|
|
269
|
+
const conditions = [isNull(npMedia.deletedAt)];
|
|
270
|
+
if (options.folderId) {
|
|
271
|
+
conditions.push(eq(npMedia.folderId, options.folderId));
|
|
272
|
+
}
|
|
273
|
+
if (options.mimeType) {
|
|
274
|
+
conditions.push(eq(npMedia.mimeType, options.mimeType));
|
|
275
|
+
}
|
|
276
|
+
if (options.uploaderKind === "staff") {
|
|
277
|
+
conditions.push(isNotNull(npMedia.uploadedBy));
|
|
278
|
+
} else if (options.uploaderKind === "member") {
|
|
279
|
+
conditions.push(isNotNull(npMedia.uploadedByMemberId));
|
|
280
|
+
}
|
|
281
|
+
if (options.uploadedByMemberId) {
|
|
282
|
+
conditions.push(eq(npMedia.uploadedByMemberId, options.uploadedByMemberId));
|
|
283
|
+
}
|
|
284
|
+
if (options.q && options.q.trim().length > 0) {
|
|
285
|
+
const needle = `%${options.q.trim().replace(/[%_]/g, (c) => `\\${c}`)}%`;
|
|
286
|
+
const search = or(
|
|
287
|
+
ilike(npMedia.filename, needle),
|
|
288
|
+
ilike(npMedia.alt, needle)
|
|
289
|
+
);
|
|
290
|
+
if (search) conditions.push(search);
|
|
291
|
+
}
|
|
292
|
+
const whereClause = combineConditions(conditions);
|
|
293
|
+
const joined = db.select({
|
|
294
|
+
media: npMedia,
|
|
295
|
+
userName: npUsers.name,
|
|
296
|
+
userEmail: npUsers.email,
|
|
297
|
+
memberHandle: npMembers.handle,
|
|
298
|
+
memberDisplayName: npMembers.displayName
|
|
299
|
+
}).from(npMedia).leftJoin(npUsers, eq(npMedia.uploadedBy, npUsers.id)).leftJoin(npMembers, eq(npMedia.uploadedByMemberId, npMembers.id)).where(whereClause).orderBy(desc(npMedia.createdAt)).limit(limit).offset(offset);
|
|
300
|
+
const rows = await joined;
|
|
301
|
+
const [{ total }] = whereClause ? await db.select({ total: count() }).from(npMedia).where(whereClause) : await db.select({ total: count() }).from(npMedia);
|
|
302
|
+
const totalDocs = Number(total ?? 0);
|
|
303
|
+
const totalPages = totalDocs === 0 ? 0 : Math.ceil(totalDocs / limit);
|
|
304
|
+
const docs = rows.map((row) => ({
|
|
305
|
+
...row.media,
|
|
306
|
+
uploader: row.userName !== null ? {
|
|
307
|
+
kind: "staff",
|
|
308
|
+
name: row.userName,
|
|
309
|
+
email: row.userEmail
|
|
310
|
+
} : row.memberHandle !== null ? {
|
|
311
|
+
kind: "member",
|
|
312
|
+
handle: row.memberHandle,
|
|
313
|
+
displayName: row.memberDisplayName
|
|
314
|
+
} : null
|
|
315
|
+
}));
|
|
316
|
+
return {
|
|
317
|
+
docs,
|
|
318
|
+
totalDocs,
|
|
319
|
+
totalPages,
|
|
320
|
+
page,
|
|
321
|
+
limit,
|
|
322
|
+
hasNextPage: page < totalPages,
|
|
323
|
+
hasPrevPage: page > 1 && totalDocs > 0
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
async function cleanupDeletedMedia(olderThanDays) {
|
|
327
|
+
const db = getDb();
|
|
328
|
+
const adapter = getStorageAdapter();
|
|
329
|
+
const threshold = new Date(Date.now() - olderThanDays * 24 * 60 * 60 * 1e3);
|
|
330
|
+
const rows = await db.select().from(npMedia).where(and(isNotNull(npMedia.deletedAt), lt(npMedia.deletedAt, threshold)));
|
|
331
|
+
const mediaRows = rows.map(toMediaRecord);
|
|
332
|
+
if (mediaRows.length === 0) {
|
|
333
|
+
return 0;
|
|
334
|
+
}
|
|
335
|
+
for (const media of mediaRows) {
|
|
336
|
+
const keys = /* @__PURE__ */ new Set([
|
|
337
|
+
media.storageKey,
|
|
338
|
+
...extractVariantStorageKeys(media.sizes)
|
|
339
|
+
]);
|
|
340
|
+
for (const key of keys) {
|
|
341
|
+
try {
|
|
342
|
+
await adapter.delete(key);
|
|
343
|
+
} catch {
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
await db.delete(npMedia).where(inArray(npMedia.id, mediaRows.map((media) => media.id)));
|
|
349
|
+
return mediaRows.length;
|
|
350
|
+
}
|
|
351
|
+
async function getMediaRecordById(id) {
|
|
352
|
+
const db = getDb();
|
|
353
|
+
const [media] = await db.select().from(npMedia).where(and(eq(npMedia.id, id), isNull(npMedia.deletedAt))).limit(1);
|
|
354
|
+
return media ? toMediaRecord(media) : null;
|
|
355
|
+
}
|
|
356
|
+
async function uploadImageVariants(adapter, mediaId, processed, format, mimeType) {
|
|
357
|
+
const entries = await Promise.all(
|
|
358
|
+
processed.variants.map(async (variant) => {
|
|
359
|
+
const filename = `${variant.name}.${format}`;
|
|
360
|
+
const storageKey = `media/${mediaId}/${filename}`;
|
|
361
|
+
await adapter.upload(storageKey, variant.buffer, {
|
|
362
|
+
contentType: mimeType,
|
|
363
|
+
contentLength: variant.size,
|
|
364
|
+
originalFilename: filename
|
|
365
|
+
});
|
|
366
|
+
return [
|
|
367
|
+
variant.name,
|
|
368
|
+
{
|
|
369
|
+
filename,
|
|
370
|
+
mimeType,
|
|
371
|
+
filesize: variant.size,
|
|
372
|
+
width: variant.width,
|
|
373
|
+
height: variant.height,
|
|
374
|
+
storageKey,
|
|
375
|
+
url: await adapter.getUrl(storageKey)
|
|
376
|
+
}
|
|
377
|
+
];
|
|
378
|
+
})
|
|
379
|
+
);
|
|
380
|
+
return Object.fromEntries(entries);
|
|
381
|
+
}
|
|
382
|
+
function extractVariantStorageKeys(sizes) {
|
|
383
|
+
if (!sizes) {
|
|
384
|
+
return [];
|
|
385
|
+
}
|
|
386
|
+
return Object.values(sizes).map((size) => size.storageKey).filter((value) => typeof value === "string" && value.length > 0);
|
|
387
|
+
}
|
|
388
|
+
function resolveFileExtension(originalFilename, mimeType) {
|
|
389
|
+
const extension = extname(originalFilename).slice(1).toLowerCase();
|
|
390
|
+
if (extension) {
|
|
391
|
+
return extension;
|
|
392
|
+
}
|
|
393
|
+
switch (mimeType) {
|
|
394
|
+
case "image/jpeg":
|
|
395
|
+
return "jpg";
|
|
396
|
+
case "image/png":
|
|
397
|
+
return "png";
|
|
398
|
+
case "image/webp":
|
|
399
|
+
return "webp";
|
|
400
|
+
case "image/avif":
|
|
401
|
+
return "avif";
|
|
402
|
+
case "image/gif":
|
|
403
|
+
return "gif";
|
|
404
|
+
case "application/pdf":
|
|
405
|
+
return "pdf";
|
|
406
|
+
default:
|
|
407
|
+
return "bin";
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
function getFormatMimeType(format) {
|
|
411
|
+
switch (format) {
|
|
412
|
+
case "avif":
|
|
413
|
+
return "image/avif";
|
|
414
|
+
case "jpeg":
|
|
415
|
+
return "image/jpeg";
|
|
416
|
+
case "png":
|
|
417
|
+
return "image/png";
|
|
418
|
+
case "webp":
|
|
419
|
+
default:
|
|
420
|
+
return "image/webp";
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
function combineConditions(conditions) {
|
|
424
|
+
if (conditions.length === 0) {
|
|
425
|
+
return void 0;
|
|
426
|
+
}
|
|
427
|
+
if (conditions.length === 1) {
|
|
428
|
+
return conditions[0];
|
|
429
|
+
}
|
|
430
|
+
return and(...conditions);
|
|
431
|
+
}
|
|
432
|
+
function normalizePage(page) {
|
|
433
|
+
if (!page || page < 1) {
|
|
434
|
+
return 1;
|
|
435
|
+
}
|
|
436
|
+
return Math.floor(page);
|
|
437
|
+
}
|
|
438
|
+
function normalizeLimit(limit) {
|
|
439
|
+
if (!limit || limit < 1) {
|
|
440
|
+
return 10;
|
|
441
|
+
}
|
|
442
|
+
return Math.floor(limit);
|
|
443
|
+
}
|
|
444
|
+
function toMediaRecord(value) {
|
|
445
|
+
const record = toRecord(value);
|
|
446
|
+
return {
|
|
447
|
+
id: asString(record.id, "id"),
|
|
448
|
+
filename: asString(record.filename, "filename"),
|
|
449
|
+
originalFilename: asString(record.originalFilename, "originalFilename"),
|
|
450
|
+
mimeType: asString(record.mimeType, "mimeType"),
|
|
451
|
+
filesize: asNumber(record.filesize, "filesize"),
|
|
452
|
+
width: asNullableNumber(record.width),
|
|
453
|
+
height: asNullableNumber(record.height),
|
|
454
|
+
sizes: asSizes(record.sizes),
|
|
455
|
+
storageKey: asString(record.storageKey, "storageKey"),
|
|
456
|
+
hash: asString(record.hash, "hash"),
|
|
457
|
+
status: asMediaStatus(record.status),
|
|
458
|
+
folderId: asNullableString(record.folderId),
|
|
459
|
+
uploadedBy: asNullableString(record.uploadedBy),
|
|
460
|
+
createdAt: asDate(record.createdAt, "createdAt"),
|
|
461
|
+
updatedAt: asDate(record.updatedAt, "updatedAt"),
|
|
462
|
+
deletedAt: asNullableDate(record.deletedAt)
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
function asSizes(value) {
|
|
466
|
+
if (value == null) {
|
|
467
|
+
return null;
|
|
468
|
+
}
|
|
469
|
+
const record = toRecord(value);
|
|
470
|
+
const sizes = {};
|
|
471
|
+
for (const [key, entry] of Object.entries(record)) {
|
|
472
|
+
const sizeRecord = toRecord(entry);
|
|
473
|
+
sizes[key] = sizeRecord;
|
|
474
|
+
}
|
|
475
|
+
return sizes;
|
|
476
|
+
}
|
|
477
|
+
function asMediaStatus(value) {
|
|
478
|
+
if (value === "processing" || value === "ready" || value === "error") {
|
|
479
|
+
return value;
|
|
480
|
+
}
|
|
481
|
+
throw new Error("Invalid media status.");
|
|
482
|
+
}
|
|
483
|
+
function asString(value, field) {
|
|
484
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
485
|
+
throw new Error(`Invalid ${field}.`);
|
|
486
|
+
}
|
|
487
|
+
return value;
|
|
488
|
+
}
|
|
489
|
+
function asNullableString(value) {
|
|
490
|
+
if (value == null) {
|
|
491
|
+
return null;
|
|
492
|
+
}
|
|
493
|
+
return asString(value, "string field");
|
|
494
|
+
}
|
|
495
|
+
function asNumber(value, field) {
|
|
496
|
+
if (typeof value !== "number") {
|
|
497
|
+
throw new Error(`Invalid ${field}.`);
|
|
498
|
+
}
|
|
499
|
+
return value;
|
|
500
|
+
}
|
|
501
|
+
function asNullableNumber(value) {
|
|
502
|
+
if (value == null) {
|
|
503
|
+
return null;
|
|
504
|
+
}
|
|
505
|
+
return asNumber(value, "number field");
|
|
506
|
+
}
|
|
507
|
+
function asDate(value, field) {
|
|
508
|
+
if (!(value instanceof Date)) {
|
|
509
|
+
throw new Error(`Invalid ${field}.`);
|
|
510
|
+
}
|
|
511
|
+
return value;
|
|
512
|
+
}
|
|
513
|
+
function asNullableDate(value) {
|
|
514
|
+
if (value == null) {
|
|
515
|
+
return null;
|
|
516
|
+
}
|
|
517
|
+
return asDate(value, "date field");
|
|
518
|
+
}
|
|
519
|
+
function toRecord(value) {
|
|
520
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
521
|
+
throw new Error("Expected object record.");
|
|
522
|
+
}
|
|
523
|
+
return value;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
export {
|
|
527
|
+
DEFAULT_IMAGE_SIZES,
|
|
528
|
+
processImage,
|
|
529
|
+
setStorageAdapter,
|
|
530
|
+
getStorageAdapter,
|
|
531
|
+
uploadMedia,
|
|
532
|
+
processMediaImage,
|
|
533
|
+
getMediaById,
|
|
534
|
+
deleteMedia,
|
|
535
|
+
listMedia,
|
|
536
|
+
cleanupDeletedMedia
|
|
537
|
+
};
|
|
538
|
+
//# sourceMappingURL=chunk-473S4TER.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/media/processor.ts","../src/media/service.ts"],"sourcesContent":["import sharp from \"sharp\";\n\nimport type { NpImageSize } from \"../config/types.js\";\n\nexport interface NpProcessedImageVariant {\n name: string;\n buffer: Buffer;\n width: number;\n height: number;\n size: number;\n}\n\nexport interface NpProcessedImageSourceMetadata {\n width: number | null;\n height: number | null;\n format: string | null;\n}\n\nexport interface NpProcessedImageResult {\n source: NpProcessedImageSourceMetadata;\n variants: NpProcessedImageVariant[];\n}\n\nexport const DEFAULT_IMAGE_SIZES: NpImageSize[] = [\n { name: \"thumbnail\", width: 300 },\n { name: \"small\", width: 600 },\n { name: \"medium\", width: 900 },\n { name: \"large\", width: 1400 },\n { name: \"xlarge\", width: 1920 },\n { name: \"og\", width: 1200, height: 630, crop: \"center\" },\n];\n\nexport async function processImage(\n inputBuffer: Buffer,\n sizes: NpImageSize[],\n options: { format?: string; quality?: number } = {},\n): Promise<NpProcessedImageResult> {\n const format = options.format ?? \"webp\";\n const quality = options.quality ?? 80;\n const sourceImage = sharp(inputBuffer).autoOrient();\n const metadata = await sourceImage.metadata();\n\n const variants = await Promise.all(\n sizes.map(async (size) => {\n const resized = size.height\n ? sourceImage.clone().resize({\n width: size.width,\n height: size.height,\n fit: \"cover\",\n position: resolveCropPosition(size.crop),\n })\n : sourceImage.clone().resize({\n width: size.width,\n fit: \"inside\",\n withoutEnlargement: true,\n });\n\n const formatted = applyFormat(resized, format, quality);\n const { data, info } = await formatted.toBuffer({ resolveWithObject: true });\n\n return {\n name: size.name,\n buffer: data,\n width: info.width,\n height: info.height,\n size: info.size ?? data.byteLength,\n };\n }),\n );\n\n return {\n source: {\n width: metadata.width ?? null,\n height: metadata.height ?? null,\n format: metadata.format ?? null,\n },\n variants,\n };\n}\n\nfunction applyFormat(\n image: sharp.Sharp,\n format: string,\n quality: number,\n): sharp.Sharp {\n switch (format) {\n case \"avif\":\n return image.avif({ quality });\n case \"jpeg\":\n return image.jpeg({ quality });\n case \"png\":\n return image.png({ quality });\n case \"webp\":\n default:\n return image.webp({ quality });\n }\n}\n\nfunction resolveCropPosition(crop?: NpImageSize[\"crop\"]): sharp.Gravity | number {\n switch (crop) {\n case \"top\":\n return \"top\";\n case \"bottom\":\n return \"bottom\";\n case \"left\":\n return \"left\";\n case \"right\":\n return \"right\";\n case \"center\":\n return \"centre\";\n default:\n return sharp.strategy.attention;\n }\n}\n","import { createHash, randomUUID } from \"node:crypto\";\nimport { extname } from \"node:path\";\nimport { buffer as consumeBuffer } from \"node:stream/consumers\";\nimport { Readable } from \"node:stream\";\n\nimport { and, count, desc, eq, gte, ilike, inArray, isNotNull, isNull, lt, or, sql } from \"drizzle-orm\";\nimport type { NodePgDatabase } from \"drizzle-orm/node-postgres\";\nimport type { PgTable } from \"drizzle-orm/pg-core\";\n\nimport type { NpFindResult, NpImageSize } from \"../config/types.js\";\nimport { readEnvPositiveInt } from \"../config/env.js\";\nimport { npMembers } from \"../db/schema/community.js\";\nimport { npMedia, npMediaRefs } from \"../db/schema/media.js\";\nimport { npUsers } from \"../db/schema/system.js\";\nimport { enqueueJob } from \"../jobs/queue.js\";\nimport { getLogger } from \"../observability/logger.js\";\nimport { getDb } from \"../db/runtime.js\";\nimport {\n DEFAULT_IMAGE_SIZES,\n processImage,\n type NpProcessedImageResult,\n} from \"./processor.js\";\nimport type { NpStorageAdapter } from \"../storage/types.js\";\n\n/**\n * Trailing-window for member upload quotas (`perDay` in\n * `npMemberUploadQuota`). Default 24h matches the historical\n * \"daily quota\" semantics; override via\n * `NP_MEMBER_QUOTA_WINDOW_HOURS` to shift to weekly or hourly\n * caps without touching code.\n */\nconst MEMBER_QUOTA_WINDOW_MS =\n readEnvPositiveInt(\"NP_MEMBER_QUOTA_WINDOW_HOURS\", 24) * 60 * 60 * 1000;\n\ninterface SelectQuery extends Promise<unknown[]> {\n where(condition: ReturnType<typeof and> | ReturnType<typeof isNull>): SelectQuery;\n orderBy(order: ReturnType<typeof desc>): SelectQuery;\n limit(limit: number): SelectQuery;\n offset(offset: number): SelectQuery;\n}\n\ninterface InsertValuesQuery extends Promise<unknown> {\n returning(): Promise<unknown[]>;\n}\n\ninterface DrizzleDatabaseLike {\n insert(table: PgTable): {\n values(values: Record<string, unknown> | Record<string, unknown>[]): InsertValuesQuery;\n };\n update(table: PgTable): {\n set(values: Record<string, unknown>): {\n where(condition: ReturnType<typeof and> | ReturnType<typeof eq>): {\n returning(): Promise<unknown[]>;\n };\n };\n };\n delete(table: PgTable): {\n where(condition: ReturnType<typeof inArray> ): Promise<unknown>;\n };\n select(selection?: Record<string, unknown>): {\n from(table: PgTable): SelectQuery;\n };\n}\n\ninterface MediaRecord {\n id: string;\n filename: string;\n originalFilename: string;\n mimeType: string;\n filesize: number;\n width: number | null;\n height: number | null;\n sizes: Record<string, Record<string, unknown>> | null;\n storageKey: string;\n hash: string;\n status: \"processing\" | \"ready\" | \"error\";\n folderId: string | null;\n uploadedBy: string | null;\n createdAt: Date;\n updatedAt: Date;\n deletedAt: Date | null;\n}\n\nlet storageAdapter: NpStorageAdapter | null = null;\n\nexport function setStorageAdapter(adapter: NpStorageAdapter): void {\n storageAdapter = adapter;\n}\n\nexport function getStorageAdapter(): NpStorageAdapter {\n if (!storageAdapter) {\n throw new Error(\"Storage adapter not initialized. Call setStorageAdapter() first.\");\n }\n\n return storageAdapter;\n}\n\n/**\n * Polymorphic uploader: a row on `np_media` is owned by exactly\n * one of staff (`uploadedBy` → `np_users.id`) or member\n * (`uploadedByMemberId` → `np_members.id`, Phase 9.7j). Pass a\n * `null` value as the second argument to `uploadMedia` for plugin /\n * system uploads with no human owner — both columns stay null and\n * the audit log carries the actor.\n */\nexport type NpMediaUploader =\n | { kind: \"staff\"; userId: string }\n | { kind: \"member\"; memberId: string }\n | null;\n\nexport async function uploadMedia(\n file: { buffer: Buffer; originalFilename: string; mimeType: string },\n uploader: NpMediaUploader | string,\n folderId?: string,\n): Promise<{ id: string; status: string }> {\n // Backwards-compat: the original signature was\n // `uploadMedia(file, userId: string | null, folderId?)`. Existing\n // callers (plugin context, admin bulk uploads, etc.) pass a bare\n // string. Coerce that into the staff variant of the polymorphic\n // shape so the rest of this function only deals with the union.\n const resolvedUploader: NpMediaUploader =\n typeof uploader === \"string\"\n ? { kind: \"staff\", userId: uploader }\n : uploader;\n\n const id = randomUUID();\n const extension = resolveFileExtension(file.originalFilename, file.mimeType);\n const storageKey = `media/${id}/original.${extension}`;\n const now = new Date();\n const insertValues = {\n id,\n filename: file.originalFilename,\n originalFilename: file.originalFilename,\n mimeType: file.mimeType,\n filesize: file.buffer.byteLength,\n storageKey,\n hash: createHash(\"sha256\").update(file.buffer).digest(\"hex\"),\n status: \"processing\" as const,\n folderId,\n uploadedBy:\n resolvedUploader && resolvedUploader.kind === \"staff\"\n ? resolvedUploader.userId\n : null,\n uploadedByMemberId:\n resolvedUploader && resolvedUploader.kind === \"member\"\n ? resolvedUploader.memberId\n : null,\n createdAt: now,\n updatedAt: now,\n };\n\n // Phase 9.7p: per-member upload quota. Staff uploads are never\n // gated. Phase 9.7p-followup (#120) — the count + insert must be\n // atomic per member, otherwise concurrent uploads can both\n // observe the same pre-insert count and both succeed past the\n // cap. Wrap the gated branch in a transaction holding a Postgres\n // advisory lock keyed on the member id; cross-member uploaders\n // don't contend (different lock keys), same-member concurrent\n // uploaders serialize and the second one sees the updated\n // count.\n //\n // Storage upload happens AFTER the DB row commits so the quota\n // count is correct before bytes touch storage. If the upload\n // fails (#138 follow-up), we hard-delete the just-inserted row\n // so it stops counting against quota and doesn't strand the\n // member with a permanent ghost. We do NOT just mark the row\n // `error` here — there's no storage object to inspect, no\n // processor will arrive (the job hasn't been enqueued yet),\n // and the quota count filters by `deletedAt IS NULL`, not\n // `status`. Hard delete is the right semantic.\n if (resolvedUploader && resolvedUploader.kind === \"member\") {\n const memberId = resolvedUploader.memberId;\n const dbPg = getDb() as unknown as NodePgDatabase<Record<string, unknown>>;\n await dbPg.transaction(async (tx) => {\n // `pg_advisory_xact_lock` auto-releases on commit/rollback.\n // `hashtextextended` produces a stable int8 from a UUID\n // string — collisions across different member ids are\n // benign (worst case some unrelated members serialize).\n await tx.execute(\n sql`SELECT pg_advisory_xact_lock(hashtextextended(${memberId}, 0))`,\n );\n await assertMemberUploadQuota(memberId, tx);\n await tx.insert(npMedia).values(insertValues);\n });\n } else {\n const db = getDb() as unknown as DrizzleDatabaseLike;\n await db.insert(npMedia).values(insertValues);\n }\n\n const adapter = getStorageAdapter();\n try {\n await adapter.upload(storageKey, file.buffer, {\n contentType: file.mimeType,\n contentLength: file.buffer.byteLength,\n originalFilename: file.originalFilename,\n });\n } catch (err) {\n // Storage failed after the DB row committed. Roll the row\n // back so it doesn't (a) eat the member's quota allowance\n // for nothing, (b) confuse operators with a permanent\n // `processing` row that never gets a job. Cleanup is\n // best-effort — if the delete itself fails we still surface\n // the original storage error to the caller, since that's\n // what they need to act on.\n try {\n const cleanupDb = getDb() as unknown as DrizzleDatabaseLike;\n await cleanupDb.delete(npMedia).where(eq(npMedia.id, id));\n } catch (cleanupErr) {\n // Swallow so the original storage error reaches the\n // caller — that's what they need to act on. But don't go\n // silent: a failed cleanup leaves a permanent ghost row\n // in `processing` that eats the member's quota with no\n // storage object to inspect and no job ever enqueued.\n // Operators need a signal to find and remediate it.\n getLogger().error(\"media upload cleanup failed\", {\n mediaId: id,\n storageKey,\n error: cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr),\n });\n }\n throw err;\n }\n\n await enqueueJob(\"media:processImage\", { mediaId: id });\n\n return { id, status: \"processing\" };\n}\n\n/**\n * Throws `NpRateLimitError` (429) if the member is at or over\n * their per-day or lifetime upload cap. Both bounds count\n * non-deleted rows, so admin / member deletes free up quota the\n * same way (mirrors the 9.7l purge semantic). When both bounds\n * are `null` (the default), this function is a no-op aside from\n * a single settings read.\n *\n * Defer-loaded `getCommunitySettings` to avoid an import cycle\n * with `community/settings.ts` — that module reads `getDb()`,\n * which is wired by the same bootstrap that wires the media DB,\n * so they sit on the same module layer; deferring keeps a clean\n * one-way edge from media → community for this single call site.\n */\nasync function assertMemberUploadQuota(\n memberId: string,\n txDb?: NodePgDatabase<Record<string, unknown>>,\n): Promise<void> {\n const { getCommunitySettings } = await import(\n \"../community/settings.js\"\n );\n const { NpRateLimitError } = await import(\"../errors.js\");\n const settings = await getCommunitySettings();\n const { perDay, total } = settings.memberUploadQuota;\n if (perDay === null && total === null) return;\n\n // When invoked inside the upload transaction (#120 fix), the\n // count + downstream insert run under the same advisory lock,\n // so the count must use the tx handle to see writes by sibling\n // statements. When called from elsewhere we fall back to the\n // shared media DB.\n const db =\n txDb ??\n (getDb() as unknown as NodePgDatabase<Record<string, unknown>>);\n\n if (total !== null) {\n const [row] = (await db\n .select({ value: count() })\n .from(npMedia)\n .where(\n and(\n eq(npMedia.uploadedByMemberId, memberId),\n isNull(npMedia.deletedAt),\n ),\n )) as Array<{ value: number }>;\n const used = row?.value ?? 0;\n if (used >= total) {\n throw new NpRateLimitError(\n `Upload quota exceeded — this account has reached its lifetime cap of ${total} uploads.`,\n );\n }\n }\n\n if (perDay !== null) {\n const since = new Date(Date.now() - MEMBER_QUOTA_WINDOW_MS);\n const [row] = (await db\n .select({ value: count() })\n .from(npMedia)\n .where(\n and(\n eq(npMedia.uploadedByMemberId, memberId),\n isNull(npMedia.deletedAt),\n gte(npMedia.createdAt, since),\n ),\n )) as Array<{ value: number }>;\n const recent = row?.value ?? 0;\n if (recent >= perDay) {\n throw new NpRateLimitError(\n `Upload rate limit exceeded — try again later (max ${perDay} uploads per 24 hours).`,\n );\n }\n }\n}\n\nexport async function processMediaImage(\n mediaId: string,\n config: { sizes?: NpImageSize[]; format?: string; quality?: number },\n): Promise<void> {\n const db = getDb() as unknown as DrizzleDatabaseLike;\n const adapter = getStorageAdapter();\n const media = await getMediaRecordById(mediaId);\n\n if (!media) {\n throw new Error(`Media '${mediaId}' not found.`);\n }\n\n try {\n const originalStream = await adapter.getStream(media.storageKey);\n const originalBuffer = await consumeBuffer(Readable.fromWeb(originalStream));\n const processed = await processImage(\n originalBuffer,\n config.sizes ?? DEFAULT_IMAGE_SIZES,\n { format: config.format, quality: config.quality },\n );\n const format = config.format ?? \"webp\";\n const mimeType = getFormatMimeType(format);\n const sizes = await uploadImageVariants(adapter, media.id, processed, format, mimeType);\n\n await db\n .update(npMedia)\n .set({\n sizes,\n width: processed.source.width,\n height: processed.source.height,\n status: \"ready\",\n updatedAt: new Date(),\n })\n .where(eq(npMedia.id, media.id))\n .returning();\n } catch (error) {\n await db\n .update(npMedia)\n .set({\n status: \"error\",\n updatedAt: new Date(),\n })\n .where(eq(npMedia.id, media.id))\n .returning();\n\n throw error;\n }\n}\n\nexport async function getMediaById(id: string): Promise<Record<string, unknown> | null> {\n const db = getDb() as unknown as DrizzleDatabaseLike;\n const [media] = await db\n .select()\n .from(npMedia)\n .where(and(eq(npMedia.id, id), isNull(npMedia.deletedAt)))\n .limit(1);\n\n return media ? toRecord(media) : null;\n}\n\nexport async function deleteMedia(\n id: string,\n): Promise<{ deleted: boolean; references?: unknown[] }> {\n const db = getDb() as unknown as DrizzleDatabaseLike;\n const references = await db.select().from(npMediaRefs).where(eq(npMediaRefs.mediaId, id));\n\n if (references.length > 0) {\n return { deleted: false, references };\n }\n\n const [media] = await db\n .select()\n .from(npMedia)\n .where(and(eq(npMedia.id, id), isNull(npMedia.deletedAt)))\n .limit(1);\n\n if (!media) {\n return { deleted: false };\n }\n\n await db\n .update(npMedia)\n .set({\n deletedAt: new Date(),\n updatedAt: new Date(),\n })\n .where(eq(npMedia.id, id))\n .returning();\n\n return { deleted: true };\n}\n\n/**\n * Phase 9.7k uploader filters. `uploaderKind` partitions the\n * library into staff-uploaded rows (`uploaded_by IS NOT NULL`) vs\n * member-uploaded rows (`uploaded_by_member_id IS NOT NULL`) — the\n * two columns are mutually exclusive on every row written through\n * `uploadMedia`. `uploadedByMemberId` narrows to a specific member\n * for \"show me everything @handle uploaded\" investigations after a\n * spam wave.\n */\nexport type NpMediaUploaderKindFilter = \"staff\" | \"member\";\n\nexport async function listMedia(options: {\n page?: number;\n limit?: number;\n folderId?: string;\n mimeType?: string;\n uploaderKind?: NpMediaUploaderKindFilter;\n uploadedByMemberId?: string;\n /**\n * Substring match against `filename` and `alt`. Matches\n * server-side via `ILIKE`, so the page-builder block-image\n * picker can search the whole library without paging through\n * every result client-side. Empty / whitespace-only `q` is\n * treated as no filter.\n */\n q?: string;\n}): Promise<NpFindResult> {\n const db = getDb() as unknown as DrizzleDatabaseLike;\n const page = normalizePage(options.page);\n const limit = normalizeLimit(options.limit);\n const offset = (page - 1) * limit;\n const conditions = [isNull(npMedia.deletedAt)];\n\n if (options.folderId) {\n conditions.push(eq(npMedia.folderId, options.folderId));\n }\n\n if (options.mimeType) {\n conditions.push(eq(npMedia.mimeType, options.mimeType));\n }\n\n if (options.uploaderKind === \"staff\") {\n conditions.push(isNotNull(npMedia.uploadedBy));\n } else if (options.uploaderKind === \"member\") {\n conditions.push(isNotNull(npMedia.uploadedByMemberId));\n }\n\n if (options.uploadedByMemberId) {\n conditions.push(eq(npMedia.uploadedByMemberId, options.uploadedByMemberId));\n }\n\n // Substring search across filename + alt. We match `ILIKE\n // %q%` against both columns and OR them so the picker's\n // search box hits filenames the operator remembers and alt\n // text they wrote. SQL escapes the literal `%` / `_` chars\n // by doubling them so a filename containing them isn't\n // treated as a wildcard.\n if (options.q && options.q.trim().length > 0) {\n const needle = `%${options.q.trim().replace(/[%_]/g, (c) => `\\\\${c}`)}%`;\n const search = or(\n ilike(npMedia.filename, needle),\n ilike(npMedia.alt, needle),\n );\n if (search) conditions.push(search);\n }\n\n const whereClause = combineConditions(conditions);\n // The local `DrizzleDatabaseLike` interface in this file is\n // narrow on purpose (only `select/insert/update/delete`); a\n // proper leftJoin chain would require typing the full Drizzle\n // builder pipeline. Cast through `unknown` for this query —\n // safer than widening the interface and dragging join semantics\n // into every other media call site.\n const joined = (db as unknown as {\n select: (s: Record<string, unknown>) => {\n from: (t: PgTable) => {\n leftJoin: (j: PgTable, c: unknown) => {\n leftJoin: (j: PgTable, c: unknown) => {\n where: (c: unknown) => {\n orderBy: (o: unknown) => {\n limit: (n: number) => {\n offset: (n: number) => Promise<Array<Record<string, unknown>>>;\n };\n };\n };\n };\n };\n };\n };\n })\n .select({\n media: npMedia,\n userName: npUsers.name,\n userEmail: npUsers.email,\n memberHandle: npMembers.handle,\n memberDisplayName: npMembers.displayName,\n })\n .from(npMedia)\n .leftJoin(npUsers, eq(npMedia.uploadedBy, npUsers.id))\n .leftJoin(npMembers, eq(npMedia.uploadedByMemberId, npMembers.id))\n .where(whereClause)\n .orderBy(desc(npMedia.createdAt))\n .limit(limit)\n .offset(offset);\n\n const rows = (await joined) as Array<{\n media: Record<string, unknown>;\n userName: string | null;\n userEmail: string | null;\n memberHandle: string | null;\n memberDisplayName: string | null;\n }>;\n const [{ total }] = (whereClause\n ? await db.select({ total: count() }).from(npMedia).where(whereClause)\n : await db.select({ total: count() }).from(npMedia)) as Array<{ total: number | string }>;\n const totalDocs = Number(total ?? 0);\n const totalPages = totalDocs === 0 ? 0 : Math.ceil(totalDocs / limit);\n\n // Flatten the JOIN result so each doc carries an `uploader`\n // sub-object alongside the standard media columns. Keeps the\n // shape backwards-compatible (the existing media columns are\n // still at the top level).\n const docs = rows.map((row) => ({\n ...row.media,\n uploader: row.userName !== null\n ? {\n kind: \"staff\" as const,\n name: row.userName,\n email: row.userEmail,\n }\n : row.memberHandle !== null\n ? {\n kind: \"member\" as const,\n handle: row.memberHandle,\n displayName: row.memberDisplayName,\n }\n : null,\n }));\n\n return {\n docs: docs as Record<string, unknown>[],\n totalDocs,\n totalPages,\n page,\n limit,\n hasNextPage: page < totalPages,\n hasPrevPage: page > 1 && totalDocs > 0,\n };\n}\n\nexport async function cleanupDeletedMedia(olderThanDays: number): Promise<number> {\n const db = getDb() as unknown as DrizzleDatabaseLike;\n const adapter = getStorageAdapter();\n const threshold = new Date(Date.now() - olderThanDays * 24 * 60 * 60 * 1000);\n const rows = await db\n .select()\n .from(npMedia)\n .where(and(isNotNull(npMedia.deletedAt), lt(npMedia.deletedAt, threshold)));\n const mediaRows = rows.map(toMediaRecord);\n\n if (mediaRows.length === 0) {\n return 0;\n }\n\n for (const media of mediaRows) {\n const keys = new Set<string>([\n media.storageKey,\n ...extractVariantStorageKeys(media.sizes),\n ]);\n\n for (const key of keys) {\n try {\n await adapter.delete(key);\n } catch {\n continue;\n }\n }\n }\n\n await db.delete(npMedia).where(inArray(npMedia.id, mediaRows.map((media) => media.id)));\n\n return mediaRows.length;\n}\n\nasync function getMediaRecordById(id: string): Promise<MediaRecord | null> {\n const db = getDb() as unknown as DrizzleDatabaseLike;\n const [media] = await db\n .select()\n .from(npMedia)\n .where(and(eq(npMedia.id, id), isNull(npMedia.deletedAt)))\n .limit(1);\n\n return media ? toMediaRecord(media) : null;\n}\n\nasync function uploadImageVariants(\n adapter: NpStorageAdapter,\n mediaId: string,\n processed: NpProcessedImageResult,\n format: string,\n mimeType: string,\n): Promise<Record<string, Record<string, unknown>>> {\n const entries = await Promise.all(\n processed.variants.map(async (variant) => {\n const filename = `${variant.name}.${format}`;\n const storageKey = `media/${mediaId}/${filename}`;\n\n await adapter.upload(storageKey, variant.buffer, {\n contentType: mimeType,\n contentLength: variant.size,\n originalFilename: filename,\n });\n\n return [\n variant.name,\n {\n filename,\n mimeType,\n filesize: variant.size,\n width: variant.width,\n height: variant.height,\n storageKey,\n url: await adapter.getUrl(storageKey),\n },\n ] as const;\n }),\n );\n\n return Object.fromEntries(entries);\n}\n\nfunction extractVariantStorageKeys(\n sizes: Record<string, Record<string, unknown>> | null,\n): string[] {\n if (!sizes) {\n return [];\n }\n\n return Object.values(sizes)\n .map((size) => size.storageKey)\n .filter((value): value is string => typeof value === \"string\" && value.length > 0);\n}\n\nfunction resolveFileExtension(originalFilename: string, mimeType: string): string {\n const extension = extname(originalFilename).slice(1).toLowerCase();\n\n if (extension) {\n return extension;\n }\n\n switch (mimeType) {\n case \"image/jpeg\":\n return \"jpg\";\n case \"image/png\":\n return \"png\";\n case \"image/webp\":\n return \"webp\";\n case \"image/avif\":\n return \"avif\";\n case \"image/gif\":\n return \"gif\";\n case \"application/pdf\":\n return \"pdf\";\n default:\n return \"bin\";\n }\n}\n\nfunction getFormatMimeType(format: string): string {\n switch (format) {\n case \"avif\":\n return \"image/avif\";\n case \"jpeg\":\n return \"image/jpeg\";\n case \"png\":\n return \"image/png\";\n case \"webp\":\n default:\n return \"image/webp\";\n }\n}\n\nfunction combineConditions(\n conditions: Array<ReturnType<typeof and> | ReturnType<typeof isNull> >,\n): ReturnType<typeof and> | ReturnType<typeof isNull> | undefined {\n if (conditions.length === 0) {\n return undefined;\n }\n\n if (conditions.length === 1) {\n return conditions[0];\n }\n\n return and(...conditions);\n}\n\nfunction normalizePage(page?: number): number {\n if (!page || page < 1) {\n return 1;\n }\n\n return Math.floor(page);\n}\n\nfunction normalizeLimit(limit?: number): number {\n if (!limit || limit < 1) {\n return 10;\n }\n\n return Math.floor(limit);\n}\n\nfunction toMediaRecord(value: unknown): MediaRecord {\n const record = toRecord(value);\n\n return {\n id: asString(record.id, \"id\"),\n filename: asString(record.filename, \"filename\"),\n originalFilename: asString(record.originalFilename, \"originalFilename\"),\n mimeType: asString(record.mimeType, \"mimeType\"),\n filesize: asNumber(record.filesize, \"filesize\"),\n width: asNullableNumber(record.width),\n height: asNullableNumber(record.height),\n sizes: asSizes(record.sizes),\n storageKey: asString(record.storageKey, \"storageKey\"),\n hash: asString(record.hash, \"hash\"),\n status: asMediaStatus(record.status),\n folderId: asNullableString(record.folderId),\n uploadedBy: asNullableString(record.uploadedBy),\n createdAt: asDate(record.createdAt, \"createdAt\"),\n updatedAt: asDate(record.updatedAt, \"updatedAt\"),\n deletedAt: asNullableDate(record.deletedAt),\n };\n}\n\nfunction asSizes(value: unknown): Record<string, Record<string, unknown>> | null {\n if (value == null) {\n return null;\n }\n\n const record = toRecord(value);\n const sizes: Record<string, Record<string, unknown>> = {};\n\n for (const [key, entry] of Object.entries(record)) {\n const sizeRecord = toRecord(entry);\n sizes[key] = sizeRecord;\n }\n\n return sizes;\n}\n\nfunction asMediaStatus(value: unknown): MediaRecord[\"status\"] {\n if (value === \"processing\" || value === \"ready\" || value === \"error\") {\n return value;\n }\n\n throw new Error(\"Invalid media status.\");\n}\n\nfunction asString(value: unknown, field: string): string {\n if (typeof value !== \"string\" || value.length === 0) {\n throw new Error(`Invalid ${field}.`);\n }\n\n return value;\n}\n\nfunction asNullableString(value: unknown): string | null {\n if (value == null) {\n return null;\n }\n\n return asString(value, \"string field\");\n}\n\nfunction asNumber(value: unknown, field: string): number {\n if (typeof value !== \"number\") {\n throw new Error(`Invalid ${field}.`);\n }\n\n return value;\n}\n\nfunction asNullableNumber(value: unknown): number | null {\n if (value == null) {\n return null;\n }\n\n return asNumber(value, \"number field\");\n}\n\nfunction asDate(value: unknown, field: string): Date {\n if (!(value instanceof Date)) {\n throw new Error(`Invalid ${field}.`);\n }\n\n return value;\n}\n\nfunction asNullableDate(value: unknown): Date | null {\n if (value == null) {\n return null;\n }\n\n return asDate(value, \"date field\");\n}\n\nfunction toRecord(value: unknown): Record<string, unknown> {\n if (!value || typeof value !== \"object\" || Array.isArray(value)) {\n throw new Error(\"Expected object record.\");\n }\n\n return value as Record<string, unknown>;\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// src/i18n/registry.ts
|
|
2
|
+
var i18nConfig = null;
|
|
3
|
+
function setI18nConfig(config) {
|
|
4
|
+
i18nConfig = config ?? null;
|
|
5
|
+
}
|
|
6
|
+
function getI18nConfig() {
|
|
7
|
+
return i18nConfig;
|
|
8
|
+
}
|
|
9
|
+
function resetI18nConfig() {
|
|
10
|
+
i18nConfig = null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export {
|
|
14
|
+
setI18nConfig,
|
|
15
|
+
getI18nConfig,
|
|
16
|
+
resetI18nConfig
|
|
17
|
+
};
|
|
18
|
+
//# sourceMappingURL=chunk-4ZLMEKFX.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/i18n/registry.ts"],"sourcesContent":["import type { NpI18nConfig } from \"../config/types.js\";\n\n/**\n * Phase 12.1 — process-wide i18n config singleton.\n *\n * The collection pipeline needs to know the configured locales\n * + default locale at write time (to validate the `locale` on\n * the data and pick a fallback when the caller omits it).\n * Loading the full NpConfig from collections would form a\n * cycle (config → collections → config), so we expose a small\n * setter the bootstrap calls during boot, mirroring how\n * `setDb` / `setStorageAdapter` / `registerThemes` are wired.\n *\n * Sites that don't configure i18n leave this null; the\n * pipeline treats per-collection `i18n: true` as a config\n * error in that case (also enforced earlier by `defineConfig`).\n */\nlet i18nConfig: NpI18nConfig | null = null;\n\nexport function setI18nConfig(config: NpI18nConfig | null): void {\n i18nConfig = config ?? null;\n}\n\nexport function getI18nConfig(): NpI18nConfig | null {\n return i18nConfig;\n}\n\nexport function resetI18nConfig(): void {\n i18nConfig = null;\n}\n"],"mappings":";AAiBA,IAAI,aAAkC;AAE/B,SAAS,cAAc,QAAmC;AAC/D,eAAa,UAAU;AACzB;AAEO,SAAS,gBAAqC;AACnD,SAAO;AACT;AAEO,SAAS,kBAAwB;AACtC,eAAa;AACf;","names":[]}
|