rasler 0.1.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/LICENSE +180 -0
- package/README.md +140 -0
- package/openapi.json +859 -0
- package/package.json +68 -0
- package/src/config.js +81 -0
- package/src/crypto/cid.js +49 -0
- package/src/index.js +45 -0
- package/src/masl/document.js +148 -0
- package/src/middleware/auth.js +11 -0
- package/src/middleware/cors.js +31 -0
- package/src/routes/mountPoints.js +86 -0
- package/src/routes/operator.js +944 -0
- package/src/routes/rasl.js +139 -0
- package/src/server.js +83 -0
- package/src/static.js +168 -0
- package/src/storage/db.js +133 -0
- package/src/storage/files.js +47 -0
- package/src/storage/store.js +171 -0
- package/src/util/env.js +32 -0
- package/src/util/loadRaslerConfig.js +14 -0
- package/src/util/mime.js +29 -0
- package/src/util/normalizeMountPath.js +12 -0
- package/src/util/parseJsonConfig.js +57 -0
- package/src/util/parseSize.js +16 -0
- package/src/watcher.js +42 -0
package/package.json
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "rasler",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "RASLer — RASL protocol node implementation with content-addressed storage and MASL support",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"author": "Wes Biggs <github@wbig.gs>",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/wesbiggs/rasler.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/wesbiggs/rasler#readme",
|
|
12
|
+
"keywords": [
|
|
13
|
+
"rasl",
|
|
14
|
+
"dasl",
|
|
15
|
+
"masl",
|
|
16
|
+
"content-addressed",
|
|
17
|
+
"ipld",
|
|
18
|
+
"cid",
|
|
19
|
+
"decentralized"
|
|
20
|
+
],
|
|
21
|
+
"type": "module",
|
|
22
|
+
"main": "src/index.js",
|
|
23
|
+
"files": [
|
|
24
|
+
"src/",
|
|
25
|
+
"openapi.json",
|
|
26
|
+
"LICENSE"
|
|
27
|
+
],
|
|
28
|
+
"scripts": {
|
|
29
|
+
"start": "node src/index.js",
|
|
30
|
+
"lint": "eslint src/ scripts/ test/",
|
|
31
|
+
"test": "npm run lint && NODE_OPTIONS=--experimental-vm-modules jest --runInBand",
|
|
32
|
+
"test:unit": "NODE_OPTIONS=--experimental-vm-modules jest test/unit --runInBand",
|
|
33
|
+
"test:integration": "NODE_OPTIONS=--experimental-vm-modules jest test/integration --runInBand",
|
|
34
|
+
"generate:openapi": "node scripts/generate-openapi.js",
|
|
35
|
+
"generate:docs": "node scripts/generate-docs.js",
|
|
36
|
+
"get-in-the-car": "node scripts/get-in-the-car.js"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@ipld/car": "5.4.6",
|
|
40
|
+
"@ipld/dag-cbor": "10.0.1",
|
|
41
|
+
"express": "5.2.1",
|
|
42
|
+
"micromatch": "4.0.8",
|
|
43
|
+
"multer": "2.1.1",
|
|
44
|
+
"multiformats": "14.0.0",
|
|
45
|
+
"swagger-ui-express": "5.0.1"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@eslint/js": "10.0.1",
|
|
49
|
+
"@jest/globals": "30.4.1",
|
|
50
|
+
"eslint": "10.4.1",
|
|
51
|
+
"globals": "17.6.0",
|
|
52
|
+
"jest": "30.4.2",
|
|
53
|
+
"marked": "18.0.4",
|
|
54
|
+
"supertest": "7.2.2",
|
|
55
|
+
"swagger-jsdoc": "6.3.0"
|
|
56
|
+
},
|
|
57
|
+
"jest": {
|
|
58
|
+
"transform": {},
|
|
59
|
+
"testEnvironment": "node",
|
|
60
|
+
"forceExit": true,
|
|
61
|
+
"testMatch": [
|
|
62
|
+
"<rootDir>/test/**/*.test.js"
|
|
63
|
+
]
|
|
64
|
+
},
|
|
65
|
+
"engines": {
|
|
66
|
+
"node": ">=23.4.0"
|
|
67
|
+
}
|
|
68
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { parseSize } from './util/parseSize.js';
|
|
4
|
+
import { required, optional } from './util/env.js';
|
|
5
|
+
import { normalizeMountPath } from './util/normalizeMountPath.js';
|
|
6
|
+
import { loadRaslerConfig } from './util/loadRaslerConfig.js';
|
|
7
|
+
import { parseJsonStaticRoots, parseJsonMountPoints } from './util/parseJsonConfig.js';
|
|
8
|
+
|
|
9
|
+
export { parseSize, normalizeMountPath };
|
|
10
|
+
|
|
11
|
+
const port = parseInt(optional('PORT', '3000'), 10);
|
|
12
|
+
|
|
13
|
+
// Accept a bare hostname (e.g. "node1.example.com") for backwards compat and
|
|
14
|
+
// default it to https://. A full origin (e.g. "http://localhost:3000") is used
|
|
15
|
+
// as-is so the correct protocol appears in Link: rel="duplicate" headers.
|
|
16
|
+
const originRaw = optional('ORIGIN', `http://localhost:${port}`);
|
|
17
|
+
const originUrl = new URL(/^https?:\/\//.test(originRaw) ? originRaw : `https://${originRaw}`);
|
|
18
|
+
|
|
19
|
+
const raslerConfig = loadRaslerConfig();
|
|
20
|
+
|
|
21
|
+
const mountPoints = parseJsonMountPoints(raslerConfig?.mountPoints ?? []);
|
|
22
|
+
|
|
23
|
+
// Static roots from JSON config, plus any directories referenced by mountPoints.
|
|
24
|
+
const staticRoots = parseJsonStaticRoots(raslerConfig?.staticRoots ?? []);
|
|
25
|
+
const mountPointDirs = new Set(mountPoints.map(mp => mp.directory));
|
|
26
|
+
for (const dir of mountPointDirs) {
|
|
27
|
+
const existing = staticRoots.find(r => r.directory === dir);
|
|
28
|
+
if (!existing) {
|
|
29
|
+
staticRoots.push({ directory: dir, watch: false, ignore: [], generateMasl: true });
|
|
30
|
+
} else if (!existing.generateMasl) {
|
|
31
|
+
// Mount-point directories always need a bundle MASL for serving.
|
|
32
|
+
existing.generateMasl = true;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Implicit ./public mount: if the directory exists and no root-level mount is
|
|
37
|
+
// already configured for the origin domain, serve it at the document root.
|
|
38
|
+
const publicDir = resolve(process.cwd(), 'public');
|
|
39
|
+
if (existsSync(publicDir)) {
|
|
40
|
+
if (!staticRoots.some(r => r.directory === publicDir)) {
|
|
41
|
+
staticRoots.push({ directory: publicDir, watch: false, ignore: [], generateMasl: true });
|
|
42
|
+
}
|
|
43
|
+
if (!mountPoints.some(mp => (mp.hostname === originUrl.hostname || mp.hostname === '') && mp.prefix === '')) {
|
|
44
|
+
mountPoints.push({ hostname: originUrl.hostname, prefix: '', directory: publicDir });
|
|
45
|
+
mountPoints.sort((a, b) => b.prefix.length - a.prefix.length);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const config = Object.freeze({
|
|
50
|
+
origin: originUrl.origin,
|
|
51
|
+
domain: originUrl.host,
|
|
52
|
+
port,
|
|
53
|
+
dataDir: resolve(optional('DATA_DIR', './data')),
|
|
54
|
+
totalCapacity: parseSize(optional('TOTAL_CAPACITY', '1G')),
|
|
55
|
+
apiSecret: (() => {
|
|
56
|
+
const secret = required('API_SECRET');
|
|
57
|
+
if (secret === 'change-me-to-a-strong-random-secret') {
|
|
58
|
+
throw new Error('API_SECRET is still set to the default placeholder — please change it before starting the server');
|
|
59
|
+
}
|
|
60
|
+
return secret;
|
|
61
|
+
})(),
|
|
62
|
+
swaggerUi: optional('SWAGGER_UI', 'true') === 'true',
|
|
63
|
+
operatorApiPathPrefix: normalizeMountPath(optional('OPERATOR_API_PATH_PREFIX', '')),
|
|
64
|
+
operatorCorsOrigins: Array.isArray(raslerConfig?.operatorCorsOrigins)
|
|
65
|
+
? raslerConfig.operatorCorsOrigins.filter(s => typeof s === 'string' && s.trim()).map(s => s.trim())
|
|
66
|
+
: [],
|
|
67
|
+
// Array of { hostname, prefix, directory } sorted longest-prefix-first.
|
|
68
|
+
mountPoints,
|
|
69
|
+
// Array of { directory, watch, ignore } for all static roots (includes mount
|
|
70
|
+
// point directories and the implicit ./public if it exists).
|
|
71
|
+
staticRoots,
|
|
72
|
+
// Maximum number of MASL versions to keep pinned per static root (including
|
|
73
|
+
// the current one). Older entries are unpinned and become eligible for LRU
|
|
74
|
+
// eviction. Unset or 0 means no limit.
|
|
75
|
+
staticMaxHistory: (() => {
|
|
76
|
+
const n = parseInt(raslerConfig?.staticMaxHistory ?? 0, 10);
|
|
77
|
+
return Number.isFinite(n) && n >= 1 ? n : null;
|
|
78
|
+
})(),
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
export default config;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { CID } from 'multiformats/cid';
|
|
2
|
+
import { sha256 } from 'multiformats/hashes/sha2';
|
|
3
|
+
import * as raw from 'multiformats/codecs/raw';
|
|
4
|
+
import * as dagCbor from '@ipld/dag-cbor';
|
|
5
|
+
import { base32 } from 'multiformats/bases/base32';
|
|
6
|
+
|
|
7
|
+
export async function computeDataCid(bytes) {
|
|
8
|
+
const hash = await sha256.digest(bytes);
|
|
9
|
+
const cid = CID.create(1, raw.code, hash);
|
|
10
|
+
return cid.toString(base32);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function computeMaslCid(cborBytes) {
|
|
14
|
+
const hash = await sha256.digest(cborBytes);
|
|
15
|
+
const cid = CID.create(1, dagCbor.code, hash);
|
|
16
|
+
return cid.toString(base32);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Returns true when the CID uses the dag-cbor codec (0x71), meaning it is a MASL document.
|
|
20
|
+
export function isMaslCid(cidString) {
|
|
21
|
+
try {
|
|
22
|
+
const cid = CID.parse(cidString, base32);
|
|
23
|
+
return cid.code === dagCbor.code;
|
|
24
|
+
} catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Returns the Unencoded-Digest header value for a CID.
|
|
30
|
+
// The SHA-256 bytes are extracted directly from the CID's multihash — no
|
|
31
|
+
// additional hashing required. Works for both data and MASL CIDs.
|
|
32
|
+
export function cidToUnencodedDigest(cidString) {
|
|
33
|
+
const cid = CID.parse(cidString, base32);
|
|
34
|
+
return 'sha-256=:' + Buffer.from(cid.multihash.digest).toString('base64') + ':';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function getRingPosition(cidString) {
|
|
38
|
+
const cid = CID.parse(cidString, base32);
|
|
39
|
+
const digest = cid.multihash.digest;
|
|
40
|
+
return bufferToBigInt(digest);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function bufferToBigInt(bytes) {
|
|
44
|
+
let result = 0n;
|
|
45
|
+
for (const byte of bytes) {
|
|
46
|
+
result = (result << 8n) | BigInt(byte);
|
|
47
|
+
}
|
|
48
|
+
return result;
|
|
49
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import config from './config.js';
|
|
2
|
+
import { openDb } from './storage/db.js';
|
|
3
|
+
import { Store } from './storage/store.js';
|
|
4
|
+
import { createApp, finalizeApp } from './server.js';
|
|
5
|
+
import { makeRaslNotFoundHandler } from './routes/rasl.js';
|
|
6
|
+
import { makeOperatorRouter } from './routes/operator.js';
|
|
7
|
+
import { indexStaticRoots } from './static.js';
|
|
8
|
+
import { startStaticRootWatchers } from './watcher.js';
|
|
9
|
+
|
|
10
|
+
function main() {
|
|
11
|
+
const db = openDb(config.dataDir);
|
|
12
|
+
const store = new Store(db, config.dataDir, config.totalCapacity, {
|
|
13
|
+
staticRoots: config.staticRoots,
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
// Index static roots in the background so the server starts immediately.
|
|
17
|
+
// Watchers start after the initial index so startup writes don't trigger
|
|
18
|
+
// spurious re-indexes. On a warm restart (no file changes) the mtime cache
|
|
19
|
+
// makes the window negligible.
|
|
20
|
+
if (config.staticRoots.length > 0) {
|
|
21
|
+
indexStaticRoots(config.staticRoots, store, { maxHistory: config.staticMaxHistory })
|
|
22
|
+
.then(() => startStaticRootWatchers(config.staticRoots, store, { maxHistory: config.staticMaxHistory }));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const app = createApp({ store, config });
|
|
26
|
+
const prefix = config.operatorApiPathPrefix || '/';
|
|
27
|
+
|
|
28
|
+
app.use(makeRaslNotFoundHandler());
|
|
29
|
+
app.use(prefix, makeOperatorRouter({
|
|
30
|
+
store,
|
|
31
|
+
selfOrigin: config.origin,
|
|
32
|
+
apiSecret: config.apiSecret,
|
|
33
|
+
corsOrigins: config.operatorCorsOrigins,
|
|
34
|
+
staticRoots: config.staticRoots,
|
|
35
|
+
mountPoints: config.mountPoints,
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
finalizeApp(app, config);
|
|
39
|
+
|
|
40
|
+
app.listen(config.port, () => {
|
|
41
|
+
console.log(`RASL node ${config.origin} listening on port ${config.port}`);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
main();
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import * as dagCbor from '@ipld/dag-cbor';
|
|
2
|
+
import { computeMaslCid } from '../crypto/cid.js';
|
|
3
|
+
|
|
4
|
+
// MASL link format: { "$link": cidString }
|
|
5
|
+
function link(cidString) {
|
|
6
|
+
return { $link: cidString };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function linkCid(linkObj) {
|
|
10
|
+
return linkObj?.$link ?? null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// HTTP header keys that MASL documents may carry and that nodes must forward when serving.
|
|
14
|
+
const HTTP_HEADER_KEYS = [
|
|
15
|
+
'content-type',
|
|
16
|
+
'content-disposition',
|
|
17
|
+
'content-encoding',
|
|
18
|
+
'content-language',
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
// Collect all present HTTP header fields from a doc or resource entry object.
|
|
22
|
+
function httpHeaders(obj) {
|
|
23
|
+
const headers = {};
|
|
24
|
+
for (const key of HTTP_HEADER_KEYS) {
|
|
25
|
+
if (obj[key] != null) headers[key] = obj[key];
|
|
26
|
+
}
|
|
27
|
+
if (!headers['content-type']) headers['content-type'] = 'application/octet-stream';
|
|
28
|
+
return headers;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function createSingleMasl({
|
|
32
|
+
name, type, size, dataCid,
|
|
33
|
+
contentEncoding, contentLanguage,
|
|
34
|
+
}) {
|
|
35
|
+
const doc = {
|
|
36
|
+
name,
|
|
37
|
+
type,
|
|
38
|
+
'content-length': size,
|
|
39
|
+
src: link(dataCid),
|
|
40
|
+
'content-type': type,
|
|
41
|
+
'content-disposition': `inline; filename="${name}"`,
|
|
42
|
+
...(contentEncoding != null ? { 'content-encoding': contentEncoding } : {}),
|
|
43
|
+
...(contentLanguage != null ? { 'content-language': contentLanguage } : {}),
|
|
44
|
+
};
|
|
45
|
+
const cborBytes = dagCbor.encode(doc);
|
|
46
|
+
const maslCid = await computeMaslCid(cborBytes);
|
|
47
|
+
return { doc, cborBytes, maslCid };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function createBundleMasl({ name, resources, prevMaslCid = null }) {
|
|
51
|
+
// resources: Array of { path, cid, size, contentType, contentDisposition?,
|
|
52
|
+
// contentEncoding?, contentLanguage? }
|
|
53
|
+
const resourceMap = {};
|
|
54
|
+
for (const { path, cid, size, contentType, contentDisposition, contentEncoding, contentLanguage } of resources) {
|
|
55
|
+
resourceMap[path] = {
|
|
56
|
+
src: link(cid),
|
|
57
|
+
'content-length': size,
|
|
58
|
+
'content-type': contentType ?? 'application/octet-stream',
|
|
59
|
+
...(contentDisposition != null ? { 'content-disposition': contentDisposition } : {}),
|
|
60
|
+
...(contentEncoding != null ? { 'content-encoding': contentEncoding } : {}),
|
|
61
|
+
...(contentLanguage != null ? { 'content-language': contentLanguage } : {}),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
const doc = {
|
|
65
|
+
name,
|
|
66
|
+
...(prevMaslCid != null ? { prev: link(prevMaslCid) } : {}),
|
|
67
|
+
resources: resourceMap,
|
|
68
|
+
};
|
|
69
|
+
const cborBytes = dagCbor.encode(doc);
|
|
70
|
+
const maslCid = await computeMaslCid(cborBytes);
|
|
71
|
+
return { doc, cborBytes, maslCid };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function parseMasl(cborBytes) {
|
|
75
|
+
return dagCbor.decode(cborBytes);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Returns [{ cid: string, size: number|null }] for all directly linked data CIDs.
|
|
79
|
+
export function maslLinkedCids(doc) {
|
|
80
|
+
const results = [];
|
|
81
|
+
if (doc.src) {
|
|
82
|
+
const cid = linkCid(doc.src);
|
|
83
|
+
if (cid) results.push({ cid, size: doc['content-length'] ?? null });
|
|
84
|
+
} else if (doc.resources) {
|
|
85
|
+
for (const entry of Object.values(doc.resources)) {
|
|
86
|
+
if (entry && typeof entry === 'object') {
|
|
87
|
+
const cid = linkCid(entry.src);
|
|
88
|
+
if (cid) results.push({ cid, size: entry['content-length'] ?? null });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return results;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Returns HTTP headers to set when serving a single-mode MASL CID or a data CID
|
|
96
|
+
// whose MASL wrapper is known.
|
|
97
|
+
export function maslContentHeaders(doc) {
|
|
98
|
+
return httpHeaders(doc);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function maslIsBundle(doc) {
|
|
102
|
+
return Boolean(doc.resources);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Returns true if bytes appear to be a MASL document (dag-cbor map with src.$link
|
|
106
|
+
// or a resources map whose entries have src.$link). Used to reject MASL uploads
|
|
107
|
+
// from POST /pin, which only accepts raw data files.
|
|
108
|
+
export function isMaslDoc(bytes) {
|
|
109
|
+
try {
|
|
110
|
+
const doc = dagCbor.decode(bytes);
|
|
111
|
+
if (!doc || typeof doc !== 'object') return false;
|
|
112
|
+
if (doc.src?.$link != null) return true;
|
|
113
|
+
if (doc.resources && typeof doc.resources === 'object') {
|
|
114
|
+
return Object.values(doc.resources).some(e => e?.src?.$link != null);
|
|
115
|
+
}
|
|
116
|
+
return false;
|
|
117
|
+
} catch {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Finds the first resource entry in a bundle whose src CID matches dataCid,
|
|
123
|
+
// and returns its HTTP headers. Used to surface content-type for path-free
|
|
124
|
+
// data CID requests backed by a bundle MASL rather than a single MASL.
|
|
125
|
+
export function findBundleHeadersForCid(doc, dataCid) {
|
|
126
|
+
if (!doc.resources) return null;
|
|
127
|
+
for (const entry of Object.values(doc.resources)) {
|
|
128
|
+
if (entry?.src?.$link === dataCid) return httpHeaders(entry);
|
|
129
|
+
}
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Returns { cid, headers } for the given bundle path, or null if not found.
|
|
134
|
+
// headers is ready to pass directly to res.set().
|
|
135
|
+
export function resolveBundleEntry(doc, pathSuffix) {
|
|
136
|
+
if (!doc.resources) return null;
|
|
137
|
+
const key = pathSuffix || '/';
|
|
138
|
+
const entry = doc.resources[key];
|
|
139
|
+
if (!entry) return null;
|
|
140
|
+
const cid = linkCid(entry.src);
|
|
141
|
+
if (!cid) return null;
|
|
142
|
+
return { cid, headers: httpHeaders(entry) };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Convenience wrapper — returns only the CID string.
|
|
146
|
+
export function resolveBundlePath(doc, pathSuffix) {
|
|
147
|
+
return resolveBundleEntry(doc, pathSuffix)?.cid ?? null;
|
|
148
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export const OPERATOR_SECRET_HEADER = 'x-rasl-operator-secret';
|
|
2
|
+
|
|
3
|
+
export function requireApiSecret(apiSecret) {
|
|
4
|
+
return (req, res, next) => {
|
|
5
|
+
const provided = req.headers[OPERATOR_SECRET_HEADER];
|
|
6
|
+
if (!provided || provided !== apiSecret) {
|
|
7
|
+
return res.status(401).json({ error: 'Unauthorized' });
|
|
8
|
+
}
|
|
9
|
+
next();
|
|
10
|
+
};
|
|
11
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { OPERATOR_SECRET_HEADER } from './auth.js';
|
|
2
|
+
|
|
3
|
+
// Returns Express middleware that adds CORS headers to operator API responses.
|
|
4
|
+
// origins: string[] — pass ['*'] to allow all, or a list of specific origins.
|
|
5
|
+
export function makeOperatorCors(origins) {
|
|
6
|
+
const allowAll = origins.includes('*');
|
|
7
|
+
|
|
8
|
+
return (req, res, next) => {
|
|
9
|
+
const requestOrigin = req.headers.origin;
|
|
10
|
+
|
|
11
|
+
if (!requestOrigin) return next();
|
|
12
|
+
|
|
13
|
+
if (allowAll) {
|
|
14
|
+
res.set('Access-Control-Allow-Origin', '*');
|
|
15
|
+
} else if (origins.includes(requestOrigin)) {
|
|
16
|
+
res.set('Access-Control-Allow-Origin', requestOrigin);
|
|
17
|
+
res.vary('Origin');
|
|
18
|
+
} else {
|
|
19
|
+
// Origin not in whitelist — no CORS headers; browser will block the request.
|
|
20
|
+
if (req.method === 'OPTIONS') return res.sendStatus(204);
|
|
21
|
+
return next();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
res.set('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
|
|
25
|
+
res.set('Access-Control-Allow-Headers', `Content-Type, ${OPERATOR_SECRET_HEADER}`);
|
|
26
|
+
|
|
27
|
+
if (req.method === 'OPTIONS') return res.sendStatus(204);
|
|
28
|
+
|
|
29
|
+
next();
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import { realpathSync } from 'fs';
|
|
3
|
+
import { parseMasl, resolveBundleEntry } from '../masl/document.js';
|
|
4
|
+
import { cidToUnencodedDigest } from '../crypto/cid.js';
|
|
5
|
+
import { OPERATOR_SECRET_HEADER } from '../middleware/auth.js';
|
|
6
|
+
|
|
7
|
+
// Returns the first mount point whose hostname and prefix match the request,
|
|
8
|
+
// or null. mountPoints must be sorted: longer prefix first, specific hostname
|
|
9
|
+
// before wildcard (hostname='') at equal prefix lengths.
|
|
10
|
+
// hostname='' matches any Host: value (or no Host: header).
|
|
11
|
+
function findMountPoint(mountPoints, hostname, path) {
|
|
12
|
+
for (const mp of mountPoints) {
|
|
13
|
+
if (mp.hostname !== '' && mp.hostname !== hostname) continue;
|
|
14
|
+
if (mp.prefix === '' || path === mp.prefix || path.startsWith(mp.prefix + '/')) {
|
|
15
|
+
return mp;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Serves content by resolving the request path against the bundle MASL for
|
|
22
|
+
// the matching mount point. Sits before the RASL router so browser clients
|
|
23
|
+
// can use normal URLs; RASL paths are passed through unchanged.
|
|
24
|
+
//
|
|
25
|
+
// The MASL CID is read from store.staticRootMasls on every request, so it
|
|
26
|
+
// reflects the current indexed version automatically after any re-index.
|
|
27
|
+
export function makeMountPointRouter({ store, mountPoints, selfOrigin }) {
|
|
28
|
+
const router = Router();
|
|
29
|
+
|
|
30
|
+
router.use((req, res, next) => {
|
|
31
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') return next();
|
|
32
|
+
// Leave RASL retrieval paths to the RASL router.
|
|
33
|
+
if (req.path.startsWith('/.well-known/rasl/')) return next();
|
|
34
|
+
// Operator API requests (identified by the secret header) bypass mount-point
|
|
35
|
+
// routing so authenticated operator paths are always reachable even when a
|
|
36
|
+
// wildcard mount is configured.
|
|
37
|
+
if (req.headers[OPERATOR_SECRET_HEADER]) return next();
|
|
38
|
+
|
|
39
|
+
let maslCid;
|
|
40
|
+
let maslPath;
|
|
41
|
+
|
|
42
|
+
// Runtime mappings take priority over static-root mappings.
|
|
43
|
+
const runtimeMp = findMountPoint(store.runtimeMountPoints, req.hostname, req.path);
|
|
44
|
+
if (runtimeMp) {
|
|
45
|
+
maslCid = runtimeMp.maslCid;
|
|
46
|
+
maslPath = runtimeMp.prefix ? req.path.slice(runtimeMp.prefix.length) || '/' : req.path;
|
|
47
|
+
} else {
|
|
48
|
+
const staticMp = findMountPoint(mountPoints, req.hostname, req.path);
|
|
49
|
+
if (!staticMp) return next();
|
|
50
|
+
|
|
51
|
+
let realRoot;
|
|
52
|
+
try { realRoot = realpathSync(staticMp.directory); } catch { return next(); }
|
|
53
|
+
|
|
54
|
+
maslCid = store.staticRootMasls.get(realRoot) ?? null;
|
|
55
|
+
if (!maslCid) return res.status(503).json({ error: 'Mount point not yet indexed' });
|
|
56
|
+
|
|
57
|
+
maslPath = staticMp.prefix ? req.path.slice(staticMp.prefix.length) || '/' : req.path;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const maslEntry = store.getContent(maslCid);
|
|
61
|
+
if (!maslEntry) return res.status(503).json({ error: 'MASL unavailable' });
|
|
62
|
+
|
|
63
|
+
let doc;
|
|
64
|
+
try { doc = parseMasl(maslEntry.bytes); } catch {
|
|
65
|
+
return res.status(500).json({ error: 'Invalid MASL document' });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const resolved = resolveBundleEntry(doc, maslPath);
|
|
69
|
+
if (!resolved) return res.status(404).send('Not found');
|
|
70
|
+
|
|
71
|
+
store.recordRequest(maslCid);
|
|
72
|
+
store.recordRequest(resolved.cid);
|
|
73
|
+
|
|
74
|
+
const result = store.getContentStream(resolved.cid);
|
|
75
|
+
if (!result) return res.status(502).json({ error: 'Content unavailable' });
|
|
76
|
+
|
|
77
|
+
res.set(resolved.headers);
|
|
78
|
+
res.set('content-length', String(result.meta.size));
|
|
79
|
+
res.set('unencoded-digest', cidToUnencodedDigest(resolved.cid));
|
|
80
|
+
res.set('link', `<${selfOrigin}/.well-known/rasl/${maslCid}${maslPath}>; rel="duplicate"`);
|
|
81
|
+
res.status(200);
|
|
82
|
+
result.stream.pipe(res);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
return router;
|
|
86
|
+
}
|