Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<?php` and `$CONFIG` on every config write.
- **A `config.php` number the double cannot print back exactly is carried through as source text too**, so `9223372036854775807`, `1.0` and `1.0E+30` survive a write. Ordinary values stay real numbers, which is what keeps `maintenance_window_start` and `redis.port` reaching their validators — do not widen this into writing every integer as a float.
- **The external-storage mounts' `idmap` (uid 1000 → `www-data` 33) is what makes the integration work at all**, and it needs StartOS 0.4.0-beta.10+. Files other services drop into NextExplorer's or FileBrowser Quantum's volume under a different uid surface as `nobody` until those services idmap their own mount to 1000 too.
- **Adding an external-storage source is a registry edit in `startos/externalStorage.ts` plus a typed mount.** NextExplorer is the shared hub most services route through, so a direct source is worth adding only for a service whose files live browsably on its own volume. Keep NextExplorer's `dataDir` on the `Files` drive: its volume root also holds every account's private `_users` tree.
- **Adding an external-storage source is a registry edit in `startos/externalStorage.ts` plus a typed mount.** NextExplorer is the shared hub most services route through, so a direct source is worth adding only for a service whose files live browsably on its own volume. NextExplorer's volume root is a list of drives, so it is surfaced one `files_external` entry per drive that exists and never as the root, which also holds every account's private `_users` tree. `Files` is a drive like the rest — it does not exist until NextExplorer has started once, and the dependency is only `exists` — so never create its entry unconditionally: a missing directory surfaces as a broken folder.
- **An unreadable external-storage volume must never be read as "no drives".** The reconcile deletes the entry of a drive that has gone, and a deleted entry takes its own settings with it — the folder name and the sharing option an admin set — and hides the folder until a later start puts it back. So a failed listing leaves every entry alone and the signature unrecorded. File ids, and the favourites, tags and shares hanging off them, are keyed by the directory and survive a delete-and-create; a renamed drive is a new directory and keeps none of them. Match a drive's entry by the directory it points at rather than by name: an admin can rename the folder in Nextcloud's settings, and a name match would then create a twin.
- **`richdocuments` drops the Microsoft formats the moment a second office connector is enabled** (`CapabilitiesService::hasOtherOOXMLApps` checks `onlyoffice` and `officeonline`), and the second app does not claim them unless it is configured — so Word, Excel and PowerPoint open in neither and silently download. That is what the `office-connectors` health check exists to catch; don't simplify it away because the reconcile disables the outgoing connector on a switch, which does not stop a user enabling one by hand.
- **`richdocuments:activate-config` fetches the discovery document as it runs, so the `office-suite` oneshot waits on the document server's own health check before it touches anything** — a bridge address resolves well before `coolwsd` accepts a connection. Do not turn that wait into a retry: a oneshot whose fn rejects is re-invoked on a widening backoff forever, so a suite selected against a stopped or uninstalled service would run `occ` for the life of the chain. Past the gate the steps do throw, and that same re-invocation is what retries them — gate first, so a server that goes away parks the retry.
- **Install and upgrade progress phases are driven by the stock entrypoint's own log lines** — `Initializing nextcloud`, `Starting nextcloud installation`, and the `pre-upgrade` hook scan. Re-read `/entrypoint.sh` when bumping the image: a reword leaves a bar indeterminate instead of failing anything, so nothing else will tell you.
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ Three volumes.
| `db` | `/var/lib/postgresql` | The PostgreSQL data directory |
| `main` | — (host side) | `store.json`; never mounted into a container |

An external-storage source's volume is mounted into the Nextcloud container as well — NextExplorer's lands at `/mnt/nextexplorer` and FileBrowser Quantum's at `/mnt/filebrowser`, outside the `nextcloud` volume. NextExplorer's `files_external` entry exposes only its `Files` drive, because the volume root also holds every account's private `_users` tree. **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.
An external-storage source's volume is mounted into the Nextcloud container as well — NextExplorer's lands at `/mnt/nextexplorer` and FileBrowser Quantum's at `/mnt/filebrowser`, outside the `nextcloud` volume. NextExplorer's volume root is a list of drives rather than one tree, so it gets one `files_external` entry per drive and none for the root: `Files` as `/NextExplorer`, and every other top-level directory as `/NextExplorer (<drive>)` — 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

Expand Down Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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.
Expand Down
98 changes: 93 additions & 5 deletions startos/externalStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,18 @@ 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 (<drive>)` — 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
* 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
}
Expand All @@ -62,11 +69,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',
Expand All @@ -79,3 +97,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
}
9 changes: 5 additions & 4 deletions startos/fileModels/store.json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,17 +36,18 @@ 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
.record(z.string(), z.array(z.string()))
.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(''),
Expand Down
Loading
Loading