Skip to content

Commit 338f0ae

Browse files
Add remoteStorage protocol support
Implements draft-dejong-remotestorage-22 on top of existing storage infrastructure. No new dependencies — reuses filesystem storage, OAuth flow (JavaScriptSolidServer#161), and WebFinger. Endpoints: - GET /storage/:user/* — read file or folder (RS JSON-LD listing) - HEAD /storage/:user/* — metadata only - PUT /storage/:user/* — write file (with If-Match/If-None-Match) - DELETE /storage/:user/* — delete file (with If-Match) Features: - Public folder (/storage/:user/public/*) readable without auth - Conditional requests (ETags, If-Match, If-None-Match) - WebFinger discovery (RS link relation added to existing response) - Bearer token auth via existing OAuth flow - Always on — no flag needed Refs JavaScriptSolidServer#106
1 parent 9600b5c commit 338f0ae

3 files changed

Lines changed: 253 additions & 0 deletions

File tree

src/ap/index.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,17 @@ export async function activityPubPlugin(fastify, options = {}) {
108108
{ profileUrl }
109109
)
110110

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+
111122
return reply
112123
.header('Content-Type', 'application/jrd+json')
113124
.header('Access-Control-Allow-Origin', '*')

src/remotestorage.js

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
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+
16+
/**
17+
* remoteStorage Fastify plugin
18+
* @param {FastifyInstance} fastify
19+
* @param {object} options
20+
* @param {string} options.username - Storage owner username
21+
* @param {string} options.ownerWebId - WebID of the storage owner
22+
*/
23+
export async function remoteStoragePlugin (fastify, options = {}) {
24+
const username = options.username || 'me'
25+
const ownerWebId = options.ownerWebId || null
26+
27+
/**
28+
* Extract the storage path from the URL
29+
* /storage/me/photos/vacation.jpg → /photos/vacation.jpg
30+
*/
31+
function getStoragePath (request) {
32+
const wildcard = request.params['*'] || ''
33+
return '/' + wildcard
34+
}
35+
36+
/**
37+
* Check if request is authorized for the given method
38+
* Public folder is readable without auth
39+
*/
40+
async function checkAuth (request, method) {
41+
const storagePath = getStoragePath(request)
42+
43+
// Public folder: readable without auth
44+
if (storagePath.startsWith('/public/') && (method === 'GET' || method === 'HEAD')) {
45+
return { authorized: true, webId: null }
46+
}
47+
48+
const { webId, error } = await getWebIdFromRequestAsync(request)
49+
if (!webId) {
50+
return { authorized: false, webId: null, error: error || 'Unauthorized' }
51+
}
52+
53+
// If ownerWebId is set, only the owner can access storage
54+
if (ownerWebId && webId !== ownerWebId) {
55+
return { authorized: false, webId, error: 'Forbidden' }
56+
}
57+
58+
return { authorized: true, webId }
59+
}
60+
61+
// GET /storage/:user/* — read file or folder
62+
fastify.get('/storage/:user/*', async (request, reply) => {
63+
const storagePath = getStoragePath(request)
64+
65+
const { authorized, error } = await checkAuth(request, 'GET')
66+
if (!authorized) {
67+
return reply.code(401).header('WWW-Authenticate', 'Bearer').send({ error })
68+
}
69+
70+
const info = await storage.stat(storagePath)
71+
if (!info) {
72+
return reply.code(404).send({ error: 'Not found' })
73+
}
74+
75+
// Conditional GET
76+
const ifNoneMatch = request.headers['if-none-match']
77+
if (ifNoneMatch && ifNoneMatch === info.etag) {
78+
return reply.code(304).send()
79+
}
80+
81+
// Directory listing
82+
if (info.isDirectory) {
83+
const entries = await storage.listContainer(storagePath)
84+
if (!entries) {
85+
return reply.code(404).send({ error: 'Not found' })
86+
}
87+
88+
const items = {}
89+
for (const entry of entries) {
90+
// Skip hidden files (ACLs, metadata)
91+
if (entry.name.startsWith('.')) continue
92+
93+
const childPath = storagePath.endsWith('/') ? storagePath + entry.name : storagePath + '/' + entry.name
94+
const childStat = await storage.stat(entry.isDirectory ? childPath + '/' : childPath)
95+
96+
if (entry.isDirectory) {
97+
items[entry.name + '/'] = {
98+
ETag: childStat?.etag?.replace(/"/g, '') || ''
99+
}
100+
} else {
101+
items[entry.name] = {
102+
ETag: childStat?.etag?.replace(/"/g, '') || '',
103+
'Content-Type': getContentType(entry.name),
104+
'Content-Length': childStat?.size || 0
105+
}
106+
}
107+
}
108+
109+
return reply
110+
.header('Content-Type', 'application/ld+json')
111+
.header('ETag', info.etag)
112+
.header('Cache-Control', 'no-cache')
113+
.send({
114+
'@context': 'http://remotestorage.io/spec/folder-description',
115+
items
116+
})
117+
}
118+
119+
// File
120+
const content = await storage.read(storagePath)
121+
if (content === null) {
122+
return reply.code(404).send({ error: 'Not found' })
123+
}
124+
125+
return reply
126+
.header('Content-Type', getContentType(storagePath))
127+
.header('Content-Length', content.length)
128+
.header('ETag', info.etag)
129+
.header('Cache-Control', 'no-cache')
130+
.send(content)
131+
})
132+
133+
// HEAD /storage/:user/* — metadata only
134+
fastify.head('/storage/:user/*', async (request, reply) => {
135+
const storagePath = getStoragePath(request)
136+
137+
const { authorized, error } = await checkAuth(request, 'HEAD')
138+
if (!authorized) {
139+
return reply.code(401).header('WWW-Authenticate', 'Bearer').send()
140+
}
141+
142+
const info = await storage.stat(storagePath)
143+
if (!info) {
144+
return reply.code(404).send()
145+
}
146+
147+
reply
148+
.header('Content-Type', info.isDirectory ? 'application/ld+json' : getContentType(storagePath))
149+
.header('ETag', info.etag)
150+
.header('Cache-Control', 'no-cache')
151+
152+
if (!info.isDirectory) {
153+
reply.header('Content-Length', info.size)
154+
}
155+
156+
return reply.code(200).send()
157+
})
158+
159+
// PUT /storage/:user/* — write file
160+
fastify.put('/storage/:user/*', async (request, reply) => {
161+
const storagePath = getStoragePath(request)
162+
163+
const { authorized, error } = await checkAuth(request, 'PUT')
164+
if (!authorized) {
165+
return reply.code(401).header('WWW-Authenticate', 'Bearer').send({ error })
166+
}
167+
168+
// Directories end with / — can't PUT to a directory
169+
if (storagePath.endsWith('/')) {
170+
return reply.code(400).send({ error: 'Cannot PUT to a folder path' })
171+
}
172+
173+
// Conditional write
174+
const ifMatch = request.headers['if-match']
175+
const ifNoneMatch = request.headers['if-none-match']
176+
const existing = await storage.stat(storagePath)
177+
178+
if (ifMatch && (!existing || existing.etag !== ifMatch)) {
179+
return reply.code(412).send({ error: 'Precondition failed' })
180+
}
181+
if (ifNoneMatch === '*' && existing) {
182+
return reply.code(412).send({ error: 'Resource already exists' })
183+
}
184+
185+
const content = Buffer.isBuffer(request.body) ? request.body : Buffer.from(request.body || '')
186+
const success = await storage.write(storagePath, content)
187+
if (!success) {
188+
return reply.code(500).send({ error: 'Write failed' })
189+
}
190+
191+
const newStat = await storage.stat(storagePath)
192+
const statusCode = existing ? 200 : 201
193+
194+
return reply
195+
.code(statusCode)
196+
.header('ETag', newStat?.etag || '')
197+
.send()
198+
})
199+
200+
// DELETE /storage/:user/* — delete file
201+
fastify.delete('/storage/:user/*', async (request, reply) => {
202+
const storagePath = getStoragePath(request)
203+
204+
const { authorized, error } = await checkAuth(request, 'DELETE')
205+
if (!authorized) {
206+
return reply.code(401).header('WWW-Authenticate', 'Bearer').send({ error })
207+
}
208+
209+
const existing = await storage.stat(storagePath)
210+
if (!existing) {
211+
return reply.code(404).send({ error: 'Not found' })
212+
}
213+
214+
// Conditional delete
215+
const ifMatch = request.headers['if-match']
216+
if (ifMatch && existing.etag !== ifMatch) {
217+
return reply.code(412).send({ error: 'Precondition failed' })
218+
}
219+
220+
const success = await storage.remove(storagePath)
221+
if (!success) {
222+
return reply.code(500).send({ error: 'Delete failed' })
223+
}
224+
225+
return reply
226+
.code(200)
227+
.header('ETag', existing.etag)
228+
.send()
229+
})
230+
231+
fastify.log.info(`remoteStorage enabled for user: ${username}`)
232+
}
233+
234+
export default remoteStoragePlugin

src/server.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { isGitRequest, isGitWriteOperation, handleGit } from './handlers/git.js'
1515
import { AccessMode } from './wac/parser.js';
1616
import { registerNostrRelay } from './nostr/relay.js';
1717
import { activityPubPlugin, getActorHandler } from './ap/index.js';
18+
import { remoteStoragePlugin } from './remotestorage.js';
1819
import { dbPlugin } from './db/index.js';
1920

2021
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -234,6 +235,12 @@ export function createServer(options = {}) {
234235
});
235236
}
236237

238+
// Register remoteStorage plugin (always on — no flag needed)
239+
fastify.register(remoteStoragePlugin, {
240+
username: apUsername || 'me',
241+
ownerWebId: singleUser ? null : undefined // single-user: any authenticated user; multi-user: check WebID
242+
});
243+
237244
// Register MongoDB /db/ route if enabled
238245
if (mongoEnabled) {
239246
fastify.register(dbPlugin, { mongoUrl, mongoDatabase, singleUser });
@@ -370,6 +377,7 @@ export function createServer(options = {}) {
370377
(gitEnabled && isGitRequest(request.url)) ||
371378
(activitypubEnabled && apPaths.some(p => request.url === p || request.url.startsWith(p + '?'))) ||
372379
isProfileAP ||
380+
request.url.startsWith('/storage/') ||
373381
(mongoEnabled && (request.url === '/db' || request.url.startsWith('/db/'))) ||
374382
mashlibPaths.some(p => request.url === p || request.url.startsWith(p + '.'))) {
375383
return;

0 commit comments

Comments
 (0)