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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javascript-solid-server",
3
- "version": "0.0.149",
3
+ "version": "0.0.151",
4
4
  "description": "A minimal, fast Solid server",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -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 (pathMatch && !pathMatch[1].startsWith('.')) {
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}`;
@@ -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
- return JSON.parse(data);
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
- // No quota file - return defaults (will be initialized on first write)
31
- return { limit: 0, used: 0 };
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
- await fs.writeFile(getQuotaPath(podName), JSON.stringify(quota, null, 2));
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 itself
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 = await initializeQuota(podName, defaultQuota);
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
+ });