From 4e16e2f1eb4409c985098fb930a5a7a8f6ff91cd Mon Sep 17 00:00:00 2001 From: Stuart Date: Sat, 19 Sep 2026 14:57:07 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20surface=20every=20NextExplorer=20dr?= =?UTF-8?q?ive=20as=20external=20storage;=2034.0.4:3=20=E2=86=92=2034.0.4:?= =?UTF-8?q?4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NextExplorer's volume root is a list of drives, and #151 surfaced one of them. Its own File Browser import lands in a `FileBrowser` drive, and a service can be pointed at a drive of its own, so files a user was told would show up in Nextcloud never did. Each top-level directory other than `_users` and the hidden ones now gets a `files_external` entry: `Files` as `/NextExplorer`, the rest as `/NextExplorer ()`. The rule mirrors NextExplorer's own drive listing. Only a drive that exists, and that www-data can open, gets one — `Files` included. NextExplorer makes `Files` on its first start and the dependency is only `exists`, so #151 surfaced a never-started NextExplorer as a broken folder; that entry is now removed until the drive is there. Drives are read with one `find` per start. The signature gains a `drives` key only while a multi-drive source is selected, so every other install keeps the signature it has; a NextExplorer install reconciles once on upgrade and changes nothing when its mount is already right. An entry is matched by the directory it points at, so a folder an admin renamed in Nextcloud's settings is kept, and one pointed by hand at `_users` is never taken for ours. An unreadable volume is never read as "no drives": every entry is left alone and the signature unrecorded. Mounts are now imported rather than created, so a restricted one is born restricted instead of open to everyone until a second command narrows it, and applicable users are left alone when already right, since re-applying goes through `--remove-all`. A failed `files_external:list` is a failure, not an empty list, which used to mean a twin of every entry. Also caps the NextExplorer range below 4, and documents that Nextcloud's scoping is the only access control on the mounted files. Verified against nextcloud:34.0.4-apache in Docker by driving the real reconcile through `docker exec`, 21 cases, then on StartOS 0.4.0.2 with a NextExplorer that had never been started (no `Files`, one service-made drive): only that drive surfaced, `files_external:verify` ok, a file written as www-data landed on disk as uid 1000, a plain restart ran no occ, restricting to one user kept the mount id, the File Browser source still mounts, and clearing both removed every entry and no files. Co-Authored-By: Claude Fable 5.1 --- AGENTS.md | 3 +- README.md | 7 +- instructions.md | 2 +- startos/externalStorage.ts | 95 +++++++++++++- startos/main.ts | 254 +++++++++++++++++++++++++++--------- startos/versions/current.ts | 22 +--- 6 files changed, 296 insertions(+), 87 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7b33a38..0a066f2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,7 +37,8 @@ verified, tried, and decided belongs in the commit message and the PR body. - **Comments have to parse.** Nextcloud 34 prepends a banner comment between `)` — its File Browser import lands in a drive of its own, and so can a service pointed at one. The root is never exposed, because it also holds every account's private `_users` tree, hidden directories are skipped as they are in NextExplorer's own drive list, and a directory `www-data` cannot open yet — one another service has just created under its own uid — is left out until it can. **Only a drive that exists gets an entry, `Files` included**: NextExplorer creates it on its first start, and the dependency asks only that NextExplorer be installed, so one that has never run surfaces no `/NextExplorer` folder rather than a broken one. **That mount uses `idmap`** to remap the source's on-disk uid to `www-data`, so Nextcloud simply owns the tree: it reads, writes, and moves files with no permission machinery, and the files it creates land back on disk under the source's own uid so the source can still manage them. ## File Models @@ -172,7 +172,10 @@ Surfaces another StartOS service's files as a folder in Nextcloud Files, using N - **Cost:** seconds, then a restart. - **Repeat safety:** idempotent — the oneshot compares a signature of the desired state against the applied one and does nothing when they match. - **Availability: only while the service is running**, since the per-source user picker reads the live Nextcloud user list. -- **Per-source scoping.** Each source is off, available to all users, or restricted to a chosen set. Clearing a source deletes its `files_external` entry; it does not delete any files. +- **Per-source scoping.** Each source is off, available to all users, or restricted to a chosen set, and every drive of a source follows that one choice. An entry is created already restricted, never open first and narrowed afterwards. Clearing a source deletes its `files_external` entries; it does not delete any files. +- **Nextcloud's scoping is the only access control on the mounted files.** NextExplorer runs with per-account drive access (`USER_VOLUMES`), but that list is NextExplorer's own and does not reach Nextcloud: every Nextcloud user the action selects sees every drive, whichever NextExplorer accounts can see it. +- **Drives are read when the service starts, not while it runs.** A drive that appears later — the File Browser import, a service given a drive of its own — shows up in Files after the next restart; the `external-storage` oneshot runs one `find` per start and touches Nextcloud only when the set of drives has changed. A drive that has gone loses its entry at that same point. **A renamed drive is a new folder to Nextcloud**, which keys a local storage by its directory: favourites, tags and shares made under the old name do not follow it, whereas an entry deleted and created again on the same directory gets all of them back. What a re-created entry does lose is its own settings — a folder name or the sharing option an admin set on it — so a volume that cannot be read leaves every drive's entry as it is, and the next start tries again. +- **A drive's entry is matched by the directory it points at, not by its name**, so a folder renamed in Nextcloud's own External storage settings is kept rather than duplicated. ### Office Suite diff --git a/instructions.md b/instructions.md index 0c4c8ac..1e16285 100644 --- a/instructions.md +++ b/instructions.md @@ -34,7 +34,7 @@ Point a Nextcloud desktop or mobile client (or any WebDAV client) at the **WebDA ### Actions - **Configure** — set the default locale, default phone region, how long deleted files are kept before Nextcloud removes them for good (**Delete Files in Trash** — by default they are kept at least 30 days and then cleared only when disk space runs short, so trash can pile up on a server with room to spare), the UTC start hour of Nextcloud's nightly maintenance window for background jobs, a toggle to stop seeding new user accounts with Nextcloud's default skeleton files (sample documents, photos, README), and **Relay Talk Calls Through Coturn** (see below). -- **External Storage** — surface another StartOS service's storage as a folder in your Nextcloud **Files**, using Nextcloud's built-in External Storage app. The action lists a dropdown for each supported service **you have installed** (**NextExplorer** → a `/NextExplorer` folder showing its Files drive, **FileBrowser Quantum** → a `/FileBrowser` folder). Each dropdown is **Not mounted** (off), **Available to all users**, or **Available to specific users** (which then lets you pick exactly which Nextcloud users see it). The folder is read-write, so you can **move files out of it into Nextcloud**. Nextcloud must be **running** to run this action (it reads your live user list). Files other services add to NextExplorer or FileBrowser Quantum appear automatically when you open the folder — so NextExplorer acts as the shared hub: point any service that should be visible in Nextcloud at NextExplorer. +- **External Storage** — surface another StartOS service's storage as a folder in your Nextcloud **Files**, using Nextcloud's built-in External Storage app. The action lists a dropdown for each supported service **you have installed** (**NextExplorer** → a `/NextExplorer` folder showing its Files drive, plus one folder for each other drive, such as `/NextExplorer (FileBrowser)` for files you imported from File Browser; **FileBrowser Quantum** → a `/FileBrowser` folder). NextExplorer creates its Files drive the first time it starts, so start it once before choosing it here. Each dropdown is **Not mounted** (off), **Available to all users**, or **Available to specific users** (which then lets you pick exactly which Nextcloud users see it). The folder is read-write, so you can **move files out of it into Nextcloud**. Who can see it is decided here, not in NextExplorer: every Nextcloud user you make it available to sees all of NextExplorer's drives, even if you have limited which NextExplorer accounts can open them. Nextcloud looks for drives when it starts, so after adding one — importing from File Browser, or pointing another service at a drive of its own — restart Nextcloud to see it here. If you rename a drive in NextExplorer, Nextcloud sees a new folder after its next restart, so favourites and tags you set under the old name do not carry over; the files themselves are never touched. Nextcloud must be **running** to run this action (it reads your live user list). Files other services add to NextExplorer or FileBrowser Quantum appear automatically when you open the folder — so NextExplorer acts as the shared hub: point any service that should be visible in Nextcloud at NextExplorer. - **Reset Admin Password** — pick an admin user and generate a new random password. Use this if the admin password is lost or you want to rotate it. - **Disable Maintenance Mode** (Maintenance group) — runs `occ maintenance:mode --off`. Brief maintenance mode after an update or restart is normal — wait at least 15 minutes before resorting to this. Nextcloud version updates now run as part of the StartOS update step and roll back cleanly if they fail, so you should rarely need this. - **Disable Non-default Apps** (Maintenance group) — disables every enabled app that Nextcloud does not ship, keeping the bundled set plus Calendar and Contacts. Use this if a third-party app has broken the UI with an Internal Server Error. The result lists what was disabled, and separately anything that could not be, so a single stuck app does not stop the rest. Stable apps must then be re-enabled individually from the Nextcloud Apps page. diff --git a/startos/externalStorage.ts b/startos/externalStorage.ts index ad85af9..99a56c0 100644 --- a/startos/externalStorage.ts +++ b/startos/externalStorage.ts @@ -45,6 +45,14 @@ export type ExternalStorageMeta = { * the Nextcloud Files UI. Also used to find the entry again for deletion. */ ncMountPoint: string + /** + * Set for a source whose volume root is a list of drives rather than one + * tree. Every top-level directory then gets a `files_external` entry of its + * own — the one at `dataDir` under `ncMountPoint`, the others under + * `ncMountPoint ()` — except the names listed here, which must never + * be surfaced. Only drives that exist get one: `dataDir` included. + */ + drives?: { exclude: readonly string[] } /** * Version range the source's StartOS package must satisfy. Floor it at the * release whose on-disk layout this integration mounts (volume id, file @@ -62,11 +70,22 @@ export const externalStorageMeta: Record< packageId: 'nextexplorer', label: 'NextExplorer', mountpoint: '/mnt/nextexplorer', - // The default drive only. The volume root also holds `_users`, one private - // directory per NextExplorer account, which an all-users mount would expose. + // The drive that takes the bare folder name. Never the volume root: it + // also holds `_users`, one private directory per NextExplorer account, + // which a mount would expose. dataDir: '/mnt/nextexplorer/Files', ncMountPoint: '/NextExplorer', - versionRange: '>=2.2.7:0', + // NextExplorer shows every top-level directory as a drive, and they are + // not all `Files`: its File Browser import lands in `FileBrowser`, and a + // service can be pointed at a drive of its own. Nor is `Files` always + // there — NextExplorer makes it when it first starts, and the dependency + // only asks that it be installed. `_users` is the one name NextExplorer + // itself keeps out of that list. + drives: { exclude: ['_users'] }, + // 2.2.7:0, the first published release, has this layout (`data` volume at + // the drive root, `Files` drive, files owned by uid 1000), and 3.x keeps + // it. Capped below 4 rather than a caret, which would exclude 3.x. + versionRange: '>=2.2.7:0 && <4.0.0:0', }, filebrowser: { packageId: 'filebrowser', @@ -79,3 +98,73 @@ export const externalStorageMeta: Record< versionRange: '^2.62.2:1', }, } + +/** The directory a drive's `files_external` entry points at. */ +export const driveDataDir = (meta: ExternalStorageMeta, drive: string) => + `${meta.mountpoint}/${drive}` + +/** The folder a drive appears as in Nextcloud Files. */ +export const driveMountPoint = (meta: ExternalStorageMeta, drive: string) => + driveDataDir(meta, drive) === meta.dataDir + ? meta.ncMountPoint + : `${meta.ncMountPoint} (${drive})` + +/** + * Whether a top-level directory of a source's volume is one of its drives. + * Mirrors NextExplorer's own drive list: nothing hidden, nothing in `exclude`. + */ +function isDrive(meta: ExternalStorageMeta, name: string): boolean { + if (!meta.drives) return false + const exclude: readonly string[] = meta.drives.exclude + return ( + name !== '' && + !name.includes('/') && + !name.startsWith('.') && + !exclude.includes(name) + ) +} + +/** + * The drives of a source to surface, given the names of the directories at the + * top of its volume. Sorted, so the result can go straight into the reconcile + * signature. + */ +export function drivesOf( + meta: ExternalStorageMeta, + topLevelDirs: string[], +): string[] { + return [...new Set(topLevelDirs)].filter((name) => isDrive(meta, name)).sort() +} + +/** The fields of a `files_external:list` row that identify a drive's entry. */ +export type ExternalMountRow = { + storage?: string + configuration?: { datadir?: string } +} + +export const LOCAL_STORAGE = '\\OC\\Files\\Storage\\Local' + +/** The directory a `files_external` entry points at, without a trailing slash. */ +export const mountDataDir = (m: ExternalMountRow) => + String(m.configuration?.datadir ?? '').replace(/\/+$/, '') + +/** + * The drive a `files_external` entry surfaces, or null when it is not one of + * this source's drives — so an entry someone pointed at an excluded or + * hidden directory by hand is never taken for ours. An entry is recognized by + * the directory it points at rather than by its folder name, so one an admin + * renamed in Nextcloud's settings is still found — kept while the drive + * exists, removed once it is gone — instead of being duplicated under the + * default name. + */ +export function driveOf( + meta: ExternalStorageMeta, + m: ExternalMountRow, +): string | null { + if (m.storage !== LOCAL_STORAGE) return null + const dir = mountDataDir(m) + const prefix = `${meta.mountpoint}/` + if (!dir.startsWith(prefix)) return null + const name = dir.slice(prefix.length) + return isDrive(meta, name) ? name : null +} diff --git a/startos/main.ts b/startos/main.ts index 06f0115..439d839 100644 --- a/startos/main.ts +++ b/startos/main.ts @@ -3,8 +3,15 @@ import { manifest as nextexplorerManifest } from 'nextexplorer-startos/startos/m import { T } from '@start9labs/start-sdk' import { EXTERNAL_STORAGE_SOURCES, + ExternalMountRow, ExternalStorageSource, + LOCAL_STORAGE, + driveDataDir, + driveMountPoint, + driveOf, + drivesOf, externalStorageMeta, + mountDataDir, } from './externalStorage' import { configPhp } from './fileModels/config.php' import { @@ -1095,10 +1102,11 @@ function parseTalkTurnApplied(configured: string): { } } -type OccMount = { +type OccMount = ExternalMountRow & { mount_id: number | string mount_point: string applicable_users?: string[] + options?: { filesystem_check_changes?: string | number } } const normMountPoint = (s: string) => s.replace(/^\/+/, '') @@ -1108,9 +1116,11 @@ const normMountPoint = (s: string) => s.replace(/^\/+/, '') * and each source's chosen applicable users. For each selected source it * ensures a `files_external` mount exists and that its applicable-users set * matches that source's selection (empty = all users); for each KNOWN-but- - * unselected source it deletes any matching mount. State is tracked by an - * opaque signature of (sources + per-source users): if it already matches, - * nothing runs; otherwise the full reconcile runs and — only if every + * unselected source it deletes any matching mount. A source whose volume is a + * list of drives gets one mount per drive found there instead, under the same + * users, and loses the mount of a drive that has gone. State is tracked by an opaque + * signature of (sources + per-source users + drives found): if it already + * matches, nothing runs; otherwise the full reconcile runs and — only if every * structural step (enable/create/delete) succeeded — the new signature is * recorded, so a failure retries on the next chain build. Failures are logged * rather than thrown, so one bad source never takes down the whole service. @@ -1127,6 +1137,40 @@ async function reconcileExternalStorage( // The applicable-user signature for a source (sorted, de-duped; [] = all). const usersFor = (id: ExternalStorageSource): string[] => [...new Set(usersBySource[id] ?? [])].sort() + // The drives at the top of each selected multi-drive source's volume. Read + // on every build, ahead of the signature check, because a drive appears and + // disappears with no change to the selection: one `find`, no PHP. Null when + // the volume could not be read, which must never be taken for "no drives" — + // that would delete every drive's mount, and with it the folder name and the + // sharing option an admin may have set on it. + const drives: Partial> = {} + for (const id of enabled) { + const meta = externalStorageMeta[id] + if (!meta.drives) continue + const res = await subc.exec( + // Only what www-data can open: a drive another service has just made + // under its own uid would otherwise surface as a broken folder. + // prettier-ignore + [ + 'find', meta.mountpoint, + '-mindepth', '1', '-maxdepth', '1', + '-type', 'd', '-readable', '-executable', + '-printf', '%f\\0', + ], + { user: 'www-data' }, + ) + if (res.exitCode === 0) { + drives[id] = drivesOf(meta, res.stdout.toString().split('\0')) + } else { + drives[id] = null + console.error( + `external-storage: could not read the drives of ${id}: ${res.stderr.toString()}`, + ) + } + } + const foundDrives = Object.fromEntries( + enabled.flatMap((id) => (drives[id] ? [[id, drives[id]]] : [])), + ) // `v` bumps whenever the applied semantics change, so an existing install // re-reconciles once on upgrade even if the selection itself is unchanged // (v2: fixed applicable-users handling — see applyApplicable). @@ -1134,6 +1178,9 @@ async function reconcileExternalStorage( v: 2, sources: enabled, users: Object.fromEntries(enabled.map((id) => [id, usersFor(id)])), + // Present only with a multi-drive source selected, so every other install + // keeps the signature it already recorded. + ...(Object.keys(foundDrives).length ? { drives: foundDrives } : {}), }) // Treat a never-written signature ('') as "nothing selected" so a fresh // install with no selection short-circuits with zero occ calls (and never @@ -1144,14 +1191,19 @@ async function reconcileExternalStorage( const occ = (args: string[]) => subc.exec(['php', 'occ', ...args], { user: 'www-data' }) - const listMounts = async (): Promise => { + // Null when the listing failed. Never an empty list: "no mounts" would make + // the caller create a twin of every entry and skip every removal. + const listMounts = async (): Promise => { const res = await occ(['files_external:list', '--output=json']) - if (res.exitCode !== 0) return [] try { - return JSON.parse(res.stdout.toString()) as OccMount[] - } catch { - return [] - } + if (res.exitCode === 0) { + return JSON.parse(res.stdout.toString()) as OccMount[] + } + } catch {} + console.error( + `external-storage: could not list mounts: ${res.stderr.toString()}`, + ) + return null } const matchingMounts = (mounts: OccMount[], ncMountPoint: string) => mounts.filter( @@ -1216,69 +1268,143 @@ async function reconcileExternalStorage( } } + // Make sure a mount exists — `find` picks it out of the current list — with + // the wanted applicable users and the rescan option. False when a structural + // step failed. + const ensureMount = async ( + find: (mounts: OccMount[]) => OccMount | undefined, + ncMountPoint: string, + dataDir: string, + wanted: string[], + ): Promise => { + const listed = await listMounts() + if (!listed) return false + const mount = find(listed) + const to = wanted.length ? wanted.join(', ') : 'all users' + if (!mount) { + // Imported rather than created, so the mount is born with its users and + // its options: `files_external:create` makes it available to everyone + // until a second command narrows it. + const create = await subc.exec( + ['php', 'occ', 'files_external:import', '-'], + { + user: 'www-data', + input: JSON.stringify([ + { + mount_point: ncMountPoint, + storage: LOCAL_STORAGE, + authentication_type: 'null::null', + configuration: { datadir: dataDir }, + // Rescan on access so out-of-band writes by the source service + // appear in Nextcloud without a manual file scan. + options: { filesystem_check_changes: 1 }, + applicable_users: wanted, + applicable_groups: [], + }, + ]), + }, + ) + if (create.exitCode !== 0) { + console.error( + `external-storage: failed to create ${ncMountPoint}: ${create.stdout.toString()} ${create.stderr.toString()}`, + ) + return false + } + console.info(`external-storage: ${ncMountPoint} available to ${to}`) + return true + } + const mountId = String(mount.mount_id) + // Left alone when already right: re-applying a restricted set goes through + // `--remove-all`, which opens the mount to everyone until the users are + // added back. + const current = [...(mount.applicable_users ?? [])].sort() + if (JSON.stringify(current) !== JSON.stringify(wanted)) { + await applyApplicable(mountId, current, wanted) + console.info( + `external-storage: ${ncMountPoint} (mount ${mountId}) available to ${to}`, + ) + } + if (String(mount.options?.filesystem_check_changes ?? '') !== '1') { + await occ([ + 'files_external:option', + mountId, + 'filesystem_check_changes', + '1', + ]) + } + return true + } + + // Removes the mount only; the files it pointed at are never touched. + const deleteMount = async (m: OccMount): Promise => { + const del = await occ([ + 'files_external:delete', + String(m.mount_id), + '--yes', + ]) + if (del.exitCode !== 0) { + console.error( + `external-storage: failed to delete ${m.mount_point} (id ${m.mount_id}): ${del.stderr.toString()}`, + ) + } + return del.exitCode === 0 + } + // Walk every KNOWN source so removals are handled without remembering the // previous selection: selected → ensure + set applicable; unselected → delete. for (const id of EXTERNAL_STORAGE_SOURCES) { if (abort.aborted) return - const { ncMountPoint, dataDir } = externalStorageMeta[id] + const meta = externalStorageMeta[id] + const { ncMountPoint, dataDir } = meta + const selected = desired.includes(id) try { - if (desired.includes(id)) { - let mount = matchingMounts(await listMounts(), ncMountPoint)[0] - if (!mount) { - const create = await occ([ - 'files_external:create', + if (!meta.drives) { + // One tree, one mount, known by its folder name. + if (selected) { + const ok = await ensureMount( + (mounts) => matchingMounts(mounts, ncMountPoint)[0], ncMountPoint, - 'local', - 'null::null', - '-c', - `datadir=${dataDir}`, - ]) - if (create.exitCode !== 0) { - allOk = false - console.error( - `external-storage: failed to create ${ncMountPoint}: ${create.stdout.toString()} ${create.stderr.toString()}`, - ) - continue - } - mount = matchingMounts(await listMounts(), ncMountPoint)[0] - } - if (!mount) { - allOk = false - console.error( - `external-storage: created ${ncMountPoint} but could not resolve its mount id`, + dataDir, + usersFor(id), ) - continue - } - const mountId = String(mount.mount_id) - const wanted = usersFor(id) - await applyApplicable(mountId, mount.applicable_users ?? [], wanted) - console.info( - `external-storage: ${ncMountPoint} (mount ${mountId}) available to ${ - wanted.length ? wanted.join(', ') : 'all users' - }`, - ) - // Rescan on access so out-of-band writes by the source service appear - // in Nextcloud without a manual file scan. - await occ([ - 'files_external:option', - mountId, - 'filesystem_check_changes', - '1', - ]) - } else { - for (const m of matchingMounts(await listMounts(), ncMountPoint)) { - const del = await occ([ - 'files_external:delete', - String(m.mount_id), - '--yes', - ]) - if (del.exitCode !== 0) { - allOk = false - console.error( - `external-storage: failed to delete ${ncMountPoint} (id ${m.mount_id}): ${del.stderr.toString()}`, - ) + if (!ok) allOk = false + } else { + const listed = await listMounts() + if (!listed) allOk = false + for (const m of matchingMounts(listed ?? [], ncMountPoint)) { + if (!(await deleteMount(m))) allOk = false } } + continue + } + + // One mount per drive found, under the source's own users; none once the + // source is cleared. Only a drive that exists gets one — `dataDir` too, + // or a source that has never started would surface as a broken folder. + // An unreadable volume leaves every mount as it is and the signature + // unrecorded, so the next build tries again. + const found = selected ? drives[id] : [] + if (found == null) { + allOk = false + continue + } + for (const drive of found) { + if (abort.aborted) return + const dir = driveDataDir(meta, drive) + const ok = await ensureMount( + (mounts) => mounts.find((m) => mountDataDir(m) === dir), + driveMountPoint(meta, drive), + dir, + usersFor(id), + ) + if (!ok) allOk = false + } + const listed = await listMounts() + if (!listed) allOk = false + for (const m of listed ?? []) { + const drive = driveOf(meta, m) + if (drive === null || found.includes(drive)) continue + if (!(await deleteMount(m))) allOk = false } } catch (e) { allOk = false diff --git a/startos/versions/current.ts b/startos/versions/current.ts index bda02e6..d9458e5 100644 --- a/startos/versions/current.ts +++ b/startos/versions/current.ts @@ -1,23 +1,13 @@ import { IMPOSSIBLE, VersionInfo } from '@start9labs/start-sdk' export const current = VersionInfo.of({ - version: '34.0.4:3', + version: '34.0.4:4', releaseNotes: { - en_US: `**External Storage** can now surface NextExplorer, the recommended file server: its Files drive appears as a NextExplorer folder in Nextcloud Files. - -An update that would skip a Nextcloud major version is refused before anything on disk changes. Install the Nextcloud 33 release from the version list first, then update again — this also works on a server where an earlier attempt at this update failed.`, - es_ES: `**Almacenamiento externo** ahora puede mostrar NextExplorer, el servidor de archivos recomendado: su unidad Files aparece como una carpeta NextExplorer en Archivos de Nextcloud. - -Una actualización que saltaría una versión mayor de Nextcloud se rechaza antes de que cambie nada en el disco. Instale primero la versión con Nextcloud 33 desde la lista de versiones y vuelva a actualizar; esto también funciona en un servidor donde un intento anterior de esta actualización falló.`, - de_DE: `**Externer Speicher** kann jetzt NextExplorer einbinden, den empfohlenen Dateiserver: sein Laufwerk Files erscheint als Ordner NextExplorer in Nextcloud Dateien. - -Ein Update, das eine Nextcloud-Hauptversion überspringen würde, wird abgelehnt, bevor sich etwas auf der Festplatte ändert. Installieren Sie zuerst die Version mit Nextcloud 33 aus der Versionsliste und aktualisieren Sie dann erneut — das funktioniert auch auf einem Server, auf dem ein früherer Versuch dieses Updates fehlgeschlagen ist.`, - pl_PL: `**Magazyn zewnętrzny** może teraz pokazać NextExplorer, zalecany serwer plików: jego dysk Files pojawia się jako folder NextExplorer w Plikach Nextcloud. - -Aktualizacja, która pominęłaby główną wersję Nextcloud, jest odrzucana, zanim cokolwiek zmieni się na dysku. Najpierw zainstaluj wydanie z Nextcloud 33 z listy wersji, a następnie zaktualizuj ponownie — działa to również na serwerze, na którym wcześniejsza próba tej aktualizacji się nie powiodła.`, - fr_FR: `**Stockage externe** peut désormais présenter NextExplorer, le serveur de fichiers recommandé : son lecteur Files apparaît comme un dossier NextExplorer dans Fichiers de Nextcloud. - -Une mise à jour qui sauterait une version majeure de Nextcloud est refusée avant que quoi que ce soit ne change sur le disque. Installez d'abord la version avec Nextcloud 33 depuis la liste des versions, puis mettez à jour à nouveau — cela fonctionne aussi sur un serveur où une tentative précédente de cette mise à jour a échoué.`, + en_US: `**External Storage** now shows every NextExplorer drive, not only Files: each other drive appears as its own folder, such as NextExplorer (FileBrowser) for files imported from File Browser. Nextcloud looks for drives when it starts, so restart it after adding one.`, + es_ES: `**Almacenamiento externo** ahora muestra todas las unidades de NextExplorer, no solo Files: cada una de las demás aparece como su propia carpeta, por ejemplo NextExplorer (FileBrowser) para los archivos importados desde File Browser. Nextcloud busca las unidades al iniciarse, así que reinícielo después de añadir una.`, + de_DE: `**Externer Speicher** zeigt jetzt alle Laufwerke von NextExplorer an, nicht nur Files: jedes weitere Laufwerk erscheint als eigener Ordner, zum Beispiel NextExplorer (FileBrowser) für die aus File Browser importierten Dateien. Nextcloud sucht beim Start nach Laufwerken — starten Sie es also neu, nachdem Sie eines hinzugefügt haben.`, + pl_PL: `**Magazyn zewnętrzny** pokazuje teraz wszystkie dyski NextExplorer, a nie tylko Files: każdy kolejny dysk pojawia się jako osobny folder, na przykład NextExplorer (FileBrowser) dla plików zaimportowanych z File Browser. Nextcloud szuka dysków podczas uruchamiania, więc uruchom go ponownie po dodaniu nowego.`, + fr_FR: `**Stockage externe** affiche désormais tous les lecteurs de NextExplorer, et plus seulement Files : chaque autre lecteur apparaît comme un dossier à part, par exemple NextExplorer (FileBrowser) pour les fichiers importés depuis File Browser. Nextcloud recherche les lecteurs à son démarrage : redémarrez-le après en avoir ajouté un.`, }, migrations: { up: async () => {}, From e3f60195ffacf9874a889e04ce8c8f333865739e Mon Sep 17 00:00:00 2001 From: Helix <267227783+helix-nine@users.noreply.github.com> Date: Sat, 19 Sep 2026 15:29:15 +0000 Subject: [PATCH 2/2] fix: reconcile external storage access exactly Track applicable groups as well as users, update restrictions without opening the mount globally, and leave the applied signature stale whenever an access or option command fails so the next start retries it. Only adopt local-storage entries that point at the expected drive. Helix-Harness: pi Helix-Model: openai-codex/gpt-5.6-sol --- instructions.md | 2 +- startos/externalStorage.ts | 3 +- startos/fileModels/store.json.ts | 9 ++- startos/main.ts | 125 ++++++++++++++++++------------- 4 files changed, 78 insertions(+), 61 deletions(-) diff --git a/instructions.md b/instructions.md index 1e16285..65ab6c6 100644 --- a/instructions.md +++ b/instructions.md @@ -34,7 +34,7 @@ Point a Nextcloud desktop or mobile client (or any WebDAV client) at the **WebDA ### Actions - **Configure** — set the default locale, default phone region, how long deleted files are kept before Nextcloud removes them for good (**Delete Files in Trash** — by default they are kept at least 30 days and then cleared only when disk space runs short, so trash can pile up on a server with room to spare), the UTC start hour of Nextcloud's nightly maintenance window for background jobs, a toggle to stop seeding new user accounts with Nextcloud's default skeleton files (sample documents, photos, README), and **Relay Talk Calls Through Coturn** (see below). -- **External Storage** — surface another StartOS service's storage as a folder in your Nextcloud **Files**, using Nextcloud's built-in External Storage app. The action lists a dropdown for each supported service **you have installed** (**NextExplorer** → a `/NextExplorer` folder showing its Files drive, plus one folder for each other drive, such as `/NextExplorer (FileBrowser)` for files you imported from File Browser; **FileBrowser Quantum** → a `/FileBrowser` folder). NextExplorer creates its Files drive the first time it starts, so start it once before choosing it here. Each dropdown is **Not mounted** (off), **Available to all users**, or **Available to specific users** (which then lets you pick exactly which Nextcloud users see it). The folder is read-write, so you can **move files out of it into Nextcloud**. Who can see it is decided here, not in NextExplorer: every Nextcloud user you make it available to sees all of NextExplorer's drives, even if you have limited which NextExplorer accounts can open them. Nextcloud looks for drives when it starts, so after adding one — importing from File Browser, or pointing another service at a drive of its own — restart Nextcloud to see it here. If you rename a drive in NextExplorer, Nextcloud sees a new folder after its next restart, so favourites and tags you set under the old name do not carry over; the files themselves are never touched. Nextcloud must be **running** to run this action (it reads your live user list). Files other services add to NextExplorer or FileBrowser Quantum appear automatically when you open the folder — so NextExplorer acts as the shared hub: point any service that should be visible in Nextcloud at NextExplorer. +- **External Storage** — surface another StartOS service's storage as a folder in your Nextcloud **Files**, using Nextcloud's built-in External Storage app. The action lists a dropdown for each supported service **you have installed** (**NextExplorer** → a `/NextExplorer` folder showing its Files drive, plus one folder for each other drive, such as `/NextExplorer (FileBrowser)` for files you imported from File Browser; **FileBrowser Quantum** → a `/FileBrowser` folder). NextExplorer creates its Files drive the first time it starts, so start it once if you want that folder to appear here. Each dropdown is **Not mounted** (off), **Available to all users**, or **Available to specific users** (which then lets you pick exactly which Nextcloud users see it). The folder is read-write, so you can **move files out of it into Nextcloud**. Who can see it is decided here, not in NextExplorer: every Nextcloud user you make it available to sees all of NextExplorer's drives, even if you have limited which NextExplorer accounts can open them. Nextcloud looks for drives when it starts, so after adding one — importing from File Browser, or pointing another service at a drive of its own — restart Nextcloud to see it here. If you rename a drive in NextExplorer, Nextcloud sees a new folder after its next restart, so favourites and tags you set under the old name do not carry over; the files themselves are never touched. Nextcloud must be **running** to run this action (it reads your live user list). Files other services add to NextExplorer or FileBrowser Quantum appear automatically when you open the folder — so NextExplorer acts as the shared hub: point any service that should be visible in Nextcloud at NextExplorer. - **Reset Admin Password** — pick an admin user and generate a new random password. Use this if the admin password is lost or you want to rotate it. - **Disable Maintenance Mode** (Maintenance group) — runs `occ maintenance:mode --off`. Brief maintenance mode after an update or restart is normal — wait at least 15 minutes before resorting to this. Nextcloud version updates now run as part of the StartOS update step and roll back cleanly if they fail, so you should rarely need this. - **Disable Non-default Apps** (Maintenance group) — disables every enabled app that Nextcloud does not ship, keeping the bundled set plus Calendar and Contacts. Use this if a third-party app has broken the UI with an Internal Server Error. The result lists what was disabled, and separately anything that could not be, so a single stuck app does not stop the rest. Stable apps must then be re-enabled individually from the Nextcloud Apps page. diff --git a/startos/externalStorage.ts b/startos/externalStorage.ts index 99a56c0..ccfdd5c 100644 --- a/startos/externalStorage.ts +++ b/startos/externalStorage.ts @@ -56,8 +56,7 @@ export type ExternalStorageMeta = { /** * Version range the source's StartOS package must satisfy. Floor it at the * release whose on-disk layout this integration mounts (volume id, file - * ownership), with a caret so a future major restructure isn't silently - * claimed compatible. + * ownership), and cap it below the next incompatible major. */ versionRange: string } diff --git a/startos/fileModels/store.json.ts b/startos/fileModels/store.json.ts index 90df39e..27ef6ef 100644 --- a/startos/fileModels/store.json.ts +++ b/startos/fileModels/store.json.ts @@ -36,7 +36,7 @@ const shape = z.object({ .catch([]) .default([]), // Per-source applicable users: source id -> Nextcloud user list. An empty or - // absent list for a selected source means "all users" (occ `--add-all`). + // absent list for a selected source means "all users". // Written by the `external-storage` action, read reactively in setupMain so a // change reconfigures just that source's mount applicability. externalStorageUsers: z @@ -44,9 +44,10 @@ const shape = z.object({ .catch({}) .default({}), // ACTUAL state: an opaque signature of the last successfully-applied config - // (selected sources + applicable users). The reconcile oneshot compares it to - // the desired signature and only does occ work when they differ, then writes - // the new signature. Read NON-reactively (`.once()`) in setupMain — like + // (selected sources + applicable users + discovered drives). The reconcile + // oneshot compares it to the desired signature and only does occ work when + // they differ, then writes the new signature. Read NON-reactively (`.once()`) + // in setupMain — like // `actions.completed`, so the oneshot's write never triggers a chain rebuild. // A plain string so FileHelper.merge replaces it wholesale. externalStoragesConfigured: z.string().catch('').default(''), diff --git a/startos/main.ts b/startos/main.ts index 439d839..655716e 100644 --- a/startos/main.ts +++ b/startos/main.ts @@ -11,7 +11,6 @@ import { driveOf, drivesOf, externalStorageMeta, - mountDataDir, } from './externalStorage' import { configPhp } from './fileModels/config.php' import { @@ -1106,6 +1105,7 @@ type OccMount = ExternalMountRow & { mount_id: number | string mount_point: string applicable_users?: string[] + applicable_groups?: string[] options?: { filesystem_check_changes?: string | number } } @@ -1118,12 +1118,12 @@ const normMountPoint = (s: string) => s.replace(/^\/+/, '') * matches that source's selection (empty = all users); for each KNOWN-but- * unselected source it deletes any matching mount. A source whose volume is a * list of drives gets one mount per drive found there instead, under the same - * users, and loses the mount of a drive that has gone. State is tracked by an opaque - * signature of (sources + per-source users + drives found): if it already + * users, and loses the mount of a drive that has gone. State is tracked by an + * opaque signature of (sources + per-source users + drives found): if it already * matches, nothing runs; otherwise the full reconcile runs and — only if every - * structural step (enable/create/delete) succeeded — the new signature is - * recorded, so a failure retries on the next chain build. Failures are logged - * rather than thrown, so one bad source never takes down the whole service. + * step succeeded — the new signature is recorded, so a failure retries on the + * next chain build. Failures are logged rather than thrown, so one bad source + * never takes down the whole service. */ async function reconcileExternalStorage( subc: Awaited>, @@ -1212,46 +1212,51 @@ async function reconcileExternalStorage( normMountPoint(ncMountPoint), ) - // Bring a mount's applicable users in line with `desiredUsers`. - // - // Nextcloud semantics: a system mount with NO applicable users (and no - // group/global entry) is available to ALL users; adding any user entry - // restricts it to exactly those users. So: - // - empty desiredUsers -> remove every currently-applicable user, leaving - // it empty == available to all. - // - non-empty -> `--remove-all` first (it clears the global flag - // AND every existing user/group entry), THEN add - // every desired user. We must re-add ALL of them, - // including ones that were already in `current`, - // because --remove-all just dropped them. (Skipping - // "already-current" users was the cross-source bug: - // a kept user got wiped and never re-added, so the - // mount fell back to "available to everyone".) - // Per-user `--add-user` calls keep this resilient to a user deleted in - // Nextcloud since the selection was made (only that user's call fails). + // A system mount with no applicable users or groups is available to all. + // Add wanted users before removing stale entries, so a failed grant never + // clears the restrictions that were already in place. const applyApplicable = async ( mountId: string, - current: string[], + currentUsers: string[], + currentGroups: string[], desiredUsers: string[], - ) => { + ): Promise => { const applicable = (...args: string[]) => occ(['files_external:applicable', mountId, ...args]) + const update = async (args: string[], description: string) => { + const result = await applicable(...args) + if (result.exitCode !== 0) { + console.error( + `external-storage: could not ${description} on mount ${mountId}: ${result.stderr.toString()}`, + ) + return false + } + return true + } - if (desiredUsers.length === 0) { - // Available to all users == no specific applicable users. - for (const u of current) await applicable('--remove-user', u) - } else { - // Restrict to exactly `desiredUsers`. - await applicable('--remove-all') - for (const u of desiredUsers) { - const r = await applicable('--add-user', u) - if (r.exitCode !== 0) { - console.error( - `external-storage: could not grant mount ${mountId} to user "${u}" (deleted in Nextcloud?): ${r.stderr.toString()}`, - ) - } + const missing = desiredUsers.filter((user) => !currentUsers.includes(user)) + for (const user of missing) { + if (!(await update(['--add-user', user], `grant user "${user}"`))) { + return false } } + + let ok = true + for (const user of currentUsers.filter( + (current) => !desiredUsers.includes(current), + )) { + if (!(await update(['--remove-user', user], `remove user "${user}"`))) { + ok = false + } + } + for (const group of currentGroups) { + if ( + !(await update(['--remove-group', group], `remove group "${group}"`)) + ) { + ok = false + } + } + return ok } let allOk = true @@ -1269,8 +1274,7 @@ async function reconcileExternalStorage( } // Make sure a mount exists — `find` picks it out of the current list — with - // the wanted applicable users and the rescan option. False when a structural - // step failed. + // the wanted applicable users and the rescan option. False when a step failed. const ensureMount = async ( find: (mounts: OccMount[]) => OccMount | undefined, ncMountPoint: string, @@ -1314,25 +1318,38 @@ async function reconcileExternalStorage( return true } const mountId = String(mount.mount_id) - // Left alone when already right: re-applying a restricted set goes through - // `--remove-all`, which opens the mount to everyone until the users are - // added back. - const current = [...(mount.applicable_users ?? [])].sort() - if (JSON.stringify(current) !== JSON.stringify(wanted)) { - await applyApplicable(mountId, current, wanted) - console.info( - `external-storage: ${ncMountPoint} (mount ${mountId}) available to ${to}`, - ) + const currentUsers = [...(mount.applicable_users ?? [])].sort() + const currentGroups = [...(mount.applicable_groups ?? [])].sort() + let ok = true + if ( + JSON.stringify(currentUsers) !== JSON.stringify(wanted) || + currentGroups.length > 0 + ) { + if ( + !(await applyApplicable(mountId, currentUsers, currentGroups, wanted)) + ) { + ok = false + } else { + console.info( + `external-storage: ${ncMountPoint} (mount ${mountId}) available to ${to}`, + ) + } } if (String(mount.options?.filesystem_check_changes ?? '') !== '1') { - await occ([ + const option = await occ([ 'files_external:option', mountId, 'filesystem_check_changes', '1', ]) + if (option.exitCode !== 0) { + ok = false + console.error( + `external-storage: could not enable change detection on mount ${mountId}: ${option.stderr.toString()}`, + ) + } } - return true + return ok } // Removes the mount only; the files it pointed at are never touched. @@ -1392,7 +1409,7 @@ async function reconcileExternalStorage( if (abort.aborted) return const dir = driveDataDir(meta, drive) const ok = await ensureMount( - (mounts) => mounts.find((m) => mountDataDir(m) === dir), + (mounts) => mounts.find((m) => driveOf(meta, m) === drive), driveMountPoint(meta, drive), dir, usersFor(id), @@ -1413,8 +1430,8 @@ async function reconcileExternalStorage( } if (abort.aborted) return - // Record the new signature only if every structural step succeeded, so a - // failure retries next build. Written non-reactively (setupMain reads + // Record the new signature only if every step succeeded, so a failure retries + // next build. Written non-reactively (setupMain reads // externalStorages / externalStorageUsers, not this field, reactively) so the // write never rebuilds the chain. Plain string → merge replaces it wholesale. if (allOk) {