@anteros/core 0.0.1 → 0.0.2-alpha.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/lib/files.ts +77 -33
- package/package.json +2 -6
- package/server/api.ts +60 -74
- package/types/file.d.ts +2 -0
- package/bin/d.ts +0 -0
package/lib/files.ts
CHANGED
|
@@ -6,7 +6,7 @@ import type { FileCollection } from "../types/file";
|
|
|
6
6
|
import path from "path";
|
|
7
7
|
import fs from "fs/promises";
|
|
8
8
|
import { createReadStream, existsSync, mkdirSync } from "fs";
|
|
9
|
-
import
|
|
9
|
+
import { ObjectId } from "mongodb";
|
|
10
10
|
|
|
11
11
|
// ─── Storage Interface ──────────────────────────────────────────────────────
|
|
12
12
|
|
|
@@ -257,38 +257,50 @@ export async function handleUpload(options: UploadOptions): Promise<FileResult>
|
|
|
257
257
|
});
|
|
258
258
|
}
|
|
259
259
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
260
|
+
// Insert metadata first to get the MongoDB auto-generated ObjectId
|
|
261
|
+
let _id: string;
|
|
262
|
+
try {
|
|
263
|
+
const rest = new useRest({ tenant_id, internal: false, useHook: false, useCustomApi: false });
|
|
264
|
+
const result = await rest.db.collection(collection).insertOne({
|
|
265
|
+
_file: {
|
|
266
|
+
filename: '',
|
|
267
|
+
name: file.name,
|
|
268
|
+
mimetype,
|
|
269
|
+
size: 0,
|
|
270
|
+
url: '',
|
|
271
|
+
},
|
|
272
|
+
...(data || {}),
|
|
273
|
+
});
|
|
274
|
+
_id = String(result.insertedId);
|
|
275
|
+
} catch (err) {
|
|
276
|
+
throw new AppError('Failed to save file metadata', {
|
|
277
|
+
code: 'FILE_METADATA_ERROR', status: 500,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
264
280
|
|
|
281
|
+
// Save the physical file to storage
|
|
265
282
|
const ext = path.extname(file.name);
|
|
266
|
-
const filename = `${
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
}
|
|
284
|
-
await storage.delete(tenant_id, collection, id, filename).catch(() => {});
|
|
285
|
-
throw new AppError('Failed to save file metadata', {
|
|
286
|
-
code: 'FILE_METADATA_ERROR', status: 500,
|
|
287
|
-
});
|
|
288
|
-
}
|
|
283
|
+
const filename = `${_id}${ext}`;
|
|
284
|
+
const storage = getStorageForCollection(collection, tenant_id);
|
|
285
|
+
const subpath = col.storage?.path || undefined;
|
|
286
|
+
const { path: filepath, size } = await storage.save(tenant_id, collection, file, { id: _id, mimetype, subpath });
|
|
287
|
+
|
|
288
|
+
// Update MongoDB with actual file info
|
|
289
|
+
const url = `/files/${tenant_id}/${collection}/${filename}`;
|
|
290
|
+
try {
|
|
291
|
+
const rest = new useRest({ tenant_id, internal: true, useHook: false, useCustomApi: false });
|
|
292
|
+
await rest.db.collection(collection).updateOne(
|
|
293
|
+
{ _id: new ObjectId(_id) },
|
|
294
|
+
{ $set: { '_file.filename': filename, '_file.size': size, '_file.url': url } },
|
|
295
|
+
);
|
|
296
|
+
} catch (err) {
|
|
297
|
+
await storage.delete(tenant_id, collection, _id, filename, subpath).catch(() => {});
|
|
298
|
+
throw new AppError('Failed to update file metadata', {
|
|
299
|
+
code: 'FILE_METADATA_ERROR', status: 500,
|
|
300
|
+
});
|
|
289
301
|
}
|
|
290
302
|
|
|
291
|
-
//
|
|
303
|
+
// Replicate to configured destinations
|
|
292
304
|
if (col.replicate?.length) {
|
|
293
305
|
replicateFile(filepath, filename, col.replicate).catch(err => {
|
|
294
306
|
console.error('Replication failed:', err?.message || err);
|
|
@@ -296,13 +308,13 @@ export async function handleUpload(options: UploadOptions): Promise<FileResult>
|
|
|
296
308
|
}
|
|
297
309
|
|
|
298
310
|
return {
|
|
299
|
-
_id
|
|
311
|
+
_id,
|
|
300
312
|
_file: {
|
|
301
313
|
filename,
|
|
302
314
|
name: file.name,
|
|
303
315
|
mimetype,
|
|
304
316
|
size,
|
|
305
|
-
url
|
|
317
|
+
url,
|
|
306
318
|
},
|
|
307
319
|
...(data || {}),
|
|
308
320
|
};
|
|
@@ -352,12 +364,44 @@ export async function handleDelete(
|
|
|
352
364
|
tenant_id: string,
|
|
353
365
|
collection: string,
|
|
354
366
|
fileId: string,
|
|
355
|
-
filename: string,
|
|
356
367
|
): Promise<void> {
|
|
357
368
|
const storage = getStorageForCollection(collection, tenant_id);
|
|
358
369
|
const col = getFileCollection(collection, tenant_id);
|
|
359
370
|
const subpath = col?.storage?.path || undefined;
|
|
360
|
-
|
|
371
|
+
|
|
372
|
+
// Look up the document in MongoDB to get the stored filename
|
|
373
|
+
let filename: string | undefined;
|
|
374
|
+
try {
|
|
375
|
+
const rest = new useRest({ tenant_id, internal: true, useHook: false, useCustomApi: false });
|
|
376
|
+
const doc = await rest.db.collection(collection).findOne(
|
|
377
|
+
{ _id: ObjectId.isValid(fileId) ? new ObjectId(fileId) : fileId },
|
|
378
|
+
{ projection: { '_file.filename': 1 } },
|
|
379
|
+
);
|
|
380
|
+
filename = doc?._file?.filename;
|
|
381
|
+
} catch {}
|
|
382
|
+
|
|
383
|
+
// Delete the physical file from storage
|
|
384
|
+
if (filename) {
|
|
385
|
+
await storage.delete(tenant_id, collection, fileId, filename, subpath);
|
|
386
|
+
} else {
|
|
387
|
+
// Fallback: try common extensions using the fileId as base name
|
|
388
|
+
for (const ext of ['.jpg', '.jpeg', '.png', '.webp', '.avif', '.gif', '.svg', '.pdf', '.mp4', '.webm', '.mp3', '.wav', '.json', '.csv', '.zip', '']) {
|
|
389
|
+
try {
|
|
390
|
+
await storage.delete(tenant_id, collection, fileId, fileId + ext, subpath);
|
|
391
|
+
break;
|
|
392
|
+
} catch {}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Delete the document from MongoDB
|
|
397
|
+
try {
|
|
398
|
+
const rest = new useRest({ tenant_id, internal: true, useHook: false, useCustomApi: false });
|
|
399
|
+
await rest.db.collection(collection).deleteOne({
|
|
400
|
+
_id: ObjectId.isValid(fileId) ? new ObjectId(fileId) : fileId,
|
|
401
|
+
});
|
|
402
|
+
} catch (err: any) {
|
|
403
|
+
console.error('Failed to delete file metadata:', err?.message || err);
|
|
404
|
+
}
|
|
361
405
|
}
|
|
362
406
|
|
|
363
407
|
// ─── Replication ──────────────────────────────────────────────────────────
|
package/package.json
CHANGED
|
@@ -1,13 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anteros/core",
|
|
3
3
|
"module": "index.ts",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.2-alpha.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Anteros Core",
|
|
7
7
|
"private": false,
|
|
8
|
-
"bin": {
|
|
9
|
-
"datiox": "bin/d.ts"
|
|
10
|
-
},
|
|
11
8
|
"engines": {
|
|
12
9
|
"bun": ">=1.3.0"
|
|
13
10
|
},
|
|
@@ -27,8 +24,7 @@
|
|
|
27
24
|
"ioredis": "^5.11.1",
|
|
28
25
|
"joi": "^18.2.1",
|
|
29
26
|
"jose": "^6.2.3",
|
|
30
|
-
"mongodb": "
|
|
31
|
-
"os": "^0.1.2",
|
|
27
|
+
"mongodb": "6.21.0",
|
|
32
28
|
"socket.io": "^4.8.3",
|
|
33
29
|
"ufo": "^1.6.4"
|
|
34
30
|
}
|
package/server/api.ts
CHANGED
|
@@ -42,6 +42,59 @@ const ActionsValues = [
|
|
|
42
42
|
|
|
43
43
|
|
|
44
44
|
|
|
45
|
+
// ─── File access helper ────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
async function checkFileAccess(
|
|
48
|
+
access: { [key: string]: boolean | ((ctx: any) => boolean | Promise<boolean>) | undefined } | undefined,
|
|
49
|
+
operation: string,
|
|
50
|
+
tenant_id: string,
|
|
51
|
+
c: any,
|
|
52
|
+
): Promise<void> {
|
|
53
|
+
// No access config at all → deny (secure by default)
|
|
54
|
+
if (!access) {
|
|
55
|
+
throw new AppError('Access denied', { status: 401, code: 'ACCESS_DENIED' });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const hasWildcard = access['*'] !== undefined;
|
|
59
|
+
const hasSpecific = access[operation] !== undefined;
|
|
60
|
+
|
|
61
|
+
// No matching rule at all → deny
|
|
62
|
+
if (!hasWildcard && !hasSpecific) {
|
|
63
|
+
throw new AppError('Access denied', { status: 401, code: 'ACCESS_DENIED' });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Specific rule takes precedence over wildcard
|
|
67
|
+
const rule = hasSpecific ? access[operation] : access['*'];
|
|
68
|
+
if (rule === undefined) {
|
|
69
|
+
throw new AppError('Access denied', { status: 401, code: 'ACCESS_DENIED' });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Boolean `true` → allow without requiring a token
|
|
73
|
+
if (typeof rule === 'boolean') {
|
|
74
|
+
if (!rule) {
|
|
75
|
+
throw new AppError(`${operation} not allowed`, { status: 401, code: 'ACCESS_DENIED' });
|
|
76
|
+
}
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Function → requires a valid token
|
|
81
|
+
const t = c.get('token');
|
|
82
|
+
const accessToken = { value: t?.value ?? null, decoded: t?.decoded ?? null, provided: t?.provided ?? false, expired: t?.expired ?? false };
|
|
83
|
+
|
|
84
|
+
if (accessToken.expired) {
|
|
85
|
+
throw new AppError('Token expired', { status: 401, code: 'TOKEN_EXPIRED' });
|
|
86
|
+
}
|
|
87
|
+
if (!accessToken.value) {
|
|
88
|
+
throw new AppError('Authentication required', { status: 401, code: 'AUTH_REQUIRED' });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const rest = new useRest({ internal: false, tenant_id });
|
|
92
|
+
const allowed = await (rule as Function)({ rest, error: fn.error, jwt: func.jwt, token: accessToken });
|
|
93
|
+
if (!allowed) {
|
|
94
|
+
throw new AppError(`${operation} not allowed`, { status: 401, code: 'ACCESS_DENIED' });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
45
98
|
function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
|
|
46
99
|
|
|
47
100
|
// Crud API
|
|
@@ -567,29 +620,7 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
|
|
|
567
620
|
if (!colUpload) {
|
|
568
621
|
throw new AppError('File collection `' + collection + '` not found', { status: 400, code: 'FILE_COLLECTION_NOT_FOUND' });
|
|
569
622
|
}
|
|
570
|
-
|
|
571
|
-
const t = c.get('token');
|
|
572
|
-
const accessToken = { value: t?.value ?? null, decoded: t?.decoded ?? null, provided: t?.provided ?? false, expired: t?.expired ?? false };
|
|
573
|
-
|
|
574
|
-
if (accessToken.expired) {
|
|
575
|
-
throw new AppError('Token expired', { status: 401, code: 'TOKEN_EXPIRED' });
|
|
576
|
-
}
|
|
577
|
-
if (!accessToken.value) {
|
|
578
|
-
throw new AppError('Authentication required', { status: 401, code: 'AUTH_REQUIRED' });
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
const accessCheck = colUpload.api.access.upload;
|
|
582
|
-
let allowed: boolean;
|
|
583
|
-
if (typeof accessCheck === 'function') {
|
|
584
|
-
const rest = new useRest({ internal: false, tenant_id });
|
|
585
|
-
allowed = await accessCheck({ rest, error: fn.error, jwt: func.jwt, token: accessToken });
|
|
586
|
-
} else {
|
|
587
|
-
allowed = accessCheck;
|
|
588
|
-
}
|
|
589
|
-
if (!allowed) {
|
|
590
|
-
throw new AppError('Upload not allowed', { status: 401, code: 'ACCESS_DENIED' });
|
|
591
|
-
}
|
|
592
|
-
}
|
|
623
|
+
await checkFileAccess(colUpload?.api?.access as any, 'upload', tenant_id, c);
|
|
593
624
|
|
|
594
625
|
const contentType = c.req.header('Content-Type') || '';
|
|
595
626
|
if (!contentType.includes('multipart/form-data')) {
|
|
@@ -646,29 +677,7 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
|
|
|
646
677
|
if (!colServe) {
|
|
647
678
|
throw new AppError('File collection `' + collection + '` not found', { status: 400, code: 'FILE_COLLECTION_NOT_FOUND' });
|
|
648
679
|
}
|
|
649
|
-
|
|
650
|
-
const t = c.get('token');
|
|
651
|
-
const accessToken = { value: t?.value ?? null, decoded: t?.decoded ?? null, provided: t?.provided ?? false, expired: t?.expired ?? false };
|
|
652
|
-
|
|
653
|
-
if (accessToken.expired) {
|
|
654
|
-
throw new AppError('Token expired', { status: 401, code: 'TOKEN_EXPIRED' });
|
|
655
|
-
}
|
|
656
|
-
if (!accessToken.value) {
|
|
657
|
-
throw new AppError('Authentication required', { status: 401, code: 'AUTH_REQUIRED' });
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
const accessCheck = colServe.api.access.read;
|
|
661
|
-
let allowed: boolean;
|
|
662
|
-
if (typeof accessCheck === 'function') {
|
|
663
|
-
const rest = new useRest({ internal: false, tenant_id });
|
|
664
|
-
allowed = await accessCheck({ rest, error: fn.error, jwt: func.jwt, token: accessToken });
|
|
665
|
-
} else {
|
|
666
|
-
allowed = accessCheck;
|
|
667
|
-
}
|
|
668
|
-
if (!allowed) {
|
|
669
|
-
throw new AppError('Access denied', { status: 401, code: 'ACCESS_DENIED' });
|
|
670
|
-
}
|
|
671
|
-
}
|
|
680
|
+
await checkFileAccess(colServe?.api?.access as any, 'read', tenant_id, c);
|
|
672
681
|
|
|
673
682
|
const filename = basename(c.req.param('file') as string);
|
|
674
683
|
if (!filename || filename.startsWith('.') || filename.includes('..') || filename.includes('/')) {
|
|
@@ -721,37 +730,14 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
|
|
|
721
730
|
if (!colDelete) {
|
|
722
731
|
throw new AppError('File collection `' + collection + '` not found', { status: 400, code: 'FILE_COLLECTION_NOT_FOUND' });
|
|
723
732
|
}
|
|
724
|
-
|
|
725
|
-
const t = c.get('token');
|
|
726
|
-
const accessToken = { value: t?.value ?? null, decoded: t?.decoded ?? null, provided: t?.provided ?? false, expired: t?.expired ?? false };
|
|
733
|
+
await checkFileAccess(colDelete?.api?.access as any, 'delete', tenant_id, c);
|
|
727
734
|
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
}
|
|
731
|
-
if (!accessToken.value) {
|
|
732
|
-
throw new AppError('Authentication required', { status: 401, code: 'AUTH_REQUIRED' });
|
|
733
|
-
}
|
|
734
|
-
|
|
735
|
-
const accessCheck = colDelete.api.access.delete;
|
|
736
|
-
let allowed: boolean;
|
|
737
|
-
if (typeof accessCheck === 'function') {
|
|
738
|
-
const rest = new useRest({ internal: false, tenant_id });
|
|
739
|
-
allowed = await accessCheck({ rest, error: fn.error, jwt: func.jwt, token: accessToken });
|
|
740
|
-
} else {
|
|
741
|
-
allowed = accessCheck;
|
|
742
|
-
}
|
|
743
|
-
if (!allowed) {
|
|
744
|
-
throw new AppError('Access denied', { status: 401, code: 'ACCESS_DENIED' });
|
|
745
|
-
}
|
|
746
|
-
}
|
|
747
|
-
|
|
748
|
-
const filename = basename(c.req.param('file') as string);
|
|
749
|
-
if (!filename || filename.startsWith('.') || filename.includes('..') || filename.includes('/')) {
|
|
750
|
-
throw new AppError('Invalid filename', { status: 400, code: 'INVALID_FILENAME' });
|
|
735
|
+
const fileId = c.req.param('file') as string;
|
|
736
|
+
if (!fileId || fileId.startsWith('.') || fileId.includes('..') || fileId.includes('/')) {
|
|
737
|
+
throw new AppError('Invalid file id', { status: 400, code: 'INVALID_FILE_ID' });
|
|
751
738
|
}
|
|
752
|
-
const fileId = filename.replace(/\.[^.]+$/, '');
|
|
753
739
|
|
|
754
|
-
await handleDelete(tenant_id, collection, fileId
|
|
740
|
+
await handleDelete(tenant_id, collection, fileId);
|
|
755
741
|
|
|
756
742
|
return c.json({ message: 'File deleted', ok: true });
|
|
757
743
|
} catch (err: any) {
|
package/types/file.d.ts
CHANGED
|
@@ -13,6 +13,8 @@ type FileAccessHandler = (ctx: {
|
|
|
13
13
|
}) => boolean | Promise<boolean>;
|
|
14
14
|
|
|
15
15
|
type FileApiAccess = {
|
|
16
|
+
/** Default access rule applied when no per-operation rule is set */
|
|
17
|
+
'*'?: boolean | FileAccessHandler;
|
|
16
18
|
/** Access control for POST /upload/:tenant_id/:slug */
|
|
17
19
|
upload?: boolean | FileAccessHandler;
|
|
18
20
|
/** Access control for GET /files/:tenant_id/:slug/:file */
|
package/bin/d.ts
DELETED
|
File without changes
|