import crypto from 'node:crypto' import { statSync, existsSync, mkdtempSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' // Connected-account fixtures for the mock server only. Never reads credentials // or contacts Nextcloud/Immich; each demo visitor has their own connection state. export function createCloudSourcesMock(library) { const files = library.filter(item => item.disk && existsSync(item.disk)) const identity = kind => ({ connection_id: kind === 'nextcloud' ? 'demo-nextcloud' : 'demo-immich', kind, installation: `demo-${kind}-installation`, account_id: 'demo', }) const stateFor = store => store.cloudSources ??= { sources: ['nextcloud', 'immich'].map(kind => { const source = identity(kind) return { connection_id: source.connection_id, identity: source, error: null } }), handles: {}, shares: [], deleted: [], uploads: [], } const category = item => item.mime_type.startsWith('image/') ? 'Photos' : item.mime_type.startsWith('audio/') ? 'Music' : 'Documents' const revision = value => crypto.createHash('sha256').update(value).digest('hex') const albums = [{ id: 'landscapes', name: 'Landscapes & travels' }, { id: 'city', name: 'City nights' }] const fileItem = (item, source) => ({ source, upstream_id: item.id, locator: source.kind === 'nextcloud' ? (item.locator ?? [category(item), item.filename]) : null, name: item.filename, mime_type: item.mime_type, size: statSync(item.disk).size, revision: revision(item.id), directory: false, preview: item.mime_type.startsWith('image/'), preview_kind: item.mime_type.startsWith('image/') ? 'image' : null, download: true, deletable: true, }) return { rpc(method, params = {}, store) { const state = stateFor(store) if (method === 'content.list-mine') return { items: state.shares } if (method === 'content.remove') { state.shares = state.shares.filter(item => item.id !== params.id); return {} } if (method === 'content.configure') { const item = state.shares.find(item => item.id === params.id) if (!item) throw new Error('Share not found.') Object.assign(item, { access: params.access === 'paid' ? { paid: { price_sats: params.price_sats, accepted: params.accepted_methods } } : params.access, availability: params.availability }) return { item } } if (method === 'cloud.sources.publish') { const descriptor = params.source const source = state.sources.find(item => item.connection_id === descriptor?.connection_id) const file = [...files, ...state.uploads.filter(item => item.connection_id === descriptor?.connection_id)].find(item => item.id === descriptor?.upstream_id && !state.deleted.includes(descriptor?.connection_id + ':' + item.id)) if (!source || !file || descriptor.revision !== revision(file.id)) throw new Error('Refresh the source file before sharing.') if (!params.access || !params.availability) throw new Error('Sharing policy required.') const item = { id: crypto.randomUUID(), filename: params.filename, mime_type: file.mime_type, size_bytes: statSync(file.disk).size, access: params.access === 'paid' ? { paid: { price_sats: params.price_sats, accepted: params.accepted_methods } } : params.access, availability: params.availability } state.shares = state.shares.filter(saved => saved.filename !== item.filename); state.shares.push(item) return { item } } if (method === 'cloud.sources.discover') { if (params.kind && ['nextcloud', 'immich'].includes(params.kind)) { const source = identity(params.kind) if (!state.sources.some(item => item.connection_id === source.connection_id)) state.sources.push({ connection_id: source.connection_id, identity: source, error: null }) } return { sources: state.sources, setup_issues: [] } } if (method === 'cloud.sources.list') return { sources: state.sources } if (method === 'cloud.sources.connect') { const kind = params.credential?.kind if (!['nextcloud', 'immich'].includes(kind)) throw new Error('Choose Nextcloud or Immich.') const source = identity(kind) if (!state.sources.some(item => item.connection_id === source.connection_id)) { state.sources.push({ connection_id: source.connection_id, identity: source, error: null }) } return { source } } if (method === 'cloud.sources.release-stream') { delete state.handles[params.handle] return {} } const source = state.sources.find(item => item.connection_id === params.connection_id)?.identity if (!source) throw new Error('This demo connection is no longer available.') if (method === 'cloud.sources.disconnect') { state.sources = state.sources.filter(item => item.connection_id !== source.connection_id) for (const [handle, entry] of Object.entries(state.handles)) { if (entry.connection_id === source.connection_id) delete state.handles[handle] } return {} } const available = [...files, ...state.uploads.filter(item => item.connection_id === source.connection_id)].filter(item => !state.deleted.includes(source.connection_id + ':' + item.id)) if (method === 'cloud.sources.delete') { const item = available.find(item => item.id === params.upstream_id) if (!item || params.revision !== revision(item.id)) throw new Error('Refresh this file before deleting.') state.deleted.push(source.connection_id + ':' + item.id) for (const [handle, entry] of Object.entries(state.handles)) if (entry.connection_id === source.connection_id && entry.item.id === item.id) delete state.handles[handle] return { deleted: true } } if (method === 'cloud.sources.browse') { let items if (source.kind === 'nextcloud') { const folder = params.folder ?? [] items = folder.length === 0 ? ['Documents', 'Photos', 'Music'].map(name => ({ source, upstream_id: `folder-${name}`, locator: [name], name, mime_type: null, size: null, revision: revision(name), directory: true, preview: false, download: false })) : [] items.push(...available.filter(item => JSON.stringify((item.locator ?? [category(item), item.filename]).slice(0, -1)) === JSON.stringify(folder)).map(item => fileItem(item, source))) } else { items = available.filter(item => item.mime_type.startsWith('image/') || item.mime_type.startsWith('video/')) .filter(item => !params.album_id || (params.album_id === 'city' ? item.id === 'photo-city-neon' : params.album_id === 'landscapes' && item.id !== 'photo-city-neon')) .map(item => fileItem(item, source)) } const page = Math.max(1, Number(params.page) || 1), pageSize = 6 return { upload: true, items: items.slice((page - 1) * pageSize, page * pageSize), next_page: items.length > page * pageSize ? page + 1 : null, listing_revision: revision(items.map(item => item.upstream_id).join(',')), albums: source.kind === 'immich' ? albums : null } } if (method === 'cloud.sources.prepare-stream') { const item = available.find(item => item.id === params.upstream_id && (source.kind === 'nextcloud' || item.mime_type.startsWith('image/'))) if (!item || params.revision !== revision(item.id)) throw new Error('Refresh this demo library and try again.') const handle = crypto.randomUUID() state.handles[handle] = { item, connection_id: source.connection_id, download: !!params.download, expires: Date.now() + 5 * 60 * 1000 } return { url: `/api/blob/cloud-source/${handle}` } } throw new Error('Unknown demo cloud source method.') }, upload(req, res, store) { try { const request = JSON.parse(req.get('x-cloud-upload') || '{}'), state = stateFor(store) const source = state.sources.find(item => item.connection_id === request.connection_id)?.identity if (!source || !Array.isArray(request.folder) || typeof request.name !== 'string' || !request.name || /[\/\\\x00-\x1f]/.test(request.name)) throw new Error('Invalid upload') if (source.kind === 'immich' && !/^(image|video)\//.test(req.get('content-type') || '')) throw new Error('Immich accepts photos and videos.') const locator = [...request.folder, request.name] const available = [...files, ...state.uploads.filter(item => item.connection_id === source.connection_id)].filter(item => !state.deleted.includes(source.connection_id + ':' + item.id)) if (source.kind === 'nextcloud' && available.some(item => JSON.stringify(item.locator ?? [category(item), item.filename]) === JSON.stringify(locator))) throw new Error('A file with this name already exists.') const directory = mkdtempSync(join(tmpdir(), 'archy-cloud-demo-')), disk = join(directory, 'file') writeFileSync(disk, req.body, { mode: 0o600 }) state.uploads.push({ id: crypto.randomUUID(), connection_id: source.connection_id, filename: request.name, mime_type: req.get('content-type') || 'application/octet-stream', disk, locator }) res.status(201).json({ uploaded: true }) } catch (error) { res.status(409).send(error.message) } }, stream(req, res, store) { const state = stateFor(store), entry = state.handles[req.params.handle] if (!entry || entry.expires < Date.now()) return res.sendStatus(404) res.set('Cache-Control', 'private, no-store') if (entry.download) res.attachment(entry.item.filename) res.type(entry.item.mime_type).sendFile(entry.item.disk) }, } }