diff --git a/README.md b/README.md index 8f7668d..c4c547c 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,9 @@ A minimal, fast, JSON-LD native Solid server. - **HTTP Range Requests** - Partial content delivery for large files and media streaming - **Single-User Mode** - Simplified setup for personal pod servers - **ActivityPub Federation** - Fediverse integration with WebFinger, inbox/outbox, HTTP signatures +- **Mastodon-compatible API** - Dynamic client registration, instance info, account verification +- **OAuth 2.0 Authorization** - Shared auth flow for Mastodon clients, remoteStorage apps, and third-party panes +- **remoteStorage Protocol** - [draft-dejong-remotestorage-22](https://remotestorage.io/spec/) file sync (requires `--activitypub` for WebFinger discovery + OAuth) - **LDP CRUD Operations** - GET, PUT, POST, DELETE, HEAD - **N3 Patch** - Solid's native patch format for RDF updates - **SPARQL Update** - Standard SPARQL UPDATE protocol for PATCH @@ -554,6 +557,106 @@ curl -H "Accept: application/activity+json" http://localhost:3000/profile/card curl http://localhost:3000/.well-known/nodeinfo/2.1 ``` +## Mastodon-compatible API + +JSS exposes Mastodon API endpoints so that Mastodon clients (Elk, Phanpy, Ice Cubes) can connect: + +```bash +jss start --activitypub --idp +``` + +### Endpoints + +| Endpoint | Description | +|----------|-------------| +| `POST /api/v1/apps` | Dynamic client registration | +| `GET /api/v1/accounts/verify_credentials` | Current user profile | +| `GET /api/v1/instance` | Instance metadata | +| `GET /oauth/authorize` | OAuth authorize page | +| `POST /oauth/authorize` | Process login | +| `POST /oauth/token` | Exchange code for Bearer token | + +### OAuth 2.0 Flow + +The OAuth layer is shared between Mastodon clients, remoteStorage apps, and third-party Solid panes: + +1. Client registers via `POST /api/v1/apps` (gets `client_id` + `client_secret`) +2. Client redirects user to `GET /oauth/authorize?client_id=...&redirect_uri=...&response_type=code` +3. User logs in, JSS redirects back with `?code=...` +4. Client exchanges code for Bearer token via `POST /oauth/token` +5. Bearer token works with all JSS endpoints (Solid, ActivityPub, remoteStorage) + +Supports out-of-band (OOB) redirect for CLI/desktop clients. + +### Testing + +```bash +# Register a client +curl -X POST http://localhost:3000/api/v1/apps \ + -H "Content-Type: application/json" \ + -d '{"client_name": "Test App", "redirect_uris": "urn:ietf:wg:oauth:2.0:oob"}' + +# Check instance info +curl http://localhost:3000/api/v1/instance +``` + +## remoteStorage + +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. + +```bash +jss start --activitypub --idp +``` + +### Discovery + +remoteStorage clients discover the storage endpoint via WebFinger: + +```bash +curl "http://localhost:3000/.well-known/webfinger?resource=acct:me@localhost:3000" +``` + +The response includes a `remotestorage` link relation pointing to `/storage/me/`. + +### Endpoints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `GET` | `/storage/:user/*` | Read file or list folder (JSON-LD) | +| `HEAD` | `/storage/:user/*` | Get metadata (ETag, Content-Type, size) | +| `PUT` | `/storage/:user/*` | Write file (creates parent folders) | +| `DELETE` | `/storage/:user/*` | Delete file | + +### How It Works + +- **Auth**: Bearer token via OAuth 2.0 (same flow as Mastodon clients) +- **Public folder**: `/storage/me/public/*` is readable without auth +- **Conditional requests**: If-Match, If-None-Match (uses shared ETag utilities) +- **Dotfile protection**: `.acl`, `.meta`, and other dotfiles are blocked +- **Read-only mode**: Respects `--read-only` flag +- **Streaming**: Large files are streamed, not buffered + +### Testing + +```bash +# Write a file (needs Bearer token from OAuth flow) +curl -X PUT http://localhost:3000/storage/me/documents/hello.txt \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -H "Content-Type: text/plain" \ + -d "Hello, remoteStorage!" + +# Read it back +curl -H "Authorization: Bearer YOUR_TOKEN" \ + http://localhost:3000/storage/me/documents/hello.txt + +# List a folder +curl -H "Authorization: Bearer YOUR_TOKEN" \ + http://localhost:3000/storage/me/documents/ + +# Read from public folder (no auth needed) +curl http://localhost:3000/storage/me/public/readme.txt +``` + ### Linking Nostr to WebID (did:nostr) Bridge your Nostr identity to a Solid WebID for seamless authentication: @@ -1097,7 +1200,10 @@ src/ │ ├── actor.js # Actor JSON-LD │ ├── inbox.js # Receive activities │ ├── outbox.js # User's activities -│ └── collections.js # Followers/following +│ ├── collections.js # Followers/following +│ ├── mastodon.js # Mastodon API (apps, instance, verify_credentials) +│ └── oauth.js # OAuth 2.0 authorize/token flow +├── remotestorage.js # remoteStorage protocol (draft-dejong-remotestorage-22) ├── rdf/ │ ├── turtle.js # Turtle <-> JSON-LD │ └── conneg.js # Content negotiation diff --git a/package.json b/package.json index 2c8782a..da270ca 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "javascript-solid-server", - "version": "0.0.96", + "version": "0.0.97", "description": "A minimal, fast Solid server", "main": "src/index.js", "type": "module", diff --git a/src/ap/index.js b/src/ap/index.js index 60756c2..cb14fb1 100644 --- a/src/ap/index.js +++ b/src/ap/index.js @@ -108,6 +108,17 @@ export async function activityPubPlugin(fastify, options = {}) { { profileUrl } ) + // 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' + } + }) + return reply .header('Content-Type', 'application/jrd+json') .header('Access-Control-Allow-Origin', '*') @@ -137,7 +148,7 @@ export async function activityPubPlugin(fastify, options = {}) { version: '2.1', software: { name: 'jss', - version: '0.0.67', + version: '0.0.97', repository: 'https://github.com/JavaScriptSolidServer/JavaScriptSolidServer' }, protocols: ['activitypub', 'solid'], diff --git a/src/ap/routes/mastodon.js b/src/ap/routes/mastodon.js index 5289404..4bcf9d2 100644 --- a/src/ap/routes/mastodon.js +++ b/src/ap/routes/mastodon.js @@ -115,7 +115,7 @@ export function createInstanceHandler (config) { title: config.displayName || 'JSS', description: 'SAND Stack: Solid + ActivityPub + Nostr + DID', short_description: 'Solid pod with Mastodon-compatible API', - version: '4.0.0 (compatible; JSS 0.0.67)', + version: '4.0.0 (compatible; JSS 0.0.97)', urls: { streaming_api: `${wsProtocol}://${host}` }, diff --git a/src/remotestorage.js b/src/remotestorage.js new file mode 100644 index 0000000..0da4d88 --- /dev/null +++ b/src/remotestorage.js @@ -0,0 +1,304 @@ +/** + * remoteStorage plugin for JSS + * Implements draft-dejong-remotestorage protocol on top of existing storage + * + * No new dependencies — reuses filesystem storage, OAuth, and WebFinger. + * Always on — no flag needed. + * + * Ref: https://remotestorage.io/spec/draft-dejong-remotestorage-22 + * Related: #106, #160 (OAuth), #159 (Mastodon API) + */ + +import * as storage from './storage/filesystem.js' +import { getContentType } from './utils/url.js' +import { getWebIdFromRequestAsync } from './auth/token.js' +import { checkIfMatch, checkIfNoneMatchForGet, checkIfNoneMatchForWrite } from './utils/conditional.js' + +/** + * remoteStorage Fastify plugin + * @param {FastifyInstance} fastify + * @param {object} options + * @param {string} options.username - Storage owner username + * @param {string} options.ownerWebId - WebID of the storage owner + */ +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 + } + + /** + * Check if the :user param matches the configured username + */ + function checkUsername (request, reply) { + if (request.params.user !== username) { + reply.code(404).send({ error: 'Unknown user' }) + return false + } + return true + } + + /** + * Check if any path segment is a blocked dotfile + */ + function hasDotfile (storagePath) { + const segments = storagePath.split('/') + return segments.some(s => s.startsWith('.') && s.length > 1) + } + + /** + * 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', 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 } + } + + // 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() + } + + // 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 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) + + 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 — stream instead of buffering + const result = storage.createReadStream(storagePath) + if (!result) { + return reply.code(404).send({ error: 'Not found' }) + } + + return reply + .header('Content-Type', getContentType(storagePath)) + .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) => { + if (!checkUsername(request, reply)) return + + const storagePath = getStoragePath(request) + + if (hasDotfile(storagePath)) { + return reply.code(404).send() + } + + const { authorized, error, status } = await checkAuth(request, 'HEAD') + if (!authorized) { + const code = status || 401 + if (code === 401) reply.header('WWW-Authenticate', 'Bearer') + return reply.code(code).send() + } + + const info = await storage.stat(storagePath) + if (!info) { + return reply.code(404).send() + } + + // Conditional HEAD + const cond = checkIfNoneMatchForGet(request.headers['if-none-match'], info.etag) + if (!cond.ok) { + return reply.code(304).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) => { + 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' }) + } + + const storagePath = getStoragePath(request) + + if (hasDotfile(storagePath)) { + return reply.code(403).send({ error: 'Cannot delete dotfiles' }) + } + + const { authorized, error, status } = await checkAuth(request, 'DELETE') + if (!authorized) { + const code = status || 401 + if (code === 401) reply.header('WWW-Authenticate', 'Bearer') + return reply.code(code).send({ error }) + } + + const existing = await storage.stat(storagePath) + if (!existing) { + return reply.code(404).send({ error: 'Not found' }) + } + + // Conditional delete — use shared utility + const ifMatchResult = checkIfMatch(request.headers['if-match'], existing.etag) + if (!ifMatchResult.ok) { + return reply.code(ifMatchResult.status).send({ error: ifMatchResult.error }) + } + + 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() + }) + + fastify.log.info(`remoteStorage enabled for user: ${username}`) +} + +export default remoteStoragePlugin diff --git a/src/server.js b/src/server.js index e1d7748..78aee3a 100644 --- a/src/server.js +++ b/src/server.js @@ -15,6 +15,7 @@ import { isGitRequest, isGitWriteOperation, handleGit } from './handlers/git.js' import { AccessMode } from './wac/parser.js'; import { registerNostrRelay } from './nostr/relay.js'; import { activityPubPlugin, getActorHandler } from './ap/index.js'; +import { remoteStoragePlugin } from './remotestorage.js'; import { dbPlugin } from './db/index.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -234,6 +235,12 @@ export function createServer(options = {}) { }); } + // Register remoteStorage plugin (always on — no flag needed) + fastify.register(remoteStoragePlugin, { + username: singleUserName || 'me', + ownerWebId: null // single-user: any authenticated user can access + }); + // Register MongoDB /db/ route if enabled if (mongoEnabled) { fastify.register(dbPlugin, { mongoUrl, mongoDatabase, singleUser }); @@ -370,6 +377,7 @@ export function createServer(options = {}) { (gitEnabled && isGitRequest(request.url)) || (activitypubEnabled && apPaths.some(p => request.url === p || request.url.startsWith(p + '?'))) || isProfileAP || + request.url.startsWith('/storage/') || (mongoEnabled && (request.url === '/db' || request.url.startsWith('/db/'))) || mashlibPaths.some(p => request.url === p || request.url.startsWith(p + '.'))) { return;