javascript-solid-server 0.0.3 → 0.0.6

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.3",
3
+ "version": "0.0.6",
4
4
  "description": "A minimal, fast Solid server",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -11,11 +11,17 @@
11
11
  },
12
12
  "dependencies": {
13
13
  "fastify": "^4.25.2",
14
- "fs-extra": "^11.2.0"
14
+ "fs-extra": "^11.2.0",
15
+ "jose": "^6.1.3"
15
16
  },
16
17
  "engines": {
17
18
  "node": ">=18.0.0"
18
19
  },
19
- "keywords": ["solid", "ldp", "linked-data", "decentralized"],
20
+ "keywords": [
21
+ "solid",
22
+ "ldp",
23
+ "linked-data",
24
+ "decentralized"
25
+ ],
20
26
  "license": "MIT"
21
27
  }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Authorization middleware
3
+ * Combines authentication (token verification) with WAC checking
4
+ * Supports both simple Bearer tokens and Solid-OIDC DPoP tokens
5
+ */
6
+
7
+ import { getWebIdFromRequestAsync } from './token.js';
8
+ import { checkAccess, getRequiredMode } from '../wac/checker.js';
9
+ import * as storage from '../storage/filesystem.js';
10
+
11
+ /**
12
+ * Check if request is authorized
13
+ * @param {object} request - Fastify request
14
+ * @param {object} reply - Fastify reply
15
+ * @returns {Promise<{authorized: boolean, webId: string|null, wacAllow: string, authError: string|null}>}
16
+ */
17
+ export async function authorize(request, reply) {
18
+ const urlPath = request.url.split('?')[0];
19
+ const method = request.method;
20
+
21
+ // Skip auth for .acl files (they need special handling)
22
+ // and for OPTIONS (CORS preflight)
23
+ if (urlPath.endsWith('.acl') || method === 'OPTIONS') {
24
+ return { authorized: true, webId: null, wacAllow: 'user="read write append control", public="read write append"', authError: null };
25
+ }
26
+
27
+ // Get WebID from token (supports both simple and Solid-OIDC tokens)
28
+ const { webId, error: authError } = await getWebIdFromRequestAsync(request);
29
+
30
+ // Get resource info
31
+ const stats = await storage.stat(urlPath);
32
+ const resourceExists = stats !== null;
33
+ const isContainer = stats?.isDirectory || urlPath.endsWith('/');
34
+
35
+ // Build resource URL
36
+ const resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
37
+
38
+ // Get required access mode for this method
39
+ const requiredMode = getRequiredMode(method);
40
+
41
+ // For write operations on non-existent resources, check parent container
42
+ let checkPath = urlPath;
43
+ let checkUrl = resourceUrl;
44
+ let checkIsContainer = isContainer;
45
+
46
+ if (!resourceExists && (method === 'PUT' || method === 'POST' || method === 'PATCH')) {
47
+ // Check write permission on parent container
48
+ const parentPath = getParentPath(urlPath);
49
+ checkPath = parentPath;
50
+ checkUrl = `${request.protocol}://${request.hostname}${parentPath}`;
51
+ checkIsContainer = true;
52
+ }
53
+
54
+ // Check WAC permissions
55
+ const { allowed, wacAllow } = await checkAccess({
56
+ resourceUrl: checkUrl,
57
+ resourcePath: checkPath,
58
+ isContainer: checkIsContainer,
59
+ agentWebId: webId,
60
+ requiredMode
61
+ });
62
+
63
+ return { authorized: allowed, webId, wacAllow, authError };
64
+ }
65
+
66
+ /**
67
+ * Get parent container path
68
+ */
69
+ function getParentPath(path) {
70
+ const normalized = path.endsWith('/') ? path.slice(0, -1) : path;
71
+ const lastSlash = normalized.lastIndexOf('/');
72
+ if (lastSlash <= 0) return '/';
73
+ return normalized.substring(0, lastSlash + 1);
74
+ }
75
+
76
+ /**
77
+ * Handle unauthorized request
78
+ * @param {object} reply - Fastify reply
79
+ * @param {boolean} isAuthenticated - Whether user is authenticated
80
+ * @param {string} wacAllow - WAC-Allow header value
81
+ * @param {string|null} authError - Authentication error message (for DPoP failures)
82
+ */
83
+ export function handleUnauthorized(reply, isAuthenticated, wacAllow, authError = null) {
84
+ reply.header('WAC-Allow', wacAllow);
85
+
86
+ if (!isAuthenticated) {
87
+ // Not authenticated - return 401
88
+ return reply.code(401).send({
89
+ error: 'Unauthorized',
90
+ message: authError || 'Authentication required'
91
+ });
92
+ } else {
93
+ // Authenticated but not authorized - return 403
94
+ return reply.code(403).send({
95
+ error: 'Forbidden',
96
+ message: 'Access denied'
97
+ });
98
+ }
99
+ }
@@ -0,0 +1,260 @@
1
+ /**
2
+ * Solid-OIDC Resource Server
3
+ * Verifies DPoP-bound access tokens from external Identity Providers
4
+ *
5
+ * Flow:
6
+ * 1. User authenticates at external IdP (e.g., solidcommunity.net)
7
+ * 2. User gets DPoP-bound access token
8
+ * 3. User sends request with Authorization: DPoP <token> and DPoP: <proof>
9
+ * 4. We verify the token and DPoP proof
10
+ * 5. Extract WebID from token
11
+ */
12
+
13
+ import * as jose from 'jose';
14
+
15
+ // Cache for OIDC configurations and JWKS
16
+ const oidcConfigCache = new Map();
17
+ const jwksCache = new Map();
18
+
19
+ // Cache TTL (15 minutes)
20
+ const CACHE_TTL = 15 * 60 * 1000;
21
+
22
+ // DPoP proof max age (5 minutes)
23
+ const DPOP_MAX_AGE = 5 * 60;
24
+
25
+ /**
26
+ * Verify a Solid-OIDC request and extract WebID
27
+ * @param {object} request - Fastify request object
28
+ * @returns {Promise<{webId: string|null, error: string|null}>}
29
+ */
30
+ export async function verifySolidOidc(request) {
31
+ const authHeader = request.headers.authorization;
32
+ const dpopHeader = request.headers.dpop;
33
+
34
+ // Check for DPoP authorization scheme
35
+ if (!authHeader || !authHeader.startsWith('DPoP ')) {
36
+ return { webId: null, error: null }; // Not a Solid-OIDC request
37
+ }
38
+
39
+ if (!dpopHeader) {
40
+ return { webId: null, error: 'Missing DPoP proof header' };
41
+ }
42
+
43
+ const accessToken = authHeader.slice(5); // Remove 'DPoP ' prefix
44
+
45
+ try {
46
+ // Step 1: Decode access token (without verification) to get issuer
47
+ const tokenPayload = jose.decodeJwt(accessToken);
48
+ const issuer = tokenPayload.iss;
49
+
50
+ if (!issuer) {
51
+ return { webId: null, error: 'Access token missing issuer' };
52
+ }
53
+
54
+ // Step 2: Verify DPoP proof
55
+ const dpopResult = await verifyDpopProof(dpopHeader, request, accessToken);
56
+ if (dpopResult.error) {
57
+ return { webId: null, error: dpopResult.error };
58
+ }
59
+
60
+ // Step 3: Fetch JWKS and verify access token
61
+ const jwks = await getJwks(issuer);
62
+ const { payload } = await jose.jwtVerify(accessToken, jwks, {
63
+ issuer,
64
+ clockTolerance: 30 // 30 seconds clock skew tolerance
65
+ });
66
+
67
+ // Step 4: Verify DPoP binding (cnf.jkt must match DPoP key thumbprint)
68
+ if (payload.cnf?.jkt) {
69
+ if (payload.cnf.jkt !== dpopResult.thumbprint) {
70
+ return { webId: null, error: 'DPoP key does not match token binding' };
71
+ }
72
+ }
73
+
74
+ // Step 5: Extract WebID
75
+ const webId = payload.webid || payload.sub;
76
+ if (!webId) {
77
+ return { webId: null, error: 'Token missing WebID claim' };
78
+ }
79
+
80
+ // Validate WebID is a valid URL
81
+ try {
82
+ new URL(webId);
83
+ } catch {
84
+ return { webId: null, error: 'Invalid WebID URL' };
85
+ }
86
+
87
+ return { webId, error: null };
88
+
89
+ } catch (err) {
90
+ // Handle specific JWT errors
91
+ if (err.code === 'ERR_JWT_EXPIRED') {
92
+ return { webId: null, error: 'Access token expired' };
93
+ }
94
+ if (err.code === 'ERR_JWS_SIGNATURE_VERIFICATION_FAILED') {
95
+ return { webId: null, error: 'Invalid token signature' };
96
+ }
97
+ if (err.code === 'ERR_JWKS_NO_MATCHING_KEY') {
98
+ return { webId: null, error: 'No matching key found in JWKS' };
99
+ }
100
+
101
+ console.error('Solid-OIDC verification error:', err.message);
102
+ return { webId: null, error: 'Token verification failed' };
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Verify DPoP proof JWT
108
+ * @param {string} dpopProof - The DPoP proof JWT
109
+ * @param {object} request - Fastify request
110
+ * @param {string} accessToken - The access token (for ath claim verification)
111
+ * @returns {Promise<{thumbprint: string|null, error: string|null}>}
112
+ */
113
+ async function verifyDpopProof(dpopProof, request, accessToken) {
114
+ try {
115
+ // Decode header to get the public key
116
+ const protectedHeader = jose.decodeProtectedHeader(dpopProof);
117
+
118
+ if (protectedHeader.typ !== 'dpop+jwt') {
119
+ return { thumbprint: null, error: 'Invalid DPoP proof type' };
120
+ }
121
+
122
+ if (!protectedHeader.jwk) {
123
+ return { thumbprint: null, error: 'DPoP proof missing jwk header' };
124
+ }
125
+
126
+ // Import the public key from the JWK in the header
127
+ const publicKey = await jose.importJWK(protectedHeader.jwk, protectedHeader.alg);
128
+
129
+ // Verify the DPoP proof signature
130
+ const { payload } = await jose.jwtVerify(dpopProof, publicKey, {
131
+ clockTolerance: 30
132
+ });
133
+
134
+ // Verify required claims
135
+ // htm: HTTP method
136
+ const expectedMethod = request.method.toUpperCase();
137
+ if (payload.htm !== expectedMethod) {
138
+ return { thumbprint: null, error: `DPoP htm mismatch: expected ${expectedMethod}` };
139
+ }
140
+
141
+ // htu: HTTP URI (without query string and fragment)
142
+ const requestUrl = `${request.protocol}://${request.hostname}${request.url.split('?')[0]}`;
143
+ // Normalize both URLs for comparison
144
+ const payloadHtu = payload.htu?.replace(/\/$/, '');
145
+ const expectedHtu = requestUrl.replace(/\/$/, '');
146
+
147
+ if (payloadHtu !== expectedHtu) {
148
+ // Also try without port for localhost
149
+ const altHtu = requestUrl.replace(/:(\d+)/, '').replace(/\/$/, '');
150
+ if (payloadHtu !== altHtu) {
151
+ return { thumbprint: null, error: `DPoP htu mismatch: expected ${expectedHtu}` };
152
+ }
153
+ }
154
+
155
+ // iat: Issued at (must be recent)
156
+ const now = Math.floor(Date.now() / 1000);
157
+ if (!payload.iat || Math.abs(now - payload.iat) > DPOP_MAX_AGE) {
158
+ return { thumbprint: null, error: 'DPoP proof expired or invalid iat' };
159
+ }
160
+
161
+ // jti: Unique identifier (we should track these to prevent replay, but skip for now)
162
+ if (!payload.jti) {
163
+ return { thumbprint: null, error: 'DPoP proof missing jti' };
164
+ }
165
+
166
+ // ath: Access token hash (optional but recommended)
167
+ if (payload.ath) {
168
+ const expectedAth = await calculateAth(accessToken);
169
+ if (payload.ath !== expectedAth) {
170
+ return { thumbprint: null, error: 'DPoP ath mismatch' };
171
+ }
172
+ }
173
+
174
+ // Calculate JWK thumbprint for binding verification
175
+ const thumbprint = await jose.calculateJwkThumbprint(protectedHeader.jwk, 'sha256');
176
+
177
+ return { thumbprint, error: null };
178
+
179
+ } catch (err) {
180
+ console.error('DPoP verification error:', err.message);
181
+ return { thumbprint: null, error: 'Invalid DPoP proof' };
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Calculate access token hash (ath) for DPoP binding
187
+ */
188
+ async function calculateAth(accessToken) {
189
+ const encoder = new TextEncoder();
190
+ const data = encoder.encode(accessToken);
191
+ const hash = await crypto.subtle.digest('SHA-256', data);
192
+ return jose.base64url.encode(new Uint8Array(hash));
193
+ }
194
+
195
+ /**
196
+ * Fetch and cache OIDC configuration
197
+ */
198
+ async function getOidcConfig(issuer) {
199
+ const cached = oidcConfigCache.get(issuer);
200
+ if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
201
+ return cached.config;
202
+ }
203
+
204
+ const configUrl = `${issuer.replace(/\/$/, '')}/.well-known/openid-configuration`;
205
+
206
+ try {
207
+ const response = await fetch(configUrl);
208
+ if (!response.ok) {
209
+ throw new Error(`Failed to fetch OIDC config: ${response.status}`);
210
+ }
211
+
212
+ const config = await response.json();
213
+ oidcConfigCache.set(issuer, { config, timestamp: Date.now() });
214
+ return config;
215
+
216
+ } catch (err) {
217
+ console.error(`Failed to fetch OIDC config from ${issuer}:`, err.message);
218
+ throw err;
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Get JWKS for an issuer (with caching)
224
+ */
225
+ async function getJwks(issuer) {
226
+ const cached = jwksCache.get(issuer);
227
+ if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
228
+ return cached.jwks;
229
+ }
230
+
231
+ // Get OIDC config to find JWKS URI
232
+ const config = await getOidcConfig(issuer);
233
+ const jwksUri = config.jwks_uri;
234
+
235
+ if (!jwksUri) {
236
+ throw new Error('OIDC config missing jwks_uri');
237
+ }
238
+
239
+ // Create a remote JWKS set
240
+ const jwks = jose.createRemoteJWKSet(new URL(jwksUri));
241
+
242
+ jwksCache.set(issuer, { jwks, timestamp: Date.now() });
243
+ return jwks;
244
+ }
245
+
246
+ /**
247
+ * Clear caches (useful for testing)
248
+ */
249
+ export function clearCaches() {
250
+ oidcConfigCache.clear();
251
+ jwksCache.clear();
252
+ }
253
+
254
+ /**
255
+ * Check if request has Solid-OIDC authorization
256
+ */
257
+ export function hasSolidOidcAuth(request) {
258
+ const authHeader = request.headers.authorization;
259
+ return authHeader && authHeader.startsWith('DPoP ');
260
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Token-based authentication
3
+ *
4
+ * Supports two modes:
5
+ * 1. Simple tokens (for local/dev use): base64(JSON({webId, iat, exp})) + HMAC signature
6
+ * 2. Solid-OIDC DPoP tokens (for federation): verified via external IdP JWKS
7
+ */
8
+
9
+ import crypto from 'crypto';
10
+ import { verifySolidOidc, hasSolidOidcAuth } from './solid-oidc.js';
11
+
12
+ // Secret for signing tokens (in production, use env var)
13
+ const SECRET = process.env.TOKEN_SECRET || 'dev-secret-change-in-production';
14
+
15
+ /**
16
+ * Create a simple token for a WebID
17
+ * @param {string} webId - The WebID to create token for
18
+ * @param {number} expiresIn - Expiration time in seconds (default 1 hour)
19
+ * @returns {string} Token string
20
+ */
21
+ export function createToken(webId, expiresIn = 3600) {
22
+ const payload = {
23
+ webId,
24
+ iat: Math.floor(Date.now() / 1000),
25
+ exp: Math.floor(Date.now() / 1000) + expiresIn
26
+ };
27
+
28
+ const data = Buffer.from(JSON.stringify(payload)).toString('base64url');
29
+ const signature = crypto
30
+ .createHmac('sha256', SECRET)
31
+ .update(data)
32
+ .digest('base64url');
33
+
34
+ return `${data}.${signature}`;
35
+ }
36
+
37
+ /**
38
+ * Verify and decode a token
39
+ * @param {string} token - The token to verify
40
+ * @returns {{webId: string, iat: number, exp: number} | null} Decoded payload or null
41
+ */
42
+ export function verifyToken(token) {
43
+ if (!token || typeof token !== 'string') {
44
+ return null;
45
+ }
46
+
47
+ const parts = token.split('.');
48
+ if (parts.length !== 2) {
49
+ return null;
50
+ }
51
+
52
+ const [data, signature] = parts;
53
+
54
+ // Verify signature
55
+ const expectedSig = crypto
56
+ .createHmac('sha256', SECRET)
57
+ .update(data)
58
+ .digest('base64url');
59
+
60
+ if (signature !== expectedSig) {
61
+ return null;
62
+ }
63
+
64
+ // Decode payload
65
+ try {
66
+ const payload = JSON.parse(Buffer.from(data, 'base64url').toString());
67
+
68
+ // Check expiration
69
+ if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) {
70
+ return null;
71
+ }
72
+
73
+ return payload;
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Extract token from Authorization header
81
+ * @param {string} authHeader - Authorization header value
82
+ * @returns {string | null} Token or null
83
+ */
84
+ export function extractToken(authHeader) {
85
+ if (!authHeader || typeof authHeader !== 'string') {
86
+ return null;
87
+ }
88
+
89
+ // Support "Bearer <token>" format
90
+ if (authHeader.startsWith('Bearer ')) {
91
+ return authHeader.slice(7);
92
+ }
93
+
94
+ // Also support raw token
95
+ return authHeader;
96
+ }
97
+
98
+ /**
99
+ * Extract WebID from request (sync version for simple tokens only)
100
+ * @param {object} request - Fastify request object
101
+ * @returns {string | null} WebID or null if not authenticated
102
+ */
103
+ export function getWebIdFromRequest(request) {
104
+ const authHeader = request.headers.authorization;
105
+
106
+ // Skip DPoP tokens - use async version for those
107
+ if (authHeader && authHeader.startsWith('DPoP ')) {
108
+ return null;
109
+ }
110
+
111
+ const token = extractToken(authHeader);
112
+
113
+ if (!token) {
114
+ return null;
115
+ }
116
+
117
+ const payload = verifyToken(token);
118
+ return payload?.webId || null;
119
+ }
120
+
121
+ /**
122
+ * Extract WebID from request (async version supporting Solid-OIDC)
123
+ * @param {object} request - Fastify request object
124
+ * @returns {Promise<{webId: string|null, error: string|null}>}
125
+ */
126
+ export async function getWebIdFromRequestAsync(request) {
127
+ const authHeader = request.headers.authorization;
128
+
129
+ if (!authHeader) {
130
+ return { webId: null, error: null };
131
+ }
132
+
133
+ // Try Solid-OIDC first (DPoP tokens)
134
+ if (hasSolidOidcAuth(request)) {
135
+ return verifySolidOidc(request);
136
+ }
137
+
138
+ // Fall back to simple Bearer tokens
139
+ const token = extractToken(authHeader);
140
+ if (!token) {
141
+ return { webId: null, error: null };
142
+ }
143
+
144
+ const payload = verifyToken(token);
145
+ if (payload?.webId) {
146
+ return { webId: payload.webId, error: null };
147
+ }
148
+
149
+ return { webId: null, error: 'Invalid token' };
150
+ }
@@ -2,9 +2,8 @@ import * as storage from '../storage/filesystem.js';
2
2
  import { getAllHeaders } from '../ldp/headers.js';
3
3
  import { isContainer } from '../utils/url.js';
4
4
  import { generateProfile, generatePreferences, generateTypeIndex, serialize } from '../webid/profile.js';
5
-
6
- // Content type for profile card
7
- const PROFILE_CONTENT_TYPE = 'text/html';
5
+ import { generateOwnerAcl, generatePrivateAcl, generateInboxAcl, serializeAcl } from '../wac/parser.js';
6
+ import { createToken } from '../auth/token.js';
8
7
 
9
8
  /**
10
9
  * Handle POST request to container (create new resource)
@@ -133,6 +132,23 @@ export async function handleCreatePod(request, reply) {
133
132
  const privateTypeIndex = generateTypeIndex(`${podUri}settings/privateTypeIndex`);
134
133
  await storage.write(`${podPath}settings/privateTypeIndex`, serialize(privateTypeIndex));
135
134
 
135
+ // Create default ACL files
136
+ // Pod root: owner full control, public read
137
+ const rootAcl = generateOwnerAcl(podUri, webId, true);
138
+ await storage.write(`${podPath}.acl`, serializeAcl(rootAcl));
139
+
140
+ // Private folder: owner only (no public)
141
+ const privateAcl = generatePrivateAcl(`${podUri}private/`, webId);
142
+ await storage.write(`${podPath}private/.acl`, serializeAcl(privateAcl));
143
+
144
+ // Settings folder: owner only
145
+ const settingsAcl = generatePrivateAcl(`${podUri}settings/`, webId);
146
+ await storage.write(`${podPath}settings/.acl`, serializeAcl(settingsAcl));
147
+
148
+ // Inbox: owner full, public append
149
+ const inboxAcl = generateInboxAcl(`${podUri}inbox/`, webId);
150
+ await storage.write(`${podPath}inbox/.acl`, serializeAcl(inboxAcl));
151
+
136
152
  } catch (err) {
137
153
  console.error('Pod creation error:', err);
138
154
  // Cleanup on failure
@@ -146,9 +162,13 @@ export async function handleCreatePod(request, reply) {
146
162
 
147
163
  Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
148
164
 
165
+ // Generate token for the pod owner
166
+ const token = createToken(webId);
167
+
149
168
  return reply.code(201).send({
150
169
  name,
151
170
  webId,
152
- podUri
171
+ podUri,
172
+ token
153
173
  });
154
174
  }
@@ -15,6 +15,7 @@ export async function handleGet(request, reply) {
15
15
  }
16
16
 
17
17
  const origin = request.headers.origin;
18
+ const resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
18
19
 
19
20
  // Handle container
20
21
  if (stats.isDirectory) {
@@ -31,7 +32,8 @@ export async function handleGet(request, reply) {
31
32
  isContainer: true,
32
33
  etag: indexStats?.etag || stats.etag,
33
34
  contentType: 'text/html',
34
- origin
35
+ origin,
36
+ resourceUrl
35
37
  });
36
38
 
37
39
  Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
@@ -40,14 +42,14 @@ export async function handleGet(request, reply) {
40
42
 
41
43
  // No index.html, return JSON-LD container listing
42
44
  const entries = await storage.listContainer(urlPath);
43
- const baseUrl = `${request.protocol}://${request.hostname}${urlPath}`;
44
- const jsonLd = generateContainerJsonLd(baseUrl, entries || []);
45
+ const jsonLd = generateContainerJsonLd(resourceUrl, entries || []);
45
46
 
46
47
  const headers = getAllHeaders({
47
48
  isContainer: true,
48
49
  etag: stats.etag,
49
50
  contentType: 'application/ld+json',
50
- origin
51
+ origin,
52
+ resourceUrl
51
53
  });
52
54
 
53
55
  Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
@@ -65,7 +67,8 @@ export async function handleGet(request, reply) {
65
67
  isContainer: false,
66
68
  etag: stats.etag,
67
69
  contentType,
68
- origin
70
+ origin,
71
+ resourceUrl
69
72
  });
70
73
 
71
74
  Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
@@ -84,13 +87,15 @@ export async function handleHead(request, reply) {
84
87
  }
85
88
 
86
89
  const origin = request.headers.origin;
90
+ const resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
87
91
  const contentType = stats.isDirectory ? 'application/ld+json' : getContentType(urlPath);
88
92
 
89
93
  const headers = getAllHeaders({
90
94
  isContainer: stats.isDirectory,
91
95
  etag: stats.etag,
92
96
  contentType,
93
- origin
97
+ origin,
98
+ resourceUrl
94
99
  });
95
100
 
96
101
  if (!stats.isDirectory) {
@@ -135,8 +140,9 @@ export async function handlePut(request, reply) {
135
140
  }
136
141
 
137
142
  const origin = request.headers.origin;
138
- const headers = getAllHeaders({ isContainer: false, origin });
139
- headers['Location'] = `${request.protocol}://${request.hostname}${urlPath}`;
143
+ const resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
144
+ const headers = getAllHeaders({ isContainer: false, origin, resourceUrl });
145
+ headers['Location'] = resourceUrl;
140
146
 
141
147
  Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
142
148
  return reply.code(existed ? 204 : 201).send();
@@ -159,7 +165,8 @@ export async function handleDelete(request, reply) {
159
165
  }
160
166
 
161
167
  const origin = request.headers.origin;
162
- const headers = getAllHeaders({ isContainer: false, origin });
168
+ const resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
169
+ const headers = getAllHeaders({ isContainer: false, origin, resourceUrl });
163
170
  Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
164
171
 
165
172
  return reply.code(204).send();
@@ -173,9 +180,11 @@ export async function handleOptions(request, reply) {
173
180
  const stats = await storage.stat(urlPath);
174
181
 
175
182
  const origin = request.headers.origin;
183
+ const resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
176
184
  const headers = getAllHeaders({
177
185
  isContainer: stats?.isDirectory || isContainer(urlPath),
178
- origin
186
+ origin,
187
+ resourceUrl
179
188
  });
180
189
 
181
190
  Object.entries(headers).forEach(([k, v]) => reply.header(k, v));