javascript-solid-server 0.0.95 → 0.0.97
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/README.md +107 -1
- package/package.json +1 -1
- package/src/ap/index.js +40 -1
- package/src/ap/routes/mastodon.js +154 -0
- package/src/ap/routes/oauth.js +284 -0
- package/src/db/index.js +3 -0
- package/src/remotestorage.js +304 -0
- package/src/server.js +12 -2
package/README.md
CHANGED
|
@@ -18,6 +18,9 @@ A minimal, fast, JSON-LD native Solid server.
|
|
|
18
18
|
- **HTTP Range Requests** - Partial content delivery for large files and media streaming
|
|
19
19
|
- **Single-User Mode** - Simplified setup for personal pod servers
|
|
20
20
|
- **ActivityPub Federation** - Fediverse integration with WebFinger, inbox/outbox, HTTP signatures
|
|
21
|
+
- **Mastodon-compatible API** - Dynamic client registration, instance info, account verification
|
|
22
|
+
- **OAuth 2.0 Authorization** - Shared auth flow for Mastodon clients, remoteStorage apps, and third-party panes
|
|
23
|
+
- **remoteStorage Protocol** - [draft-dejong-remotestorage-22](https://remotestorage.io/spec/) file sync (requires `--activitypub` for WebFinger discovery + OAuth)
|
|
21
24
|
- **LDP CRUD Operations** - GET, PUT, POST, DELETE, HEAD
|
|
22
25
|
- **N3 Patch** - Solid's native patch format for RDF updates
|
|
23
26
|
- **SPARQL Update** - Standard SPARQL UPDATE protocol for PATCH
|
|
@@ -554,6 +557,106 @@ curl -H "Accept: application/activity+json" http://localhost:3000/profile/card
|
|
|
554
557
|
curl http://localhost:3000/.well-known/nodeinfo/2.1
|
|
555
558
|
```
|
|
556
559
|
|
|
560
|
+
## Mastodon-compatible API
|
|
561
|
+
|
|
562
|
+
JSS exposes Mastodon API endpoints so that Mastodon clients (Elk, Phanpy, Ice Cubes) can connect:
|
|
563
|
+
|
|
564
|
+
```bash
|
|
565
|
+
jss start --activitypub --idp
|
|
566
|
+
```
|
|
567
|
+
|
|
568
|
+
### Endpoints
|
|
569
|
+
|
|
570
|
+
| Endpoint | Description |
|
|
571
|
+
|----------|-------------|
|
|
572
|
+
| `POST /api/v1/apps` | Dynamic client registration |
|
|
573
|
+
| `GET /api/v1/accounts/verify_credentials` | Current user profile |
|
|
574
|
+
| `GET /api/v1/instance` | Instance metadata |
|
|
575
|
+
| `GET /oauth/authorize` | OAuth authorize page |
|
|
576
|
+
| `POST /oauth/authorize` | Process login |
|
|
577
|
+
| `POST /oauth/token` | Exchange code for Bearer token |
|
|
578
|
+
|
|
579
|
+
### OAuth 2.0 Flow
|
|
580
|
+
|
|
581
|
+
The OAuth layer is shared between Mastodon clients, remoteStorage apps, and third-party Solid panes:
|
|
582
|
+
|
|
583
|
+
1. Client registers via `POST /api/v1/apps` (gets `client_id` + `client_secret`)
|
|
584
|
+
2. Client redirects user to `GET /oauth/authorize?client_id=...&redirect_uri=...&response_type=code`
|
|
585
|
+
3. User logs in, JSS redirects back with `?code=...`
|
|
586
|
+
4. Client exchanges code for Bearer token via `POST /oauth/token`
|
|
587
|
+
5. Bearer token works with all JSS endpoints (Solid, ActivityPub, remoteStorage)
|
|
588
|
+
|
|
589
|
+
Supports out-of-band (OOB) redirect for CLI/desktop clients.
|
|
590
|
+
|
|
591
|
+
### Testing
|
|
592
|
+
|
|
593
|
+
```bash
|
|
594
|
+
# Register a client
|
|
595
|
+
curl -X POST http://localhost:3000/api/v1/apps \
|
|
596
|
+
-H "Content-Type: application/json" \
|
|
597
|
+
-d '{"client_name": "Test App", "redirect_uris": "urn:ietf:wg:oauth:2.0:oob"}'
|
|
598
|
+
|
|
599
|
+
# Check instance info
|
|
600
|
+
curl http://localhost:3000/api/v1/instance
|
|
601
|
+
```
|
|
602
|
+
|
|
603
|
+
## remoteStorage
|
|
604
|
+
|
|
605
|
+
JSS implements the [remoteStorage protocol](https://remotestorage.io/spec/draft-dejong-remotestorage-22). The storage routes are always available, but WebFinger discovery and OAuth require `--activitypub` (which provides the WebFinger and OAuth endpoints). Any remoteStorage-compatible app can store and sync data on your pod.
|
|
606
|
+
|
|
607
|
+
```bash
|
|
608
|
+
jss start --activitypub --idp
|
|
609
|
+
```
|
|
610
|
+
|
|
611
|
+
### Discovery
|
|
612
|
+
|
|
613
|
+
remoteStorage clients discover the storage endpoint via WebFinger:
|
|
614
|
+
|
|
615
|
+
```bash
|
|
616
|
+
curl "http://localhost:3000/.well-known/webfinger?resource=acct:me@localhost:3000"
|
|
617
|
+
```
|
|
618
|
+
|
|
619
|
+
The response includes a `remotestorage` link relation pointing to `/storage/me/`.
|
|
620
|
+
|
|
621
|
+
### Endpoints
|
|
622
|
+
|
|
623
|
+
| Method | Endpoint | Description |
|
|
624
|
+
|--------|----------|-------------|
|
|
625
|
+
| `GET` | `/storage/:user/*` | Read file or list folder (JSON-LD) |
|
|
626
|
+
| `HEAD` | `/storage/:user/*` | Get metadata (ETag, Content-Type, size) |
|
|
627
|
+
| `PUT` | `/storage/:user/*` | Write file (creates parent folders) |
|
|
628
|
+
| `DELETE` | `/storage/:user/*` | Delete file |
|
|
629
|
+
|
|
630
|
+
### How It Works
|
|
631
|
+
|
|
632
|
+
- **Auth**: Bearer token via OAuth 2.0 (same flow as Mastodon clients)
|
|
633
|
+
- **Public folder**: `/storage/me/public/*` is readable without auth
|
|
634
|
+
- **Conditional requests**: If-Match, If-None-Match (uses shared ETag utilities)
|
|
635
|
+
- **Dotfile protection**: `.acl`, `.meta`, and other dotfiles are blocked
|
|
636
|
+
- **Read-only mode**: Respects `--read-only` flag
|
|
637
|
+
- **Streaming**: Large files are streamed, not buffered
|
|
638
|
+
|
|
639
|
+
### Testing
|
|
640
|
+
|
|
641
|
+
```bash
|
|
642
|
+
# Write a file (needs Bearer token from OAuth flow)
|
|
643
|
+
curl -X PUT http://localhost:3000/storage/me/documents/hello.txt \
|
|
644
|
+
-H "Authorization: Bearer YOUR_TOKEN" \
|
|
645
|
+
-H "Content-Type: text/plain" \
|
|
646
|
+
-d "Hello, remoteStorage!"
|
|
647
|
+
|
|
648
|
+
# Read it back
|
|
649
|
+
curl -H "Authorization: Bearer YOUR_TOKEN" \
|
|
650
|
+
http://localhost:3000/storage/me/documents/hello.txt
|
|
651
|
+
|
|
652
|
+
# List a folder
|
|
653
|
+
curl -H "Authorization: Bearer YOUR_TOKEN" \
|
|
654
|
+
http://localhost:3000/storage/me/documents/
|
|
655
|
+
|
|
656
|
+
# Read from public folder (no auth needed)
|
|
657
|
+
curl http://localhost:3000/storage/me/public/readme.txt
|
|
658
|
+
```
|
|
659
|
+
|
|
557
660
|
### Linking Nostr to WebID (did:nostr)
|
|
558
661
|
|
|
559
662
|
Bridge your Nostr identity to a Solid WebID for seamless authentication:
|
|
@@ -1097,7 +1200,10 @@ src/
|
|
|
1097
1200
|
│ ├── actor.js # Actor JSON-LD
|
|
1098
1201
|
│ ├── inbox.js # Receive activities
|
|
1099
1202
|
│ ├── outbox.js # User's activities
|
|
1100
|
-
│
|
|
1203
|
+
│ ├── collections.js # Followers/following
|
|
1204
|
+
│ ├── mastodon.js # Mastodon API (apps, instance, verify_credentials)
|
|
1205
|
+
│ └── oauth.js # OAuth 2.0 authorize/token flow
|
|
1206
|
+
├── remotestorage.js # remoteStorage protocol (draft-dejong-remotestorage-22)
|
|
1101
1207
|
├── rdf/
|
|
1102
1208
|
│ ├── turtle.js # Turtle <-> JSON-LD
|
|
1103
1209
|
│ └── conneg.js # Content negotiation
|
package/package.json
CHANGED
package/src/ap/index.js
CHANGED
|
@@ -10,6 +10,8 @@ import { createInboxHandler } from './routes/inbox.js'
|
|
|
10
10
|
import { createOutboxHandler, createOutboxPostHandler } from './routes/outbox.js'
|
|
11
11
|
import { createCollectionsHandler } from './routes/collections.js'
|
|
12
12
|
import { createActorHandler } from './routes/actor.js'
|
|
13
|
+
import { createAppsHandler, createVerifyCredentialsHandler, createInstanceHandler } from './routes/mastodon.js'
|
|
14
|
+
import { createAuthorizeHandler, createAuthorizePostHandler, createTokenHandler } from './routes/oauth.js'
|
|
13
15
|
|
|
14
16
|
// Shared state for actor handler (accessed by server.js)
|
|
15
17
|
let sharedActorHandler = null
|
|
@@ -106,6 +108,17 @@ export async function activityPubPlugin(fastify, options = {}) {
|
|
|
106
108
|
{ profileUrl }
|
|
107
109
|
)
|
|
108
110
|
|
|
111
|
+
// Add remoteStorage link relation
|
|
112
|
+
response.links.push({
|
|
113
|
+
rel: 'http://tools.ietf.org/id/draft-dejong-remotestorage',
|
|
114
|
+
href: `${baseUrl}/storage/${config.username}/`,
|
|
115
|
+
properties: {
|
|
116
|
+
'http://remotestorage.io/spec/version': 'draft-dejong-remotestorage-22',
|
|
117
|
+
'http://tools.ietf.org/html/rfc6749#section-4.2': `${baseUrl}/oauth/authorize`,
|
|
118
|
+
'http://tools.ietf.org/html/rfc6750#section-2.3': 'Bearer'
|
|
119
|
+
}
|
|
120
|
+
})
|
|
121
|
+
|
|
109
122
|
return reply
|
|
110
123
|
.header('Content-Type', 'application/jrd+json')
|
|
111
124
|
.header('Access-Control-Allow-Origin', '*')
|
|
@@ -135,7 +148,7 @@ export async function activityPubPlugin(fastify, options = {}) {
|
|
|
135
148
|
version: '2.1',
|
|
136
149
|
software: {
|
|
137
150
|
name: 'jss',
|
|
138
|
-
version: '0.0.
|
|
151
|
+
version: '0.0.97',
|
|
139
152
|
repository: 'https://github.com/JavaScriptSolidServer/JavaScriptSolidServer'
|
|
140
153
|
},
|
|
141
154
|
protocols: ['activitypub', 'solid'],
|
|
@@ -173,6 +186,32 @@ export async function activityPubPlugin(fastify, options = {}) {
|
|
|
173
186
|
const collectionsHandler = createCollectionsHandler(config)
|
|
174
187
|
fastify.get('/profile/card/followers', (req, reply) => collectionsHandler(req, reply, 'followers'))
|
|
175
188
|
fastify.get('/profile/card/following', (req, reply) => collectionsHandler(req, reply, 'following'))
|
|
189
|
+
|
|
190
|
+
// Mastodon-compatible API endpoints
|
|
191
|
+
fastify.post('/api/v1/apps', createAppsHandler())
|
|
192
|
+
fastify.get('/api/v1/accounts/verify_credentials', createVerifyCredentialsHandler(config))
|
|
193
|
+
fastify.get('/api/v1/instance', createInstanceHandler(config))
|
|
194
|
+
|
|
195
|
+
// OAuth 2.0 authorize/token flow (Mastodon clients, remoteStorage, third-party panes)
|
|
196
|
+
fastify.get('/oauth/authorize', createAuthorizeHandler())
|
|
197
|
+
fastify.post('/oauth/authorize', {
|
|
198
|
+
config: {
|
|
199
|
+
rateLimit: {
|
|
200
|
+
max: 10,
|
|
201
|
+
timeWindow: '1 minute',
|
|
202
|
+
keyGenerator: (request) => request.ip
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}, createAuthorizePostHandler())
|
|
206
|
+
fastify.post('/oauth/token', {
|
|
207
|
+
config: {
|
|
208
|
+
rateLimit: {
|
|
209
|
+
max: 10,
|
|
210
|
+
timeWindow: '1 minute',
|
|
211
|
+
keyGenerator: (request) => request.ip
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}, createTokenHandler())
|
|
176
215
|
}
|
|
177
216
|
|
|
178
217
|
export default activityPubPlugin
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mastodon-compatible API endpoints
|
|
3
|
+
* Allows Mastodon clients (Elk, Phanpy, Ice Cubes) to connect to JSS
|
|
4
|
+
*
|
|
5
|
+
* Step 1: Dynamic client registration + account verification
|
|
6
|
+
* Refs: https://docs.joinmastodon.org/methods/apps/
|
|
7
|
+
* https://docs.joinmastodon.org/methods/accounts/#verify_credentials
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// In-memory client store (replace with persistent storage later)
|
|
11
|
+
const clients = new Map()
|
|
12
|
+
|
|
13
|
+
// Stable instance start time (used for created_at)
|
|
14
|
+
const startedAt = new Date().toISOString()
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Parse request body — handles both JSON and form-urlencoded
|
|
18
|
+
* (JSS uses raw buffer parser for all content types)
|
|
19
|
+
*/
|
|
20
|
+
function parseBody (request) {
|
|
21
|
+
if (request.body && typeof request.body === 'object' && !Buffer.isBuffer(request.body)) {
|
|
22
|
+
return request.body
|
|
23
|
+
}
|
|
24
|
+
const raw = Buffer.isBuffer(request.body) ? request.body.toString() : String(request.body || '')
|
|
25
|
+
const ct = request.headers['content-type'] || ''
|
|
26
|
+
if (ct.includes('application/json')) {
|
|
27
|
+
try { return JSON.parse(raw) } catch { return {} }
|
|
28
|
+
}
|
|
29
|
+
// Default: parse as form-urlencoded
|
|
30
|
+
return Object.fromEntries(new URLSearchParams(raw))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* POST /api/v1/apps — Dynamic client registration
|
|
35
|
+
* Mastodon clients call this to register before OAuth
|
|
36
|
+
*/
|
|
37
|
+
export function createAppsHandler () {
|
|
38
|
+
return async (request, reply) => {
|
|
39
|
+
const body = parseBody(request)
|
|
40
|
+
const { client_name, redirect_uris, scopes, website } = body
|
|
41
|
+
|
|
42
|
+
if (!client_name || !redirect_uris) {
|
|
43
|
+
return reply.code(422).send({ error: 'client_name and redirect_uris are required' })
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const clientId = crypto.randomUUID()
|
|
47
|
+
const clientSecret = crypto.randomUUID()
|
|
48
|
+
|
|
49
|
+
const client = {
|
|
50
|
+
id: clientId,
|
|
51
|
+
name: client_name,
|
|
52
|
+
redirect_uri: redirect_uris,
|
|
53
|
+
client_id: clientId,
|
|
54
|
+
client_secret: clientSecret,
|
|
55
|
+
scopes: scopes || 'read',
|
|
56
|
+
website: website || null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
clients.set(clientId, client)
|
|
60
|
+
|
|
61
|
+
return reply.send(client)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* GET /api/v1/accounts/verify_credentials — Who am I?
|
|
67
|
+
* Returns the authenticated user's profile as a Mastodon Account object
|
|
68
|
+
*/
|
|
69
|
+
export function createVerifyCredentialsHandler (config) {
|
|
70
|
+
return async (request, reply) => {
|
|
71
|
+
const protocol = request.headers['x-forwarded-proto'] || request.protocol
|
|
72
|
+
const host = request.headers['x-forwarded-host'] || request.hostname
|
|
73
|
+
const baseUrl = `${protocol}://${host}`
|
|
74
|
+
|
|
75
|
+
const account = {
|
|
76
|
+
id: '1',
|
|
77
|
+
username: config.username,
|
|
78
|
+
acct: config.username,
|
|
79
|
+
display_name: config.displayName,
|
|
80
|
+
note: config.summary ? `<p>${escapeHtml(config.summary)}</p>` : '',
|
|
81
|
+
url: `${baseUrl}/profile/card`,
|
|
82
|
+
uri: `${baseUrl}/profile/card#me`,
|
|
83
|
+
avatar: `${baseUrl}/profile/avatar.png`,
|
|
84
|
+
header: '',
|
|
85
|
+
locked: false,
|
|
86
|
+
bot: false,
|
|
87
|
+
created_at: startedAt,
|
|
88
|
+
followers_count: 0,
|
|
89
|
+
following_count: 0,
|
|
90
|
+
statuses_count: 0,
|
|
91
|
+
source: {
|
|
92
|
+
privacy: 'public',
|
|
93
|
+
sensitive: false,
|
|
94
|
+
language: 'en',
|
|
95
|
+
note: config.summary || ''
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return reply.send(account)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* GET /api/v1/instance — Instance information
|
|
105
|
+
* Required by most Mastodon clients before login
|
|
106
|
+
*/
|
|
107
|
+
export function createInstanceHandler (config) {
|
|
108
|
+
return async (request, reply) => {
|
|
109
|
+
const protocol = request.headers['x-forwarded-proto'] || request.protocol
|
|
110
|
+
const host = request.headers['x-forwarded-host'] || request.hostname
|
|
111
|
+
const wsProtocol = protocol === 'https' ? 'wss' : 'ws'
|
|
112
|
+
|
|
113
|
+
return reply.send({
|
|
114
|
+
uri: host,
|
|
115
|
+
title: config.displayName || 'JSS',
|
|
116
|
+
description: 'SAND Stack: Solid + ActivityPub + Nostr + DID',
|
|
117
|
+
short_description: 'Solid pod with Mastodon-compatible API',
|
|
118
|
+
version: '4.0.0 (compatible; JSS 0.0.97)',
|
|
119
|
+
urls: {
|
|
120
|
+
streaming_api: `${wsProtocol}://${host}`
|
|
121
|
+
},
|
|
122
|
+
stats: {
|
|
123
|
+
user_count: 1,
|
|
124
|
+
status_count: 0,
|
|
125
|
+
domain_count: 1
|
|
126
|
+
},
|
|
127
|
+
languages: ['en'],
|
|
128
|
+
registrations: false,
|
|
129
|
+
approval_required: false,
|
|
130
|
+
configuration: {
|
|
131
|
+
statuses: { max_characters: 5000 },
|
|
132
|
+
media_attachments: { supported_mime_types: [] }
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Look up a registered client
|
|
140
|
+
*/
|
|
141
|
+
export function getClient (clientId) {
|
|
142
|
+
return clients.get(clientId) || null
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function escapeHtml (str) {
|
|
146
|
+
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export default {
|
|
150
|
+
createAppsHandler,
|
|
151
|
+
createVerifyCredentialsHandler,
|
|
152
|
+
createInstanceHandler,
|
|
153
|
+
getClient
|
|
154
|
+
}
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth 2.0 authorize/token flow
|
|
3
|
+
* Shared infrastructure for Mastodon clients, remoteStorage apps, and third-party panes
|
|
4
|
+
*
|
|
5
|
+
* Refs: https://docs.joinmastodon.org/methods/oauth/
|
|
6
|
+
* https://datatracker.ietf.org/doc/html/rfc6749
|
|
7
|
+
*
|
|
8
|
+
* Related: #158, #159 (Mastodon API), #106 (remoteStorage), #160 (this)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import crypto from 'crypto'
|
|
12
|
+
import { getClient } from './mastodon.js'
|
|
13
|
+
import { authenticate } from '../../idp/accounts.js'
|
|
14
|
+
import { createToken } from '../../auth/token.js'
|
|
15
|
+
|
|
16
|
+
// Mastodon OOB redirect — display code instead of redirecting
|
|
17
|
+
const OOB_REDIRECT = 'urn:ietf:wg:oauth:2.0:oob'
|
|
18
|
+
|
|
19
|
+
// Auth codes: code → { clientId, redirectUri, webId, scope, expiresAt }
|
|
20
|
+
const authCodes = new Map()
|
|
21
|
+
|
|
22
|
+
// Clean up expired codes every 60s
|
|
23
|
+
setInterval(() => {
|
|
24
|
+
const now = Date.now()
|
|
25
|
+
for (const [code, data] of authCodes) {
|
|
26
|
+
if (data.expiresAt < now) authCodes.delete(code)
|
|
27
|
+
}
|
|
28
|
+
}, 60000).unref()
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Parse request body — handles JSON and form-urlencoded
|
|
32
|
+
*/
|
|
33
|
+
function parseBody (request) {
|
|
34
|
+
if (request.body && typeof request.body === 'object' && !Buffer.isBuffer(request.body)) {
|
|
35
|
+
return request.body
|
|
36
|
+
}
|
|
37
|
+
const raw = Buffer.isBuffer(request.body) ? request.body.toString() : String(request.body || '')
|
|
38
|
+
const ct = request.headers['content-type'] || ''
|
|
39
|
+
if (ct.includes('application/json')) {
|
|
40
|
+
try { return JSON.parse(raw) } catch { return {} }
|
|
41
|
+
}
|
|
42
|
+
return Object.fromEntries(new URLSearchParams(raw))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Validate client_id and redirect_uri against registered client
|
|
47
|
+
* Returns { client, error } — client is null if validation fails
|
|
48
|
+
*/
|
|
49
|
+
function validateClient (clientId, redirectUri) {
|
|
50
|
+
if (!clientId || !redirectUri) {
|
|
51
|
+
return { client: null, error: 'Missing client_id or redirect_uri' }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const client = getClient(clientId)
|
|
55
|
+
if (!client) {
|
|
56
|
+
return { client: null, error: 'Unknown client_id. Register via POST /api/v1/apps first.' }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Validate redirect_uri matches registered value (RFC 6749 §10.6)
|
|
60
|
+
if (redirectUri !== OOB_REDIRECT && redirectUri !== client.redirect_uri) {
|
|
61
|
+
return { client: null, error: 'redirect_uri does not match registered value' }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return { client, error: null }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* GET /oauth/authorize — Show login/consent page
|
|
69
|
+
*/
|
|
70
|
+
export function createAuthorizeHandler () {
|
|
71
|
+
return async (request, reply) => {
|
|
72
|
+
const { client_id, redirect_uri, response_type, scope, state } = request.query
|
|
73
|
+
|
|
74
|
+
if (response_type && response_type !== 'code') {
|
|
75
|
+
return reply.code(400).send({ error: 'unsupported_response_type', error_description: 'Only response_type=code is supported' })
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const { client, error } = validateClient(client_id, redirect_uri)
|
|
79
|
+
if (!client) {
|
|
80
|
+
return reply.code(400).send({ error: 'invalid_client', error_description: error })
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return reply.type('text/html').send(
|
|
84
|
+
loginPage({ clientId: client_id, redirectUri: redirect_uri, scope: scope || 'read', state, clientName: client.name })
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* POST /oauth/authorize — Process login form
|
|
91
|
+
*/
|
|
92
|
+
export function createAuthorizePostHandler () {
|
|
93
|
+
return async (request, reply) => {
|
|
94
|
+
const body = parseBody(request)
|
|
95
|
+
const { username, password, client_id, redirect_uri, scope, state } = body
|
|
96
|
+
|
|
97
|
+
// Validate client + redirect_uri (prevent open redirect via form tampering)
|
|
98
|
+
const { client, error: clientError } = validateClient(client_id, redirect_uri)
|
|
99
|
+
if (!client) {
|
|
100
|
+
return reply.code(400).send({ error: 'invalid_client', error_description: clientError })
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (!username || !password) {
|
|
104
|
+
return reply.type('text/html').send(
|
|
105
|
+
loginPage({ clientId: client_id, redirectUri: redirect_uri, scope, state, clientName: client.name, error: 'Username and password are required' })
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const account = await authenticate(username, password)
|
|
110
|
+
if (!account) {
|
|
111
|
+
return reply.type('text/html').send(
|
|
112
|
+
loginPage({ clientId: client_id, redirectUri: redirect_uri, scope, state, clientName: client.name, error: 'Invalid username or password' })
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Generate one-time auth code (10 min TTL)
|
|
117
|
+
const code = crypto.randomUUID()
|
|
118
|
+
authCodes.set(code, {
|
|
119
|
+
clientId: client_id,
|
|
120
|
+
redirectUri: redirect_uri,
|
|
121
|
+
webId: account.webId,
|
|
122
|
+
scope: scope || 'read',
|
|
123
|
+
expiresAt: Date.now() + 600_000
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
// Handle OOB redirect — display code to user instead of redirecting
|
|
127
|
+
if (redirect_uri === OOB_REDIRECT) {
|
|
128
|
+
return reply.type('text/html').send(oobPage(code))
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Redirect back to client with code + state (RFC 6749 §4.1.2)
|
|
132
|
+
const url = new URL(redirect_uri)
|
|
133
|
+
url.searchParams.set('code', code)
|
|
134
|
+
if (state) url.searchParams.set('state', state)
|
|
135
|
+
return reply.redirect(url.toString())
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* POST /oauth/token — Exchange auth code for Bearer token
|
|
141
|
+
*/
|
|
142
|
+
export function createTokenHandler () {
|
|
143
|
+
return async (request, reply) => {
|
|
144
|
+
const body = parseBody(request)
|
|
145
|
+
const { grant_type, code, client_id, client_secret, redirect_uri } = body
|
|
146
|
+
|
|
147
|
+
if (grant_type !== 'authorization_code') {
|
|
148
|
+
return reply.code(400).send({ error: 'unsupported_grant_type' })
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (!code) {
|
|
152
|
+
return reply.code(400).send({ error: 'invalid_request', error_description: 'Missing code' })
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Validate client credentials (RFC 6749 §2.3)
|
|
156
|
+
const client = getClient(client_id)
|
|
157
|
+
if (!client) {
|
|
158
|
+
return reply.code(401).send({ error: 'invalid_client', error_description: 'Unknown client_id' })
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
if (!crypto.timingSafeEqual(Buffer.from(client.client_secret), Buffer.from(client_secret || ''))) {
|
|
162
|
+
return reply.code(401).send({ error: 'invalid_client', error_description: 'Invalid client_secret' })
|
|
163
|
+
}
|
|
164
|
+
} catch {
|
|
165
|
+
return reply.code(401).send({ error: 'invalid_client', error_description: 'Invalid client_secret' })
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Look up auth code and consume immediately (RFC 6749 §10.5 — one-time use)
|
|
169
|
+
const authCode = authCodes.get(code)
|
|
170
|
+
authCodes.delete(code)
|
|
171
|
+
|
|
172
|
+
if (!authCode || authCode.expiresAt < Date.now()) {
|
|
173
|
+
return reply.code(400).send({ error: 'invalid_grant', error_description: 'Code expired or invalid' })
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (authCode.clientId !== client_id) {
|
|
177
|
+
return reply.code(400).send({ error: 'invalid_client' })
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (authCode.redirectUri !== redirect_uri) {
|
|
181
|
+
return reply.code(400).send({ error: 'invalid_grant', error_description: 'redirect_uri mismatch' })
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Generate Bearer token using existing token infrastructure
|
|
185
|
+
const accessToken = createToken(authCode.webId)
|
|
186
|
+
|
|
187
|
+
return reply.send({
|
|
188
|
+
access_token: accessToken,
|
|
189
|
+
token_type: 'Bearer',
|
|
190
|
+
scope: authCode.scope,
|
|
191
|
+
created_at: Math.floor(Date.now() / 1000)
|
|
192
|
+
})
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Minimal login page HTML
|
|
198
|
+
*/
|
|
199
|
+
function loginPage ({ clientId, redirectUri, scope, state, clientName, error }) {
|
|
200
|
+
const escapedError = error ? escapeHtml(error) : ''
|
|
201
|
+
const escapedName = escapeHtml(clientName || clientId || 'Unknown app')
|
|
202
|
+
|
|
203
|
+
return `<!DOCTYPE html>
|
|
204
|
+
<html lang="en">
|
|
205
|
+
<head>
|
|
206
|
+
<meta charset="utf-8">
|
|
207
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
208
|
+
<title>Authorize ${escapedName}</title>
|
|
209
|
+
<style>
|
|
210
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
211
|
+
body { font-family: -apple-system, BlinkMacSystemFont, system-ui, sans-serif; background: #f5f5f5; display: flex; justify-content: center; align-items: center; min-height: 100vh; }
|
|
212
|
+
.card { background: white; border-radius: 12px; padding: 2rem; max-width: 400px; width: 90%; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
|
|
213
|
+
h1 { font-size: 1.25rem; margin-bottom: 0.5rem; }
|
|
214
|
+
.subtitle { color: #666; margin-bottom: 1.5rem; font-size: 0.9rem; }
|
|
215
|
+
.scope { background: #f0f0f0; padding: 0.5rem 0.75rem; border-radius: 6px; margin-bottom: 1.5rem; font-size: 0.85rem; color: #444; }
|
|
216
|
+
label { display: block; font-size: 0.85rem; font-weight: 500; margin-bottom: 0.25rem; color: #333; }
|
|
217
|
+
input[type="text"], input[type="password"] { width: 100%; padding: 0.6rem; border: 1px solid #ddd; border-radius: 6px; font-size: 1rem; margin-bottom: 1rem; }
|
|
218
|
+
input:focus { outline: none; border-color: #4a9eff; box-shadow: 0 0 0 2px rgba(74,158,255,0.2); }
|
|
219
|
+
button { width: 100%; padding: 0.7rem; background: #4a9eff; color: white; border: none; border-radius: 6px; font-size: 1rem; font-weight: 500; cursor: pointer; }
|
|
220
|
+
button:hover { background: #3a8eef; }
|
|
221
|
+
.error { background: #fee; color: #c00; padding: 0.6rem; border-radius: 6px; margin-bottom: 1rem; font-size: 0.85rem; }
|
|
222
|
+
</style>
|
|
223
|
+
</head>
|
|
224
|
+
<body>
|
|
225
|
+
<div class="card">
|
|
226
|
+
<h1>Authorize</h1>
|
|
227
|
+
<p class="subtitle"><strong>${escapedName}</strong> wants access to your account</p>
|
|
228
|
+
<div class="scope">Scope: ${escapeHtml(scope || 'read')}</div>
|
|
229
|
+
${escapedError ? `<div class="error">${escapedError}</div>` : ''}
|
|
230
|
+
<form method="POST" action="/oauth/authorize">
|
|
231
|
+
<input type="hidden" name="client_id" value="${escapeHtml(clientId || '')}">
|
|
232
|
+
<input type="hidden" name="redirect_uri" value="${escapeHtml(redirectUri || '')}">
|
|
233
|
+
<input type="hidden" name="scope" value="${escapeHtml(scope || 'read')}">
|
|
234
|
+
${state ? `<input type="hidden" name="state" value="${escapeHtml(state)}">` : ''}
|
|
235
|
+
<label for="username">Username</label>
|
|
236
|
+
<input type="text" id="username" name="username" required autocomplete="username">
|
|
237
|
+
<label for="password">Password</label>
|
|
238
|
+
<input type="password" id="password" name="password" required autocomplete="current-password">
|
|
239
|
+
<button type="submit">Authorize</button>
|
|
240
|
+
</form>
|
|
241
|
+
</div>
|
|
242
|
+
</body>
|
|
243
|
+
</html>`
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* OOB (out-of-band) code display page
|
|
248
|
+
* Used when redirect_uri is urn:ietf:wg:oauth:2.0:oob
|
|
249
|
+
*/
|
|
250
|
+
function oobPage (code) {
|
|
251
|
+
return `<!DOCTYPE html>
|
|
252
|
+
<html lang="en">
|
|
253
|
+
<head>
|
|
254
|
+
<meta charset="utf-8">
|
|
255
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
256
|
+
<title>Authorization Code</title>
|
|
257
|
+
<style>
|
|
258
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
259
|
+
body { font-family: -apple-system, BlinkMacSystemFont, system-ui, sans-serif; background: #f5f5f5; display: flex; justify-content: center; align-items: center; min-height: 100vh; }
|
|
260
|
+
.card { background: white; border-radius: 12px; padding: 2rem; max-width: 400px; width: 90%; box-shadow: 0 2px 8px rgba(0,0,0,0.1); text-align: center; }
|
|
261
|
+
h1 { font-size: 1.25rem; margin-bottom: 1rem; }
|
|
262
|
+
.code { background: #f0f0f0; padding: 1rem; border-radius: 6px; font-family: monospace; font-size: 0.9rem; word-break: break-all; user-select: all; }
|
|
263
|
+
p { color: #666; margin-top: 1rem; font-size: 0.85rem; }
|
|
264
|
+
</style>
|
|
265
|
+
</head>
|
|
266
|
+
<body>
|
|
267
|
+
<div class="card">
|
|
268
|
+
<h1>Authorization Successful</h1>
|
|
269
|
+
<div class="code">${escapeHtml(code)}</div>
|
|
270
|
+
<p>Copy this code and paste it into your application.</p>
|
|
271
|
+
</div>
|
|
272
|
+
</body>
|
|
273
|
+
</html>`
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function escapeHtml (str) {
|
|
277
|
+
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export default {
|
|
281
|
+
createAuthorizeHandler,
|
|
282
|
+
createAuthorizePostHandler,
|
|
283
|
+
createTokenHandler
|
|
284
|
+
}
|
package/src/db/index.js
CHANGED
|
@@ -49,6 +49,9 @@ export async function dbPlugin(fastify, options) {
|
|
|
49
49
|
return reply.code(401).send({ error: 'Unauthorized', message: 'Authentication required' });
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
// Single-user mode: any authenticated user is the owner
|
|
53
|
+
if (options.singleUser) return;
|
|
54
|
+
|
|
52
55
|
// Ownership check: only pod owner can write to /db/{podName}/...
|
|
53
56
|
const urlPath = request.url.split('?')[0];
|
|
54
57
|
const relative = urlPath.replace(/^\/db\//, '');
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* remoteStorage plugin for JSS
|
|
3
|
+
* Implements draft-dejong-remotestorage protocol on top of existing storage
|
|
4
|
+
*
|
|
5
|
+
* No new dependencies — reuses filesystem storage, OAuth, and WebFinger.
|
|
6
|
+
* Always on — no flag needed.
|
|
7
|
+
*
|
|
8
|
+
* Ref: https://remotestorage.io/spec/draft-dejong-remotestorage-22
|
|
9
|
+
* Related: #106, #160 (OAuth), #159 (Mastodon API)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import * as storage from './storage/filesystem.js'
|
|
13
|
+
import { getContentType } from './utils/url.js'
|
|
14
|
+
import { getWebIdFromRequestAsync } from './auth/token.js'
|
|
15
|
+
import { checkIfMatch, checkIfNoneMatchForGet, checkIfNoneMatchForWrite } from './utils/conditional.js'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* remoteStorage Fastify plugin
|
|
19
|
+
* @param {FastifyInstance} fastify
|
|
20
|
+
* @param {object} options
|
|
21
|
+
* @param {string} options.username - Storage owner username
|
|
22
|
+
* @param {string} options.ownerWebId - WebID of the storage owner
|
|
23
|
+
*/
|
|
24
|
+
export async function remoteStoragePlugin (fastify, options = {}) {
|
|
25
|
+
const username = options.username || 'me'
|
|
26
|
+
const ownerWebId = options.ownerWebId || null
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Extract the storage path from the URL
|
|
30
|
+
* /storage/me/photos/vacation.jpg → /photos/vacation.jpg
|
|
31
|
+
*/
|
|
32
|
+
function getStoragePath (request) {
|
|
33
|
+
const wildcard = request.params['*'] || ''
|
|
34
|
+
return '/' + wildcard
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Check if the :user param matches the configured username
|
|
39
|
+
*/
|
|
40
|
+
function checkUsername (request, reply) {
|
|
41
|
+
if (request.params.user !== username) {
|
|
42
|
+
reply.code(404).send({ error: 'Unknown user' })
|
|
43
|
+
return false
|
|
44
|
+
}
|
|
45
|
+
return true
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Check if any path segment is a blocked dotfile
|
|
50
|
+
*/
|
|
51
|
+
function hasDotfile (storagePath) {
|
|
52
|
+
const segments = storagePath.split('/')
|
|
53
|
+
return segments.some(s => s.startsWith('.') && s.length > 1)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Check if request is authorized for the given method
|
|
58
|
+
* Public folder is readable without auth
|
|
59
|
+
*/
|
|
60
|
+
async function checkAuth (request, method) {
|
|
61
|
+
const storagePath = getStoragePath(request)
|
|
62
|
+
|
|
63
|
+
// Public folder: readable without auth
|
|
64
|
+
if (storagePath.startsWith('/public/') && (method === 'GET' || method === 'HEAD')) {
|
|
65
|
+
return { authorized: true, webId: null }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const { webId, error } = await getWebIdFromRequestAsync(request)
|
|
69
|
+
if (!webId) {
|
|
70
|
+
return { authorized: false, webId: null, error: error || 'Unauthorized', status: 401 }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// If ownerWebId is set, only the owner can access storage
|
|
74
|
+
if (ownerWebId && webId !== ownerWebId) {
|
|
75
|
+
return { authorized: false, webId, error: 'Forbidden', status: 403 }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return { authorized: true, webId }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// GET /storage/:user/* — read file or folder
|
|
82
|
+
fastify.get('/storage/:user/*', async (request, reply) => {
|
|
83
|
+
if (!checkUsername(request, reply)) return
|
|
84
|
+
|
|
85
|
+
const storagePath = getStoragePath(request)
|
|
86
|
+
|
|
87
|
+
// Block dotfile access
|
|
88
|
+
if (hasDotfile(storagePath)) {
|
|
89
|
+
return reply.code(404).send({ error: 'Not found' })
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const { authorized, error, status } = await checkAuth(request, 'GET')
|
|
93
|
+
if (!authorized) {
|
|
94
|
+
const code = status || 401
|
|
95
|
+
if (code === 401) reply.header('WWW-Authenticate', 'Bearer')
|
|
96
|
+
return reply.code(code).send({ error })
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const info = await storage.stat(storagePath)
|
|
100
|
+
if (!info) {
|
|
101
|
+
return reply.code(404).send({ error: 'Not found' })
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Conditional GET — use shared utility
|
|
105
|
+
const cond = checkIfNoneMatchForGet(request.headers['if-none-match'], info.etag)
|
|
106
|
+
if (!cond.ok) {
|
|
107
|
+
return reply.code(304).send()
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Directory listing
|
|
111
|
+
if (info.isDirectory) {
|
|
112
|
+
const entries = await storage.listContainer(storagePath)
|
|
113
|
+
if (!entries) {
|
|
114
|
+
return reply.code(404).send({ error: 'Not found' })
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const items = {}
|
|
118
|
+
for (const entry of entries) {
|
|
119
|
+
// Skip dotfiles (ACLs, metadata, etc.)
|
|
120
|
+
if (entry.name.startsWith('.')) continue
|
|
121
|
+
|
|
122
|
+
const childPath = storagePath.endsWith('/') ? storagePath + entry.name : storagePath + '/' + entry.name
|
|
123
|
+
const childStat = await storage.stat(entry.isDirectory ? childPath + '/' : childPath)
|
|
124
|
+
|
|
125
|
+
if (entry.isDirectory) {
|
|
126
|
+
items[entry.name + '/'] = {
|
|
127
|
+
ETag: childStat?.etag?.replace(/"/g, '') || ''
|
|
128
|
+
}
|
|
129
|
+
} else {
|
|
130
|
+
items[entry.name] = {
|
|
131
|
+
ETag: childStat?.etag?.replace(/"/g, '') || '',
|
|
132
|
+
'Content-Type': getContentType(entry.name),
|
|
133
|
+
'Content-Length': childStat?.size || 0
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return reply
|
|
139
|
+
.header('Content-Type', 'application/ld+json')
|
|
140
|
+
.header('ETag', info.etag)
|
|
141
|
+
.header('Cache-Control', 'no-cache')
|
|
142
|
+
.send({
|
|
143
|
+
'@context': 'http://remotestorage.io/spec/folder-description',
|
|
144
|
+
items
|
|
145
|
+
})
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// File — stream instead of buffering
|
|
149
|
+
const result = storage.createReadStream(storagePath)
|
|
150
|
+
if (!result) {
|
|
151
|
+
return reply.code(404).send({ error: 'Not found' })
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return reply
|
|
155
|
+
.header('Content-Type', getContentType(storagePath))
|
|
156
|
+
.header('Content-Length', info.size)
|
|
157
|
+
.header('ETag', info.etag)
|
|
158
|
+
.header('Cache-Control', 'no-cache')
|
|
159
|
+
.send(result.stream)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
// HEAD /storage/:user/* — metadata only
|
|
163
|
+
fastify.head('/storage/:user/*', async (request, reply) => {
|
|
164
|
+
if (!checkUsername(request, reply)) return
|
|
165
|
+
|
|
166
|
+
const storagePath = getStoragePath(request)
|
|
167
|
+
|
|
168
|
+
if (hasDotfile(storagePath)) {
|
|
169
|
+
return reply.code(404).send()
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const { authorized, error, status } = await checkAuth(request, 'HEAD')
|
|
173
|
+
if (!authorized) {
|
|
174
|
+
const code = status || 401
|
|
175
|
+
if (code === 401) reply.header('WWW-Authenticate', 'Bearer')
|
|
176
|
+
return reply.code(code).send()
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const info = await storage.stat(storagePath)
|
|
180
|
+
if (!info) {
|
|
181
|
+
return reply.code(404).send()
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Conditional HEAD
|
|
185
|
+
const cond = checkIfNoneMatchForGet(request.headers['if-none-match'], info.etag)
|
|
186
|
+
if (!cond.ok) {
|
|
187
|
+
return reply.code(304).send()
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
reply
|
|
191
|
+
.header('Content-Type', info.isDirectory ? 'application/ld+json' : getContentType(storagePath))
|
|
192
|
+
.header('ETag', info.etag)
|
|
193
|
+
.header('Cache-Control', 'no-cache')
|
|
194
|
+
|
|
195
|
+
if (!info.isDirectory) {
|
|
196
|
+
reply.header('Content-Length', info.size)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return reply.code(200).send()
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
// PUT /storage/:user/* — write file
|
|
203
|
+
fastify.put('/storage/:user/*', async (request, reply) => {
|
|
204
|
+
if (!checkUsername(request, reply)) return
|
|
205
|
+
|
|
206
|
+
// Respect readOnly mode
|
|
207
|
+
if (request.config?.readOnly) {
|
|
208
|
+
return reply.code(405).send({ error: 'Method Not Allowed', message: 'Server is in read-only mode' })
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const storagePath = getStoragePath(request)
|
|
212
|
+
|
|
213
|
+
if (hasDotfile(storagePath)) {
|
|
214
|
+
return reply.code(403).send({ error: 'Cannot write to dotfiles' })
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const { authorized, error, status } = await checkAuth(request, 'PUT')
|
|
218
|
+
if (!authorized) {
|
|
219
|
+
const code = status || 401
|
|
220
|
+
if (code === 401) reply.header('WWW-Authenticate', 'Bearer')
|
|
221
|
+
return reply.code(code).send({ error })
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Directories end with / — can't PUT to a directory
|
|
225
|
+
if (storagePath.endsWith('/')) {
|
|
226
|
+
return reply.code(400).send({ error: 'Cannot PUT to a folder path' })
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Conditional write — use shared utilities
|
|
230
|
+
const existing = await storage.stat(storagePath)
|
|
231
|
+
|
|
232
|
+
const ifMatchResult = checkIfMatch(request.headers['if-match'], existing?.etag || null)
|
|
233
|
+
if (!ifMatchResult.ok) {
|
|
234
|
+
return reply.code(ifMatchResult.status).send({ error: ifMatchResult.error })
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const ifNoneMatchResult = checkIfNoneMatchForWrite(request.headers['if-none-match'], existing?.etag || null)
|
|
238
|
+
if (!ifNoneMatchResult.ok) {
|
|
239
|
+
return reply.code(ifNoneMatchResult.status).send({ error: ifNoneMatchResult.error })
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const content = Buffer.isBuffer(request.body) ? request.body : Buffer.from(request.body || '')
|
|
243
|
+
const success = await storage.write(storagePath, content)
|
|
244
|
+
if (!success) {
|
|
245
|
+
return reply.code(500).send({ error: 'Write failed' })
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const newStat = await storage.stat(storagePath)
|
|
249
|
+
const statusCode = existing ? 200 : 201
|
|
250
|
+
|
|
251
|
+
return reply
|
|
252
|
+
.code(statusCode)
|
|
253
|
+
.header('ETag', newStat?.etag || '')
|
|
254
|
+
.send()
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
// DELETE /storage/:user/* — delete file
|
|
258
|
+
fastify.delete('/storage/:user/*', async (request, reply) => {
|
|
259
|
+
if (!checkUsername(request, reply)) return
|
|
260
|
+
|
|
261
|
+
// Respect readOnly mode
|
|
262
|
+
if (request.config?.readOnly) {
|
|
263
|
+
return reply.code(405).send({ error: 'Method Not Allowed', message: 'Server is in read-only mode' })
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const storagePath = getStoragePath(request)
|
|
267
|
+
|
|
268
|
+
if (hasDotfile(storagePath)) {
|
|
269
|
+
return reply.code(403).send({ error: 'Cannot delete dotfiles' })
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const { authorized, error, status } = await checkAuth(request, 'DELETE')
|
|
273
|
+
if (!authorized) {
|
|
274
|
+
const code = status || 401
|
|
275
|
+
if (code === 401) reply.header('WWW-Authenticate', 'Bearer')
|
|
276
|
+
return reply.code(code).send({ error })
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const existing = await storage.stat(storagePath)
|
|
280
|
+
if (!existing) {
|
|
281
|
+
return reply.code(404).send({ error: 'Not found' })
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Conditional delete — use shared utility
|
|
285
|
+
const ifMatchResult = checkIfMatch(request.headers['if-match'], existing.etag)
|
|
286
|
+
if (!ifMatchResult.ok) {
|
|
287
|
+
return reply.code(ifMatchResult.status).send({ error: ifMatchResult.error })
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const success = await storage.remove(storagePath)
|
|
291
|
+
if (!success) {
|
|
292
|
+
return reply.code(500).send({ error: 'Delete failed' })
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return reply
|
|
296
|
+
.code(200)
|
|
297
|
+
.header('ETag', existing.etag)
|
|
298
|
+
.send()
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
fastify.log.info(`remoteStorage enabled for user: ${username}`)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export default remoteStoragePlugin
|
package/src/server.js
CHANGED
|
@@ -15,6 +15,7 @@ import { isGitRequest, isGitWriteOperation, handleGit } from './handlers/git.js'
|
|
|
15
15
|
import { AccessMode } from './wac/parser.js';
|
|
16
16
|
import { registerNostrRelay } from './nostr/relay.js';
|
|
17
17
|
import { activityPubPlugin, getActorHandler } from './ap/index.js';
|
|
18
|
+
import { remoteStoragePlugin } from './remotestorage.js';
|
|
18
19
|
import { dbPlugin } from './db/index.js';
|
|
19
20
|
|
|
20
21
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
@@ -234,9 +235,15 @@ export function createServer(options = {}) {
|
|
|
234
235
|
});
|
|
235
236
|
}
|
|
236
237
|
|
|
238
|
+
// Register remoteStorage plugin (always on — no flag needed)
|
|
239
|
+
fastify.register(remoteStoragePlugin, {
|
|
240
|
+
username: singleUserName || 'me',
|
|
241
|
+
ownerWebId: null // single-user: any authenticated user can access
|
|
242
|
+
});
|
|
243
|
+
|
|
237
244
|
// Register MongoDB /db/ route if enabled
|
|
238
245
|
if (mongoEnabled) {
|
|
239
|
-
fastify.register(dbPlugin, { mongoUrl, mongoDatabase });
|
|
246
|
+
fastify.register(dbPlugin, { mongoUrl, mongoDatabase, singleUser });
|
|
240
247
|
}
|
|
241
248
|
|
|
242
249
|
// Register rate limiting plugin
|
|
@@ -353,7 +360,9 @@ export function createServer(options = {}) {
|
|
|
353
360
|
fastify.addHook('preHandler', async (request, reply) => {
|
|
354
361
|
// Skip auth for pod creation, OPTIONS, IdP routes, mashlib, solidos-ui, well-known, notifications, nostr, git, and AP
|
|
355
362
|
const mashlibPaths = ['/mashlib.min.js', '/mash.css', '/841.mashlib.min.js'];
|
|
356
|
-
const apPaths = ['/inbox', '/profile/card/inbox', '/profile/card/outbox', '/profile/card/followers', '/profile/card/following'
|
|
363
|
+
const apPaths = ['/inbox', '/profile/card/inbox', '/profile/card/outbox', '/profile/card/followers', '/profile/card/following',
|
|
364
|
+
'/api/v1/apps', '/api/v1/instance', '/api/v1/accounts/verify_credentials',
|
|
365
|
+
'/oauth/authorize', '/oauth/token'];
|
|
357
366
|
// Check if request wants ActivityPub content for profile
|
|
358
367
|
const accept = request.headers.accept || '';
|
|
359
368
|
const wantsAP = accept.includes('activity+json') || accept.includes('ld+json; profile="https://www.w3.org/ns/activitystreams"');
|
|
@@ -368,6 +377,7 @@ export function createServer(options = {}) {
|
|
|
368
377
|
(gitEnabled && isGitRequest(request.url)) ||
|
|
369
378
|
(activitypubEnabled && apPaths.some(p => request.url === p || request.url.startsWith(p + '?'))) ||
|
|
370
379
|
isProfileAP ||
|
|
380
|
+
request.url.startsWith('/storage/') ||
|
|
371
381
|
(mongoEnabled && (request.url === '/db' || request.url.startsWith('/db/'))) ||
|
|
372
382
|
mashlibPaths.some(p => request.url === p || request.url.startsWith(p + '.'))) {
|
|
373
383
|
return;
|