next-blogpanel 1.0.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 +211 -0
- package/dist/admin/index.cjs +3062 -0
- package/dist/admin/index.cjs.map +1 -0
- package/dist/admin/index.d.cts +51 -0
- package/dist/admin/index.d.ts +51 -0
- package/dist/admin/index.js +3029 -0
- package/dist/admin/index.js.map +1 -0
- package/dist/api/categories.cjs +82 -0
- package/dist/api/categories.cjs.map +1 -0
- package/dist/api/categories.d.cts +27 -0
- package/dist/api/categories.d.ts +27 -0
- package/dist/api/categories.js +77 -0
- package/dist/api/categories.js.map +1 -0
- package/dist/api/media-file.cjs +46 -0
- package/dist/api/media-file.cjs.map +1 -0
- package/dist/api/media-file.d.cts +7 -0
- package/dist/api/media-file.d.ts +7 -0
- package/dist/api/media-file.js +44 -0
- package/dist/api/media-file.js.map +1 -0
- package/dist/api/media.cjs +180 -0
- package/dist/api/media.cjs.map +1 -0
- package/dist/api/media.d.cts +22 -0
- package/dist/api/media.d.ts +22 -0
- package/dist/api/media.js +176 -0
- package/dist/api/media.js.map +1 -0
- package/dist/api/posts.cjs +103 -0
- package/dist/api/posts.cjs.map +1 -0
- package/dist/api/posts.d.cts +27 -0
- package/dist/api/posts.d.ts +27 -0
- package/dist/api/posts.js +98 -0
- package/dist/api/posts.js.map +1 -0
- package/dist/api/rss.cjs +25 -0
- package/dist/api/rss.cjs.map +1 -0
- package/dist/api/rss.d.cts +5 -0
- package/dist/api/rss.d.ts +5 -0
- package/dist/api/rss.js +23 -0
- package/dist/api/rss.js.map +1 -0
- package/dist/api/settings.cjs +40 -0
- package/dist/api/settings.cjs.map +1 -0
- package/dist/api/settings.d.cts +17 -0
- package/dist/api/settings.d.ts +17 -0
- package/dist/api/settings.js +37 -0
- package/dist/api/settings.js.map +1 -0
- package/dist/api/sitemap.cjs +25 -0
- package/dist/api/sitemap.cjs.map +1 -0
- package/dist/api/sitemap.d.cts +5 -0
- package/dist/api/sitemap.d.ts +5 -0
- package/dist/api/sitemap.js +23 -0
- package/dist/api/sitemap.js.map +1 -0
- package/dist/api/tokens.cjs +56 -0
- package/dist/api/tokens.cjs.map +1 -0
- package/dist/api/tokens.d.cts +22 -0
- package/dist/api/tokens.d.ts +22 -0
- package/dist/api/tokens.js +52 -0
- package/dist/api/tokens.js.map +1 -0
- package/dist/chunk-2M46MMWN.cjs +76 -0
- package/dist/chunk-2M46MMWN.cjs.map +1 -0
- package/dist/chunk-4GK22Q3R.cjs +229 -0
- package/dist/chunk-4GK22Q3R.cjs.map +1 -0
- package/dist/chunk-6PZ772NQ.js +595 -0
- package/dist/chunk-6PZ772NQ.js.map +1 -0
- package/dist/chunk-BZXPRGLN.cjs +351 -0
- package/dist/chunk-BZXPRGLN.cjs.map +1 -0
- package/dist/chunk-DAX5C6SC.cjs +86 -0
- package/dist/chunk-DAX5C6SC.cjs.map +1 -0
- package/dist/chunk-DWNMHQ4I.cjs +57 -0
- package/dist/chunk-DWNMHQ4I.cjs.map +1 -0
- package/dist/chunk-FPGVR2BX.cjs +121 -0
- package/dist/chunk-FPGVR2BX.cjs.map +1 -0
- package/dist/chunk-JBP3G4QJ.js +50 -0
- package/dist/chunk-JBP3G4QJ.js.map +1 -0
- package/dist/chunk-JDP4DIHI.js +215 -0
- package/dist/chunk-JDP4DIHI.js.map +1 -0
- package/dist/chunk-LIZOVA4W.js +118 -0
- package/dist/chunk-LIZOVA4W.js.map +1 -0
- package/dist/chunk-QLBTOCT3.js +118 -0
- package/dist/chunk-QLBTOCT3.js.map +1 -0
- package/dist/chunk-UI2KDZ5H.js +344 -0
- package/dist/chunk-UI2KDZ5H.js.map +1 -0
- package/dist/chunk-V7QPRX4A.cjs +125 -0
- package/dist/chunk-V7QPRX4A.cjs.map +1 -0
- package/dist/chunk-VEJFN65Y.js +82 -0
- package/dist/chunk-VEJFN65Y.js.map +1 -0
- package/dist/chunk-WYZFAI6O.js +74 -0
- package/dist/chunk-WYZFAI6O.js.map +1 -0
- package/dist/chunk-XLYTATRQ.cjs +637 -0
- package/dist/chunk-XLYTATRQ.cjs.map +1 -0
- package/dist/cli/index.cjs +2036 -0
- package/dist/components/index.cjs +647 -0
- package/dist/components/index.cjs.map +1 -0
- package/dist/components/index.d.cts +187 -0
- package/dist/components/index.d.ts +187 -0
- package/dist/components/index.js +633 -0
- package/dist/components/index.js.map +1 -0
- package/dist/db-M3KXLBUP.js +3 -0
- package/dist/db-M3KXLBUP.js.map +1 -0
- package/dist/db-TDCZNY3Q.cjs +112 -0
- package/dist/db-TDCZNY3Q.cjs.map +1 -0
- package/dist/editor/index.cjs +1126 -0
- package/dist/editor/index.cjs.map +1 -0
- package/dist/editor/index.d.cts +137 -0
- package/dist/editor/index.d.ts +137 -0
- package/dist/editor/index.js +1093 -0
- package/dist/editor/index.js.map +1 -0
- package/dist/index-BcNka7gK.d.cts +475 -0
- package/dist/index-DPRgaNEE.d.ts +475 -0
- package/dist/index.cjs +433 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +27 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +209 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/index.cjs +193 -0
- package/dist/lib/index.cjs.map +1 -0
- package/dist/lib/index.d.cts +4 -0
- package/dist/lib/index.d.ts +4 -0
- package/dist/lib/index.js +8 -0
- package/dist/lib/index.js.map +1 -0
- package/dist/styles/admin.css +667 -0
- package/dist/styles/blog.css +851 -0
- package/dist/styles/editor.css +452 -0
- package/dist/styles/globals.css +270 -0
- package/dist/styles/prose.css +299 -0
- package/dist/types-CgrBuig-.d.cts +778 -0
- package/dist/types-CgrBuig-.d.ts +778 -0
- package/package.json +140 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var chunkXLYTATRQ_cjs = require('./chunk-XLYTATRQ.cjs');
|
|
4
|
+
var clientS3 = require('@aws-sdk/client-s3');
|
|
5
|
+
var mongodb = require('mongodb');
|
|
6
|
+
var crypto = require('crypto');
|
|
7
|
+
|
|
8
|
+
var BUILT_IN_PROVIDER_NAMES = ["gridfs", "r2"];
|
|
9
|
+
var clientInstance = null;
|
|
10
|
+
function getS3Client() {
|
|
11
|
+
if (clientInstance) return clientInstance;
|
|
12
|
+
if (!chunkXLYTATRQ_cjs.isR2Configured()) {
|
|
13
|
+
throw new Error(
|
|
14
|
+
"Cloudflare R2 is not configured. Set BLOGPANEL_R2_ACCOUNT_ID, BLOGPANEL_R2_ACCESS_KEY, BLOGPANEL_R2_SECRET_KEY, BLOGPANEL_R2_BUCKET, and BLOGPANEL_R2_PUBLIC_URL environment variables to enable image uploads."
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
const env = chunkXLYTATRQ_cjs.getEnvConfig();
|
|
18
|
+
clientInstance = new clientS3.S3Client({
|
|
19
|
+
region: "auto",
|
|
20
|
+
endpoint: `https://${env.BLOGPANEL_R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
|
|
21
|
+
credentials: {
|
|
22
|
+
accessKeyId: env.BLOGPANEL_R2_ACCESS_KEY,
|
|
23
|
+
secretAccessKey: env.BLOGPANEL_R2_SECRET_KEY
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
return clientInstance;
|
|
27
|
+
}
|
|
28
|
+
function generateKey(filename) {
|
|
29
|
+
const now = /* @__PURE__ */ new Date();
|
|
30
|
+
const year = now.getFullYear();
|
|
31
|
+
const month = String(now.getMonth() + 1).padStart(2, "0");
|
|
32
|
+
const uuid = crypto.randomUUID().split("-")[0];
|
|
33
|
+
const safeName = filename.replace(/[^a-zA-Z0-9._-]/g, "-").toLowerCase();
|
|
34
|
+
return `media/${year}/${month}/${uuid}-${safeName}`;
|
|
35
|
+
}
|
|
36
|
+
var R2StorageProvider = class {
|
|
37
|
+
constructor() {
|
|
38
|
+
this.name = "r2";
|
|
39
|
+
}
|
|
40
|
+
async upload(file, filename, contentType) {
|
|
41
|
+
const env = chunkXLYTATRQ_cjs.getEnvConfig();
|
|
42
|
+
const client = getS3Client();
|
|
43
|
+
const key = generateKey(filename);
|
|
44
|
+
await client.send(
|
|
45
|
+
new clientS3.PutObjectCommand({
|
|
46
|
+
Bucket: env.BLOGPANEL_R2_BUCKET,
|
|
47
|
+
Key: key,
|
|
48
|
+
Body: file,
|
|
49
|
+
ContentType: contentType
|
|
50
|
+
})
|
|
51
|
+
);
|
|
52
|
+
return {
|
|
53
|
+
key,
|
|
54
|
+
url: `${env.BLOGPANEL_R2_PUBLIC_URL}/${key}`,
|
|
55
|
+
size: file.length,
|
|
56
|
+
contentType
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
async delete(key) {
|
|
60
|
+
const env = chunkXLYTATRQ_cjs.getEnvConfig();
|
|
61
|
+
const client = getS3Client();
|
|
62
|
+
await client.send(
|
|
63
|
+
new clientS3.DeleteObjectCommand({
|
|
64
|
+
Bucket: env.BLOGPANEL_R2_BUCKET,
|
|
65
|
+
Key: key
|
|
66
|
+
})
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
async list(prefix) {
|
|
70
|
+
const env = chunkXLYTATRQ_cjs.getEnvConfig();
|
|
71
|
+
const client = getS3Client();
|
|
72
|
+
const response = await client.send(
|
|
73
|
+
new clientS3.ListObjectsV2Command({
|
|
74
|
+
Bucket: env.BLOGPANEL_R2_BUCKET,
|
|
75
|
+
Prefix: prefix || "media/"
|
|
76
|
+
})
|
|
77
|
+
);
|
|
78
|
+
return (response.Contents || []).map((obj) => ({
|
|
79
|
+
key: obj.Key || "",
|
|
80
|
+
size: obj.Size || 0,
|
|
81
|
+
lastModified: obj.LastModified || /* @__PURE__ */ new Date()
|
|
82
|
+
}));
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
var GRIDFS_BUCKET = "media";
|
|
86
|
+
var GridFSStorageProvider = class {
|
|
87
|
+
constructor() {
|
|
88
|
+
this.name = "gridfs";
|
|
89
|
+
}
|
|
90
|
+
async bucket() {
|
|
91
|
+
const db = await chunkXLYTATRQ_cjs.getDb();
|
|
92
|
+
return new mongodb.GridFSBucket(db, { bucketName: GRIDFS_BUCKET });
|
|
93
|
+
}
|
|
94
|
+
async upload(file, filename, contentType) {
|
|
95
|
+
const apiPath = chunkXLYTATRQ_cjs.requireApiPath("the URL for this GridFS upload");
|
|
96
|
+
const bucket = await this.bucket();
|
|
97
|
+
const key = generateKey(filename);
|
|
98
|
+
await new Promise((resolve, reject) => {
|
|
99
|
+
const stream = bucket.openUploadStream(key, { contentType });
|
|
100
|
+
stream.on("error", reject);
|
|
101
|
+
stream.on("finish", () => resolve());
|
|
102
|
+
stream.end(file);
|
|
103
|
+
});
|
|
104
|
+
return {
|
|
105
|
+
key,
|
|
106
|
+
url: `${apiPath}/media/file/${key}`,
|
|
107
|
+
size: file.length,
|
|
108
|
+
contentType
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
async delete(key) {
|
|
112
|
+
const bucket = await this.bucket();
|
|
113
|
+
const files = await bucket.find({ filename: key }).toArray();
|
|
114
|
+
for (const file of files) {
|
|
115
|
+
await bucket.delete(file._id);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
async list(prefix) {
|
|
119
|
+
const bucket = await this.bucket();
|
|
120
|
+
const pattern = new RegExp("^" + (prefix || "media/").replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
|
121
|
+
const files = await bucket.find({ filename: { $regex: pattern } }).toArray();
|
|
122
|
+
return files.map((f) => ({
|
|
123
|
+
key: f.filename,
|
|
124
|
+
size: f.length,
|
|
125
|
+
lastModified: f.uploadDate
|
|
126
|
+
}));
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
var REGISTRY_SLOT = /* @__PURE__ */ Symbol.for("next-blogpanel.storage.registry.v1");
|
|
130
|
+
function registry() {
|
|
131
|
+
const store = globalThis;
|
|
132
|
+
let current = store[REGISTRY_SLOT];
|
|
133
|
+
if (!current) {
|
|
134
|
+
current = /* @__PURE__ */ new Map();
|
|
135
|
+
store[REGISTRY_SLOT] = current;
|
|
136
|
+
}
|
|
137
|
+
return current;
|
|
138
|
+
}
|
|
139
|
+
var StorageProviderNotRegisteredError = class extends Error {
|
|
140
|
+
constructor(providerName, available) {
|
|
141
|
+
super(
|
|
142
|
+
`Next BlogPanel: no storage provider named '${providerName}' is registered in this server runtime.
|
|
143
|
+
Resolvable here: ${available.map((n) => `'${n}'`).join(", ")}.
|
|
144
|
+
This is refused rather than quietly falling back to the configured default, because every backend generates identically-shaped keys: a default asked to delete a key it does not hold finds nothing and returns cleanly, silently stranding the real object.
|
|
145
|
+
Fix: call registerStorageProvider(new MyProvider()) before any request is served \u2014 the generated setup module (app/next-blogpanel.setup.ts) is the place, since every scaffolded route imports it. If '${providerName}' is a typo in BLOGPANEL_STORAGE or in a stored Media.provider value, correct that instead.
|
|
146
|
+
See docs/storage.md \u2014 "Custom storage providers".`
|
|
147
|
+
);
|
|
148
|
+
this.name = "StorageProviderNotRegisteredError";
|
|
149
|
+
this.providerName = providerName;
|
|
150
|
+
this.available = available;
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
function registerStorageProvider(provider) {
|
|
154
|
+
if (!provider || typeof provider !== "object") {
|
|
155
|
+
throw new Error(
|
|
156
|
+
"Next BlogPanel: registerStorageProvider() expects a StorageProvider instance."
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
const name = provider.name;
|
|
160
|
+
if (typeof name !== "string" || name.trim() === "") {
|
|
161
|
+
throw new Error(
|
|
162
|
+
"Next BlogPanel: a storage provider must expose a non-empty `name`. That value is written to Media.provider on every upload and read back to route the delete, so a provider without one cannot have its objects deleted."
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
if (BUILT_IN_PROVIDER_NAMES.includes(name)) {
|
|
166
|
+
throw new Error(
|
|
167
|
+
`Next BlogPanel: '${name}' is reserved by a built-in storage provider and cannot be registered over. Media documents already record '${name}', so replacing it would redirect their deletes to a backend that does not hold them \u2014 which strands the objects silently. Give your provider a distinct name.`
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
for (const method of ["upload", "delete", "list"]) {
|
|
171
|
+
if (typeof provider[method] !== "function") {
|
|
172
|
+
throw new Error(
|
|
173
|
+
`Next BlogPanel: storage provider '${name}' is missing a \`${method}()\` method. upload(), delete() and list() are all required \u2014 see docs/storage.md.`
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
registry().set(name, provider);
|
|
178
|
+
return provider;
|
|
179
|
+
}
|
|
180
|
+
function unregisterStorageProvider(name) {
|
|
181
|
+
return registry().delete(name);
|
|
182
|
+
}
|
|
183
|
+
function listStorageProviderNames() {
|
|
184
|
+
return [...BUILT_IN_PROVIDER_NAMES, ...registry().keys()];
|
|
185
|
+
}
|
|
186
|
+
function isStorageProviderRegistered(name) {
|
|
187
|
+
return BUILT_IN_PROVIDER_NAMES.includes(name) || registry().has(name);
|
|
188
|
+
}
|
|
189
|
+
function getStorageProvider() {
|
|
190
|
+
const env = chunkXLYTATRQ_cjs.getEnvConfig();
|
|
191
|
+
const forced = env.BLOGPANEL_STORAGE?.trim();
|
|
192
|
+
if (forced) return getStorageProviderByName(forced);
|
|
193
|
+
return chunkXLYTATRQ_cjs.isR2Configured() ? new R2StorageProvider() : new GridFSStorageProvider();
|
|
194
|
+
}
|
|
195
|
+
function getStorageProviderByName(name) {
|
|
196
|
+
if (name === "gridfs") return new GridFSStorageProvider();
|
|
197
|
+
if (name === "r2") return new R2StorageProvider();
|
|
198
|
+
const registered = registry().get(name);
|
|
199
|
+
if (registered) return registered;
|
|
200
|
+
throw new StorageProviderNotRegisteredError(name, listStorageProviderNames());
|
|
201
|
+
}
|
|
202
|
+
async function rollbackUploads(storage, keys, context) {
|
|
203
|
+
await Promise.allSettled(
|
|
204
|
+
keys.map(
|
|
205
|
+
(key) => storage.delete(key).catch((cleanupError) => {
|
|
206
|
+
console.error(
|
|
207
|
+
`[blogpanel] Failed to clean up orphaned upload after ${context} (provider=${storage.name}, key=${key}):`,
|
|
208
|
+
cleanupError
|
|
209
|
+
);
|
|
210
|
+
})
|
|
211
|
+
)
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
exports.BUILT_IN_PROVIDER_NAMES = BUILT_IN_PROVIDER_NAMES;
|
|
216
|
+
exports.GRIDFS_BUCKET = GRIDFS_BUCKET;
|
|
217
|
+
exports.GridFSStorageProvider = GridFSStorageProvider;
|
|
218
|
+
exports.R2StorageProvider = R2StorageProvider;
|
|
219
|
+
exports.StorageProviderNotRegisteredError = StorageProviderNotRegisteredError;
|
|
220
|
+
exports.generateKey = generateKey;
|
|
221
|
+
exports.getStorageProvider = getStorageProvider;
|
|
222
|
+
exports.getStorageProviderByName = getStorageProviderByName;
|
|
223
|
+
exports.isStorageProviderRegistered = isStorageProviderRegistered;
|
|
224
|
+
exports.listStorageProviderNames = listStorageProviderNames;
|
|
225
|
+
exports.registerStorageProvider = registerStorageProvider;
|
|
226
|
+
exports.rollbackUploads = rollbackUploads;
|
|
227
|
+
exports.unregisterStorageProvider = unregisterStorageProvider;
|
|
228
|
+
//# sourceMappingURL=chunk-4GK22Q3R.cjs.map
|
|
229
|
+
//# sourceMappingURL=chunk-4GK22Q3R.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/lib/storage.ts"],"names":["isR2Configured","getEnvConfig","S3Client","randomUUID","PutObjectCommand","DeleteObjectCommand","ListObjectsV2Command","getDb","GridFSBucket","requireApiPath"],"mappings":"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","file":"chunk-4GK22Q3R.cjs","sourcesContent":["import {\n S3Client,\n PutObjectCommand,\n DeleteObjectCommand,\n ListObjectsV2Command,\n} from '@aws-sdk/client-s3';\nimport { GridFSBucket } from 'mongodb';\nimport { getEnvConfig, isR2Configured, requireApiPath } from './config';\nimport { getDb } from './db';\nimport { randomUUID } from 'crypto';\n\nexport interface MediaUploadResult {\n key: string;\n url: string;\n size: number;\n contentType: string;\n}\n\nexport interface StorageObject {\n key: string;\n size: number;\n lastModified: Date;\n}\n\n/**\n * The contract every storage backend satisfies — public API, implementable\n * from outside the package.\n *\n * `name` is deliberately a free-form string rather than a union of the two\n * built-ins: it is written verbatim to `Media.provider` on every upload and\n * read back to route the delete, so a third backend that cannot state its own\n * name cannot have its objects deleted. See `registerStorageProvider()` for\n * how a name is made resolvable, and docs/storage.md for what an\n * implementation must guarantee.\n */\nexport interface StorageProvider {\n /**\n * Stable identifier for this backend, persisted to `Media.provider`.\n *\n * Must be stable across deploys — changing it strands every object already\n * recorded under the old name, because `getStorageProviderByName()` will no\n * longer resolve it. `'gridfs'` and `'r2'` are reserved for the built-ins.\n */\n readonly name: string;\n upload(file: Buffer, filename: string, contentType: string): Promise<MediaUploadResult>;\n delete(key: string): Promise<void>;\n list(prefix?: string): Promise<StorageObject[]>;\n}\n\n/**\n * Names owned by the built-in providers. Reserved: registering over them\n * would silently redirect deletes for every object already recorded under\n * that name to a different backend.\n */\nexport const BUILT_IN_PROVIDER_NAMES = ['gridfs', 'r2'] as const;\n\nlet clientInstance: S3Client | null = null;\n\nfunction getS3Client(): S3Client {\n if (clientInstance) return clientInstance;\n\n if (!isR2Configured()) {\n throw new Error(\n 'Cloudflare R2 is not configured. Set BLOGPANEL_R2_ACCOUNT_ID, BLOGPANEL_R2_ACCESS_KEY, BLOGPANEL_R2_SECRET_KEY, BLOGPANEL_R2_BUCKET, and BLOGPANEL_R2_PUBLIC_URL environment variables to enable image uploads.'\n );\n }\n\n const env = getEnvConfig();\n clientInstance = new S3Client({\n region: 'auto',\n endpoint: `https://${env.BLOGPANEL_R2_ACCOUNT_ID!}.r2.cloudflarestorage.com`,\n credentials: {\n accessKeyId: env.BLOGPANEL_R2_ACCESS_KEY!,\n secretAccessKey: env.BLOGPANEL_R2_SECRET_KEY!,\n },\n });\n\n return clientInstance;\n}\n\nexport function generateKey(filename: string): string {\n const now = new Date();\n const year = now.getFullYear();\n const month = String(now.getMonth() + 1).padStart(2, '0');\n const uuid = randomUUID().split('-')[0];\n const safeName = filename.replace(/[^a-zA-Z0-9._-]/g, '-').toLowerCase();\n return `media/${year}/${month}/${uuid}-${safeName}`;\n}\n\nexport class R2StorageProvider implements StorageProvider {\n readonly name = 'r2' as const;\n\n async upload(\n file: Buffer,\n filename: string,\n contentType: string\n ): Promise<MediaUploadResult> {\n const env = getEnvConfig();\n const client = getS3Client();\n const key = generateKey(filename);\n\n await client.send(\n new PutObjectCommand({\n Bucket: env.BLOGPANEL_R2_BUCKET,\n Key: key,\n Body: file,\n ContentType: contentType,\n })\n );\n\n return {\n key,\n url: `${env.BLOGPANEL_R2_PUBLIC_URL}/${key}`,\n size: file.length,\n contentType,\n };\n }\n\n async delete(key: string): Promise<void> {\n const env = getEnvConfig();\n const client = getS3Client();\n\n await client.send(\n new DeleteObjectCommand({\n Bucket: env.BLOGPANEL_R2_BUCKET,\n Key: key,\n })\n );\n }\n\n async list(prefix?: string): Promise<StorageObject[]> {\n const env = getEnvConfig();\n const client = getS3Client();\n\n const response = await client.send(\n new ListObjectsV2Command({\n Bucket: env.BLOGPANEL_R2_BUCKET,\n Prefix: prefix || 'media/',\n })\n );\n\n return (response.Contents || []).map((obj) => ({\n key: obj.Key || '',\n size: obj.Size || 0,\n lastModified: obj.LastModified || new Date(),\n }));\n }\n}\n\nexport const GRIDFS_BUCKET = 'media';\n\nexport class GridFSStorageProvider implements StorageProvider {\n readonly name = 'gridfs' as const;\n\n private async bucket(): Promise<GridFSBucket> {\n const db = await getDb();\n return new GridFSBucket(db, { bucketName: GRIDFS_BUCKET });\n }\n\n async upload(\n file: Buffer,\n filename: string,\n contentType: string\n ): Promise<MediaUploadResult> {\n // requireApiPath, not getConfig().apiPath: this URL is persisted into the\n // media document and into the body of every post that embeds the image,\n // so a default guessed in place of a config that never reached the server\n // would bake in a route that does not exist — and correcting the config\n // afterwards would not repair a single already-written reference. Fail\n // loudly instead, and fail *before* the bytes are written so a refused\n // upload leaves nothing behind in the bucket.\n const apiPath = requireApiPath('the URL for this GridFS upload');\n\n const bucket = await this.bucket();\n const key = generateKey(filename);\n\n await new Promise<void>((resolve, reject) => {\n const stream = bucket.openUploadStream(key, { contentType });\n stream.on('error', reject);\n stream.on('finish', () => resolve());\n stream.end(file);\n });\n\n return {\n key,\n url: `${apiPath}/media/file/${key}`,\n size: file.length,\n contentType,\n };\n }\n\n async delete(key: string): Promise<void> {\n const bucket = await this.bucket();\n const files = await bucket.find({ filename: key }).toArray();\n for (const file of files) {\n await bucket.delete(file._id);\n }\n }\n\n async list(prefix?: string): Promise<StorageObject[]> {\n const bucket = await this.bucket();\n const pattern = new RegExp('^' + (prefix || 'media/').replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&'));\n const files = await bucket.find({ filename: { $regex: pattern } }).toArray();\n\n return files.map((f) => ({\n key: f.filename,\n size: f.length,\n lastModified: f.uploadDate,\n }));\n }\n}\n\n// ============================================================\n// Custom provider registry\n// ============================================================\n\n/**\n * Where registered third-party providers live.\n *\n * Same `globalThis`-keyed-by-`Symbol.for()` treatment as `setConfig()` in\n * config.ts, and for the same reason rather than by imitation. Next.js\n * instantiates server modules per route entry: a `Map` held in module scope\n * and populated while the bundle for `/api/blog/media` was being evaluated is\n * not guaranteed to be the same `Map` the bundle serving some other route\n * reads, and in a production build with per-route bundling it usually is not.\n *\n * The consequence of getting that wrong is worse here than it is for config.\n * A config that fails to propagate produces a wrong-but-uniform apiPath; a\n * registry that fails to propagate means `getStorageProviderByName('my-cdn')`\n * resolves on the route that happened to register and throws on every other\n * one — a backend that works during upload and fails during delete, depending\n * on which route entry the request landed in. Registration must therefore be\n * process-wide, exactly like the standard Next.js MongoClient singleton, and\n * the symbol is registry-keyed so two bundled copies of this module (or two\n * versions of the package in one tree) still address the same slot.\n */\nconst REGISTRY_SLOT = Symbol.for('next-blogpanel.storage.registry.v1');\n\nfunction registry(): Map<string, StorageProvider> {\n const store = globalThis as unknown as Record<symbol, Map<string, StorageProvider> | undefined>;\n let current = store[REGISTRY_SLOT];\n if (!current) {\n current = new Map<string, StorageProvider>();\n store[REGISTRY_SLOT] = current;\n }\n return current;\n}\n\n/**\n * Thrown when a provider name cannot be resolved to a backend.\n *\n * A dedicated class rather than a string-matched `Error`, for the same reason\n * as `ConfigNotInstalledError`: `POST`/`DELETE /media` need to tell \"this\n * install never registered that provider\" apart from any other failure so\n * they can pass the actionable message through instead of flattening it to a\n * generic 500, and an `error.message.startsWith(...)` check silently stops\n * working the moment the wording changes.\n *\n * There is deliberately no fallback to the configured default in its place.\n * `generateKey()` produces identically-shaped keys on every backend, so a\n * default asked to delete a key it does not hold finds nothing and returns\n * cleanly — the object is stranded and nothing reports it. Writing to the\n * wrong backend is the same failure with the bytes going somewhere the stored\n * URL does not point.\n */\nexport class StorageProviderNotRegisteredError extends Error {\n /** The unresolvable name, for callers that want to report it structurally. */\n readonly providerName: string;\n /** Every name that *is* resolvable in this process, built-ins included. */\n readonly available: string[];\n\n constructor(providerName: string, available: string[]) {\n super(\n `Next BlogPanel: no storage provider named '${providerName}' is registered in this server runtime.\\n` +\n ` Resolvable here: ${available.map((n) => `'${n}'`).join(', ')}.\\n` +\n ` This is refused rather than quietly falling back to the configured default, because every backend generates identically-shaped keys: a default asked to delete a key it does not hold finds nothing and returns cleanly, silently stranding the real object.\\n` +\n ` Fix: call registerStorageProvider(new MyProvider()) before any request is served — the generated setup module (app/next-blogpanel.setup.ts) is the place, since every scaffolded route imports it. If '${providerName}' is a typo in BLOGPANEL_STORAGE or in a stored Media.provider value, correct that instead.\\n` +\n ` See docs/storage.md — \"Custom storage providers\".`\n );\n this.name = 'StorageProviderNotRegisteredError';\n this.providerName = providerName;\n this.available = available;\n }\n}\n\n/**\n * Makes a custom backend resolvable by name for the lifetime of this process.\n *\n * Call it before any request is served. The generated `next-blogpanel.setup`\n * module is the intended home: every scaffolded route imports it, so whichever\n * route entry a request lands on first has already run registration.\n *\n * Calling it more than once with the same name is expected and is a silent\n * last-write-wins overwrite, not an error — each per-route copy of the setup\n * module evaluates its own `new MyProvider()`, so the duplicate registrations\n * are a normal consequence of the bundling, exactly as repeated `setConfig()`\n * calls are.\n *\n * The registered value is an instance, not a factory, and is shared by every\n * concurrent request in the process — it must be safe to use that way.\n */\nexport function registerStorageProvider(provider: StorageProvider): StorageProvider {\n if (!provider || typeof provider !== 'object') {\n throw new Error(\n 'Next BlogPanel: registerStorageProvider() expects a StorageProvider instance.'\n );\n }\n\n const name = provider.name;\n if (typeof name !== 'string' || name.trim() === '') {\n throw new Error(\n 'Next BlogPanel: a storage provider must expose a non-empty `name`. That value is written to ' +\n 'Media.provider on every upload and read back to route the delete, so a provider without one ' +\n 'cannot have its objects deleted.'\n );\n }\n\n if ((BUILT_IN_PROVIDER_NAMES as readonly string[]).includes(name)) {\n throw new Error(\n `Next BlogPanel: '${name}' is reserved by a built-in storage provider and cannot be registered over. ` +\n `Media documents already record '${name}', so replacing it would redirect their deletes to a backend ` +\n `that does not hold them — which strands the objects silently. Give your provider a distinct name.`\n );\n }\n\n for (const method of ['upload', 'delete', 'list'] as const) {\n if (typeof provider[method] !== 'function') {\n throw new Error(\n `Next BlogPanel: storage provider '${name}' is missing a \\`${method}()\\` method. ` +\n `upload(), delete() and list() are all required — see docs/storage.md.`\n );\n }\n }\n\n registry().set(name, provider);\n return provider;\n}\n\n/** Removes a previously registered custom provider. Built-in names are not removable. */\nexport function unregisterStorageProvider(name: string): boolean {\n return registry().delete(name);\n}\n\n/** Every provider name resolvable in this process, built-ins first. */\nexport function listStorageProviderNames(): string[] {\n return [...BUILT_IN_PROVIDER_NAMES, ...registry().keys()];\n}\n\n/** True when `name` resolves to a backend without throwing. */\nexport function isStorageProviderRegistered(name: string): boolean {\n return (BUILT_IN_PROVIDER_NAMES as readonly string[]).includes(name) || registry().has(name);\n}\n\n/** Test-only: drops every registered custom provider. */\nexport function resetStorageProviders(): void {\n const store = globalThis as unknown as Record<symbol, unknown>;\n delete store[REGISTRY_SLOT];\n}\n\n// ============================================================\n// Resolution\n// ============================================================\n\n/**\n * Resolves the backend new uploads should go to.\n *\n * `BLOGPANEL_STORAGE` forces a specific one by name — including a registered\n * custom provider — and an unresolvable value throws rather than quietly\n * reverting to the automatic choice. Otherwise R2 is selected when all five\n * of its variables are present, and GridFS is the zero-setup default.\n */\nexport function getStorageProvider(): StorageProvider {\n const env = getEnvConfig();\n\n const forced = env.BLOGPANEL_STORAGE?.trim();\n if (forced) return getStorageProviderByName(forced);\n\n return isR2Configured() ? new R2StorageProvider() : new GridFSStorageProvider();\n}\n\n/**\n * Resolves the provider a specific object was stored under, by name — not by\n * whatever backend happens to be configured right now.\n *\n * `getStorageProvider()` answers \"what should new uploads use today?\", which\n * can diverge from \"what actually holds this particular object?\" once a\n * deployment switches backends (e.g. GridFS -> R2). `generateKey()` produces\n * identically-shaped keys on every provider, so asking the wrong one to\n * delete a key doesn't error — it just finds nothing and returns cleanly,\n * silently orphaning the real object. Callers that know which backend a\n * document was written under (`Media.provider`) must use this instead.\n *\n * The two built-in names always resolve; anything else must have been handed\n * to `registerStorageProvider()` in this process. An unknown name throws\n * `StorageProviderNotRegisteredError` — it never degrades to a default,\n * because that is the exact shape of the silent-stranding bug above.\n */\nexport function getStorageProviderByName(name: string): StorageProvider {\n if (name === 'gridfs') return new GridFSStorageProvider();\n if (name === 'r2') return new R2StorageProvider();\n\n const registered = registry().get(name);\n if (registered) return registered;\n\n throw new StorageProviderNotRegisteredError(name, listStorageProviderNames());\n}\n\n/**\n * Best-effort cleanup of storage objects after a failure elsewhere in the\n * upload pipeline (a derivative failed after the original landed, or a DB\n * write failed after every object uploaded successfully). Never throws — a\n * failed cleanup is logged with enough detail for manual recovery, never\n * allowed to mask the error that triggered the rollback.\n */\nexport async function rollbackUploads(\n storage: StorageProvider,\n keys: string[],\n context: string\n): Promise<void> {\n await Promise.allSettled(\n keys.map((key) =>\n storage.delete(key).catch((cleanupError) => {\n console.error(\n `[blogpanel] Failed to clean up orphaned upload after ${context} ` +\n `(provider=${storage.name}, key=${key}):`,\n cleanupError\n );\n })\n )\n );\n}\n"]}
|