Add NixOS module and integrated service stack#6
Open
mtuckerb wants to merge 39 commits intofoxsen:mainfrom
Open
Add NixOS module and integrated service stack#6mtuckerb wants to merge 39 commits intofoxsen:mainfrom
mtuckerb wants to merge 39 commits intofoxsen:mainfrom
Conversation
The composer install step was failing in Nix's sandboxed build because it has no network access. This splits the build into two stages: 1. A fixed-output derivation (composerVendor) that fetches PHP dependencies via composer - FODs are allowed network access since their output is content-addressed. 2. The main dataserverPkg copies the pre-fetched vendor directory and runs composer dump-autoload to regenerate autoload files. Also adds dontCheckForBrokenSymlinks to handle a dangling symlink in the upstream zotero/dataserver source (htdocs/schema points to a git submodule not included in fetchFromGitHub). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
writeShellApplication runs ShellCheck, which flags combined export+assignment from command substitution as SC2155. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Same fix as the composer vendor issue — npm ci/install needs network access which isn't available in Nix's sandbox. Split each into a fixed-output derivation for fetching deps and a regular derivation for assembly. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
nodePackages has been removed from nixpkgs. npm ships with the nodejs package already. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The module was setting services.nginx.recommendedTlsSettings = true globally, which on nixos-25.05 emits an ssl_conf_command directive for post-quantum hybrid groups (X25519MLKEM768). Older nginx builds don't support that directive and refuse to start, breaking the host's existing virtual hosts. TLS settings are a host-wide concern, not service-specific. Leave them to the user. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The current NixOS module has several latent issues that only surface once the dataserver tries to handle a request — they were hidden behind a sops-decryption failure that prevented the service from starting at all. Document the full set of workarounds as a drop-in override block, plus manual setup steps for Zend Framework 1, the zotero-schema submodule, the MariaDB user, and the schema-to-DB ingest, and add a complete walkthrough for building and serving the zotero/web-library SPA at /library/ with HTTP basic auth. Update TODO to reflect that web-library is now working and shift focus to fixing the upstream module bugs at the source.
… NixOS deploy actually works
The previous module had several latent issues that only surfaced once
the dataserver tried to handle a request. They were originally hidden
behind a sops-decryption failure that prevented the service from
starting at all; once the secrets file is encrypted, you hit them in
sequence. This commit fixes all of them at the source so users no
longer need the long workaround block in the README.
dataserverPkg / composerVendor:
* Apply src/patches/dataserver/{0001,0002,0004}.patch (rate limit
increase, AWS SDK minio config, getUploadBaseURL minio fix). Patch
0003 (debug logging) is intentionally omitted because it no longer
applies cleanly to the pinned dataserver source.
* Extract src/patches/dataserver/Zend.tar.gz into include/Zend at
build time. The dataserver `require_once 'Zend/Db/Profiler.php'`
but the upstream zotero/dataserver ships an empty Zend/ directory
(only .gitignore); the docker workflow's utils/patch.sh extracts
ZF1 from this archive and the nix module wasn't doing the same.
* Add new `services.zotero-selfhost.zoteroSchemaSrc` option that
defaults to a fetchFromGitHub of zotero/zotero-schema, and copy
schema.json into htdocs/zotero-schema/ at build time. zotero-schema
is a git submodule of upstream zotero/dataserver but pkgs.fetchFromGitHub
doesn't fetch submodules, so without this Schema::init() throws
"Locales not available" → silent HTTP 500 on /itemTypes etc.
php buildEnv:
* Add include_path and auto_prepend_file to extraConfig. Without
these, htdocs/index.php's `require('config/routes.inc.php')` fails
to find include/config/routes.inc.php (silent HTTP 500), and
Z_ENV_CONTROLLER_PATH from include/header.inc.php is undefined
(silent HTTP 500). The upstream Apache config auto-prepends
header.inc.php; `php -S` doesn't unless the ini says so.
systemd:
* Pin pkgs.nodejs_20 for stream-server and tinymce runtime ExecStart.
pkgs.nodejs currently tracks the latest LTS (24); the bundled
node-config npm package calls util.isRegExp which was removed in
Node 22+. Pure-JS deps are portable across node versions so the
build-time FOD wrappers can stay on pkgs.nodejs without
invalidating the user-supplied hashes.
* Add tmpfiles.d rules to pre-create the runtime working directories
for dataserver/htdocs, stream-server, and tinymce-clean-server
before systemd checks each service's WorkingDirectory= (which is
enforced before ExecStartPre runs, so the prepare scripts can never
bootstrap them on first start without this).
initScript:
* Drop `SET @@global.innodb_large_prefix = 1` — variable was removed
in MariaDB 10.5+; DYNAMIC row format is the default now. Setting
it returns "Unknown system variable" and aborts the init.
* Fix four INSERTs that broke against the current dataserver schema:
- libraries: schema has 6 columns (added hasData), insert provided 5
- users (zotero_master): schema has 3 columns, insert provided 5
- shardLibraries: schema has 5 columns (added storageUsage),
insert provided 4
Use named columns for robustness against future schema additions.
* ALTER TABLE users_email ADD COLUMN validated TINYINT(1) after
loading www.sql, default 0, mark seeded admin email as validated=1.
The dataserver's Storage::getInstitutionalUserQuota joins
users_email to storage_institutions WHERE validated=1, but
www.sql doesn't include this column. Symptom: HTTP 500 with
"Unknown column 'validated' in 'WHERE'" when uploading any file
through the web library or desktop client.
* Run admin/schema_update at the end of init to ingest schema.json
mappings (item types, fields, creator types, locales) into the
fields/creatorTypes/itemTypes/itemTypeFields/baseFieldMappings/
itemTypeCreatorTypes tables. Without this, write operations fail
with "Field 'X' from schema N not found in ItemFields.inc.php"
because coredata.sql only ships an old schema. Use
`php -d auto_prepend_file=` to override the new php.ini auto_prepend
(admin/schema_update has its own require('header.inc.php'); without
disabling auto_prepend the function gets declared twice → fatal
redeclare).
createUserScript:
* Same column-count fixes as initScript: libraries needs hasData,
zotero_master.users has 3 columns not 5, shardLibraries needs
storageUsage. Also use users_email with validated=1 for new users.
Patch 0004 from src/patches/dataserver/ hardcodes `http://` in Storage::getUploadBaseURL() because it was originally written for local docker dev with no SSL. For production NixOS hosts with ACME on the attachments hostname this is wrong: the browser receives an http://attachments.host/zotero/ upload URL, POSTs the PDF body to it, nginx 301-redirects http→https, and 301 doesn't preserve the POST body across the redirect → silent upload failure with "Error: Received error from Zotero server: An error occurred" in the web library. Drop patch 0004 from the patches list and substitute the URL via sed in dataserverPkg.installPhase using a new option `services.zotero-selfhost.s3.scheme` (enum http|https, default https). Local docker dev users can set scheme = "http" to restore the old behavior.
The previous "NixOS gotchas — workarounds you must apply" section documented a long override block that paste-installed runtime fixes for ~7 latent bugs. All of those are now fixed at the source in nix/module.nix (PHP include_path + auto_prepend_file, Zend Framework 1 extraction, zotero-schema fetch, dataserver patches 0001/0002/0004, nodejs_20 runtime, init script column-count fixes, schema_update, runtime working directory tmpfiles, dbconnect mysql.user, validated column on users_email, configurable s3.scheme). Replace the override block with a much shorter "One-time manual setup" section that only covers things the module legitimately can't do for you: encrypting the sops file, creating the MariaDB user, running zotero-selfhost-init, restarting minio if it picked up empty credentials, and (optionally) creating an API key for the web library SPA. The stream-server uws→ws workaround stays as its own subsection because it's still a real out-of-band hack — uws is an abandoned native module and the proper fix is to either patch upstream zotero/stream-server or replace streamServerSrc. Net change: -230 lines (834 → 604).
Patch 0002 (config-aws-for-local-minio-server) hardcodes both 'endpoint' => 'http://...' and 'scheme' => 'http' in the AWS SDK config. After our successful upload to minio, the dataserver runs Storage::registerUpload which does a HeadObject call through the SDK; the SDK uses the http URL, the http nginx listener 301- redirects to https or returns 403, and the SDK turns that into a 500 from the registration endpoint. The browser sees this as "An error occurred" after the multipart POST to minio already succeeded with HTTP 201. Apply the same sed substitution as the Storage.inc.php fix using cfg.s3.scheme so the SDK and the upload URL agree.
…ible VALUES() zotero/dataserver line 573 of model/Storage.inc.php uses INSERT INTO storageFileItems (...) VALUES (?,?,?,?) AS new ON DUPLICATE KEY UPDATE storageFileID=new.storageFileID, ... which is MySQL 8.0+ syntax. MariaDB 11.4 (the NixOS default) doesn't recognize the `AS new` row alias and aborts with "near 'AS new'". This blocks PDF upload registration: the multipart POST to minio succeeds with HTTP 201 but the dataserver's Storage::registerUpload fails to record the file metadata, returning HTTP 500 to the client. Substitute the row-alias form for the legacy VALUES(col) form via sed in dataserverPkg.installPhase. VALUES(col) in ON DUPLICATE KEY UPDATE is deprecated in MySQL 8.0.20+ but still works there, and is the only form MariaDB supports.
Without this, on a fresh host the minio start script reads
/run/secrets/zotero-selfhost/s3-{access,secret}-key as empty strings
(the files don't exist yet because sops-install-secrets hasn't run)
and minio falls back to its default minioadmin:minioadmin credentials.
The dataserver then can't authenticate against minio (it's using the
sops credentials) and PDF uploads return 403 InvalidAccessKeyId.
The fix only affects fresh boots; on a host where minio has already
started successfully it's a no-op.
…needed) The upstream zotero/dataserver wraps download URLs in a custom HMAC- signed format that points at $ATTACHMENT_PROXY_URL, expecting a separate "attachment proxy" service to verify the signature, decode the payload, and stream the file from S3 / minio. Upstream zotero.org runs that proxy as a separate component but doesn't open-source it, and the NixOS module doesn't ship a substitute. Symptom: the web library's PDF reader fetches a download URL from /file/view/url and gets back something like https://attachments.example.com/<base64-json>/<hmac>/<filename> which minio interprets as an invalid S3 key and rejects with HTTP 400. Add a new patch (0005) that rewrites Zotero_Attachments::generateSignedURL to generate AWS SDK V4 presigned GET URLs when the payload contains a 'hash' (download URLs). Minio validates the V4 signature itself and serves the file directly. The legacy proxy format is kept as a fallback for the upload-URL code path (which uses a different field shape and isn't actually exercised by the working upload flow that uses Storage::getUploadFileInfo's signed POST policy).
The upstream zotero/stream-server repo depends on `uws` (µWebSockets)
10.148.1, an abandoned native binding with no prebuilt binaries for
modern Node and source that no longer compiles against modern Node
ABI. The streamServerNodeDeps FOD fails on any reasonably modern host.
Add a new patches list streamServerPatches with one patch (0001)
that replaces require('uws') with require('ws') in connections.js
and server.js, handles the upgradeReq → req second-arg API change in
the connection handler, and updates package.json to depend on
ws ^7.5.10 (which was already a devDependency).
Apply the patches to both streamServerNodeDeps (so npm install sees
the new package.json) and streamServerPkg (so the runtime js is
patched). Switch streamServerNodeDeps from `npm ci` to `npm install`
because the patch invalidates the upstream package-lock.json — the
FOD outputHash still pins reproducibility.
streamServerNodeDepsHash default is set to a placeholder; bumping
this commit on any consumer requires re-running nix build to discover
the real hash, then setting it on services.zotero-selfhost.streamServerNodeDepsHash.
…atch Computed via FederalNix rebuild against the previous commit (0dc653e). The hash is the result of `npm install --omit=dev` on the patched stream-server source where uws@10.148.1 has been replaced with ws@^7.5.10. Pinned for reproducibility.
Add services.zotero-selfhost.webLibrary.enable. When true, the upstream zotero/web-library is fetched (via fetchgit with fetchSubmodules=true since the SPA has 6 submodules), built as a fixed-output derivation (network needed for npm install + locale + styles fetches), and served at the configured hostname's root. Source patches applied via sed in the build derivation: - src/js/constants/defaults.js: rewrite api.zotero.org → cfg.webLibrary.hostname for apiAuthorityPart, websiteUrl, and streamingApiUrl. - src/js/utils.js: rewrite item canonical URL + parser regex to use the configured hostname (and accept upstream zotero.org URLs in the parser for backward compat with synced items). - src/html/index.html: replace the demo zotero-web-library-config block with userId/userSlug/apiKey from new options, and the menu config with a single My Library entry pointing at the configured userSlug. - scripts/task-runner.mjs: wrap process.stdin.setRawMode in isTTY guards (the upstream interactive task picker crashes when stdin isn't a TTY, which is the case in the nix sandbox). - scripts/build.mjs: replace `cp -aL` with `cp -arL` because uutils-coreutils on NixOS doesn't accept GNU's `-a` shorthand. When webLibrary.enable is true, the nginx vhost layout switches to serve the SPA at root with a regex location carving out dataserver API paths (/users, /groups, /itemTypes, etc) that takes precedence. The SPA's React Router uses root-relative paths so it MUST be mounted at root — sub-path mounting breaks internal navigation on refresh. Optional basicAuthFile gates access to the bundle (which embeds the apiKey). webLibraryHash defaults to a placeholder; bumping webLibrarySrc or any webLibrary.* option that affects the build requires re-running nixos-rebuild to discover the real hash from the FOD error message. New options: - services.zotero-selfhost.webLibrary.enable - services.zotero-selfhost.webLibrary.hostname (default: infrastructure.hostname) - services.zotero-selfhost.webLibrary.apiKey (required when enable=true) - services.zotero-selfhost.webLibrary.userId (default: "1") - services.zotero-selfhost.webLibrary.userSlug (default: "admin") - services.zotero-selfhost.webLibrary.basicAuthFile (default: null) - services.zotero-selfhost.webLibrarySrc (default: zotero/web-library v1.7.5) - services.zotero-selfhost.webLibraryHash (default: placeholder)
The v1.7.5 tag has a different scripts/ layout (no scripts/build.mjs, no scripts/task-runner.mjs) which doesn't match the source patches in webLibraryPkg. main HEAD has the build flow our patches target.
The previous heredoc approach with python triple-quoted strings nested inside Nix's `''...''` multiline string syntax was getting mangled (Nix interprets `''` as the escape character and ate the python string delimiters). Move the python script out to a pkgs.writeText file referenced from the install phase.
…tall Some web-library transitive dependencies (e.g. unrs-resolver via napi-postinstall) have postinstall hooks that use #!/usr/bin/env shebangs which fail in the nix build sandbox (no /usr/bin/env). Install with --ignore-scripts to skip those, then patchShebangs the node_modules tree to rewrite absolute store paths.
scripts/fetch-or-build-modules.mjs uses curl and unzip via child_process.exec to download pre-built reader/pdf-worker/note-editor zips from zotero-download.s3.amazonaws.com. python3 is needed for the webLibraryHtmlPatcher script.
…irectly The upstream scripts/build.mjs uses scripts/task-runner.mjs which buffers each sub-task's stdout and only prints a one-line label. On failure that label is `…` and the actual error message is swallowed, making sandbox builds undebuggable. Run each prepare script and each build step directly so failures surface real stdout/stderr.
…dInputs scripts/fetch-or-build-modules.mjs runs `git rev-parse HEAD` inside each modules/<submodule> directory to determine which prebuild artifact to fetch from zotero-download.s3.amazonaws.com. fetchgit strips .git from submodules by default. Set leaveDotGit=true to preserve it (the FOD outputHash still pins reproducibility downstream) and add pkgs.git to nativeBuildInputs so the rev-parse call works. Update the source sha256 to match the new hash with .git preserved.
Computed via FederalNix rebuild against the previous commit (96810ee). The hash pins the result of npm install + npm run build for the zotero/web-library main HEAD pinned in webLibrarySrc, with all the source patches applied and the prepare/build steps run directly (bypassing the task runner).
Add src/patches/zotero-client/0002-zotero7-config-for-self-host.patch that updates the five URL fields the self-host actually serves (DOMAIN_NAME, BASE_URI, WWW_BASE_URL, API_URL, STREAMING_URL) in Zotero 7's resource/config.mjs (the new ESM module location). The old 0001 patch targets Zotero 5/6's resource/config.js layout. Add utils/patch-zotero7-client.sh which auto-detects the Zotero 7 install path (Linux / macOS / Windows), backs up the original config.mjs, and substitutes the user's hostname into the five fields. Idempotent and reversible (saves the original to config.mjs.zotero-org.bak). Caveat documented in the script: Zotero auto-update overwrites config.mjs, so users must either rerun the script after each update or disable auto-updates in Zotero preferences.
Modern Zotero (6+) honors two runtime preference overrides for the sync URLs: extensions.zotero.api.url (overrides ZOTERO_CONFIG.API_URL) extensions.zotero.streaming.url (overrides ZOTERO_CONFIG.STREAMING_URL) These are read by syncRunner.js, syncAPIClient.js, retractions.js, and streamer.js — i.e. the entire sync code path. So redirecting the desktop client at a self-hosted dataserver does NOT require patching resource/config.mjs (which is bundled inside omni.ja in Zotero 7+ and would need extract/repack/clear-startup-cache cycles that fight Zotero's auto-updater). Replace the previous patch-zotero7-client.sh + 0002 patch with a configure-zotero-client.sh that auto-detects the Zotero profile directory (Linux / macOS / Windows / Snap), writes the two prefs to user.js (which Zotero evaluates as user-pref overrides at startup), and prints next-step instructions for the user. The user.js approach survives Zotero auto-updates (the file lives in the profile dir, not the install dir). Idempotent: re-running with the same hostname is a no-op; running with a different host rewrites the values. Drop the old patch-zotero7-client.sh and 0002 patch since they target a layout that doesn't exist in Zotero 7/8 (loose resource/config.js was Zotero 5/6 only — modern bundles it in omni.ja and prefs are the user-facing override path anyway).
Upstream zotero/dataserver enforces a 300 MB per-user default quota (matching the zotero.org free tier) that surfaces as "out of space" errors from the desktop sync engine after a few hundred attachments. For a self-hosted deployment where the operator already controls storage at the minio/S3 layer, this is a confusing first-run wall with no obvious fix. 0006-remove-storage-quota.patch bumps Zotero_Storage::$defaultQuota from 300 MB to 10 TB. The per-user override path through storageAccounts still works for operators who want real per-user limits, so this only changes the default for unconfigured users. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
For deployments where the host already runs an S3-compatible backend (another minio, AWS S3, R2, B2, etc), the bundled minio is redundant — double the disk + RAM + bucket of credentials, and a fresh-boot race where minio falls back to default minioadmin credentials before the sops secrets are decrypted. Add `services.zotero-selfhost.infrastructure.minio.enable` (default true for backward compat). When set to false: - The bundled `zotero-selfhost-minio.service` is not declared - The dataserver service no longer waits for it at startup - The integrated nginx attachments vhost reverse-proxies to whatever `services.zotero-selfhost.s3.endpointUrl` points at, instead of the hardcoded loopback minioPort The operator is then expected to set s3.endpointUrl explicitly and populate the sops s3-access-key/s3-secret-key entries with credentials that work against their existing backend. The bucket bootstrap in dataserver-prepare runs against s3.endpointUrl regardless, so the zotero/zotero-fulltext buckets will be auto-created on the external backend on first start (assuming the credentials have mb permission). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
nginx defaults client_max_body_size to 1m. The integrated attachments vhost (the public reverse proxy in front of S3 / minio for attachment uploads + downloads) inherits that default, which silently rejects any Zotero PDF over 1 MB with HTTP 413 — the desktop client surfaces this as a generic "file sync error" with no useful diagnostics. Add `services.zotero-selfhost.infrastructure.attachmentMaxBodySize` defaulting to 2g, applied as extraConfig on the attachments vhost. 2g covers every reasonable Zotero attachment (papers, scanned books, large slide decks) while still capping pathological uploads. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Zotero_Storage::$uploadQueueLimit = 10 is the zotero.org abuse-prevention default — every user is capped at 10 in-flight upload URL requests in any 5-minute window. The desktop client surfaces breaches as a generic "file sync error" with no useful diagnostics, and stale rows from prior failed attempts (quota wall, body-size wall, missing-bucket wall) sit in storageUploadQueue for 5 full minutes before aging out — so even after fixing the underlying problem the queue stays full and every retry gets 413'd until the rows expire. Bump to 200. Self-hosted single-user (or small-team) deployments will routinely batch dozens of uploads in a single restore-to-online migration. 200 is still bounded so a runaway client can't DoS the server, but high enough that no realistic single-user workload will ever notice. The 300-second timeout is unchanged so genuinely stale entries still age out. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Zotero_FullText::indexItem requests S3 StorageClass=STANDARD_IA for any fulltext payload over 75 KB gzipped, as a cost-tier optimization on the hosted zotero.org backend. minio doesn't accept STANDARD_IA and rejects the put with "Invalid storage class". The exception bubbles up through updateMultipleFromJSON's catch block and rolls back the MySQL itemFulltext write. The desktop client surfaces this as a generic "ZoteroObjectUploadError: An error occurred" with no detail about which specific S3 call failed. The bug looks intermittent because items with smaller extracted PDF text bodies fall under the 75 KB threshold and use the STANDARD branch (which minio accepts), while longer PDFs always fail. From a user's perspective, fulltext sync "works for some items" — the worst kind of bug to debug. Drop the StorageClass parameter entirely. minio doesn't have cheaper tiers to opt into, and a self-host operator who wants tiering can configure it via lifecycle policies on the bucket itself. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Zotero desktop polls /retractions/list on every sync to check for
retracted publications. This endpoint exists on hosted zotero.org but
is not implemented in upstream zotero/dataserver, so on a self-host
the request falls through to the SPA root location and trips the
basic-auth gate (when webLibrary.basicAuthFile is set). The client
logs:
HTTP GET https://.../retractions/list failed with status code 401
…on every sync attempt. Doesn't block sync but surfaces as a recurring
"unable to check for retractions" warning in the Zotero error console.
Add an explicit `location = /retractions/list` to the integrated
nginx vhost that returns an empty JSON array with auth_basic off.
The client interprets this as "no retractions known" and stops
complaining. No backend code change required.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This adds first-class NixOS service support for zotero-selfhost.
What’s included:
nixosModules.defaultandnixosModules.zotero-selfhostservices.zotero-selfhostNixOS modulesops-nixValidation performed:
nix flake check --no-buildnixosSystemevaluation confirming the expected systemd units existThis should make the repo usable directly as a flake input for NixOS deployments.