javascript-solid-server 0.0.149 → 0.0.151
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/package.json +1 -1
- package/src/auth/middleware.js +7 -2
- package/src/storage/quota.js +44 -8
- package/test/quota-race.test.js +143 -0
- package/test/subdomain-base-files.test.js +109 -0
package/package.json
CHANGED
package/src/auth/middleware.js
CHANGED
|
@@ -23,13 +23,18 @@ import { generateDatabrowserHtml, generateModuleDatabrowserHtml } from '../mashl
|
|
|
23
23
|
* @param {string} urlPath - URL path (e.g. /alice/public/file.ttl)
|
|
24
24
|
* @returns {string} Normalized resource URL
|
|
25
25
|
*/
|
|
26
|
-
function buildResourceUrl(request, urlPath) {
|
|
26
|
+
export function buildResourceUrl(request, urlPath) {
|
|
27
27
|
// Use request.headers.host (includes port) instead of request.hostname (strips port)
|
|
28
28
|
const host = request.headers.host || request.hostname;
|
|
29
29
|
if (request.subdomainsEnabled && request.baseDomain &&
|
|
30
30
|
request.hostname === request.baseDomain && !request.podName) {
|
|
31
31
|
const pathMatch = urlPath.match(/^\/([^/]+)(\/.*)?$/);
|
|
32
|
-
if
|
|
32
|
+
// Treat a path segment as a pod name only if it looks like one:
|
|
33
|
+
// - not a dotfile (.well-known, .acl, .meta, ...)
|
|
34
|
+
// - no dot (pod names are DNS labels; file names have extensions)
|
|
35
|
+
// This avoids rewriting /mashlib.js to https://mashlib.js.basedomain/
|
|
36
|
+
// which would fail WAC against the base domain's ACL. (#307)
|
|
37
|
+
if (pathMatch && !pathMatch[1].startsWith('.') && !pathMatch[1].includes('.')) {
|
|
33
38
|
const podName = pathMatch[1];
|
|
34
39
|
const remainder = pathMatch[2] || '/';
|
|
35
40
|
return `${request.protocol}://${podName}.${request.baseDomain}${remainder}`;
|
package/src/storage/quota.js
CHANGED
|
@@ -8,6 +8,10 @@ import { join } from 'path';
|
|
|
8
8
|
import { getDataRoot } from '../utils/url.js';
|
|
9
9
|
|
|
10
10
|
const QUOTA_FILE = '.quota.json';
|
|
11
|
+
// Temp files created by saveQuota live next to the quota file; they must be
|
|
12
|
+
// excluded from disk-usage reconciliation so an orphan (e.g. from a crash
|
|
13
|
+
// between writeFile and rename) doesn't inflate pod usage.
|
|
14
|
+
const QUOTA_TMP_PREFIX = `${QUOTA_FILE}.tmp.`;
|
|
11
15
|
|
|
12
16
|
/**
|
|
13
17
|
* Get quota file path for a pod
|
|
@@ -16,6 +20,18 @@ function getQuotaPath(podName) {
|
|
|
16
20
|
return join(getDataRoot(), podName, QUOTA_FILE);
|
|
17
21
|
}
|
|
18
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Coerce a parsed quota object to a sane shape so all callers can do
|
|
25
|
+
* arithmetic on it without worrying about undefined/NaN/negative values.
|
|
26
|
+
*/
|
|
27
|
+
function sanitizeQuota(q) {
|
|
28
|
+
const toNum = (v) => {
|
|
29
|
+
const n = Number(v);
|
|
30
|
+
return Number.isFinite(n) && n >= 0 ? n : 0;
|
|
31
|
+
};
|
|
32
|
+
return { limit: toNum(q?.limit), used: toNum(q?.used) };
|
|
33
|
+
}
|
|
34
|
+
|
|
19
35
|
/**
|
|
20
36
|
* Load quota data for a pod
|
|
21
37
|
* @param {string} podName - The pod name
|
|
@@ -24,11 +40,15 @@ function getQuotaPath(podName) {
|
|
|
24
40
|
export async function loadQuota(podName) {
|
|
25
41
|
try {
|
|
26
42
|
const data = await fs.readFile(getQuotaPath(podName), 'utf-8');
|
|
27
|
-
|
|
43
|
+
// Empty/partial file can appear if a write was interrupted (or from a
|
|
44
|
+
// racing non-atomic save on older versions). Treat as missing and
|
|
45
|
+
// reconcile against on-disk usage so we don't under-count.
|
|
46
|
+
if (!data.trim()) return { limit: 0, used: await calculatePodSize(podName) };
|
|
47
|
+
return sanitizeQuota(JSON.parse(data));
|
|
28
48
|
} catch (err) {
|
|
29
|
-
if (err.code === 'ENOENT') {
|
|
30
|
-
|
|
31
|
-
return { limit: 0, used:
|
|
49
|
+
if (err.code === 'ENOENT') return { limit: 0, used: 0 };
|
|
50
|
+
if (err instanceof SyntaxError) {
|
|
51
|
+
return { limit: 0, used: await calculatePodSize(podName) };
|
|
32
52
|
}
|
|
33
53
|
throw err;
|
|
34
54
|
}
|
|
@@ -40,7 +60,19 @@ export async function loadQuota(podName) {
|
|
|
40
60
|
* @param {object} quota - Quota data
|
|
41
61
|
*/
|
|
42
62
|
export async function saveQuota(podName, quota) {
|
|
43
|
-
|
|
63
|
+
// Atomic write: fs.writeFile truncates before writing, which lets concurrent
|
|
64
|
+
// readers see an empty file. Write to a temp file and rename (POSIX-atomic).
|
|
65
|
+
// Any failure (writeFile or rename) cleans up the temp file so failed writes
|
|
66
|
+
// don't accumulate orphans.
|
|
67
|
+
const finalPath = getQuotaPath(podName);
|
|
68
|
+
const tmpPath = `${finalPath}.tmp.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}`;
|
|
69
|
+
try {
|
|
70
|
+
await fs.writeFile(tmpPath, JSON.stringify(quota, null, 2));
|
|
71
|
+
await fs.rename(tmpPath, finalPath);
|
|
72
|
+
} catch (err) {
|
|
73
|
+
await fs.unlink(tmpPath).catch(() => {});
|
|
74
|
+
throw err;
|
|
75
|
+
}
|
|
44
76
|
}
|
|
45
77
|
|
|
46
78
|
/**
|
|
@@ -76,8 +108,9 @@ async function calculateDirSize(dirPath) {
|
|
|
76
108
|
for (const entry of entries) {
|
|
77
109
|
const fullPath = join(dirPath, entry.name);
|
|
78
110
|
|
|
79
|
-
// Skip quota file
|
|
111
|
+
// Skip the quota file and any orphaned temp files from saveQuota.
|
|
80
112
|
if (entry.name === QUOTA_FILE) continue;
|
|
113
|
+
if (entry.name.startsWith(QUOTA_TMP_PREFIX)) continue;
|
|
81
114
|
|
|
82
115
|
if (entry.isDirectory()) {
|
|
83
116
|
total += await calculateDirSize(fullPath);
|
|
@@ -106,9 +139,12 @@ async function calculateDirSize(dirPath) {
|
|
|
106
139
|
export async function checkQuota(podName, additionalBytes, defaultQuota) {
|
|
107
140
|
let quota = await loadQuota(podName);
|
|
108
141
|
|
|
109
|
-
// Initialize if no quota set
|
|
142
|
+
// Initialize if no quota set. loadQuota already sanitizes shape, so `used`
|
|
143
|
+
// is a finite non-negative number here — preserve it so reconciled usage
|
|
144
|
+
// from a recovered corrupt/empty file is not reset to 0.
|
|
110
145
|
if (quota.limit === 0 && defaultQuota > 0) {
|
|
111
|
-
quota =
|
|
146
|
+
quota = { limit: defaultQuota, used: quota.used };
|
|
147
|
+
await saveQuota(podName, quota);
|
|
112
148
|
}
|
|
113
149
|
|
|
114
150
|
// No quota enforcement if limit is 0
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression tests for #309 — concurrent quota updates causing 500s.
|
|
3
|
+
*
|
|
4
|
+
* Without atomic writes, two concurrent updateQuotaUsage calls race on
|
|
5
|
+
* .quota.json — the second load can read an empty/partial file and
|
|
6
|
+
* JSON.parse throws "Unexpected end of JSON input".
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, before, after } from 'node:test';
|
|
10
|
+
import assert from 'node:assert';
|
|
11
|
+
import fs from 'fs-extra';
|
|
12
|
+
import os from 'os';
|
|
13
|
+
import path from 'path';
|
|
14
|
+
import {
|
|
15
|
+
initializeQuota,
|
|
16
|
+
updateQuotaUsage,
|
|
17
|
+
loadQuota,
|
|
18
|
+
saveQuota,
|
|
19
|
+
checkQuota,
|
|
20
|
+
calculatePodSize
|
|
21
|
+
} from '../src/storage/quota.js';
|
|
22
|
+
|
|
23
|
+
const POD = 'testpod';
|
|
24
|
+
let TEST_ROOT;
|
|
25
|
+
let originalDataRoot;
|
|
26
|
+
|
|
27
|
+
describe('quota — concurrent updates (#309)', () => {
|
|
28
|
+
before(async () => {
|
|
29
|
+
originalDataRoot = process.env.DATA_ROOT;
|
|
30
|
+
TEST_ROOT = await fs.mkdtemp(path.join(os.tmpdir(), 'jss-quota-'));
|
|
31
|
+
process.env.DATA_ROOT = TEST_ROOT;
|
|
32
|
+
await fs.ensureDir(path.join(TEST_ROOT, POD));
|
|
33
|
+
await initializeQuota(POD, 50 * 1024 * 1024);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
after(async () => {
|
|
37
|
+
if (originalDataRoot === undefined) delete process.env.DATA_ROOT;
|
|
38
|
+
else process.env.DATA_ROOT = originalDataRoot;
|
|
39
|
+
await fs.remove(TEST_ROOT);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('many concurrent updates do not throw', async () => {
|
|
43
|
+
const N = 50;
|
|
44
|
+
const updates = Array.from({ length: N }, (_, i) =>
|
|
45
|
+
updateQuotaUsage(POD, 10 + i)
|
|
46
|
+
);
|
|
47
|
+
await assert.doesNotReject(Promise.all(updates));
|
|
48
|
+
const final = await loadQuota(POD);
|
|
49
|
+
assert.strictEqual(typeof final.used, 'number');
|
|
50
|
+
assert.ok(final.used > 0);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
async function withResourceAndBrokenQuota(brokenContent) {
|
|
54
|
+
const resourcePath = path.join(TEST_ROOT, POD, 'resource.txt');
|
|
55
|
+
const quotaPath = path.join(TEST_ROOT, POD, '.quota.json');
|
|
56
|
+
const size = 321;
|
|
57
|
+
await fs.writeFile(resourcePath, 'x'.repeat(size));
|
|
58
|
+
await fs.writeFile(quotaPath, brokenContent);
|
|
59
|
+
return { size, cleanup: () => fs.remove(resourcePath) };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
it('loadQuota reconciles usage from disk when quota file is empty', async () => {
|
|
63
|
+
const { size, cleanup } = await withResourceAndBrokenQuota('');
|
|
64
|
+
try {
|
|
65
|
+
const q = await loadQuota(POD);
|
|
66
|
+
assert.strictEqual(q.limit, 0);
|
|
67
|
+
assert.strictEqual(q.used, size);
|
|
68
|
+
} finally { await cleanup(); }
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('loadQuota reconciles usage from disk when quota file is corrupt', async () => {
|
|
72
|
+
const { size, cleanup } = await withResourceAndBrokenQuota('{"limit":524');
|
|
73
|
+
try {
|
|
74
|
+
const q = await loadQuota(POD);
|
|
75
|
+
assert.strictEqual(q.limit, 0);
|
|
76
|
+
assert.strictEqual(q.used, size);
|
|
77
|
+
} finally { await cleanup(); }
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('loadQuota sanitizes malformed fields (missing/null/negative)', async () => {
|
|
81
|
+
const quotaPath = path.join(TEST_ROOT, POD, '.quota.json');
|
|
82
|
+
const cases = [
|
|
83
|
+
['{"limit":0}', { limit: 0, used: 0 }],
|
|
84
|
+
['{"limit":null,"used":null}', { limit: 0, used: 0 }],
|
|
85
|
+
['{"limit":-10,"used":-5}', { limit: 0, used: 0 }],
|
|
86
|
+
// Numeric strings are coerced — tolerates legacy/manually-edited files.
|
|
87
|
+
['{"limit":"100","used":"50"}', { limit: 100, used: 50 }]
|
|
88
|
+
];
|
|
89
|
+
for (const [body, expected] of cases) {
|
|
90
|
+
await fs.writeFile(quotaPath, body);
|
|
91
|
+
const q = await loadQuota(POD);
|
|
92
|
+
assert.deepStrictEqual(q, expected, `for ${body}`);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('calculatePodSize ignores orphaned quota temp files', async () => {
|
|
97
|
+
// Simulate an orphan from a crashed saveQuota (process died between
|
|
98
|
+
// writeFile and rename) — must not be counted toward pod usage.
|
|
99
|
+
const resource = path.join(TEST_ROOT, POD, 'data.txt');
|
|
100
|
+
const orphan = path.join(TEST_ROOT, POD, '.quota.json.tmp.999.1.abc');
|
|
101
|
+
await fs.writeFile(resource, 'x'.repeat(50));
|
|
102
|
+
await fs.writeFile(orphan, 'x'.repeat(9999));
|
|
103
|
+
try {
|
|
104
|
+
const size = await calculatePodSize(POD);
|
|
105
|
+
assert.strictEqual(size, 50, 'orphan temp file must not be counted');
|
|
106
|
+
} finally {
|
|
107
|
+
await fs.remove(resource);
|
|
108
|
+
await fs.remove(orphan);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('checkQuota preserves reconciled usage when re-initializing limit', async () => {
|
|
113
|
+
const { size, cleanup } = await withResourceAndBrokenQuota('');
|
|
114
|
+
try {
|
|
115
|
+
const defaultQuota = 10 * 1024 * 1024;
|
|
116
|
+
const { quota } = await checkQuota(POD, 0, defaultQuota);
|
|
117
|
+
assert.strictEqual(quota.limit, defaultQuota);
|
|
118
|
+
assert.strictEqual(quota.used, size, 'reconciled usage must not be reset to 0');
|
|
119
|
+
} finally { await cleanup(); }
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('saveQuota is atomic — concurrent read during save never sees empty file', async () => {
|
|
123
|
+
await saveQuota(POD, { limit: 1000, used: 100 });
|
|
124
|
+
const quotaPath = path.join(TEST_ROOT, POD, '.quota.json');
|
|
125
|
+
|
|
126
|
+
// Interleave 200 saves with 200 reads; with non-atomic writes, at least
|
|
127
|
+
// one read would land on a truncated file — the empty-file assertion
|
|
128
|
+
// below (or the JSON.parse) would then fail.
|
|
129
|
+
const ops = [];
|
|
130
|
+
for (let i = 0; i < 200; i++) {
|
|
131
|
+
ops.push(saveQuota(POD, { limit: 1000, used: i }));
|
|
132
|
+
ops.push(fs.readFile(quotaPath, 'utf-8').then((data) => {
|
|
133
|
+
assert.notStrictEqual(
|
|
134
|
+
data.length,
|
|
135
|
+
0,
|
|
136
|
+
'concurrent read saw an empty quota file'
|
|
137
|
+
);
|
|
138
|
+
JSON.parse(data);
|
|
139
|
+
}));
|
|
140
|
+
}
|
|
141
|
+
await assert.doesNotReject(Promise.all(ops));
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression tests for #307 — buildResourceUrl rewriting the base-domain
|
|
3
|
+
* root file paths into non-existent pod subdomains.
|
|
4
|
+
*
|
|
5
|
+
* Unit tests against buildResourceUrl directly, since Node's fetch() overrides
|
|
6
|
+
* the Host header with the TCP target, which makes end-to-end tests of
|
|
7
|
+
* subdomain routing impossible without a real reverse proxy.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, it } from 'node:test';
|
|
11
|
+
import assert from 'node:assert';
|
|
12
|
+
import { buildResourceUrl } from '../src/auth/middleware.js';
|
|
13
|
+
|
|
14
|
+
function makeRequest({ hostname, baseDomain, subdomainsEnabled = true, podName = null, protocol = 'https' }) {
|
|
15
|
+
return {
|
|
16
|
+
protocol,
|
|
17
|
+
hostname,
|
|
18
|
+
headers: { host: hostname },
|
|
19
|
+
subdomainsEnabled,
|
|
20
|
+
baseDomain,
|
|
21
|
+
podName
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('buildResourceUrl — base-domain files (#307)', () => {
|
|
26
|
+
const baseDomain = 'example.com';
|
|
27
|
+
|
|
28
|
+
it('base-domain root (/) — no rewrite', () => {
|
|
29
|
+
const req = makeRequest({ hostname: baseDomain, baseDomain });
|
|
30
|
+
assert.strictEqual(buildResourceUrl(req, '/'), 'https://example.com/');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('base-domain /welcome.js — no rewrite (filename has extension)', () => {
|
|
34
|
+
const req = makeRequest({ hostname: baseDomain, baseDomain });
|
|
35
|
+
assert.strictEqual(buildResourceUrl(req, '/welcome.js'), 'https://example.com/welcome.js');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('base-domain /mashlib.js — no rewrite', () => {
|
|
39
|
+
const req = makeRequest({ hostname: baseDomain, baseDomain });
|
|
40
|
+
assert.strictEqual(buildResourceUrl(req, '/mashlib.js'), 'https://example.com/mashlib.js');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('base-domain /terms.html — no rewrite', () => {
|
|
44
|
+
const req = makeRequest({ hostname: baseDomain, baseDomain });
|
|
45
|
+
assert.strictEqual(buildResourceUrl(req, '/terms.html'), 'https://example.com/terms.html');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('base-domain /.well-known/foo — no rewrite (leading dot)', () => {
|
|
49
|
+
const req = makeRequest({ hostname: baseDomain, baseDomain });
|
|
50
|
+
assert.strictEqual(
|
|
51
|
+
buildResourceUrl(req, '/.well-known/foo'),
|
|
52
|
+
'https://example.com/.well-known/foo'
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('buildResourceUrl — pod routing still works', () => {
|
|
58
|
+
const baseDomain = 'example.com';
|
|
59
|
+
|
|
60
|
+
it('base-domain /alice/ — rewrites to alice.example.com (no dot → pod name)', () => {
|
|
61
|
+
const req = makeRequest({ hostname: baseDomain, baseDomain });
|
|
62
|
+
assert.strictEqual(buildResourceUrl(req, '/alice/'), 'https://alice.example.com/');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('base-domain /alice — rewrites (bare pod-root without trailing slash)', () => {
|
|
66
|
+
const req = makeRequest({ hostname: baseDomain, baseDomain });
|
|
67
|
+
assert.strictEqual(buildResourceUrl(req, '/alice'), 'https://alice.example.com/');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('base-domain /alice/profile/card.jsonld — rewrites to alice.example.com/profile/card.jsonld', () => {
|
|
71
|
+
const req = makeRequest({ hostname: baseDomain, baseDomain });
|
|
72
|
+
assert.strictEqual(
|
|
73
|
+
buildResourceUrl(req, '/alice/profile/card.jsonld'),
|
|
74
|
+
'https://alice.example.com/profile/card.jsonld'
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('already-on-subdomain request — no rewrite (hostname !== baseDomain)', () => {
|
|
79
|
+
const req = makeRequest({
|
|
80
|
+
hostname: 'alice.example.com',
|
|
81
|
+
baseDomain,
|
|
82
|
+
podName: 'alice'
|
|
83
|
+
});
|
|
84
|
+
assert.strictEqual(
|
|
85
|
+
buildResourceUrl(req, '/profile/card.jsonld'),
|
|
86
|
+
'https://alice.example.com/profile/card.jsonld'
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('buildResourceUrl — subdomain mode disabled', () => {
|
|
92
|
+
it('no rewrite when subdomainsEnabled is false', () => {
|
|
93
|
+
const req = makeRequest({
|
|
94
|
+
hostname: 'example.com',
|
|
95
|
+
baseDomain: 'example.com',
|
|
96
|
+
subdomainsEnabled: false
|
|
97
|
+
});
|
|
98
|
+
assert.strictEqual(buildResourceUrl(req, '/alice/'), 'https://example.com/alice/');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('no rewrite when baseDomain is not set', () => {
|
|
102
|
+
const req = makeRequest({
|
|
103
|
+
hostname: 'example.com',
|
|
104
|
+
baseDomain: null,
|
|
105
|
+
subdomainsEnabled: true
|
|
106
|
+
});
|
|
107
|
+
assert.strictEqual(buildResourceUrl(req, '/alice/'), 'https://example.com/alice/');
|
|
108
|
+
});
|
|
109
|
+
});
|