remoteStorage protocol support#162
Conversation
Implements draft-dejong-remotestorage-22 on top of existing storage infrastructure. No new dependencies — reuses filesystem storage, OAuth flow (#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 #106
There was a problem hiding this comment.
Pull request overview
Adds remoteStorage protocol endpoints to JavaScriptSolidServer (JSS) by introducing a Fastify plugin under /storage/:user/*, integrating Bearer-token auth, conditional requests, and WebFinger discovery.
Changes:
- Register a new always-on
remoteStoragePluginand bypass WAC for/storage/*routes. - Add
/storage/:user/*GET/HEAD/PUT/DELETE handlers backed by existing filesystem storage. - Extend the existing WebFinger response (in the ActivityPub plugin) with a remoteStorage link relation.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 12 comments.
| File | Description |
|---|---|
src/server.js |
Registers the remoteStorage plugin and exempts /storage/* from the global WAC auth hook. |
src/remotestorage.js |
Implements remoteStorage HTTP endpoints, auth, directory listing, and conditional request behavior. |
src/ap/index.js |
Adds remoteStorage discovery information to the WebFinger response. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Register remoteStorage plugin (always on — no flag needed) | ||
| fastify.register(remoteStoragePlugin, { | ||
| username: apUsername || 'me', | ||
| ownerWebId: singleUser ? null : undefined // single-user: any authenticated user; multi-user: check WebID |
There was a problem hiding this comment.
ownerWebId: singleUser ? null : undefined does not achieve the intended multi-user ownership check. In remoteStoragePlugin, const ownerWebId = options.ownerWebId || null coerces undefined to null, so the if (ownerWebId && webId !== ownerWebId) guard never runs in multi-user mode. Pass an explicit expected WebID here (derived from :user/pod) or change the plugin to distinguish “option omitted” from “option set to null”.
| ownerWebId: singleUser ? null : undefined // single-user: any authenticated user; multi-user: check WebID | |
| ownerWebId: singleUser ? null : (apUsername || 'me') // single-user: any authenticated user; multi-user: enforce owner WebID check |
| export async function remoteStoragePlugin (fastify, options = {}) { | ||
| const username = options.username || 'me' | ||
| const ownerWebId = options.ownerWebId || null | ||
|
|
||
| /** | ||
| * Extract the storage path from the URL | ||
| * /storage/me/photos/vacation.jpg → /photos/vacation.jpg | ||
| */ | ||
| function getStoragePath (request) { | ||
| const wildcard = request.params['*'] || '' | ||
| return '/' + wildcard | ||
| } |
There was a problem hiding this comment.
username and the :user path param are never used to scope storage. getStoragePath() drops the /storage/:user prefix and operates on absolute paths like /photos/..., so requests to /storage/alice/* and /storage/bob/* hit the same underlying filesystem namespace. This breaks multi-user isolation and makes the :user portion of the API misleading; the handler should either enforce request.params.user === username (single-user) or map :user to a per-user prefix (multi-user/subdomain-aware).
| const { authorized, error } = await checkAuth(request, 'GET') | ||
| if (!authorized) { | ||
| return reply.code(401).header('WWW-Authenticate', 'Bearer').send({ error }) |
There was a problem hiding this comment.
Authorization failures are always returned as 401 in the handlers, even when authentication succeeded but the user is not permitted (e.g., when checkAuth() returns error: 'Forbidden'). This makes clients treat an authorization failure as an authentication challenge. Return 403 when a WebID is present but access is denied, and reserve 401 (+ WWW-Authenticate) for missing/invalid credentials.
| const { authorized, error } = await checkAuth(request, 'GET') | |
| if (!authorized) { | |
| return reply.code(401).header('WWW-Authenticate', 'Bearer').send({ error }) | |
| const { authorized, error, webId } = await checkAuth(request, 'GET') | |
| if (!authorized) { | |
| // Authentication failure: no WebID → 401 with WWW-Authenticate | |
| if (!webId) { | |
| return reply.code(401).header('WWW-Authenticate', 'Bearer').send({ error }) | |
| } | |
| // Authorization failure: WebID present but access denied → 403 | |
| return reply.code(403).send({ error }) |
| // Conditional GET | ||
| const ifNoneMatch = request.headers['if-none-match'] | ||
| if (ifNoneMatch && ifNoneMatch === info.etag) { | ||
| return reply.code(304).send() | ||
| } |
There was a problem hiding this comment.
Conditional request handling is too strict for real-world ETag headers: If-None-Match can include multiple values and weak validators (W/"..."), and equality against the raw info.etag will miss matches. The codebase already has checkIfNoneMatchForGet / checkIfMatch utilities (src/utils/conditional.js) that normalize/parse ETags; reusing them here would also align behavior with the existing LDP/DB handlers.
| // HEAD /storage/:user/* — metadata only | ||
| fastify.head('/storage/:user/*', async (request, reply) => { | ||
| const storagePath = getStoragePath(request) | ||
|
|
||
| const { authorized, error } = await checkAuth(request, 'HEAD') | ||
| if (!authorized) { | ||
| return reply.code(401).header('WWW-Authenticate', 'Bearer').send() | ||
| } | ||
|
|
||
| const info = await storage.stat(storagePath) | ||
| if (!info) { | ||
| return reply.code(404).send() | ||
| } | ||
|
|
||
| reply | ||
| .header('Content-Type', info.isDirectory ? 'application/ld+json' : getContentType(storagePath)) | ||
| .header('ETag', info.etag) | ||
| .header('Cache-Control', 'no-cache') | ||
|
|
||
| if (!info.isDirectory) { | ||
| reply.header('Content-Length', info.size) | ||
| } | ||
|
|
||
| return reply.code(200).send() | ||
| }) |
There was a problem hiding this comment.
HEAD does not implement conditional requests (If-None-Match / If-Match) even though remoteStorage clients commonly rely on them for metadata checks. As written, a HEAD with a matching If-None-Match will still return 200 rather than 304. Consider applying the same conditional logic used for GET (ideally via checkIfNoneMatchForGet).
| // Add remoteStorage link relation | ||
| response.links.push({ | ||
| rel: 'http://tools.ietf.org/id/draft-dejong-remotestorage', | ||
| href: `${baseUrl}/storage/${config.username}`, |
There was a problem hiding this comment.
The WebFinger remoteStorage href is ${baseUrl}/storage/${config.username} (no trailing slash), but the server only registers /storage/:user/* routes. By analogy with the existing /* vs / handling in server.js, /storage/me is likely to 404 while /storage/me/ works. Either add explicit /storage/:user routes (or redirect) in the plugin, or include the trailing slash in the WebFinger href to match the registered routes.
| href: `${baseUrl}/storage/${config.username}`, | |
| href: `${baseUrl}/storage/${config.username}/`, |
| (gitEnabled && isGitRequest(request.url)) || | ||
| (activitypubEnabled && apPaths.some(p => request.url === p || request.url.startsWith(p + '?'))) || | ||
| isProfileAP || | ||
| request.url.startsWith('/storage/') || |
There was a problem hiding this comment.
Skipping the global WAC authorize() hook for all /storage/* requests means these routes fully bypass read-only mode, quota enforcement, and ACL protections unless the remoteStorage plugin re-implements them. Given the current plugin writes directly via storage.write/remove, this creates a path where clients can mutate data even when the server is configured read-only and without any WAC checks. Consider routing remoteStorage requests through the existing resource handlers (or reusing their quota/readOnly/notification logic) rather than bypassing WAC entirely at this layer.
| request.url.startsWith('/storage/') || |
| function getStoragePath (request) { | ||
| const wildcard = request.params['*'] || '' | ||
| return '/' + wildcard | ||
| } | ||
|
|
||
| /** | ||
| * Check if request is authorized for the given method | ||
| * Public folder is readable without auth | ||
| */ | ||
| async function checkAuth (request, method) { | ||
| const storagePath = getStoragePath(request) | ||
|
|
||
| // Public folder: readable without auth | ||
| if (storagePath.startsWith('/public/') && (method === 'GET' || method === 'HEAD')) { | ||
| return { authorized: true, webId: null } | ||
| } | ||
|
|
||
| const { webId, error } = await getWebIdFromRequestAsync(request) | ||
| if (!webId) { | ||
| return { authorized: false, webId: null, error: error || 'Unauthorized' } | ||
| } | ||
|
|
||
| // If ownerWebId is set, only the owner can access storage | ||
| if (ownerWebId && webId !== ownerWebId) { | ||
| return { authorized: false, webId, error: 'Forbidden' } | ||
| } | ||
|
|
||
| return { authorized: true, webId } | ||
| } |
There was a problem hiding this comment.
The remoteStorage routes operate directly on filesystem paths and currently allow access to dotfiles and server-internal control files under the data root (e.g. /.pods, /.account, /.acl, /.meta) because getStoragePath() can return those paths and checkAuth() only checks bearer authentication. This lets OAuth bearer tokens read/modify Solid ACLs and other administrative files, which is a serious privilege escalation vector. Restrict remoteStorage to a per-user root and explicitly deny any path segment starting with . (and/or specifically block .acl/.meta/.pods/.account).
| const content = await storage.read(storagePath) | ||
| if (content === null) { | ||
| return reply.code(404).send({ error: 'Not found' }) | ||
| } | ||
|
|
||
| return reply | ||
| .header('Content-Type', getContentType(storagePath)) | ||
| .header('Content-Length', content.length) | ||
| .header('ETag', info.etag) | ||
| .header('Cache-Control', 'no-cache') | ||
| .send(content) |
There was a problem hiding this comment.
GET reads entire files into memory via storage.read() and then sets Content-Length from the buffer. This can cause high memory usage for large remoteStorage objects and bypasses the server’s existing streaming + range support (storage.createReadStream() used elsewhere). Prefer streaming responses for file reads to keep memory bounded.
| const content = await storage.read(storagePath) | |
| if (content === null) { | |
| return reply.code(404).send({ error: 'Not found' }) | |
| } | |
| return reply | |
| .header('Content-Type', getContentType(storagePath)) | |
| .header('Content-Length', content.length) | |
| .header('ETag', info.etag) | |
| .header('Cache-Control', 'no-cache') | |
| .send(content) | |
| const stream = storage.createReadStream(storagePath) | |
| return reply | |
| .header('Content-Type', getContentType(storagePath)) | |
| .header('Content-Length', info.size) | |
| .header('ETag', info.etag) | |
| .header('Cache-Control', 'no-cache') | |
| .send(stream) |
| // GET /storage/:user/* — read file or folder | ||
| fastify.get('/storage/:user/*', async (request, reply) => { | ||
| const storagePath = getStoragePath(request) | ||
|
|
||
| const { authorized, error } = await checkAuth(request, 'GET') | ||
| if (!authorized) { | ||
| return reply.code(401).header('WWW-Authenticate', 'Bearer').send({ error }) | ||
| } | ||
|
|
||
| const info = await storage.stat(storagePath) | ||
| if (!info) { | ||
| return reply.code(404).send({ error: 'Not found' }) | ||
| } | ||
|
|
||
| // Conditional GET | ||
| const ifNoneMatch = request.headers['if-none-match'] | ||
| if (ifNoneMatch && ifNoneMatch === info.etag) { | ||
| return reply.code(304).send() | ||
| } | ||
|
|
||
| // Directory listing | ||
| if (info.isDirectory) { | ||
| const entries = await storage.listContainer(storagePath) | ||
| if (!entries) { | ||
| return reply.code(404).send({ error: 'Not found' }) | ||
| } | ||
|
|
||
| const items = {} | ||
| for (const entry of entries) { | ||
| // Skip hidden files (ACLs, metadata) | ||
| if (entry.name.startsWith('.')) continue | ||
|
|
||
| const childPath = storagePath.endsWith('/') ? storagePath + entry.name : storagePath + '/' + entry.name | ||
| const childStat = await storage.stat(entry.isDirectory ? childPath + '/' : childPath) | ||
|
|
||
| if (entry.isDirectory) { | ||
| items[entry.name + '/'] = { | ||
| ETag: childStat?.etag?.replace(/"/g, '') || '' | ||
| } | ||
| } else { | ||
| items[entry.name] = { | ||
| ETag: childStat?.etag?.replace(/"/g, '') || '', | ||
| 'Content-Type': getContentType(entry.name), | ||
| 'Content-Length': childStat?.size || 0 | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return reply | ||
| .header('Content-Type', 'application/ld+json') | ||
| .header('ETag', info.etag) | ||
| .header('Cache-Control', 'no-cache') | ||
| .send({ | ||
| '@context': 'http://remotestorage.io/spec/folder-description', | ||
| items | ||
| }) | ||
| } | ||
|
|
||
| // File | ||
| const content = await storage.read(storagePath) | ||
| if (content === null) { | ||
| return reply.code(404).send({ error: 'Not found' }) | ||
| } | ||
|
|
||
| return reply | ||
| .header('Content-Type', getContentType(storagePath)) | ||
| .header('Content-Length', content.length) | ||
| .header('ETag', info.etag) | ||
| .header('Cache-Control', 'no-cache') | ||
| .send(content) | ||
| }) | ||
|
|
||
| // HEAD /storage/:user/* — metadata only | ||
| fastify.head('/storage/:user/*', async (request, reply) => { | ||
| const storagePath = getStoragePath(request) | ||
|
|
||
| const { authorized, error } = await checkAuth(request, 'HEAD') | ||
| if (!authorized) { | ||
| return reply.code(401).header('WWW-Authenticate', 'Bearer').send() | ||
| } | ||
|
|
||
| const info = await storage.stat(storagePath) | ||
| if (!info) { | ||
| return reply.code(404).send() | ||
| } | ||
|
|
||
| reply | ||
| .header('Content-Type', info.isDirectory ? 'application/ld+json' : getContentType(storagePath)) | ||
| .header('ETag', info.etag) | ||
| .header('Cache-Control', 'no-cache') | ||
|
|
||
| if (!info.isDirectory) { | ||
| reply.header('Content-Length', info.size) | ||
| } | ||
|
|
||
| return reply.code(200).send() | ||
| }) | ||
|
|
||
| // PUT /storage/:user/* — write file | ||
| fastify.put('/storage/:user/*', async (request, reply) => { | ||
| const storagePath = getStoragePath(request) | ||
|
|
||
| const { authorized, error } = await checkAuth(request, 'PUT') | ||
| if (!authorized) { | ||
| return reply.code(401).header('WWW-Authenticate', 'Bearer').send({ error }) | ||
| } | ||
|
|
||
| // Directories end with / — can't PUT to a directory | ||
| if (storagePath.endsWith('/')) { | ||
| return reply.code(400).send({ error: 'Cannot PUT to a folder path' }) | ||
| } | ||
|
|
||
| // Conditional write | ||
| const ifMatch = request.headers['if-match'] | ||
| const ifNoneMatch = request.headers['if-none-match'] | ||
| const existing = await storage.stat(storagePath) | ||
|
|
||
| if (ifMatch && (!existing || existing.etag !== ifMatch)) { | ||
| return reply.code(412).send({ error: 'Precondition failed' }) | ||
| } | ||
| if (ifNoneMatch === '*' && existing) { | ||
| return reply.code(412).send({ error: 'Resource already exists' }) | ||
| } | ||
|
|
||
| const content = Buffer.isBuffer(request.body) ? request.body : Buffer.from(request.body || '') | ||
| const success = await storage.write(storagePath, content) | ||
| if (!success) { | ||
| return reply.code(500).send({ error: 'Write failed' }) | ||
| } | ||
|
|
||
| const newStat = await storage.stat(storagePath) | ||
| const statusCode = existing ? 200 : 201 | ||
|
|
||
| return reply | ||
| .code(statusCode) | ||
| .header('ETag', newStat?.etag || '') | ||
| .send() | ||
| }) | ||
|
|
||
| // DELETE /storage/:user/* — delete file | ||
| fastify.delete('/storage/:user/*', async (request, reply) => { | ||
| const storagePath = getStoragePath(request) | ||
|
|
||
| const { authorized, error } = await checkAuth(request, 'DELETE') | ||
| if (!authorized) { | ||
| return reply.code(401).header('WWW-Authenticate', 'Bearer').send({ error }) | ||
| } | ||
|
|
||
| const existing = await storage.stat(storagePath) | ||
| if (!existing) { | ||
| return reply.code(404).send({ error: 'Not found' }) | ||
| } | ||
|
|
||
| // Conditional delete | ||
| const ifMatch = request.headers['if-match'] | ||
| if (ifMatch && existing.etag !== ifMatch) { | ||
| return reply.code(412).send({ error: 'Precondition failed' }) | ||
| } | ||
|
|
||
| const success = await storage.remove(storagePath) | ||
| if (!success) { | ||
| return reply.code(500).send({ error: 'Delete failed' }) | ||
| } | ||
|
|
||
| return reply | ||
| .code(200) | ||
| .header('ETag', existing.etag) | ||
| .send() | ||
| }) |
There was a problem hiding this comment.
There are comprehensive Node.js tests in test/*.test.js, but this PR introduces a new protocol surface (/storage/:user/* methods, auth rules, directory listing format, conditional requests) without any automated tests. Adding a focused test file (e.g., covering WebFinger discovery, public folder unauthenticated GET/HEAD, and If-Match/If-None-Match behavior) would help prevent regressions.
- Enforce username match (request.params.user === configured username) - Return 403 (not 401) when authenticated but forbidden - Use shared conditional utilities (checkIfMatch, checkIfNoneMatchForGet/Write) - Block dotfile access (.acl, .meta, etc.) — security fix - Use createReadStream() for file reads instead of buffering entire files - Add conditional request handling to HEAD - Respect readOnly mode for PUT/DELETE - Fix WebFinger RS href trailing slash
Adds Mastodon API, OAuth 2.0, and remoteStorage protocol. Also fixes stale 0.0.67 version strings in nodeinfo and instance endpoint.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated 10 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Register remoteStorage plugin (always on — no flag needed) | ||
| fastify.register(remoteStoragePlugin, { | ||
| username: apUsername || 'me', | ||
| ownerWebId: singleUser ? null : undefined // single-user: any authenticated user; multi-user: check WebID | ||
| }); |
There was a problem hiding this comment.
remoteStoragePlugin is registered with username: apUsername || 'me', which is unrelated to singleUserName and can diverge from the actual pod path the server creates/serves. Additionally, passing ownerWebId: singleUser ? null : undefined can't express the intended difference because the plugin currently normalizes falsy values to null. Consider wiring remoteStorage username/ownership to the pod/user model explicitly (e.g., singleUserName in single-user mode, and per-request user mapping in multi-user mode).
| .header('Content-Length', info.size) | ||
| .header('ETag', info.etag) | ||
| .header('Cache-Control', 'no-cache') | ||
| .send(result.stream) | ||
| }) | ||
|
|
||
| // HEAD /storage/:user/* — metadata only | ||
| fastify.head('/storage/:user/*', async (request, reply) => { |
There was a problem hiding this comment.
PUT handler doesn't currently honor the server's read-only mode (request.config.readOnly), unlike other write endpoints which return 405 when read-only is enabled. Since /storage/* bypasses the WAC preHandler, this route needs its own read-only enforcement.
| } | ||
|
|
||
| return reply.code(200).send() | ||
| }) | ||
|
|
||
| // PUT /storage/:user/* — write file | ||
| fastify.put('/storage/:user/*', async (request, reply) => { | ||
| if (!checkUsername(request, reply)) return |
There was a problem hiding this comment.
DELETE handler doesn't currently honor the server's read-only mode (request.config.readOnly), unlike other write endpoints. Add a read-only check here as well to prevent deletes when the server is configured read-only.
| const storagePath = getStoragePath(request) | ||
|
|
||
| if (hasDotfile(storagePath)) { | ||
| return reply.code(403).send({ error: 'Cannot write to dotfiles' }) | ||
| } |
There was a problem hiding this comment.
Conditional DELETE uses a direct existing.etag !== ifMatch check, which doesn't handle standard If-Match parsing/normalization (weak ETags, multiple values, quoted/unquoted). Using the shared conditional helpers would align behavior with the rest of the server.
|
|
||
| const items = {} | ||
| for (const entry of entries) { | ||
| // Skip dotfiles (ACLs, metadata, etc.) | ||
| if (entry.name.startsWith('.')) continue | ||
|
|
||
| const childPath = storagePath.endsWith('/') ? storagePath + entry.name : storagePath + '/' + entry.name |
There was a problem hiding this comment.
GET for files reads the entire file into memory via storage.read() before sending it. For large files this can create significant memory pressure; the filesystem storage layer already exposes createReadStream(), which would allow streaming responses like the main resource handler.
| // Add remoteStorage link relation | ||
| response.links.push({ | ||
| rel: 'http://tools.ietf.org/id/draft-dejong-remotestorage', | ||
| href: `${baseUrl}/storage/${config.username}/`, | ||
| properties: { |
There was a problem hiding this comment.
The WebFinger response advertises storage at /storage/${config.username} regardless of the acct: username requested. This prevents multi-user discovery and can mislead clients if the requested acct doesn't match config.username. Consider using the parsed WebFinger username in the storage href (and ensuring the storage routes validate/authorize that user appropriately).
| rel: 'http://tools.ietf.org/id/draft-dejong-remotestorage', | ||
| href: `${baseUrl}/storage/${config.username}/`, | ||
| properties: { |
There was a problem hiding this comment.
The advertised remoteStorage href does not include a trailing slash, while storage routes are defined as /storage/:user/* (which may not match /storage/:user without /). To avoid clients hitting a 404 when dereferencing the advertised URL, either advertise /storage/:user/ or add a handler/redirect for /storage/:user.
| * Public folder is readable without auth | ||
| */ |
There was a problem hiding this comment.
This PR adds new externally-consumed endpoints (/storage/:user/*) with non-trivial behavior (auth, public folder, conditional requests). The repo has an extensive test suite, but there are currently no tests for these routes; please add integration tests covering at least GET/PUT/DELETE/HEAD, public access, and conditional headers (If-Match / If-None-Match).
| * Public folder is readable without auth | ||
| */ | ||
| async function checkAuth (request, method) { | ||
| const storagePath = getStoragePath(request) |
There was a problem hiding this comment.
remoteStorage routes currently allow direct access to dotfiles under the data root (e.g., requesting /storage/<user>/.pods, /.acl, /.meta, etc.) because no dotfile/path-segment filtering is applied before calling storage.stat/read. Since some dotfiles are intentionally accessible for Solid internals, remoteStorage should independently block dotfiles (typically any segment starting with .) to avoid leaking server/pod metadata.
| */ | ||
| function hasDotfile (storagePath) { | ||
| const segments = storagePath.split('/') | ||
| return segments.some(s => s.startsWith('.') && s.length > 1) |
There was a problem hiding this comment.
checkAuth() returns 'Forbidden' when ownerWebId is set and the token WebID doesn't match, but the handlers always respond with HTTP 401. Please propagate a 403 status for authorization failures (and only set WWW-Authenticate: Bearer for 401), so clients can distinguish unauthenticated vs forbidden.
The RS plugin username should match the pod username, not the ActivityPub username which may differ or be unset.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated 10 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const items = {} | ||
| for (const entry of entries) { | ||
| // Skip dotfiles (ACLs, metadata, etc.) | ||
| if (entry.name.startsWith('.')) continue | ||
|
|
||
| const childPath = storagePath.endsWith('/') ? storagePath + entry.name : storagePath + '/' + entry.name | ||
| const childStat = await storage.stat(entry.isDirectory ? childPath + '/' : childPath) | ||
|
|
There was a problem hiding this comment.
Directory listing builds per-entry metadata by await storage.stat(...) inside a for loop, resulting in N+1 sequential filesystem stats for large folders. This can become noticeably slow for directories with many entries. Consider batching these stats (e.g., Promise.all) or extending listContainer() to return the required metadata to avoid repeated stat calls.
| // Dotfiles that must never be exposed via remoteStorage | ||
| const BLOCKED_NAMES = new Set(['.acl', '.meta', '.pods']) | ||
|
|
There was a problem hiding this comment.
BLOCKED_NAMES is declared but never used. Since dotfile blocking is implemented via hasDotfile(), this constant is dead code and may confuse future maintenance. Consider removing it or using it to explicitly block only specific dotfiles (if that was the intent).
| const { webId, error } = await getWebIdFromRequestAsync(request) | ||
| if (!webId) { | ||
| return { authorized: false, webId: null, error: error || 'Unauthorized', status: 401 } | ||
| } | ||
|
|
||
| // If ownerWebId is set, only the owner can access storage | ||
| if (ownerWebId && webId !== ownerWebId) { | ||
| return { authorized: false, webId, error: 'Forbidden', status: 403 } | ||
| } | ||
|
|
||
| return { authorized: true, webId } | ||
| } |
There was a problem hiding this comment.
checkAuth() treats any valid token as authorized when ownerWebId is unset, and does not verify that the authenticated webId corresponds to request.params.user. Combined with the always-on registration, this allows authenticated users to access another user’s storage path in multi-user setups. Consider enforcing webId ↔ :user ownership (or always configuring ownerWebId for single-user) so the storage namespace can’t be accessed by other accounts.
| const success = await storage.write(storagePath, content) | ||
| if (!success) { | ||
| return reply.code(500).send({ error: 'Write failed' }) | ||
| } |
There was a problem hiding this comment.
remoteStorage PUT writes directly via storage.write() without applying the pod quota checks / quota accounting used by the main LDP handlePut path. This allows clients to exceed configured quotas (and bypass any related operational constraints). Consider reusing the existing PUT/DELETE handlers or invoking checkQuota/updateQuotaUsage equivalents for these routes.
| // PUT /storage/:user/* — write file | ||
| fastify.put('/storage/:user/*', async (request, reply) => { | ||
| if (!checkUsername(request, reply)) return | ||
|
|
||
| // Respect readOnly mode | ||
| if (request.config?.readOnly) { | ||
| return reply.code(405).send({ error: 'Method Not Allowed', message: 'Server is in read-only mode' }) | ||
| } | ||
|
|
||
| const storagePath = getStoragePath(request) | ||
|
|
||
| if (hasDotfile(storagePath)) { | ||
| return reply.code(403).send({ error: 'Cannot write to dotfiles' }) | ||
| } | ||
|
|
||
| const { authorized, error, status } = await checkAuth(request, 'PUT') | ||
| if (!authorized) { | ||
| const code = status || 401 | ||
| if (code === 401) reply.header('WWW-Authenticate', 'Bearer') | ||
| return reply.code(code).send({ error }) | ||
| } | ||
|
|
||
| // Directories end with / — can't PUT to a directory | ||
| if (storagePath.endsWith('/')) { | ||
| return reply.code(400).send({ error: 'Cannot PUT to a folder path' }) | ||
| } | ||
|
|
||
| // Conditional write — use shared utilities | ||
| const existing = await storage.stat(storagePath) | ||
|
|
||
| const ifMatchResult = checkIfMatch(request.headers['if-match'], existing?.etag || null) | ||
| if (!ifMatchResult.ok) { | ||
| return reply.code(ifMatchResult.status).send({ error: ifMatchResult.error }) | ||
| } | ||
|
|
||
| const ifNoneMatchResult = checkIfNoneMatchForWrite(request.headers['if-none-match'], existing?.etag || null) | ||
| if (!ifNoneMatchResult.ok) { | ||
| return reply.code(ifNoneMatchResult.status).send({ error: ifNoneMatchResult.error }) | ||
| } | ||
|
|
||
| const content = Buffer.isBuffer(request.body) ? request.body : Buffer.from(request.body || '') | ||
| const success = await storage.write(storagePath, content) | ||
| if (!success) { | ||
| return reply.code(500).send({ error: 'Write failed' }) | ||
| } | ||
|
|
||
| const newStat = await storage.stat(storagePath) | ||
| const statusCode = existing ? 200 : 201 | ||
|
|
||
| return reply | ||
| .code(statusCode) | ||
| .header('ETag', newStat?.etag || '') | ||
| .send() | ||
| }) | ||
|
|
||
| // DELETE /storage/:user/* — delete file | ||
| fastify.delete('/storage/:user/*', async (request, reply) => { | ||
| if (!checkUsername(request, reply)) return | ||
|
|
||
| // Respect readOnly mode | ||
| if (request.config?.readOnly) { | ||
| return reply.code(405).send({ error: 'Method Not Allowed', message: 'Server is in read-only mode' }) | ||
| } | ||
|
|
There was a problem hiding this comment.
Write operations under /* are rate-limited via writeRateLimit, but the new /storage/:user/* PUT/DELETE routes are not. This creates an easy bypass for write-abuse/resource-exhaustion protections. Consider applying the same rateLimit config (keyed by webId/IP) to these remoteStorage write routes.
| // Add remoteStorage link relation | ||
| response.links.push({ | ||
| rel: 'http://tools.ietf.org/id/draft-dejong-remotestorage', | ||
| href: `${baseUrl}/storage/${config.username}/`, | ||
| properties: { | ||
| 'http://remotestorage.io/spec/version': 'draft-dejong-remotestorage-22', | ||
| 'http://tools.ietf.org/html/rfc6749#section-4.2': `${baseUrl}/oauth/authorize`, | ||
| 'http://tools.ietf.org/html/rfc6750#section-2.3': 'Bearer' | ||
| } | ||
| }) |
There was a problem hiding this comment.
The remoteStorage discovery link is added inside activityPubPlugin’s WebFinger handler. Since activitypub is disabled by default (and /oauth/* is also registered only in this plugin), remoteStorage clients won’t be able to discover the storage endpoint or complete the OAuth flow unless ActivityPub is enabled—contradicting the PR/README claim that remoteStorage is “always on, no flag needed”. Consider moving the WebFinger + OAuth pieces to an always-on plugin, or update the docs/description to reflect the --activitypub requirement.
| // GET /storage/:user/* — read file or folder | ||
| fastify.get('/storage/:user/*', async (request, reply) => { | ||
| if (!checkUsername(request, reply)) return | ||
|
|
||
| const storagePath = getStoragePath(request) | ||
|
|
||
| // Block dotfile access | ||
| if (hasDotfile(storagePath)) { | ||
| return reply.code(404).send({ error: 'Not found' }) | ||
| } | ||
|
|
||
| const { authorized, error, status } = await checkAuth(request, 'GET') | ||
| if (!authorized) { | ||
| const code = status || 401 | ||
| if (code === 401) reply.header('WWW-Authenticate', 'Bearer') | ||
| return reply.code(code).send({ error }) | ||
| } | ||
|
|
||
| const info = await storage.stat(storagePath) | ||
| if (!info) { | ||
| return reply.code(404).send({ error: 'Not found' }) | ||
| } | ||
|
|
||
| // Conditional GET — use shared utility | ||
| const cond = checkIfNoneMatchForGet(request.headers['if-none-match'], info.etag) | ||
| if (!cond.ok) { | ||
| return reply.code(304).send() | ||
| } |
There was a problem hiding this comment.
This PR adds a new protocol surface area (auth + conditional requests + filesystem writes) but does not add automated tests. Given the repo uses node --test with extensive integration coverage, consider adding tests that cover at least: WebFinger RS link, public read access, Bearer-required writes, and conditional GET/PUT/DELETE behaviors (If-None-Match/If-Match).
| (activitypubEnabled && apPaths.some(p => request.url === p || request.url.startsWith(p + '?'))) || | ||
| isProfileAP || | ||
| request.url.startsWith('/storage/') || | ||
| (mongoEnabled && (request.url === '/db' || request.url.startsWith('/db/'))) || |
There was a problem hiding this comment.
WAC auth is skipped for any URL starting with /storage/. Given these routes implement their own auth, it’s important they provide equivalent protections to WAC for writes (e.g., quota enforcement, rate limiting, and restricting writes to the owning pod). Right now remoteStorage handlers write directly to filesystem storage, so this skip can become a privilege-escalation path unless the plugin enforces the same constraints.
| function getStoragePath (request) { | ||
| const wildcard = request.params['*'] || '' | ||
| return '/' + wildcard | ||
| } |
There was a problem hiding this comment.
getStoragePath() ignores the :user segment entirely (/storage/:user/* → /*). This means remoteStorage requests do not map into the user’s existing pod path (e.g., /me/...) and can create/read resources outside the intended user namespace, undermining the “same files as Solid” claim and potentially exposing/overwriting server resources. Consider including the resolved pod/user prefix in the translated path (and making it subdomain-aware if needed).
| const content = Buffer.isBuffer(request.body) ? request.body : Buffer.from(request.body || '') | ||
| const success = await storage.write(storagePath, content) |
There was a problem hiding this comment.
PUT body handling can throw: when Fastify parses application/json, request.body is an object, but Buffer.from(request.body || '') will raise a TypeError. Consider mirroring the existing LDP handlePut behavior (Buffer/string/object cases) so remoteStorage can store JSON safely without crashing the request handler.
Storage routes are always on, but WebFinger and OAuth are currently inside the AP plugin. Tracked in #164 for standalone extraction.
Summary
Endpoints
/storage/:user/*/storage/:user/*/storage/:user/*/storage/:user/*Features
http://remotestorage.io/spec/folder-description)/storage/:user/public/*) readable without authHow it works
Same files, same filesystem, same ETags. A remoteStorage.js client and a Solid client can read/write the same data.
WebFinger
{ "rel": "http://tools.ietf.org/id/draft-dejong-remotestorage", "href": "https://example.com/storage/me", "properties": { "http://remotestorage.io/spec/version": "draft-dejong-remotestorage-22", "http://tools.ietf.org/html/rfc6749#section-4.2": "https://example.com/oauth/authorize", "http://tools.ietf.org/html/rfc6750#section-2.3": "Bearer" } }Test plan
GET /.well-known/webfinger?resource=acct:me@example.comincludes RS linkGET /storage/me/returns folder listing in RS JSON-LD formatPUT /storage/me/test/hello.txtcreates fileGET /storage/me/test/hello.txtreturns file with ETagGET /storage/me/test/hello.txtwithIf-None-Matchreturns 304DELETE /storage/me/test/hello.txtwithIf-Matchdeletes fileGET /storage/me/public/works without authPUT /storage/me/test.txtwithout auth returns 401Refs #106