@isardsat/editorial-server 6.17.0 → 6.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app.js +7 -1
- package/dist/lib/cache.d.ts +11 -0
- package/dist/lib/cache.js +37 -0
- package/dist/lib/middleware/auth.d.ts +9 -0
- package/dist/lib/middleware/auth.js +33 -0
- package/dist/lib/utils/diff.d.ts +10 -0
- package/dist/lib/utils/diff.js +50 -0
- package/dist/lib/utils/resolve.d.ts +10 -0
- package/dist/lib/utils/resolve.js +82 -0
- package/dist/lib/utils/resolver.d.ts +10 -0
- package/dist/lib/utils/resolver.js +82 -0
- package/dist/routes/actions.d.ts +2 -1
- package/dist/routes/actions.js +8 -1
- package/dist/routes/data.js +12 -167
- package/dist/routes/files.js +9 -0
- package/package.json +4 -3
package/dist/app.js
CHANGED
|
@@ -25,7 +25,7 @@ export async function createEditorialServer({ configDirectory = BASE_EDITORIAL_P
|
|
|
25
25
|
app.route("/api/v1", createConfigRoutes(config));
|
|
26
26
|
app.route("/api/v1", createDataRoutes(config, storage));
|
|
27
27
|
app.route("/api/v1", await createFilesRoutes(config));
|
|
28
|
-
app.route("/api/v1", createActionRoutes(storage, hooks));
|
|
28
|
+
app.route("/api/v1", createActionRoutes(config, storage, hooks));
|
|
29
29
|
app.route("/", createAdminRoutes(config));
|
|
30
30
|
app.doc("/doc", {
|
|
31
31
|
openapi: "3.0.0",
|
|
@@ -39,6 +39,12 @@ export async function createEditorialServer({ configDirectory = BASE_EDITORIAL_P
|
|
|
39
39
|
root: config.publicDir,
|
|
40
40
|
rewriteRequestPath: (path) => path.replace(/^\/public/, ""),
|
|
41
41
|
}));
|
|
42
|
+
app.openAPIRegistry.registerComponent("securitySchemes", "bearerAuth", {
|
|
43
|
+
type: "http",
|
|
44
|
+
scheme: "bearer",
|
|
45
|
+
bearerFormat: "JWT",
|
|
46
|
+
description: "Firebase JWT token required in Authorization header",
|
|
47
|
+
});
|
|
42
48
|
return {
|
|
43
49
|
app,
|
|
44
50
|
config,
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type EditorialData, type EditorialSchema } from "@isardsat/editorial-common";
|
|
2
|
+
import type { Storage } from "../lib/storage.js";
|
|
3
|
+
export declare function createCache(): {
|
|
4
|
+
getSchema(storage: Storage): Promise<EditorialSchema>;
|
|
5
|
+
getContent(storage: Storage, options?: {
|
|
6
|
+
production?: boolean;
|
|
7
|
+
lang?: string;
|
|
8
|
+
}): Promise<EditorialData>;
|
|
9
|
+
invalidateSchema(): void;
|
|
10
|
+
invalidateContent(): void;
|
|
11
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import {} from "@isardsat/editorial-common";
|
|
2
|
+
export function createCache() {
|
|
3
|
+
let schemaCache = null;
|
|
4
|
+
const contentCache = new Map();
|
|
5
|
+
const ttl = 5 * 60 * 1000; // 5 minutes TTL
|
|
6
|
+
function isExpired(entry) {
|
|
7
|
+
return Date.now() - entry.timestamp > ttl;
|
|
8
|
+
}
|
|
9
|
+
return {
|
|
10
|
+
async getSchema(storage) {
|
|
11
|
+
if (schemaCache && !isExpired(schemaCache)) {
|
|
12
|
+
return schemaCache.data;
|
|
13
|
+
}
|
|
14
|
+
const schema = await storage.getSchema();
|
|
15
|
+
schemaCache = { data: schema, timestamp: Date.now() };
|
|
16
|
+
return schema;
|
|
17
|
+
},
|
|
18
|
+
async getContent(storage, options = {}) {
|
|
19
|
+
const mode = options.production ? "production" : "preview";
|
|
20
|
+
const langSuffix = options.lang ? `-${options.lang}` : "";
|
|
21
|
+
const cacheKey = `${mode}${langSuffix}`;
|
|
22
|
+
const cachedEntry = contentCache.get(cacheKey);
|
|
23
|
+
if (cachedEntry && !isExpired(cachedEntry)) {
|
|
24
|
+
return cachedEntry.data;
|
|
25
|
+
}
|
|
26
|
+
const content = await storage.getContent(options);
|
|
27
|
+
contentCache.set(cacheKey, { data: content, timestamp: Date.now() });
|
|
28
|
+
return content;
|
|
29
|
+
},
|
|
30
|
+
invalidateSchema() {
|
|
31
|
+
schemaCache = null;
|
|
32
|
+
},
|
|
33
|
+
invalidateContent() {
|
|
34
|
+
contentCache.clear();
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Middleware to verify Firebase ID tokens without firebase-admin
|
|
3
|
+
* See https://firebase.google.com/docs/auth/admin/verify-id-tokens#verify_id_tokens_using_a_third-party_jwt_library
|
|
4
|
+
* https://stackoverflow.com/questions/55594930/getting-jwks-for-firebase-in-rfc7517-format
|
|
5
|
+
* Requires projectId (public, not secret)
|
|
6
|
+
*/
|
|
7
|
+
export declare const firebaseAuth: (projectId: string) => import("hono").MiddlewareHandler<any, any, {}, Response | (Response & import("hono").TypedResponse<{
|
|
8
|
+
error: string;
|
|
9
|
+
}, 401, "json">)>;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { createMiddleware } from "hono/factory";
|
|
2
|
+
import { createRemoteJWKSet, jwtVerify } from "jose";
|
|
3
|
+
/**
|
|
4
|
+
* Google public keys for Firebase token verification
|
|
5
|
+
*/
|
|
6
|
+
const JWKS = createRemoteJWKSet(new URL("https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com"));
|
|
7
|
+
/**
|
|
8
|
+
* Middleware to verify Firebase ID tokens without firebase-admin
|
|
9
|
+
* See https://firebase.google.com/docs/auth/admin/verify-id-tokens#verify_id_tokens_using_a_third-party_jwt_library
|
|
10
|
+
* https://stackoverflow.com/questions/55594930/getting-jwks-for-firebase-in-rfc7517-format
|
|
11
|
+
* Requires projectId (public, not secret)
|
|
12
|
+
*/
|
|
13
|
+
export const firebaseAuth = (projectId) => createMiddleware(async (c, next) => {
|
|
14
|
+
const authHeader = c.req.header("Authorization");
|
|
15
|
+
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
|
16
|
+
return c.json({ error: "Unauthorized: Missing or invalid authorization header" }, 401);
|
|
17
|
+
}
|
|
18
|
+
const idToken = authHeader.replace("Bearer ", "");
|
|
19
|
+
try {
|
|
20
|
+
const { payload } = await jwtVerify(idToken, JWKS, {
|
|
21
|
+
issuer: `https://securetoken.google.com/${projectId}`,
|
|
22
|
+
audience: projectId,
|
|
23
|
+
});
|
|
24
|
+
if (!payload || typeof payload !== "object") {
|
|
25
|
+
throw new Error("Invalid token payload");
|
|
26
|
+
}
|
|
27
|
+
await next();
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
console.error("Firebase auth error:", error);
|
|
31
|
+
return c.json({ error: "Unauthorized: Invalid token" }, 401);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type EditorialDataItem } from "@isardsat/editorial-common";
|
|
2
|
+
/**
|
|
3
|
+
* Compares two items and returns the list of fields that have changed.
|
|
4
|
+
* Excludes metadata fields like 'updatedAt'.
|
|
5
|
+
*/
|
|
6
|
+
export declare function getChangedFields(previewItem: EditorialDataItem, productionItem: EditorialDataItem): string[];
|
|
7
|
+
/**
|
|
8
|
+
* Deep equality comparison for values.
|
|
9
|
+
*/
|
|
10
|
+
export declare function deepEqual(value1: unknown, value2: unknown): boolean;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import {} from "@isardsat/editorial-common";
|
|
2
|
+
/**
|
|
3
|
+
* Compares two items and returns the list of fields that have changed.
|
|
4
|
+
* Excludes metadata fields like 'updatedAt'.
|
|
5
|
+
*/
|
|
6
|
+
export function getChangedFields(previewItem, productionItem) {
|
|
7
|
+
const changedFields = [];
|
|
8
|
+
const excludedFields = ["updatedAt", "createdAt"];
|
|
9
|
+
// Get all unique keys from both items
|
|
10
|
+
const allKeys = new Set([
|
|
11
|
+
...Object.keys(previewItem),
|
|
12
|
+
...Object.keys(productionItem),
|
|
13
|
+
]);
|
|
14
|
+
for (const key of allKeys) {
|
|
15
|
+
if (excludedFields.includes(key))
|
|
16
|
+
continue;
|
|
17
|
+
const previewValue = previewItem[key];
|
|
18
|
+
const productionValue = productionItem[key];
|
|
19
|
+
if (!deepEqual(previewValue, productionValue)) {
|
|
20
|
+
changedFields.push(key);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return changedFields;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Deep equality comparison for values.
|
|
27
|
+
*/
|
|
28
|
+
export function deepEqual(value1, value2) {
|
|
29
|
+
if (value1 === value2)
|
|
30
|
+
return true;
|
|
31
|
+
if (value1 == null || value2 == null)
|
|
32
|
+
return false;
|
|
33
|
+
if (typeof value1 !== typeof value2)
|
|
34
|
+
return false;
|
|
35
|
+
if (typeof value1 !== "object") {
|
|
36
|
+
return value1 === value2;
|
|
37
|
+
}
|
|
38
|
+
if (Array.isArray(value1) !== Array.isArray(value2))
|
|
39
|
+
return false;
|
|
40
|
+
if (Array.isArray(value1)) {
|
|
41
|
+
if (value1.length !== value2.length)
|
|
42
|
+
return false;
|
|
43
|
+
return value1.every((item, index) => deepEqual(item, value2[index]));
|
|
44
|
+
}
|
|
45
|
+
const keys1 = Object.keys(value1);
|
|
46
|
+
const keys2 = Object.keys(value2);
|
|
47
|
+
if (keys1.length !== keys2.length)
|
|
48
|
+
return false;
|
|
49
|
+
return keys1.every((key) => deepEqual(value1[key], value2[key]));
|
|
50
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type EditorialData, type EditorialDataItem, type EditorialSchema } from "@isardsat/editorial-common";
|
|
2
|
+
/**
|
|
3
|
+
* Resolves referenced fields in an item, replacing IDs with full objects.
|
|
4
|
+
* Recursively resolves nested references.
|
|
5
|
+
*/
|
|
6
|
+
export declare function resolveReferences(item: EditorialDataItem, schema: EditorialSchema, itemType: string, content: EditorialData, origin: string, resolvedIds?: Set<string>): EditorialDataItem;
|
|
7
|
+
/**
|
|
8
|
+
* Resolves all references in a collection.
|
|
9
|
+
*/
|
|
10
|
+
export declare function resolveCollectionReferences(collection: Record<string, EditorialDataItem>, schema: EditorialSchema, itemType: string, content: EditorialData, origin: string): Record<string, EditorialDataItem>;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import {} from "@isardsat/editorial-common";
|
|
2
|
+
import { getOptionsReference } from "@isardsat/editorial-common";
|
|
3
|
+
/**
|
|
4
|
+
* Resolves uploaded file paths to full URLs for an item.
|
|
5
|
+
*/
|
|
6
|
+
function resolveFileUrls(item, schema, itemType, origin) {
|
|
7
|
+
const resolvedItem = { ...item };
|
|
8
|
+
const itemSchema = schema[itemType];
|
|
9
|
+
if (!itemSchema)
|
|
10
|
+
return resolvedItem;
|
|
11
|
+
for (const [key, value] of Object.entries(resolvedItem)) {
|
|
12
|
+
if (!itemSchema.fields[key]?.isUploadedFile)
|
|
13
|
+
continue;
|
|
14
|
+
if (typeof value !== "string")
|
|
15
|
+
continue;
|
|
16
|
+
if (value.startsWith("http"))
|
|
17
|
+
continue;
|
|
18
|
+
resolvedItem[key] = `${origin}/${value}`;
|
|
19
|
+
}
|
|
20
|
+
return resolvedItem;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Resolves referenced fields in an item, replacing IDs with full objects.
|
|
24
|
+
* Recursively resolves nested references.
|
|
25
|
+
*/
|
|
26
|
+
export function resolveReferences(item, schema, itemType, content, origin, resolvedIds = new Set()) {
|
|
27
|
+
const itemIdentifier = `${itemType}:${item.id}`;
|
|
28
|
+
// Prevent circular references
|
|
29
|
+
if (resolvedIds.has(itemIdentifier)) {
|
|
30
|
+
return resolveFileUrls(item, schema, itemType, origin);
|
|
31
|
+
}
|
|
32
|
+
resolvedIds.add(itemIdentifier);
|
|
33
|
+
// First resolve file URLs for the current item
|
|
34
|
+
let resolvedItem = resolveFileUrls(item, schema, itemType, origin);
|
|
35
|
+
const itemSchema = schema[itemType];
|
|
36
|
+
if (!itemSchema)
|
|
37
|
+
return resolvedItem;
|
|
38
|
+
for (const [fieldKey, fieldConfig] of Object.entries(itemSchema.fields)) {
|
|
39
|
+
if (fieldConfig.type !== "select" && fieldConfig.type !== "multiselect") {
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
const referencedType = getOptionsReference(fieldConfig.options);
|
|
43
|
+
if (!referencedType)
|
|
44
|
+
continue;
|
|
45
|
+
const referencedCollection = content[referencedType];
|
|
46
|
+
if (!referencedCollection)
|
|
47
|
+
continue;
|
|
48
|
+
const fieldValue = item[fieldKey];
|
|
49
|
+
if (fieldConfig.type === "select" && typeof fieldValue === "string") {
|
|
50
|
+
// Single reference - replace ID with full object
|
|
51
|
+
const referencedItem = referencedCollection[fieldValue];
|
|
52
|
+
if (referencedItem) {
|
|
53
|
+
// Recursively resolve nested references
|
|
54
|
+
resolvedItem[fieldKey] = resolveReferences(referencedItem, schema, referencedType, content, origin, new Set(resolvedIds));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
else if (fieldConfig.type === "multiselect" &&
|
|
58
|
+
Array.isArray(fieldValue)) {
|
|
59
|
+
// Multiple references - replace IDs with full objects
|
|
60
|
+
resolvedItem[fieldKey] = fieldValue
|
|
61
|
+
.map((id) => {
|
|
62
|
+
const referencedItem = referencedCollection[id];
|
|
63
|
+
if (!referencedItem)
|
|
64
|
+
return null;
|
|
65
|
+
// Recursively resolve nested references
|
|
66
|
+
return resolveReferences(referencedItem, schema, referencedType, content, origin, new Set(resolvedIds));
|
|
67
|
+
})
|
|
68
|
+
.filter(Boolean);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return resolvedItem;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Resolves all references in a collection.
|
|
75
|
+
*/
|
|
76
|
+
export function resolveCollectionReferences(collection, schema, itemType, content, origin) {
|
|
77
|
+
const resolvedCollection = {};
|
|
78
|
+
for (const [itemKey, item] of Object.entries(collection)) {
|
|
79
|
+
resolvedCollection[itemKey] = resolveReferences(item, schema, itemType, content, origin);
|
|
80
|
+
}
|
|
81
|
+
return resolvedCollection;
|
|
82
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type EditorialData, type EditorialDataItem, type EditorialSchema } from "@isardsat/editorial-common";
|
|
2
|
+
/**
|
|
3
|
+
* Resolves referenced fields in an item, replacing IDs with full objects.
|
|
4
|
+
* Recursively resolves nested references.
|
|
5
|
+
*/
|
|
6
|
+
export declare function resolveReferences(item: EditorialDataItem, schema: EditorialSchema, itemType: string, content: EditorialData, origin: string, resolvedIds?: Set<string>): EditorialDataItem;
|
|
7
|
+
/**
|
|
8
|
+
* Resolves all references in a collection.
|
|
9
|
+
*/
|
|
10
|
+
export declare function resolveCollectionReferences(collection: Record<string, EditorialDataItem>, schema: EditorialSchema, itemType: string, content: EditorialData, origin: string): Record<string, EditorialDataItem>;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import {} from "@isardsat/editorial-common";
|
|
2
|
+
import { getOptionsReference } from "@isardsat/editorial-common";
|
|
3
|
+
/**
|
|
4
|
+
* Resolves uploaded file paths to full URLs for an item.
|
|
5
|
+
*/
|
|
6
|
+
function resolveFileUrls(item, schema, itemType, origin) {
|
|
7
|
+
const resolvedItem = { ...item };
|
|
8
|
+
const itemSchema = schema[itemType];
|
|
9
|
+
if (!itemSchema)
|
|
10
|
+
return resolvedItem;
|
|
11
|
+
for (const [key, value] of Object.entries(resolvedItem)) {
|
|
12
|
+
if (!itemSchema.fields[key]?.isUploadedFile)
|
|
13
|
+
continue;
|
|
14
|
+
if (typeof value !== "string")
|
|
15
|
+
continue;
|
|
16
|
+
if (value.startsWith("http"))
|
|
17
|
+
continue;
|
|
18
|
+
resolvedItem[key] = `${origin}/${value}`;
|
|
19
|
+
}
|
|
20
|
+
return resolvedItem;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Resolves referenced fields in an item, replacing IDs with full objects.
|
|
24
|
+
* Recursively resolves nested references.
|
|
25
|
+
*/
|
|
26
|
+
export function resolveReferences(item, schema, itemType, content, origin, resolvedIds = new Set()) {
|
|
27
|
+
const itemIdentifier = `${itemType}:${item.id}`;
|
|
28
|
+
// Prevent circular references
|
|
29
|
+
if (resolvedIds.has(itemIdentifier)) {
|
|
30
|
+
return resolveFileUrls(item, schema, itemType, origin);
|
|
31
|
+
}
|
|
32
|
+
resolvedIds.add(itemIdentifier);
|
|
33
|
+
// First resolve file URLs for the current item
|
|
34
|
+
let resolvedItem = resolveFileUrls(item, schema, itemType, origin);
|
|
35
|
+
const itemSchema = schema[itemType];
|
|
36
|
+
if (!itemSchema)
|
|
37
|
+
return resolvedItem;
|
|
38
|
+
for (const [fieldKey, fieldConfig] of Object.entries(itemSchema.fields)) {
|
|
39
|
+
if (fieldConfig.type !== "select" && fieldConfig.type !== "multiselect") {
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
const referencedType = getOptionsReference(fieldConfig.options);
|
|
43
|
+
if (!referencedType)
|
|
44
|
+
continue;
|
|
45
|
+
const referencedCollection = content[referencedType];
|
|
46
|
+
if (!referencedCollection)
|
|
47
|
+
continue;
|
|
48
|
+
const fieldValue = item[fieldKey];
|
|
49
|
+
if (fieldConfig.type === "select" && typeof fieldValue === "string") {
|
|
50
|
+
// Single reference - replace ID with full object
|
|
51
|
+
const referencedItem = referencedCollection[fieldValue];
|
|
52
|
+
if (referencedItem) {
|
|
53
|
+
// Recursively resolve nested references
|
|
54
|
+
resolvedItem[fieldKey] = resolveReferences(referencedItem, schema, referencedType, content, origin, new Set(resolvedIds));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
else if (fieldConfig.type === "multiselect" &&
|
|
58
|
+
Array.isArray(fieldValue)) {
|
|
59
|
+
// Multiple references - replace IDs with full objects
|
|
60
|
+
resolvedItem[fieldKey] = fieldValue
|
|
61
|
+
.map((id) => {
|
|
62
|
+
const referencedItem = referencedCollection[id];
|
|
63
|
+
if (!referencedItem)
|
|
64
|
+
return null;
|
|
65
|
+
// Recursively resolve nested references
|
|
66
|
+
return resolveReferences(referencedItem, schema, referencedType, content, origin, new Set(resolvedIds));
|
|
67
|
+
})
|
|
68
|
+
.filter(Boolean);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return resolvedItem;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Resolves all references in a collection.
|
|
75
|
+
*/
|
|
76
|
+
export function resolveCollectionReferences(collection, schema, itemType, content, origin) {
|
|
77
|
+
const resolvedCollection = {};
|
|
78
|
+
for (const [itemKey, item] of Object.entries(collection)) {
|
|
79
|
+
resolvedCollection[itemKey] = resolveReferences(item, schema, itemType, content, origin);
|
|
80
|
+
}
|
|
81
|
+
return resolvedCollection;
|
|
82
|
+
}
|
package/dist/routes/actions.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { OpenAPIHono } from "@hono/zod-openapi";
|
|
2
|
+
import type { EditorialConfig } from "@isardsat/editorial-common";
|
|
2
3
|
import type { Hooks } from "../lib/hooks.js";
|
|
3
4
|
import type { Storage } from "../lib/storage.js";
|
|
4
|
-
export declare function createActionRoutes(storage: Storage, hooks: Hooks): OpenAPIHono<import("hono").Env, {}, "/">;
|
|
5
|
+
export declare function createActionRoutes(config: EditorialConfig, storage: Storage, hooks: Hooks): OpenAPIHono<import("hono").Env, {}, "/">;
|
package/dist/routes/actions.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
|
|
2
|
+
import { firebaseAuth } from "../lib/middleware/auth.js";
|
|
2
3
|
const ActionRequestSchema = z.object({
|
|
3
4
|
author: z.string(),
|
|
4
5
|
});
|
|
5
|
-
export function createActionRoutes(storage, hooks) {
|
|
6
|
+
export function createActionRoutes(config, storage, hooks) {
|
|
6
7
|
const app = new OpenAPIHono();
|
|
7
8
|
app.openapi(createRoute({
|
|
8
9
|
method: "post",
|
|
@@ -28,6 +29,8 @@ export function createActionRoutes(storage, hooks) {
|
|
|
28
29
|
},
|
|
29
30
|
},
|
|
30
31
|
tags: ["Actions"],
|
|
32
|
+
middleware: [firebaseAuth(config.firebase?.projectId || "")],
|
|
33
|
+
security: [{ bearerAuth: [] }],
|
|
31
34
|
}),
|
|
32
35
|
// TODO: Don't async, let the promises run in the background.
|
|
33
36
|
async (c) => {
|
|
@@ -80,6 +83,8 @@ export function createActionRoutes(storage, hooks) {
|
|
|
80
83
|
},
|
|
81
84
|
},
|
|
82
85
|
tags: ["Actions"],
|
|
86
|
+
middleware: [firebaseAuth(config.firebase?.projectId || "")],
|
|
87
|
+
security: [{ bearerAuth: [] }],
|
|
83
88
|
}), async (c) => {
|
|
84
89
|
const { author } = c.req.valid("json");
|
|
85
90
|
await hooks.onPull(author);
|
|
@@ -104,6 +109,8 @@ export function createActionRoutes(storage, hooks) {
|
|
|
104
109
|
},
|
|
105
110
|
},
|
|
106
111
|
tags: ["Actions"],
|
|
112
|
+
middleware: [firebaseAuth(config.firebase?.projectId || "")],
|
|
113
|
+
security: [{ bearerAuth: [] }],
|
|
107
114
|
}), async (c) => {
|
|
108
115
|
const { author } = c.req.valid("json");
|
|
109
116
|
await hooks.onPush(author);
|
package/dist/routes/data.js
CHANGED
|
@@ -1,171 +1,10 @@
|
|
|
1
1
|
import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
|
|
2
|
-
import { EditorialDataItemSchema, EditorialDataSchema, EditorialDiffResponseSchema, EditorialSchemaSchema,
|
|
2
|
+
import { EditorialDataItemSchema, EditorialDataSchema, EditorialDiffResponseSchema, EditorialSchemaSchema, } from "@isardsat/editorial-common";
|
|
3
|
+
import { createCache } from "../lib/cache.js";
|
|
4
|
+
import { firebaseAuth } from "../lib/middleware/auth.js";
|
|
5
|
+
import { getChangedFields } from "../lib/utils/diff.js";
|
|
6
|
+
import { resolveCollectionReferences, resolveReferences, } from "../lib/utils/resolve.js";
|
|
3
7
|
import { generateMetaSchema } from "../lib/utils/schema.js";
|
|
4
|
-
function createCache() {
|
|
5
|
-
let schemaCache = null;
|
|
6
|
-
const contentCache = new Map();
|
|
7
|
-
const ttl = 5 * 60 * 1000; // 5 minutes TTL
|
|
8
|
-
function isExpired(entry) {
|
|
9
|
-
return Date.now() - entry.timestamp > ttl;
|
|
10
|
-
}
|
|
11
|
-
return {
|
|
12
|
-
async getSchema(storage) {
|
|
13
|
-
if (schemaCache && !isExpired(schemaCache)) {
|
|
14
|
-
return schemaCache.data;
|
|
15
|
-
}
|
|
16
|
-
const schema = await storage.getSchema();
|
|
17
|
-
schemaCache = { data: schema, timestamp: Date.now() };
|
|
18
|
-
return schema;
|
|
19
|
-
},
|
|
20
|
-
async getContent(storage, options = {}) {
|
|
21
|
-
const mode = options.production ? "production" : "preview";
|
|
22
|
-
const langSuffix = options.lang ? `-${options.lang}` : "";
|
|
23
|
-
const cacheKey = `${mode}${langSuffix}`;
|
|
24
|
-
const cachedEntry = contentCache.get(cacheKey);
|
|
25
|
-
if (cachedEntry && !isExpired(cachedEntry)) {
|
|
26
|
-
return cachedEntry.data;
|
|
27
|
-
}
|
|
28
|
-
const content = await storage.getContent(options);
|
|
29
|
-
contentCache.set(cacheKey, { data: content, timestamp: Date.now() });
|
|
30
|
-
return content;
|
|
31
|
-
},
|
|
32
|
-
invalidateSchema() {
|
|
33
|
-
schemaCache = null;
|
|
34
|
-
},
|
|
35
|
-
invalidateContent() {
|
|
36
|
-
contentCache.clear();
|
|
37
|
-
},
|
|
38
|
-
};
|
|
39
|
-
}
|
|
40
|
-
/**
|
|
41
|
-
* Resolves uploaded file paths to full URLs for an item.
|
|
42
|
-
*/
|
|
43
|
-
function resolveFileUrls(item, schema, itemType, origin) {
|
|
44
|
-
const resolvedItem = { ...item };
|
|
45
|
-
const itemSchema = schema[itemType];
|
|
46
|
-
if (!itemSchema)
|
|
47
|
-
return resolvedItem;
|
|
48
|
-
for (const [key, value] of Object.entries(resolvedItem)) {
|
|
49
|
-
if (!itemSchema.fields[key]?.isUploadedFile)
|
|
50
|
-
continue;
|
|
51
|
-
if (typeof value !== "string")
|
|
52
|
-
continue;
|
|
53
|
-
if (value.startsWith("http"))
|
|
54
|
-
continue;
|
|
55
|
-
resolvedItem[key] = `${origin}/${value}`;
|
|
56
|
-
}
|
|
57
|
-
return resolvedItem;
|
|
58
|
-
}
|
|
59
|
-
/**
|
|
60
|
-
* Resolves referenced fields in an item, replacing IDs with full objects.
|
|
61
|
-
* Recursively resolves nested references.
|
|
62
|
-
*/
|
|
63
|
-
function resolveReferences(item, schema, itemType, content, origin, resolvedIds = new Set()) {
|
|
64
|
-
const itemIdentifier = `${itemType}:${item.id}`;
|
|
65
|
-
// Prevent circular references
|
|
66
|
-
if (resolvedIds.has(itemIdentifier)) {
|
|
67
|
-
return resolveFileUrls(item, schema, itemType, origin);
|
|
68
|
-
}
|
|
69
|
-
resolvedIds.add(itemIdentifier);
|
|
70
|
-
// First resolve file URLs for the current item
|
|
71
|
-
let resolvedItem = resolveFileUrls(item, schema, itemType, origin);
|
|
72
|
-
const itemSchema = schema[itemType];
|
|
73
|
-
if (!itemSchema)
|
|
74
|
-
return resolvedItem;
|
|
75
|
-
for (const [fieldKey, fieldConfig] of Object.entries(itemSchema.fields)) {
|
|
76
|
-
if (fieldConfig.type !== "select" && fieldConfig.type !== "multiselect") {
|
|
77
|
-
continue;
|
|
78
|
-
}
|
|
79
|
-
const referencedType = getOptionsReference(fieldConfig.options);
|
|
80
|
-
if (!referencedType)
|
|
81
|
-
continue;
|
|
82
|
-
const referencedCollection = content[referencedType];
|
|
83
|
-
if (!referencedCollection)
|
|
84
|
-
continue;
|
|
85
|
-
const fieldValue = item[fieldKey];
|
|
86
|
-
if (fieldConfig.type === "select" && typeof fieldValue === "string") {
|
|
87
|
-
// Single reference - replace ID with full object
|
|
88
|
-
const referencedItem = referencedCollection[fieldValue];
|
|
89
|
-
if (referencedItem) {
|
|
90
|
-
// Recursively resolve nested references
|
|
91
|
-
resolvedItem[fieldKey] = resolveReferences(referencedItem, schema, referencedType, content, origin, new Set(resolvedIds));
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
else if (fieldConfig.type === "multiselect" &&
|
|
95
|
-
Array.isArray(fieldValue)) {
|
|
96
|
-
// Multiple references - replace IDs with full objects
|
|
97
|
-
resolvedItem[fieldKey] = fieldValue
|
|
98
|
-
.map((id) => {
|
|
99
|
-
const referencedItem = referencedCollection[id];
|
|
100
|
-
if (!referencedItem)
|
|
101
|
-
return null;
|
|
102
|
-
// Recursively resolve nested references
|
|
103
|
-
return resolveReferences(referencedItem, schema, referencedType, content, origin, new Set(resolvedIds));
|
|
104
|
-
})
|
|
105
|
-
.filter(Boolean);
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
return resolvedItem;
|
|
109
|
-
}
|
|
110
|
-
/**
|
|
111
|
-
* Resolves all references in a collection.
|
|
112
|
-
*/
|
|
113
|
-
function resolveCollectionReferences(collection, schema, itemType, content, origin) {
|
|
114
|
-
const resolvedCollection = {};
|
|
115
|
-
for (const [itemKey, item] of Object.entries(collection)) {
|
|
116
|
-
resolvedCollection[itemKey] = resolveReferences(item, schema, itemType, content, origin);
|
|
117
|
-
}
|
|
118
|
-
return resolvedCollection;
|
|
119
|
-
}
|
|
120
|
-
/**
|
|
121
|
-
* Compares two items and returns the list of fields that have changed.
|
|
122
|
-
* Excludes metadata fields like 'updatedAt'.
|
|
123
|
-
*/
|
|
124
|
-
function getChangedFields(previewItem, productionItem) {
|
|
125
|
-
const changedFields = [];
|
|
126
|
-
const excludedFields = ["updatedAt", "createdAt"];
|
|
127
|
-
// Get all unique keys from both items
|
|
128
|
-
const allKeys = new Set([
|
|
129
|
-
...Object.keys(previewItem),
|
|
130
|
-
...Object.keys(productionItem),
|
|
131
|
-
]);
|
|
132
|
-
for (const key of allKeys) {
|
|
133
|
-
if (excludedFields.includes(key))
|
|
134
|
-
continue;
|
|
135
|
-
const previewValue = previewItem[key];
|
|
136
|
-
const productionValue = productionItem[key];
|
|
137
|
-
if (!deepEqual(previewValue, productionValue)) {
|
|
138
|
-
changedFields.push(key);
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
return changedFields;
|
|
142
|
-
}
|
|
143
|
-
/**
|
|
144
|
-
* Deep equality comparison for values.
|
|
145
|
-
*/
|
|
146
|
-
function deepEqual(value1, value2) {
|
|
147
|
-
if (value1 === value2)
|
|
148
|
-
return true;
|
|
149
|
-
if (value1 == null || value2 == null)
|
|
150
|
-
return false;
|
|
151
|
-
if (typeof value1 !== typeof value2)
|
|
152
|
-
return false;
|
|
153
|
-
if (typeof value1 !== "object") {
|
|
154
|
-
return value1 === value2;
|
|
155
|
-
}
|
|
156
|
-
if (Array.isArray(value1) !== Array.isArray(value2))
|
|
157
|
-
return false;
|
|
158
|
-
if (Array.isArray(value1)) {
|
|
159
|
-
if (value1.length !== value2.length)
|
|
160
|
-
return false;
|
|
161
|
-
return value1.every((item, index) => deepEqual(item, value2[index]));
|
|
162
|
-
}
|
|
163
|
-
const keys1 = Object.keys(value1);
|
|
164
|
-
const keys2 = Object.keys(value2);
|
|
165
|
-
if (keys1.length !== keys2.length)
|
|
166
|
-
return false;
|
|
167
|
-
return keys1.every((key) => deepEqual(value1[key], value2[key]));
|
|
168
|
-
}
|
|
169
8
|
export function createDataRoutes(config, storage) {
|
|
170
9
|
const app = new OpenAPIHono();
|
|
171
10
|
const cache = createCache();
|
|
@@ -487,6 +326,8 @@ export function createDataRoutes(config, storage) {
|
|
|
487
326
|
},
|
|
488
327
|
},
|
|
489
328
|
tags: ["Data"],
|
|
329
|
+
middleware: [firebaseAuth(config.firebase?.projectId || "")],
|
|
330
|
+
security: [{ bearerAuth: [] }],
|
|
490
331
|
}), async (c) => {
|
|
491
332
|
const itemAtts = await c.req.json();
|
|
492
333
|
const newItem = await storage.createItem(itemAtts);
|
|
@@ -528,6 +369,8 @@ export function createDataRoutes(config, storage) {
|
|
|
528
369
|
},
|
|
529
370
|
},
|
|
530
371
|
tags: ["Data"],
|
|
372
|
+
middleware: [firebaseAuth(config.firebase?.projectId || "")],
|
|
373
|
+
security: [{ bearerAuth: [] }],
|
|
531
374
|
}), async (c) => {
|
|
532
375
|
const itemAtts = await c.req.json();
|
|
533
376
|
const newItem = await storage.updateItem(itemAtts);
|
|
@@ -561,6 +404,8 @@ export function createDataRoutes(config, storage) {
|
|
|
561
404
|
},
|
|
562
405
|
},
|
|
563
406
|
tags: ["Data"],
|
|
407
|
+
middleware: [firebaseAuth(config.firebase?.projectId || "")],
|
|
408
|
+
security: [{ bearerAuth: [] }],
|
|
564
409
|
}), async (c) => {
|
|
565
410
|
const { itemType, id } = c.req.valid("param");
|
|
566
411
|
await storage.deleteItem({ type: itemType, id });
|
|
@@ -604,7 +449,7 @@ export function createDataRoutes(config, storage) {
|
|
|
604
449
|
});
|
|
605
450
|
app.openapi(createRoute({
|
|
606
451
|
method: "get",
|
|
607
|
-
path: "/diff",
|
|
452
|
+
path: "/environments/diff",
|
|
608
453
|
summary: "Get differences between preview and production data",
|
|
609
454
|
responses: {
|
|
610
455
|
200: {
|
package/dist/routes/files.js
CHANGED
|
@@ -3,6 +3,7 @@ import { EditorialFilesResponseSchema, } from "@isardsat/editorial-common";
|
|
|
3
3
|
import { readdirSync, statSync } from "node:fs";
|
|
4
4
|
import { access, constants, mkdir, rename, writeFile } from "node:fs/promises";
|
|
5
5
|
import { basename, dirname, join, normalize, relative } from "node:path";
|
|
6
|
+
import { firebaseAuth } from "../lib/middleware/auth.js";
|
|
6
7
|
export async function createFilesRoutes(config) {
|
|
7
8
|
const app = new OpenAPIHono();
|
|
8
9
|
// Dynamically import the ES module and call its init function
|
|
@@ -31,6 +32,8 @@ export async function createFilesRoutes(config) {
|
|
|
31
32
|
},
|
|
32
33
|
},
|
|
33
34
|
tags: ["Files"],
|
|
35
|
+
middleware: [firebaseAuth(config.firebase?.projectId || "")],
|
|
36
|
+
security: [{ bearerAuth: [] }],
|
|
34
37
|
}),
|
|
35
38
|
// TODO: Index large files from bucket.
|
|
36
39
|
async (c) => {
|
|
@@ -150,6 +153,8 @@ export async function createFilesRoutes(config) {
|
|
|
150
153
|
},
|
|
151
154
|
},
|
|
152
155
|
tags: ["Files"],
|
|
156
|
+
middleware: [firebaseAuth(config.firebase?.projectId || "")],
|
|
157
|
+
security: [{ bearerAuth: [] }],
|
|
153
158
|
}), async (c) => {
|
|
154
159
|
const { path: relativePathInput } = c.req.valid("json");
|
|
155
160
|
try {
|
|
@@ -223,6 +228,8 @@ export async function createFilesRoutes(config) {
|
|
|
223
228
|
},
|
|
224
229
|
},
|
|
225
230
|
tags: ["Files"],
|
|
231
|
+
middleware: [firebaseAuth(config.firebase?.projectId || "")],
|
|
232
|
+
security: [{ bearerAuth: [] }],
|
|
226
233
|
}), async (c) => {
|
|
227
234
|
try {
|
|
228
235
|
const body = await c.req.parseBody();
|
|
@@ -322,6 +329,8 @@ export async function createFilesRoutes(config) {
|
|
|
322
329
|
},
|
|
323
330
|
},
|
|
324
331
|
tags: ["Files"],
|
|
332
|
+
middleware: [firebaseAuth(config.firebase?.projectId || "")],
|
|
333
|
+
security: [{ bearerAuth: [] }],
|
|
325
334
|
}), async (c) => {
|
|
326
335
|
try {
|
|
327
336
|
const { path, name } = c.req.valid("json");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@isardsat/editorial-server",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.18.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -12,10 +12,11 @@
|
|
|
12
12
|
"@hono/swagger-ui": "^0.5.0",
|
|
13
13
|
"@hono/zod-openapi": "^1.1.3",
|
|
14
14
|
"hono": "^4.9.8",
|
|
15
|
+
"jose": "^6.1.3",
|
|
15
16
|
"yaml": "^2.8.1",
|
|
16
17
|
"zod": "^4.1.11",
|
|
17
|
-
"@isardsat/editorial-
|
|
18
|
-
"@isardsat/editorial-
|
|
18
|
+
"@isardsat/editorial-admin": "^6.18.0",
|
|
19
|
+
"@isardsat/editorial-common": "^6.18.0"
|
|
19
20
|
},
|
|
20
21
|
"devDependencies": {
|
|
21
22
|
"@tsconfig/node22": "^22.0.0",
|