opacacms 0.1.13 → 0.1.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/admin/webcomponent.js +2 -9630
- package/dist/{chunk-0d9aqz6z.js → chunk-5ftxp8m2.js} +290 -288
- package/dist/runtimes/bun.js +1 -1
- package/dist/runtimes/cloudflare-workers.js +1 -1
- package/dist/runtimes/next.js +1 -1
- package/dist/runtimes/node.js +1 -1
- package/dist/server/routers/admin.d.ts +9 -0
- package/dist/server/routers/auth.d.ts +9 -0
- package/dist/server/routers/collections.d.ts +20 -0
- package/dist/server.js +1 -1
- package/package.json +3 -3
|
@@ -736,9 +736,9 @@ function createGlobalHandlers(config, globalConfig, getAuth) {
|
|
|
736
736
|
}
|
|
737
737
|
|
|
738
738
|
// src/server/router.ts
|
|
739
|
-
import { Hono as
|
|
739
|
+
import { Hono as Hono4 } from "hono";
|
|
740
740
|
|
|
741
|
-
// src/server/admin
|
|
741
|
+
// src/server/routers/admin.ts
|
|
742
742
|
import { Hono } from "hono";
|
|
743
743
|
|
|
744
744
|
// src/server/middlewares/admin.ts
|
|
@@ -759,7 +759,7 @@ var adminMiddleware = async (c, next) => {
|
|
|
759
759
|
return c.json({ message: "Forbidden" }, 403);
|
|
760
760
|
};
|
|
761
761
|
|
|
762
|
-
// src/server/admin
|
|
762
|
+
// src/server/routers/admin.ts
|
|
763
763
|
function createAdminRouter(config, state) {
|
|
764
764
|
const adminRouter = new Hono;
|
|
765
765
|
const adminHandlers = createAdminHandlers(config, () => state.auth);
|
|
@@ -771,8 +771,259 @@ function createAdminRouter(config, state) {
|
|
|
771
771
|
return adminRouter;
|
|
772
772
|
}
|
|
773
773
|
|
|
774
|
-
// src/server/
|
|
774
|
+
// src/server/routers/auth.ts
|
|
775
|
+
import { Hono as Hono2 } from "hono";
|
|
776
|
+
function createAuthRouter(config, state) {
|
|
777
|
+
const authRouter = new Hono2;
|
|
778
|
+
authRouter.all("/*", async (c) => {
|
|
779
|
+
if (!state.auth) {
|
|
780
|
+
return c.json({ message: "Auth not initialized" }, 503);
|
|
781
|
+
}
|
|
782
|
+
return await state.auth.handler(c.req.raw);
|
|
783
|
+
});
|
|
784
|
+
return authRouter;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// src/server/routers/collections.ts
|
|
775
788
|
init_system_schema();
|
|
789
|
+
import { Hono as Hono3 } from "hono";
|
|
790
|
+
|
|
791
|
+
// src/server/assets.ts
|
|
792
|
+
function createAssetsHandlers(config) {
|
|
793
|
+
return {
|
|
794
|
+
async upload(c) {
|
|
795
|
+
const user = c.get("user");
|
|
796
|
+
if (!user)
|
|
797
|
+
return c.json({ error: "Unauthorized" }, 401);
|
|
798
|
+
const bucket = c.req.query("bucket") || "default";
|
|
799
|
+
if (!config.storages)
|
|
800
|
+
return c.json({ error: "Storage not configured" }, 500);
|
|
801
|
+
const storageAdapter = config.storages[bucket];
|
|
802
|
+
if (!storageAdapter) {
|
|
803
|
+
return c.json({ error: `Bucket '${bucket}' not found` }, 404);
|
|
804
|
+
}
|
|
805
|
+
try {
|
|
806
|
+
try {
|
|
807
|
+
if (config.db.name === "sqlite" || config.db.name === "d1") {
|
|
808
|
+
const tableInfo = await config.db.unsafe(`PRAGMA table_info(_opaca_assets)`);
|
|
809
|
+
const columns = tableInfo.map((c2) => c2.name);
|
|
810
|
+
if (!columns.includes("folder"))
|
|
811
|
+
await config.db.unsafe(`ALTER TABLE _opaca_assets ADD COLUMN folder TEXT`);
|
|
812
|
+
if (!columns.includes("alt_text"))
|
|
813
|
+
await config.db.unsafe(`ALTER TABLE _opaca_assets ADD COLUMN alt_text TEXT`);
|
|
814
|
+
if (!columns.includes("caption"))
|
|
815
|
+
await config.db.unsafe(`ALTER TABLE _opaca_assets ADD COLUMN caption TEXT`);
|
|
816
|
+
} else if (config.db.name === "postgres") {
|
|
817
|
+
const checkCols = await config.db.unsafe(`
|
|
818
|
+
SELECT column_name FROM information_schema.columns
|
|
819
|
+
WHERE table_name = '_opaca_assets' AND column_name IN ('folder', 'alt_text', 'caption')
|
|
820
|
+
`);
|
|
821
|
+
const existing = checkCols.map((c2) => c2.column_name);
|
|
822
|
+
if (!existing.includes("folder"))
|
|
823
|
+
await config.db.unsafe(`ALTER TABLE _opaca_assets ADD COLUMN folder TEXT`);
|
|
824
|
+
if (!existing.includes("alt_text"))
|
|
825
|
+
await config.db.unsafe(`ALTER TABLE _opaca_assets ADD COLUMN "alt_text" TEXT`);
|
|
826
|
+
if (!existing.includes("caption"))
|
|
827
|
+
await config.db.unsafe(`ALTER TABLE _opaca_assets ADD COLUMN caption TEXT`);
|
|
828
|
+
}
|
|
829
|
+
} catch (e) {
|
|
830
|
+
console.error("Auto-patch columns failed", e);
|
|
831
|
+
}
|
|
832
|
+
const folder = c.req.query("folder") || null;
|
|
833
|
+
const keyPrefix = folder ? `${folder}/` : "";
|
|
834
|
+
const now = new Date().toISOString();
|
|
835
|
+
const formData = await c.req.parseBody({ all: true });
|
|
836
|
+
const fileRaw = formData["file"];
|
|
837
|
+
const file = Array.isArray(fileRaw) ? fileRaw[0] : fileRaw;
|
|
838
|
+
if (!file || typeof file !== "object" && typeof file !== "string") {
|
|
839
|
+
return c.json({ error: "No file provided" }, 400);
|
|
840
|
+
}
|
|
841
|
+
const fileName = file.name || "unnamed";
|
|
842
|
+
const fileType = file.type || "application/octet-stream";
|
|
843
|
+
const fileSize = file.size || 0;
|
|
844
|
+
const fileRecord = {
|
|
845
|
+
filename: fileName,
|
|
846
|
+
original_filename: fileName,
|
|
847
|
+
mime_type: fileType,
|
|
848
|
+
filesize: fileSize,
|
|
849
|
+
stream: typeof file.stream === "function" ? file.stream() : new Response(file).body
|
|
850
|
+
};
|
|
851
|
+
const uploadedFileData = await storageAdapter.upload(fileRecord, {
|
|
852
|
+
generateUniqueName: true,
|
|
853
|
+
keyPrefix
|
|
854
|
+
});
|
|
855
|
+
const storedKey = keyPrefix + uploadedFileData.filename;
|
|
856
|
+
try {
|
|
857
|
+
const assetId = (globalThis.crypto?.randomUUID?.() || Math.random().toString(36).slice(2)).replace(/-/g, "");
|
|
858
|
+
await config.db.create("_opaca_assets", {
|
|
859
|
+
id: assetId,
|
|
860
|
+
key: storedKey,
|
|
861
|
+
filename: fileName,
|
|
862
|
+
originalFilename: fileName,
|
|
863
|
+
mimeType: uploadedFileData.mime_type,
|
|
864
|
+
filesize: uploadedFileData.filesize,
|
|
865
|
+
bucket,
|
|
866
|
+
folder,
|
|
867
|
+
altText: null,
|
|
868
|
+
caption: null,
|
|
869
|
+
uploadedBy: user.id || null
|
|
870
|
+
});
|
|
871
|
+
return c.json({
|
|
872
|
+
assetId,
|
|
873
|
+
...uploadedFileData,
|
|
874
|
+
key: storedKey
|
|
875
|
+
}, 201);
|
|
876
|
+
} catch (dbError) {
|
|
877
|
+
console.error(`[OpacaCMS] Registry insert failed, rolling back physical file upload: ${storedKey}`);
|
|
878
|
+
storageAdapter.delete(storedKey).catch((cleanupError) => {
|
|
879
|
+
console.error(`[OpacaCMS] CRITICAL: Failed to clean up orphaned file ${storedKey}!`, cleanupError);
|
|
880
|
+
});
|
|
881
|
+
throw dbError;
|
|
882
|
+
}
|
|
883
|
+
} catch (error) {
|
|
884
|
+
return c.json({ error: error.message }, 400);
|
|
885
|
+
}
|
|
886
|
+
},
|
|
887
|
+
async list(c) {
|
|
888
|
+
const user = c.get("user");
|
|
889
|
+
if (!user || user.role !== "admin" && !user.role?.includes("admin")) {
|
|
890
|
+
return c.json({ error: "Unauthorized" }, 401);
|
|
891
|
+
}
|
|
892
|
+
const bucket = c.req.query("bucket") || "all";
|
|
893
|
+
const page = parseInt(c.req.query("page") || "1", 10);
|
|
894
|
+
const limit = parseInt(c.req.query("limit") || "20", 10);
|
|
895
|
+
const offset = (page - 1) * limit;
|
|
896
|
+
const folder = c.req.query("folder") || null;
|
|
897
|
+
try {
|
|
898
|
+
let query = {};
|
|
899
|
+
if (bucket !== "all")
|
|
900
|
+
query.bucket = bucket;
|
|
901
|
+
if (folder !== null && folder !== "") {
|
|
902
|
+
query.folder = folder;
|
|
903
|
+
} else {
|
|
904
|
+
if (bucket !== "all") {
|
|
905
|
+
query = {
|
|
906
|
+
and: [{ bucket }, { or: [{ folder: null }, { folder: "" }] }]
|
|
907
|
+
};
|
|
908
|
+
} else {
|
|
909
|
+
query = { or: [{ folder: null }, { folder: "" }] };
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
const result = await config.db.find("_opaca_assets", query, {
|
|
913
|
+
page,
|
|
914
|
+
limit,
|
|
915
|
+
sort: "created_at:desc"
|
|
916
|
+
});
|
|
917
|
+
const rows = result.docs;
|
|
918
|
+
const total = result.totalDocs;
|
|
919
|
+
let folderRows = [];
|
|
920
|
+
const bucketFilter = bucket !== "all" ? `AND bucket = ?` : "";
|
|
921
|
+
const bucketParam = bucket !== "all" ? [bucket] : [];
|
|
922
|
+
if (config.db.name === "postgres") {
|
|
923
|
+
const pgBucketFilter = bucketFilter.replace("?", "$1");
|
|
924
|
+
if (folder === null || folder === "") {
|
|
925
|
+
folderRows = await config.db.unsafe(`SELECT DISTINCT split_part(folder, '/', 1) as subfolder, bucket FROM _opaca_assets WHERE folder IS NOT NULL AND folder != '' ${pgBucketFilter}`, bucketParam);
|
|
926
|
+
} else {
|
|
927
|
+
folderRows = await config.db.unsafe(`SELECT DISTINCT split_part(substring(folder from length($1) + 2), '/', 1) as subfolder, bucket FROM _opaca_assets WHERE folder LIKE $2 ${bucket !== "all" ? "AND bucket = $3" : ""}`, [folder, `${folder}/%`, ...bucketParam]);
|
|
928
|
+
}
|
|
929
|
+
} else {
|
|
930
|
+
if (folder === null || folder === "") {
|
|
931
|
+
folderRows = await config.db.unsafe(`
|
|
932
|
+
SELECT DISTINCT
|
|
933
|
+
CASE
|
|
934
|
+
WHEN INSTR(folder, '/') > 0 THEN SUBSTR(folder, 1, INSTR(folder, '/') - 1)
|
|
935
|
+
ELSE folder
|
|
936
|
+
END as subfolder,
|
|
937
|
+
bucket
|
|
938
|
+
FROM _opaca_assets WHERE folder IS NOT NULL AND folder != '' ${bucketFilter}
|
|
939
|
+
`, bucketParam);
|
|
940
|
+
} else {
|
|
941
|
+
const skipLen = folder.length + 2;
|
|
942
|
+
folderRows = await config.db.unsafe(`
|
|
943
|
+
SELECT DISTINCT
|
|
944
|
+
CASE
|
|
945
|
+
WHEN INSTR(SUBSTR(folder, ?), '/') > 0 THEN SUBSTR(SUBSTR(folder, ?), 1, INSTR(SUBSTR(folder, ?), '/') - 1)
|
|
946
|
+
ELSE SUBSTR(folder, ?)
|
|
947
|
+
END as subfolder,
|
|
948
|
+
bucket
|
|
949
|
+
FROM _opaca_assets WHERE folder LIKE ? ${bucketFilter}
|
|
950
|
+
`, [skipLen, skipLen, skipLen, skipLen, `${folder}/%`, ...bucketParam]);
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
const folderMap = {};
|
|
954
|
+
for (const row of folderRows) {
|
|
955
|
+
if (!row.subfolder)
|
|
956
|
+
continue;
|
|
957
|
+
if (!folderMap[row.subfolder])
|
|
958
|
+
folderMap[row.subfolder] = [];
|
|
959
|
+
if (!folderMap[row.subfolder]?.includes(row.bucket)) {
|
|
960
|
+
folderMap[row.subfolder]?.push(row.bucket);
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
const folders = Object.entries(folderMap).map(([name, buckets]) => ({
|
|
964
|
+
name,
|
|
965
|
+
buckets
|
|
966
|
+
}));
|
|
967
|
+
return c.json({
|
|
968
|
+
docs: rows,
|
|
969
|
+
folders,
|
|
970
|
+
totalDocs: total,
|
|
971
|
+
limit,
|
|
972
|
+
page,
|
|
973
|
+
totalPages: Math.ceil(total / limit)
|
|
974
|
+
});
|
|
975
|
+
} catch (e) {
|
|
976
|
+
return c.json({ error: e.message }, 500);
|
|
977
|
+
}
|
|
978
|
+
},
|
|
979
|
+
async presign(c) {
|
|
980
|
+
const user = c.get("user");
|
|
981
|
+
if (!user)
|
|
982
|
+
return c.json({ error: "Unauthorized" }, 401);
|
|
983
|
+
const { filename, bucket = "default", operation = "write" } = await c.req.json();
|
|
984
|
+
if (!config.storages || !config.storages[bucket]) {
|
|
985
|
+
return c.json({ error: "Bucket not found" }, 404);
|
|
986
|
+
}
|
|
987
|
+
const adapter = config.storages[bucket];
|
|
988
|
+
if (!adapter.generatePresignedUrl) {
|
|
989
|
+
return c.json({ error: "Adapter does not support presigned URLs" }, 400);
|
|
990
|
+
}
|
|
991
|
+
try {
|
|
992
|
+
const url = await adapter.generatePresignedUrl(filename, operation, 3600);
|
|
993
|
+
return c.json({ uploadUrl: url, filename });
|
|
994
|
+
} catch (e) {
|
|
995
|
+
return c.json({ error: e.message }, 500);
|
|
996
|
+
}
|
|
997
|
+
},
|
|
998
|
+
async serve(c) {
|
|
999
|
+
const id = c.req.param("id");
|
|
1000
|
+
try {
|
|
1001
|
+
const asset = await config.db.findOne("_opaca_assets", { id });
|
|
1002
|
+
if (!asset) {
|
|
1003
|
+
return c.json({ error: "Asset not found" }, 404);
|
|
1004
|
+
}
|
|
1005
|
+
const bucket = asset.bucket || "default";
|
|
1006
|
+
if (!config.storages || !config.storages[bucket]) {
|
|
1007
|
+
return c.json({ error: "Storage bucket not configured" }, 500);
|
|
1008
|
+
}
|
|
1009
|
+
const adapter = config.storages[bucket];
|
|
1010
|
+
if (!adapter.download) {
|
|
1011
|
+
return c.json({ error: "Storage adapter does not support direct downloads" }, 400);
|
|
1012
|
+
}
|
|
1013
|
+
const stream = await adapter.download(asset.key || asset.filename);
|
|
1014
|
+
c.header("Content-Type", asset.mimeType || "application/octet-stream");
|
|
1015
|
+
c.header("Content-Length", asset.filesize.toString());
|
|
1016
|
+
c.header("Cache-Control", "public, max-age=86400");
|
|
1017
|
+
return c.body(stream);
|
|
1018
|
+
} catch (e) {
|
|
1019
|
+
console.error(`[OpacaCMS] Failed to serve asset ${id}:`, e);
|
|
1020
|
+
return c.json({ error: e.message }, 500);
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
};
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
// src/server/routers/collections.ts
|
|
776
1027
|
function mountCollectionRoutes(router, config, state) {
|
|
777
1028
|
const combinedCollections = [...config.collections];
|
|
778
1029
|
for (const systemCol of getSystemCollections()) {
|
|
@@ -804,18 +1055,38 @@ function mountGlobalRoutes(router, config, state) {
|
|
|
804
1055
|
}
|
|
805
1056
|
}
|
|
806
1057
|
}
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
1058
|
+
function createSystemRouter(config) {
|
|
1059
|
+
const systemRouter = new Hono3;
|
|
1060
|
+
if (config.storages) {
|
|
1061
|
+
const assetsHandlers = createAssetsHandlers(config);
|
|
1062
|
+
systemRouter.post("/assets/upload", adminMiddleware, assetsHandlers.upload);
|
|
1063
|
+
systemRouter.get("/assets", adminMiddleware, assetsHandlers.list);
|
|
1064
|
+
systemRouter.post("/assets/presign-upload", adminMiddleware, assetsHandlers.presign);
|
|
1065
|
+
}
|
|
1066
|
+
return systemRouter;
|
|
1067
|
+
}
|
|
1068
|
+
function createAssetsServingRouter(config) {
|
|
1069
|
+
const assetsServingRouter = new Hono3;
|
|
1070
|
+
if (config.storages) {
|
|
1071
|
+
const assetsHandlers = createAssetsHandlers(config);
|
|
1072
|
+
const assetCol = getSystemCollections().find((c) => c.slug === "_opaca_assets");
|
|
1073
|
+
const assetPath = `/${assetCol?.apiPath || assetCol?.slug || "_opaca_assets"}`;
|
|
1074
|
+
assetsServingRouter.get(`${assetPath}/:id/view`, assetsHandlers.serve);
|
|
1075
|
+
}
|
|
1076
|
+
return assetsServingRouter;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
// src/server/middlewares/auth.ts
|
|
1080
|
+
function createAuthMiddleware(getAuth) {
|
|
1081
|
+
return async (c, next) => {
|
|
1082
|
+
const auth = getAuth();
|
|
1083
|
+
if (!auth) {
|
|
1084
|
+
c.set("user", null);
|
|
1085
|
+
c.set("session", null);
|
|
1086
|
+
c.set("apiKey", null);
|
|
1087
|
+
await next();
|
|
1088
|
+
return;
|
|
1089
|
+
}
|
|
819
1090
|
const session = await auth.api.getSession({ headers: c.req.raw.headers });
|
|
820
1091
|
if (session) {
|
|
821
1092
|
c.set("user", session.user);
|
|
@@ -1064,288 +1335,19 @@ function setupMiddlewares(router, config, state) {
|
|
|
1064
1335
|
});
|
|
1065
1336
|
}
|
|
1066
1337
|
function setupAuthMiddlewares(router, config, state) {
|
|
1067
|
-
|
|
1068
|
-
if (supportsAuth) {
|
|
1069
|
-
router.use("*", createAuthMiddleware(() => state.auth));
|
|
1070
|
-
router.on(["POST", "GET"], ["/auth/*"], async (c) => {
|
|
1071
|
-
if (!state.auth) {
|
|
1072
|
-
return c.json({ message: "Auth not initialized" }, 503);
|
|
1073
|
-
}
|
|
1074
|
-
return await state.auth.handler(c.req.raw);
|
|
1075
|
-
});
|
|
1076
|
-
}
|
|
1077
|
-
}
|
|
1078
|
-
|
|
1079
|
-
// src/server/system-router.ts
|
|
1080
|
-
init_system_schema();
|
|
1081
|
-
import { Hono as Hono2 } from "hono";
|
|
1082
|
-
|
|
1083
|
-
// src/server/assets.ts
|
|
1084
|
-
function createAssetsHandlers(config) {
|
|
1085
|
-
return {
|
|
1086
|
-
async upload(c) {
|
|
1087
|
-
const user = c.get("user");
|
|
1088
|
-
if (!user)
|
|
1089
|
-
return c.json({ error: "Unauthorized" }, 401);
|
|
1090
|
-
const bucket = c.req.query("bucket") || "default";
|
|
1091
|
-
if (!config.storages)
|
|
1092
|
-
return c.json({ error: "Storage not configured" }, 500);
|
|
1093
|
-
const storageAdapter = config.storages[bucket];
|
|
1094
|
-
if (!storageAdapter) {
|
|
1095
|
-
return c.json({ error: `Bucket '${bucket}' not found` }, 404);
|
|
1096
|
-
}
|
|
1097
|
-
try {
|
|
1098
|
-
try {
|
|
1099
|
-
if (config.db.name === "sqlite" || config.db.name === "d1") {
|
|
1100
|
-
const tableInfo = await config.db.unsafe(`PRAGMA table_info(_opaca_assets)`);
|
|
1101
|
-
const columns = tableInfo.map((c2) => c2.name);
|
|
1102
|
-
if (!columns.includes("folder"))
|
|
1103
|
-
await config.db.unsafe(`ALTER TABLE _opaca_assets ADD COLUMN folder TEXT`);
|
|
1104
|
-
if (!columns.includes("alt_text"))
|
|
1105
|
-
await config.db.unsafe(`ALTER TABLE _opaca_assets ADD COLUMN alt_text TEXT`);
|
|
1106
|
-
if (!columns.includes("caption"))
|
|
1107
|
-
await config.db.unsafe(`ALTER TABLE _opaca_assets ADD COLUMN caption TEXT`);
|
|
1108
|
-
} else if (config.db.name === "postgres") {
|
|
1109
|
-
const checkCols = await config.db.unsafe(`
|
|
1110
|
-
SELECT column_name FROM information_schema.columns
|
|
1111
|
-
WHERE table_name = '_opaca_assets' AND column_name IN ('folder', 'alt_text', 'caption')
|
|
1112
|
-
`);
|
|
1113
|
-
const existing = checkCols.map((c2) => c2.column_name);
|
|
1114
|
-
if (!existing.includes("folder"))
|
|
1115
|
-
await config.db.unsafe(`ALTER TABLE _opaca_assets ADD COLUMN folder TEXT`);
|
|
1116
|
-
if (!existing.includes("alt_text"))
|
|
1117
|
-
await config.db.unsafe(`ALTER TABLE _opaca_assets ADD COLUMN "alt_text" TEXT`);
|
|
1118
|
-
if (!existing.includes("caption"))
|
|
1119
|
-
await config.db.unsafe(`ALTER TABLE _opaca_assets ADD COLUMN caption TEXT`);
|
|
1120
|
-
}
|
|
1121
|
-
} catch (e) {
|
|
1122
|
-
console.error("Auto-patch columns failed", e);
|
|
1123
|
-
}
|
|
1124
|
-
const folder = c.req.query("folder") || null;
|
|
1125
|
-
const keyPrefix = folder ? `${folder}/` : "";
|
|
1126
|
-
const now = new Date().toISOString();
|
|
1127
|
-
const formData = await c.req.parseBody({ all: true });
|
|
1128
|
-
const fileRaw = formData["file"];
|
|
1129
|
-
const file = Array.isArray(fileRaw) ? fileRaw[0] : fileRaw;
|
|
1130
|
-
if (!file || typeof file !== "object" && typeof file !== "string") {
|
|
1131
|
-
return c.json({ error: "No file provided" }, 400);
|
|
1132
|
-
}
|
|
1133
|
-
const fileName = file.name || "unnamed";
|
|
1134
|
-
const fileType = file.type || "application/octet-stream";
|
|
1135
|
-
const fileSize = file.size || 0;
|
|
1136
|
-
const fileRecord = {
|
|
1137
|
-
filename: fileName,
|
|
1138
|
-
original_filename: fileName,
|
|
1139
|
-
mime_type: fileType,
|
|
1140
|
-
filesize: fileSize,
|
|
1141
|
-
stream: typeof file.stream === "function" ? file.stream() : new Response(file).body
|
|
1142
|
-
};
|
|
1143
|
-
const uploadedFileData = await storageAdapter.upload(fileRecord, {
|
|
1144
|
-
generateUniqueName: true,
|
|
1145
|
-
keyPrefix
|
|
1146
|
-
});
|
|
1147
|
-
const storedKey = keyPrefix + uploadedFileData.filename;
|
|
1148
|
-
try {
|
|
1149
|
-
const assetId = (globalThis.crypto?.randomUUID?.() || Math.random().toString(36).slice(2)).replace(/-/g, "");
|
|
1150
|
-
await config.db.create("_opaca_assets", {
|
|
1151
|
-
id: assetId,
|
|
1152
|
-
key: storedKey,
|
|
1153
|
-
filename: fileName,
|
|
1154
|
-
originalFilename: fileName,
|
|
1155
|
-
mimeType: uploadedFileData.mime_type,
|
|
1156
|
-
filesize: uploadedFileData.filesize,
|
|
1157
|
-
bucket,
|
|
1158
|
-
folder,
|
|
1159
|
-
altText: null,
|
|
1160
|
-
caption: null,
|
|
1161
|
-
uploadedBy: user.id || null
|
|
1162
|
-
});
|
|
1163
|
-
return c.json({
|
|
1164
|
-
assetId,
|
|
1165
|
-
...uploadedFileData,
|
|
1166
|
-
key: storedKey
|
|
1167
|
-
}, 201);
|
|
1168
|
-
} catch (dbError) {
|
|
1169
|
-
console.error(`[OpacaCMS] Registry insert failed, rolling back physical file upload: ${storedKey}`);
|
|
1170
|
-
storageAdapter.delete(storedKey).catch((cleanupError) => {
|
|
1171
|
-
console.error(`[OpacaCMS] CRITICAL: Failed to clean up orphaned file ${storedKey}!`, cleanupError);
|
|
1172
|
-
});
|
|
1173
|
-
throw dbError;
|
|
1174
|
-
}
|
|
1175
|
-
} catch (error) {
|
|
1176
|
-
return c.json({ error: error.message }, 400);
|
|
1177
|
-
}
|
|
1178
|
-
},
|
|
1179
|
-
async list(c) {
|
|
1180
|
-
const user = c.get("user");
|
|
1181
|
-
if (!user || user.role !== "admin" && !user.role?.includes("admin")) {
|
|
1182
|
-
return c.json({ error: "Unauthorized" }, 401);
|
|
1183
|
-
}
|
|
1184
|
-
const bucket = c.req.query("bucket") || "all";
|
|
1185
|
-
const page = parseInt(c.req.query("page") || "1", 10);
|
|
1186
|
-
const limit = parseInt(c.req.query("limit") || "20", 10);
|
|
1187
|
-
const offset = (page - 1) * limit;
|
|
1188
|
-
const folder = c.req.query("folder") || null;
|
|
1189
|
-
try {
|
|
1190
|
-
let query = {};
|
|
1191
|
-
if (bucket !== "all")
|
|
1192
|
-
query.bucket = bucket;
|
|
1193
|
-
if (folder !== null && folder !== "") {
|
|
1194
|
-
query.folder = folder;
|
|
1195
|
-
} else {
|
|
1196
|
-
if (bucket !== "all") {
|
|
1197
|
-
query = {
|
|
1198
|
-
and: [{ bucket }, { or: [{ folder: null }, { folder: "" }] }]
|
|
1199
|
-
};
|
|
1200
|
-
} else {
|
|
1201
|
-
query = { or: [{ folder: null }, { folder: "" }] };
|
|
1202
|
-
}
|
|
1203
|
-
}
|
|
1204
|
-
const result = await config.db.find("_opaca_assets", query, {
|
|
1205
|
-
page,
|
|
1206
|
-
limit,
|
|
1207
|
-
sort: "created_at:desc"
|
|
1208
|
-
});
|
|
1209
|
-
const rows = result.docs;
|
|
1210
|
-
const total = result.totalDocs;
|
|
1211
|
-
let folderRows = [];
|
|
1212
|
-
const bucketFilter = bucket !== "all" ? `AND bucket = ?` : "";
|
|
1213
|
-
const bucketParam = bucket !== "all" ? [bucket] : [];
|
|
1214
|
-
if (config.db.name === "postgres") {
|
|
1215
|
-
const pgBucketFilter = bucketFilter.replace("?", "$1");
|
|
1216
|
-
if (folder === null || folder === "") {
|
|
1217
|
-
folderRows = await config.db.unsafe(`SELECT DISTINCT split_part(folder, '/', 1) as subfolder, bucket FROM _opaca_assets WHERE folder IS NOT NULL AND folder != '' ${pgBucketFilter}`, bucketParam);
|
|
1218
|
-
} else {
|
|
1219
|
-
folderRows = await config.db.unsafe(`SELECT DISTINCT split_part(substring(folder from length($1) + 2), '/', 1) as subfolder, bucket FROM _opaca_assets WHERE folder LIKE $2 ${bucket !== "all" ? "AND bucket = $3" : ""}`, [folder, `${folder}/%`, ...bucketParam]);
|
|
1220
|
-
}
|
|
1221
|
-
} else {
|
|
1222
|
-
if (folder === null || folder === "") {
|
|
1223
|
-
folderRows = await config.db.unsafe(`
|
|
1224
|
-
SELECT DISTINCT
|
|
1225
|
-
CASE
|
|
1226
|
-
WHEN INSTR(folder, '/') > 0 THEN SUBSTR(folder, 1, INSTR(folder, '/') - 1)
|
|
1227
|
-
ELSE folder
|
|
1228
|
-
END as subfolder,
|
|
1229
|
-
bucket
|
|
1230
|
-
FROM _opaca_assets WHERE folder IS NOT NULL AND folder != '' ${bucketFilter}
|
|
1231
|
-
`, bucketParam);
|
|
1232
|
-
} else {
|
|
1233
|
-
const skipLen = folder.length + 2;
|
|
1234
|
-
folderRows = await config.db.unsafe(`
|
|
1235
|
-
SELECT DISTINCT
|
|
1236
|
-
CASE
|
|
1237
|
-
WHEN INSTR(SUBSTR(folder, ?), '/') > 0 THEN SUBSTR(SUBSTR(folder, ?), 1, INSTR(SUBSTR(folder, ?), '/') - 1)
|
|
1238
|
-
ELSE SUBSTR(folder, ?)
|
|
1239
|
-
END as subfolder,
|
|
1240
|
-
bucket
|
|
1241
|
-
FROM _opaca_assets WHERE folder LIKE ? ${bucketFilter}
|
|
1242
|
-
`, [skipLen, skipLen, skipLen, skipLen, `${folder}/%`, ...bucketParam]);
|
|
1243
|
-
}
|
|
1244
|
-
}
|
|
1245
|
-
const folderMap = {};
|
|
1246
|
-
for (const row of folderRows) {
|
|
1247
|
-
if (!row.subfolder)
|
|
1248
|
-
continue;
|
|
1249
|
-
if (!folderMap[row.subfolder])
|
|
1250
|
-
folderMap[row.subfolder] = [];
|
|
1251
|
-
if (!folderMap[row.subfolder]?.includes(row.bucket)) {
|
|
1252
|
-
folderMap[row.subfolder]?.push(row.bucket);
|
|
1253
|
-
}
|
|
1254
|
-
}
|
|
1255
|
-
const folders = Object.entries(folderMap).map(([name, buckets]) => ({
|
|
1256
|
-
name,
|
|
1257
|
-
buckets
|
|
1258
|
-
}));
|
|
1259
|
-
return c.json({
|
|
1260
|
-
docs: rows,
|
|
1261
|
-
folders,
|
|
1262
|
-
totalDocs: total,
|
|
1263
|
-
limit,
|
|
1264
|
-
page,
|
|
1265
|
-
totalPages: Math.ceil(total / limit)
|
|
1266
|
-
});
|
|
1267
|
-
} catch (e) {
|
|
1268
|
-
return c.json({ error: e.message }, 500);
|
|
1269
|
-
}
|
|
1270
|
-
},
|
|
1271
|
-
async presign(c) {
|
|
1272
|
-
const user = c.get("user");
|
|
1273
|
-
if (!user)
|
|
1274
|
-
return c.json({ error: "Unauthorized" }, 401);
|
|
1275
|
-
const { filename, bucket = "default", operation = "write" } = await c.req.json();
|
|
1276
|
-
if (!config.storages || !config.storages[bucket]) {
|
|
1277
|
-
return c.json({ error: "Bucket not found" }, 404);
|
|
1278
|
-
}
|
|
1279
|
-
const adapter = config.storages[bucket];
|
|
1280
|
-
if (!adapter.generatePresignedUrl) {
|
|
1281
|
-
return c.json({ error: "Adapter does not support presigned URLs" }, 400);
|
|
1282
|
-
}
|
|
1283
|
-
try {
|
|
1284
|
-
const url = await adapter.generatePresignedUrl(filename, operation, 3600);
|
|
1285
|
-
return c.json({ uploadUrl: url, filename });
|
|
1286
|
-
} catch (e) {
|
|
1287
|
-
return c.json({ error: e.message }, 500);
|
|
1288
|
-
}
|
|
1289
|
-
},
|
|
1290
|
-
async serve(c) {
|
|
1291
|
-
const id = c.req.param("id");
|
|
1292
|
-
try {
|
|
1293
|
-
const asset = await config.db.findOne("_opaca_assets", { id });
|
|
1294
|
-
if (!asset) {
|
|
1295
|
-
return c.json({ error: "Asset not found" }, 404);
|
|
1296
|
-
}
|
|
1297
|
-
const bucket = asset.bucket || "default";
|
|
1298
|
-
if (!config.storages || !config.storages[bucket]) {
|
|
1299
|
-
return c.json({ error: "Storage bucket not configured" }, 500);
|
|
1300
|
-
}
|
|
1301
|
-
const adapter = config.storages[bucket];
|
|
1302
|
-
if (!adapter.download) {
|
|
1303
|
-
return c.json({ error: "Storage adapter does not support direct downloads" }, 400);
|
|
1304
|
-
}
|
|
1305
|
-
const stream = await adapter.download(asset.key || asset.filename);
|
|
1306
|
-
c.header("Content-Type", asset.mimeType || "application/octet-stream");
|
|
1307
|
-
c.header("Content-Length", asset.filesize.toString());
|
|
1308
|
-
c.header("Cache-Control", "public, max-age=86400");
|
|
1309
|
-
return c.body(stream);
|
|
1310
|
-
} catch (e) {
|
|
1311
|
-
console.error(`[OpacaCMS] Failed to serve asset ${id}:`, e);
|
|
1312
|
-
return c.json({ error: e.message }, 500);
|
|
1313
|
-
}
|
|
1314
|
-
}
|
|
1315
|
-
};
|
|
1316
|
-
}
|
|
1317
|
-
|
|
1318
|
-
// src/server/system-router.ts
|
|
1319
|
-
function createSystemRouter(config) {
|
|
1320
|
-
const systemRouter = new Hono2;
|
|
1321
|
-
if (config.storages) {
|
|
1322
|
-
const assetsHandlers = createAssetsHandlers(config);
|
|
1323
|
-
systemRouter.post("/assets/upload", adminMiddleware, assetsHandlers.upload);
|
|
1324
|
-
systemRouter.get("/assets", adminMiddleware, assetsHandlers.list);
|
|
1325
|
-
systemRouter.post("/assets/presign-upload", adminMiddleware, assetsHandlers.presign);
|
|
1326
|
-
}
|
|
1327
|
-
return systemRouter;
|
|
1328
|
-
}
|
|
1329
|
-
function createAssetsServingRouter(config) {
|
|
1330
|
-
const assetsServingRouter = new Hono2;
|
|
1331
|
-
if (config.storages) {
|
|
1332
|
-
const assetsHandlers = createAssetsHandlers(config);
|
|
1333
|
-
const assetCol = getSystemCollections().find((c) => c.slug === "_opaca_assets");
|
|
1334
|
-
const assetPath = `/${assetCol?.apiPath || assetCol?.slug || "_opaca_assets"}`;
|
|
1335
|
-
assetsServingRouter.get(`${assetPath}/:id/view`, assetsHandlers.serve);
|
|
1336
|
-
}
|
|
1337
|
-
return assetsServingRouter;
|
|
1338
|
+
router.use("*", createAuthMiddleware(() => state.auth));
|
|
1338
1339
|
}
|
|
1339
1340
|
|
|
1340
1341
|
// src/server/router.ts
|
|
1341
1342
|
function createAPIRouter(config) {
|
|
1342
1343
|
const state = { auth: undefined, migrated: false };
|
|
1343
|
-
const router = new
|
|
1344
|
+
const router = new Hono4().basePath("/api");
|
|
1344
1345
|
setupMiddlewares(router, config, state);
|
|
1345
1346
|
setupAuthMiddlewares(router, config, state);
|
|
1346
1347
|
router.get("/", (c) => {
|
|
1347
1348
|
return c.json({ status: "ok", version: "1.0.0", appName: config.appName });
|
|
1348
1349
|
});
|
|
1350
|
+
router.route("/auth", createAuthRouter(config, state));
|
|
1349
1351
|
router.route("/__admin", createAdminRouter(config, state));
|
|
1350
1352
|
router.route("/__system", createSystemRouter(config));
|
|
1351
1353
|
router.route("/", createAssetsServingRouter(config));
|
package/dist/runtimes/bun.js
CHANGED
package/dist/runtimes/next.js
CHANGED
package/dist/runtimes/node.js
CHANGED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type { Auth } from "../../auth";
|
|
3
|
+
import type { OpacaConfig } from "../../types";
|
|
4
|
+
import type { ApiContextVariables } from "../router";
|
|
5
|
+
export declare function createAdminRouter(config: OpacaConfig, state: {
|
|
6
|
+
auth: Auth | undefined;
|
|
7
|
+
}): Hono<{
|
|
8
|
+
Variables: ApiContextVariables;
|
|
9
|
+
}, import("hono/types").BlankSchema, "/">;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type { Auth } from "../../auth";
|
|
3
|
+
import type { OpacaConfig } from "../../types";
|
|
4
|
+
import type { ApiContextVariables } from "../router";
|
|
5
|
+
export declare function createAuthRouter(config: OpacaConfig, state: {
|
|
6
|
+
auth: Auth | undefined;
|
|
7
|
+
}): Hono<{
|
|
8
|
+
Variables: ApiContextVariables;
|
|
9
|
+
}, import("hono/types").BlankSchema, "/">;
|