javascript-solid-server 0.0.2 → 0.0.5
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/.claude/settings.local.json +5 -1
- package/package.json +2 -2
- package/src/auth/middleware.js +97 -0
- package/src/auth/token.js +112 -0
- package/src/handlers/container.js +71 -7
- package/src/handlers/resource.js +39 -9
- package/src/ldp/headers.js +31 -6
- package/src/server.js +23 -1
- package/src/wac/checker.js +257 -0
- package/src/wac/parser.js +284 -0
- package/src/webid/profile.js +161 -0
- package/test/auth.test.js +175 -0
- package/test/helpers.js +158 -0
- package/test/ldp.test.js +363 -0
- package/test/pod.test.js +119 -0
- package/test/wac.test.js +189 -0
- package/test/webid.test.js +152 -0
- package/benchmark-report-2025-03-31T14-25-24.234Z.json +0 -44
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "javascript-solid-server",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.5",
|
|
4
4
|
"description": "A minimal, fast Solid server",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"scripts": {
|
|
8
8
|
"start": "node src/index.js",
|
|
9
9
|
"dev": "node --watch src/index.js",
|
|
10
|
-
"test": "node --test"
|
|
10
|
+
"test": "node --test --test-concurrency=1"
|
|
11
11
|
},
|
|
12
12
|
"dependencies": {
|
|
13
13
|
"fastify": "^4.25.2",
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authorization middleware
|
|
3
|
+
* Combines authentication (token verification) with WAC checking
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { getWebIdFromRequest } from './token.js';
|
|
7
|
+
import { checkAccess, getRequiredMode } from '../wac/checker.js';
|
|
8
|
+
import * as storage from '../storage/filesystem.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Check if request is authorized
|
|
12
|
+
* @param {object} request - Fastify request
|
|
13
|
+
* @param {object} reply - Fastify reply
|
|
14
|
+
* @returns {Promise<{authorized: boolean, webId: string|null, wacAllow: string}>}
|
|
15
|
+
*/
|
|
16
|
+
export async function authorize(request, reply) {
|
|
17
|
+
const urlPath = request.url.split('?')[0];
|
|
18
|
+
const method = request.method;
|
|
19
|
+
|
|
20
|
+
// Skip auth for .acl files (they need special handling)
|
|
21
|
+
// and for OPTIONS (CORS preflight)
|
|
22
|
+
if (urlPath.endsWith('.acl') || method === 'OPTIONS') {
|
|
23
|
+
return { authorized: true, webId: null, wacAllow: 'user="read write append control", public="read write append"' };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Get WebID from token (null if not authenticated)
|
|
27
|
+
const webId = getWebIdFromRequest(request);
|
|
28
|
+
|
|
29
|
+
// Get resource info
|
|
30
|
+
const stats = await storage.stat(urlPath);
|
|
31
|
+
const resourceExists = stats !== null;
|
|
32
|
+
const isContainer = stats?.isDirectory || urlPath.endsWith('/');
|
|
33
|
+
|
|
34
|
+
// Build resource URL
|
|
35
|
+
const resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
|
|
36
|
+
|
|
37
|
+
// Get required access mode for this method
|
|
38
|
+
const requiredMode = getRequiredMode(method);
|
|
39
|
+
|
|
40
|
+
// For write operations on non-existent resources, check parent container
|
|
41
|
+
let checkPath = urlPath;
|
|
42
|
+
let checkUrl = resourceUrl;
|
|
43
|
+
let checkIsContainer = isContainer;
|
|
44
|
+
|
|
45
|
+
if (!resourceExists && (method === 'PUT' || method === 'POST' || method === 'PATCH')) {
|
|
46
|
+
// Check write permission on parent container
|
|
47
|
+
const parentPath = getParentPath(urlPath);
|
|
48
|
+
checkPath = parentPath;
|
|
49
|
+
checkUrl = `${request.protocol}://${request.hostname}${parentPath}`;
|
|
50
|
+
checkIsContainer = true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Check WAC permissions
|
|
54
|
+
const { allowed, wacAllow } = await checkAccess({
|
|
55
|
+
resourceUrl: checkUrl,
|
|
56
|
+
resourcePath: checkPath,
|
|
57
|
+
isContainer: checkIsContainer,
|
|
58
|
+
agentWebId: webId,
|
|
59
|
+
requiredMode
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
return { authorized: allowed, webId, wacAllow };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Get parent container path
|
|
67
|
+
*/
|
|
68
|
+
function getParentPath(path) {
|
|
69
|
+
const normalized = path.endsWith('/') ? path.slice(0, -1) : path;
|
|
70
|
+
const lastSlash = normalized.lastIndexOf('/');
|
|
71
|
+
if (lastSlash <= 0) return '/';
|
|
72
|
+
return normalized.substring(0, lastSlash + 1);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Handle unauthorized request
|
|
77
|
+
* @param {object} reply - Fastify reply
|
|
78
|
+
* @param {boolean} isAuthenticated - Whether user is authenticated
|
|
79
|
+
* @param {string} wacAllow - WAC-Allow header value
|
|
80
|
+
*/
|
|
81
|
+
export function handleUnauthorized(reply, isAuthenticated, wacAllow) {
|
|
82
|
+
reply.header('WAC-Allow', wacAllow);
|
|
83
|
+
|
|
84
|
+
if (!isAuthenticated) {
|
|
85
|
+
// Not authenticated - return 401
|
|
86
|
+
return reply.code(401).send({
|
|
87
|
+
error: 'Unauthorized',
|
|
88
|
+
message: 'Authentication required'
|
|
89
|
+
});
|
|
90
|
+
} else {
|
|
91
|
+
// Authenticated but not authorized - return 403
|
|
92
|
+
return reply.code(403).send({
|
|
93
|
+
error: 'Forbidden',
|
|
94
|
+
message: 'Access denied'
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simple token-based authentication
|
|
3
|
+
*
|
|
4
|
+
* For now, we use a simple JWT-like approach:
|
|
5
|
+
* - Token format: base64(JSON({webId, iat, exp}))
|
|
6
|
+
* - In production, this would be replaced with proper Solid-OIDC DPoP tokens
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import crypto from 'crypto';
|
|
10
|
+
|
|
11
|
+
// Secret for signing tokens (in production, use env var)
|
|
12
|
+
const SECRET = process.env.TOKEN_SECRET || 'dev-secret-change-in-production';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Create a simple token for a WebID
|
|
16
|
+
* @param {string} webId - The WebID to create token for
|
|
17
|
+
* @param {number} expiresIn - Expiration time in seconds (default 1 hour)
|
|
18
|
+
* @returns {string} Token string
|
|
19
|
+
*/
|
|
20
|
+
export function createToken(webId, expiresIn = 3600) {
|
|
21
|
+
const payload = {
|
|
22
|
+
webId,
|
|
23
|
+
iat: Math.floor(Date.now() / 1000),
|
|
24
|
+
exp: Math.floor(Date.now() / 1000) + expiresIn
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const data = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
|
28
|
+
const signature = crypto
|
|
29
|
+
.createHmac('sha256', SECRET)
|
|
30
|
+
.update(data)
|
|
31
|
+
.digest('base64url');
|
|
32
|
+
|
|
33
|
+
return `${data}.${signature}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Verify and decode a token
|
|
38
|
+
* @param {string} token - The token to verify
|
|
39
|
+
* @returns {{webId: string, iat: number, exp: number} | null} Decoded payload or null
|
|
40
|
+
*/
|
|
41
|
+
export function verifyToken(token) {
|
|
42
|
+
if (!token || typeof token !== 'string') {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const parts = token.split('.');
|
|
47
|
+
if (parts.length !== 2) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const [data, signature] = parts;
|
|
52
|
+
|
|
53
|
+
// Verify signature
|
|
54
|
+
const expectedSig = crypto
|
|
55
|
+
.createHmac('sha256', SECRET)
|
|
56
|
+
.update(data)
|
|
57
|
+
.digest('base64url');
|
|
58
|
+
|
|
59
|
+
if (signature !== expectedSig) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Decode payload
|
|
64
|
+
try {
|
|
65
|
+
const payload = JSON.parse(Buffer.from(data, 'base64url').toString());
|
|
66
|
+
|
|
67
|
+
// Check expiration
|
|
68
|
+
if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return payload;
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Extract token from Authorization header
|
|
80
|
+
* @param {string} authHeader - Authorization header value
|
|
81
|
+
* @returns {string | null} Token or null
|
|
82
|
+
*/
|
|
83
|
+
export function extractToken(authHeader) {
|
|
84
|
+
if (!authHeader || typeof authHeader !== 'string') {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Support "Bearer <token>" format
|
|
89
|
+
if (authHeader.startsWith('Bearer ')) {
|
|
90
|
+
return authHeader.slice(7);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Also support raw token
|
|
94
|
+
return authHeader;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Extract WebID from request
|
|
99
|
+
* @param {object} request - Fastify request object
|
|
100
|
+
* @returns {string | null} WebID or null if not authenticated
|
|
101
|
+
*/
|
|
102
|
+
export function getWebIdFromRequest(request) {
|
|
103
|
+
const authHeader = request.headers.authorization;
|
|
104
|
+
const token = extractToken(authHeader);
|
|
105
|
+
|
|
106
|
+
if (!token) {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const payload = verifyToken(token);
|
|
111
|
+
return payload?.webId || null;
|
|
112
|
+
}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import * as storage from '../storage/filesystem.js';
|
|
2
2
|
import { getAllHeaders } from '../ldp/headers.js';
|
|
3
3
|
import { isContainer } from '../utils/url.js';
|
|
4
|
+
import { generateProfile, generatePreferences, generateTypeIndex, serialize } from '../webid/profile.js';
|
|
5
|
+
import { generateOwnerAcl, generatePrivateAcl, generateInboxAcl, serializeAcl } from '../wac/parser.js';
|
|
6
|
+
import { createToken } from '../auth/token.js';
|
|
4
7
|
|
|
5
8
|
/**
|
|
6
9
|
* Handle POST request to container (create new resource)
|
|
@@ -69,6 +72,16 @@ export async function handlePost(request, reply) {
|
|
|
69
72
|
/**
|
|
70
73
|
* Create a pod (container) for a user
|
|
71
74
|
* POST /.pods with { "name": "alice" }
|
|
75
|
+
*
|
|
76
|
+
* Creates the following structure:
|
|
77
|
+
* /{name}/
|
|
78
|
+
* /{name}/profile/card - WebID profile
|
|
79
|
+
* /{name}/inbox/ - Notifications
|
|
80
|
+
* /{name}/public/ - Public files
|
|
81
|
+
* /{name}/private/ - Private files
|
|
82
|
+
* /{name}/settings/prefs - Preferences
|
|
83
|
+
* /{name}/settings/publicTypeIndex
|
|
84
|
+
* /{name}/settings/privateTypeIndex
|
|
72
85
|
*/
|
|
73
86
|
export async function handleCreatePod(request, reply) {
|
|
74
87
|
const { name } = request.body || {};
|
|
@@ -89,22 +102,73 @@ export async function handleCreatePod(request, reply) {
|
|
|
89
102
|
return reply.code(409).send({ error: 'Pod already exists' });
|
|
90
103
|
}
|
|
91
104
|
|
|
92
|
-
//
|
|
93
|
-
|
|
94
|
-
|
|
105
|
+
// Build URIs
|
|
106
|
+
// WebID is at pod root: /alice/#me
|
|
107
|
+
const baseUri = `${request.protocol}://${request.hostname}`;
|
|
108
|
+
const podUri = `${baseUri}${podPath}`;
|
|
109
|
+
const webId = `${podUri}#me`;
|
|
110
|
+
const issuer = baseUri;
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
// Create pod directory structure
|
|
114
|
+
await storage.createContainer(podPath);
|
|
115
|
+
await storage.createContainer(`${podPath}inbox/`);
|
|
116
|
+
await storage.createContainer(`${podPath}public/`);
|
|
117
|
+
await storage.createContainer(`${podPath}private/`);
|
|
118
|
+
await storage.createContainer(`${podPath}settings/`);
|
|
119
|
+
|
|
120
|
+
// Generate and write WebID profile as index.html at pod root
|
|
121
|
+
const profileHtml = generateProfile({ webId, name, podUri, issuer });
|
|
122
|
+
await storage.write(`${podPath}index.html`, profileHtml);
|
|
123
|
+
|
|
124
|
+
// Generate and write preferences
|
|
125
|
+
const prefs = generatePreferences({ webId, podUri });
|
|
126
|
+
await storage.write(`${podPath}settings/prefs`, serialize(prefs));
|
|
127
|
+
|
|
128
|
+
// Generate and write type indexes
|
|
129
|
+
const publicTypeIndex = generateTypeIndex(`${podUri}settings/publicTypeIndex`);
|
|
130
|
+
await storage.write(`${podPath}settings/publicTypeIndex`, serialize(publicTypeIndex));
|
|
131
|
+
|
|
132
|
+
const privateTypeIndex = generateTypeIndex(`${podUri}settings/privateTypeIndex`);
|
|
133
|
+
await storage.write(`${podPath}settings/privateTypeIndex`, serialize(privateTypeIndex));
|
|
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
|
+
|
|
152
|
+
} catch (err) {
|
|
153
|
+
console.error('Pod creation error:', err);
|
|
154
|
+
// Cleanup on failure
|
|
155
|
+
await storage.remove(podPath);
|
|
95
156
|
return reply.code(500).send({ error: 'Failed to create pod' });
|
|
96
157
|
}
|
|
97
158
|
|
|
98
|
-
const location = `${request.protocol}://${request.hostname}${podPath}`;
|
|
99
159
|
const origin = request.headers.origin;
|
|
100
|
-
|
|
101
160
|
const headers = getAllHeaders({ isContainer: true, origin });
|
|
102
|
-
headers['Location'] =
|
|
161
|
+
headers['Location'] = podUri;
|
|
103
162
|
|
|
104
163
|
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
105
164
|
|
|
165
|
+
// Generate token for the pod owner
|
|
166
|
+
const token = createToken(webId);
|
|
167
|
+
|
|
106
168
|
return reply.code(201).send({
|
|
107
169
|
name,
|
|
108
|
-
|
|
170
|
+
webId,
|
|
171
|
+
podUri,
|
|
172
|
+
token
|
|
109
173
|
});
|
|
110
174
|
}
|
package/src/handlers/resource.js
CHANGED
|
@@ -15,18 +15,41 @@ 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) {
|
|
22
|
+
// Check for index.html (serves as both profile and container representation)
|
|
23
|
+
const indexPath = urlPath.endsWith('/') ? `${urlPath}index.html` : `${urlPath}/index.html`;
|
|
24
|
+
const indexExists = await storage.exists(indexPath);
|
|
25
|
+
|
|
26
|
+
if (indexExists) {
|
|
27
|
+
// Serve index.html (contains JSON-LD structured data)
|
|
28
|
+
const content = await storage.read(indexPath);
|
|
29
|
+
const indexStats = await storage.stat(indexPath);
|
|
30
|
+
|
|
31
|
+
const headers = getAllHeaders({
|
|
32
|
+
isContainer: true,
|
|
33
|
+
etag: indexStats?.etag || stats.etag,
|
|
34
|
+
contentType: 'text/html',
|
|
35
|
+
origin,
|
|
36
|
+
resourceUrl
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
40
|
+
return reply.send(content);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// No index.html, return JSON-LD container listing
|
|
21
44
|
const entries = await storage.listContainer(urlPath);
|
|
22
|
-
const
|
|
23
|
-
const jsonLd = generateContainerJsonLd(baseUrl, entries || []);
|
|
45
|
+
const jsonLd = generateContainerJsonLd(resourceUrl, entries || []);
|
|
24
46
|
|
|
25
47
|
const headers = getAllHeaders({
|
|
26
48
|
isContainer: true,
|
|
27
49
|
etag: stats.etag,
|
|
28
50
|
contentType: 'application/ld+json',
|
|
29
|
-
origin
|
|
51
|
+
origin,
|
|
52
|
+
resourceUrl
|
|
30
53
|
});
|
|
31
54
|
|
|
32
55
|
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
@@ -44,7 +67,8 @@ export async function handleGet(request, reply) {
|
|
|
44
67
|
isContainer: false,
|
|
45
68
|
etag: stats.etag,
|
|
46
69
|
contentType,
|
|
47
|
-
origin
|
|
70
|
+
origin,
|
|
71
|
+
resourceUrl
|
|
48
72
|
});
|
|
49
73
|
|
|
50
74
|
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
@@ -63,13 +87,15 @@ export async function handleHead(request, reply) {
|
|
|
63
87
|
}
|
|
64
88
|
|
|
65
89
|
const origin = request.headers.origin;
|
|
90
|
+
const resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
|
|
66
91
|
const contentType = stats.isDirectory ? 'application/ld+json' : getContentType(urlPath);
|
|
67
92
|
|
|
68
93
|
const headers = getAllHeaders({
|
|
69
94
|
isContainer: stats.isDirectory,
|
|
70
95
|
etag: stats.etag,
|
|
71
96
|
contentType,
|
|
72
|
-
origin
|
|
97
|
+
origin,
|
|
98
|
+
resourceUrl
|
|
73
99
|
});
|
|
74
100
|
|
|
75
101
|
if (!stats.isDirectory) {
|
|
@@ -114,8 +140,9 @@ export async function handlePut(request, reply) {
|
|
|
114
140
|
}
|
|
115
141
|
|
|
116
142
|
const origin = request.headers.origin;
|
|
117
|
-
const
|
|
118
|
-
headers
|
|
143
|
+
const resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
|
|
144
|
+
const headers = getAllHeaders({ isContainer: false, origin, resourceUrl });
|
|
145
|
+
headers['Location'] = resourceUrl;
|
|
119
146
|
|
|
120
147
|
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
121
148
|
return reply.code(existed ? 204 : 201).send();
|
|
@@ -138,7 +165,8 @@ export async function handleDelete(request, reply) {
|
|
|
138
165
|
}
|
|
139
166
|
|
|
140
167
|
const origin = request.headers.origin;
|
|
141
|
-
const
|
|
168
|
+
const resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
|
|
169
|
+
const headers = getAllHeaders({ isContainer: false, origin, resourceUrl });
|
|
142
170
|
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
143
171
|
|
|
144
172
|
return reply.code(204).send();
|
|
@@ -152,9 +180,11 @@ export async function handleOptions(request, reply) {
|
|
|
152
180
|
const stats = await storage.stat(urlPath);
|
|
153
181
|
|
|
154
182
|
const origin = request.headers.origin;
|
|
183
|
+
const resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
|
|
155
184
|
const headers = getAllHeaders({
|
|
156
185
|
isContainer: stats?.isDirectory || isContainer(urlPath),
|
|
157
|
-
origin
|
|
186
|
+
origin,
|
|
187
|
+
resourceUrl
|
|
158
188
|
});
|
|
159
189
|
|
|
160
190
|
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
package/src/ldp/headers.js
CHANGED
|
@@ -7,9 +7,10 @@ const LDP = 'http://www.w3.org/ns/ldp#';
|
|
|
7
7
|
/**
|
|
8
8
|
* Get Link headers for a resource
|
|
9
9
|
* @param {boolean} isContainer
|
|
10
|
+
* @param {string} aclUrl - URL to the ACL resource
|
|
10
11
|
* @returns {string}
|
|
11
12
|
*/
|
|
12
|
-
export function getLinkHeader(isContainer) {
|
|
13
|
+
export function getLinkHeader(isContainer, aclUrl = null) {
|
|
13
14
|
const links = [`<${LDP}Resource>; rel="type"`];
|
|
14
15
|
|
|
15
16
|
if (isContainer) {
|
|
@@ -17,18 +18,42 @@ export function getLinkHeader(isContainer) {
|
|
|
17
18
|
links.push(`<${LDP}BasicContainer>; rel="type"`);
|
|
18
19
|
}
|
|
19
20
|
|
|
21
|
+
// Add acl link for auxiliary resource discovery
|
|
22
|
+
if (aclUrl) {
|
|
23
|
+
links.push(`<${aclUrl}>; rel="acl"`);
|
|
24
|
+
}
|
|
25
|
+
|
|
20
26
|
return links.join(', ');
|
|
21
27
|
}
|
|
22
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Get the ACL URL for a resource
|
|
31
|
+
* @param {string} resourceUrl - Full URL of the resource
|
|
32
|
+
* @param {boolean} isContainer - Whether the resource is a container
|
|
33
|
+
* @returns {string} ACL URL
|
|
34
|
+
*/
|
|
35
|
+
export function getAclUrl(resourceUrl, isContainer) {
|
|
36
|
+
if (isContainer) {
|
|
37
|
+
// Container ACL: /path/.acl
|
|
38
|
+
const base = resourceUrl.endsWith('/') ? resourceUrl : resourceUrl + '/';
|
|
39
|
+
return base + '.acl';
|
|
40
|
+
}
|
|
41
|
+
// Resource ACL: /path/file.acl
|
|
42
|
+
return resourceUrl + '.acl';
|
|
43
|
+
}
|
|
44
|
+
|
|
23
45
|
/**
|
|
24
46
|
* Get standard LDP response headers
|
|
25
47
|
* @param {object} options
|
|
26
48
|
* @returns {object}
|
|
27
49
|
*/
|
|
28
|
-
export function getResponseHeaders({ isContainer = false, etag = null, contentType = null }) {
|
|
50
|
+
export function getResponseHeaders({ isContainer = false, etag = null, contentType = null, resourceUrl = null, wacAllow = null }) {
|
|
51
|
+
// Calculate ACL URL if resource URL provided
|
|
52
|
+
const aclUrl = resourceUrl ? getAclUrl(resourceUrl, isContainer) : null;
|
|
53
|
+
|
|
29
54
|
const headers = {
|
|
30
|
-
'Link': getLinkHeader(isContainer),
|
|
31
|
-
'WAC-Allow': 'user="read write append control", public="read write append"',
|
|
55
|
+
'Link': getLinkHeader(isContainer, aclUrl),
|
|
56
|
+
'WAC-Allow': wacAllow || 'user="read write append control", public="read write append"',
|
|
32
57
|
'Accept-Patch': 'application/sparql-update',
|
|
33
58
|
'Allow': 'GET, HEAD, PUT, DELETE, OPTIONS' + (isContainer ? ', POST' : ''),
|
|
34
59
|
'Vary': 'Accept, Authorization, Origin'
|
|
@@ -70,9 +95,9 @@ export function getCorsHeaders(origin) {
|
|
|
70
95
|
* @param {object} options
|
|
71
96
|
* @returns {object}
|
|
72
97
|
*/
|
|
73
|
-
export function getAllHeaders({ isContainer = false, etag = null, contentType = null, origin = null }) {
|
|
98
|
+
export function getAllHeaders({ isContainer = false, etag = null, contentType = null, origin = null, resourceUrl = null, wacAllow = null }) {
|
|
74
99
|
return {
|
|
75
|
-
...getResponseHeaders({ isContainer, etag, contentType }),
|
|
100
|
+
...getResponseHeaders({ isContainer, etag, contentType, resourceUrl, wacAllow }),
|
|
76
101
|
...getCorsHeaders(origin)
|
|
77
102
|
};
|
|
78
103
|
}
|
package/src/server.js
CHANGED
|
@@ -2,6 +2,7 @@ import Fastify from 'fastify';
|
|
|
2
2
|
import { handleGet, handleHead, handlePut, handleDelete, handleOptions } from './handlers/resource.js';
|
|
3
3
|
import { handlePost, handleCreatePod } from './handlers/container.js';
|
|
4
4
|
import { getCorsHeaders } from './ldp/headers.js';
|
|
5
|
+
import { authorize, handleUnauthorized } from './auth/middleware.js';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Create and configure Fastify server
|
|
@@ -25,13 +26,34 @@ export function createServer(options = {}) {
|
|
|
25
26
|
const corsHeaders = getCorsHeaders(request.headers.origin);
|
|
26
27
|
Object.entries(corsHeaders).forEach(([k, v]) => reply.header(k, v));
|
|
27
28
|
|
|
28
|
-
// Handle preflight
|
|
29
|
+
// Handle preflight OPTIONS
|
|
29
30
|
if (request.method === 'OPTIONS') {
|
|
31
|
+
// Add Allow header for LDP compliance
|
|
32
|
+
reply.header('Allow', 'GET, HEAD, POST, PUT, DELETE, PATCH, OPTIONS');
|
|
30
33
|
reply.code(204).send();
|
|
31
34
|
return reply;
|
|
32
35
|
}
|
|
33
36
|
});
|
|
34
37
|
|
|
38
|
+
// Authorization hook - check WAC permissions
|
|
39
|
+
// Skip for pod creation endpoint (needs special handling)
|
|
40
|
+
fastify.addHook('preHandler', async (request, reply) => {
|
|
41
|
+
// Skip auth for pod creation and OPTIONS
|
|
42
|
+
if (request.url === '/.pods' || request.method === 'OPTIONS') {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const { authorized, webId, wacAllow } = await authorize(request, reply);
|
|
47
|
+
|
|
48
|
+
// Store webId and wacAllow on request for handlers to use
|
|
49
|
+
request.webId = webId;
|
|
50
|
+
request.wacAllow = wacAllow;
|
|
51
|
+
|
|
52
|
+
if (!authorized) {
|
|
53
|
+
return handleUnauthorized(reply, webId !== null, wacAllow);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
35
57
|
// Pod creation endpoint
|
|
36
58
|
fastify.post('/.pods', handleCreatePod);
|
|
37
59
|
|