@coffer-org/server 1.1.0 → 1.2.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/collection-io.d.ts +17 -13
- package/dist/collection-io.js +99 -43
- package/dist/db.d.ts +1 -1
- package/dist/db.js +22 -25
- package/dist/dialect.d.ts +4 -0
- package/dist/dialect.js +14 -0
- package/dist/embed.d.ts +14 -0
- package/dist/embed.js +46 -0
- package/dist/embeddings.d.ts +38 -0
- package/dist/embeddings.js +109 -0
- package/dist/entity-schema.d.ts +6 -17
- package/dist/entity-schema.js +103 -68
- package/dist/extend-io.d.ts +3 -4
- package/dist/extend-io.js +24 -11
- package/dist/extend-table.d.ts +5 -4
- package/dist/extend-table.js +26 -17
- package/dist/fs-list.d.ts +11 -0
- package/dist/fs-list.js +18 -0
- package/dist/index.d.ts +0 -1
- package/dist/index.js +268 -190
- package/dist/local-api.d.ts +4 -5
- package/dist/local-api.js +14 -42
- package/dist/migrations.d.ts +24 -0
- package/dist/migrations.js +198 -0
- package/dist/msg-log.d.ts +12 -0
- package/dist/msg-log.js +5 -0
- package/dist/mutate.d.ts +6 -4
- package/dist/mutate.js +47 -37
- package/dist/plugin-discovery.d.ts +1 -12
- package/dist/plugin-discovery.js +15 -28
- package/dist/plugin-hooks.d.ts +36 -26
- package/dist/plugin-hooks.js +0 -2
- package/dist/plugin-runtime.d.ts +0 -23
- package/dist/plugin-runtime.js +33 -67
- package/dist/plugin-state.d.ts +2 -0
- package/dist/plugin-state.js +11 -0
- package/dist/plugins-api.d.ts +0 -1
- package/dist/plugins-api.js +2 -10
- package/dist/records-api.d.ts +19 -0
- package/dist/records-api.js +116 -0
- package/dist/registry-context.d.ts +1 -7
- package/dist/registry-context.js +4 -3
- package/dist/schema-api.d.ts +1 -0
- package/dist/schema-api.js +11 -0
- package/dist/schema-sync.d.ts +0 -1
- package/dist/schema-sync.js +1 -10
- package/dist/secrets.d.ts +7 -0
- package/dist/secrets.js +60 -0
- package/dist/seeds.d.ts +14 -0
- package/dist/seeds.js +23 -0
- package/dist/temporal.d.ts +0 -16
- package/dist/temporal.js +0 -6
- package/dist/uploads.d.ts +4 -0
- package/dist/uploads.js +12 -0
- package/package.json +12 -4
- package/dist/collection-io.d.ts.map +0 -1
- package/dist/collection-io.js.map +0 -1
- package/dist/db.d.ts.map +0 -1
- package/dist/db.js.map +0 -1
- package/dist/entity-schema.d.ts.map +0 -1
- package/dist/entity-schema.js.map +0 -1
- package/dist/extend-io.d.ts.map +0 -1
- package/dist/extend-io.js.map +0 -1
- package/dist/extend-table.d.ts.map +0 -1
- package/dist/extend-table.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/local-api.d.ts.map +0 -1
- package/dist/local-api.js.map +0 -1
- package/dist/mutate.d.ts.map +0 -1
- package/dist/mutate.js.map +0 -1
- package/dist/plugin-discovery.d.ts.map +0 -1
- package/dist/plugin-discovery.js.map +0 -1
- package/dist/plugin-hooks.d.ts.map +0 -1
- package/dist/plugin-hooks.js.map +0 -1
- package/dist/plugin-runtime.d.ts.map +0 -1
- package/dist/plugin-runtime.js.map +0 -1
- package/dist/plugins-api.d.ts.map +0 -1
- package/dist/plugins-api.js.map +0 -1
- package/dist/registry-context.d.ts.map +0 -1
- package/dist/registry-context.js.map +0 -1
- package/dist/schema-sync.d.ts.map +0 -1
- package/dist/schema-sync.js.map +0 -1
- package/dist/temporal.d.ts.map +0 -1
- package/dist/temporal.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,49 +1,59 @@
|
|
|
1
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
2
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
3
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
4
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
5
|
+
});
|
|
6
|
+
}
|
|
7
|
+
return path;
|
|
8
|
+
};
|
|
1
9
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { createWriteStream,
|
|
10
|
+
import { createWriteStream, existsSync } from 'node:fs';
|
|
11
|
+
import { realpath } from 'node:fs/promises';
|
|
3
12
|
import { pipeline } from 'node:stream/promises';
|
|
4
|
-
import { extname, join } from 'node:path';
|
|
13
|
+
import { extname, join, sep } from 'node:path';
|
|
14
|
+
import { homedir } from 'node:os';
|
|
5
15
|
import Fastify from 'fastify';
|
|
6
16
|
import cors from '@fastify/cors';
|
|
7
17
|
import multipart from '@fastify/multipart';
|
|
8
18
|
import fastifyStatic from '@fastify/static';
|
|
9
19
|
import { serialize } from '@mikro-orm/core';
|
|
10
|
-
import {
|
|
11
|
-
import { tokenize
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
import { textSearchKeys, recordTitle, buildZodObject } from '@coffer-org/sdk/module';
|
|
21
|
+
import { tokenize } from '@coffer-org/core/search';
|
|
22
|
+
import { moduleTableName } from "./entity-schema.js";
|
|
23
|
+
import { getEm, closeDb } from "./db.js";
|
|
24
|
+
import { ValidationError, NotFoundError, toIssue } from "./mutate.js";
|
|
25
|
+
import { resolveEmbed } from "./embed.js";
|
|
26
|
+
import { resolveWithinHome, filterSort, listDir } from "./fs-list.js";
|
|
27
|
+
import { initPlugins, teardownPlugins, readDisabled, purgePluginData, getPluginSettings, getPlugins, } from "./plugin-runtime.js";
|
|
28
|
+
import { registerPluginsApi } from "./plugins-api.js";
|
|
29
|
+
import { buildClientSchema } from "./schema-api.js";
|
|
30
|
+
import { recordList, recordGet, recordCreate, recordUpdate, recordDelete, rowMatch, UnknownTypeError } from "./records-api.js";
|
|
31
|
+
import { maskTree, preserveTree } from "./secrets.js";
|
|
32
|
+
import { uploadsDir } from "./uploads.js";
|
|
23
33
|
const ENV_FILE = join(process.cwd(), '.env');
|
|
24
34
|
if (existsSync(ENV_FILE))
|
|
25
35
|
process.loadEnvFile(ENV_FILE);
|
|
26
36
|
const PORT = Number(process.env.PORT ?? 7023);
|
|
27
|
-
// initPlugins() сам ініціалізує ORM (двофазний bootstrap: системні таблиці →
|
|
28
|
-
// _plugins → discoverEntity активних плагінів → syncSchema).
|
|
29
|
-
// Життєвий цикл плагінів → АКТИВНИЙ реєстр (набір з БД _plugins).
|
|
30
37
|
const reg = await initPlugins();
|
|
31
38
|
const { vaults, modules, getModule, extends_, getExtendsFor } = reg;
|
|
32
|
-
// Мапи: vault/type → ім'я сутності MikroORM (== ім'я таблиці) + ModuleDef.
|
|
33
39
|
const entityNames = new Map();
|
|
34
40
|
const moduleDefs = new Map();
|
|
35
41
|
for (const m of modules) {
|
|
36
42
|
entityNames.set(`${m.vault}/${m.module}`, moduleTableName(m.vault, m.module));
|
|
37
43
|
moduleDefs.set(`${m.vault}/${m.module}`, m);
|
|
38
44
|
}
|
|
39
|
-
|
|
40
|
-
const UPLOADS = process.env.UPLOADS ?? join(process.cwd(), 'data', 'uploads');
|
|
41
|
-
mkdirSync(UPLOADS, { recursive: true });
|
|
45
|
+
const UPLOADS = uploadsDir();
|
|
42
46
|
const app = Fastify({ logger: true });
|
|
43
47
|
await app.register(cors, { origin: true });
|
|
44
48
|
await app.register(multipart, { limits: { fileSize: 25 * 1024 * 1024 } });
|
|
45
|
-
await app.register(fastifyStatic, {
|
|
46
|
-
|
|
49
|
+
await app.register(fastifyStatic, {
|
|
50
|
+
root: UPLOADS,
|
|
51
|
+
prefix: '/uploads/',
|
|
52
|
+
setHeaders: (res) => {
|
|
53
|
+
res.setHeader('Content-Security-Policy', 'sandbox');
|
|
54
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
55
|
+
},
|
|
56
|
+
});
|
|
47
57
|
app.post('/api/upload', async (req, reply) => {
|
|
48
58
|
const data = await req.file();
|
|
49
59
|
if (!data)
|
|
@@ -53,10 +63,18 @@ app.post('/api/upload', async (req, reply) => {
|
|
|
53
63
|
await pipeline(data.file, createWriteStream(join(UPLOADS, name)));
|
|
54
64
|
return reply.send({ filename: name });
|
|
55
65
|
});
|
|
66
|
+
app.post('/api/embed', async (req, reply) => {
|
|
67
|
+
const url = req.body?.url;
|
|
68
|
+
if (!url || typeof url !== 'string')
|
|
69
|
+
return reply.code(400).send({ error: 'no_url' });
|
|
70
|
+
const rec = await resolveEmbed(url);
|
|
71
|
+
if (!rec)
|
|
72
|
+
return reply.code(422).send({ error: 'unresolved' });
|
|
73
|
+
return reply.send(rec);
|
|
74
|
+
});
|
|
56
75
|
function key(vault, type) {
|
|
57
76
|
return `${vault}/${type}`;
|
|
58
77
|
}
|
|
59
|
-
// ─── Error helper ─────────────────────────────────────────────────────────────
|
|
60
78
|
async function guard(reply, fn) {
|
|
61
79
|
try {
|
|
62
80
|
return await fn();
|
|
@@ -70,25 +88,16 @@ async function guard(reply, fn) {
|
|
|
70
88
|
return reply.code(500).send({ error: 'internal' });
|
|
71
89
|
}
|
|
72
90
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
return { ...record, _extends };
|
|
84
|
-
}
|
|
85
|
-
// ─── Vaults / schema ──────────────────────────────────────────────────────────
|
|
86
|
-
app.get('/api/vaults', () => vaults.map((v) => ({
|
|
87
|
-
...v.meta,
|
|
88
|
-
modules: v.modules.map(moduleToClient),
|
|
89
|
-
extends: extends_.filter((e) => e.attachTo.some((r) => r.vault === v.meta.id)).map(extendToClient),
|
|
90
|
-
})));
|
|
91
|
-
// ─── Лічильники записів для Dashboard (усі standalone-модулі активних плагінів) ──
|
|
91
|
+
app.get('/health', async (_req, reply) => {
|
|
92
|
+
try {
|
|
93
|
+
await getEm().getConnection().execute('select 1');
|
|
94
|
+
return { status: 'ok' };
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return reply.code(503).send({ status: 'db_unavailable' });
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
app.get('/api/vaults', () => buildClientSchema());
|
|
92
101
|
app.get('/api/counts', async () => {
|
|
93
102
|
const fork = getEm().fork();
|
|
94
103
|
const out = {};
|
|
@@ -102,30 +111,6 @@ app.get('/api/counts', async () => {
|
|
|
102
111
|
}
|
|
103
112
|
return out;
|
|
104
113
|
});
|
|
105
|
-
/**
|
|
106
|
-
* Матч запису модуля проти токенів пошуку — спільне для глобального та спискового
|
|
107
|
-
* пошуку. Заголовок (titleKey) важить більше за інші текстові поля. Повертає
|
|
108
|
-
* score + сниппет (перше «інше» поле з токеном, інакше заголовок), або null якщо
|
|
109
|
-
* не матч. Поля згортаються один раз і перевикористовуються (score + сниппет).
|
|
110
|
-
*/
|
|
111
|
-
function rowMatch(mdef, row, tokens) {
|
|
112
|
-
const textKeys = textSearchKeys(mdef);
|
|
113
|
-
if (!textKeys.length)
|
|
114
|
-
return null;
|
|
115
|
-
const tk = titleKey(mdef);
|
|
116
|
-
const otherKeys = textKeys.filter((key) => key !== tk);
|
|
117
|
-
const label = recordTitle(mdef, row);
|
|
118
|
-
const otherRaw = otherKeys.map((key) => String(row[key] ?? ''));
|
|
119
|
-
const foldedOthers = otherRaw.map(foldText);
|
|
120
|
-
const score = matchScoreFolded(foldText(label), foldedOthers, tokens);
|
|
121
|
-
if (score === null)
|
|
122
|
-
return null;
|
|
123
|
-
const hitIdx = foldedOthers.findIndex((f) => tokens.some((t) => f.includes(t)));
|
|
124
|
-
const raw = (hitIdx >= 0 ? otherRaw[hitIdx] : label) ?? label;
|
|
125
|
-
const snippet = raw.length > 120 ? raw.slice(0, 120) + '…' : raw;
|
|
126
|
-
return { score, snippet };
|
|
127
|
-
}
|
|
128
|
-
// ─── Глобальний пошук ─────────────────────────────────────────────────────────
|
|
129
114
|
app.get('/api/search', async (req) => {
|
|
130
115
|
const { q = '', limit = '20' } = req.query;
|
|
131
116
|
const tokens = tokenize(q);
|
|
@@ -143,7 +128,6 @@ app.get('/api/search', async (req) => {
|
|
|
143
128
|
if (!textSearchKeys(mdef).length)
|
|
144
129
|
continue;
|
|
145
130
|
const [vault, type] = k.split('/');
|
|
146
|
-
// Cap на модуль — захист від великих таблиць (персональний масштаб).
|
|
147
131
|
const rows = (await fork.find(ename, {}, { limit: 1000 })).map((r) => serialize(r));
|
|
148
132
|
for (const row of rows) {
|
|
149
133
|
const m = rowMatch(mdef, row, tokens);
|
|
@@ -159,9 +143,6 @@ app.get('/api/search', async (req) => {
|
|
|
159
143
|
scored.sort((a, b) => b.score - a.score);
|
|
160
144
|
return scored.slice(0, lim).map((s) => s.result);
|
|
161
145
|
});
|
|
162
|
-
// ─── Plugins (дашборд: набір активних плагінів живе в _plugins) ───────────────
|
|
163
|
-
// NB: GET /api/plugins тепер постачає registerPluginsApi (web-federation маніфест
|
|
164
|
-
// з asset-URL). Решта plugin-management ендпоінтів (PATCH/DELETE/settings) лишаються тут.
|
|
165
146
|
app.patch('/api/plugins/:id', async (req, reply) => {
|
|
166
147
|
const { id } = req.params;
|
|
167
148
|
const { enabled } = (req.body ?? {});
|
|
@@ -192,11 +173,8 @@ app.patch('/api/plugins/:id', async (req, reply) => {
|
|
|
192
173
|
fork.create('_Plugin', { id, enabled: enabled ? 1 : 0, updated_at: new Date().toISOString() });
|
|
193
174
|
}
|
|
194
175
|
await fork.flush();
|
|
195
|
-
// Зміна набору активних плагінів застосовується на наступному старті сервера.
|
|
196
176
|
return { ok: true, restartRequired: true };
|
|
197
177
|
});
|
|
198
|
-
// ЯВНЕ видалення даних плагіна (безповоротне): DROP таблиць + enabled=0.
|
|
199
|
-
// Toggle (PATCH) даних не чіпає — це окрема свідома дія.
|
|
200
178
|
app.delete('/api/plugins/:id/data', async (req, reply) => {
|
|
201
179
|
const { id } = req.params;
|
|
202
180
|
const plugins = await getPlugins();
|
|
@@ -221,7 +199,7 @@ app.get('/api/plugins/:id/settings', async (req, reply) => {
|
|
|
221
199
|
return reply.code(404).send({ error: 'no_settings' });
|
|
222
200
|
}
|
|
223
201
|
const row = await getPluginSettings(id);
|
|
224
|
-
return row;
|
|
202
|
+
return maskTree(p.settings.fields, row);
|
|
225
203
|
});
|
|
226
204
|
app.put('/api/plugins/:id/settings', (req, reply) => guard(reply, async () => {
|
|
227
205
|
const { id } = req.params;
|
|
@@ -236,29 +214,19 @@ app.put('/api/plugins/:id/settings', (req, reply) => guard(reply, async () => {
|
|
|
236
214
|
label: `${id}.plugin.label`,
|
|
237
215
|
fields: p.settings.fields,
|
|
238
216
|
};
|
|
239
|
-
const
|
|
217
|
+
const existing = await getPluginSettings(id);
|
|
218
|
+
const body = preserveTree(p.settings.fields, (req.body ?? {}), existing);
|
|
219
|
+
const parsed = buildZodObject(syntheticMod).safeParse(body);
|
|
240
220
|
if (!parsed.success) {
|
|
241
|
-
throw new ValidationError(parsed.error.issues.map(
|
|
242
|
-
const field = String(i.path[0] ?? '');
|
|
243
|
-
try {
|
|
244
|
-
const m = JSON.parse(i.message);
|
|
245
|
-
if (typeof m.code === 'string')
|
|
246
|
-
return { field, code: m.code, params: m.params };
|
|
247
|
-
}
|
|
248
|
-
catch {
|
|
249
|
-
/* */
|
|
250
|
-
}
|
|
251
|
-
return { field, code: 'invalid' };
|
|
252
|
-
}));
|
|
221
|
+
throw new ValidationError(parsed.error.issues.map(toIssue));
|
|
253
222
|
}
|
|
254
223
|
const entityName = `_settings__${id}`;
|
|
255
224
|
const row = { plugin_id: id, ...parsed.data };
|
|
256
225
|
await getEm()
|
|
257
226
|
.fork()
|
|
258
227
|
.upsert(entityName, row);
|
|
259
|
-
return { ok: true, row };
|
|
228
|
+
return { ok: true, row: maskTree(p.settings.fields, row) };
|
|
260
229
|
}));
|
|
261
|
-
// ─── Nextcloud: test connection ───────────────────────────────────────────────
|
|
262
230
|
app.post('/api/plugins/nextcloud/test', async (_req, reply) => {
|
|
263
231
|
const settings = await getPluginSettings('nextcloud');
|
|
264
232
|
const url = settings['nextcloud_url'];
|
|
@@ -267,132 +235,237 @@ app.post('/api/plugins/nextcloud/test', async (_req, reply) => {
|
|
|
267
235
|
if (!url || !login || !password) {
|
|
268
236
|
return reply.code(400).send({ error: 'Nextcloud settings not configured. Save them first.' });
|
|
269
237
|
}
|
|
270
|
-
// Змінний специфікатор (не літерал): server не тягне @coffer-org/plugin-nextcloud у свою
|
|
271
|
-
// tsc-програму (розрив циклу server↔плагіни). Generic plugin-routes — окреме питання.
|
|
272
238
|
const ncRuntime = '@coffer-org/plugin-nextcloud/runtime';
|
|
273
|
-
const { checkNextcloudConnection } = (await import(ncRuntime));
|
|
239
|
+
const { checkNextcloudConnection } = (await import(__rewriteRelativeImportExtension(ncRuntime)));
|
|
274
240
|
const result = await checkNextcloudConnection(url, login, password);
|
|
275
241
|
if (result.ok)
|
|
276
242
|
return { ok: true };
|
|
277
243
|
return reply.code(400).send({ error: result.error });
|
|
278
244
|
});
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
return
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
245
|
+
app.post('/api/plugins/finance/test', async (_req, reply) => {
|
|
246
|
+
const settings = await getPluginSettings('finance');
|
|
247
|
+
const url = settings['firefly_url'];
|
|
248
|
+
const token = settings['token'];
|
|
249
|
+
if (!url || !token) {
|
|
250
|
+
return reply.code(400).send({ error: 'Finance settings not configured. Save them first.' });
|
|
251
|
+
}
|
|
252
|
+
const finRuntime = '@coffer-org/plugin-finance/runtime';
|
|
253
|
+
const { checkFireflyConnection } = (await import(__rewriteRelativeImportExtension(finRuntime)));
|
|
254
|
+
const result = await checkFireflyConnection(url, token);
|
|
255
|
+
if (result.ok)
|
|
256
|
+
return { ok: true };
|
|
257
|
+
return reply.code(400).send({ error: result.error });
|
|
258
|
+
});
|
|
259
|
+
app.post('/api/plugins/finance/sync', async (_req, reply) => {
|
|
260
|
+
const finRuntime = '@coffer-org/plugin-finance/runtime';
|
|
261
|
+
const { runSync } = (await import(__rewriteRelativeImportExtension(finRuntime)));
|
|
262
|
+
try {
|
|
263
|
+
const counts = await runSync();
|
|
264
|
+
return { ok: true, ...counts };
|
|
265
|
+
}
|
|
266
|
+
catch (e) {
|
|
267
|
+
return reply.code(400).send({ error: e.message });
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
app.post('/api/plugins/finance/import-wise', async (_req, reply) => {
|
|
271
|
+
const finRuntime = '@coffer-org/plugin-finance/runtime';
|
|
272
|
+
const { importWise } = (await import(__rewriteRelativeImportExtension(finRuntime)));
|
|
273
|
+
try {
|
|
274
|
+
return await importWise();
|
|
275
|
+
}
|
|
276
|
+
catch (e) {
|
|
277
|
+
return reply.code(400).send({ error: e.message });
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
app.post('/api/plugins/devices/scan', async (_req, reply) => {
|
|
281
|
+
const devRuntime = '@coffer-org/plugin-devices/runtime';
|
|
282
|
+
const { runScan } = (await import(__rewriteRelativeImportExtension(devRuntime)));
|
|
283
|
+
try {
|
|
284
|
+
const result = await runScan({ create: true });
|
|
285
|
+
return { ok: true, result };
|
|
286
|
+
}
|
|
287
|
+
catch (e) {
|
|
288
|
+
if (e instanceof ValidationError) {
|
|
289
|
+
const detail = e.issues.map((i) => `${i.path.join('.') || i.field}: ${i.code}`).join('; ');
|
|
290
|
+
return reply.code(422).send({ error: `Validation error during scan — ${detail}`, issues: e.issues });
|
|
291
|
+
}
|
|
292
|
+
return reply.code(409).send({ error: e.message });
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
app.post('/api/plugins/media/test', async (_req, reply) => {
|
|
296
|
+
const settings = await getPluginSettings('media');
|
|
297
|
+
const url = settings['jellyfin_url'];
|
|
298
|
+
const token = settings['jellyfin_token'];
|
|
299
|
+
if (!url || !token) {
|
|
300
|
+
return reply.code(400).send({ error: 'Jellyfin settings not configured. Save them first.' });
|
|
301
|
+
}
|
|
302
|
+
const mediaRuntime = '@coffer-org/plugin-media/runtime';
|
|
303
|
+
const { testConnection } = (await import(__rewriteRelativeImportExtension(mediaRuntime)));
|
|
304
|
+
const result = await testConnection(url, token);
|
|
305
|
+
if (result.ok)
|
|
306
|
+
return { ok: true };
|
|
307
|
+
return reply.code(400).send({ error: result.error });
|
|
308
|
+
});
|
|
309
|
+
app.post('/api/plugins/media/sync', async (_req, reply) => {
|
|
310
|
+
const mediaRuntime = '@coffer-org/plugin-media/runtime';
|
|
311
|
+
const { runJellyfinSync } = (await import(__rewriteRelativeImportExtension(mediaRuntime)));
|
|
312
|
+
try {
|
|
313
|
+
const counts = await runJellyfinSync();
|
|
314
|
+
return { ok: true, ...counts };
|
|
315
|
+
}
|
|
316
|
+
catch (e) {
|
|
317
|
+
return reply.code(400).send({ error: e.message });
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
app.post('/api/plugins/media/add', async (req, reply) => {
|
|
321
|
+
const { query, tmdb_id, kind } = (req.body ?? {});
|
|
322
|
+
const mediaRuntime = '@coffer-org/plugin-media/runtime';
|
|
323
|
+
const { addTitle } = (await import(__rewriteRelativeImportExtension(mediaRuntime)));
|
|
324
|
+
try {
|
|
325
|
+
return await addTitle(query, tmdb_id, kind);
|
|
326
|
+
}
|
|
327
|
+
catch (e) {
|
|
328
|
+
return reply.code(400).send({ error: e.message });
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
app.get('/api/fs/list', async (req, reply) => {
|
|
332
|
+
const home = homedir();
|
|
333
|
+
const q = req.query;
|
|
334
|
+
const r = resolveWithinHome(q.path ?? '', home);
|
|
335
|
+
if ('error' in r)
|
|
336
|
+
return reply.code(403).send({ error: r.error });
|
|
337
|
+
let abs;
|
|
338
|
+
try {
|
|
339
|
+
abs = await realpath(r.abs);
|
|
340
|
+
}
|
|
341
|
+
catch {
|
|
342
|
+
return { dir: r.abs, entries: [] };
|
|
343
|
+
}
|
|
344
|
+
if (abs !== home && !abs.startsWith(home + sep))
|
|
345
|
+
return reply.code(403).send({ error: 'outside_home' });
|
|
346
|
+
try {
|
|
347
|
+
const entries = filterSort(await listDir(abs), q.exts ? q.exts.split(',') : undefined);
|
|
348
|
+
return { dir: abs, entries };
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
return { dir: abs, entries: [] };
|
|
352
|
+
}
|
|
353
|
+
});
|
|
354
|
+
function maskRecordRow(vault, type, row) {
|
|
355
|
+
const m = getModule(vault, type);
|
|
356
|
+
let out = m ? maskTree(m.fields, row) : row;
|
|
357
|
+
const ext = out._extends;
|
|
358
|
+
if (ext) {
|
|
359
|
+
const defs = getExtendsFor(vault, type);
|
|
360
|
+
const maskedExt = {};
|
|
361
|
+
for (const [id, sub] of Object.entries(ext)) {
|
|
362
|
+
const def = defs.find((e) => e.id === id);
|
|
363
|
+
maskedExt[id] = sub && def ? maskTree(def.fields, sub) : sub;
|
|
364
|
+
}
|
|
365
|
+
out = { ...out, _extends: maskedExt };
|
|
366
|
+
}
|
|
367
|
+
return out;
|
|
291
368
|
}
|
|
292
369
|
app.get('/api/:vault/:type', async (req, reply) => {
|
|
293
370
|
const { vault, type } = req.params;
|
|
294
371
|
const query = req.query;
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
// тому приводимо до типу колонки (boolean/number) під типи сутності.
|
|
304
|
-
if (mdef) {
|
|
305
|
-
const fm = fieldMap(mdef.fields);
|
|
306
|
-
for (const [k, v] of Object.entries(filterParams)) {
|
|
307
|
-
const fieldDef = fm[k];
|
|
308
|
-
if (fieldDef && !fieldDef.virtual)
|
|
309
|
-
where[k] = coerceFilter(v, fieldDef.column);
|
|
310
|
-
}
|
|
372
|
+
try {
|
|
373
|
+
const rows = await recordList(vault, type, query);
|
|
374
|
+
return rows.map((r) => maskRecordRow(vault, type, r));
|
|
375
|
+
}
|
|
376
|
+
catch (e) {
|
|
377
|
+
if (e instanceof UnknownTypeError)
|
|
378
|
+
return reply.code(404).send({ error: 'unknown_type' });
|
|
379
|
+
throw e;
|
|
311
380
|
}
|
|
312
|
-
// Точкова вибірка за id (comma-separated): резолв relation-цілей,
|
|
313
|
-
// зокрема standalone:false-модулів, що не перелічуються глобально.
|
|
314
|
-
if (filterParams.id) {
|
|
315
|
-
const ids = String(filterParams.id)
|
|
316
|
-
.split(',')
|
|
317
|
-
.map((s) => s.trim())
|
|
318
|
-
.filter(Boolean);
|
|
319
|
-
if (ids.length)
|
|
320
|
-
where['id'] = ids.length === 1 ? ids[0] : { $in: ids };
|
|
321
|
-
}
|
|
322
|
-
// standalone:false-модулі не перелічуються глобально — лише точкова вибірка
|
|
323
|
-
// (за полем-фільтром або id). Порожній where → порожній список.
|
|
324
|
-
if (mdef?.standalone === false && Object.keys(where).length === 0)
|
|
325
|
-
return [];
|
|
326
|
-
const fork = getEm().fork();
|
|
327
|
-
let rows = (await fork.find(ename, where)).map((r) => serialize(r));
|
|
328
|
-
// Текстовий пошук — у JS (SQLite LIKE не сворачивает кириличний регістр).
|
|
329
|
-
// Порядок/сортування лишаємо клієнту (TypeView сортує по колонках) — без ранжування.
|
|
330
|
-
const tokens = q ? tokenize(q) : [];
|
|
331
|
-
if (tokens.length > 0 && mdef) {
|
|
332
|
-
rows = rows.filter((row) => rowMatch(mdef, row, tokens) !== null);
|
|
333
|
-
}
|
|
334
|
-
const decoded = mdef ? rows.map((r) => decodeTemporal(mdef, r)) : rows;
|
|
335
|
-
return Promise.all(decoded.map((row) => withExtends(row, vault, type)));
|
|
336
381
|
});
|
|
337
382
|
app.get('/api/:vault/:type/:id', async (req, reply) => {
|
|
338
383
|
const { vault, type, id } = req.params;
|
|
339
|
-
const
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
384
|
+
const rid = Number(id);
|
|
385
|
+
if (isNaN(rid))
|
|
386
|
+
return reply.code(400).send({ error: 'invalid_id' });
|
|
387
|
+
try {
|
|
388
|
+
const row = await recordGet(vault, type, rid);
|
|
389
|
+
if (!row)
|
|
390
|
+
return reply.code(404).send({ error: 'not_found' });
|
|
391
|
+
return maskRecordRow(vault, type, row);
|
|
392
|
+
}
|
|
393
|
+
catch (e) {
|
|
394
|
+
if (e instanceof UnknownTypeError)
|
|
395
|
+
return reply.code(404).send({ error: 'unknown_type' });
|
|
396
|
+
throw e;
|
|
397
|
+
}
|
|
349
398
|
});
|
|
350
399
|
app.post('/api/:vault/:type', (req, reply) => guard(reply, async () => {
|
|
351
400
|
const { vault, type } = req.params;
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
401
|
+
try {
|
|
402
|
+
const m = getModule(vault, type);
|
|
403
|
+
let body = (req.body ?? {});
|
|
404
|
+
if (m)
|
|
405
|
+
body = preserveTree(m.fields, body, {});
|
|
406
|
+
const row = await recordCreate(vault, type, body);
|
|
407
|
+
return reply.code(201).send(maskRecordRow(vault, type, row));
|
|
408
|
+
}
|
|
409
|
+
catch (e) {
|
|
410
|
+
if (e instanceof UnknownTypeError)
|
|
411
|
+
return reply.code(404).send({ error: 'unknown_type' });
|
|
412
|
+
throw e;
|
|
413
|
+
}
|
|
360
414
|
}));
|
|
361
415
|
app.patch('/api/:vault/:type/:id', (req, reply) => guard(reply, async () => {
|
|
362
416
|
const { vault, type, id } = req.params;
|
|
363
|
-
const
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
417
|
+
const rid = Number(id);
|
|
418
|
+
if (isNaN(rid))
|
|
419
|
+
return reply.code(400).send({ error: 'invalid_id' });
|
|
420
|
+
try {
|
|
421
|
+
const m = getModule(vault, type);
|
|
422
|
+
let body = (req.body ?? {});
|
|
423
|
+
if (m) {
|
|
424
|
+
const existing = (await recordGet(vault, type, rid)) ?? {};
|
|
425
|
+
body = preserveTree(m.fields, body, existing);
|
|
426
|
+
const exDefs = getExtendsFor(vault, type);
|
|
427
|
+
const exRecs = (existing._extends ?? {});
|
|
428
|
+
for (const def of exDefs) {
|
|
429
|
+
const bk = `_extend_${def.id}`;
|
|
430
|
+
const sub = body[bk];
|
|
431
|
+
if (sub && typeof sub === 'object') {
|
|
432
|
+
body = { ...body, [bk]: preserveTree(def.fields, sub, (exRecs[def.id] ?? {})) };
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
const row = await recordUpdate(vault, type, rid, body);
|
|
437
|
+
return maskRecordRow(vault, type, row);
|
|
438
|
+
}
|
|
439
|
+
catch (e) {
|
|
440
|
+
if (e instanceof UnknownTypeError)
|
|
441
|
+
return reply.code(404).send({ error: 'unknown_type' });
|
|
442
|
+
throw e;
|
|
443
|
+
}
|
|
371
444
|
}));
|
|
372
445
|
app.delete('/api/:vault/:type/:id', (req, reply) => guard(reply, async () => {
|
|
373
446
|
const { vault, type, id } = req.params;
|
|
374
|
-
const
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
447
|
+
const rid = Number(id);
|
|
448
|
+
if (isNaN(rid))
|
|
449
|
+
return reply.code(400).send({ error: 'invalid_id' });
|
|
450
|
+
try {
|
|
451
|
+
await recordDelete(vault, type, rid);
|
|
452
|
+
return reply.code(204).send();
|
|
453
|
+
}
|
|
454
|
+
catch (e) {
|
|
455
|
+
if (e instanceof UnknownTypeError)
|
|
456
|
+
return reply.code(404).send({ error: 'unknown_type' });
|
|
457
|
+
throw e;
|
|
458
|
+
}
|
|
380
459
|
}));
|
|
381
|
-
// ─── Web-federation: маніфест активних плагінів + статичні asset'и з node_modules ─
|
|
382
460
|
await registerPluginsApi(app);
|
|
383
|
-
// ─── Serve-режим (продакшн): віддавати зібраний web + SPA-fallback ─────────────
|
|
384
|
-
// Вмикається коли WEB_DIST вказує на packages/web/dist (через `coffer start`).
|
|
385
461
|
const WEB_DIST = process.env.WEB_DIST;
|
|
386
462
|
if (WEB_DIST && existsSync(join(WEB_DIST, 'index.html'))) {
|
|
387
463
|
await app.register(fastifyStatic, { root: WEB_DIST, prefix: '/', decorateReply: false });
|
|
388
|
-
// Vendor singletons (react/react-dom/jsx-runtime/i18next/web-sdk) — sibling of dist/,
|
|
389
|
-
// served at /vendor/ so the importmap in index.html resolves plugin bare imports.
|
|
390
464
|
const VENDOR_DIST = join(WEB_DIST, '..', 'dist-vendor');
|
|
391
465
|
if (existsSync(VENDOR_DIST)) {
|
|
392
466
|
await app.register(fastifyStatic, { root: VENDOR_DIST, prefix: '/vendor/', decorateReply: false });
|
|
393
467
|
}
|
|
394
468
|
app.setNotFoundHandler((req, reply) => {
|
|
395
|
-
// API/uploads/vendor — справжні 404; решта GET → index.html (клієнтський роутинг)
|
|
396
469
|
if (req.method === 'GET' &&
|
|
397
470
|
!req.url.startsWith('/api') &&
|
|
398
471
|
!req.url.startsWith('/uploads') &&
|
|
@@ -410,11 +483,16 @@ app
|
|
|
410
483
|
app.log.error(e);
|
|
411
484
|
process.exit(1);
|
|
412
485
|
});
|
|
413
|
-
|
|
414
|
-
const shutdown = () =>
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
486
|
+
let shuttingDown = false;
|
|
487
|
+
const shutdown = () => {
|
|
488
|
+
if (shuttingDown)
|
|
489
|
+
return;
|
|
490
|
+
shuttingDown = true;
|
|
491
|
+
Promise.resolve(teardownPlugins())
|
|
492
|
+
.then(() => app.close())
|
|
493
|
+
.then(() => closeDb())
|
|
494
|
+
.then(() => process.exit(0))
|
|
495
|
+
.catch(() => process.exit(1));
|
|
496
|
+
};
|
|
418
497
|
process.on('SIGTERM', shutdown);
|
|
419
498
|
process.on('SIGINT', shutdown);
|
|
420
|
-
//# sourceMappingURL=index.js.map
|
package/dist/local-api.d.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
}
|
|
3
|
-
export declare function localExists(vault: string, type: string, id:
|
|
1
|
+
import { UnknownTypeError } from './records-api.ts';
|
|
2
|
+
export { UnknownTypeError };
|
|
3
|
+
export declare function localExists(vault: string, type: string, id: number): Promise<boolean>;
|
|
4
4
|
export declare function localPost(vault: string, type: string, body: unknown): Promise<Record<string, unknown>>;
|
|
5
|
-
export declare function localPatch(vault: string, type: string, id:
|
|
6
|
-
//# sourceMappingURL=local-api.d.ts.map
|
|
5
|
+
export declare function localPatch(vault: string, type: string, id: number, body: unknown): Promise<Record<string, unknown>>;
|