@isardsat/editorial-server 6.3.2 → 6.4.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 CHANGED
@@ -23,8 +23,8 @@ export async function createEditorialServer({ configDirectory = BASE_EDITORIAL_P
23
23
  origin: "*",
24
24
  }));
25
25
  app.route("/api/v1", createConfigRoutes(config));
26
- app.route("/api/v1", createDataRoutes(storage));
27
- app.route("/api/v1", createFilesRoutes());
26
+ app.route("/api/v1", createDataRoutes(config, storage));
27
+ app.route("/api/v1", await createFilesRoutes(config));
28
28
  app.route("/api/v1", createActionRoutes(storage, hooks));
29
29
  app.route("/", createAdminRoutes(config));
30
30
  app.doc("/doc", {
@@ -35,8 +35,9 @@ export async function createEditorialServer({ configDirectory = BASE_EDITORIAL_P
35
35
  },
36
36
  });
37
37
  app.get("/doc/ui", swaggerUI({ url: "/doc" }));
38
- app.use("/public/*", serveStatic({
39
- root: "./",
38
+ app.use("/*", serveStatic({
39
+ root: config.publicDir,
40
+ rewriteRequestPath: (path) => path.replace(/^\/public/, ""),
40
41
  }));
41
42
  return {
42
43
  app,
@@ -1,6 +1,10 @@
1
1
  export declare function createConfig(configDirectory: string): Promise<{
2
2
  name: string;
3
3
  publicUrl: string;
4
+ publicDir: string;
5
+ publicDeletedDir: string;
6
+ filesUrl: string;
7
+ largeFilesUrl: string;
4
8
  previewUrl?: string | undefined;
5
9
  silent?: boolean | undefined;
6
10
  firebase?: {
@@ -1,6 +1,10 @@
1
1
  export declare function createSchema(configDirectory: string): Promise<{
2
2
  name: string;
3
3
  publicUrl: string;
4
+ publicDir: string;
5
+ publicDeletedDir: string;
6
+ filesUrl: string;
7
+ largeFilesUrl: string;
4
8
  previewUrl?: string | undefined;
5
9
  silent?: boolean | undefined;
6
10
  firebase?: {
@@ -33,6 +33,7 @@ export function createStorage(dataDirectory) {
33
33
  }
34
34
  async function createItem(item) {
35
35
  const content = await getContent();
36
+ content[item.type] = content[item.type] ?? {};
36
37
  content[item.type][item.id] = EditorialDataItemSchema.parse(item);
37
38
  // TODO: Use superjson to safely encode different types.
38
39
  await writeFileSafe(dataPath, JSON.stringify(content, null, 2));
@@ -1,3 +1,4 @@
1
1
  import { OpenAPIHono } from "@hono/zod-openapi";
2
+ import { type EditorialConfig } from "@isardsat/editorial-common";
2
3
  import type { Storage } from "../lib/storage.js";
3
- export declare function createDataRoutes(storage: Storage): OpenAPIHono<import("hono").Env, {}, "/">;
4
+ export declare function createDataRoutes(config: EditorialConfig, storage: Storage): OpenAPIHono<import("hono").Env, {}, "/">;
@@ -1,7 +1,8 @@
1
1
  import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
2
2
  import { EditorialDataItemSchema, EditorialDataSchema, EditorialSchemaSchema, } from "@isardsat/editorial-common";
3
- export function createDataRoutes(storage) {
3
+ export function createDataRoutes(config, storage) {
4
4
  const app = new OpenAPIHono();
5
+ const publicFilesUrl = config.filesUrl;
5
6
  app.openapi(createRoute({
6
7
  method: "get",
7
8
  path: "/schema",
@@ -54,6 +55,7 @@ export function createDataRoutes(storage) {
54
55
  param: { name: "lang", in: "query" },
55
56
  example: "es_ES",
56
57
  }),
58
+ preview: z.string().optional(),
57
59
  }),
58
60
  },
59
61
  responses: {
@@ -65,12 +67,20 @@ export function createDataRoutes(storage) {
65
67
  },
66
68
  description: "Get objects by type",
67
69
  },
70
+ 404: {
71
+ description: "Collection not found",
72
+ },
68
73
  },
69
74
  }), async (c) => {
70
75
  const { itemType } = c.req.valid("param");
71
- const { lang } = c.req.valid("query");
76
+ const { lang, preview } = c.req.valid("query");
77
+ const origin = preview ? new URL(c.req.url).origin : publicFilesUrl;
72
78
  const content = await storage.getContent();
79
+ const schema = await storage.getSchema();
73
80
  const collection = content[itemType];
81
+ if (!collection) {
82
+ return c.notFound();
83
+ }
74
84
  // TODO: Formalize this process.
75
85
  if (lang) {
76
86
  const messages = await storage.getLocalisationMessages(lang);
@@ -81,6 +91,13 @@ export function createDataRoutes(storage) {
81
91
  }
82
92
  }
83
93
  }
94
+ for (const [itemKey, itemValue] of Object.entries(collection)) {
95
+ for (const [key, value] of Object.entries(itemValue)) {
96
+ if (!schema[itemType].fields[key]?.isUploadedFile)
97
+ continue;
98
+ collection[itemKey][key] = `${origin}/${value}`;
99
+ }
100
+ }
84
101
  return c.json(collection);
85
102
  });
86
103
  app.openapi(createRoute({
@@ -131,6 +148,7 @@ export function createDataRoutes(storage) {
131
148
  param: { name: "lang", in: "query" },
132
149
  example: "es_ES",
133
150
  }),
151
+ preview: z.string().optional(),
134
152
  }),
135
153
  },
136
154
  responses: {
@@ -142,22 +160,39 @@ export function createDataRoutes(storage) {
142
160
  },
143
161
  description: "Get object data",
144
162
  },
163
+ 404: {
164
+ description: "Collection or item not found",
165
+ },
145
166
  },
146
167
  }), async (c) => {
147
168
  const { itemType, id } = c.req.valid("param");
148
- const { lang } = c.req.valid("query");
169
+ const { lang, preview } = c.req.valid("query");
170
+ const origin = preview ? new URL(c.req.url).origin : publicFilesUrl;
149
171
  const content = await storage.getContent();
150
- const item = content[itemType][id];
172
+ const schema = await storage.getSchema();
173
+ const collection = content[itemType];
174
+ if (!collection) {
175
+ return c.notFound();
176
+ }
177
+ const item = collection[id];
178
+ if (!item) {
179
+ return c.notFound();
180
+ }
151
181
  // TODO: Formalize this process.
152
182
  if (lang) {
153
183
  const messages = await storage.getLocalisationMessages(lang);
154
184
  for (const [key, message] of Object.entries(messages)) {
155
- const [contentKey, typeKey, fieldKey, hash] = key.split(".");
185
+ const [, typeKey, fieldKey] = key.split(".");
156
186
  if (typeKey === id) {
157
187
  item[fieldKey] = message.defaultMessage;
158
188
  }
159
189
  }
160
190
  }
191
+ for (const [key, value] of Object.entries(item)) {
192
+ if (!schema[itemType].fields[key]?.isUploadedFile)
193
+ continue;
194
+ item[key] = `${origin}/${value}`;
195
+ }
161
196
  return c.json(item);
162
197
  });
163
198
  app.openapi(createRoute({
@@ -1,2 +1,3 @@
1
1
  import { OpenAPIHono } from "@hono/zod-openapi";
2
- export declare function createFilesRoutes(): OpenAPIHono<import("hono").Env, {}, "/">;
2
+ import { type EditorialConfig } from "@isardsat/editorial-common";
3
+ export declare function createFilesRoutes(config: EditorialConfig): Promise<OpenAPIHono<import("hono").Env, {}, "/">>;
@@ -1,15 +1,24 @@
1
1
  import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
2
2
  import { EditorialFilesResponseSchema, } from "@isardsat/editorial-common";
3
3
  import { readdirSync, statSync } from "node:fs";
4
- import { access, constants, mkdir, rename } from "node:fs/promises";
5
- import { basename, join, normalize } from "node:path";
6
- export function createFilesRoutes() {
4
+ import { access, constants, mkdir, rename, writeFile } from "node:fs/promises";
5
+ import { basename, join, normalize, relative } from "node:path";
6
+ export async function createFilesRoutes(config) {
7
7
  const app = new OpenAPIHono();
8
- const publicDirPath = "public";
9
- const deletedDirPath = "public/.deleted";
8
+ // Dynamically import the ES module and call its init function
9
+ const hookScript = join(process.cwd(), "editorial", "largeFileHandler");
10
+ const largeFilesHandler = await import(`${hookScript}.mjs`).then((mod) => mod.init());
11
+ const publicFilesUrl = config.filesUrl;
12
+ const publicDirPath = config.publicDir;
13
+ const deletedDirPath = config.publicDeletedDir;
10
14
  app.openapi(createRoute({
11
15
  method: "get",
12
16
  path: "/files",
17
+ request: {
18
+ query: z.object({
19
+ preview: z.string().optional(),
20
+ }),
21
+ },
13
22
  responses: {
14
23
  200: {
15
24
  content: {
@@ -20,7 +29,11 @@ export function createFilesRoutes() {
20
29
  description: "Get tree of public files with total size",
21
30
  },
22
31
  },
23
- }), async (c) => {
32
+ }),
33
+ // TODO: Index large files from bucket.
34
+ async (c) => {
35
+ const { preview } = c.req.valid("query");
36
+ const origin = preview ? new URL(c.req.url).origin : publicFilesUrl;
24
37
  function calculateTotalSize(files) {
25
38
  return files.reduce((total, file) => {
26
39
  if (file.type === "file") {
@@ -33,15 +46,19 @@ export function createFilesRoutes() {
33
46
  }, 0);
34
47
  }
35
48
  function readDirectoryChildren(path) {
49
+ // TODO: Gracefully handle missing directory?
36
50
  const directory = readdirSync(path);
37
51
  return directory
38
52
  .filter((fileName) => !fileName.startsWith("."))
39
53
  .map((fileName) => {
40
54
  const file = statSync(join(path, fileName));
55
+ const filePath = join(path, fileName);
41
56
  const isDirectory = file.isDirectory();
57
+ const relativePath = relative(publicDirPath, filePath);
42
58
  return {
43
59
  name: basename(fileName),
44
- path: join(path, fileName),
60
+ path: isDirectory ? relativePath : `${origin}/${relativePath}`,
61
+ relativePath: relativePath,
45
62
  size: file.size,
46
63
  type: isDirectory ? "directory" : "file",
47
64
  children: isDirectory
@@ -49,11 +66,45 @@ export function createFilesRoutes() {
49
66
  : undefined,
50
67
  };
51
68
  })
52
- .sort((a, b) => (a.type === "directory" ? -1 : 0));
69
+ .toSorted((a, b) => {
70
+ if (a.type === "directory" && b.type !== "directory")
71
+ return -1;
72
+ if (a.type !== "directory" && b.type === "directory")
73
+ return 1;
74
+ return a.name.localeCompare(b.name);
75
+ });
76
+ }
77
+ function mergeDirectoryTrees(localFiles, largeFiles) {
78
+ const merged = [...localFiles];
79
+ for (const largeFile of largeFiles) {
80
+ const existingIndex = merged.findIndex((file) => file.name === largeFile.name && file.type === "directory");
81
+ if (existingIndex !== -1 &&
82
+ merged[existingIndex].type === "directory") {
83
+ // Merge directories with same name
84
+ const existingDir = merged[existingIndex];
85
+ merged[existingIndex] = {
86
+ ...existingDir,
87
+ children: mergeDirectoryTrees(existingDir.children || [], largeFile.children || []),
88
+ };
89
+ }
90
+ else {
91
+ // Add new file/directory
92
+ merged.push(largeFile);
93
+ }
94
+ }
95
+ return merged.toSorted((a, b) => {
96
+ if (a.type === "directory" && b.type !== "directory")
97
+ return -1;
98
+ if (a.type !== "directory" && b.type === "directory")
99
+ return 1;
100
+ return a.name.localeCompare(b.name);
101
+ });
53
102
  }
54
103
  const files = readDirectoryChildren(publicDirPath);
55
- const totalSize = calculateTotalSize(files);
56
- return c.json({ files, totalSize });
104
+ const largeFiles = await largeFilesHandler.list();
105
+ const mergedFiles = mergeDirectoryTrees(files, largeFiles);
106
+ const totalSize = calculateTotalSize(mergedFiles);
107
+ return c.json({ files: mergedFiles, totalSize });
57
108
  });
58
109
  app.openapi(createRoute({
59
110
  method: "delete",
@@ -133,5 +184,86 @@ export function createFilesRoutes() {
133
184
  return c.json({ error: "Server error" }, 500);
134
185
  }
135
186
  });
187
+ app.openapi(createRoute({
188
+ method: "put",
189
+ path: "/files",
190
+ request: {
191
+ body: {
192
+ content: {
193
+ "multipart/form-data": {
194
+ schema: z.object({
195
+ path: z.string().optional(),
196
+ files: z.any(),
197
+ }),
198
+ },
199
+ },
200
+ required: true,
201
+ },
202
+ },
203
+ responses: {
204
+ 200: {
205
+ content: {
206
+ "application/json": {
207
+ schema: z.string().array(),
208
+ },
209
+ },
210
+ description: "Files uploaded successfully",
211
+ },
212
+ 400: {
213
+ content: {
214
+ "application/json": {
215
+ schema: z.object({
216
+ error: z.string(),
217
+ }),
218
+ },
219
+ },
220
+ description: "Invalid request",
221
+ },
222
+ 500: {
223
+ content: {
224
+ "application/json": {
225
+ schema: z.object({
226
+ error: z.string(),
227
+ }),
228
+ },
229
+ },
230
+ description: "Server error",
231
+ },
232
+ },
233
+ }), async (c) => {
234
+ try {
235
+ const body = await c.req.parseBody();
236
+ const targetPath = body.path || "";
237
+ const files = body.files;
238
+ if (!files) {
239
+ return c.json({ error: "No files provided" }, 400);
240
+ }
241
+ const fileArray = Array.isArray(files) ? files : [files];
242
+ const uploadedFiles = [];
243
+ const targetDir = join(publicDirPath, targetPath);
244
+ const normalizedTargetDir = normalize(targetDir);
245
+ if (!normalizedTargetDir.startsWith(publicDirPath)) {
246
+ return c.json({ error: "Invalid target path" }, 400);
247
+ }
248
+ await mkdir(normalizedTargetDir, { recursive: true });
249
+ for (const file of fileArray) {
250
+ if (file instanceof File) {
251
+ if (file.size >= 1e6) {
252
+ return c.json({ error: "File too large" }, 400);
253
+ }
254
+ const fileName = file.name;
255
+ const filePath = join(normalizedTargetDir, fileName);
256
+ const buffer = await file.arrayBuffer();
257
+ await writeFile(filePath, new Uint8Array(buffer));
258
+ uploadedFiles.push(fileName);
259
+ }
260
+ }
261
+ return c.json(uploadedFiles, 200);
262
+ }
263
+ catch (err) {
264
+ console.error("Upload failed:", err);
265
+ return c.json({ error: "Server error" }, 500);
266
+ }
267
+ });
136
268
  return app;
137
269
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@isardsat/editorial-server",
3
- "version": "6.3.2",
3
+ "version": "6.4.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -14,8 +14,8 @@
14
14
  "hono": "^4.6.20",
15
15
  "yaml": "^2.7.0",
16
16
  "zod": "^3.24.1",
17
- "@isardsat/editorial-admin": "^6.3.2",
18
- "@isardsat/editorial-common": "^6.3.2"
17
+ "@isardsat/editorial-admin": "^6.4.0",
18
+ "@isardsat/editorial-common": "^6.4.0"
19
19
  },
20
20
  "devDependencies": {
21
21
  "@tsconfig/node22": "^22.0.0",