Connect installed Nextcloud and Immich accounts automatically after app setup, integrate their folders and indexed files into Cloud, and support private uploads, permission-checked deletion, and explicit sharing snapshots. Persist disconnect choices and retain advanced account setup. Refine floating navigation, app cards, container icons, scroll fades, private-key dialogs, and federation graph request visibility across desktop and mobile. Validation: cargo check and UI typecheck passed; 38 focused UI tests, 25 adapter tests including disposable Nextcloud/Immich integration, and persistent opt-out test passed. Chromium, Firefox, and WebKit desktop/mobile workflows passed. Requires UI/UX UAT approval from the maintainer before release.
140 lines
9.5 KiB
JavaScript
140 lines
9.5 KiB
JavaScript
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)
|
|
},
|
|
}
|
|
}
|