javascript-solid-server 0.0.35 → 0.0.37
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 +20 -1
- package/AGENTS.md +152 -0
- package/bin/jss.js +4 -0
- package/docs/design/nostr-relay-integration.md +353 -0
- package/docs/git-support.md +283 -0
- package/package.json +1 -1
- package/src/handlers/git.js +207 -0
- package/src/handlers/resource.js +75 -23
- package/src/idp/credentials.js +3 -2
- package/src/idp/index.js +1 -1
- package/src/idp/keys.js +57 -8
- package/src/idp/provider.js +100 -2
- package/src/rdf/turtle.js +4 -2
- package/src/server.js +53 -1
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
# Adding Git Support to a Solid Server
|
|
2
|
+
|
|
3
|
+
This guide explains how to add Git HTTP backend support to a Solid server, enabling `git clone` and `git push` operations on pod containers.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
The Git HTTP protocol allows clients to clone and push to repositories over HTTP. This is implemented using Git's built-in `git http-backend` CGI program - the same one used by Apache and Nginx.
|
|
8
|
+
|
|
9
|
+
### How It Works
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
┌─────────────┐ HTTP ┌──────────────┐ CGI ┌─────────────────┐
|
|
13
|
+
│ Git Client │ ─────────────▶│ Solid Server │ ────────────▶│ git http-backend│
|
|
14
|
+
│ │◀───────────── │ │◀──────────── │ │
|
|
15
|
+
└─────────────┘ └──────────────┘ └─────────────────┘
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
**Clone flow:**
|
|
19
|
+
1. `GET /repo/info/refs?service=git-upload-pack` - Discovery
|
|
20
|
+
2. `POST /repo/git-upload-pack` - Fetch objects
|
|
21
|
+
|
|
22
|
+
**Push flow:**
|
|
23
|
+
1. `GET /repo/info/refs?service=git-receive-pack` - Discovery
|
|
24
|
+
2. `POST /repo/git-receive-pack` - Send objects
|
|
25
|
+
|
|
26
|
+
## Implementation
|
|
27
|
+
|
|
28
|
+
### 1. Detect Git Requests
|
|
29
|
+
|
|
30
|
+
Git protocol requests are identified by URL patterns:
|
|
31
|
+
|
|
32
|
+
```javascript
|
|
33
|
+
function isGitRequest(urlPath) {
|
|
34
|
+
return urlPath.includes('/info/refs') ||
|
|
35
|
+
urlPath.includes('/git-upload-pack') ||
|
|
36
|
+
urlPath.includes('/git-receive-pack');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isGitWriteOperation(urlPath) {
|
|
40
|
+
return urlPath.includes('/git-receive-pack');
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### 2. Security: Block Direct .git Access
|
|
45
|
+
|
|
46
|
+
**Important:** Git protocol requests should be allowed, but direct file access to `.git/` contents must be blocked:
|
|
47
|
+
|
|
48
|
+
```javascript
|
|
49
|
+
// BLOCK: Direct access to .git contents (security risk)
|
|
50
|
+
GET /.git/config → 403 Forbidden
|
|
51
|
+
GET /.git/objects/abc123 → 403 Forbidden
|
|
52
|
+
|
|
53
|
+
// ALLOW: Git protocol (handled by git http-backend)
|
|
54
|
+
GET /repo/info/refs?service=git-upload-pack → 200 OK
|
|
55
|
+
POST /repo/git-upload-pack → 200 OK
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### 3. Authorization with WAC
|
|
59
|
+
|
|
60
|
+
Check permissions before allowing git operations:
|
|
61
|
+
|
|
62
|
+
```javascript
|
|
63
|
+
// Clone/fetch requires Read access
|
|
64
|
+
// Push requires Write access
|
|
65
|
+
|
|
66
|
+
const needsWrite = isGitWriteOperation(request.url);
|
|
67
|
+
const requiredMode = needsWrite ? 'write' : 'read';
|
|
68
|
+
|
|
69
|
+
const { allowed } = await checkAccess({
|
|
70
|
+
resourceUrl,
|
|
71
|
+
resourcePath,
|
|
72
|
+
agentWebId: request.webId,
|
|
73
|
+
requiredMode
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
if (!allowed) {
|
|
77
|
+
return reply.code(needsWrite ? 403 : 401).send({
|
|
78
|
+
error: needsWrite ? 'Write access required' : 'Read access required'
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### 4. Git HTTP Backend Handler
|
|
84
|
+
|
|
85
|
+
The core handler spawns `git http-backend` with CGI environment variables:
|
|
86
|
+
|
|
87
|
+
```javascript
|
|
88
|
+
import { spawn } from 'child_process';
|
|
89
|
+
|
|
90
|
+
async function handleGit(request, reply) {
|
|
91
|
+
const urlPath = decodeURIComponent(request.url.split('?')[0]);
|
|
92
|
+
const queryString = request.url.split('?')[1] || '';
|
|
93
|
+
|
|
94
|
+
// Build CGI environment
|
|
95
|
+
const env = {
|
|
96
|
+
...process.env,
|
|
97
|
+
GIT_PROJECT_ROOT: dataRoot, // Where repos are stored
|
|
98
|
+
GIT_HTTP_EXPORT_ALL: '', // Allow read access
|
|
99
|
+
GIT_HTTP_RECEIVE_PACK: 'true', // Enable push
|
|
100
|
+
PATH_INFO: urlPath,
|
|
101
|
+
REQUEST_METHOD: request.method,
|
|
102
|
+
CONTENT_TYPE: request.headers['content-type'] || '',
|
|
103
|
+
QUERY_STRING: queryString,
|
|
104
|
+
CONTENT_LENGTH: request.headers['content-length'] || '0',
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// For non-bare repos, set GIT_DIR to .git subdirectory
|
|
108
|
+
if (isRegularRepo) {
|
|
109
|
+
env.GIT_DIR = path.join(repoPath, '.git');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Spawn git http-backend
|
|
113
|
+
const child = spawn('git', ['http-backend'], { env });
|
|
114
|
+
|
|
115
|
+
// Send request body (for POST requests)
|
|
116
|
+
if (request.body && request.body.length > 0) {
|
|
117
|
+
child.stdin.write(request.body);
|
|
118
|
+
}
|
|
119
|
+
child.stdin.end();
|
|
120
|
+
|
|
121
|
+
// Parse CGI response and send to client
|
|
122
|
+
// ... (see full implementation below)
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### 5. CGI Response Parsing
|
|
127
|
+
|
|
128
|
+
Git http-backend outputs CGI format (headers + body). Parse and forward:
|
|
129
|
+
|
|
130
|
+
```javascript
|
|
131
|
+
let buffer = Buffer.alloc(0);
|
|
132
|
+
let headersSent = false;
|
|
133
|
+
|
|
134
|
+
child.stdout.on('data', (data) => {
|
|
135
|
+
buffer = Buffer.concat([buffer, data]);
|
|
136
|
+
|
|
137
|
+
if (!headersSent) {
|
|
138
|
+
// Find header/body separator (try both \r\n\r\n and \n\n)
|
|
139
|
+
let headerEnd = buffer.indexOf('\r\n\r\n');
|
|
140
|
+
let sep = '\r\n';
|
|
141
|
+
let sepLen = 4;
|
|
142
|
+
|
|
143
|
+
if (headerEnd === -1) {
|
|
144
|
+
headerEnd = buffer.indexOf('\n\n');
|
|
145
|
+
sep = '\n';
|
|
146
|
+
sepLen = 2;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (headerEnd !== -1) {
|
|
150
|
+
const headerSection = buffer.subarray(0, headerEnd).toString();
|
|
151
|
+
const bodySection = buffer.subarray(headerEnd + sepLen);
|
|
152
|
+
|
|
153
|
+
// Parse CGI headers
|
|
154
|
+
for (const line of headerSection.split(sep)) {
|
|
155
|
+
const colonIdx = line.indexOf(':');
|
|
156
|
+
if (colonIdx > 0) {
|
|
157
|
+
const key = line.substring(0, colonIdx).trim();
|
|
158
|
+
const value = line.substring(colonIdx + 1).trim();
|
|
159
|
+
|
|
160
|
+
if (key.toLowerCase() === 'status') {
|
|
161
|
+
statusCode = parseInt(value.split(' ')[0], 10);
|
|
162
|
+
} else {
|
|
163
|
+
reply.raw.setHeader(key, value);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
reply.raw.writeHead(statusCode);
|
|
169
|
+
reply.raw.write(bodySection);
|
|
170
|
+
headersSent = true;
|
|
171
|
+
}
|
|
172
|
+
} else {
|
|
173
|
+
reply.raw.write(buffer);
|
|
174
|
+
}
|
|
175
|
+
buffer = Buffer.alloc(0);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
child.stdout.on('end', () => {
|
|
179
|
+
reply.raw.end();
|
|
180
|
+
});
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
## Repository Setup
|
|
184
|
+
|
|
185
|
+
### Regular Repository (with working directory)
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
cd /path/to/pod/myrepo
|
|
189
|
+
git init
|
|
190
|
+
echo "# My Project" > README.md
|
|
191
|
+
git add .
|
|
192
|
+
git commit -m "Initial commit"
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
### Bare Repository (server-only, more efficient)
|
|
196
|
+
|
|
197
|
+
```bash
|
|
198
|
+
cd /path/to/pod
|
|
199
|
+
git init --bare myrepo.git
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
### ACL for Public Clone
|
|
203
|
+
|
|
204
|
+
Create `/path/to/pod/myrepo/.acl`:
|
|
205
|
+
|
|
206
|
+
```turtle
|
|
207
|
+
@prefix acl: <http://www.w3.org/ns/auth/acl#>.
|
|
208
|
+
@prefix foaf: <http://xmlns.com/foaf/0.1/>.
|
|
209
|
+
|
|
210
|
+
<#public>
|
|
211
|
+
a acl:Authorization;
|
|
212
|
+
acl:agentClass foaf:Agent;
|
|
213
|
+
acl:accessTo <./>;
|
|
214
|
+
acl:default <./>;
|
|
215
|
+
acl:mode acl:Read.
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
### ACL for Authenticated Push
|
|
219
|
+
|
|
220
|
+
```turtle
|
|
221
|
+
@prefix acl: <http://www.w3.org/ns/auth/acl#>.
|
|
222
|
+
@prefix foaf: <http://xmlns.com/foaf/0.1/>.
|
|
223
|
+
|
|
224
|
+
<#owner>
|
|
225
|
+
a acl:Authorization;
|
|
226
|
+
acl:agent <https://alice.example.com/#me>;
|
|
227
|
+
acl:accessTo <./>;
|
|
228
|
+
acl:default <./>;
|
|
229
|
+
acl:mode acl:Read, acl:Write, acl:Control.
|
|
230
|
+
|
|
231
|
+
<#public>
|
|
232
|
+
a acl:Authorization;
|
|
233
|
+
acl:agentClass foaf:Agent;
|
|
234
|
+
acl:accessTo <./>;
|
|
235
|
+
acl:default <./>;
|
|
236
|
+
acl:mode acl:Read.
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
## Usage
|
|
240
|
+
|
|
241
|
+
### Server
|
|
242
|
+
|
|
243
|
+
```bash
|
|
244
|
+
# Start server with git support enabled
|
|
245
|
+
jss start --git
|
|
246
|
+
|
|
247
|
+
# Or via environment variable
|
|
248
|
+
JSS_GIT=true jss start
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
### Client
|
|
252
|
+
|
|
253
|
+
```bash
|
|
254
|
+
# Clone
|
|
255
|
+
git clone http://localhost:3000/myrepo
|
|
256
|
+
|
|
257
|
+
# Clone with authentication (if required)
|
|
258
|
+
git clone http://localhost:3000/myrepo
|
|
259
|
+
# Git will prompt for credentials
|
|
260
|
+
|
|
261
|
+
# Push (requires write access)
|
|
262
|
+
cd myrepo
|
|
263
|
+
echo "New content" >> README.md
|
|
264
|
+
git add .
|
|
265
|
+
git commit -m "Update readme"
|
|
266
|
+
git push
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
## Complete Handler Code
|
|
270
|
+
|
|
271
|
+
See `src/handlers/git.js` in the JSS repository for the full implementation.
|
|
272
|
+
|
|
273
|
+
## References
|
|
274
|
+
|
|
275
|
+
- [Git HTTP Protocol](https://git-scm.com/book/en/v2/Git-on-the-Server-Smart-HTTP)
|
|
276
|
+
- [git-http-backend documentation](https://git-scm.com/docs/git-http-backend)
|
|
277
|
+
- [CGI Specification](https://www.rfc-editor.org/rfc/rfc3875)
|
|
278
|
+
- [Web Access Control (WAC)](https://solidproject.org/TR/wac)
|
|
279
|
+
|
|
280
|
+
## Prior Art
|
|
281
|
+
|
|
282
|
+
- [nosdav/server](https://github.com/nosdav/server) - Git support implementation
|
|
283
|
+
- [QuitStore](https://github.com/AKSW/QuitStore) - Git + RDF versioning
|
package/package.json
CHANGED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import { existsSync, statSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Check if a URL path is a Git protocol request
|
|
7
|
+
* @param {string} urlPath - The URL path
|
|
8
|
+
* @returns {boolean}
|
|
9
|
+
*/
|
|
10
|
+
export function isGitRequest(urlPath) {
|
|
11
|
+
return urlPath.includes('/info/refs') ||
|
|
12
|
+
urlPath.includes('/git-upload-pack') ||
|
|
13
|
+
urlPath.includes('/git-receive-pack');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Determine if this is a write operation (push)
|
|
18
|
+
* @param {string} urlPath - The URL path
|
|
19
|
+
* @returns {boolean}
|
|
20
|
+
*/
|
|
21
|
+
export function isGitWriteOperation(urlPath) {
|
|
22
|
+
return urlPath.includes('/git-receive-pack');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Extract the repository path from the URL
|
|
27
|
+
* @param {string} urlPath - The URL path
|
|
28
|
+
* @returns {string|null} The repository relative path or null
|
|
29
|
+
*/
|
|
30
|
+
function extractRepoPath(urlPath) {
|
|
31
|
+
// Remove git service suffixes to get the repo path
|
|
32
|
+
const cleanPath = urlPath
|
|
33
|
+
.replace(/\/info\/refs.*$/, '')
|
|
34
|
+
.replace(/\/git-upload-pack$/, '')
|
|
35
|
+
.replace(/\/git-receive-pack$/, '');
|
|
36
|
+
|
|
37
|
+
// Remove leading slash
|
|
38
|
+
return cleanPath.replace(/^\//, '') || null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Find the git directory for a path
|
|
43
|
+
* @param {string} repoPath - Absolute path to check
|
|
44
|
+
* @returns {{gitDir: string, isRegular: boolean}|null}
|
|
45
|
+
*/
|
|
46
|
+
function findGitDir(repoPath) {
|
|
47
|
+
if (!existsSync(repoPath) || !statSync(repoPath).isDirectory()) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Check for regular repo with .git subdirectory
|
|
52
|
+
const dotGitPath = join(repoPath, '.git');
|
|
53
|
+
if (existsSync(dotGitPath) && statSync(dotGitPath).isDirectory()) {
|
|
54
|
+
return { gitDir: dotGitPath, isRegular: true };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Check for bare repository
|
|
58
|
+
const objectsPath = join(repoPath, 'objects');
|
|
59
|
+
const refsPath = join(repoPath, 'refs');
|
|
60
|
+
if (existsSync(objectsPath) && existsSync(refsPath)) {
|
|
61
|
+
return { gitDir: repoPath, isRegular: false };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Handle Git HTTP requests using git http-backend
|
|
69
|
+
* @param {FastifyRequest} request
|
|
70
|
+
* @param {FastifyReply} reply
|
|
71
|
+
*/
|
|
72
|
+
export async function handleGit(request, reply) {
|
|
73
|
+
const urlPath = decodeURIComponent(request.url.split('?')[0]);
|
|
74
|
+
const queryString = request.url.split('?')[1] || '';
|
|
75
|
+
|
|
76
|
+
// Extract repository path
|
|
77
|
+
const repoRelative = extractRepoPath(urlPath);
|
|
78
|
+
if (!repoRelative) {
|
|
79
|
+
return reply.code(400).send({ error: 'Invalid git request' });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Handle subdomain mode
|
|
83
|
+
let dataRoot = process.env.DATA_ROOT || './data';
|
|
84
|
+
if (request.podName) {
|
|
85
|
+
dataRoot = join(dataRoot, request.podName);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const repoAbs = join(dataRoot, repoRelative);
|
|
89
|
+
|
|
90
|
+
// Find git directory
|
|
91
|
+
const gitInfo = findGitDir(repoAbs);
|
|
92
|
+
if (!gitInfo) {
|
|
93
|
+
return reply.code(404).send({ error: 'Not a git repository' });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Build CGI environment
|
|
97
|
+
const env = {
|
|
98
|
+
...process.env,
|
|
99
|
+
GIT_PROJECT_ROOT: dataRoot,
|
|
100
|
+
GIT_HTTP_EXPORT_ALL: '', // Allow read access
|
|
101
|
+
GIT_HTTP_RECEIVE_PACK: 'true', // Enable push
|
|
102
|
+
GIT_CONFIG_PARAMETERS: "'uploadpack.allowTipSHA1InWant=true'",
|
|
103
|
+
PATH_INFO: urlPath,
|
|
104
|
+
REQUEST_METHOD: request.method,
|
|
105
|
+
CONTENT_TYPE: request.headers['content-type'] || '',
|
|
106
|
+
QUERY_STRING: queryString,
|
|
107
|
+
REMOTE_USER: request.webId || '', // Pass authenticated user
|
|
108
|
+
CONTENT_LENGTH: request.headers['content-length'] || '0',
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// For regular repositories, set GIT_DIR
|
|
112
|
+
if (gitInfo.isRegular) {
|
|
113
|
+
env.GIT_DIR = gitInfo.gitDir;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Spawn git http-backend
|
|
117
|
+
return new Promise((resolve, reject) => {
|
|
118
|
+
const child = spawn('git', ['http-backend'], { env });
|
|
119
|
+
|
|
120
|
+
let buffer = Buffer.alloc(0);
|
|
121
|
+
let headersSent = false;
|
|
122
|
+
|
|
123
|
+
child.stdout.on('data', (data) => {
|
|
124
|
+
buffer = Buffer.concat([buffer, data]);
|
|
125
|
+
|
|
126
|
+
if (!headersSent) {
|
|
127
|
+
// Look for end of CGI headers (try both \r\n\r\n and \n\n)
|
|
128
|
+
let headerEnd = buffer.indexOf('\r\n\r\n');
|
|
129
|
+
let headerSep = '\r\n';
|
|
130
|
+
let headerEndLen = 4;
|
|
131
|
+
|
|
132
|
+
if (headerEnd === -1) {
|
|
133
|
+
headerEnd = buffer.indexOf('\n\n');
|
|
134
|
+
headerSep = '\n';
|
|
135
|
+
headerEndLen = 2;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (headerEnd !== -1) {
|
|
139
|
+
const headerSection = buffer.subarray(0, headerEnd).toString();
|
|
140
|
+
const bodySection = buffer.subarray(headerEnd + headerEndLen);
|
|
141
|
+
|
|
142
|
+
// Parse CGI headers and set on raw response
|
|
143
|
+
const lines = headerSection.split(headerSep);
|
|
144
|
+
let statusCode = 200;
|
|
145
|
+
|
|
146
|
+
for (const line of lines) {
|
|
147
|
+
const colonIndex = line.indexOf(':');
|
|
148
|
+
if (colonIndex > 0) {
|
|
149
|
+
const key = line.substring(0, colonIndex).trim();
|
|
150
|
+
const value = line.substring(colonIndex + 1).trim();
|
|
151
|
+
|
|
152
|
+
// Handle Status header specially
|
|
153
|
+
if (key.toLowerCase() === 'status') {
|
|
154
|
+
statusCode = parseInt(value.split(' ')[0], 10);
|
|
155
|
+
} else {
|
|
156
|
+
reply.raw.setHeader(key, value);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
reply.raw.writeHead(statusCode);
|
|
162
|
+
headersSent = true;
|
|
163
|
+
reply.raw.write(bodySection);
|
|
164
|
+
buffer = Buffer.alloc(0);
|
|
165
|
+
}
|
|
166
|
+
} else {
|
|
167
|
+
reply.raw.write(buffer);
|
|
168
|
+
buffer = Buffer.alloc(0);
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
child.stdout.on('end', () => {
|
|
173
|
+
reply.raw.end();
|
|
174
|
+
resolve();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// Send request body to git
|
|
178
|
+
// For POST requests, Fastify has already parsed the body into request.body
|
|
179
|
+
if (request.body && request.body.length > 0) {
|
|
180
|
+
child.stdin.write(request.body);
|
|
181
|
+
child.stdin.end();
|
|
182
|
+
} else {
|
|
183
|
+
// For GET requests or empty bodies, just close stdin
|
|
184
|
+
child.stdin.end();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Log errors
|
|
188
|
+
child.stderr.on('data', (data) => {
|
|
189
|
+
console.error('git http-backend stderr:', data.toString());
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
child.on('error', (err) => {
|
|
193
|
+
console.error('Failed to spawn git http-backend:', err);
|
|
194
|
+
if (!headersSent) {
|
|
195
|
+
reply.code(500).send({ error: 'Git backend error' });
|
|
196
|
+
}
|
|
197
|
+
resolve();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
child.on('close', (code) => {
|
|
201
|
+
if (code !== 0 && !headersSent) {
|
|
202
|
+
reply.code(500).send({ error: 'Git operation failed' });
|
|
203
|
+
}
|
|
204
|
+
resolve();
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
}
|
package/src/handlers/resource.js
CHANGED
|
@@ -75,36 +75,57 @@ export async function handleGet(request, reply) {
|
|
|
75
75
|
acceptHeader.includes('text/n3') ||
|
|
76
76
|
acceptHeader.includes('application/n-triples')
|
|
77
77
|
);
|
|
78
|
+
const wantsJsonLd = connegEnabled && (
|
|
79
|
+
acceptHeader.includes('application/ld+json') ||
|
|
80
|
+
acceptHeader.includes('application/json')
|
|
81
|
+
);
|
|
78
82
|
|
|
79
|
-
if (wantsTurtle) {
|
|
80
|
-
// Extract JSON-LD from HTML
|
|
83
|
+
if (wantsTurtle || wantsJsonLd) {
|
|
84
|
+
// Extract JSON-LD from HTML data island
|
|
81
85
|
try {
|
|
82
86
|
const htmlStr = content.toString();
|
|
83
|
-
const jsonLdMatch = htmlStr.match(/<script type="application\/ld\+json"
|
|
87
|
+
const jsonLdMatch = htmlStr.match(/<script type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/);
|
|
84
88
|
if (jsonLdMatch) {
|
|
85
89
|
const jsonLd = JSON.parse(jsonLdMatch[1]);
|
|
86
|
-
const { content: turtleContent } = await fromJsonLd(
|
|
87
|
-
jsonLd,
|
|
88
|
-
'text/turtle',
|
|
89
|
-
resourceUrl,
|
|
90
|
-
true
|
|
91
|
-
);
|
|
92
|
-
|
|
93
|
-
const headers = getAllHeaders({
|
|
94
|
-
isContainer: true,
|
|
95
|
-
etag: indexStats?.etag || stats.etag,
|
|
96
|
-
contentType: 'text/turtle',
|
|
97
|
-
origin,
|
|
98
|
-
resourceUrl,
|
|
99
|
-
connegEnabled
|
|
100
|
-
});
|
|
101
90
|
|
|
102
|
-
|
|
103
|
-
|
|
91
|
+
if (wantsTurtle) {
|
|
92
|
+
// Convert to Turtle
|
|
93
|
+
const { content: turtleContent } = await fromJsonLd(
|
|
94
|
+
jsonLd,
|
|
95
|
+
'text/turtle',
|
|
96
|
+
resourceUrl,
|
|
97
|
+
true
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
const headers = getAllHeaders({
|
|
101
|
+
isContainer: true,
|
|
102
|
+
etag: indexStats?.etag || stats.etag,
|
|
103
|
+
contentType: 'text/turtle',
|
|
104
|
+
origin,
|
|
105
|
+
resourceUrl,
|
|
106
|
+
connegEnabled
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
110
|
+
return reply.send(turtleContent);
|
|
111
|
+
} else {
|
|
112
|
+
// Return JSON-LD directly
|
|
113
|
+
const headers = getAllHeaders({
|
|
114
|
+
isContainer: true,
|
|
115
|
+
etag: indexStats?.etag || stats.etag,
|
|
116
|
+
contentType: 'application/ld+json',
|
|
117
|
+
origin,
|
|
118
|
+
resourceUrl,
|
|
119
|
+
connegEnabled
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
123
|
+
return reply.send(JSON.stringify(jsonLd, null, 2));
|
|
124
|
+
}
|
|
104
125
|
}
|
|
105
126
|
} catch (err) {
|
|
106
127
|
// Fall through to serve HTML if conversion fails
|
|
107
|
-
console.error('Failed to convert profile to
|
|
128
|
+
console.error('Failed to convert profile to RDF:', err.message);
|
|
108
129
|
}
|
|
109
130
|
}
|
|
110
131
|
|
|
@@ -329,14 +350,45 @@ export async function handleHead(request, reply) {
|
|
|
329
350
|
}
|
|
330
351
|
|
|
331
352
|
const origin = request.headers.origin;
|
|
332
|
-
const
|
|
353
|
+
const connegEnabled = request.connegEnabled || false;
|
|
354
|
+
let contentType;
|
|
355
|
+
|
|
356
|
+
if (stats.isDirectory) {
|
|
357
|
+
// For directories with index.html, determine content type based on Accept header
|
|
358
|
+
const indexPath = storagePath.endsWith('/') ? `${storagePath}index.html` : `${storagePath}/index.html`;
|
|
359
|
+
const indexExists = await storage.exists(indexPath);
|
|
360
|
+
|
|
361
|
+
if (indexExists && connegEnabled) {
|
|
362
|
+
const acceptHeader = request.headers.accept || '';
|
|
363
|
+
const wantsTurtle = acceptHeader.includes('text/turtle') ||
|
|
364
|
+
acceptHeader.includes('text/n3') ||
|
|
365
|
+
acceptHeader.includes('application/n-triples');
|
|
366
|
+
const wantsJsonLd = acceptHeader.includes('application/ld+json') ||
|
|
367
|
+
acceptHeader.includes('application/json');
|
|
368
|
+
|
|
369
|
+
if (wantsTurtle) {
|
|
370
|
+
contentType = 'text/turtle';
|
|
371
|
+
} else if (wantsJsonLd) {
|
|
372
|
+
contentType = 'application/ld+json';
|
|
373
|
+
} else {
|
|
374
|
+
contentType = 'text/html';
|
|
375
|
+
}
|
|
376
|
+
} else if (indexExists) {
|
|
377
|
+
contentType = 'text/html';
|
|
378
|
+
} else {
|
|
379
|
+
contentType = 'application/ld+json';
|
|
380
|
+
}
|
|
381
|
+
} else {
|
|
382
|
+
contentType = getContentType(storagePath);
|
|
383
|
+
}
|
|
333
384
|
|
|
334
385
|
const headers = getAllHeaders({
|
|
335
386
|
isContainer: stats.isDirectory,
|
|
336
387
|
etag: stats.etag,
|
|
337
388
|
contentType,
|
|
338
389
|
origin,
|
|
339
|
-
resourceUrl
|
|
390
|
+
resourceUrl,
|
|
391
|
+
connegEnabled
|
|
340
392
|
});
|
|
341
393
|
|
|
342
394
|
if (!stats.isDirectory) {
|
package/src/idp/credentials.js
CHANGED
|
@@ -106,7 +106,8 @@ export async function handleCredentials(request, reply, issuer) {
|
|
|
106
106
|
// Always generate a proper JWT - CTH requires JWT format
|
|
107
107
|
const jwks = await getJwks();
|
|
108
108
|
const signingKey = jwks.keys[0];
|
|
109
|
-
const
|
|
109
|
+
const signingAlg = signingKey.alg || 'ES256'; // Use key's algorithm
|
|
110
|
+
const privateKey = await jose.importJWK(signingKey, signingAlg);
|
|
110
111
|
|
|
111
112
|
const now = Math.floor(Date.now() / 1000);
|
|
112
113
|
const tokenPayload = {
|
|
@@ -131,7 +132,7 @@ export async function handleCredentials(request, reply, issuer) {
|
|
|
131
132
|
}
|
|
132
133
|
|
|
133
134
|
const accessToken = await new jose.SignJWT(tokenPayload)
|
|
134
|
-
.setProtectedHeader({ alg:
|
|
135
|
+
.setProtectedHeader({ alg: signingAlg, kid: signingKey.kid })
|
|
135
136
|
.sign(privateKey);
|
|
136
137
|
|
|
137
138
|
// Response
|
package/src/idp/index.js
CHANGED
|
@@ -179,7 +179,7 @@ export async function idpPlugin(fastify, options) {
|
|
|
179
179
|
response_modes_supported: ['query', 'fragment', 'form_post'],
|
|
180
180
|
grant_types_supported: ['authorization_code', 'refresh_token', 'client_credentials'],
|
|
181
181
|
subject_types_supported: ['public'],
|
|
182
|
-
id_token_signing_alg_values_supported: ['ES256'],
|
|
182
|
+
id_token_signing_alg_values_supported: ['RS256', 'ES256'],
|
|
183
183
|
token_endpoint_auth_methods_supported: ['none', 'client_secret_basic', 'client_secret_post'],
|
|
184
184
|
claims_supported: ['sub', 'webid', 'name', 'email', 'email_verified'],
|
|
185
185
|
code_challenge_methods_supported: ['S256'],
|