From f64a50d813b8dfe70f1f3a84f3f901fbe40e3bbe Mon Sep 17 00:00:00 2001 From: Benjy Date: Sat, 26 Sep 2026 15:29:51 +0200 Subject: [PATCH 1/6] Give the screens the strings they ask for, and a test that says so MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The About page's list of optional tools showed its own keys: `settings.about.tools.title` where the heading belongs, and `settings.about.tools.gives.videoThumbnails` under each one. The page was ported with no catalogue entries at all, and nothing failed — the build succeeded, the page rendered, and the words were missing. It is the one defect a batch of screens can ship with while passing every other gate. Twelve more keys were already in that state, on eleven screens: the Back button on an account, the folder-size "Excluded" mark and its explanation, the Access heading on both share lists, the branding page's note after choosing the default logo, the empty user list, and six strings in the share dialog — Password, Protected, Expires, Shared with, Creating, Loading users. So a test reads every `t('…')` a screen asks for and holds it to the English catalogue. English alone: a key missing from the others falls back to it, which is a reader seeing the wrong language rather than a key. A prefix being built up — `t('settings.categories.' + name)` — is not a key and is passed over, which is also the limit of what can be checked from the source. --- backend/tests/frontend-strings.test.js | 69 ++++++++++++++++++++++++++ frontend/src/i18n/locales/de.json | 44 +++++++++++++--- frontend/src/i18n/locales/en.json | 44 +++++++++++++--- frontend/src/i18n/locales/es.json | 44 +++++++++++++--- frontend/src/i18n/locales/fr.json | 44 +++++++++++++--- frontend/src/i18n/locales/hi.json | 44 +++++++++++++--- frontend/src/i18n/locales/it.json | 44 +++++++++++++--- frontend/src/i18n/locales/ko.json | 44 +++++++++++++--- frontend/src/i18n/locales/nl.json | 44 +++++++++++++--- frontend/src/i18n/locales/pl.json | 44 +++++++++++++--- frontend/src/i18n/locales/pt-BR.json | 44 +++++++++++++--- frontend/src/i18n/locales/ro.json | 44 +++++++++++++--- frontend/src/i18n/locales/ru.json | 44 +++++++++++++--- frontend/src/i18n/locales/sv.json | 44 +++++++++++++--- frontend/src/i18n/locales/zh-CN.json | 44 +++++++++++++--- frontend/src/i18n/locales/zh-TW.json | 44 +++++++++++++--- 16 files changed, 624 insertions(+), 105 deletions(-) create mode 100644 backend/tests/frontend-strings.test.js diff --git a/backend/tests/frontend-strings.test.js b/backend/tests/frontend-strings.test.js new file mode 100644 index 00000000..25af4b32 --- /dev/null +++ b/backend/tests/frontend-strings.test.js @@ -0,0 +1,69 @@ +import { describe, it, expect } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; + +/** + * Every string a screen asks for is one the catalogue has. + * + * A screen that arrives without its strings shows its own keys — + * `settings.about.tools.title` where a heading belongs — and nothing fails: + * the build succeeds, the page renders, and the words are missing. It is the + * one defect a batch of screens can ship with and pass every other gate, and + * it has happened: the About page's list of optional tools was ported with no + * catalogue entries at all. + * + * English is the one checked. The others are missing-translation fallbacks by + * design — a key absent there falls back to English, which is a reader seeing + * the wrong language rather than a key. + */ + +const FRONTEND = path.join(__dirname, '..', '..', 'frontend', 'src'); +const CATALOGUE = path.join(FRONTEND, 'i18n', 'locales', 'en.json'); + +/** `t('a.b')`, `$t('a.b')`, `te('a.b')` — the literal calls, which is all that can be checked. */ +const KEY_CALL = /(? { + const found = []; + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const full = path.join(directory, entry.name); + if (entry.isDirectory()) { + if (entry.name === 'locales') continue; + found.push(...sourcesUnder(full)); + } else if (/\.(vue|js)$/.test(entry.name) && !entry.name.endsWith('.spec.js')) { + found.push(full); + } + } + return found; +}; + +const has = (catalogue, key) => { + let node = catalogue; + for (const part of key.split('.')) { + if (!node || typeof node !== 'object' || !(part in node)) return false; + node = node[part]; + } + return typeof node === 'string' || typeof node === 'object'; +}; + +describe('the strings the interface asks for', () => { + it('are all in the English catalogue', () => { + const catalogue = JSON.parse(fs.readFileSync(CATALOGUE, 'utf8')); + const missing = new Set(); + + for (const file of sourcesUnder(FRONTEND)) { + const source = fs.readFileSync(file, 'utf8'); + for (const [, key] of source.matchAll(KEY_CALL)) { + // A key is a path with a dot in it; a bare word is some other `t(...)`. + if (!key.includes('.')) continue; + // `t('settings.categories.' + name)` — a prefix being built, not a key. + if (key.endsWith('.')) continue; + if (!has(catalogue, key)) { + missing.add(`${key} (${path.relative(FRONTEND, file)})`); + } + } + } + + expect([...missing].sort()).toEqual([]); + }); +}); diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 710ff5e1..786de147 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -70,7 +70,8 @@ "unsavedChanges": "Sie haben ungespeicherte Änderungen", "readonly": "Schreibgeschützt", "readwrite": "Lesen/Schreiben", - "add": "Hinzufügen" + "add": "Hinzufügen", + "back": "Zurück" }, "editor": { "editing": "Editing", @@ -332,7 +333,9 @@ "camera": "Kamera: {makeModel}", "lens": "Objektiv: {lens}", "duration": "Dauer: {seconds}s", - "versions": "Versionen" + "versions": "Versionen", + "folderSizeExcluded": "Ausgeschlossen", + "folderSizeExcludedHelp": "Dieser Pfad ist von der Indizierung der Ordnergrößen ausgeschlossen." }, "auth": { "preparing": "Ihr Explorer wird vorbereitet…", @@ -428,7 +431,25 @@ "gitCommit": "Git-Commit", "gitCommitHelp": "Quell-Revision, die zur Build-Zeit eingebettet wurde.", "branch": "Branch", - "branchHelp": "Git-Branch zur Build-Zeit." + "branchHelp": "Git-Branch zur Build-Zeit.", + "tools": { + "title": "Optionale Werkzeuge", + "subtitle": "Was diese Instanz kann und was ein fehlendes Werkzeug hinzufügen würde. Der Server sagt dasselbe beim Start in seinem Protokoll.", + "installed": "Installiert", + "missing": "Nicht installiert", + "unused": "Hier nicht verwendet", + "package": "Paket", + "cannotOpen": "Kann nicht öffnen: {formats}", + "gives": { + "videoThumbnails": "Vorschaubilder für Videos und Standbilder aus HEIC-Fotos", + "mediaDetails": "Dauer und Spuren von Audio und Video", + "fastSearch": "Schnelle Suche im Inhalt von Dateien", + "pdfSearch": "Der Text von PDFs in der Suche", + "rawPreviews": "Vorschauen von RAW-Fotos", + "copyProgress": "Fortschritt bei großen Kopier- und Verschiebevorgängen", + "archives": "Archive jenseits von .zip durchsuchen und entpacken" + } + } }, "branding": { "subtitle": "Passen Sie den in der Oberfläche angezeigten Anwendungsnamen und das Logo an.", @@ -446,7 +467,8 @@ "fileTooLargeForDataUri": "Datei ist zu groß. Bitte verwenden Sie ein kleineres Bild (unter 500 KB für beste Ergebnisse)", "preview": "Vorschau", "showPoweredBy": "Attributionslink anzeigen", - "showPoweredByHelp": "Zeigt in der Fußzeile einen Link 'Powered by nextExplorer', um das Projekt zu unterstützen" + "showPoweredByHelp": "Zeigt in der Fußzeile einen Link 'Powered by nextExplorer', um das Projekt zu unterstützen", + "defaultLogoSelected": "Standardlogo ausgewählt. Zum Anwenden auf Speichern klicken." }, "userPreferences": { "title": "Benutzereinstellungen", @@ -508,7 +530,8 @@ "browse": "Ordner auswählen", "pathInvalid": "Dieser Pfad liegt in keinem Volume, daher greift die Regel nie.", "pathMissing": "Unter diesem Pfad gibt es keinen Ordner. Eine Regel verwendet den Pfad so, wie NextExplorer ihn anzeigt, beginnend mit dem Namen des Volumes.", - "useSuggestion": "{path} verwenden" + "useSuggestion": "{path} verwenden", + "title": "Zugriff" }, "comingSoon": { "subtitle": "Dieser Einstellungsbereich wird derzeit entwickelt. Bleiben Sie dran!" @@ -554,7 +577,8 @@ "selectedPath": "Ausgewählt", "volumePath": "Volumenpfad", "pathCannotChange": "Der Pfad kann nach der Erstellung nicht mehr geändert werden. Entfernen Sie dieses Volume und erstellen Sie bei Bedarf ein neues.", - "accessMode": "Zugriffsmodus" + "accessMode": "Zugriffsmodus", + "noUsers": "Keine Benutzer verfügbar" }, "userDetails": { "profileTab": "Profile", @@ -976,7 +1000,13 @@ "download": "Herunterladen" }, "deleteShareTitle": "Freigabe entfernen?", - "deleteShareMessage": "Personen mit diesem Link können nicht mehr auf das freigegebene Element zugreifen." + "deleteShareMessage": "Personen mit diesem Link können nicht mehr auf das freigegebene Element zugreifen.", + "creating": "Wird erstellt…", + "expires": "Läuft ab", + "loadingUsers": "Benutzer werden geladen...", + "password": "Passwort", + "protected": "Geschützt", + "sharedWith": "Geteilt mit" }, "trash": { "title": "Papierkorb", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 3e01d18c..6863b8f3 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -70,7 +70,8 @@ "unsavedChanges": "You have unsaved changes", "readonly": "Read Only", "readwrite": "Read/Write", - "add": "Add" + "add": "Add", + "back": "Back" }, "editor": { "editing": "Editing", @@ -332,7 +333,9 @@ "camera": "Camera: {makeModel}", "lens": "Lens: {lens}", "duration": "Duration: {seconds}s", - "versions": "Versions" + "versions": "Versions", + "folderSizeExcluded": "Excluded", + "folderSizeExcludedHelp": "This path is excluded from folder-size indexing." }, "auth": { "preparing": "Preparing your explorer…", @@ -428,7 +431,25 @@ "gitCommit": "Git commit", "gitCommitHelp": "Source revision embedded at build time.", "branch": "Branch", - "branchHelp": "Git branch at build time." + "branchHelp": "Git branch at build time.", + "tools": { + "title": "Optional tools", + "subtitle": "What this instance can do, and what installing a missing tool would add. The server says the same in its log when it starts.", + "installed": "Installed", + "missing": "Not installed", + "unused": "Not used here", + "package": "Package", + "cannotOpen": "Cannot open: {formats}", + "gives": { + "videoThumbnails": "Video thumbnails, and stills from HEIC photos", + "mediaDetails": "Durations and tracks of audio and video", + "fastSearch": "Fast search inside files", + "pdfSearch": "The text of PDFs in search", + "rawPreviews": "Previews of RAW photos", + "copyProgress": "Progress on large copies and moves", + "archives": "Browsing and extracting archives beyond .zip" + } + } }, "branding": { "subtitle": "Customize the application name and logo displayed throughout the interface.", @@ -446,7 +467,8 @@ "fileTooLargeForDataUri": "File is too large. Please use a smaller image (under 500KB for best results)", "preview": "Preview", "showPoweredBy": "Display attribution link", - "showPoweredByHelp": "Show a 'Powered by nextExplorer' link in the footer to help support the project" + "showPoweredByHelp": "Show a 'Powered by nextExplorer' link in the footer to help support the project", + "defaultLogoSelected": "Default logo selected. Click Save to apply." }, "userPreferences": { "title": "User Preferences", @@ -508,7 +530,8 @@ "browse": "Choose a folder", "pathInvalid": "This path is not inside a volume, so the rule will never apply.", "pathMissing": "No folder at this path. A rule uses the path as NextExplorer shows it, starting with the volume name.", - "useSuggestion": "Use {path}" + "useSuggestion": "Use {path}", + "title": "Access" }, "comingSoon": { "subtitle": "This settings section is being built. Stay tuned!" @@ -554,7 +577,8 @@ "selectedPath": "Selected", "volumePath": "Volume Path", "pathCannotChange": "The path cannot be changed after creation. Remove this volume and create a new one if needed.", - "accessMode": "Access Mode" + "accessMode": "Access Mode", + "noUsers": "No users available" }, "userDetails": { "profileTab": "Profile", @@ -976,7 +1000,13 @@ "removeShare": "Remove share", "sharedWithAnyone": "Everyone", "sharedWithUsers": "{count} user | {count} users", - "expiresNever": "Never" + "expiresNever": "Never", + "creating": "Creating…", + "expires": "Expires", + "loadingUsers": "Loading users...", + "password": "Password", + "protected": "Protected", + "sharedWith": "Shared with" }, "trash": { "title": "Trash", diff --git a/frontend/src/i18n/locales/es.json b/frontend/src/i18n/locales/es.json index 6b3323a5..a348d05a 100644 --- a/frontend/src/i18n/locales/es.json +++ b/frontend/src/i18n/locales/es.json @@ -70,7 +70,8 @@ "unsavedChanges": "Tienes cambios sin guardar", "readonly": "Solo lectura", "readwrite": "Lectura/Escritura", - "add": "Añadir" + "add": "Añadir", + "back": "Atrás" }, "editor": { "editing": "Editing", @@ -332,7 +333,9 @@ "camera": "Cámara: {makeModel}", "lens": "Lente: {lens}", "duration": "Duración: {seconds}s", - "versions": "Versiones" + "versions": "Versiones", + "folderSizeExcluded": "Excluido", + "folderSizeExcludedHelp": "Esta ruta está excluida de la indexación de tamaños de carpeta." }, "auth": { "preparing": "Preparando tu explorador…", @@ -428,7 +431,25 @@ "gitCommit": "Commit de Git", "gitCommitHelp": "Revisión de código fuente incrustada en el momento de la compilación.", "branch": "Rama", - "branchHelp": "Rama de Git en el momento de la compilación." + "branchHelp": "Rama de Git en el momento de la compilación.", + "tools": { + "title": "Herramientas opcionales", + "subtitle": "Lo que esta instancia puede hacer y lo que añadiría una herramienta que falta. El servidor dice lo mismo en su registro al arrancar.", + "installed": "Instalada", + "missing": "No instalada", + "unused": "No se usa aquí", + "package": "Paquete", + "cannotOpen": "No puede abrir: {formats}", + "gives": { + "videoThumbnails": "Miniaturas de vídeo e imágenes de fotos HEIC", + "mediaDetails": "Duración y pistas de audio y vídeo", + "fastSearch": "Búsqueda rápida dentro de los archivos", + "pdfSearch": "El texto de los PDF en la búsqueda", + "rawPreviews": "Vistas previas de fotos RAW", + "copyProgress": "Progreso en copias y movimientos grandes", + "archives": "Explorar y extraer archivos comprimidos más allá de .zip" + } + } }, "branding": { "subtitle": "Personaliza el nombre de la aplicación y el logotipo que se muestran en toda la interfaz.", @@ -446,7 +467,8 @@ "fileTooLargeForDataUri": "El archivo es demasiado grande. Usa una imagen más pequeña (menos de 500 KB para obtener mejores resultados)", "preview": "Vista previa", "showPoweredBy": "Mostrar enlace de atribución", - "showPoweredByHelp": "Muestra un enlace 'Powered by nextExplorer' en el pie de página para ayudar a apoyar el proyecto" + "showPoweredByHelp": "Muestra un enlace 'Powered by nextExplorer' en el pie de página para ayudar a apoyar el proyecto", + "defaultLogoSelected": "Logotipo predeterminado seleccionado. Haz clic en Guardar para aplicar." }, "userPreferences": { "title": "Preferencias de usuario", @@ -508,7 +530,8 @@ "browse": "Elegir una carpeta", "pathInvalid": "Esta ruta no está dentro de ningún volumen, así que la regla nunca se aplicará.", "pathMissing": "No hay ninguna carpeta en esta ruta. Una regla usa la ruta tal como la muestra NextExplorer, empezando por el nombre del volumen.", - "useSuggestion": "Usar {path}" + "useSuggestion": "Usar {path}", + "title": "Acceso" }, "comingSoon": { "subtitle": "Esta sección de configuración está en desarrollo. ¡Permanece atento!" @@ -554,7 +577,8 @@ "selectedPath": "Seleccionado", "volumePath": "Ruta del volumen", "pathCannotChange": "La ruta no se puede cambiar después de la creación. Elimine este volumen y cree uno nuevo si es necesario.", - "accessMode": "Modo de acceso" + "accessMode": "Modo de acceso", + "noUsers": "No hay usuarios disponibles" }, "userDetails": { "profileTab": "Profile", @@ -976,7 +1000,13 @@ "download": "Descargar" }, "deleteShareTitle": "¿Eliminar enlace compartido?", - "deleteShareMessage": "Las personas con este enlace ya no podrán acceder al elemento compartido." + "deleteShareMessage": "Las personas con este enlace ya no podrán acceder al elemento compartido.", + "creating": "Creando…", + "expires": "Expira", + "loadingUsers": "Cargando usuarios...", + "password": "Contraseña", + "protected": "Protegido", + "sharedWith": "Compartido con" }, "trash": { "title": "Papelera", diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index 408bb435..da686fba 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -70,7 +70,8 @@ "unsavedChanges": "Vous avez des modifications non enregistrées", "readonly": "Lecture seule", "readwrite": "Lecture/Écriture", - "add": "Ajouter" + "add": "Ajouter", + "back": "Retour" }, "editor": { "editing": "Editing", @@ -332,7 +333,9 @@ "camera": "Appareil photo : {makeModel}", "lens": "Objectif : {lens}", "duration": "Durée : {seconds}s", - "versions": "Versions" + "versions": "Versions", + "folderSizeExcluded": "Exclu", + "folderSizeExcludedHelp": "Ce chemin est exclu de l’indexation des tailles." }, "auth": { "preparing": "Préparation de votre explorateur…", @@ -428,7 +431,25 @@ "gitCommit": "Commit Git", "gitCommitHelp": "Révision du code source intégrée au moment de la build.", "branch": "Branche", - "branchHelp": "Branche Git au moment de la build." + "branchHelp": "Branche Git au moment de la build.", + "tools": { + "title": "Outils optionnels", + "subtitle": "Ce que cette instance sait faire, et ce qu'apporterait un outil manquant. Le serveur dit la même chose dans son journal au démarrage.", + "installed": "Installé", + "missing": "Non installé", + "unused": "Non utilisé ici", + "package": "Paquet", + "cannotOpen": "Ne sait pas ouvrir : {formats}", + "gives": { + "videoThumbnails": "Vignettes des vidéos et images des photos HEIC", + "mediaDetails": "Durées et pistes de l'audio et de la vidéo", + "fastSearch": "Recherche rapide dans le contenu des fichiers", + "pdfSearch": "Le texte des PDF dans la recherche", + "rawPreviews": "Aperçus des photos RAW", + "copyProgress": "Progression des grosses copies et déplacements", + "archives": "Parcourir et extraire les archives autres que .zip" + } + } }, "branding": { "subtitle": "Personnalisez le nom de l’application et le logo affichés dans toute l’interface.", @@ -446,7 +467,8 @@ "fileTooLargeForDataUri": "Le fichier est trop volumineux. Veuillez utiliser une image plus petite (moins de 500 Ko pour de meilleurs résultats)", "preview": "Aperçu", "showPoweredBy": "Afficher le lien d’attribution", - "showPoweredByHelp": "Afficher un lien « Powered by nextExplorer » dans le pied de page pour aider à soutenir le projet" + "showPoweredByHelp": "Afficher un lien « Powered by nextExplorer » dans le pied de page pour aider à soutenir le projet", + "defaultLogoSelected": "Logo par défaut sélectionné. Cliquez sur Enregistrer pour appliquer." }, "userPreferences": { "title": "Préférences utilisateur", @@ -508,7 +530,8 @@ "browse": "Choisir un dossier", "pathInvalid": "Ce chemin n'est dans aucun volume : la règle ne s'appliquera jamais.", "pathMissing": "Aucun dossier à ce chemin. Une règle utilise le chemin tel que NextExplorer l'affiche, en commençant par le nom du volume.", - "useSuggestion": "Utiliser {path}" + "useSuggestion": "Utiliser {path}", + "title": "Accès" }, "comingSoon": { "subtitle": "Cette section de paramètres est en cours de développement. Restez à l'écoute !" @@ -554,7 +577,8 @@ "selectedPath": "Sélectionné", "volumePath": "Chemin du volume", "pathCannotChange": "Le chemin ne peut pas être modifié après la création. Supprimez ce volume et créez-en un nouveau si nécessaire.", - "accessMode": "Mode d'accès" + "accessMode": "Mode d'accès", + "noUsers": "Aucun utilisateur disponible" }, "userDetails": { "profileTab": "Profile", @@ -976,7 +1000,13 @@ "removeShare": "Supprimer le partage", "sharedWithAnyone": "Tout le monde", "sharedWithUsers": "{count} utilisateur | {count} utilisateurs", - "expiresNever": "Jamais" + "expiresNever": "Jamais", + "creating": "Création…", + "expires": "Expire", + "loadingUsers": "Chargement des utilisateurs...", + "password": "Mot de passe", + "protected": "Protégé", + "sharedWith": "Partagé avec" }, "trash": { "title": "Corbeille", diff --git a/frontend/src/i18n/locales/hi.json b/frontend/src/i18n/locales/hi.json index 7b6d55bf..8e637dfe 100644 --- a/frontend/src/i18n/locales/hi.json +++ b/frontend/src/i18n/locales/hi.json @@ -70,7 +70,8 @@ "unsavedChanges": "आपके पास बिना सहेजे परिवर्तन हैं", "readonly": "केवल पढ़ने के लिए", "readwrite": "पढ़ना/लिखना", - "add": "जोड़ें" + "add": "जोड़ें", + "back": "पीछे" }, "editor": { "editing": "Editing", @@ -332,7 +333,9 @@ "camera": "कैमरा: {makeModel}", "lens": "लेंस: {lens}", "duration": "अवधि: {seconds}s", - "versions": "संस्करण" + "versions": "संस्करण", + "folderSizeExcluded": "बाहर रखा गया", + "folderSizeExcludedHelp": "यह पथ फ़ोल्डर-आकार अनुक्रमण से बाहर रखा गया है।" }, "auth": { "preparing": "आपका एक्सप्लोरर तैयार किया जा रहा है…", @@ -428,7 +431,25 @@ "gitCommit": "Git कमिट", "gitCommitHelp": "बिल्ड समय पर जोड़ी गई स्रोत संशोधन।", "branch": "ब्रांच", - "branchHelp": "बिल्ड समय पर Git ब्रांच।" + "branchHelp": "बिल्ड समय पर Git ब्रांच।", + "tools": { + "title": "वैकल्पिक टूल", + "subtitle": "यह इंस्टेंस क्या कर सकता है, और कोई अनुपस्थित टूल क्या जोड़ेगा। सर्वर शुरू होते समय अपने लॉग में यही बताता है।", + "installed": "इंस्टॉल है", + "missing": "इंस्टॉल नहीं है", + "unused": "यहाँ उपयोग नहीं होता", + "package": "पैकेज", + "cannotOpen": "नहीं खोल सकता: {formats}", + "gives": { + "videoThumbnails": "वीडियो के थंबनेल और HEIC फ़ोटो की छवियाँ", + "mediaDetails": "ऑडियो और वीडियो की अवधि और ट्रैक", + "fastSearch": "फ़ाइलों की सामग्री में तेज़ खोज", + "pdfSearch": "खोज में PDF का पाठ", + "rawPreviews": "RAW फ़ोटो के पूर्वावलोकन", + "copyProgress": "बड़ी कॉपी और स्थानांतरण की प्रगति", + "archives": ".zip के अलावा अन्य आर्काइव ब्राउज़ करना और निकालना" + } + } }, "branding": { "subtitle": "इंटरफ़ेस में दिखने वाले एप्लिकेशन नाम और लोगो को अनुकूलित करें।", @@ -446,7 +467,8 @@ "fileTooLargeForDataUri": "फ़ाइल बहुत बड़ी है। कृपया एक छोटी छवि उपयोग करें (बेहतर परिणामों के लिए 500KB से कम)", "preview": "पूर्वावलोकन", "showPoweredBy": "अट्रिब्यूशन लिंक दिखाएँ", - "showPoweredByHelp": "प्रोजेक्ट को सपोर्ट करने के लिए फ़ुटर में 'Powered by nextExplorer' लिंक दिखाएँ" + "showPoweredByHelp": "प्रोजेक्ट को सपोर्ट करने के लिए फ़ुटर में 'Powered by nextExplorer' लिंक दिखाएँ", + "defaultLogoSelected": "डिफ़ॉल्ट लोगो चुना गया। लागू करने के लिए सहेजें पर क्लिक करें।" }, "userPreferences": { "title": "उपयोगकर्ता प्राथमिकताएँ", @@ -508,7 +530,8 @@ "browse": "फ़ोल्डर चुनें", "pathInvalid": "यह पथ किसी वॉल्यूम के भीतर नहीं है, इसलिए यह नियम कभी लागू नहीं होगा।", "pathMissing": "इस पथ पर कोई फ़ोल्डर नहीं है। नियम उसी पथ का उपयोग करता है जैसा NextExplorer दिखाता है, वॉल्यूम के नाम से शुरू करके।", - "useSuggestion": "{path} उपयोग करें" + "useSuggestion": "{path} उपयोग करें", + "title": "एक्सेस" }, "comingSoon": { "subtitle": "यह सेटिंग सेक्शन बनाया जा रहा है। जुड़े रहें!" @@ -554,7 +577,8 @@ "selectedPath": "चयनित", "volumePath": "वॉल्यूम पथ", "pathCannotChange": "निर्माण के बाद पथ बदला नहीं जा सकता। यदि आवश्यक हो तो इसे हटा दें और नया बनाएँ।", - "accessMode": "पहुँच मोड" + "accessMode": "पहुँच मोड", + "noUsers": "कोई उपयोगकर्ता उपलब्ध नहीं" }, "userDetails": { "profileTab": "Profile", @@ -976,7 +1000,13 @@ "download": "डाउनलोड करें" }, "deleteShareTitle": "शेयर हटाएँ?", - "deleteShareMessage": "इस लिंक वाले लोग अब साझा किए गए आइटम तक पहुँच नहीं कर पाएँगे।" + "deleteShareMessage": "इस लिंक वाले लोग अब साझा किए गए आइटम तक पहुँच नहीं कर पाएँगे।", + "creating": "बनाया जा रहा है…", + "expires": "समाप्त होगा", + "loadingUsers": "उपयोगकर्ता लोड हो रहे हैं...", + "password": "पासवर्ड", + "protected": "सुरक्षित", + "sharedWith": "इनके साथ साझा" }, "trash": { "title": "रीसायकल बिन", diff --git a/frontend/src/i18n/locales/it.json b/frontend/src/i18n/locales/it.json index aa0db71d..23847256 100644 --- a/frontend/src/i18n/locales/it.json +++ b/frontend/src/i18n/locales/it.json @@ -70,7 +70,8 @@ "unsavedChanges": "Hai modifiche non salvate", "readonly": "Sola lettura", "readwrite": "Lettura/Scrittura", - "add": "Aggiungi" + "add": "Aggiungi", + "back": "Indietro" }, "editor": { "editing": "Editing", @@ -332,7 +333,9 @@ "camera": "Fotocamera: {makeModel}", "lens": "Obiettivo: {lens}", "duration": "Durata: {seconds}s", - "versions": "Versioni" + "versions": "Versioni", + "folderSizeExcluded": "Escluso", + "folderSizeExcludedHelp": "Questo percorso è escluso dall'indicizzazione delle dimensioni delle cartelle." }, "auth": { "preparing": "Preparazione del tuo explorer…", @@ -428,7 +431,25 @@ "gitCommit": "Commit git", "gitCommitHelp": "Revisione sorgente incorporata al momento della build.", "branch": "Branch", - "branchHelp": "Branch git al momento della build." + "branchHelp": "Branch git al momento della build.", + "tools": { + "title": "Strumenti opzionali", + "subtitle": "Cosa sa fare questa istanza e cosa aggiungerebbe uno strumento mancante. Il server dice lo stesso nel suo log all'avvio.", + "installed": "Installato", + "missing": "Non installato", + "unused": "Non usato qui", + "package": "Pacchetto", + "cannotOpen": "Non può aprire: {formats}", + "gives": { + "videoThumbnails": "Miniature dei video e immagini delle foto HEIC", + "mediaDetails": "Durata e tracce di audio e video", + "fastSearch": "Ricerca rapida nel contenuto dei file", + "pdfSearch": "Il testo dei PDF nella ricerca", + "rawPreviews": "Anteprime delle foto RAW", + "copyProgress": "Avanzamento di copie e spostamenti di grandi dimensioni", + "archives": "Sfogliare ed estrarre archivi oltre .zip" + } + } }, "branding": { "subtitle": "Personalizza il nome dell’applicazione e il logo visualizzati in tutta l’interfaccia.", @@ -446,7 +467,8 @@ "fileTooLargeForDataUri": "Il file è troppo grande. Usa un’immagine più piccola (sotto i 500 KB per risultati migliori)", "preview": "Anteprima", "showPoweredBy": "Mostra link di attribuzione", - "showPoweredByHelp": "Mostra nel piè di pagina un link 'Powered by nextExplorer' per supportare il progetto" + "showPoweredByHelp": "Mostra nel piè di pagina un link 'Powered by nextExplorer' per supportare il progetto", + "defaultLogoSelected": "Logo predefinito selezionato. Fai clic su Salva per applicare." }, "userPreferences": { "title": "Preferenze utente", @@ -508,7 +530,8 @@ "browse": "Scegli una cartella", "pathInvalid": "Questo percorso non è in nessun volume, quindi la regola non si applicherà mai.", "pathMissing": "Nessuna cartella a questo percorso. Una regola usa il percorso come lo mostra NextExplorer, a partire dal nome del volume.", - "useSuggestion": "Usa {path}" + "useSuggestion": "Usa {path}", + "title": "Accesso" }, "comingSoon": { "subtitle": "Questa sezione delle impostazioni è in costruzione. Resta sintonizzato!" @@ -554,7 +577,8 @@ "selectedPath": "Selezionato", "volumePath": "Percorso volume", "pathCannotChange": "Il percorso non può essere cambiato dopo la creazione. Rimuovi questo volume e creane uno nuovo se necessario.", - "accessMode": "Modalità di accesso" + "accessMode": "Modalità di accesso", + "noUsers": "Nessun utente disponibile" }, "userDetails": { "profileTab": "Profile", @@ -976,7 +1000,13 @@ "download": "Scarica" }, "deleteShareTitle": "Rimuovere la condivisione?", - "deleteShareMessage": "Le persone con questo link non potranno più accedere all’elemento condiviso." + "deleteShareMessage": "Le persone con questo link non potranno più accedere all’elemento condiviso.", + "creating": "Creazione…", + "expires": "Scade", + "loadingUsers": "Caricamento utenti...", + "password": "Password", + "protected": "Protetto", + "sharedWith": "Condiviso con" }, "trash": { "title": "Cestino", diff --git a/frontend/src/i18n/locales/ko.json b/frontend/src/i18n/locales/ko.json index f56cefa6..81b9ec9b 100644 --- a/frontend/src/i18n/locales/ko.json +++ b/frontend/src/i18n/locales/ko.json @@ -70,7 +70,8 @@ "unsavedChanges": "저장되지 않은 변경 사항이 있습니다", "readonly": "읽기 전용", "readwrite": "읽기/쓰기", - "add": "추가" + "add": "추가", + "back": "뒤로 가기" }, "editor": { "editing": "편집 중", @@ -332,7 +333,9 @@ "camera": "카메라: {makeModel}", "lens": "렌즈: {lens}", "duration": "길이: {seconds}초", - "versions": "버전" + "versions": "버전", + "folderSizeExcluded": "제외됨", + "folderSizeExcludedHelp": "이 경로는 폴더 크기 인덱싱에서 제외됩니다." }, "auth": { "preparing": "탐색기를 준비하는 중입니다…", @@ -428,7 +431,25 @@ "gitCommit": "Git 커밋 해시", "gitCommitHelp": "빌드 시점에 삽입된 커밋 해시입니다.", "branch": "브랜치", - "branchHelp": "빌드 시점의 Git 브랜치입니다." + "branchHelp": "빌드 시점의 Git 브랜치입니다.", + "tools": { + "title": "선택 도구", + "subtitle": "이 인스턴스가 할 수 있는 일과, 없는 도구를 설치하면 추가되는 기능입니다. 서버는 시작할 때 로그에도 같은 내용을 기록합니다.", + "installed": "설치됨", + "missing": "설치 안 됨", + "unused": "여기서는 사용 안 함", + "package": "패키지", + "cannotOpen": "열 수 없음: {formats}", + "gives": { + "videoThumbnails": "동영상 미리보기 이미지와 HEIC 사진의 정지 이미지", + "mediaDetails": "오디오와 동영상의 길이와 트랙", + "fastSearch": "파일 내용 빠른 검색", + "pdfSearch": "검색에 포함되는 PDF 텍스트", + "rawPreviews": "RAW 사진 미리보기", + "copyProgress": "대용량 복사 및 이동 진행률", + "archives": ".zip 외 압축 파일 탐색 및 추출" + } + } }, "branding": { "subtitle": "인터페이스에 표시되는 애플리케이션 이름과 로고를 커스터마이징하세요.", @@ -446,7 +467,8 @@ "fileTooLargeForDataUri": "파일 크기가 너무 큽니다. 더 작은 이미지를 사용해주세요 (500KB 이하가 가장 좋음)", "preview": "미리보기", "showPoweredBy": "기여 링크 표시", - "showPoweredByHelp": "페이지 하단에 'Powered by nextExplorer' 링크를 표시하여 프로젝트를 지원해주세요." + "showPoweredByHelp": "페이지 하단에 'Powered by nextExplorer' 링크를 표시하여 프로젝트를 지원해주세요.", + "defaultLogoSelected": "기본 로고가 선택되었습니다. 적용하려면 저장을 클릭하세요." }, "userPreferences": { "title": "유저 설정", @@ -508,7 +530,8 @@ "browse": "폴더 선택", "pathInvalid": "이 경로는 어떤 볼륨에도 속하지 않으므로 규칙이 적용되지 않습니다.", "pathMissing": "이 경로에 폴더가 없습니다. 규칙은 볼륨 이름부터 시작하여 NextExplorer에 표시되는 경로를 사용합니다.", - "useSuggestion": "{path} 사용" + "useSuggestion": "{path} 사용", + "title": "접근" }, "comingSoon": { "subtitle": "이 설정 항목은 현재 개발 중입니다. 기대해주세요!" @@ -554,7 +577,8 @@ "selectedPath": "선택된 경로", "volumePath": "볼륨 경로", "pathCannotChange": "생성된 이후에는 경로를 변경할 수 없습니다. 필요 시에는 이 볼륨을 제거하고 새로운 볼륨을 생성해야 합니다.", - "accessMode": "접근 모드" + "accessMode": "접근 모드", + "noUsers": "유저가 없습니다" }, "userDetails": { "profileTab": "프로필", @@ -976,7 +1000,13 @@ "download": "다운로드" }, "deleteShareTitle": "공유를 제거할까요?", - "deleteShareMessage": "이 링크가 있는 사용자는 더 이상 공유 항목에 접근할 수 없습니다." + "deleteShareMessage": "이 링크가 있는 사용자는 더 이상 공유 항목에 접근할 수 없습니다.", + "creating": "생성 중…", + "expires": "만료", + "loadingUsers": "유저 정보 로딩 중...", + "password": "비밀번호", + "protected": "보호됨", + "sharedWith": "공유 대상" }, "trash": { "title": "휴지통", diff --git a/frontend/src/i18n/locales/nl.json b/frontend/src/i18n/locales/nl.json index 452c67bc..2ce4f92e 100644 --- a/frontend/src/i18n/locales/nl.json +++ b/frontend/src/i18n/locales/nl.json @@ -70,7 +70,8 @@ "unsavedChanges": "Er zijn niet-opgeslagen wijzigingen", "readonly": "Alleen-lezen", "readwrite": "Lezen/schrijven", - "add": "Toevoegen" + "add": "Toevoegen", + "back": "Terug" }, "editor": { "editing": "Bewerken", @@ -332,7 +333,9 @@ "camera": "Camera: {makeModel}", "lens": "Lens: {lens}", "duration": "Duur: {seconds}s", - "versions": "Versies" + "versions": "Versies", + "folderSizeExcluded": "Uitgesloten", + "folderSizeExcludedHelp": "Dit pad is uitgesloten van het indexeren van mapgroottes." }, "auth": { "preparing": "Verkenner voorbereiden…", @@ -428,7 +431,25 @@ "gitCommit": "Git commit", "gitCommitHelp": "Bronrevisie die tijdens het bouwen is vastgelegd.", "branch": "Branch", - "branchHelp": "Git-branch tijdens het bouwen." + "branchHelp": "Git-branch tijdens het bouwen.", + "tools": { + "title": "Optionele hulpmiddelen", + "subtitle": "Wat deze instantie kan en wat een ontbrekend hulpmiddel zou toevoegen. De server zegt hetzelfde in zijn log bij het starten.", + "installed": "Geïnstalleerd", + "missing": "Niet geïnstalleerd", + "unused": "Hier niet gebruikt", + "package": "Pakket", + "cannotOpen": "Kan niet openen: {formats}", + "gives": { + "videoThumbnails": "Miniaturen van video's en stilstaande beelden uit HEIC-foto's", + "mediaDetails": "Duur en sporen van audio en video", + "fastSearch": "Snel zoeken in de inhoud van bestanden", + "pdfSearch": "De tekst van pdf's in de zoekfunctie", + "rawPreviews": "Voorbeelden van RAW-foto's", + "copyProgress": "Voortgang bij grote kopieer- en verplaatsacties", + "archives": "Archieven buiten .zip bekijken en uitpakken" + } + } }, "branding": { "subtitle": "Pas de naam en het logo van de applicatie aan die door de hele interface worden weergegeven.", @@ -446,7 +467,8 @@ "uploading": "Uploaden…", "uploadSuccess": "Logo succesvol geüpload", "uploadError": "Logo uploaden mislukt", - "fileTooLargeForDataUri": "Bestand is te groot. Gebruik een kleinere afbeelding (voor het beste resultaat minder dan 500 KB)." + "fileTooLargeForDataUri": "Bestand is te groot. Gebruik een kleinere afbeelding (voor het beste resultaat minder dan 500 KB).", + "defaultLogoSelected": "Standaardlogo geselecteerd. Klik op Opslaan om het toe te passen." }, "userPreferences": { "title": "Gebruikersvoorkeuren", @@ -508,7 +530,8 @@ "browse": "Map kiezen", "pathInvalid": "Dit pad ligt niet in een volume, dus de regel wordt nooit toegepast.", "pathMissing": "Er is geen map op dit pad. Een regel gebruikt het pad zoals NextExplorer het toont, beginnend met de naam van het volume.", - "useSuggestion": "{path} gebruiken" + "useSuggestion": "{path} gebruiken", + "title": "Toegang" }, "comingSoon": { "subtitle": "Deze sectie Instellingen wordt momenteel gebouwd. Blijf op de hoogte!" @@ -554,7 +577,8 @@ "pathCannotChange": "Het pad kan na aanmaak niet worden gewijzigd. Verwijder dit volume en maak zonodig een nieuwe aan.", "accessMode": "Toegangsmodus", "makeAdmin": "Beheerder maken", - "editUser": "Gebruiker bewerken" + "editUser": "Gebruiker bewerken", + "noUsers": "Geen gebruikers beschikbaar" }, "userDetails": { "profileTab": "Profiel", @@ -959,7 +983,13 @@ "noMyShares": "U hebt nog geen shares aangemaakt.", "noMySharesDescription": "Maak een share van een bestand of map om die hier te zien.", "mySharesCount": "{count} actieve sharelink | {count} actieve sharelinks", - "sharedWithUsers": "{count} gebruiker | {count} gebruikers" + "sharedWithUsers": "{count} gebruiker | {count} gebruikers", + "creating": "Bezig met maken…", + "expires": "Verloopt", + "loadingUsers": "Gebruikers laden...", + "password": "Wachtwoord", + "protected": "Beveiligd", + "sharedWith": "Gedeeld met" }, "mediaPreview": { "download": "Media downloaden", diff --git a/frontend/src/i18n/locales/pl.json b/frontend/src/i18n/locales/pl.json index 5178fd9e..46323de3 100644 --- a/frontend/src/i18n/locales/pl.json +++ b/frontend/src/i18n/locales/pl.json @@ -70,7 +70,8 @@ "unsavedChanges": "Masz niezapisane zmiany", "readonly": "Tylko do odczytu", "readwrite": "Odczyt/Zapis", - "add": "Dodaj" + "add": "Dodaj", + "back": "Wstecz" }, "editor": { "editing": "Editing", @@ -332,7 +333,9 @@ "camera": "Aparat: {makeModel}", "lens": "Obiektyw: {lens}", "duration": "Czas trwania: {seconds}s", - "versions": "Wersje" + "versions": "Wersje", + "folderSizeExcluded": "Wykluczone", + "folderSizeExcludedHelp": "Ta ścieżka jest wykluczona z indeksowania rozmiaru folderów." }, "auth": { "preparing": "Trwa przygotowywanie eksploratora…", @@ -428,7 +431,25 @@ "gitCommit": "Commit Git", "gitCommitHelp": "Rewizja źródła osadzona w czasie kompilacji.", "branch": "Gałąź", - "branchHelp": "Gałąź Git w czasie kompilacji." + "branchHelp": "Gałąź Git w czasie kompilacji.", + "tools": { + "title": "Narzędzia opcjonalne", + "subtitle": "Co potrafi ta instancja i co dodałoby brakujące narzędzie. Serwer mówi to samo w swoim dzienniku przy starcie.", + "installed": "Zainstalowane", + "missing": "Niezainstalowane", + "unused": "Nieużywane tutaj", + "package": "Pakiet", + "cannotOpen": "Nie otworzy: {formats}", + "gives": { + "videoThumbnails": "Miniatury filmów i klatki ze zdjęć HEIC", + "mediaDetails": "Czas trwania i ścieżki audio oraz wideo", + "fastSearch": "Szybkie wyszukiwanie w treści plików", + "pdfSearch": "Tekst plików PDF w wyszukiwaniu", + "rawPreviews": "Podglądy zdjęć RAW", + "copyProgress": "Postęp dużych operacji kopiowania i przenoszenia", + "archives": "Przeglądanie i rozpakowywanie archiwów innych niż .zip" + } + } }, "branding": { "subtitle": "Dostosuj nazwę aplikacji i logo wyświetlane w całym interfejsie.", @@ -446,7 +467,8 @@ "fileTooLargeForDataUri": "Plik jest za duży. Użyj mniejszego obrazu (poniżej 500 KB dla najlepszych efektów)", "preview": "Podgląd", "showPoweredBy": "Wyświetl link z atrybucją", - "showPoweredByHelp": "Pokaż w stopce link „Powered by nextExplorer”, aby wesprzeć projekt" + "showPoweredByHelp": "Pokaż w stopce link „Powered by nextExplorer”, aby wesprzeć projekt", + "defaultLogoSelected": "Wybrano domyślne logo. Kliknij Zapisz, aby zastosować." }, "userPreferences": { "title": "Preferencje użytkownika", @@ -508,7 +530,8 @@ "browse": "Wybierz folder", "pathInvalid": "Ta ścieżka nie leży w żadnym woluminie, więc reguła nigdy nie zadziała.", "pathMissing": "Pod tą ścieżką nie ma folderu. Reguła używa ścieżki tak, jak pokazuje ją NextExplorer, zaczynając od nazwy woluminu.", - "useSuggestion": "Użyj {path}" + "useSuggestion": "Użyj {path}", + "title": "Dostęp" }, "comingSoon": { "subtitle": "Ta sekcja ustawień jest w przygotowaniu. Bądź na bieżąco!" @@ -554,7 +577,8 @@ "selectedPath": "Wybrano", "volumePath": "Ścieżka woluminu", "pathCannotChange": "Ścieżki nie można zmienić po utworzeniu. Usuń ten wolumin i utwórz nowy w razie potrzeby.", - "accessMode": "Tryb dostępu" + "accessMode": "Tryb dostępu", + "noUsers": "Brak dostępnych użytkowników" }, "userDetails": { "profileTab": "Profile", @@ -976,7 +1000,13 @@ "download": "Pobierz" }, "deleteShareTitle": "Usunąć udostępnienie?", - "deleteShareMessage": "Osoby z tym linkiem nie będą już mogły uzyskać dostępu do udostępnionego elementu." + "deleteShareMessage": "Osoby z tym linkiem nie będą już mogły uzyskać dostępu do udostępnionego elementu.", + "creating": "Tworzenie…", + "expires": "Wygasa", + "loadingUsers": "Ładowanie użytkowników...", + "password": "Hasło", + "protected": "Chronione", + "sharedWith": "Udostępnione dla" }, "trash": { "title": "Kosz", diff --git a/frontend/src/i18n/locales/pt-BR.json b/frontend/src/i18n/locales/pt-BR.json index 73fb0f96..e4bdeed3 100644 --- a/frontend/src/i18n/locales/pt-BR.json +++ b/frontend/src/i18n/locales/pt-BR.json @@ -70,7 +70,8 @@ "unsavedChanges": "Você tem alterações não salvas", "readonly": "Somente leitura", "readwrite": "Leitura/Escrita", - "add": "Adicionar" + "add": "Adicionar", + "back": "Voltar" }, "editor": { "editing": "Editando", @@ -332,7 +333,9 @@ "camera": "Câmera: {makeModel}", "lens": "Lente: {lens}", "duration": "Duração: {seconds}s", - "versions": "Versões" + "versions": "Versões", + "folderSizeExcluded": "Excluído", + "folderSizeExcludedHelp": "Este caminho está fora da indexação de tamanho de pastas." }, "auth": { "preparing": "Preparando seu explorador…", @@ -428,7 +431,25 @@ "gitCommit": "Commit Git", "gitCommitHelp": "Revisão do código-fonte embutida na compilação.", "branch": "Branch", - "branchHelp": "Branch Git na compilação." + "branchHelp": "Branch Git na compilação.", + "tools": { + "title": "Ferramentas opcionais", + "subtitle": "O que esta instância sabe fazer e o que uma ferramenta ausente acrescentaria. O servidor diz o mesmo no seu log ao iniciar.", + "installed": "Instalada", + "missing": "Não instalada", + "unused": "Não usada aqui", + "package": "Pacote", + "cannotOpen": "Não abre: {formats}", + "gives": { + "videoThumbnails": "Miniaturas de vídeos e imagens de fotos HEIC", + "mediaDetails": "Duração e faixas de áudio e vídeo", + "fastSearch": "Busca rápida dentro dos arquivos", + "pdfSearch": "O texto dos PDFs na busca", + "rawPreviews": "Pré-visualizações de fotos RAW", + "copyProgress": "Progresso em cópias e movimentações grandes", + "archives": "Navegar e extrair arquivos compactados além de .zip" + } + } }, "branding": { "subtitle": "Personalize o nome e o logotipo do aplicativo exibidos em toda a interface.", @@ -446,7 +467,8 @@ "fileTooLargeForDataUri": "O arquivo é muito grande. Use uma imagem menor (menos de 500KB para melhores resultados)", "preview": "Pré-visualização", "showPoweredBy": "Exibir link de atribuição", - "showPoweredByHelp": "Exibir um link 'Desenvolvido por nextExplorer' no rodapé para apoiar o projeto" + "showPoweredByHelp": "Exibir um link 'Desenvolvido por nextExplorer' no rodapé para apoiar o projeto", + "defaultLogoSelected": "Logotipo padrão selecionado. Clique em Salvar para aplicar." }, "userPreferences": { "title": "Preferências do Usuário", @@ -508,7 +530,8 @@ "browse": "Escolher uma pasta", "pathInvalid": "Este caminho não está dentro de nenhum volume, então a regra nunca será aplicada.", "pathMissing": "Não há pasta neste caminho. Uma regra usa o caminho como o NextExplorer o mostra, começando pelo nome do volume.", - "useSuggestion": "Usar {path}" + "useSuggestion": "Usar {path}", + "title": "Acesso" }, "comingSoon": { "subtitle": "Esta seção de configurações está em construção. Aguarde!" @@ -554,7 +577,8 @@ "selectedPath": "Selecionado", "volumePath": "Caminho do Volume", "pathCannotChange": "O caminho não pode ser alterado após a criação. Remova este volume e crie um novo se necessário.", - "accessMode": "Modo de Acesso" + "accessMode": "Modo de Acesso", + "noUsers": "Nenhum usuário disponível" }, "userDetails": { "profileTab": "Perfil", @@ -976,7 +1000,13 @@ "removeShare": "Remover compartilhamento", "sharedWithAnyone": "Todos", "sharedWithUsers": "{count} usuário | {count} usuários", - "expiresNever": "Nunca" + "expiresNever": "Nunca", + "creating": "Criando…", + "expires": "Expira em", + "loadingUsers": "Carregando usuários...", + "password": "Senha", + "protected": "Protegido", + "sharedWith": "Compartilhado com" }, "trash": { "title": "Lixeira", diff --git a/frontend/src/i18n/locales/ro.json b/frontend/src/i18n/locales/ro.json index 493d0efc..3064e626 100644 --- a/frontend/src/i18n/locales/ro.json +++ b/frontend/src/i18n/locales/ro.json @@ -70,7 +70,8 @@ "unsavedChanges": "Ai modificări nesalvate", "readonly": "Doar citire", "readwrite": "Citire/Scriere", - "add": "Adaugă" + "add": "Adaugă", + "back": "Înapoi" }, "editor": { "editing": "Editing", @@ -332,7 +333,9 @@ "camera": "Cameră: {makeModel}", "lens": "Obiectiv: {lens}", "duration": "Durată: {seconds}s", - "versions": "Versiuni" + "versions": "Versiuni", + "folderSizeExcluded": "Exclus", + "folderSizeExcludedHelp": "Această cale este exclusă din indexarea dimensiunii dosarelor." }, "auth": { "preparing": "Se pregătește explorerul…", @@ -428,7 +431,25 @@ "gitCommit": "Commit git", "gitCommitHelp": "Revizia sursei integrată la momentul build-ului.", "branch": "Branch", - "branchHelp": "Branch-ul git la momentul build-ului." + "branchHelp": "Branch-ul git la momentul build-ului.", + "tools": { + "title": "Instrumente opționale", + "subtitle": "Ce poate face această instanță și ce ar adăuga un instrument lipsă. Serverul spune același lucru în jurnal la pornire.", + "installed": "Instalat", + "missing": "Neinstalat", + "unused": "Nefolosit aici", + "package": "Pachet", + "cannotOpen": "Nu poate deschide: {formats}", + "gives": { + "videoThumbnails": "Miniaturi video și imagini din fotografiile HEIC", + "mediaDetails": "Durata și pistele audio și video", + "fastSearch": "Căutare rapidă în conținutul fișierelor", + "pdfSearch": "Textul PDF-urilor în căutare", + "rawPreviews": "Previzualizări ale fotografiilor RAW", + "copyProgress": "Progresul copierilor și mutărilor mari", + "archives": "Răsfoirea și extragerea arhivelor dincolo de .zip" + } + } }, "branding": { "subtitle": "Personalizează numele aplicației și sigla afișate în întreaga interfață.", @@ -446,7 +467,8 @@ "fileTooLargeForDataUri": "Fișierul este prea mare. Folosește o imagine mai mică (sub 500 KB pentru cele mai bune rezultate)", "preview": "Previzualizare", "showPoweredBy": "Afișează linkul de atribuire", - "showPoweredByHelp": "Afișează în subsol un link „Powered by nextExplorer” pentru a ajuta la susținerea proiectului" + "showPoweredByHelp": "Afișează în subsol un link „Powered by nextExplorer” pentru a ajuta la susținerea proiectului", + "defaultLogoSelected": "Siglă implicită selectată. Faceți clic pe Salvare pentru a aplica." }, "userPreferences": { "title": "Preferințe utilizator", @@ -508,7 +530,8 @@ "browse": "Alegeți un dosar", "pathInvalid": "Această cale nu se află în niciun volum, așa că regula nu se va aplica niciodată.", "pathMissing": "Nu există niciun dosar la această cale. O regulă folosește calea așa cum o arată NextExplorer, începând cu numele volumului.", - "useSuggestion": "Folosiți {path}" + "useSuggestion": "Folosiți {path}", + "title": "Acces" }, "comingSoon": { "subtitle": "Această secțiune de setări este în lucru. Revino în curând!" @@ -554,7 +577,8 @@ "selectedPath": "Selectat", "volumePath": "Cale volum", "pathCannotChange": "Calea nu poate fi schimbată după creare. Șterge și creează unul nou dacă e necesar.", - "accessMode": "Mod acces" + "accessMode": "Mod acces", + "noUsers": "Nu există utilizatori disponibili" }, "userDetails": { "profileTab": "Profile", @@ -976,7 +1000,13 @@ "download": "Descarcă" }, "deleteShareTitle": "Elimini partajarea?", - "deleteShareMessage": "Persoanele cu acest link nu vor mai putea accesa elementul partajat." + "deleteShareMessage": "Persoanele cu acest link nu vor mai putea accesa elementul partajat.", + "creating": "Se creează…", + "expires": "Expiră", + "loadingUsers": "Se încarcă utilizatorii...", + "password": "Parolă", + "protected": "Protejat", + "sharedWith": "Partajat cu" }, "trash": { "title": "Coș de gunoi", diff --git a/frontend/src/i18n/locales/ru.json b/frontend/src/i18n/locales/ru.json index 91be56fe..c0efffdf 100644 --- a/frontend/src/i18n/locales/ru.json +++ b/frontend/src/i18n/locales/ru.json @@ -70,7 +70,8 @@ "unsavedChanges": "У вас есть несохраненные изменения", "readonly": "Только чтение", "readwrite": "Чтение/Запись", - "add": "Добавить" + "add": "Добавить", + "back": "Назад" }, "editor": { "editing": "Editing", @@ -332,7 +333,9 @@ "camera": "Камера: {makeModel}", "lens": "Объектив: {lens}", "duration": "Длительность: {seconds}с", - "versions": "Версии" + "versions": "Версии", + "folderSizeExcluded": "Исключено", + "folderSizeExcludedHelp": "Этот путь исключен из индексации размеров папок." }, "auth": { "preparing": "Подготовка проводника…", @@ -428,7 +431,25 @@ "gitCommit": "Git коммит", "gitCommitHelp": "Ревизия исходного кода, встроенная при сборке.", "branch": "Ветка", - "branchHelp": "Ветка Git на момент сборки." + "branchHelp": "Ветка Git на момент сборки.", + "tools": { + "title": "Дополнительные инструменты", + "subtitle": "Что умеет этот экземпляр и что добавил бы недостающий инструмент. Сервер пишет то же самое в журнал при запуске.", + "installed": "Установлен", + "missing": "Не установлен", + "unused": "Здесь не используется", + "package": "Пакет", + "cannotOpen": "Не открывает: {formats}", + "gives": { + "videoThumbnails": "Миниатюры видео и кадры из фото HEIC", + "mediaDetails": "Длительность и дорожки аудио и видео", + "fastSearch": "Быстрый поиск по содержимому файлов", + "pdfSearch": "Текст PDF в поиске", + "rawPreviews": "Предпросмотр RAW-фотографий", + "copyProgress": "Прогресс больших копирований и перемещений", + "archives": "Просмотр и распаковка архивов помимо .zip" + } + } }, "branding": { "subtitle": "Настройте название приложения и логотип для интерфейса.", @@ -446,7 +467,8 @@ "fileTooLargeForDataUri": "Файл слишком велик. Используйте изображение поменьше (до 500КБ для лучшего результата)", "preview": "Предпросмотр", "showPoweredBy": "Отображать ссылку на автора", - "showPoweredByHelp": "Показывать ссылку 'Powered by nextExplorer' в футере для поддержки проекта" + "showPoweredByHelp": "Показывать ссылку 'Powered by nextExplorer' в футере для поддержки проекта", + "defaultLogoSelected": "Выбран логотип по умолчанию. Нажмите «Сохранить», чтобы применить." }, "userPreferences": { "title": "Настройки пользователя", @@ -508,7 +530,8 @@ "browse": "Выбрать папку", "pathInvalid": "Этот путь не находится ни в одном томе, поэтому правило никогда не сработает.", "pathMissing": "По этому пути нет папки. Правило использует путь так, как его показывает NextExplorer, начиная с имени тома.", - "useSuggestion": "Использовать {path}" + "useSuggestion": "Использовать {path}", + "title": "Доступ" }, "comingSoon": { "subtitle": "Этот раздел настроек находится в разработке. Следите за обновлениями!" @@ -554,7 +577,8 @@ "selectedPath": "Выбрано", "volumePath": "Путь к тому", "pathCannotChange": "Путь нельзя изменить после создания. При необходимости удалите этот том и создайте новый.", - "accessMode": "Режим доступа" + "accessMode": "Режим доступа", + "noUsers": "Нет доступных пользователей" }, "userDetails": { "profileTab": "Profile", @@ -976,7 +1000,13 @@ "download": "Скачать" }, "deleteShareTitle": "Удалить общий доступ?", - "deleteShareMessage": "Пользователи с этой ссылкой больше не смогут получить доступ к общему элементу." + "deleteShareMessage": "Пользователи с этой ссылкой больше не смогут получить доступ к общему элементу.", + "creating": "Создание…", + "expires": "Истекает", + "loadingUsers": "Загрузка пользователей...", + "password": "Пароль", + "protected": "Защищено", + "sharedWith": "Кому доступно" }, "trash": { "title": "Корзина", diff --git a/frontend/src/i18n/locales/sv.json b/frontend/src/i18n/locales/sv.json index 1c683741..f83ebcb5 100644 --- a/frontend/src/i18n/locales/sv.json +++ b/frontend/src/i18n/locales/sv.json @@ -70,7 +70,8 @@ "unsavedChanges": "Du har osparade ändringar", "readonly": "Skrivskyddad", "readwrite": "Läs/Skriv", - "add": "Lägg till" + "add": "Lägg till", + "back": "Bakåt" }, "editor": { "editing": "Editing", @@ -332,7 +333,9 @@ "camera": "Kamera: {makeModel}", "lens": "Lins: {lens}", "duration": "Speltid: {seconds}s", - "versions": "Versioner" + "versions": "Versioner", + "folderSizeExcluded": "Undantagen", + "folderSizeExcludedHelp": "Denna sökväg är undantagen från indexering av mappstorlekar." }, "auth": { "preparing": "Förbereder din utforskare…", @@ -428,7 +431,25 @@ "gitCommit": "Git commit", "gitCommitHelp": "Källkodsrevision inbäddad vid byggtid.", "branch": "Gren", - "branchHelp": "Git-gren vid byggtid." + "branchHelp": "Git-gren vid byggtid.", + "tools": { + "title": "Valfria verktyg", + "subtitle": "Vad den här instansen kan och vad ett saknat verktyg skulle tillföra. Servern säger samma sak i sin logg vid start.", + "installed": "Installerat", + "missing": "Inte installerat", + "unused": "Används inte här", + "package": "Paket", + "cannotOpen": "Kan inte öppna: {formats}", + "gives": { + "videoThumbnails": "Miniatyrer för videor och stillbilder ur HEIC-foton", + "mediaDetails": "Längd och spår för ljud och video", + "fastSearch": "Snabb sökning i filers innehåll", + "pdfSearch": "Texten i PDF:er i sökningen", + "rawPreviews": "Förhandsvisningar av RAW-foton", + "copyProgress": "Förlopp för stora kopieringar och flyttar", + "archives": "Bläddra i och packa upp arkiv utöver .zip" + } + } }, "branding": { "subtitle": "Anpassa appens namn och logotyp som visas i hela gränssnittet.", @@ -446,7 +467,8 @@ "fileTooLargeForDataUri": "Filen är för stor. Använd en mindre bild (under 500 KB för bästa resultat)", "preview": "Förhandsgranskning", "showPoweredBy": "Visa attribueringslänk", - "showPoweredByHelp": "Visa en \"Powered by nextExplorer\"-länk i sidfoten för att stödja projektet" + "showPoweredByHelp": "Visa en \"Powered by nextExplorer\"-länk i sidfoten för att stödja projektet", + "defaultLogoSelected": "Standardlogotyp vald. Klicka på Spara för att tillämpa." }, "userPreferences": { "title": "Användarpreferenser", @@ -508,7 +530,8 @@ "browse": "Välj en mapp", "pathInvalid": "Den här sökvägen ligger inte i någon volym, så regeln kommer aldrig att gälla.", "pathMissing": "Det finns ingen mapp på den här sökvägen. En regel använder sökvägen så som NextExplorer visar den, med volymens namn först.", - "useSuggestion": "Använd {path}" + "useSuggestion": "Använd {path}", + "title": "Åtkomst" }, "comingSoon": { "subtitle": "Denna inställningssektion håller på att byggas. Vänta och se!" @@ -554,7 +577,8 @@ "selectedPath": "Vald", "volumePath": "Volymsökväg", "pathCannotChange": "Sökvägen kan inte ändras i efterhand. Ta bort och skapa ny vid behov.", - "accessMode": "Åtkomstläge" + "accessMode": "Åtkomstläge", + "noUsers": "Inga användare tillgängliga" }, "userDetails": { "profileTab": "Profile", @@ -976,7 +1000,13 @@ "download": "Hämta" }, "deleteShareTitle": "Ta bort delning?", - "deleteShareMessage": "Personer med den här länken kommer inte längre åt det delade objektet." + "deleteShareMessage": "Personer med den här länken kommer inte längre åt det delade objektet.", + "creating": "Skapar…", + "expires": "Går ut", + "loadingUsers": "Läser in användare...", + "password": "Lösenord", + "protected": "Skyddad", + "sharedWith": "Delad med" }, "trash": { "title": "Papperskorg", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 72628b79..2b86cde0 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -70,7 +70,8 @@ "unsavedChanges": "您有未保存的更改", "readonly": "只读", "readwrite": "读/写", - "add": "添加" + "add": "添加", + "back": "后退" }, "editor": { "editing": "Editing", @@ -332,7 +333,9 @@ "camera": "相机:{makeModel}", "lens": "镜头:{lens}", "duration": "时长:{seconds}s", - "versions": "版本" + "versions": "版本", + "folderSizeExcluded": "已排除", + "folderSizeExcludedHelp": "此路径已从文件夹大小索引中排除。" }, "auth": { "preparing": "正在为您准备 Explorer…", @@ -428,7 +431,25 @@ "gitCommit": "Git 提交", "gitCommitHelp": "构建时嵌入的源代码修订版本。", "branch": "分支", - "branchHelp": "构建时的 Git 分支。" + "branchHelp": "构建时的 Git 分支。", + "tools": { + "title": "可选工具", + "subtitle": "此实例能做什么,以及安装缺少的工具会增加什么。服务器启动时也会在日志中说明这些。", + "installed": "已安装", + "missing": "未安装", + "unused": "此处未使用", + "package": "软件包", + "cannotOpen": "无法打开:{formats}", + "gives": { + "videoThumbnails": "视频缩略图,以及 HEIC 照片的静态图像", + "mediaDetails": "音频和视频的时长与轨道", + "fastSearch": "快速搜索文件内容", + "pdfSearch": "在搜索中包含 PDF 文本", + "rawPreviews": "RAW 照片预览", + "copyProgress": "大型复制和移动的进度", + "archives": "浏览和解压 .zip 以外的压缩包" + } + } }, "branding": { "subtitle": "自定义在界面中显示的应用名称和标志。", @@ -446,7 +467,8 @@ "fileTooLargeForDataUri": "文件过大。请使用更小的图片(建议小于 500KB 以获得最佳效果)", "preview": "预览", "showPoweredBy": "显示署名链接", - "showPoweredByHelp": "在页脚显示 “Powered by nextExplorer” 链接以支持项目" + "showPoweredByHelp": "在页脚显示 “Powered by nextExplorer” 链接以支持项目", + "defaultLogoSelected": "已选择默认徽标。点击\"保存\"以应用。" }, "userPreferences": { "title": "用户偏好", @@ -508,7 +530,8 @@ "browse": "选择文件夹", "pathInvalid": "此路径不在任何卷内,因此该规则永远不会生效。", "pathMissing": "此路径下没有文件夹。规则使用 NextExplorer 显示的路径,以卷名开头。", - "useSuggestion": "使用 {path}" + "useSuggestion": "使用 {path}", + "title": "访问" }, "comingSoon": { "subtitle": "此设置部分正在构建中,敬请期待!" @@ -554,7 +577,8 @@ "selectedPath": "已选择", "volumePath": "卷路径", "pathCannotChange": "创建后无法更改路径。如需更改,请删除此卷并重新创建。", - "accessMode": "访问模式" + "accessMode": "访问模式", + "noUsers": "没有可用用户" }, "userDetails": { "profileTab": "Profile", @@ -976,7 +1000,13 @@ "download": "下载" }, "deleteShareTitle": "移除共享?", - "deleteShareMessage": "拥有此链接的人员将无法再访问共享项目。" + "deleteShareMessage": "拥有此链接的人员将无法再访问共享项目。", + "creating": "正在创建…", + "expires": "到期", + "loadingUsers": "正在加载用户...", + "password": "密码", + "protected": "已保护", + "sharedWith": "共享对象" }, "trash": { "title": "回收站", diff --git a/frontend/src/i18n/locales/zh-TW.json b/frontend/src/i18n/locales/zh-TW.json index 6b40906c..c4c383ba 100644 --- a/frontend/src/i18n/locales/zh-TW.json +++ b/frontend/src/i18n/locales/zh-TW.json @@ -70,7 +70,8 @@ "unsavedChanges": "您有未儲存的變更", "readonly": "讀取", "readwrite": "讀寫", - "add": "新增" + "add": "新增", + "back": "後退" }, "editor": { "editing": "Editing", @@ -332,7 +333,9 @@ "camera": "相機:{makeModel}", "lens": "鏡頭:{lens}", "duration": "長度:{seconds}s", - "versions": "版本" + "versions": "版本", + "folderSizeExcluded": "已排除", + "folderSizeExcludedHelp": "此路徑已從檔案夾大小索引中排除。" }, "auth": { "preparing": "正在為您準備 Explorer…", @@ -428,7 +431,25 @@ "gitCommit": "Git 提交", "gitCommitHelp": "構建時提交的原始碼修訂版本。", "branch": "分支", - "branchHelp": "構建時的 Git 分支。" + "branchHelp": "構建時的 Git 分支。", + "tools": { + "title": "選用工具", + "subtitle": "此實例能做什麼,以及安裝缺少的工具會增加什麼。伺服器啟動時也會在記錄中說明這些。", + "installed": "已安裝", + "missing": "未安裝", + "unused": "此處未使用", + "package": "套件", + "cannotOpen": "無法開啟:{formats}", + "gives": { + "videoThumbnails": "影片縮圖,以及 HEIC 相片的靜態影像", + "mediaDetails": "音訊與影片的長度與音軌", + "fastSearch": "快速搜尋檔案內容", + "pdfSearch": "在搜尋中包含 PDF 文字", + "rawPreviews": "RAW 相片預覽", + "copyProgress": "大型複製與移動的進度", + "archives": "瀏覽並解壓縮 .zip 以外的壓縮檔" + } + } }, "branding": { "subtitle": "自訂在使用者界面中顯示的應用名稱和圖示。", @@ -446,7 +467,8 @@ "fileTooLargeForDataUri": "文件過大。請使用更小的圖片(建議小於 500KB 以獲得最佳效果)", "preview": "預覽", "showPoweredBy": "顯示簽名連結", - "showPoweredByHelp": "在頁尾顯示 “Powered by nextExplorer” 連結來支持專案" + "showPoweredByHelp": "在頁尾顯示 “Powered by nextExplorer” 連結來支持專案", + "defaultLogoSelected": "已選擇預設標誌。點選「儲存」以套用。" }, "userPreferences": { "title": "使用者偏好設定", @@ -508,7 +530,8 @@ "browse": "選擇資料夾", "pathInvalid": "此路徑不在任何磁碟區內,因此該規則永遠不會生效。", "pathMissing": "此路徑下沒有資料夾。規則使用 NextExplorer 顯示的路徑,以磁碟區名稱開頭。", - "useSuggestion": "使用 {path}" + "useSuggestion": "使用 {path}", + "title": "存取" }, "comingSoon": { "subtitle": "此設置部分正在建置中,敬請期待!" @@ -554,7 +577,8 @@ "selectedPath": "已選擇", "volumePath": "儲存卷路徑", "pathCannotChange": "建立後無法更改路徑。如需更改,請刪除此卷並重新建立。", - "accessMode": "存取模式" + "accessMode": "存取模式", + "noUsers": "沒有可用使用者" }, "userDetails": { "profileTab": "Profile", @@ -976,7 +1000,13 @@ "download": "下載" }, "deleteShareTitle": "移除分享?", - "deleteShareMessage": "擁有此連結的人將無法再存取分享的項目。" + "deleteShareMessage": "擁有此連結的人將無法再存取分享的項目。", + "creating": "正在建立…", + "expires": "到期時間", + "loadingUsers": "正在載入使用者...", + "password": "密碼", + "protected": "已保護", + "sharedWith": "分享對象" }, "trash": { "title": "資源回收筒", From d8f0fd7c5739026103acced8a1c8876f6082dea7 Mon Sep 17 00:00:00 2001 From: Benjy Date: Sat, 26 Sep 2026 17:37:19 +0200 Subject: [PATCH 2/6] Hold the catalogues to each other, which batch 33 promised and did not carry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Batch 33 of the plan was "the locale catalogues, with the key-parity test". The catalogues are at parity on `main` today; nothing holds them there. The test that was supposed to travel with them stayed in the fork, where it is a frontend spec and there is no frontend runner here to execute it. So it is written where the runner CI executes lives, reading the catalogues as files. Three things, each the failure it prevents: - **Every English key in every language.** A key added in English and nowhere else falls back to English: a reader seeing the wrong language, quieter than a raw key and just as wrong. - **No key English does not define.** A key left behind after it was renamed in English is dead weight nobody will ever see again. - **The placeholders survive translation.** `{count} items` translated without `{count}` says "items", with the number silently gone. All three pass as things stand. Each was checked by breaking one catalogue — a key removed from French, an invented key added to German, `{percent}` dropped from Spanish — and each assertion named its own mutation and nothing else. --- backend/tests/frontend-strings.test.js | 85 +++++++++++++++++++++++++- 1 file changed, 84 insertions(+), 1 deletion(-) diff --git a/backend/tests/frontend-strings.test.js b/backend/tests/frontend-strings.test.js index 25af4b32..41689ea2 100644 --- a/backend/tests/frontend-strings.test.js +++ b/backend/tests/frontend-strings.test.js @@ -18,7 +18,8 @@ import path from 'node:path'; */ const FRONTEND = path.join(__dirname, '..', '..', 'frontend', 'src'); -const CATALOGUE = path.join(FRONTEND, 'i18n', 'locales', 'en.json'); +const LOCALES = path.join(FRONTEND, 'i18n', 'locales'); +const CATALOGUE = path.join(LOCALES, 'en.json'); /** `t('a.b')`, `$t('a.b')`, `te('a.b')` — the literal calls, which is all that can be checked. */ const KEY_CALL = /(? { expect([...missing].sort()).toEqual([]); }); }); + +/** Every key in a catalogue, as dotted paths, so two catalogues can be compared. */ +const pathsIn = (node, prefix = '') => + Object.entries(node).flatMap(([key, value]) => + value && typeof value === 'object' ? pathsIn(value, `${prefix}${key}.`) : [`${prefix}${key}`] + ); + +const read = (locale) => JSON.parse(fs.readFileSync(path.join(LOCALES, `${locale}.json`), 'utf8')); + +const at = (catalogue, key) => key.split('.').reduce((node, part) => node?.[part], catalogue); + +/** `{name}`, `{0}` — what vue-i18n will substitute, and what a translation must keep. */ +const placeholdersIn = (value) => + new Set(typeof value === 'string' ? [...value.matchAll(/\{(\w+)\}/g)].map((m) => m[1]) : []); + +const locales = fs + .readdirSync(LOCALES) + .filter((name) => name.endsWith('.json')) + .map((name) => name.replace(/\.json$/, '')) + .filter((locale) => locale !== 'en'); + +/** + * The catalogues, held to each other. + * + * A key added in English and nowhere else falls back to English, which is a + * reader seeing the wrong language — quieter than a raw key and just as wrong. A + * key left behind in one catalogue after it was renamed in English is dead weight + * nobody will ever see again. And a translation that drops a placeholder loses + * whatever it stood for: `{count} items` translated without `{count}` says + * "items", with the number silently gone. + * + * Asserted here rather than in a frontend suite because this is where the runner + * that CI executes lives, and a test nothing runs holds nothing. + */ +describe('the translation catalogues', () => { + it('has more than one language to keep aligned', () => { + expect(locales.length).toBeGreaterThan(1); + }); + + it('ships every English key in every language', () => { + const english = pathsIn(JSON.parse(fs.readFileSync(CATALOGUE, 'utf8'))); + const missing = []; + + for (const locale of locales) { + const theirs = new Set(pathsIn(read(locale))); + for (const key of english) if (!theirs.has(key)) missing.push(`${locale}: ${key}`); + } + + expect(missing).toEqual([]); + }); + + it('defines no key English does not', () => { + const english = new Set(pathsIn(JSON.parse(fs.readFileSync(CATALOGUE, 'utf8')))); + const extra = []; + + for (const locale of locales) { + for (const key of pathsIn(read(locale))) + if (!english.has(key)) extra.push(`${locale}: ${key}`); + } + + expect(extra).toEqual([]); + }); + + it('keeps the placeholders the English string had', () => { + const english = JSON.parse(fs.readFileSync(CATALOGUE, 'utf8')); + const keys = pathsIn(english); + const lost = []; + + for (const locale of locales) { + const theirs = read(locale); + for (const key of keys) { + const wanted = placeholdersIn(at(english, key)); + if (!wanted.size) continue; + const got = placeholdersIn(at(theirs, key)); + const dropped = [...wanted].filter((name) => !got.has(name)); + if (dropped.length) lost.push(`${locale}: ${key} lost {${dropped.join('}, {')}}`); + } + } + + expect(lost).toEqual([]); + }); +}); From e2ee730bfa36492f56edd250d52d4f71ce312045 Mon Sep 17 00:00:00 2001 From: Benjy Date: Sat, 26 Sep 2026 17:10:43 +0200 Subject: [PATCH 3/6] Keep the session secret, so a restart does not sign everyone out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `auth.sessionSecret` fell back to `crypto.randomBytes(32)` whenever SESSION_SECRET was not set, and `configureSession` drew a second one of its own behind it. The sessions themselves survive a restart — they are rows in CACHE_DIR/sessions.db — but a cookie signed with the previous secret no longer verifies, so the row was read as a stranger's. Every restart, every upgrade and every crash asked everyone to sign in again, on the default configuration. The secrets derived from it went with it: ONLYOFFICE without ONLYOFFICE_SECRET, and the signed thumbnail links. So the first start draws one and keeps it in CONFIG_DIR, and later starts read it back. SESSION_SECRET still wins and nothing is written then. The file is written beside its final name and renamed into place, so a start interrupted half-way leaves either no file or a whole one, and it is created 0600 — nobody but the user the server runs as has business reading it. A CONFIG_DIR that cannot be written is not fatal: the start says, in words, that a secret is being used for this run only and that the next restart will sign everyone out, which is the behaviour it had all along. `configureSession` now takes the resolved value rather than drawing its own, so there is one secret and one place that decides it. --- backend/src/config/index.js | 3 +- backend/src/config/sessionSecret.js | 135 ++++++++++++++++++ backend/src/middleware/session.js | 9 +- backend/tests/config/session-secret.test.js | 146 ++++++++++++++++++++ 4 files changed, 287 insertions(+), 6 deletions(-) create mode 100644 backend/src/config/sessionSecret.js create mode 100644 backend/tests/config/session-secret.test.js diff --git a/backend/src/config/index.js b/backend/src/config/index.js index 70a12630..4bc9da34 100644 --- a/backend/src/config/index.js +++ b/backend/src/config/index.js @@ -1,6 +1,7 @@ const path = require('path'); const crypto = require('crypto'); const env = require('./env'); +const { resolveSessionSecret } = require('./sessionSecret'); const constants = require('./constants'); const loggingConfig = require('./logging'); const { parseByteSize } = require('../utils/env'); @@ -251,7 +252,7 @@ const authMode = determineAuthMode(); const auth = { enabled: authMode === 'disabled' ? false : env.AUTH_ENABLED !== false, - sessionSecret: env.SESSION_SECRET || crypto.randomBytes(32).toString('hex'), + sessionSecret: resolveSessionSecret({ configured: env.SESSION_SECRET, configDir }), sessionMaxAgeMs: env.SESSION_MAX_AGE_DAYS * 24 * 60 * 60 * 1000, // Convert days to milliseconds mode: authMode, oidc: { diff --git a/backend/src/config/sessionSecret.js b/backend/src/config/sessionSecret.js new file mode 100644 index 00000000..4fabb83e --- /dev/null +++ b/backend/src/config/sessionSecret.js @@ -0,0 +1,135 @@ +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); +const logger = require('../utils/logger'); + +/** + * The secret sessions are signed with, when nobody configured one. + * + * It was drawn at random at every start. Sessions themselves outlive a restart + * — they are kept in CACHE_DIR/sessions.db — but a cookie signed with the + * previous secret no longer verifies, so every restart, every upgrade and every + * crash signed everyone out. The secrets derived from it (ONLYOFFICE without + * ONLYOFFICE_SECRET, the thumbnail links) changed with it. + * + * So the first start draws one and keeps it in CONFIG_DIR, and later starts read + * it back. SESSION_SECRET, when set, always wins and nothing is written. + * + * Synchronous on purpose: the configuration is required before anything else + * runs, and every value derived from the secret is computed at that moment. + */ + +const SECRET_FILE_NAME = 'session-secret'; +const SECRET_PATTERN = /^[0-9a-f]{64}$/i; + +const draw = () => crypto.randomBytes(32).toString('hex'); + +/** Read the stored secret: the secret, `null` when there is none to use, or the error. */ +const readStored = (file) => { + let contents; + try { + contents = fs.readFileSync(file, 'utf8'); + } catch (error) { + if (error.code === 'ENOENT') return { secret: null }; + return { error }; + } + + const value = contents.trim(); + if (SECRET_PATTERN.test(value)) return { secret: value }; + + // Nothing of the file's contents is logged: a hand-written secret would + // otherwise land in the logs on its way to being replaced. + logger.warn( + { file, reason: value ? 'not a 64-character hexadecimal secret' : 'empty' }, + 'The stored session secret is unusable and is being replaced; sessions signed with it end ' + + 'here. To choose the secret yourself, set SESSION_SECRET instead.' + ); + return { secret: null }; +}; + +/** + * Write the secret beside its final name, then rename it into place, so a start + * interrupted half-way leaves either no file or a whole one — never a truncated + * secret that the next start would have to throw away. + * + * The staging name is fixed rather than unique: a write that keeps failing (a + * full disk) then leaves one stray file that the next attempt reuses, not a new + * one per start. Removing it is not this module's business. + */ +const store = (configDir, file, secret) => { + fs.mkdirSync(configDir, { recursive: true }); + + const staging = path.join(configDir, `.${SECRET_FILE_NAME}.tmp`); + const fd = fs.openSync(staging, 'w', 0o600); + try { + // The mode given to open only applies to a file it creates; a staging file + // left by an older attempt keeps its own. + fs.fchmodSync(fd, 0o600); + fs.writeFileSync(fd, `${secret}\n`); + fs.fsyncSync(fd); + } finally { + fs.closeSync(fd); + } + fs.renameSync(staging, file); + + // The rename only survives a power cut once the directory is on disk too. + // Not every platform lets a directory be opened for that, and the file is + // already in place, so a refusal here costs nothing but that guarantee. + try { + const dirFd = fs.openSync(configDir, 'r'); + try { + fs.fsyncSync(dirFd); + } finally { + fs.closeSync(dirFd); + } + } catch { + /* best effort */ + } +}; + +const warnEphemeral = (configDir, error, action) => { + logger.warn( + { directory: configDir, code: error.code || null, err: { message: error.message } }, + `Could not ${action} the session secret in CONFIG_DIR, so a new one is used for this run ` + + 'only: everyone will be signed out at the next restart. Make CONFIG_DIR writable by the ' + + 'user the server runs as, or set SESSION_SECRET.' + ); +}; + +/** + * @param {object} options + * @param {string|null|undefined} options.configured SESSION_SECRET, as read from the environment + * @param {string} options.configDir The resolved CONFIG_DIR + * @returns {string} + */ +const resolveSessionSecret = ({ configured, configDir }) => { + if (configured) return configured; + + const file = path.join(configDir, SECRET_FILE_NAME); + + const stored = readStored(file); + if (stored.error) { + // A file that exists and cannot be read belongs to someone else — another + // user, a mount gone wrong. Replacing it would throw away a secret that may + // still be good once the permissions are, so it is left alone. + warnEphemeral(configDir, stored.error, 'read'); + return draw(); + } + if (stored.secret) return stored.secret; + + const secret = draw(); + try { + store(configDir, file, secret); + } catch (error) { + warnEphemeral(configDir, error, 'store'); + return secret; + } + + logger.info( + { file }, + 'Generated a session secret and stored it in CONFIG_DIR; sessions now survive restarts' + ); + return secret; +}; + +module.exports = { resolveSessionSecret, SECRET_FILE_NAME }; diff --git a/backend/src/middleware/session.js b/backend/src/middleware/session.js index e4ddb330..d7eafcec 100644 --- a/backend/src/middleware/session.js +++ b/backend/src/middleware/session.js @@ -1,4 +1,3 @@ -const crypto = require('crypto'); const session = require('express-session'); const { auth: envAuthConfig } = require('../config/index'); @@ -6,10 +5,10 @@ const { localStore } = require('../utils/sessionStore'); const logger = require('../utils/logger'); const configureSession = (app) => { - const sessionSecret = - (envAuthConfig && envAuthConfig.sessionSecret) || - process.env.SESSION_SECRET || - crypto.randomBytes(32).toString('hex'); + // One source: the configuration resolved it, from SESSION_SECRET or from the + // copy kept in CONFIG_DIR. A second fallback drawing its own random secret + // here would have signed everyone out whenever it was the one that applied. + const sessionSecret = envAuthConfig.sessionSecret; logger.debug({ hasSessionSecret: Boolean(sessionSecret) }, 'Session secret resolved'); diff --git a/backend/tests/config/session-secret.test.js b/backend/tests/config/session-secret.test.js new file mode 100644 index 00000000..78d36d8b --- /dev/null +++ b/backend/tests/config/session-secret.test.js @@ -0,0 +1,146 @@ +import fsp from 'node:fs/promises'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import express from 'express'; +import request from 'supertest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { clearApplicationModules, modulePath, setupTestEnv } from '../helpers/env-test-utils.js'; + +/** + * Whether a restart signs everyone out. + * + * The secret sessions are signed with was drawn at random on every start when + * SESSION_SECRET was not set. The sessions themselves survive — they are rows in + * CACHE_DIR/sessions.db — but a cookie signed with the previous secret no longer + * verifies, so the row was read as a stranger's and everyone was asked to sign + * in again after every restart, every upgrade and every crash. + * + * So the test that matters is not "the resolver returns the same string twice"; + * it is a session set before a restart still being that session after one, which + * is what a person notices. It is asserted through the real session middleware, + * against a real store, because the secret is only ever used for signing. + */ + +const require = createRequire(import.meta.url); +const load = (relative) => require(modulePath(relative)); +const RUNS_AS_ROOT = typeof process.getuid === 'function' && process.getuid() === 0; + +let env; +const unlocked = []; + +afterEach(async () => { + vi.restoreAllMocks(); + while (unlocked.length) await fsp.chmod(unlocked.pop(), 0o755).catch(() => {}); + if (env) await env.cleanup(); + env = null; +}); + +/** Everything a logger spy was told, message and context both, as one string. */ +const said = (spy) => + spy.mock.calls + .map((call) => + call + .map((part) => + part && typeof part === 'object' + ? Object.entries(part) + .map(([key, value]) => `${key}=${value?.message ?? value}`) + .join(' ') + : String(part) + ) + .join(' ') + ) + .join('\n'); + +/** A test environment where nobody configured a secret. */ +const seed = (extra = {}) => + setupTestEnv({ tag: 'session-secret-', env: { SESSION_SECRET: undefined, ...extra } }); + +/** + * An application that can be asked to remember a name and to say it back. + * + * Built from a freshly loaded session middleware every time, so building one + * after `clearApplicationModules()` is exactly what a restart does: the same + * CONFIG_DIR and the same store, read by new module instances. + */ +const buildApp = () => { + const application = express(); + load('src/middleware/session').configureSession(application); + application.post('/remember', (req, res) => { + req.session.who = 'benjy'; + req.session.save(() => res.json({ ok: true })); + }); + application.get('/who', (req, res) => res.json({ who: req.session.who ?? null })); + return application; +}; + +describe('the secret sessions are signed with', () => { + it('keeps a session across a restart when nobody configured one', async () => { + env = await seed(); + + const before = buildApp(); + const set = await request(before).post('/remember'); + expect(set.status).toBe(200); + const cookie = set.headers['set-cookie']; + expect(cookie).toBeTruthy(); + // It is that session while the server is up, which was never in question. + expect((await request(before).get('/who').set('Cookie', cookie)).body.who).toBe('benjy'); + + // The restart: every module reloaded, the same CONFIG_DIR and the same store. + clearApplicationModules(); + const after = buildApp(); + + const asked = await request(after).get('/who').set('Cookie', cookie); + + expect(asked.body.who).toBe('benjy'); + }); + + it('is the one the operator set, and stores nothing then', async () => { + env = await seed({ SESSION_SECRET: 'chosen by the operator' }); + + expect(load('src/config/index').auth.sessionSecret).toBe('chosen by the operator'); + await expect(fsp.access(path.join(env.configDir, 'session-secret'))).rejects.toThrow(); + }); + + it('stores its own where nobody but the server can read it', async () => { + env = await seed(); + + const secret = load('src/config/index').auth.sessionSecret; + + expect(secret).toMatch(/^[0-9a-f]{64}$/); + const stored = await fsp.stat(path.join(env.configDir, 'session-secret')); + expect(stored.mode & 0o777).toBe(0o600); + expect((await fsp.readFile(path.join(env.configDir, 'session-secret'), 'utf8')).trim()).toBe( + secret + ); + }); + + it('replaces an unusable stored secret without putting it in the log', async () => { + env = await seed(); + // What a person editing the file by hand would leave behind. + await fsp.writeFile(path.join(env.configDir, 'session-secret'), 'hunter2\n'); + const warn = vi.spyOn(load('src/utils/logger'), 'warn'); + + const secret = load('src/config/index').auth.sessionSecret; + + expect(secret).toMatch(/^[0-9a-f]{64}$/); + expect(warn).toHaveBeenCalled(); + // The secret on its way to being replaced is still a secret. Everything the + // call carried, message and context flattened by hand: JSON.stringify leaves + // an Error as {} and would have passed a secret hidden inside one. + expect(said(warn)).not.toContain('hunter2'); + }); + + it.skipIf(RUNS_AS_ROOT)('still starts when CONFIG_DIR cannot be written', async () => { + env = await seed(); + await fsp.chmod(env.configDir, 0o555); + unlocked.push(env.configDir); + const warn = vi.spyOn(load('src/utils/logger'), 'warn'); + + const secret = load('src/config/index').auth.sessionSecret; + + expect(secret).toMatch(/^[0-9a-f]{64}$/); + // And says why the next restart will sign everyone out anyway. + expect(said(warn)).toContain('signed out'); + }); +}); From 27c624c27bcc8daa49dcf852fb7b57045719b590 Mon Sep 17 00:00:00 2001 From: Benjy Date: Sat, 26 Sep 2026 17:15:40 +0200 Subject: [PATCH 4/6] A promise nobody awaited should fail the request, not the server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Node's default for a rejected promise with no listener is to raise it as an uncaught exception and stop the process. So one forgotten `await` anywhere — on a path check, a database read, a stat — answers a single bad request by taking the server down, and everybody else's work goes with it. That price is out of proportion to the cause. A rejection raised while serving a request is almost always confined to that request: the connection fails, and nothing else is touched. So it is reported, with what it was rejected with, and the server carries on. An uncaught exception is treated as what it is. There the stack unwound through code that had no chance to put anything back, so a lock may still be held and a transaction half applied; serving from that is worse than stopping. It shuts down — the ordinary cleanup first, so the store and the sweeps close as they would on SIGTERM — and exits 1. The shutdown runs in the same unknown state, so it is bounded: five seconds, or a failure of its own, and the process still ends. Nothing is hidden from development. Only `server.js` installs this; the test suites never load it, and the runner still fails a run that leaves an unhandled rejection behind. --- backend/src/server.js | 4 + backend/src/utils/processFailures.js | 96 ++++++++++++ backend/tests/utils/process-failures.test.js | 156 +++++++++++++++++++ 3 files changed, 256 insertions(+) create mode 100644 backend/src/utils/processFailures.js create mode 100644 backend/tests/utils/process-failures.test.js diff --git a/backend/src/server.js b/backend/src/server.js index 978fb4ed..b67e2beb 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -19,6 +19,7 @@ const { purgeExpiredDocumentKeys } = require('./services/onlyofficeDocumentKeySe const editorSessions = require('./services/onlyofficeEditorSessionService'); const { sweepActivity } = require('./services/activityLog'); const capabilities = require('./services/capabilities'); +const { installProcessFailureHandlers } = require('./utils/processFailures'); let server = null; @@ -124,6 +125,9 @@ const startServer = async () => { process.on('SIGTERM', cleanup); process.on('SIGINT', cleanup); + // Installed last, so the shutdown it may need already exists. + installProcessFailureHandlers({ onFatal: cleanup }); + return server; }; diff --git a/backend/src/utils/processFailures.js b/backend/src/utils/processFailures.js new file mode 100644 index 00000000..7524e17b --- /dev/null +++ b/backend/src/utils/processFailures.js @@ -0,0 +1,96 @@ +const logger = require('./logger'); + +/** + * What a failure nobody caught should cost. + * + * Node's default for a rejected promise with no listener is to raise it as an + * uncaught exception and stop the process. So one forgotten `await` anywhere in + * the application — on a path check, a database read, a stat — answers a single + * bad request by taking the server down with it, and everybody else's work goes + * with it. + * + * That is a disproportionate price. A rejection raised while serving a request is + * almost always confined to that request: the connection fails, and nothing else + * is touched. Reporting it and carrying on is the proportionate answer. + * + * An uncaught exception is not the same thing and is not treated the same way. + * There the stack unwound through code that had no chance to put anything back, + * so what is in memory afterwards is unknown — a lock still held, a transaction + * half applied. Continuing to serve from that is worse than stopping, so this + * stops, deliberately and after saying why. + * + * None of this hides anything from development. The test suites never load this + * file, and the runner already fails a run that leaves an unhandled rejection + * behind. The quiet is bought only where the server runs, where staying up is + * worth more than dying loudly. + */ + +/** How long a shutdown may take before it is abandoned. */ +const FATAL_SHUTDOWN_TIMEOUT_MS = 5000; + +/** + * @param {object} [options] + * @param {object} [options.log] where to report, injected so a test can read it + * @param {() => Promise|void} [options.onFatal] the ordinary shutdown, tried + * before giving up on an uncaught exception + * @param {(code: number) => void} [options.exit] + * @param {number} [options.shutdownTimeoutMs] + * @param {NodeJS.EventEmitter} [options.target] the process to attach to + * @returns {() => void} removes both listeners again + */ +const installProcessFailureHandlers = ({ + log = logger, + onFatal = null, + exit = (code) => process.exit(code), + shutdownTimeoutMs = FATAL_SHUTDOWN_TIMEOUT_MS, + target = process, +} = {}) => { + const onUnhandledRejection = (reason) => { + // Normalised: a rejection carries whatever was thrown, which is often an + // Error and sometimes a string nobody meant to reject with. + const err = reason instanceof Error ? reason : new Error(String(reason)); + log.error( + { err }, + 'A promise was rejected with nobody listening. The request behind it has failed; the server has not.' + ); + }; + + const onUncaughtException = (error) => { + log.error( + { err: error }, + 'Uncaught exception. Shutting down: what is in memory after this cannot be trusted.' + ); + + // Bounded, because the shutdown runs in the same unknown state and may never + // finish. Whichever comes first wins, and the process ends either way. + let ended = false; + const end = () => { + if (ended) return; + ended = true; + exit(1); + }; + + const timer = setTimeout(end, shutdownTimeoutMs); + timer.unref?.(); + + Promise.resolve() + .then(() => onFatal?.()) + .catch((shutdownError) => { + log.error({ err: shutdownError }, 'Shutdown after an uncaught exception failed too'); + }) + .finally(() => { + clearTimeout(timer); + end(); + }); + }; + + target.on('unhandledRejection', onUnhandledRejection); + target.on('uncaughtException', onUncaughtException); + + return () => { + target.off('unhandledRejection', onUnhandledRejection); + target.off('uncaughtException', onUncaughtException); + }; +}; + +module.exports = { installProcessFailureHandlers, FATAL_SHUTDOWN_TIMEOUT_MS }; diff --git a/backend/tests/utils/process-failures.test.js b/backend/tests/utils/process-failures.test.js new file mode 100644 index 00000000..bc698f93 --- /dev/null +++ b/backend/tests/utils/process-failures.test.js @@ -0,0 +1,156 @@ +import { EventEmitter } from 'node:events'; +import { execFile } from 'node:child_process'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import { describe, expect, it, vi } from 'vitest'; + +/** + * What a failure nobody caught costs the server. + * + * Node stops the process for a rejected promise with no listener, so one + * forgotten `await` answers a single bad request by ending the server for + * everybody. The first test here is a real Node process rather than an injected + * emitter, because that default is the thing being changed and nothing short of + * a process shows it. + * + * The rest use an injected emitter and an injected `exit`: an uncaught exception + * must still stop the server — after the shutdown has had a bounded chance to + * run — and a test that really exited would take the runner with it. + */ + +const require = createRequire(import.meta.url); +const MODULE = path.join(__dirname, '..', '..', 'src', 'utils', 'processFailures.js'); +const { installProcessFailureHandlers } = require(MODULE); + +/** Run a snippet in its own Node process and report how it ended. */ +const run = (source) => + new Promise((resolve) => { + execFile(process.execPath, ['-e', source], { timeout: 10_000 }, (error, stdout, stderr) => { + resolve({ code: error?.code ?? 0, stdout, stderr }); + }); + }); + +// A rejection nobody listens to, then a line printed once the queue has drained. +const STRAY_REJECTION = ` + Promise.reject(new Error('nobody awaited this')); + setTimeout(() => { console.log('still here'); }, 50); +`; + +describe('a promise rejected with nobody listening', () => { + it('ends a Node process that has not installed the handlers', async () => { + const { code, stdout } = await run(STRAY_REJECTION); + + // Node's default, and the behaviour being changed. + expect(code).toBe(1); + expect(stdout).not.toContain('still here'); + }); + + it('leaves the server running once they are installed', async () => { + const { code, stdout, stderr } = await run(` + require(${JSON.stringify(MODULE)}).installProcessFailureHandlers(); + ${STRAY_REJECTION} + `); + + expect(code).toBe(0); + expect(stdout).toContain('still here'); + // And it is not silent about it. + expect(`${stdout}${stderr}`).toContain('nobody awaited this'); + }); + + it('reports what was rejected with, even when it was not an Error', () => { + const target = new EventEmitter(); + const log = { error: vi.fn() }; + const remove = installProcessFailureHandlers({ target, log, exit: vi.fn() }); + + target.emit('unhandledRejection', 'a bare string'); + + expect(log.error).toHaveBeenCalledTimes(1); + expect(log.error.mock.calls[0][0].err).toBeInstanceOf(Error); + expect(log.error.mock.calls[0][0].err.message).toBe('a bare string'); + remove(); + }); +}); + +describe('an uncaught exception', () => { + const uncaught = (overrides = {}) => { + const target = new EventEmitter(); + const log = { error: vi.fn() }; + const exit = vi.fn(); + const remove = installProcessFailureHandlers({ target, log, exit, ...overrides }); + return { target, log, exit, remove }; + }; + + it('shuts the server down, in that order', async () => { + const order = []; + const onFatal = vi.fn(() => { + order.push('shutdown'); + }); + const { target, exit, log, remove } = uncaught({ onFatal }); + exit.mockImplementation(() => order.push('exit')); + + target.emit('uncaughtException', new Error('the stack unwound')); + await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1)); + + // What is in memory cannot be trusted, so it goes — but the shutdown is + // given its chance first, or the server would leave its locks behind. + expect(order).toEqual(['shutdown', 'exit']); + expect(log.error).toHaveBeenCalled(); + remove(); + }); + + it('stops even when the shutdown never finishes', async () => { + const { target, exit, remove } = uncaught({ + onFatal: () => new Promise(() => {}), + shutdownTimeoutMs: 20, + }); + + target.emit('uncaughtException', new Error('and the shutdown hangs')); + + await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1)); + remove(); + }); + + it('stops even when the shutdown fails too, and says so', async () => { + const { target, exit, log, remove } = uncaught({ + onFatal: () => Promise.reject(new Error('the store was already closed')), + }); + + target.emit('uncaughtException', new Error('the stack unwound')); + + await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1)); + // Through the error itself: JSON.stringify flattens an Error to {} and + // would have passed whatever the shutdown reported. + const reported = log.error.mock.calls.map( + ([context, message]) => `${message} ${context?.err?.message ?? ''}` + ); + expect(reported.some((line) => line.includes('already closed'))).toBe(true); + remove(); + }); + + it('exits once, not once per path that could end it', async () => { + const { target, exit, remove } = uncaught({ onFatal: () => {}, shutdownTimeoutMs: 5 }); + + target.emit('uncaughtException', new Error('the stack unwound')); + await vi.waitFor(() => expect(exit).toHaveBeenCalled()); + await new Promise((resolve) => setTimeout(resolve, 30)); + + expect(exit).toHaveBeenCalledTimes(1); + remove(); + }); +}); + +describe('the handlers', () => { + it('can be taken off again, leaving the process as it was', () => { + const target = new EventEmitter(); + const log = { error: vi.fn() }; + + const remove = installProcessFailureHandlers({ target, log, exit: vi.fn() }); + expect(target.listenerCount('unhandledRejection')).toBe(1); + expect(target.listenerCount('uncaughtException')).toBe(1); + + remove(); + + expect(target.listenerCount('unhandledRejection')).toBe(0); + expect(target.listenerCount('uncaughtException')).toBe(0); + }); +}); From 336b12303c50bcfc929429beff1226e615776f4a Mon Sep 17 00:00:00 2001 From: Benjy Date: Sat, 26 Sep 2026 17:22:37 +0200 Subject: [PATCH 5/6] Make a document from the menu, and open it where it was made for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `POST /api/files/office-document` and `createOfficeDocument` in the API layer both landed with nothing calling them: there is no screen from which a blank Word, Excel or PowerPoint document can be created, so the route cannot be reached. The New menu gains a New document row, and a drawer beside it listing what can be made — the three office formats when an editor is configured, and text, Markdown and CSV, which are worth creating whether one is or not. A blank .docx is only useful if something opens it, so the office entries are hidden when neither ONLYOFFICE nor Collabora is set up. Naming comes first here, unlike everywhere else in the app: the document opens straight into an editor covering the whole window, so an inline rename box would be behind it. The dialog shows the extension and does not let it be edited — the format was chosen from the menu and the server owns the extension either way. Once created, the document opens in the editor rather than landing back in the listing, which would leave the person to find and open a file they have just asked for by name. Two defects in the files this touches, fixed here: `createFile` in the file store read the created name from `created?.name`, where the route answers `{ success, item }`. That was always undefined and always fell back to the name asked for, so when that name was taken and the server picked the next free one, the store looked up the asked-for name in the refreshed listing, found the file that already held it, and opened the rename box on *that* — making a second untitled file renamed the first one. The button that opens the New menu sits outside the menu, so `onClickOutside` counted its click as outside: the handler shut the menu in the capture phase and the button's own toggle opened it again, and the button could open the menu but never close it. It is now ignored by name. The drawer is measured against the viewport each time it opens, and hands itself to the other side when there is not room — a menu near the right edge would open its drawer off-screen otherwise. --- frontend/src/components/CreateNew.vue | 195 ++++++++++++++++-- .../components/NewOfficeDocumentDialog.vue | 104 ++++++++++ frontend/src/i18n/locales/de.json | 17 +- frontend/src/i18n/locales/en.json | 17 +- frontend/src/i18n/locales/es.json | 17 +- frontend/src/i18n/locales/fr.json | 17 +- frontend/src/i18n/locales/hi.json | 17 +- frontend/src/i18n/locales/it.json | 17 +- frontend/src/i18n/locales/ko.json | 17 +- frontend/src/i18n/locales/nl.json | 17 +- frontend/src/i18n/locales/pl.json | 17 +- frontend/src/i18n/locales/pt-BR.json | 17 +- frontend/src/i18n/locales/ro.json | 17 +- frontend/src/i18n/locales/ru.json | 17 +- frontend/src/i18n/locales/sv.json | 17 +- frontend/src/i18n/locales/zh-CN.json | 17 +- frontend/src/i18n/locales/zh-TW.json | 17 +- frontend/src/stores/fileStore.js | 29 ++- 18 files changed, 540 insertions(+), 43 deletions(-) create mode 100644 frontend/src/components/NewOfficeDocumentDialog.vue diff --git a/frontend/src/components/CreateNew.vue b/frontend/src/components/CreateNew.vue index b4acb534..e1f855ac 100644 --- a/frontend/src/components/CreateNew.vue +++ b/frontend/src/components/CreateNew.vue @@ -1,40 +1,169 @@ + + diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 786de147..344789e7 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -106,7 +106,14 @@ "sortBy": "Sortieren nach", "addRule": "Regel hinzufügen", "clearAll": "Alles löschen", - "cancelAll": "Alle abbrechen" + "cancelAll": "Alle abbrechen", + "newWordDocument": "Neues Word-Dokument", + "newSpreadsheet": "Neue Excel-Arbeitsmappe", + "newPresentation": "Neue PowerPoint-Präsentation", + "newTextFile": "Neue Textdatei", + "newMarkdownFile": "Neue Markdown-Datei", + "newCsvFile": "Neue CSV-Datei", + "newDocument": "Neues Dokument" }, "placeholders": { "email": "benutzer{'@'}beispiel.com", @@ -218,7 +225,13 @@ "darkTheme": "Dunkles Design" }, "create": { - "createNew": "Neu" + "createNew": "Neu", + "documentName": "Dokumentname", + "createAndOpen": "Erstellen und öffnen", + "defaultDocumentName": "Dokument", + "defaultSpreadsheetName": "Arbeitsmappe", + "defaultPresentationName": "Präsentation", + "defaultDataName": "Daten" }, "context": { "getInfo": "Informationen", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 6863b8f3..7cad38bf 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -106,7 +106,14 @@ "sortBy": "Sort by", "addRule": "Add rule", "clearAll": "Clear all", - "cancelAll": "Cancel all" + "cancelAll": "Cancel all", + "newWordDocument": "New Word document", + "newSpreadsheet": "New Excel spreadsheet", + "newPresentation": "New PowerPoint presentation", + "newTextFile": "New text file", + "newMarkdownFile": "New Markdown file", + "newCsvFile": "New CSV file", + "newDocument": "New document" }, "placeholders": { "email": "user{'@'}example.com", @@ -218,7 +225,13 @@ "darkTheme": "Dark theme" }, "create": { - "createNew": "New" + "createNew": "New", + "documentName": "Document name", + "createAndOpen": "Create and open", + "defaultDocumentName": "Document", + "defaultSpreadsheetName": "Spreadsheet", + "defaultPresentationName": "Presentation", + "defaultDataName": "Data" }, "context": { "getInfo": "Get Info", diff --git a/frontend/src/i18n/locales/es.json b/frontend/src/i18n/locales/es.json index a348d05a..a4851ff2 100644 --- a/frontend/src/i18n/locales/es.json +++ b/frontend/src/i18n/locales/es.json @@ -106,7 +106,14 @@ "sortBy": "Ordenar por", "addRule": "Añadir regla", "clearAll": "Borrar todo", - "cancelAll": "Cancelar todo" + "cancelAll": "Cancelar todo", + "newWordDocument": "Nuevo documento de Word", + "newSpreadsheet": "Nueva hoja de cálculo de Excel", + "newPresentation": "Nueva presentación de PowerPoint", + "newTextFile": "Nuevo archivo de texto", + "newMarkdownFile": "Nuevo archivo Markdown", + "newCsvFile": "Nuevo archivo CSV", + "newDocument": "Nuevo documento" }, "placeholders": { "email": "usuario{'@'}ejemplo.com", @@ -218,7 +225,13 @@ "darkTheme": "Tema oscuro" }, "create": { - "createNew": "Nuevo" + "createNew": "Nuevo", + "documentName": "Nombre del documento", + "createAndOpen": "Crear y abrir", + "defaultDocumentName": "Documento", + "defaultSpreadsheetName": "Hoja de cálculo", + "defaultPresentationName": "Presentación", + "defaultDataName": "Datos" }, "context": { "getInfo": "Obtener información", diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index da686fba..48736f9a 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -106,7 +106,14 @@ "sortBy": "Trier par", "addRule": "Ajouter une règle", "clearAll": "Tout effacer", - "cancelAll": "Tout annuler" + "cancelAll": "Tout annuler", + "newWordDocument": "Nouveau document Word", + "newSpreadsheet": "Nouveau classeur Excel", + "newPresentation": "Nouvelle présentation PowerPoint", + "newTextFile": "Nouveau fichier texte", + "newMarkdownFile": "Nouveau fichier Markdown", + "newCsvFile": "Nouveau fichier CSV", + "newDocument": "Nouveau document" }, "placeholders": { "email": "utilisateur{'@'}exemple.com", @@ -218,7 +225,13 @@ "darkTheme": "Thème sombre" }, "create": { - "createNew": "Nouveau" + "createNew": "Nouveau", + "documentName": "Nom du document", + "createAndOpen": "Créer et ouvrir", + "defaultDocumentName": "Document", + "defaultSpreadsheetName": "Classeur", + "defaultPresentationName": "Présentation", + "defaultDataName": "Données" }, "context": { "getInfo": "Obtenir des informations", diff --git a/frontend/src/i18n/locales/hi.json b/frontend/src/i18n/locales/hi.json index 8e637dfe..3368d959 100644 --- a/frontend/src/i18n/locales/hi.json +++ b/frontend/src/i18n/locales/hi.json @@ -106,7 +106,14 @@ "sortBy": "क्रमबद्ध करें", "addRule": "नियम जोड़ें", "clearAll": "सभी साफ़ करें", - "cancelAll": "सभी रद्द करें" + "cancelAll": "सभी रद्द करें", + "newWordDocument": "नया Word दस्तावेज़", + "newSpreadsheet": "नई Excel स्प्रेडशीट", + "newPresentation": "नई PowerPoint प्रस्तुति", + "newTextFile": "नई टेक्स्ट फ़ाइल", + "newMarkdownFile": "नई Markdown फ़ाइल", + "newCsvFile": "नई CSV फ़ाइल", + "newDocument": "नया दस्तावेज़" }, "placeholders": { "email": "user{'@'}example.com", @@ -218,7 +225,13 @@ "darkTheme": "डार्क थीम" }, "create": { - "createNew": "नया" + "createNew": "नया", + "documentName": "दस्तावेज़ का नाम", + "createAndOpen": "बनाएँ और खोलें", + "defaultDocumentName": "दस्तावेज़", + "defaultSpreadsheetName": "स्प्रेडशीट", + "defaultPresentationName": "प्रस्तुति", + "defaultDataName": "डेटा" }, "context": { "getInfo": "जानकारी प्राप्त करें", diff --git a/frontend/src/i18n/locales/it.json b/frontend/src/i18n/locales/it.json index 23847256..131fe8d4 100644 --- a/frontend/src/i18n/locales/it.json +++ b/frontend/src/i18n/locales/it.json @@ -106,7 +106,14 @@ "sortBy": "Ordina per", "addRule": "Aggiungi regola", "clearAll": "Cancella tutto", - "cancelAll": "Annulla tutto" + "cancelAll": "Annulla tutto", + "newWordDocument": "Nuovo documento Word", + "newSpreadsheet": "Nuovo foglio di calcolo Excel", + "newPresentation": "Nuova presentazione PowerPoint", + "newTextFile": "Nuovo file di testo", + "newMarkdownFile": "Nuovo file Markdown", + "newCsvFile": "Nuovo file CSV", + "newDocument": "Nuovo documento" }, "placeholders": { "email": "utente{'@'}esempio.com", @@ -218,7 +225,13 @@ "darkTheme": "Tema scuro" }, "create": { - "createNew": "Nuovo" + "createNew": "Nuovo", + "documentName": "Nome del documento", + "createAndOpen": "Crea e apri", + "defaultDocumentName": "Documento", + "defaultSpreadsheetName": "Foglio di calcolo", + "defaultPresentationName": "Presentazione", + "defaultDataName": "Dati" }, "context": { "getInfo": "Ottieni informazioni", diff --git a/frontend/src/i18n/locales/ko.json b/frontend/src/i18n/locales/ko.json index 81b9ec9b..866ed07c 100644 --- a/frontend/src/i18n/locales/ko.json +++ b/frontend/src/i18n/locales/ko.json @@ -106,7 +106,14 @@ "sortBy": "정렬 기준", "addRule": "규칙 추가", "clearAll": "전부 지우기", - "cancelAll": "전부 취소" + "cancelAll": "전부 취소", + "newWordDocument": "새 Word 문서", + "newSpreadsheet": "새 Excel 통합 문서", + "newPresentation": "새 PowerPoint 프레젠테이션", + "newTextFile": "새 텍스트 파일", + "newMarkdownFile": "새 Markdown 파일", + "newCsvFile": "새 CSV 파일", + "newDocument": "새 문서" }, "placeholders": { "email": "user{'@'}example.com", @@ -218,7 +225,13 @@ "darkTheme": "다크 테마" }, "create": { - "createNew": "추가" + "createNew": "추가", + "documentName": "문서 이름", + "createAndOpen": "만들고 열기", + "defaultDocumentName": "문서", + "defaultSpreadsheetName": "통합 문서", + "defaultPresentationName": "프레젠테이션", + "defaultDataName": "데이터" }, "context": { "getInfo": "속성", diff --git a/frontend/src/i18n/locales/nl.json b/frontend/src/i18n/locales/nl.json index 2ce4f92e..f73cdef7 100644 --- a/frontend/src/i18n/locales/nl.json +++ b/frontend/src/i18n/locales/nl.json @@ -106,7 +106,14 @@ "addRule": "Regel toevoegen", "clearAll": "Alles wissen", "cancelAll": "Alles annuleren", - "extractZip": "ZIP uitpakken" + "extractZip": "ZIP uitpakken", + "newWordDocument": "Nieuw Word-document", + "newSpreadsheet": "Nieuwe Excel-werkmap", + "newPresentation": "Nieuwe PowerPoint-presentatie", + "newTextFile": "Nieuw tekstbestand", + "newMarkdownFile": "Nieuw Markdown-bestand", + "newCsvFile": "Nieuw CSV-bestand", + "newDocument": "Nieuw document" }, "placeholders": { "email": "user{'@'}example.com", @@ -218,7 +225,13 @@ "darkTheme": "Donker thema" }, "create": { - "createNew": "Nieuw" + "createNew": "Nieuw", + "documentName": "Naam van het document", + "createAndOpen": "Maken en openen", + "defaultDocumentName": "Document", + "defaultSpreadsheetName": "Werkmap", + "defaultPresentationName": "Presentatie", + "defaultDataName": "Gegevens" }, "context": { "getInfo": "Eigenschappen", diff --git a/frontend/src/i18n/locales/pl.json b/frontend/src/i18n/locales/pl.json index 46323de3..0995b0d1 100644 --- a/frontend/src/i18n/locales/pl.json +++ b/frontend/src/i18n/locales/pl.json @@ -106,7 +106,14 @@ "sortBy": "Sortuj według", "addRule": "Dodaj regułę", "clearAll": "Wyczyść wszystko", - "cancelAll": "Anuluj wszystko" + "cancelAll": "Anuluj wszystko", + "newWordDocument": "Nowy dokument Word", + "newSpreadsheet": "Nowy arkusz Excel", + "newPresentation": "Nowa prezentacja PowerPoint", + "newTextFile": "Nowy plik tekstowy", + "newMarkdownFile": "Nowy plik Markdown", + "newCsvFile": "Nowy plik CSV", + "newDocument": "Nowy dokument" }, "placeholders": { "email": "uzytkownik{'@'}przyklad.com", @@ -218,7 +225,13 @@ "darkTheme": "Ciemny motyw" }, "create": { - "createNew": "Nowy" + "createNew": "Nowy", + "documentName": "Nazwa dokumentu", + "createAndOpen": "Utwórz i otwórz", + "defaultDocumentName": "Dokument", + "defaultSpreadsheetName": "Arkusz", + "defaultPresentationName": "Prezentacja", + "defaultDataName": "Dane" }, "context": { "getInfo": "Informacje", diff --git a/frontend/src/i18n/locales/pt-BR.json b/frontend/src/i18n/locales/pt-BR.json index e4bdeed3..99bfc278 100644 --- a/frontend/src/i18n/locales/pt-BR.json +++ b/frontend/src/i18n/locales/pt-BR.json @@ -106,7 +106,14 @@ "sortBy": "Ordenar por", "addRule": "Adicionar regra", "clearAll": "Limpar tudo", - "cancelAll": "Cancelar tudo" + "cancelAll": "Cancelar tudo", + "newWordDocument": "Novo documento do Word", + "newSpreadsheet": "Nova planilha do Excel", + "newPresentation": "Nova apresentação do PowerPoint", + "newTextFile": "Novo arquivo de texto", + "newMarkdownFile": "Novo arquivo Markdown", + "newCsvFile": "Novo arquivo CSV", + "newDocument": "Novo documento" }, "placeholders": { "email": "usuario{'@'}exemplo.com", @@ -218,7 +225,13 @@ "darkTheme": "Tema escuro" }, "create": { - "createNew": "Novo" + "createNew": "Novo", + "documentName": "Nome do documento", + "createAndOpen": "Criar e abrir", + "defaultDocumentName": "Documento", + "defaultSpreadsheetName": "Planilha", + "defaultPresentationName": "Apresentação", + "defaultDataName": "Dados" }, "context": { "getInfo": "Obter Informações", diff --git a/frontend/src/i18n/locales/ro.json b/frontend/src/i18n/locales/ro.json index 3064e626..36e3e379 100644 --- a/frontend/src/i18n/locales/ro.json +++ b/frontend/src/i18n/locales/ro.json @@ -106,7 +106,14 @@ "sortBy": "Sortează după", "addRule": "Adaugă regulă", "clearAll": "Șterge tot", - "cancelAll": "Anulează tot" + "cancelAll": "Anulează tot", + "newWordDocument": "Document Word nou", + "newSpreadsheet": "Registru Excel nou", + "newPresentation": "Prezentare PowerPoint nouă", + "newTextFile": "Fișier text nou", + "newMarkdownFile": "Fișier Markdown nou", + "newCsvFile": "Fișier CSV nou", + "newDocument": "Document nou" }, "placeholders": { "email": "utilizator{'@'}exemplu.com", @@ -218,7 +225,13 @@ "darkTheme": "Temă închisă" }, "create": { - "createNew": "Nou" + "createNew": "Nou", + "documentName": "Numele documentului", + "createAndOpen": "Creează și deschide", + "defaultDocumentName": "Document", + "defaultSpreadsheetName": "Registru", + "defaultPresentationName": "Prezentare", + "defaultDataName": "Date" }, "context": { "getInfo": "Obține informații", diff --git a/frontend/src/i18n/locales/ru.json b/frontend/src/i18n/locales/ru.json index c0efffdf..efdfcb07 100644 --- a/frontend/src/i18n/locales/ru.json +++ b/frontend/src/i18n/locales/ru.json @@ -106,7 +106,14 @@ "sortBy": "Сортировать по", "addRule": "Добавить правило", "clearAll": "Очистить все", - "cancelAll": "Отменить все" + "cancelAll": "Отменить все", + "newWordDocument": "Новый документ Word", + "newSpreadsheet": "Новая книга Excel", + "newPresentation": "Новая презентация PowerPoint", + "newTextFile": "Новый текстовый файл", + "newMarkdownFile": "Новый файл Markdown", + "newCsvFile": "Новый файл CSV", + "newDocument": "Новый документ" }, "placeholders": { "email": "user{'@'}example.com", @@ -218,7 +225,13 @@ "darkTheme": "Темная тема" }, "create": { - "createNew": "Создать" + "createNew": "Создать", + "documentName": "Имя документа", + "createAndOpen": "Создать и открыть", + "defaultDocumentName": "Документ", + "defaultSpreadsheetName": "Книга", + "defaultPresentationName": "Презентация", + "defaultDataName": "Данные" }, "context": { "getInfo": "Свойства", diff --git a/frontend/src/i18n/locales/sv.json b/frontend/src/i18n/locales/sv.json index f83ebcb5..4dd0e7dc 100644 --- a/frontend/src/i18n/locales/sv.json +++ b/frontend/src/i18n/locales/sv.json @@ -106,7 +106,14 @@ "sortBy": "Sortera efter", "addRule": "Lägg till regel", "clearAll": "Töm allt", - "cancelAll": "Avbryt allt" + "cancelAll": "Avbryt allt", + "newWordDocument": "Nytt Word-dokument", + "newSpreadsheet": "Ny Excel-arbetsbok", + "newPresentation": "Ny PowerPoint-presentation", + "newTextFile": "Ny textfil", + "newMarkdownFile": "Ny Markdown-fil", + "newCsvFile": "Ny CSV-fil", + "newDocument": "Nytt dokument" }, "placeholders": { "email": "användare{'@'}exempel.se", @@ -218,7 +225,13 @@ "darkTheme": "Mörkt tema" }, "create": { - "createNew": "Ny" + "createNew": "Ny", + "documentName": "Dokumentnamn", + "createAndOpen": "Skapa och öppna", + "defaultDocumentName": "Dokument", + "defaultSpreadsheetName": "Arbetsbok", + "defaultPresentationName": "Presentation", + "defaultDataName": "Data" }, "context": { "getInfo": "Hämta info", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 2b86cde0..07183569 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -106,7 +106,14 @@ "sortBy": "排序方式", "addRule": "添加规则", "clearAll": "清除全部", - "cancelAll": "全部取消" + "cancelAll": "全部取消", + "newWordDocument": "新建 Word 文档", + "newSpreadsheet": "新建 Excel 工作簿", + "newPresentation": "新建 PowerPoint 演示文稿", + "newTextFile": "新建文本文件", + "newMarkdownFile": "新建 Markdown 文件", + "newCsvFile": "新建 CSV 文件", + "newDocument": "新建文档" }, "placeholders": { "email": "user{'@'}example.com", @@ -218,7 +225,13 @@ "darkTheme": "深色主题" }, "create": { - "createNew": "新建" + "createNew": "新建", + "documentName": "文档名称", + "createAndOpen": "创建并打开", + "defaultDocumentName": "文档", + "defaultSpreadsheetName": "工作簿", + "defaultPresentationName": "演示文稿", + "defaultDataName": "数据" }, "context": { "getInfo": "获取信息", diff --git a/frontend/src/i18n/locales/zh-TW.json b/frontend/src/i18n/locales/zh-TW.json index c4c383ba..960008e9 100644 --- a/frontend/src/i18n/locales/zh-TW.json +++ b/frontend/src/i18n/locales/zh-TW.json @@ -106,7 +106,14 @@ "sortBy": "排序方式", "addRule": "新增規則", "clearAll": "清除全部", - "cancelAll": "全部取消" + "cancelAll": "全部取消", + "newWordDocument": "新增 Word 文件", + "newSpreadsheet": "新增 Excel 活頁簿", + "newPresentation": "新增 PowerPoint 簡報", + "newTextFile": "新增文字檔", + "newMarkdownFile": "新增 Markdown 檔", + "newCsvFile": "新增 CSV 檔", + "newDocument": "新增文件" }, "placeholders": { "email": "user{'@'}example.com", @@ -218,7 +225,13 @@ "darkTheme": "深色主題" }, "create": { - "createNew": "建立" + "createNew": "建立", + "documentName": "文件名稱", + "createAndOpen": "建立並開啟", + "defaultDocumentName": "文件", + "defaultSpreadsheetName": "活頁簿", + "defaultPresentationName": "簡報", + "defaultDataName": "資料" }, "context": { "getInfo": "詳細資訊", diff --git a/frontend/src/stores/fileStore.js b/frontend/src/stores/fileStore.js index 5b242b94..5a81411d 100644 --- a/frontend/src/stores/fileStore.js +++ b/frontend/src/stores/fileStore.js @@ -8,6 +8,7 @@ import { deleteItems, normalizePath, createFile as createFileApi, + createOfficeDocument as createOfficeDocumentApi, createFolder as createFolderApi, renameItem as renameItemApi, fetchThumbnail as fetchThumbnailApi, @@ -201,7 +202,12 @@ export const useFileStore = defineStore('fileStore', () => { // writing in — and writing the file through the editor's save meant an // empty file could land on top of one that arrived meanwhile. const created = await createFileApi(destination, defaultName); - const candidate = created?.name || defaultName; + // The route answers `{ success, item }`. Read from the root, this was always + // undefined and always fell back to the name that was asked for — so when + // that name was taken and the server picked the next free one, the rename box + // opened on whatever already held the asked-for name instead of on the new + // file. + const candidate = created?.item?.name || defaultName; // Refresh and start rename for the created item await fetchPathItems(destination); @@ -216,6 +222,26 @@ export const useFileStore = defineStore('fileStore', () => { return { success: true, name: candidate }; }; + /** + * Create a blank office document in the current folder and return it. + * + * Unlike `createFile` this starts no inline rename: the name was settled before + * the document existed, and the caller opens it in an editor straight away — a + * rename box behind a full-window editor is a rename box nobody can see. + */ + const createOfficeDocument = async ({ format, name } = {}) => { + const destination = normalizePath(currentPath.value || ''); + const created = await createOfficeDocumentApi(destination, { format, name }); + + await fetchPathItems(destination); + + // From the refreshed listing where it can be found, so what is handed to the + // editor carries everything the listing knows about it. + const createdName = created?.item?.name; + const fromListing = createdName ? findItemByKey(`${destination}::${createdName}`) : null; + return fromListing || created?.item || null; + }; + const extractZipArchive = async (relativePath) => { const normalized = normalizePath(relativePath || ''); if (!normalized) return null; @@ -562,6 +588,7 @@ export const useFileStore = defineStore('fileStore', () => { resetClipboard, createFolder, createFile, + createOfficeDocument, extractZipArchive, compressSelectionToZip, renameState, From c645488ccd3523697355a9344e0258d3395c4ce7 Mon Sep 17 00:00:00 2001 From: Benjy Date: Sat, 26 Sep 2026 18:46:00 +0200 Subject: [PATCH 6/6] Document the trash and file versions, which shipped without their pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plan's own rule is that a batch carries its documentation. The trash landed in #405 and #416, file versions in #406 and #417, and neither brought a page: there is nothing under `docs/` that describes either, so the retention settings, what a restore keeps, who sees what in the trash and how versions are thinned are all readable only in the source. Two new pages, and nine existing ones brought up to what the application now does — the environment reference gains the variables the last twenty batches added, the feature and workflow pages gain the trash, versions, archives and search index, and the sidebar gains the two new entries in both of its shapes. Both pages were trimmed to what `main` has, rather than copied. Three things are deliberately left out and travel with the batch that brings them: - restoring to a folder of your choosing. `POST /api/trash/restore` accepts a destination, but nothing on the screen offers one and `restoreTrashItems` does not send one, so the page would describe an action nobody can reach. - `COPY_PRESERVE_PERMISSIONS`, `PREVIEW_MAX_RENDER_SIZE`, `BULK_DELETE_CONCURRENCY`, `MAX_BROWSABLE_ARCHIVE_SIZE`, `ARCHIVE_CACHE_MAX_SIZE`, `UPLOAD_CHUNKED_AUTO_FALLBACK` and the six `PERFORMANCE_DIAGNOSTICS_*` — twelve rows for variables the configuration does not read yet. - recent destinations, per-folder preferences and the inline quick-actions menu. ## Checks `npm run docs:build` — and it is worth saying that it was failing before this last pass: three links pointed at an installation page that only exists in the fork, and vitepress treats a dead link as an error. Every internal link in the eleven pages was resolved against the tree, which is how those turned up. `npm run format:check` reports the same 22 files as `main` does on its own; the eleven pages and the config are clean. `npm run build` and the backend module load both pass, untouched by a documentation batch. --- docs/.vitepress/config.mjs | 4 + docs/admin/guide.md | 58 +++- docs/admin/trash.md | 89 ++++++ docs/admin/versions.md | 104 +++++++ docs/configuration/environment.md | 381 +++++++++++++++++-------- docs/configuration/personal-folders.md | 34 ++- docs/experience/features.md | 207 +++++++++++++- docs/experience/workflows.md | 38 +-- docs/installation/deployment.md | 50 +++- docs/installation/reverse-proxy.md | 207 +++++++++++++- docs/reference/contributing.md | 43 ++- docs/reference/faq.md | 38 ++- 12 files changed, 1069 insertions(+), 184 deletions(-) create mode 100644 docs/admin/trash.md create mode 100644 docs/admin/versions.md diff --git a/docs/.vitepress/config.mjs b/docs/.vitepress/config.mjs index 6cae79be..baab11da 100644 --- a/docs/.vitepress/config.mjs +++ b/docs/.vitepress/config.mjs @@ -42,6 +42,8 @@ export default defineConfig({ }, { text: 'Admin & Access', link: '/admin/guide' }, { text: 'User volumes', link: '/admin/user-volumes' }, + { text: 'Trash', link: '/admin/trash' }, + { text: 'File versions', link: '/admin/versions' }, { text: 'OIDC', link: '/integrations/oidc' }, { text: 'Authelia', link: '/integrations/authelia' }, { text: 'ONLYOFFICE', link: '/integrations/onlyoffice' }, @@ -110,6 +112,8 @@ export default defineConfig({ items: [ { text: 'Administrator Guide', link: '/admin/guide' }, { text: 'User volumes', link: '/admin/user-volumes' }, + { text: 'Trash', link: '/admin/trash' }, + { text: 'File versions', link: '/admin/versions' }, ], }, { diff --git a/docs/admin/guide.md b/docs/admin/guide.md index 98076602..f193f757 100644 --- a/docs/admin/guide.md +++ b/docs/admin/guide.md @@ -11,8 +11,38 @@ Administrators control users, folders, and security policies through Settings. T ## User management - Navigate to **Settings → Admin → Users** to add local users, assign roles, and reset passwords. +- Resetting a password signs that account out of every session it has open, on + every device — the sessions opened with a password and the ones opened through + the identity provider alike. Someone changing their own password from + **Settings → Password** is signed out everywhere except where they made the + change. A changed `AUTH_ADMIN_PASSWORD` does the same to the administrator at + the next start; the same value set again at each restart signs nobody out. +- One thing a password change does **not** end: the tokens already handed to + ONLYOFFICE and Collabora for documents open at that moment. Those are signed + rather than stored, so there is nothing on the server to withdraw. Each + reaches the one file it was minted for, with the rights it was minted with, + and expires on its own — within 12 hours for ONLYOFFICE, 6 for Collabora. + Reopening the document asks for a new one, which the new password governs. To + end them sooner, change `ONLYOFFICE_SECRET` or `COLLABORA_SECRET` and restart: + every token signed with the old value stops working at once, for everyone. - When `USER_VOLUMES=true`, each user profile includes a **Volumes** tab for assigning per-user volumes. See [User volumes](/admin/user-volumes). - Local users store credentials in the SQLite database inside your `/config` mount. +- People sign in with **either their email address or their username**, in the + same box. Case does not matter for either. +- A username has to name one account to be usable for signing in. NextExplorer + refuses a new account, or a rename, that would take a username another account + already has — but an installation upgraded from an older version may already + hold duplicates, because the username is derived from the local part of the + address (`alice@example.com` and `alice@other.org` both become `alice`). Where + a username answers for more than one account it signs nobody in, and those + accounts use their email address instead. Give one of them a different + username in **Settings → Admin → Users** to free it. +- **Two-factor authentication** is each person's to turn on, in **Settings → Two-factor**: a QR code for any authenticator app, one code to prove the phone kept the secret, and ten recovery codes shown once. From then on that account's sign-in asks for a code after the password. The user list says who has it on. When somebody loses both the phone and the codes, an administrator takes it off for them — the same person who could already reset that account's password — and the account signs in with its password alone until it is set up again. With OIDC the second factor belongs to the provider, and this is for local passwords only. +- **Passkeys** are each person's to add, in **Settings → Passkeys**: a fingerprint, a face or a device PIN instead of a password. The device keeps the key and never hands it over, and what it signs names this site — so a passkey cannot be typed into a page pretending to be this one, read over a shoulder, or replayed anywhere else. A passkey that was unlocked to be used is already two things (the device, and whoever can unlock it), so it answers an account's second factor on its own; one used without unlocking still asks for the code. Passkeys are for local accounts, like the second factor; with `AUTH_MODE=oidc` they are not offered. + - **The browser decides where this can work.** A passkey is bound to a hostname, and browsers refuse to make one on a page that is not secure or on an address that is not a name: over plain `http`, or at `https://192.168.1.10`, the page says so instead of offering a button. Reach the server over https, or through `localhost`, to add one. + - **An installation reached through two hostnames has to pick one.** A passkey made on `files.example.com` is refused on `files.lan`, by design. `PUBLIC_URL` settles it where it is set; `WEBAUTHN_RP_ID` settles it explicitly. Change either one and the passkeys already made stop working — they belong to the name they were made on. + - **The last way in is protected.** An account with no password is refused the removal of its last passkey, and removing any passkey asks for the password when the account has one. + - **When the device that held the passkey is gone**, an administrator takes every passkey off that account — the same deliberate act, by the same person, as taking somebody's second factor off. What is left is an account that signs in with its password and adds a passkey again from whatever device is in front of it. - Promote trusted accounts to admin inside the UI—note that demotions are blocked when it would remove the last admin. - When OIDC is enabled, users are created automatically on first login (unless `OIDC_AUTO_CREATE_USERS=false`) and elevated to admin if their `groups`, `roles`, or `entitlements` claims match any of the names inside `OIDC_ADMIN_GROUPS`. @@ -20,9 +50,10 @@ Administrators control users, folders, and security policies through Settings. T - **Settings → Access Control** lets you define rules with the following types: - `rw` – Read/write access (default). Applies when no rule matches. - - `ro` – Read-only access; uploads and edits are disabled. - - `hidden` – Keeps the volume/folder out of listings; only accessible via direct path. -- Rules use the logical root (e.g., `Projects/Team`) and evaluate in defined order, so place more specific rules above general ones. + - `ro` – Read-only access; uploads and edits are disabled for every account except administrators. + - `hidden` – Keeps the volume/folder out of listings and search, and refuses it by its address too — for everyone, administrators included. +- Rules use the logical root (e.g., `Projects/Team`), the path NextExplorer shows with the volume first — not the path of the mount on the host or in the container. The folder button beside the field chooses it for you, and a path that names no folder is flagged with the one probably meant. +- Rules evaluate in defined order, so place more specific rules above general ones. - Recursive rules apply to subfolders when the recursion checkbox is enabled. ## Sharing & guest access @@ -34,13 +65,24 @@ Administrators control users, folders, and security policies through Settings. T ## Security & logging - Authentication can be fully disabled for trusted networks via **Settings → Security**, but enabling protects all API routes. -- Session persistence uses `SESSION_SECRET`; set this environment variable to ensure sessions persist across container restarts and multi-node deployments. +- Sessions survive restarts: without `SESSION_SECRET`, the secret generated at the first start is kept in `/config/session-secret`. Set `SESSION_SECRET` (or `SESSION_SECRET_FILE`) to choose it, or for several replicas. - **Persistent sessions:** By default, users stay logged in for 30 days even after closing their browser. Configure `SESSION_MAX_AGE_DAYS` to adjust this duration (e.g., `7` for weekly re-authentication, `90` for extended sessions). +- **Two-factor secrets are kept unreadable in `app.db`**, under a key drawn into `/config/totp-key` at the first use. A copy of the database on its own — a backup, a support ticket — is not a set of working authenticators. Losing that key costs authenticators, not accounts: recovery codes are hashed and go on working, and whoever uses one signs in and sets their phone up again. +- **The activity log is off unless you ask for it**, in **Settings → Activity log**. On, it writes down sign-ins (including the ones that were refused, and the name that was tried) and sign-outs; files downloaded, uploaded, sent to the trash, restored and removed for good; what left through a share link — and what arrived through one — and from which link; shares created and deleted; account changes, whether somebody's own (password, second factor, passkey) or an administrator's (an account created, renamed, given roles, or removed); and which settings were changed, including this switch itself. Each line carries who, what, when, the address it came from, and whether it worked. Behind a reverse proxy — or in a container reached from its own host — that address is only the person's if `TRUST_PROXY` says the proxy may be believed; see [the reverse proxy guide](/installation/reverse-proxy). Only administrators can read it. + - **Nothing before it was switched on is in it.** The log is a record kept from the moment it is asked for, not a history reconstructed afterwards. + - Lines are kept for the retention set beside the switch (90 days by default, `ACTIVITY_RETENTION_DAYS` to start elsewhere) and swept hourly, whether the log is on or off — switching it off lets the disk go back rather than freezing yesterday's rows. **Empty the log** removes everything at once — and leaves one line saying who emptied it, when, and how many rows went, because a record that can be taken away without a trace is worth less than the rows it lost. + - It lives in `app.db`, so it is in the same backup as everything else. It has no foreign key to the accounts: what somebody did while their account existed is exactly what a log is for, and deleting the account does not take it away. + - Nothing here can fail a request. A line that cannot be written is reported in the server's own log and the download, the sign-in or the deletion carries on. - Http logging toggles (`ENABLE_HTTP_LOGGING`, `LOG_LEVEL`, `DEBUG`) help surface suspicious activity; send container logs to a centralized system for audits. ## Backups & persistence -- `/config` houses `app.db`, `app-config.json`, and extension packages. Back these files up before upgrades or migrations. -- `/cache` contains generated thumbnails and search indexes that can be deleted if needed; the app recreates them as you browse. -- An upload, a copy, an extraction or a compression stopped half-way by a restart leaves a hidden temporary file, folder or archive in the volume: what is still being written never sits under the name it is meant to take. Each is recorded in `/cache/in-flight` while it runs, and the next start removes what an interrupted one left, and only that. A `/cache` cleared in between loses the record, and the leftover stays for you to delete. -- When upgrading, run `docker compose pull` followed by `docker compose up -d`; the entrypoint preserves `CONFIG_DIR` while migrating legacy `/cache` configs. +- `/config` houses `app.db` — accounts, shares, settings, and the records of the trash and file versions — `logos/`, the logo uploaded in Branding, `session-secret`, the secret sessions are signed with when `SESSION_SECRET` is not set — a `/config` restored without it signs everyone out once — and `totp-key`, which is what makes the two-factor secrets in `app.db` readable. Back it up before upgrades. Copy `app.db` with the container stopped, or together with `app.db-wal`: a copy of `app.db` alone can miss what was written last. +- Back up `app.db` and the volumes together. The trash and file versions keep their content in each volume’s `.nextexplorer` folder and their records in `app.db`; one restored without the other leaves items that cannot be restored, or content nothing lists. +- `app-config.json` in `/config` held settings and favorites in early releases, and is only read when `app.db` is created, to carry them into it. Settings are read from `app.db` alone: a read that fails is refused rather than answered from that file, and a current installation keeps nothing in it. +- `app.db` gives back the space its deletions free. SQLite keeps freed pages inside the file, so a large deletion used to leave `app.db` — and every backup of it — at its largest size. An hourly pass now hands free space back once more than 16 MB of it has built up, and the write-ahead log is cut back to 64 MB after a checkpoint. A database created by an earlier release is rewritten once, at the first start, to make this possible; the log says how large it was before and after. The same pass covers `index.db` and `sessions.db` in `/cache`. +- Records nobody needs any more are purged too: ONLYOFFICE document keys past their expiry, every hour, and each trash zone’s events beyond its newest thousand. +- `/cache` holds what can be made again: thumbnails and RAW previews — each kept within its limit, with what a crash left of them removed — sessions, and `index.db` — the search index and the folder sizes. Nothing in it needs a backup. Deleting it signs everyone out and costs a pass over the volumes to rebuild the indexes, so keep it on a persistent mount: without one, every new container reads the volumes again. +- A save, an ONLYOFFICE download, an extraction, a compression, or a copy or move across disks stopped half-way by a restart leaves a hidden temporary file, extraction folder or archive in the volume: what is still being written never sits under the name it is meant to take. Each is recorded in `/cache/in-flight` while it runs, and the next start removes what an interrupted one left, and only that. A `/cache` cleared in between loses the record, and the leftover stays for you to delete. +- An installation upgraded from 3.6.0 or earlier has its indexes moved out of `app.db` into `/cache/index.db` at the first start, as they are — the volumes are not read again for it — and `app.db` is rewritten without them. +- When upgrading, run `docker compose pull` followed by `docker compose up -d`. An installation that started on 1.1.7 or earlier kept `app.db` in `/cache`; nothing moves it to `/config` any more, so copy it there by hand first; the server warns at start when it finds such a file there. Links named `app.db`, `app-config.json` or `extensions` left in `/cache` by 1.1.8 to 2.0.2 are unused and can be deleted. diff --git a/docs/admin/trash.md b/docs/admin/trash.md new file mode 100644 index 00000000..c4cbf639 --- /dev/null +++ b/docs/admin/trash.md @@ -0,0 +1,89 @@ +# Trash + +Deleting a file or folder moves it to the trash instead of removing it. It stays there for a retention period (30 days by default), can be restored from the **Trash** page in the sidebar, and is then removed for good. + +## How it works + +- **No copy, ever.** Each volume keeps its trash in a hidden `.nextexplorer` folder at its root. Deleting is a rename on the same disk: a 40 GB folder goes to the trash as fast as a small file, and needs no free space to do so. Personal folders and volumes assigned to users outside `VOLUME_ROOT` have their own `.nextexplorer` folder the same way. +- **Nobody browses the zone.** The `.nextexplorer` folder never appears in listings or search, whatever the hidden-file settings say, and no path through it can be opened, downloaded or shared. Nothing can be named `.nextexplorer`. +- **Shares go at once.** A share pointing at a deleted item is removed when the item goes to the trash, as before: nothing in the trash stays public. Restoring does not bring shares back. +- **Crash-safe.** Every operation writes what it is about to do before it touches the disk. After a crash or a power cut, the next start finishes or undoes whatever was interrupted. Content found in a zone without a record — a database restored from an older backup — is adopted rather than deleted; each item carries a small description beside it for that purpose. + +## Who sees what + +- Each person sees what they deleted, and what came from their own personal folder or from shares they own. +- Administrators see everything. +- Share visitors have no trash: what they delete through a share link goes to the share owner's trash. +- Restoring puts an item back where it was. A parent folder that no longer exists is recreated; a name that is now taken gets a suffix, like a copy. Someone who has lost write access to the original location since the deletion cannot restore into it — an administrator can. + +## Acting on an item + +Right-click an item on the **Trash** page — or hold it on a touch screen, or press the menu key on its checkbox — for what can be done with it: open a deleted folder, preview a file, restore it where it was, open its original location, or delete it for good. With several items selected, the menu acts on all of them. A double click opens a deleted folder, or previews a file. + +**Preview** shows a text file — plain text, Markdown, scripts, code: the extensions the editor opens — in the editor, **read only**: nothing can be typed, there is no Save, and Close goes back to the trash. The file is read with the editor's limits, so a file that is too large, or not text, is not shown. Nothing in the trash can be changed this way. + +## Restoring part of a deleted folder + +A deleted folder is one item in the trash, however much it holds. Click its name on the **Trash** page to open it, go further in if needed, select what you want back, and **Restore**: each selected file or folder goes back to its own place inside the original folder, and the rest stays in the trash. **Restore whole folder** puts back everything that is left. + +- Nothing extra is recorded for what is inside a folder. The folder's own record gives its original path, and an entry at `drafts/v2.txt` inside it goes back to `/drafts/v2.txt`. +- The original folder and the folders on the way are recreated if they are gone. What exists there now is never replaced: a name that is taken gets a suffix, as for a whole item. +- Whoever may restore the folder may restore what is inside it, under the same conditions. +- A symbolic link inside a deleted folder is listed and restored as the link it is; nothing is ever opened through it. A restore is refused when the place it would go back to now leads outside the volume through a link. + +## Share links + +- When shared content goes to the trash, its share links — and those of anything inside a deleted folder — stop working at once: nothing in the trash stays public. The delete dialog says so beforehand. The links are kept with the item. +- When it is restored, you choose: **Restore the share links** brings them back as they were — same link, password, expiry, permitted people and label — or **Delete the share links** lets them go. Without a choice, they are deleted. +- A share link that expired while in the trash, or whose owner no longer exists, cannot come back. +- Deleted for good — from the trash, by emptying it, at the end of its retention, or straight away — an item takes its share links with it for good. +- Visits opened through a link are not kept: whoever had it open opens it again. + +## What a restore keeps + +Deleting and restoring on the same disk are renames: a file or folder comes back with its owner, permissions, ACLs, extended attributes and modification times as they were. A copy to another disk goes through the same copy as a transfer: in the container, `rsync` keeps permissions and modification times, and the copied files belong to the user the application runs as. + +Some things do not come back: + +- a folder recreated on the way back, because it no longer existed, is new, with the permissions the application gives new folders; +- the favorites pointing at an item are forgotten when it goes to the trash, and a restore does not bring them back. Share links are the exception: see [Share links](#share-links). + +Access rules and assigned volumes are set on paths, not on items, so they apply again as soon as an item is back under the path they name. + +## When an item cannot go to the trash + +The delete dialog says, before anyone confirms, which items would be removed for good and why: + +- the item is on **another disk** than its volume's trash (a network share or a separate mount inside a volume); +- it is **larger than the whole trash** of its volume; +- it is **a volume itself**, which cannot go into its own trash. + +A folder's size is only known once it is measured, during the deletion. If it turns out too large then, it is left where it is and the person is asked again. A deletion is never permanent without the person having been told. + +The dialog also offers **Delete permanently** to skip the trash on purpose. + +## Space and retention + +The trash of each volume may hold at most a share of the volume (10% by default), optionally capped by a size. [File versions](/admin/versions) are kept in the same space, and counted with the trash. A maintenance pass runs at startup, every hour, and shortly after deletions: + +1. items past their retention are removed for good, whatever the space; +2. while the space is over its budget, or the volume is below the upload reserve (`UPLOAD_STORAGE_RESERVE`), what matters least goes first: versions that are not the latest of their file, then the oldest items in the trash, then the latest version of each file, and pinned versions last. + +Before an upload is refused for lack of space, the destination volume gives back that space in the same order — but only when that is enough for the upload to fit. + +Every early removal (before the retention), recovery or failure is recorded in the zone's journal, shown in **Settings → Trash and versions**. + +## Settings → Trash and versions + +Administrators can: + +- switch the trash on or off, and set the retention and the size limits (the defaults come from [environment variables](/configuration/environment#trash)); versions are switched on and off, and thinned, in their own section — see [File versions](/admin/versions#settings-trash-and-versions); +- see, for each volume, what its trash holds, its budget, and what the last maintenance did; +- **Verify** that every zone's records and files agree; +- **Run maintenance now**. + +A zone whose disk is not mounted, or has been replaced by another one, is shown as unavailable and left untouched: an unmounted disk and an emptied trash look the same from a path, and nextExplorer never removes records on that basis. An administrator who knows the disk is gone for good can delete those items from the Trash page to forget them. + +## Backups + +The `.nextexplorer` folder is inside each volume, so a backup of the volume includes the trash. Exclude `.nextexplorer/` from backups if you do not want to back up deleted items. diff --git a/docs/admin/versions.md b/docs/admin/versions.md new file mode 100644 index 00000000..7addc0f4 --- /dev/null +++ b/docs/admin/versions.md @@ -0,0 +1,104 @@ +# File versions + +Saving over a file keeps what the save replaces as a version. Earlier versions can be listed, opened, downloaded, restored, taken out as a copy or put over another file, named, pinned and deleted from the **Versions** panel — right-click a file, or open its details. + +A file that has versions carries a small mark in the folder listing, with how many; clicking it opens the panel. It is on by default and each person can turn it off under **Settings → Preferences → Mark files that have versions**. It appears only where its file's history would be shown anyway, so a share that does not hand out histories does not hand out the mark either. + +## What is kept + +- **Every save through NextExplorer.** The text editor, the editor opened through a share link, ONLYOFFICE and Collabora all keep the content they replace. A file changed outside NextExplorer — over SMB, by a script — keeps its history, but those changes leave no version: nothing saw them happen. +- **No copy, ever.** The content a save replaces is moved into the volume's hidden `.nextexplorer` folder, the same zone the [trash](/admin/trash) uses, and the new content takes its place in one rename. Saving a 2 GB file does not need 2 GB of free space for its version. +- **The same content once.** A save that changes nothing keeps nothing, and content identical to the latest version is not kept twice. +- **One version per office editing session.** ONLYOFFICE and Collabora save on their own every few seconds while someone types; kept one by one, those saves would fill a volume with near copies of the same document. What is kept is the document as it was before the session, a save someone asked for (the editor's Save, closing the document), and, in a long session, a checkpoint at most every 10 minutes. +- **Crash-safe.** A save writes what it is about to do before it touches the disk. After a crash or a power cut, the next start either finishes the save or puts the file back as it was. + +## Thinning + +Versions are thinned out as they age, so a file edited every day keeps a useful history without keeping every save: + +| Age | Kept | +| -------------- | ----------------------- | +| up to 24 hours | every version | +| up to 7 days | the newest of each hour | +| up to 30 days | the newest of each day | +| older | the newest of each week | + +A file keeps at most 50 versions. The tiers and the limit are set under **Settings → Trash and versions**. + +**Pinned** versions escape the thinning and the per-file limit. Pin a version to keep it — the one sent to a client, the one before a large rewrite — and give it a name so it is easy to find. + +## Space + +Versions and the trash share each volume's reserved space (`TRASH_MAX_PERCENT` and `TRASH_MAX_SIZE`). When that space runs short, or the volume falls below the upload reserve, what goes first is what matters least: + +1. versions that are not the latest of their file, oldest first; +2. items in the trash, oldest first; +3. the latest version of each file; +4. pinned versions, last. + +A version larger than the whole reserved space is not kept, and the zone's journal says so. Every version removed early is recorded in the journal too. + +Versions and the trash are switched on and off separately: versions can be kept without a trash, and the other way round. + +## Restoring + +- **Restore** puts the file back as the version had it. The content it replaces becomes a version like any other save, and the restored version stays in the history: nothing is lost by restoring the wrong one. +- **Restore as a copy…** writes the version as a new file in a folder you choose, named after the file and the version's date. +- **Replace another file…** puts the version over an existing file you choose; that file's own content becomes one of its versions. +- **Download** saves the version without restoring anything. +- **Open read-only** shows a text file's version in the editor, and a document's in ONLYOFFICE or Collabora. Nothing can be saved from it. + +An editor that was already open on the file when it was restored still holds what the restore replaced. Its next save does not undo the restore: it is set aside as a version marked **Set aside**, from which it can be restored in turn. + +## Inside the office editors + +- **ONLYOFFICE**: the editor's **History** shows the document's versions. Click one to see it; **Restore** is offered to whoever may change the document and works like a restore from the panel. +- **Collabora**: **File → Revision history** opens the Versions panel over the document. A restore made there reopens the document in the editor. + +## Who may do what + +- **See** a file's history: whoever may read the file. +- **Download** a version, or take it out as a copy or over another file: whoever may also download the file. +- **Restore**, **name** or **pin** a version: whoever may change the file. +- **Delete** versions: whoever may delete the file. Deleting versions is permanent; **Delete all** includes pinned versions. The file itself stays. + +### Through a share + +A share's owner decides what it shows of its files' history, with two options in the share dialog: + +- **Show file versions** — visitors see the history, and may restore versions if the share lets them edit; +- **Allow downloading versions** — visitors may also download versions or take them out as copies. It needs the history to be shown. + +Both are on for a new share with named people, who could see the history anyway, and off for a link for anyone. Shares with named people made before versions existed have both switched on. + +## Following the file + +- **Renamed or moved** in NextExplorer, a file keeps its history, to another disk included. +- **Copied**, the copy starts with no history. +- **Sent to the trash**, a file takes its history with it, and gets it back when restored. +- **Deleted for good** — from the trash, at the end of its retention, or straight away — a file's versions go with it. +- **Deleted outside NextExplorer**, a file's history is kept for the trash retention, in case the file comes back to the same place, then removed. + +## Settings → File versions + +Every file in the installation that has a history, in one list, for administrators. The panel answers "what happened to this file"; this answers "where has the space gone", which no path can be asked about — a file deleted outside NextExplorer leaves its versions behind, and those are the histories least likely to be found by looking. + +Each row gives the file, the space it is in, how many versions it has and what they hold, and the date of the most recent. The list is ordered by space used by default, and can be searched by path, narrowed to one space, and narrowed by what became of the file: + +- **Present** — the file is still there; +- **In the trash** — it was deleted and can still be restored, its history with it; +- **Gone** — it disappeared outside NextExplorer, and its versions are the only copy left. They are kept for the trash retention in case it comes back. + +Open a row to see its versions, and delete any of them, or the whole history, from there. Deleting is permanent and includes pinned versions; the file itself is never touched. When a history whose file is gone loses its last version, its entry goes too. + +This list shows paths from every space, personal folders included — which no account can otherwise see of another. That is why the page is for administrators only, and why deleting from it is not something to do on somebody else's behalf without telling them. + +## Settings → Trash and versions + +Administrators can switch versions on or off, set the thinning tiers, the most versions per file and the office checkpoint. Switched off, saves keep nothing new; the versions already kept stay until they are thinned out, removed for space or deleted. The defaults come from [environment variables](/configuration/environment#file-versions). + +Each volume shows how many versions it holds and how much space they take, counted with the trash in its usage bar. + +## Backups + +Versions live in `.nextexplorer/versions` inside each volume, so a backup of the volume includes them. Exclude `.nextexplorer/` from backups if you do not want to back up versions and deleted items. diff --git a/docs/configuration/environment.md b/docs/configuration/environment.md index 007be1a4..214a4357 100644 --- a/docs/configuration/environment.md +++ b/docs/configuration/environment.md @@ -2,16 +2,54 @@ nextExplorer is configured almost entirely through environment variables. The backend (`backend/src/config/env.js`) centralizes the defaults you see here. Use this reference when you want to tune ports, paths, auth, integrations, or feature flags. +## Secrets + +Every credential listed below can be read from a file instead of the environment. Append `_FILE` to the variable name and point it at the file holding the value: + +| Variable | File variant | +| ------------------------------------------- | -------------------------- | +| `SESSION_SECRET` (or `AUTH_SESSION_SECRET`) | `SESSION_SECRET_FILE` | +| `AUTH_ADMIN_PASSWORD` (or `ADMIN_PASSWORD`) | `AUTH_ADMIN_PASSWORD_FILE` | +| `OIDC_CLIENT_SECRET` | `OIDC_CLIENT_SECRET_FILE` | +| `ONLYOFFICE_SECRET` | `ONLYOFFICE_SECRET_FILE` | +| `COLLABORA_SECRET` | `COLLABORA_SECRET_FILE` | + +`docker inspect` prints every environment variable a container was started with, so a secret passed inline is readable by anyone who can reach the Docker daemon and stays in the container's stored configuration. Mounting it as a file keeps it out of both: + +```yaml +services: + nextexplorer: + environment: + ONLYOFFICE_SECRET_FILE: /run/secrets/onlyoffice_secret + secrets: + - onlyoffice_secret + +secrets: + onlyoffice_secret: + file: ./secrets/onlyoffice_secret +``` + +The plain variable wins when both are set. Surrounding whitespace is stripped, so a file written with `echo secret > file` behaves as expected. A `_FILE` naming a missing or empty file stops the server at startup instead of quietly running without the secret. + ## Server & networking -| Variable | Default | Description | -| ------------------------------------------------ | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `PORT` | `3000` | Port the Express API and frontend listen on inside the container. | -| `HTTP_TIMEOUT` | `0` | Node.js HTTP `requestTimeout` (ms). Use `0` to disable (avoids the Node 5-minute default that can abort large uploads). | -| `PUBLIC_URL` | _none_ | External URL (no trailing slash). Drives cookie settings, CORS defaults, and derived callback URLs (OIDC/OnlyOffice). | -| `INTERNAL_URL` | _none_ | Additional origin(s) the app may also be reached from (e.g. a LAN IP for fast local uploads), comma-separated. Treated as valid (no public-URL mismatch warning) and accepted by CORS; `PUBLIC_URL` stays canonical for share links / OIDC. | -| `TRUST_PROXY` | `loopback,uniquelocal` when `PUBLIC_URL` is set | Express trust proxy configuration. Accepts `false`, numbers, CIDRs, or lists. | -| `CORS_ORIGIN`, `CORS_ORIGINS`, `ALLOWED_ORIGINS` | _empty_ | Comma-separated list of allowed CORS origins. Defaults to the `PUBLIC_URL` / `INTERNAL_URL` origins; with none of them set, no cross-origin caller is allowed (same-origin use is unaffected). `*` reflects any origin. | +| Variable | Default | Description | +| ------------------------------------------------ | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `PORT` | `3000` | Port the Express API and frontend listen on inside the container. | +| `ADDRESS` | `0.0.0.0` | Interface the server binds to. Leave it alone unless you have a reason to reach only one network. | +| `HTTP_TIMEOUT` | `0` | Node.js HTTP `requestTimeout` (ms). Use `0` to disable (avoids the Node 5-minute default that can abort large uploads). | +| `UPLOAD_INACTIVITY_TIMEOUT` | `120000` | Classic upload inactivity timeout (ms). If no bytes are received for this delay, the request is aborted and `.uploading` is cleaned up. Use `0` to disable. | +| `UPLOAD_CHUNKED_ENABLED` | `false` | Default for the admin upload setting. When enabled, browser uploads use TUS chunked transfer instead of one large request. | +| `UPLOAD_CHUNK_SIZE` | `8M` | Default TUS chunk size. Supports byte-size suffixes such as `4M`, `16M`, or `64M`; keep it below reverse proxy body limits. | +| `MAX_CHUNK_SIZE_MIB` | `512` | Upper bound (MiB) an admin may set for the chunk size; caps the settings slider/input and clamps saved values. Hard ceiling of 512 MiB. | +| `UPLOAD_STORAGE_RESERVE` | `64M` | Free-space reserve kept when accepting uploads. An upload is rejected with `507` when the destination — or, for a chunked upload, the temporary storage — cannot fit what is coming plus this reserve. The reserve is what keeps a full volume from taking the database down with it, where `/config` shares the filesystem. | +| `TUS_UPLOAD_DIR` | `/tus-uploads` | Temporary storage directory for TUS chunked uploads. Put it on a volume large enough for the biggest in-progress uploads, and — importantly — on the **same filesystem as the destination**: chunks are assembled here and the finished file is then moved into place, which is instant within one filesystem but becomes a full byte-for-byte copy across two. With the default under `CACHE_DIR`, a multi-gigabyte upload appears to stall at 100% while that copy runs. Across filesystems the copy is written under a hidden `.upload-.uploading` name beside the destination and takes its name only once whole, never replacing a file already there (it becomes “name (1)”); one left by a killed process is removed when the next upload to that folder is created. | +| `TUS_INCOMPLETE_UPLOAD_TTL_MS` | `3600000` | Age after which an abandoned chunked upload, or a finished one that could not be moved into its folder, is deleted from the temporary directory (1 hour). An upload being moved into place is never deleted, whatever its age. | +| `TUS_CLEANUP_INTERVAL_MS` | `600000` | Delay between sweeps of the chunked-upload temporary directory (10 minutes). It is also swept at startup and when an upload is created; `0` sweeps only then. | +| `PUBLIC_URL` | _none_ | External URL (no trailing slash). Drives cookie settings, CORS defaults, and derived callback URLs (OIDC/OnlyOffice). | +| `INTERNAL_URL` | _none_ | Additional comma-separated origins. They are accepted by CORS and OIDC returns to the configured origin where login began. | +| `TRUST_PROXY` | `loopback,uniquelocal` when `PUBLIC_URL` is set | Express trust proxy configuration. Accepts `false`, numbers, CIDRs, or lists. | +| `CORS_ORIGIN`, `CORS_ORIGINS`, `ALLOWED_ORIGINS` | _empty_ | Comma-separated list of allowed CORS origins. Defaults to the `PUBLIC_URL` / `INTERNAL_URL` origins when set. When none of them is set, no cross-origin caller is allowed — same-origin use (frontend and API on one host) is unaffected. Use `*` only if you deliberately want to reflect any origin. | ## Logging & debugging @@ -23,27 +61,69 @@ nextExplorer is configured almost entirely through environment variables. The ba ## Paths & volumes -| Variable | Default | Description | -| ------------------------ | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `VOLUME_ROOT` | `/mnt` | Root directory that houses all mounted volumes. | -| `CONFIG_DIR` | `/config` | Location for SQLite, `app-config.json`, extensions, and settings. | -| `CACHE_DIR` | `/cache` | Location for thumbnails, ripgrep indexes, and temporary data. | -| `USER_ROOT` | `/_users` when unset | Root directory for **per-user personal folders**. Each authenticated user gets their own subdirectory under this path. | -| `USER_FOLDER_NAME_ORDER` | `id,username,email_local` | Controls how per-user folder names are derived for personal folders (e.g. set `username,id` to reuse `/home/` when `USER_ROOT=/home`). | -| `HIDDEN_FILE_PATTERNS` | `.` | Comma- or space-separated hidden filename patterns used by directory listings, volume pickers, and search. Plain values are fast filename prefixes, e.g. `.,@` hides dotfiles and Synology `@...` entries. Advanced entries can use `regex:` or `/source/flags`. Set to an empty value to disable pattern hiding. | +| Variable | Default | Description | +| ------------------------ | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `VOLUME_ROOT` | `/mnt` | Root directory that houses all mounted volumes. | +| `CONFIG_DIR` | `/config` | Location for `app.db` (accounts, shares, settings), `logos/` and `session-secret`. The folder to back up. | +| `CACHE_DIR` | `/cache` | Location for thumbnails, RAW previews, sessions, `index.db` (the search index and folder sizes), and temporary data, including `in-flight/`: what a save, an extraction or a compression is writing, so that the next start removes what a stop interrupted. Everything in it can be rebuilt, but the indexes take a pass over the volumes to do so: mount it persistently. | +| `USER_ROOT` | `/_users` when unset | Root directory for **per-user personal folders**. Each authenticated user gets their own subdirectory under this path. | +| `USER_FOLDER_NAME_ORDER` | `id,username,email_local` | Preference order for per-user folder names (e.g. set `username,id` to reuse `/home/` when `USER_ROOT=/home`). A name is given once and kept; an account whose preferred name is already taken takes the next in the order, so two accounts never share a folder. See [personal folders](./personal-folders.md). | +| `HIDDEN_FILE_PATTERNS` | `.,regex:\\.download$,regex:\\.uploading$` | Comma- or space-separated hidden filename patterns used by directory listings, volume pickers, and search. Plain values are fast filename prefixes, e.g. `.,@` hides dotfiles and Synology `@...` entries. Advanced entries can use `regex:` or `/source/flags`; by default, the artifacts of a transfer in progress — `.download` while a file is being fetched, `.uploading` while one is being written — are hidden through this same configurable policy. Overriding this variable replaces the defaults, so include those two patterns in your own list to keep them hidden. Set to an empty value to disable pattern hiding. | + +## Copying & moving + +| Variable | Default | Description | +| ---------------------- | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `FILE_TRANSFER_ENGINE` | native on Linux, `stream` elsewhere | `stream` copies in the application rather than through `rsync`; `native` asks for `rsync` and `rm` whatever the platform. Slower on large transfers, and useful where a native tool is unwanted. Setting it is rarely necessary: a native tool that is missing, or too old to understand what it is asked for — `--info=progress2` arrived in rsync 3.1, and RHEL 7 ships 3.0.9 — is detected on the first copy and the application falls back on its own, saying so in the log. Either way, a copy is written under a hidden `.nextexplorer-copying-*` name beside where it goes and takes its name only once whole, never replacing what holds it (it becomes “name (1)”); a cancelled or failed copy removes only that hidden entry, and one left by a stop is removed at the next start. | + +## Folder-size index + +| Variable | Default | Description | +| --------------------------------------- | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `FOLDER_SIZE_MODE` | `off` | Enables indexed folder sizes: `full` is recursive, `shallow` counts direct entries only. Also a choice in **Settings → Folder sizes**: when this variable is set it decides, and the page shows it and cannot change it; when it is not, the page does. Moving between `shallow` and `full` measures again, since a size counted one way is wrong read the other. | +| `FOLDER_SIZE_EXCLUDE_PATHS` | empty | Comma- or newline-separated paths relative to `VOLUME_ROOT` excluded from folder-size scans. | +| `FOLDER_SIZE_RECONCILE_BATCH` | `100` | Number of indexed folders checked per periodic reconciliation page. | +| `FOLDER_SIZE_RECONCILE_PAUSE_MS` | `200` | Delay between reconciliation pages, used to smooth background I/O. | +| `FOLDER_SIZE_RECONCILE_MAX_DIRECTORIES` | `200` | Maximum indexed folders checked by one scheduled reconciliation slice. `0` restores a full sweep. | +| `FOLDER_SIZE_IO_TIMEOUT_MS` | `30000` | Deadline for one indexed folder-size filesystem operation; `0` disables this protection. | +| `FOLDER_SIZE_MAX_STALLED_IO` | `2` | Timed-out folder-size operations allowed before the indexer pauses further filesystem work. | +| `FOLDER_SIZE_SUBTREE_BATCH` | reconciliation batch | Metadata checks per batch while recovering a folder tree created or changed outside NextExplorer. | +| `FOLDER_SIZE_CONCURRENCY` | `6` | Parallel folder-size scans on local storage. | +| `FOLDER_SIZE_NETWORK_CONCURRENCY` | `2` | Parallel folder-size scans on network storage, where seek latency dominates. | +| `FOLDER_SIZE_FLUSH_MS` | `3000` | Delay before pending folder-size updates are written to the index. | +| `FOLDER_SIZE_RECONCILE_MS` | `0` | Fixed interval between reconciliation sweeps. `0` uses the adaptive interval below. | +| `FOLDER_SIZE_RECONCILE_MIN_MS` | `900000` | Shortest adaptive reconciliation interval (15 minutes). | +| `FOLDER_SIZE_RECONCILE_MAX_MS` | `43200000` | Longest adaptive reconciliation interval (12 hours). | +| `FOLDER_SIZE_REBUILD` | `false` | Drop and rebuild the folder-size index at startup. | +| `FOLDER_SIZE_SUBTREE_PAUSE_MS` | reconciliation pause | Delay between targeted recovery batches. Leave unset to inherit the reconciliation pacing. | +| `FOLDER_SIZE_SUBTREE_SLOW_LOG_MS` | `5000` | Duration after which a targeted recovery emits one `info` performance summary. | + +Targeted subtree recoveries are always serialized so concurrent external changes cannot race their SQLite ancestor updates. The batch and pause settings govern their I/O intensity without affecting the normal list-view reads. ## Authentication -| Variable | Default | Description | -| --------------------------------------- | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `AUTH_ENABLED` | `true` (in prod) | Toggles authentication; disabling makes all APIs public. **Deprecated:** use `AUTH_MODE=disabled` instead. | -| `AUTH_MODE` | `both` (or `local` if OIDC not configured) | Controls which authentication methods are available: `local` (username/password only), `oidc` (SSO only), `both` (both methods), or `disabled` (skip login entirely, same as `AUTH_ENABLED=false`). | -| `SESSION_SECRET`, `AUTH_SESSION_SECRET` | _auto-generated_ | Cryptographic secret used by Express to sign and encrypt session cookies and related tokens. In production, set this to a long, random, **stable** value (at least 32 characters) so sessions remain valid across restarts and multiple replicas; if left unset, a new random secret is generated on each start and all users will be logged out after every restart. | -| `SESSION_MAX_AGE_DAYS` | `30` | Duration (in days) that user sessions remain valid. Sessions persist across browser restarts and server reboots. Set to a lower value (e.g., `7`) for stricter security, or higher (e.g., `90`) for convenience. Applies to both local authentication and OIDC sessions. | -| `AUTH_MAX_FAILED` | `5` | Failed login attempts before temporary lockout. | -| `AUTH_LOCK_MINUTES` | `15` | Lockout duration in minutes when max failures reached. | -| `AUTH_ADMIN_EMAIL` | _none_ | Optional first-run bootstrap for local auth: when set with `AUTH_ADMIN_PASSWORD`, the backend creates an admin user on startup (and the setup wizard is skipped). | -| `AUTH_ADMIN_PASSWORD` | _none_ | Password used for `AUTH_ADMIN_EMAIL` bootstrap. If a user already exists with the same email, this value **overrides/resets** the local password on startup. (Minimum 6 chars; avoid leaving this set unless you want the password enforced on every restart.) | +| Variable | Default | Description | +| --------------------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `AUTH_ENABLED` | `true` (in prod) | Toggles authentication; disabling makes all APIs public. **Deprecated:** use `AUTH_MODE=disabled` instead. | +| `AUTH_MODE` | `both` (or `local` if OIDC not configured) | Controls which authentication methods are available: `local` (username/password only), `oidc` (SSO only), `both` (both methods), or `disabled` (skip login entirely, same as `AUTH_ENABLED=false`). | +| `SESSION_SECRET`, `AUTH_SESSION_SECRET` | _generated once, kept in `CONFIG_DIR/session-secret`_ | Cryptographic secret used by Express to sign session cookies and related tokens. When unset, one is generated at the first start and kept in `CONFIG_DIR/session-secret`, readable by the server’s user only, so sessions survive restarts. A configured value always wins, and nothing is written then: set one — long, random, at least 32 characters — to choose it, or when several replicas share the sessions. If `CONFIG_DIR` cannot be written, a warning is logged and the secret lasts until the next restart. | +| `SESSION_MAX_AGE_DAYS` | `30` | Duration (in days) that user sessions remain valid. Sessions persist across browser restarts and server reboots. Set to a lower value (e.g., `7`) for stricter security, or higher (e.g., `90`) for convenience. Applies to both local authentication and OIDC sessions. | +| `AUTH_MAX_FAILED` | `5` | Failed login attempts before temporary lockout. | +| `AUTH_LOCK_MINUTES` | `15` | Lockout duration in minutes when max failures reached. | +| `AUTH_ADMIN_EMAIL` | _none_ | Optional first-run bootstrap for local auth: when set with `AUTH_ADMIN_PASSWORD`, the backend creates an admin user on startup (and the setup wizard is skipped). | +| `AUTH_ADMIN_PASSWORD` | _none_ | Password used for `AUTH_ADMIN_EMAIL` bootstrap. If a user already exists with the same email, this value **overrides/resets** the local password on startup. (Minimum 6 chars; avoid leaving this set unless you want the password enforced on every restart.) | + +## Passkeys + +A passkey is bound to the hostname it was made on. Nothing here is required for +a single-hostname installation: `PUBLIC_URL` already answers it where it is +set, and the name the request arrived on answers it where it is not. See +[Admin & Access](/admin/guide). + +| Variable | Default | Description | +| ------------------ | ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `WEBAUTHN_RP_ID` | _(from `PUBLIC_URL`, else the request's host)_ | The hostname passkeys are bound to. Set it where an installation is reached through more than one name, so a passkey made on one works on the others. Changing it stops the passkeys already made from working. | +| `WEBAUTHN_RP_NAME` | `NextExplorer` | The name the browser shows while asking for a fingerprint or a PIN. | ## Activity log @@ -57,94 +137,80 @@ what is in force under **Settings → Activity log**. ## OIDC & SSO -| Variable | Default | Description | -| --------------------------------------------------------------- | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `OIDC_ENABLED` | `false` | Enable Express OpenID Connect authentication flow. | -| `OIDC_ISSUER` | _none_ | IdP issuer URL (discovery). | -| `OIDC_AUTHORIZATION_URL`, `OIDC_TOKEN_URL`, `OIDC_USERINFO_URL` | _none_ | Optional overrides for discovery endpoints. | -| `OIDC_LOGOUT_URL` | _none_ | Optional custom IdP logout URL. When set, logout requests redirect to this URL with a `post_logout_redirect_uri` parameter (OIDC standard). If not set, logout only clears the local session. | -| `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET` | _none_ | IdP credentials. | -| `OIDC_CALLBACK_URL` | `${PUBLIC_URL}/callback` when `PUBLIC_URL` is set | Explicit callback path; defaults to `/callback` under `PUBLIC_URL`. | -| `OIDC_SCOPES` | `openid profile email` | Default scopes; add `groups` to propagate group claims. | -| `OIDC_ADMIN_GROUPS` | _none_ | Space/comma-separated names that grant admin rights when found in `groups`, `roles`, or `entitlements`. | -| `OIDC_REQUIRE_EMAIL_VERIFIED` | `false` | When `true`, requires the IdP to verify the user's email before allowing user creation or auto-linking. Some providers like newer Authentik versions set `email_verified` to `false` by default. | -| `OIDC_AUTO_CREATE_USERS` | `true` | When `false`, the user must already exist in the nextExplorer database (local or previously OIDC-linked), otherwise OIDC login is denied. | -| `OIDC_MOBILE_REDIRECT_URIS` | `nextexplorer://oidc-callback` | Comma-separated allowlist of native-app custom-scheme redirect URIs for the mobile PKCE bridge. HTTP(S) URIs are rejected; only an allowlisted URI can receive the one-time authorization code. | - -## Search - -| Variable | Default | Description | -| --------------------- | ------- | ----------------------------------------------------------------------------------------------------- | -| `SEARCH_DEEP` | `true` | Enables deep content search; ripgrep is used when `SEARCH_RIPGREP` is true. | -| `SEARCH_RIPGREP` | `true` | Prefer ripgrep for fast searches; fallback search is used when unavailable. | -| `SEARCH_MAX_FILESIZE` | `5MB` | Skip content search for files larger than this. Accepts a byte count or `K`, `M`, `G`, or `T` suffix. | -| `SEARCH_TIMEOUT_MS` | `5000` | Maximum time a live search may run before returning the results collected so far. | - -## Optional content search index - -The index stores file metadata and extracted search terms, not file contents. It is disabled by default; set `SEARCH_INDEX=true` to build it. The live search remains available while the index catches up. - -| Variable | Default | Description | -| --------------------------- | --------- | ---------------------------------------------------------------------------------------- | -| `SEARCH_INDEX` | `false` | Enables the resumable contentless search index. | -| `SEARCH_INDEX_BATCH` | `25` | Documents committed per index transaction. | -| `SEARCH_INDEX_CPU_PERCENT` | `25` | Maximum share of one CPU core used while indexing (`1`–`100`). | -| `SEARCH_INDEX_MEMORY_MB` | `256` | Extra process-memory budget for an indexing pass when no container memory limit applies. | -| `SEARCH_INDEX_EXCLUDE` | _empty_ | Comma- or newline-separated relative paths that the index must not read. | -| `SEARCH_INDEX_REBUILD` | `false` | When `true`, clears the derived index at startup and rebuilds it. | -| `SEARCH_INDEX_RECONCILE_MS` | `3600000` | Interval for reconciling the index with filesystem changes. | - -## Archives - -The official image includes 7-Zip. Archive operations stream to disk, report progress, can be cancelled, and reject archives that exceed the configured extraction limits. - -| Variable | Default | Description | -| ---------------------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `ARCHIVE_EXTENSIONS` | Built-in archive list | Comma-separated extraction allowlist. Prefix with `+` to extend the built-in list instead of replacing it (for example, `+udf,squashfs`). | -| `MAX_EXTRACTED_ARCHIVE_SIZE` | `32GB` | Maximum total uncompressed size allowed during extraction. Accepts a byte count or `K`, `M`, `G`, or `T` suffix. | -| `MAX_ARCHIVE_ENTRIES` | `100000` | Maximum number of archive entries allowed during extraction. | +| Variable | Default | Description | +| --------------------------------------------------------------- | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `OIDC_ENABLED` | `false` | Enable Express OpenID Connect authentication flow. | +| `OIDC_ISSUER` | _none_ | IdP issuer URL (discovery). | +| `OIDC_AUTHORIZATION_URL`, `OIDC_TOKEN_URL`, `OIDC_USERINFO_URL` | _none_ | Optional overrides for discovery endpoints. | +| `OIDC_LOGOUT_URL` | _none_ | Optional custom IdP logout URL. When set, logout requests redirect to this URL with a `post_logout_redirect_uri` parameter (OIDC standard). If not set, logout only clears the local session. | +| `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET` | _none_ | IdP credentials. | +| `OIDC_CALLBACK_URL` | `${PUBLIC_URL}/callback` when `PUBLIC_URL` is set | Explicit canonical callback path; defaults to `/callback` under `PUBLIC_URL`. Register every `/callback` with the IdP when internal origins are configured. | +| `OIDC_SCOPES` | `openid profile email` | Default scopes; add `groups` to propagate group claims. | +| `OIDC_ADMIN_GROUPS` | _none_ | Space/comma-separated names that grant admin rights when found in `groups`, `roles`, or `entitlements`. | +| `OIDC_REQUIRE_EMAIL_VERIFIED` | `false` | When `true`, requires the IdP to verify the user's email before allowing user creation or auto-linking. Some providers like newer Authentik versions set `email_verified` to `false` by default. | +| `OIDC_AUTO_CREATE_USERS` | `true` | When `false`, the user must already exist in the nextExplorer database (local or previously OIDC-linked), otherwise OIDC login is denied. | +| `OIDC_MOBILE_REDIRECT_URIS` | `nextexplorer://oidc-callback` | Comma-separated allowlist of custom-scheme URIs a native app may receive the mobile sign-in code at. `http(s)` URIs are refused, so the code can never be handed to a web address. Only used by the mobile bridge; see [OIDC](/integrations/oidc#signing-in-from-a-native-app). | + +## Upload & archive limits + +These are safety ceilings, not tuning knobs: they exist so a single request cannot fill the volume. The defaults are high enough for normal use. + +| Variable | Default | Description | +| ---------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `MAX_DIRECT_UPLOAD_SIZE` | `64GB` | Largest single file accepted by a direct (non-chunked) upload, e.g. `10GB`. Chunked/TUS uploads are bounded by their storage guard. | +| `MAX_FILES_PER_UPLOAD` | `50` | Maximum number of files in one direct upload request. | +| `MAX_JSON_BODY_SIZE` | `8MB` | Largest JSON request body accepted. These carry lists of paths — deleting or copying a few thousand files needs a few hundred kB — not file content. Saving from the text editor is the exception: the file travels in one, so leaving this unset lets it rise to carry whatever `EDITOR_MAX_FILESIZE` opens, while a value set here is a ceiling that is kept and lowers the editor instead. | +| `MAX_EXTRACTED_ARCHIVE_SIZE` | `32GB` | Refuse to extract an archive whose declared uncompressed size exceeds this ("zip bomb" guard). | +| `MAX_ARCHIVE_ENTRIES` | `100000` | Refuse to extract an archive holding more entries than this. | -## Recursive folder sizes - -Folder-size indexing is off by default. It calculates recursive byte totals and entry counts in the background; scans resume after interruption and use timeouts and circuit breakers to avoid overloading slow filesystems. - -| Variable | Default | Description | -| --------------------------------- | ------- | --------------------------------------------------------------------------------------------------------- | -| `FOLDER_SIZE_MODE` | `off` | `off` disables indexing; `shallow` indexes listed folders; `full` recursively indexes the available tree. | -| `FOLDER_SIZE_EXCLUDE_PATHS` | _empty_ | Comma- or newline-separated paths to omit from folder-size scans. | -| `FOLDER_SIZE_CONCURRENCY` | `6` | Maximum concurrent local filesystem operations. | -| `FOLDER_SIZE_NETWORK_CONCURRENCY` | `2` | Maximum concurrent operations on network filesystems. | -| `FOLDER_SIZE_FLUSH_MS` | `3000` | Delay before queued index updates are flushed to storage. | -| `FOLDER_SIZE_RECONCILE_MS` | `0` | Optional fixed reconciliation interval; `0` uses adaptive scheduling. | -| `FOLDER_SIZE_REBUILD` | `false` | When `true`, rebuilds the derived folder-size index at startup. | +## Feature toggles -## Upload limits +| Variable | Default | Description | +| --------------------------- | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `SEARCH_DEEP` | _false_ | Enables deep content search; ripgrep is used when `SEARCH_RIPGREP` is true. | +| `SEARCH_RIPGREP` | _true_ | Prefer ripgrep for fast searches; fallback search is used when unavailable. | +| `SEARCH_MAX_FILESIZE` | `5M` | Skip files larger than this when searching their contents. Accepts `5MB`, `5M`, `5mb` or a plain byte count. | +| `SEARCH_TIMEOUT_MS` | `5000` | How long one search may spend looking before answering with what it has. Reading a large tree to be certain there is nothing more is worse than an answer that arrives; the response is marked `truncated` when this ended it, and the panel says so rather than letting a short list look like the whole answer. | +| `SEARCH_INDEX` | `false` | Keep an index of the volume instead of reading it on every search: the words inside documents, and the name of every file and folder whether or not any words could be taken out of them — a folder nobody has filled yet is findable, and one the application has just made is findable at once. Searching a name then costs a query rather than a walk of the storage, which is what makes it bearable on a network share — half a million names answer in about forty milliseconds, where the walk is one round trip per folder. Built by a paced background pass that skips anything it has already read, and stops when the server is asked to. Results are as fresh as the last pass, except in the folder being searched, which is read directly so that a file dropped there a moment ago is still found; a share, a personal folder or an assigned volume is answered from the index too when it lies inside the volume, and read as the search goes when it is mounted somewhere else, since the pass indexes the volume and nothing outside it; so is a search by a reader who has asked to see hidden files, since the pass does not walk into dot-folders. Reckon about 240 bytes of index per file or folder. Searching contents through the index matches whole words and the beginnings of them, where reading the files matches any run of characters: `azul` finds `azules` either way, `ules` only by reading. Also a switch in **Settings → Search index**: when this variable is set it decides, and the switch shows it and cannot move it; when it is not, the switch does. | +| `SEARCH_INDEX_BATCH` | `25` | Documents written per transaction while indexing. | +| `SEARCH_INDEX_CPU_PERCENT` | `25` | The share of one core a background pass may take. It works for a slice of time and then stands aside for the rest, so the load is what you chose whatever the files are. Raising it shortens the first pass and is felt while it runs. | +| `SEARCH_INDEX_EXCLUDE` | _(none)_ | Folders search leaves alone, comma or newline separated, relative to the volume root. Neither the index nor a filename search walks into them — the exception being when one of them is the folder the search was started from, since navigating into it is asking to look. A build tree, a mail spool, a machine backup — hundreds of thousands of files nobody searches by content, and reading them is the whole overhead. Set here they cannot be removed from the interface; **Settings → Search index** holds a second list an administrator can edit. Nothing is excluded by default: with the index answering in place of the live scan, a folder left out is one that cannot be found by content. | +| `SEARCH_INDEX_REBUILD` | `false` | Empty the index at startup and read everything again. It is derived data — every row was read from a file that is still there — so the only cost of being wrong about needing this is one pass. Unset it once the rebuild has finished, or it happens on every start. | +| `SEARCH_INDEX_MEMORY_MB` | `256` | What a background pass may add to the process before it stops and carries on a couple of minutes later. Only consulted when the container enforces no memory limit of its own — where it does, three quarters of that limit is the ceiling instead. What the pass wrote is kept either way, so the next one resumes from there. | +| `SEARCH_INDEX_RECONCILE_MS` | `3600000` | How often to walk the volume again, for changes made outside the application — an rsync, a network share. | +| `SHOW_VOLUME_USAGE` | `false` | Show volume usage badges in the sidebar. | +| `FAVORITES_DEFAULT_ICON` | `outline:StarIcon` | Icon a new favorite starts with, as `variant:IconName` (`outline` or `solid`, and any Heroicons name). Each favorite can be given its own icon afterwards from the sidebar's edit mode. | +| `USER_DIR_ENABLED` | `false` | When `true`, enables a **personal “My Files” space** for each authenticated user under `USER_ROOT`. The frontend shows a “My Files” entry when this flag is on. | +| `USER_VOLUMES` | `false` | When `true`, non-admin users only see volumes assigned to them by an admin. See [User volumes](/admin/user-volumes). | +| `SKIP_HOME` | `false` | When `true`, visits to the home view (`/browse/`) automatically redirect into the first volume instead. | +| `TERMINAL_ENABLED` | `true` | Controls the admin terminal feature. When `false`, terminal routes/UI are disabled. When `true`, nextExplorer attempts to load terminal dependencies and automatically hides/disables terminal if dependencies are unavailable (startup continues). | +| `TERMINAL_FILE_EXTENSIONS` | `sh` | Comma-separated list of file extensions that show the context-menu action to open the file in the admin terminal (for example `sh,bash` or `.sh,.bash`). | -Safety ceilings rather than tuning knobs: they exist so a single request cannot fill the volume, and the defaults are high enough for normal use. +The sharing system (toolbar **Share** button, guest links such as `/share/:token`, and the **Shared with me** page) works out of the box with the feature flags above. Advanced share tuning knobs are documented under **Sharing (advanced)** below. -| Variable | Default | Description | -| ------------------------ | ------- | --------------------------------------------------- | -| `MAX_DIRECT_UPLOAD_SIZE` | `64GB` | Largest single file an upload accepts, e.g. `10GB`. | -| `MAX_FILES_PER_UPLOAD` | `50` | Maximum number of files in one upload request. | +## Trash -## Copying & moving +Deleting moves an item into a hidden `.nextexplorer` folder at the root of its volume — a rename on the same disk, never a copy. These variables set the defaults; administrators change what is in force under **Settings → Trash**. See [Trash](/admin/trash). -| Variable | Default | Description | -| ---------------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `FILE_TRANSFER_ENGINE` | `native` on Linux, else `stream` | Which engine copies a folder: `native` hands the tree to `rsync`, which does the work in one process off the event loop; `stream` copies it in JavaScript. The image carries rsync; without it the JavaScript path runs anyway. | +| Variable | Default | Description | +| ---------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `TRASH_ENABLED` | `true` | Send deleted items to the trash. When `false`, deleting removes items for good; items already in the trash still expire. | +| `TRASH_RETENTION_DAYS` | `30` | How long an item stays in the trash before it is removed for good, from 1 to 3650 days. | +| `TRASH_MAX_PERCENT` | `10` | The most the trash may hold on each volume, as a share of that volume's size (1 to 90). The oldest items go first when it is exceeded. | +| `TRASH_MAX_SIZE` | _(none)_ | An optional size cap per volume, such as `50G`. The smaller of this and `TRASH_MAX_PERCENT` applies. An item larger than the whole trash is never silently removed: the person deleting it is asked. | -## Feature toggles +## File versions -| Variable | Default | Description | -| -------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `SHOW_VOLUME_USAGE` | `false` | Show volume usage badges in the sidebar. | -| `USER_DIR_ENABLED` | `false` | When `true`, enables a protected personal **My Files** space for each authenticated user under `USER_ROOT`. | -| `USER_VOLUMES` | `false` | When `true`, non-admin users only see volumes assigned to them by an admin. See [User volumes](/admin/user-volumes). | -| `SKIP_HOME` | `false` | When `true`, visits to the home view (`/browse/`) automatically redirect into the first volume. | -| `TERMINAL_ENABLED` | `true` | Controls the admin terminal feature. When `false`, terminal routes/UI are disabled. | -| `TERMINAL_FILE_EXTENSIONS` | `sh` | Comma-separated extensions that show the context-menu action to open a file in the admin terminal (for example `sh,bash` or `.sh,.bash`). | +Saving over a file keeps what the save replaces as a version, in the same `.nextexplorer` zone and the same reserved space as the trash. These variables set the defaults; administrators change what is in force under **Settings → Trash and versions**. See [File versions](/admin/versions). -The sharing system (toolbar **Share** button, guest links such as `/share/:token`, and the **Shared with me** page) works out of the box with the feature flags above. Advanced share tuning knobs are documented under **Sharing (advanced)** below. +| Variable | Default | Description | +| ------------------------------------- | ------- | ---------------------------------------------------------------------------------------------------- | +| `VERSIONS_ENABLED` | `true` | Keep earlier versions of files. Independent of `TRASH_ENABLED`. | +| `VERSIONS_KEEP_ALL_HOURS` | `24` | Every version is kept for this many hours (1 to 720). | +| `VERSIONS_HOURLY_DAYS` | `7` | Then the newest of each hour, up to this many days (1 to 365). | +| `VERSIONS_DAILY_DAYS` | `30` | Then the newest of each day, up to this many days (1 to 3650); after that, the newest of each week. | +| `VERSIONS_MAX_PER_FILE` | `50` | The most versions a file keeps (1 to 1000). Pinned versions do not count. | +| `VERSIONS_SESSION_CHECKPOINT_MINUTES` | `10` | In a long ONLYOFFICE or Collabora session, a version is kept at most this often (1 to 1440 minutes). | ## Editor @@ -153,19 +219,48 @@ The sharing system (toolbar **Share** button, guest links such as `/share/:token | `EDITOR_EXTENSIONS` | _empty_ | Comma-separated list of additional file extensions to support in the inline text editor (e.g., `toml,proto,graphql` or `.toml,.proto`). These are **added to** the built-in defaults (txt, md, json, js, ts, py, etc.), not replacing them. Changes take effect on container restart—no frontend rebuild required. | | `EDITOR_MAX_FILESIZE` | `2M` | Maximum file size allowed to open in the inline text editor. Accepts a byte count or a size with `K`, `M`, `G`, `T` suffix (base 1024), e.g. `512K`, `2M`, `1G`. Files larger than this will show “This file is too large to open in the text editor.” | +## Archives + +| Variable | Default | Description | +| -------------------- | ------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ARCHIVE_EXTENSIONS` | `7z,zip,iso,rar,tar,gz,tgz,bz2,tbz2,xz,txz,cab,wim,cpio,rpm,deb,z,lzh,arj,zst` | Extensions offered for the “Extract archive” action, and for looking inside one. A plain list (e.g. `zip,iso,7z`) **replaces** the defaults; prefix the list with `+` (e.g. `+udf,squashfs`) to **extend** them instead. Whatever the list says, a format is only offered when the bundled 7-Zip build actually supports it (probed at startup). Password-protected ZIP, 7z and RAR archives are supported through the extraction dialog; passwords are not persisted. | + ## OnlyOffice & thumbnails -| Variable | Default | Description | -| ----------------------------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ONLYOFFICE_URL` | _none_ | Public URL for Document Server (must reach your app's `PUBLIC_URL`). | -| `ONLYOFFICE_SECRET` | _none_ | JWT secret shared with OnlyOffice Document Server for `/api/onlyoffice` calls. | -| `ONLYOFFICE_DOWNLOAD_ORIGINS` | _none_ | Comma-separated extra origins a saved document may be fetched from. Set it when the Document Server reports itself under another host than `ONLYOFFICE_URL`; that one is always allowed. | -| `ONLYOFFICE_LANG` | `en` | Language code for the editor UI. | -| `ONLYOFFICE_FORCE_SAVE` | `false` | When true, OnlyOffice forces users to save via the editor UI. | -| `ONLYOFFICE_FILE_EXTENSIONS` | _default list_ | Extra file extensions to surface to the Document Server. | -| `FFMPEG_PATH`, `FFPROBE_PATH` | _bundled binaries_ | Point to custom ffmpeg/ffprobe if the bundle doesn't suit your needs. | -| `FFMPEG_HWACCEL` | _none_ | Optional ffmpeg `-hwaccel` value used for video thumbnail generation when supported by your ffmpeg build (e.g. `vaapi`, `qsv`, `cuda`). | -| `FFMPEG_HWACCEL_DEVICE` | _none_ | Optional ffmpeg `-hwaccel_device` value used with `FFMPEG_HWACCEL` (e.g. `0` or `/dev/dri/renderD128`). | +| Variable | Default | Description | +| ------------------------------------- | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ONLYOFFICE_URL` | _none_ | Public URL for Document Server (must reach your app's `PUBLIC_URL`). | +| `ONLYOFFICE_SECRET` | _none_ | JWT secret shared with OnlyOffice Document Server for `/api/onlyoffice` calls. | +| `ONLYOFFICE_DOWNLOAD_ORIGINS` | _none_ | Comma-separated extra origins the Document Server may serve saved documents from. Set it when the callback URL host differs from `ONLYOFFICE_URL`; that origin is always allowed. | +| `ONLYOFFICE_LANG` | `en` | Language code for the editor UI. | +| `ONLYOFFICE_FORCE_SAVE` | `false` | When true, the OnlyOffice Save button writes the current version immediately. | +| `ONLYOFFICE_AUTO_SAVE_INTERVAL_MS` | `30000` | Minimum delay in milliseconds between background force-saves after OnlyOffice has synchronized changes. Set `0` to save only when closing; capped at `300000`. | +| `ONLYOFFICE_FORCE_SAVE_TIMEOUT_MS` | `10000` | Retry window in milliseconds when a force-save reaches Document Server before its final changes. Minimum `7000`; the interface does not wait for the callback. | +| `ONLYOFFICE_FILE_EXTENSIONS` | _default list_ | Extra file extensions to surface to the Document Server. | +| `FFMPEG_PATH`, `FFPROBE_PATH` | _bundled binaries_ | Point to custom ffmpeg/ffprobe if the bundle doesn't suit your needs. | +| `SEVEN_ZIP_PATH` | `7z` _on PATH_ | The 7-Zip to run. Which archive formats can be opened is read from `7z i` at startup and written to the log, so a build without the RAR codec loses RAR and nothing else. | +| `EXIFTOOL_PATH` | _bundled ExifTool_ | The ExifTool to run, for RAW photo metadata. Rarely needed: the bundled copy is used when it is there, and when it is not — the minimal archive leaves its 21 MB of Perl behind — `/usr/bin/exiftool`, `/usr/local/bin/exiftool` and `/opt/homebrew/bin/exiftool` are tried in that order, so `apt install libimage-exiftool-perl` is the whole of it. Set this only for one kept somewhere else. Absolute paths and not `PATH`, which a service inherits from whatever started it. | +| `FFMPEG_HWACCEL` | _none_ | Optional ffmpeg `-hwaccel` value used for video thumbnail generation when supported by your ffmpeg build (e.g. `vaapi`, `qsv`, `cuda`). | +| `FFMPEG_HWACCEL_DEVICE` | _none_ | Optional ffmpeg `-hwaccel_device` value used with `FFMPEG_HWACCEL` (e.g. `0` or `/dev/dri/renderD128`). | +| `FFMPEG_HWACCEL_OUTPUT_FORMAT` | _none_ | Optional ffmpeg `-hwaccel_output_format` value, used with `FFMPEG_HWACCEL`. Some hardware pipelines need it (for example `vaapi`) to hand frames back in a format the encoder accepts. | +| `THUMBNAILS_ENABLED` | `true` | Set to `false` to disable thumbnail generation globally, regardless of the UI setting. | +| `THUMBNAIL_CACHE_MAX_FILES` | `3000` | Maximum number of files kept in the thumbnail cache; past it, the least recently written go first. Thumbnails written by releases up to 2.0.3, and temporary files untouched for an hour, are removed too. Set `0` to lift the limit on the count; outdated, expired and abandoned files are still removed. | +| `THUMBNAIL_CACHE_CLEANUP_INTERVAL_MS` | `3600000` | Delay between thumbnail and RAW preview cache cleanup passes. They run on their own, whether or not anything is being generated. | +| `THUMBNAIL_CACHE_CLEANUP_BATCH_SIZE` | `500` | Maximum number of thumbnail or RAW preview cache files deleted per cleanup pass. | +| `THUMBNAIL_CACHE_TTL_DAYS` | `30` | Remove thumbnails and RAW previews older than this age during the periodic cleanup. Set `0` to keep entries until the file-count limit is reached. | +| `RAW_PREVIEW_CACHE_MAX_FILES` | `500` | Maximum number of embedded RAW previews kept in `/cache/raw-previews`, full-size JPEGs; the oldest go first. Set `0` to lift the limit on the count; outdated, expired and abandoned previews are still removed. | +| `THUMBNAIL_SHARP_CACHE_MEMORY_MB` | `0` | Memory in MB allowed for Sharp/libvips thumbnail cache. Keep `0` to minimize idle RSS after thumbnail generation. | +| `THUMBNAIL_VIDEO_CONCURRENCY` | `1` | Maximum number of concurrent ffmpeg thumbnail jobs. Keep low on small hosts to avoid memory spikes. | +| `THUMBNAIL_DIAGNOSTICS_ENABLED` | `false` | Enable periodic thumbnail diagnostics logs with queue, memory, active job, external process, and cache cleanup counters. | +| `THUMBNAIL_DIAGNOSTICS_INTERVAL_MS` | `30000` | Interval between thumbnail diagnostics logs when diagnostics are enabled. | +| `THUMBNAIL_BACKGROUND_QUEUE_LIMIT` | `200` | Maximum thumbnails queued for background generation before new requests are dropped. | +| `THUMBNAIL_PROCESS_NICE` | `10` | `nice` value applied to external thumbnail processes, so they yield to interactive work. | +| `THUMBNAIL_VIDEO_SEEK_PERCENT` | `10` | Position in the video, as a percentage of its duration, used to grab the thumbnail frame. | +| `THUMBNAIL_VIDEO_SCALE_FLAGS` | `fast_bilinear` | ffmpeg scaling algorithm for video thumbnails. Slower flags give a sharper image. | +| `THUMBNAIL_VIDEO_SEEK_SECONDS` | `5` | Fixed position in the video used to grab the thumbnail frame, when no percentage is set. | +| `THUMBNAIL_VIDEO_THREADS` | `2` | Threads allowed to one ffmpeg thumbnail job. | +| `THUMBNAIL_SLOW_JOB_MS` | `10000` | Duration threshold after which a thumbnail job/process is logged even when diagnostics are disabled. | +| `THUMBNAIL_FFMPEG_TIMEOUT_MS` | `300000` | Longest one ffmpeg may take over a single thumbnail before it is killed and the thumbnail marked failed. Raise it if very large videos on slow storage are being cut short; the minimum is `1000`. | ## Collabora (WOPI) @@ -194,6 +289,54 @@ These variables are available for tuning the share system. The defaults are suit ## Container user mapping -| Variable | Description | -| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `PUID`, `PGID` | Map container processes to host user/group IDs so created files have consistent ownership. Defaults to `1000`. The entrypoint adjusts ownership of `/app`, `/config`, and `/cache` accordingly. | +| Variable | Description | +| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `PUID`, `PGID` | Map container processes to host user/group IDs so created files have consistent ownership. Defaults to `1000`. The entrypoint adjusts ownership of `/app`, `/config`, and `/cache` accordingly. Set both to `0` to run as root — see [Running as root](#running-as-root). | + +## Running as root + +The entrypoint always finishes with `gosu appuser`, so the application runs as +`appuser` whatever Compose's `user:` says. Setting `user: root` therefore +changes who runs the entrypoint — which was already root — and not who runs the +server. The knob is `PUID` and `PGID`: + +```yaml +environment: + - PUID=0 + - PGID=0 +``` + +The entrypoint renumbers `appuser` to those ids before dropping to it, so the +server runs as root and can read and write anything the mounts expose. + +Compose's `user:` is a different thing and does not do this. It decides who runs +the entrypoint, which was already root; the entrypoint then drops to `appuser` +whatever it says. + +That is a real choice rather than a default, and worth making deliberately: + +- Every file NextExplorer creates on the host is owned by root. +- A mount of `/` gives it the whole host, `/etc` included, with write access + wherever the share or the volume allows writing. + +Where the aim is only to reach a folder the container cannot currently read, +matching its owner is usually enough — `PUID` and `PGID` set to that owner's +ids, which is what they are for. + +## Starting the container as a fixed user + +Giving the container a user of its own — `docker run --user`, Compose's +`user: 1000:1000`, a Kubernetes `securityContext` — is supported and means +something different from `PUID`/`PGID`. + +The entrypoint notices, and does none of the things only root can do: it does +not renumber `appuser`, does not take ownership of `/config` and `/cache`, and +does not drop to another user. The application runs as whoever the container was +started as, which is what was asked for. `PUID` and `PGID` are ignored in that +case, and the log says so when they were set. + +What that leaves to the deployment is ownership of the mounts. Under Docker, +the directories must already be readable and writable by that user. Under +Kubernetes, `fsGroup` does the job `PUID`/`PGID` do here — which is also what +makes the image usable on a cluster enforcing the `restricted` Pod Security +Standard, where running as root is refused outright. diff --git a/docs/configuration/personal-folders.md b/docs/configuration/personal-folders.md index 11c19fd3..e0652336 100644 --- a/docs/configuration/personal-folders.md +++ b/docs/configuration/personal-folders.md @@ -7,7 +7,6 @@ This section explains how it works and how to enable it. ## How personal folders work - Each authenticated user gets a private directory under a common root (`USER_ROOT`). -- The resolver confines every personal path to that user’s directory. It rejects attempts to traverse through symlinks, access another user’s folder, or exploit a colliding folder name. - Logical paths for personal items always start with `personal/`: - Example: `personal`, `personal/photos`, `personal/docs/report.docx`. - The backend maps those logical paths to the filesystem: @@ -49,8 +48,34 @@ USER_FOLDER_NAME_ORDER=id,username,email_local - Default: `id,username,email_local`. - Controls how `` is chosen for personal folders. Valid values: `id`, `username`, `email`, `email_local`, `displayname`. - Example (reuse existing Linux home directories): set `USER_ROOT=/home` and `USER_FOLDER_NAME_ORDER=username,id`. - -> Note: Changing `USER_FOLDER_NAME_ORDER` after users already have personal folders will make nextExplorer look in a different location. Move/rename directories (or create symlinks) if you need to migrate existing data. + - The order is a preference, not a formula. The first account to be given a + name keeps it, and an account whose preferred name is already taken walks + down the rest of the order to the next free one — `id` is always last, and + ids are unique, so it always ends somewhere. Nothing about `username` or + `email_local` is unique on its own (`bob@a.com` and `bob@b.com` both yield + `bob`), and without this two accounts would share a folder and see each + other's files. + - Deleting an account does not free its name while its folder is still on + disk: the folder stays, with whatever was in it, and the next account whose + preferred name it is takes the next free one instead of opening the + deleted account's files. Once the folder is removed or renamed, the name + can be given again. + +> Note: the name an account is given is kept. Changing `USER_FOLDER_NAME_ORDER` +> afterwards applies to accounts created from then on, and leaves the existing +> ones where they are — so the change cannot silently take a folder away from +> whoever is using it. +> +> To move existing accounts to a new order deliberately, move or rename their +> directories on disk, then clear the assignment so it is worked out again at +> their next sign-in: +> +> ```sql +> UPDATE users SET personal_folder_name = NULL; +> ``` +> +> Where two accounts then prefer the same name, the first to sign in gets it. +> Assign the names yourself in the same statement if that matters. > Note: The personal folder UI will not appear unless `USER_DIR_ENABLED=true`. The app also refuses to resolve `personal/...` when this flag is off. @@ -94,7 +119,7 @@ To enable personal folders in a production deploy (see the full example in [Depl ```yaml services: nextexplorer: - image: nxzai/explorer:latest + image: ghcr.io/cerede2000/explorer:latest environment: - NODE_ENV=production - PUBLIC_URL=https://files.example.com @@ -116,7 +141,6 @@ services: ## Permissions and access control - Personal folders are designed to be **per-user** homes. -- Users cannot browse another user’s personal folder, even when directory names derived from usernames or email addresses are similar. - By default, access control rules (Settings → Access Control) apply to logical paths: - You can create rules targeting `personal` or `personal/` if you want to further restrict access. - Favorites and quick access: diff --git a/docs/experience/features.md b/docs/experience/features.md index 0aed8f0a..0531ef0d 100644 --- a/docs/experience/features.md +++ b/docs/experience/features.md @@ -5,11 +5,21 @@ nextExplorer mixes a modern browser experience with secure access controls and f ## File browsing & previews - **Dual views:** Switch between responsive grid, list, and column modes while keeping breadcrumbs, toolbar, and search accessible. -- **Inline previews:** Images, videos, PDFs, and text files preview instantly without downloads. Media previews identify unsupported codecs and support embedded or sidecar subtitles, converting compatible subtitle tracks to WebVTT. Image/video thumbnails are generated automatically using FFmpeg (`FFMPEG_PATH`/`FFPROBE_PATH` can override binaries). -- **Drag-to-move (desktop):** Select one or more items, then drag them onto a destination folder to move them. +- **A document has an address:** Every document that opens — a photograph, a video, a PDF, a spreadsheet in ONLYOFFICE or Collabora — has a URL of its own, `/open/`. It can be linked to, kept as a bookmark, and opened in a browser tab of its own. Settings → Preferences → **Open documents in a new tab** makes that the default for every kind of file at once, so several stay open while you go on browsing; left off, documents open over the folder as they always have. Closing the tab ends the editing session exactly as closing the panel does, so nothing is left marked as being edited by somebody who has gone. +- **A file that has a history says so:** A small mark on the row, with how many earlier versions there are; clicking it opens the [Versions](/admin/versions) panel. Settings → Preferences → **Mark files that have versions** turns it off. It appears only where the history itself would be shown, so a share that does not hand out histories does not hand out the mark either. Administrators get the other end of it under Settings → **File versions**: every file in the installation that has one, ordered by the space it takes, with the versions deletable from there. +- **Inline previews:** Images, videos, PDFs, and text files preview instantly without downloads. Image/video thumbnails are generated automatically using FFmpeg (`FFMPEG_PATH`/`FFPROBE_PATH` can override binaries). +- **Media gallery:** Pictures and videos open in one viewer and are browsed together — swipe on a touch device, arrow keys or on-screen arrows elsewhere. Pictures zoom by pinch, double-tap or ctrl-wheel; while zoomed, dragging pans the picture instead of turning the page. +- **Subtitles:** A video offers the subtitle tracks inside it and any subtitle files sitting beside it — `film.srt`, `film.fr.srt`, `film.en.forced.srt` — converted to WebVTT and listed in the browser's own captions menu. Blu-ray and DVD subtitles are pictures of words rather than text, so they are not offered; turning those into captions would need OCR. +- **Why a video is silent:** Playback hands the file to the browser and never transcodes — that is a media server's job, not a file explorer's. The consequence used to be invisible: a film whose soundtrack is AC-3, E-AC-3, DTS or TrueHD plays perfectly with no sound in Chrome or Firefox, because those browsers will not decode them. The player now says so, naming the codec, and says the same when the picture itself is one the browser cannot decode — HEVC, most often. Switching between audio tracks appears only in browsers that support it, which today means Safari. + +- **Drag-to-move (desktop):** Select one or more items, then drag them onto a destination folder to move them. Hold Alt (Option on macOS) to copy instead, and drop onto a favorite in the sidebar to send items there without navigating. +- **Move to / Copy to:** From the context menu, pick a destination and the transfer runs in the background. Nothing is ever replaced: a name already taken gets a suffix. - **Drag-to-upload:** Drop files or folders from your device onto the main pane to upload them. - **Mobile selection mode:** On touch devices, use **Select** to enable checkbox selection for batch actions. -- **Context menus:** Right-click the background or individual items for quick shortcuts (New Folder/File, Paste, Rename, Get Info, download, delete). +- **Context menus:** Right-click the background or individual items for quick shortcuts (New Folder/File, Paste, Move to, Rename, Get Info, download, delete). +- **Per-folder sorting:** A folder reopens sorted the way you left it. +- **Folder sizes:** Switched on from **Settings → Folder sizes** (or `FOLDER_SIZE_MODE`), folders show their recursive size — or only the size of their own files — computed in the background and kept up to date as files move. +- **Keyboard navigation:** Move through a folder with the up and down arrows, open with Enter or the right arrow, and go up a level with Backspace or the left arrow. ## Editing, sharing & document workflows @@ -17,29 +27,204 @@ nextExplorer mixes a modern browser experience with secure access controls and f - **Link-based sharing:** Use the **Share** button in the toolbar to create share links for any folder or file you can access (including items under **My Files** when personal folders are enabled). Shares can be: - **Read-only** or **read/write**. - **Anyone with the link** or **specific users**. + - **Downloadable, or read-only in the stricter sense.** Turning downloads off leaves the share readable while withholding the file itself — the download button is gone and the endpoint refuses. It is deliberately independent of read/write, because "collaborate on this, but do not take a copy home" is a coherent thing to ask for. Shares created before this existed, and any share where it is not set, allow downloads. - Optionally **password-protected** and **time-limited** with an expiration date. After creation, the dialog shows a friendly label, final URL (based on `PUBLIC_URL` when set), and a one-click **Copy link** button. -- **Guest access to shares:** Public “anyone with the link” shares use short tokens (for example, `/share/aBc123XyZ`) and create a limited **guest session** so visitors can browse just the shared item. Password-protected shares prompt for the password first — every visitor but the share's owner, signed-in accounts included, since being signed in is not knowing the password — and ask once per visit; setting or changing the password signs out everyone who opened the link before; user-specific shares redirect to the login screen and apply normal access checks after authentication. +- **Guest access to shares:** Public “anyone with the link” shares use short tokens (for example, `/share/aBc123XyZ`) and create a limited **guest session** so visitors can browse just the shared item. Password-protected shares prompt for the password first; user-specific shares redirect to the login screen and apply normal access checks after authentication. The password applies to everyone except the share's owner — being signed in, including as an administrator, is not the same as knowing it. This matters for shares pointing at a personal folder, which no other account can reach any other way. With `AUTH_MODE=disabled` there are no accounts to tell apart and every visitor already browses the whole filesystem, so the prompt is skipped. - **“Shared with me” view:** The **Shares** section in the sidebar links to a **Shared with me** page showing items other people have shared with you, including status (active/expired), access mode, and last accessed time. -- **ONLYOFFICE integration:** When `ONLYOFFICE_URL` and the JWT `ONLYOFFICE_SECRET` are configured, docx/xlsx/pptx/odt/ods/odp files open with co-editing capabilities via `/api/onlyoffice/*` endpoints. +- **ONLYOFFICE integration:** When `ONLYOFFICE_URL` and the JWT `ONLYOFFICE_SECRET` are configured, docx/xlsx/pptx/odt/ods/odp files open for editing via `/api/onlyoffice/*`. Two people opening the same document join the same session and edit it together. The editor follows the app's theme, closes with its own button (saving on the way out), and can rename the open document, save it under a new name, share it, mention other users, compare against another version, and insert files picked from your own storage. Work is saved in the background while the document stays open. +- **New office documents:** The drawer beside **New file** creates a blank Word, Excel or PowerPoint document and opens it straight in the editor. - **Favorites:** Pin folders to the sidebar with a star so critical paths stay in reach across sessions. -- **Archive operations:** Extract supported 7-Zip formats or create archives from the context menu. Password-protected archives, progress, cancellation, and extraction safety limits are supported. ## Search & metadata -- **Smart search:** Search filenames, Office documents, PDFs, and file contents from one interface. Filename glob patterns such as `*.pdf` match names without scanning contents; `SEARCH_RIPGREP`, `SEARCH_DEEP`, `SEARCH_MAX_FILESIZE`, and `SEARCH_TIMEOUT_MS` tune live searches. Set `SEARCH_INDEX=true` for an optional, bounded background index. +- **Smart search:** The search bar finds names and contents inside the current folder and its children, from three characters on. With the search index switched on (**Settings → Search index**, or `SEARCH_INDEX`), both are answered from the index, so a search on a network share costs a query rather than a walk of the storage; without it, ripgrep reads the files (`SEARCH_RIPGREP`, `SEARCH_DEEP`, `SEARCH_MAX_FILESIZE`). Each result says whether its name or its contents matched. The line shown under a content match is read back from the file, a few files at a time and only for the results on the page; on a slow share, those not read within two seconds are listed without their line rather than holding the answer. Names are ranked — the whole name, then a name that begins with the term, then one that holds it — and contents by relevance when the index answers, by folder otherwise, so that a folder's files arrive together. A search that ran out of time says so, rather than passing a short list off as the whole answer, and an accented name is found however the machine that wrote it encoded the accent. +- **Filename patterns:** `*` and `?` in a search term match filenames rather than text — `*.ps1` finds the scripts, `conf?g.json` finds either spelling, and `Stacks/*/logs/*.log` reaches across folders. A pattern names a shape, so nothing is read inside files for it, which is also what makes it immediate. +- **Inside documents:** Word, Excel and PowerPoint files are archives of XML and PDFs keep their words in compressed streams, so a plain content search finds nothing in either. Their text is read and searched — including a word an author emphasised halfway through, which Word stores in pieces. A scanned PDF is a picture of a page and stays unsearchable: that would need OCR. - **Metadata overlays:** List view shows size, kind, modified date, owner, and volume stats (volume usage visibility flips on with `SHOW_VOLUME_USAGE`). -- **Thumbnail cache:** `/cache` holds thumbnails and search indexes that regenerate when cleared. +- **Thumbnail cache:** `/cache` holds thumbnails, RAW previews and search indexes that regenerate when cleared; thumbnails and previews are kept within their limits (`THUMBNAIL_CACHE_MAX_FILES`, `RAW_PREVIEW_CACHE_MAX_FILES`). ## Access & security - **Local users & groups:** Create local accounts from Settings → Admin; the first account becomes admin and can’t be removed while others exist. -- **OIDC SSO:** Express OpenID Connect exposes `/login`, `/logout`, and `/callback`, so you can federate with Keycloak, Authentik, Authelia, or any compliant provider. Native iOS and Android clients can use the PKCE-secured mobile bridge. Admin elevation happens when the IdP groups/roles intersect `OIDC_ADMIN_GROUPS`. +- **Passkeys:** Any local account can add one in Settings → Passkeys, and sign in with a fingerprint, a face or the device's PIN instead of a password. The key stays on the device and what it signs names this site, so it cannot be phished, watched or replayed. A passkey that was unlocked to be used answers the second factor as well; one that was not still asks for the code. Browsers only allow this on a secure page served from a hostname, which the page says when it cannot be offered. +- **Two-factor authentication:** Any local account can turn on a second factor in Settings → Two-factor — a QR code for any authenticator app, a code to confirm the phone kept the secret, and ten recovery codes shown once. Signing in then asks for a code after the password; six digits are worth one sign-in, and a recovery code one use. With OIDC the second factor is the provider's. +- **OIDC SSO:** Express OpenID Connect exposes `/login`, `/logout`, and `/callback`, so you can federate with Keycloak, Authentik, Authelia, or any compliant provider. Admin elevation happens when the IdP groups/roles intersect `OIDC_ADMIN_GROUPS`. +- **Per-user access control:** Grant or deny paths per user or group, with read, write and delete kept apart. Personal folders (`USER_DIR_ENABLED`) and per-user volumes build on the same rules. +- **Secrets from files:** Every credential can be read from a file instead of the environment, so nothing sensitive appears in `docker inspect`. See [Secrets](/configuration/environment#secrets). - **Workspace lock:** A workspace password (set on first run) gates access, and admin-only sections (Files & Thumbnails, Security, Access Control, Admin Users) appear only when your role allows it. ## Operational helpers +- **The language you read in:** The interface follows the browser, which is right for most people and wrong for anybody whose browser is not in their language — a shared machine, a company image, a second account. Settings → Preferences → **Language** chooses one for the account, so it travels with you to whichever browser you sign in from; left on _Follow the browser_, nothing changes. The globe on the sign-in page still chooses a language for that browser, which is the one thing an account cannot do before anybody has signed in. - **Resizeable sidebar:** The sidebar can be dragged to different widths for wide or narrow monitors. -- **Notifications & uploads:** A floating footer panel tracks uploads, providing pause/resume/cancel controls plus multi-file progress. -- **Nothing is ever replaced:** A copy, a move, an upload, an extraction, a new archive or a new folder takes the name it asks for only when nothing holds it — even something that arrives while it runs — and otherwise takes “name (1)”, or “name 2” for a new folder. It never replaces a file and never pours into a folder that is already there. +- **Notifications & transfers:** A floating panel tracks uploads, copies, moves and archive work, with pause, resume and cancel, the transfer rate, and per-file detail when several run at once. +- **Chunked uploads:** Large files can be uploaded in resumable chunks (`UPLOAD_CHUNKED_ENABLED`), which survives a dropped connection and gets past reverse proxies that refuse large bodies — a fallback switches to chunks automatically when one does. Once the transfer ends, the server may still be writing the file into place; that phase is reported separately rather than appearing to stall at 100%, and a file that arrived but could not be put in its folder is reported as a failure, with the reason, never as done. +- **Cancellable file operations:** Copy and move run natively with real progress and can be stopped mid-way, leaving nothing half-written. +- **Nothing is ever replaced:** A copy, a move, an upload, an extraction, a new archive, a new folder, a Save as, a copy of a version or a restore from the trash takes the name it asks for only when nothing holds it — even something that arrives while it runs — and otherwise takes “name (1)”, or “name 2” for a new folder. It never replaces a file and never pours into a folder that is already there, and undoing one that failed removes only what it wrote itself: a file someone saved in the meantime stays. - **Keyboard shortcuts:** ⌘/Ctrl+C/X/V for clipboard actions, plus quick navigation via breadcrumbs and toolbar icons. + +- **Activity log:** Off unless an administrator turns it on, in Settings → Activity log. On, it writes down who signed in — including who tried and failed — what was downloaded, uploaded, deleted, restored and removed for good, what left through which share link and what arrived through one, every change to a password, a second factor or a passkey, and every account or setting an administrator changed. Administrators read it, and it keeps each line for as long as the retention says. + +## How it compares + +Two projects solve the same problem from a different angle: +[FileBrowser Quantum](https://github.com/gtsteffaniak/filebrowser), the active +fork of File Browser, and [Filestash](https://www.filestash.app/), which speaks +every storage protocol there is. Every cell below was read on **16 September +2026** from the project it describes — its repository, its documentation, its +pricing page — rather than from anybody's marketing or anybody's comparison +chart. The sources are listed underneath, including the ones about NextExplorer. + +✅ shipped · 🚧 announced by that project as coming · ❌ not offered · 💰 paid +tier · — not documented + +### The project + +| | **NextExplorer 3.7** | **FileBrowser Quantum** | **Filestash** | +| --------------------------------- | -------------------- | ----------------------- | ----------------------------------------------- | +| Licence | GPL-3.0 | Apache-2.0 | AGPL-3.0 (core) | +| Price | Free | Free | Free — Pro from $50/mo, Enterprise from $290/mo | +| Interface languages | 15 | 26 | — | +| Docker image, amd64 and arm64 | ✅ | ✅ | ✅ | +| Official installer outside Docker | ✅ Linux archive | ✅ | 💰 | + +### Archives, without unpacking them + +| | **NextExplorer 3.7** | **FileBrowser Quantum** | **Filestash** | +| --------------------------------- | ---------------------------------- | ----------------------- | ---------------- | +| Browse one like a folder | ✅ zip, 7z, rar, iso, tar, tar.gz… | 🚧 | ✅ viewer plugin | +| Read a file inside one | ✅ text, Markdown, images | 🚧 | ✅ viewer plugin | +| Take one entry — or several — out | ✅ into any folder you pick | ❌ | ❌ | +| Compress a selection | ✅ | ✅ | ❌ | + +### Getting data in and out + +| | **NextExplorer 3.7** | **FileBrowser Quantum** | **Filestash** | +| ------------------------------ | ----------------------------- | ----------------------- | ------------- | +| Chunked, resumable uploads | ✅ | ✅ | ✅ | +| Upload a whole folder | ✅ | ✅ | ✅ | +| Never replaces a file silently | ✅ “name (1)”, and it says so | — | — | + +### When something goes wrong + +| | **NextExplorer 3.7** | **FileBrowser Quantum** | **Filestash** | +| --------------------------------------------- | --------------------------- | ----------------------- | ------------- | +| Trash, with restore | ✅ | 🚧 | ❌ | +| Restore part of a deleted folder | ✅ | ❌ | ❌ | +| Earlier versions of a file | ✅ | ❌ | 💰 Enterprise | +| Versions from the office editors' own history | ✅ ONLYOFFICE and Collabora | ❌ | ❌ | + +### Finding things + +| | **NextExplorer 3.7** | **FileBrowser Quantum** | **Filestash** | +| ------------------------------------ | -------------------------------- | ----------------------- | ------------- | +| Search by name, indexed, as you type | ✅ | ✅ | ✅ | +| Search inside file contents | ✅ Office documents and PDFs too | ❌ | ✅ | + +### Viewing and editing + +| | **NextExplorer 3.7** | **FileBrowser Quantum** | **Filestash** | +| --------------------------------------- | ------------------------- | ----------------------- | ------------- | +| Images, video and audio, in the browser | ✅ | ✅ | ✅ | +| Office documents | ✅ ONLYOFFICE / Collabora | ✅ | ✅ | +| Text and code editor | ✅ | ✅ | ✅ | +| Folder sizes in the listing | ✅ | ✅ | — | + +### Who gets in + +| | **NextExplorer 3.7** | **FileBrowser Quantum** | **Filestash** | +| --------------------------------------- | ------------------------------------ | ----------------------- | ------------- | +| Local accounts | ✅ | ✅ | ✅ | +| OIDC single sign-on | ✅ | ✅ | 💰 Enterprise | +| LDAP sign-on | ❌ | ✅ | 💰 Enterprise | +| Second factor from an authenticator app | ✅ with recovery codes | ✅ | 💰 Enterprise | +| Passkeys (WebAuthn) | ✅ and they answer the second factor | ✅ | 💰 Enterprise | +| Brute force on the sign-in | ✅ account lockout | ✅ rate limiting | — | +| Access rules per path | ✅ read, write and delete apart | ✅ | 💰 RBAC | + +### Sharing + +| | **NextExplorer 3.7** | **FileBrowser Quantum** | **Filestash** | +| ----------------------------------- | -------------------- | ----------------------- | ------------- | +| Links with a password and an expiry | ✅ | ✅ | ✅ | +| Per-operation permissions on a link | ✅ | ✅ | ✅ | +| Guests can upload into a share | ✅ | ✅ | ✅ | + +### Running it + +| | **NextExplorer 3.7** | **FileBrowser Quantum** | **Filestash** | +| ----------------------------------- | --------------------------- | ----------------------- | --------------------- | +| WebDAV | ❌ by choice | ✅ | ✅ | +| Storage beyond the local filesystem | ❌ by choice | ❌ | ✅ about 25 protocols | +| API tokens for scripts | ✅ read-only or read-write | ✅ | ✅ | +| Activity log | ✅ optional, off by default | ✅ | 💰 | +| Space quotas | 🚧 | 🚧 | 💰 | +| Terminal in the browser | ✅ switchable | ❌ removed deliberately | ❌ | + +### Where each answer comes from + +- **NextExplorer**: the pages on this site — [archives](/experience/workflows), + [trash](/admin/trash), [file versions](/admin/versions), + [search](/experience/features), [two-factor, passkeys and + access](/admin/guide) — and the suites in the repository. The 🚧 are recorded + in `TODO.md` with what each would take; they are intentions, not dates. The ❌ + are honest: there is no LDAP here, and the two marked _by choice_ are settled + positions rather than a backlog nobody got to. +- **FileBrowser Quantum**: its [README](https://github.com/gtsteffaniak/filebrowser) + states OIDC, LDAP, JWT, password + 2FA and proxy sign-in, WebDAV, folder + sizes, API tokens, granular permissions, share expiry and permissions, and + that shell commands were removed on purpose. Its own comparison chart is + where trash, quotas and browsing archives are marked as coming, and + content-aware search as absent. In its source: chunked uploads, TOTP, + WebAuthn passkeys, rate limiting on the auth routes, archive creation as zip + or tar.gz, and twenty-six interface languages — and no extraction from an + archive, and nothing about versioning. +- **Filestash**: its [pricing page](https://www.filestash.app/pricing/) is + where free ends and paid begins — the self-hosted Hobby edition is AGPL and + free, Pro starts at $50/month, Enterprise at $290/month. In its own feature + table, OIDC, SAML, LDAP sign-on, MFA, RBAC and versioning are Enterprise; + quotas and the audit journal are Pro; resumable uploads, shared links, the + editors and Docker are free, and the Debian and RHEL installers are not. Its + [README](https://github.com/mickael-kerjean/filestash) is the source for the + storage protocols and the viewer plugin that opens `tar`, `tgz` and `zip`. + Nothing in either describes a trash or an extraction. +- **The original [File Browser](https://github.com/filebrowser/filebrowser)** + is left out: its README says it was archived on 1 September 2026, that there + will be no further releases, and that two classes of security issue — the + command runner, and sessions that are self-contained JWTs and therefore + cannot be revoked — will not be fixed. Quantum is its active fork, and stands + in the table instead. + +### API tokens + +That row was a 🚧 until a script had a credential of its own. A token is issued +from the settings of the account it belongs to, shown once and stored hashed, +and revoked on its own without disturbing the account or the other tokens. It +is deliberately **less** than the account: a read-only token reaches `GET` and +nothing that changes anything, and no token at all — whatever its scope, and +even when its owner is an administrator — reaches the account's own settings, +any administrative route, or the terminal. [Driving the API](/reference/api) has +the whole of it. + +### The intention left, and the two crosses that stay + +Space quotas are what the comparison still says is missing here, and it is in +the backlog with the shape it would take: the recursive folder-size index +already counts what a quota would hold people to; what it needs is a decision +about what a quota applies to, and one place where a write is refused rather +than ten. The activity log and the API tokens that were here beside it are +done — see [Admin & Access](/admin/guide). + +WebDAV is a cross rather than a 🚧, and stays one. NextExplorer is a file +browser, not a server: something you open and use, not something other software +mounts. A protocol is a second permanent way in — its own way of proving who is +asking, its own locks, its own clients writing whenever they like — on top of a +filesystem this already reaches, and every rule about who may read, write or +delete a path would have to hold on that side too. What is mounted into the +container is what this browses, the way a drive is a volume in Windows Explorer: +an NFS or SMB share mounted on the host is already here, without this project +speaking a protocol of its own. Twenty-five storage protocols are a cross for +the same reason from the other end: that is Filestash's ground, and arriving +second on it would cost the thing this does well, which is knowing one +filesystem deeply. diff --git a/docs/experience/workflows.md b/docs/experience/workflows.md index 5da14b14..7760dc78 100644 --- a/docs/experience/workflows.md +++ b/docs/experience/workflows.md @@ -12,10 +12,11 @@ These are the day-to-day actions your team will take in nextExplorer. Every work - **Create a folder/file:** Use the `Create` menu, context menu (right-click background → New Folder/File), or press the `+` toolbar button. A new folder takes “Untitled Folder 2” when the name is taken, even by one created at the same moment. - **Rename:** Right-click an item and choose Rename or use F2 key to rename. -- **Move (desktop drag-and-drop):** Select one or more items (Ctrl/⌘-click, Shift-click, or drag a selection rectangle), then drag any selected item onto a destination folder and drop to move everything selected. -- **Move (touch devices):** Drag-to-move is disabled on touch devices; use the context menu Cut → Paste instead. +- **Move (desktop drag-and-drop):** Select one or more items (Ctrl/⌘-click, Shift-click, or drag a selection rectangle), then drag any selected item onto a destination folder and drop to move everything selected. Hold Alt (Option on macOS) while dropping to copy instead, and drop onto a favorite in the sidebar to send items there without navigating to it. +- **Move to / Copy to:** Right-click a selection and choose **Move to** or **Copy to**. The dialog lists the destinations you have used recently, then your favorites, then the storage to browse; destinations that cannot work — the root, a folder inside itself — are refused before the transfer rather than after. What is copied or moved never replaces a file or merges into a folder already at the destination, including one that appears during the transfer: it takes “name (1)”. Nothing appears under the name until a copy is whole; a cancelled copy leaves nothing behind, and what someone put in the destination meanwhile stays. A symbolic link is copied as a link, not as what it points to, whichever engine copies it; one that leaves its volume is shown as such and opens nothing. +- **Move (touch devices):** Drag-and-drop is disabled on touch devices, so use **Move to** from the item menu (long-press to open it). Cut → Paste still works if you prefer it. - **Delete:** The context menu’s Delete option (or toolbar action) prompts for confirmation and supports multi-select deletions. -- **Clipboard shortcuts:** ⌘/Ctrl+C/X/V work just like desktop file managers and respect Access Control rules (read-only folders can’t be written). What is pasted or moved never replaces a file or merges into a folder already at the destination, including one that appears during the transfer: it takes “name (1)”. +- **Clipboard shortcuts:** ⌘/Ctrl+C/X/V work just like desktop file managers and respect Access Control rules (read-only folders can’t be written). - **Mobile multi-select (checkboxes):** Tap **Select** in the toolbar to enter selection mode, then tap items to toggle selection without opening them; tap **Done** to exit (selection clears on exit). Long-press opens the item menu. ## Uploads & downloads @@ -23,31 +24,35 @@ These are the day-to-day actions your team will take in nextExplorer. Every work - **Drag-and-drop upload:** Drop files/folders from your device onto the main pane to upload; the floating footer upload panel shows per-file and total progress. An upload never replaces a file already there, even one that arrives while it is sent: it takes “name (1)”. - **Create menu upload:** Select Upload files/folders from the Create menu if you prefer a dialog. - **Download:** Select one or more items and hit the Download button; multiple items or folders produce a ZIP archive. -- **Transfer control:** Pause, resume, or cancel uploads directly from the footer panel. +- **Transfer control:** Pause, resume, or cancel uploads directly from the footer panel, which also shows the current rate. With several files in flight, the summary adds them up and expanding the list gives each file its own figure. +- **Large files:** With chunked uploads enabled, a transfer resumes from where it stopped rather than starting over, and gets past reverse proxies that reject large bodies. Once every byte is sent, the server may still be writing the file into place — that phase is shown separately, so a long copy is not mistaken for a stalled upload. If the file cannot be put in its folder, the upload fails with the server’s reason instead of showing as done. ## Search - Click the search icon in the toolbar, type a query, and press Enter. - Search covers filenames and file contents thanks to ripgrep; disable deep search with `SEARCH_DEEP=false` if you want faster scans. - Large files respect `SEARCH_MAX_FILESIZE`; if ripgrep isn’t available, the app falls back to a built-in indexer that still searches filenames. -- Use glob patterns to search names without searching contents: `*.pdf` finds PDFs and `reports/*.xlsx` matches files in that relative path. Set `SEARCH_TIMEOUT_MS` when a search should return partial results sooner; enable `SEARCH_INDEX=true` for a resumable background index. +- Type `*` or `?` to search by filename shape rather than by text: `*.ps1` for the scripts, `*.xlsx` for the spreadsheets. A pattern is matched against the whole name, so `*.ps1` does not return `deploy.ps1.bak`. +- With `SEARCH_INDEX` on, content searches are answered from the index rather than by reading the volume, and a search names any folder it did not have time to finish looking through. ## Previews & editing -- **Preview:** Click images/videos/PDFs to open them inside the app (previews are cached in `/cache`). Compatible embedded and sidecar subtitles are available in media previews; unsupported codecs are identified clearly. +- **Preview:** Click images/videos/PDFs to open them inside the app (previews are cached in `/cache`). - **Editor:** Double-click text/code files to open the inline editor with syntax highlighting, line numbers, and Save/Cancel actions. The editor supports 50+ file types by default including common text formats (txt, md, log), data files (json, yaml, xml, csv), programming languages (js, ts, py, java, go, rust, etc.), config files (ini, env, properties), shell scripts (sh, bash, ps1), and web formats (html, css, scss, vue). Add support for custom file types (e.g., `.toml`, `.proto`, `.graphql`) at runtime using the `EDITOR_EXTENSIONS` environment variable—no rebuild needed, changes apply on container restart. -- **ONLYOFFICE:** When configured, office documents (DOCX, XLSX, PPTX, ODT, ODS, ODP) launch in the embedded ONLYOFFICE editor; nextExplorer signs requests with `ONLYOFFICE_SECRET` and calls `/api/onlyoffice/config`, `/api/onlyoffice/file`, and `/api/onlyoffice/callback` to orchestrate editing. - -## Archives - -- **Extract:** Right-click a supported archive and choose **Extract**. Password-protected archives prompt for a password; progress is shown while extraction runs and the operation can be cancelled. -- **Create:** Select files or folders, then use the context-menu archive action to create an archive in the current folder. -- **Safety limits:** Extractions are refused when their declared entry count or expanded size exceeds `MAX_ARCHIVE_ENTRIES` or `MAX_EXTRACTED_ARCHIVE_SIZE`. +- **ONLYOFFICE:** When configured, office documents (DOCX, XLSX, PPTX, ODT, ODS, ODP) launch in the embedded ONLYOFFICE editor; nextExplorer signs requests with `ONLYOFFICE_SECRET` and calls `/api/onlyoffice/config`, `/api/onlyoffice/file`, and `/api/onlyoffice/callback` to orchestrate editing. Leaving the document — closing the panel, or closing the browser tab it was opened in — calls `/api/onlyoffice/session-end`, which asks Document Server for one last save and then lets the editing session go. One route for both, because a tab being closed has time for exactly one request and the two have to leave the server in the same state. +- **Inside an archive:** Open a zip, 7z, rar, iso or tar to see what is in it without extracting anything, listed the way a folder is. Folders open, the trail at the top walks back, and every row offers two things: take this one file out onto the volume, or download it. The rest of the archive is never unpacked. Which formats open is the same list the Extract action offers, and it depends on the 7-Zip the image was built with. + - **Reading one, without taking it out:** the name of an entry the panel can show is a link — text and code, Markdown, and the images a browser decodes on its own (JPEG, PNG, GIF, WebP, BMP, SVG, ICO, AVIF). It opens in the same window, with the file as the last step of the trail and the folder it is in as the way back. A camera's raw file and a HEIC are not offered: what is shown here comes straight out of the archive, and nothing converts it on the way. Text stops at 2 MB and an image at 32 MB — past that the panel says the size and leaves downloading or extracting as the way to open it. + - **Extract here** puts that entry — a file, or a folder with everything under it — in the folder the archive is in. Nothing is ever replaced: a name already held becomes “name (1)”, and the panel says which name it landed under. + - **Several at once, and somewhere else:** every row has a tick box, and the box in the column header takes everything at this level. What is ticked goes out in one request — **Extract here** for the folder the archive is in, or **Extract to…** for the same “Move to” dialog the rest of the application uses, opened on that folder. Ticks are forgotten when another folder is opened, and once what was ticked has come out. + - A `.tar.gz` and its family (`.tbz2`, `.txz`, `.tar.zst`) are two archives, so the tar inside is decompressed once into `CACHE_DIR/archives` and read from there. Past a certain size the answer is to extract the archive instead. + - A solid `.7z` — one where every file went into a single compressed stream — is read the same way until somebody reads a second entry from it. At that point it is extracted once into `CACHE_DIR/archives` and every read after it comes from there: measured on a real 7-Zip, reading ten entries one at a time costs five times what extracting the whole archive costs, and the second read costs about the same either way. Nothing is extracted for a first read, or for an archive past that size; what is kept shares the cache's budget and is swept the same way. + - An archive whose table of contents is itself password-protected says so rather than opening empty; entries whose contents are encrypted are listed but not handed over. Extraction is where a password is asked for. + - An archive can hold names that point outside itself. Those are never shown as a place inside it, and the panel says how many were left out. ## Sharing items -- **Create a share link:** Select a single file or folder in any browse view (including **My Files** when personal folders are enabled) and click the **Share** button in the toolbar. Configure access mode (read-only vs read/write), choose whether the link is open to **anyone with the link** or restricted to **specific users**, optionally set a password and expiration date, then create the link and copy it from the success screen. -- **Open a share link as a guest:** Visitors open URLs like `https://files.example.com/share/aBc123XyZ`. The Share access page shows basic information (label, type, expiration) and either auto-opens the shared item, prompts for a password (of anyone but the share's owner, whether signed in or not), or redirects to the login page for user-specific shares. Guest sessions are limited so they can only browse within the shared item. +- **Create a share link:** Select a single file or folder in any browse view (including **My Files** when personal folders are enabled) and click the **Share** button in the toolbar. Configure access mode (read-only vs read/write), choose whether the link is open to **anyone with the link** or restricted to **specific users**, optionally set a password and expiration date, optionally withhold downloads so the share can be read but not copied, then create the link and copy it from the success screen. +- **Open a share link as a guest:** Visitors open URLs like `https://files.example.com/share/aBc123XyZ`. The Share access page shows basic information (label, type, expiration) and either auto-opens the shared item, prompts for a password, or redirects to the login page for user-specific shares. Guest sessions are limited so they can only browse within the shared item. - **Review items shared with you:** Use the **Shares → Shared with me** entry in the sidebar to see folders/files other users have shared with your account, filter by status (active/expired), and click into a share to open it in the normal browser view. ## Favorites & quick access @@ -58,6 +63,7 @@ These are the day-to-day actions your team will take in nextExplorer. Every work ## Access control & admin actions - **Access Control rules:** Settings → Access Control defines per-folder policies (`rw`, `ro`, `hidden`). The first matched rule applies. -- **Hidden folders:** Use `hidden` to hide sensitive folders from listings; they remain accessible via direct paths if you know them. +- **Hidden folders:** Use `hidden` to keep a folder out of listings _and_ refuse it when asked for by name — it is a denial, not a cosmetic filter. This page used to say the opposite, which would have talked an administrator out of the one control that stops a path being read. - **Admin users:** Settings → Admin lets you add local users, reset passwords, and grant the admin role. Demoting an admin via UI is disabled to avoid lockouts. +- **Changing a password:** Settings → Password changes your own. Every other session of your account is signed out — another browser, another device, anyone who had the old password — and the one you changed it from stays signed in. A reset by an administrator signs the account out everywhere. - **Sign-out:** Use the user menu in the sidebar to log out or manage user-specific settings. diff --git a/docs/installation/deployment.md b/docs/installation/deployment.md index 6a75506f..d3666093 100644 --- a/docs/installation/deployment.md +++ b/docs/installation/deployment.md @@ -5,24 +5,52 @@ Deploy nextExplorer via Docker Compose for reproducible self-hosted workflows. T ## Prerequisites - **Docker Engine 24+ and Docker Compose v2** (or later). The official image depends on modern orchestration features. -- **Host directories** for data volumes, `/config`, and optional `/cache` (make sure the Docker user can read/write these paths). +- **Host directories** for data volumes, `/config`, and `/cache` (make sure the Docker user can read/write these paths). `/cache` can be left out, but it holds the search index and the folder sizes: without a persistent mount, every new container reads the volumes again to rebuild them. - **TLS-capable reverse proxy** if you need HTTPS, custom domains, or sticky sessions. +## Image variants + +Two images are published, on both registries: + +| Tag | Contains | +| ---------------------------- | -------------------------------------------------------------------------------- | +| `latest`, `3.11.0` | Everything, including hardware video acceleration (VA-API) and RAW photo support | +| `latest-lean`, `3.11.0-lean` | The same application without VA-API or RAW — a considerably smaller image | + +Take the full image unless you know you need neither: VA-API only helps where the host exposes a render device to the container, and RAW support only matters if you keep camera files. Both variants are built for `linux/amd64` and `linux/arm64`. + +``` +ghcr.io/cerede2000/explorer:latest +ghcr.io/cerede2000/explorer:latest-lean +``` + +They are also on Docker Hub under the same tags. + +`latest` and `latest-lean` follow `main`, so a fix reaches them without waiting +for a release. Every build is also published under the version in +`package.json` — `3.11.0`, `3.11.0-lean` — republished for as long as that +version is current, and left alone once the next one is cut. + +Only the last two versions stay published: on Docker Hub the older one is +removed as the next is published, and on GHCR a weekly job does the same. Pin a +version you intend to keep running and move it forward deliberately rather than +expecting an old tag to still be there. + ## Host folder layout -| Purpose | Container path | Notes | -| ---------------------------------- | --------------------------------------------- | ----------------------------------------------------------------------------------------------------- | -| Configuration, user DB, extensions | `/config` | Holds SQLite, `app-config.json`, and upgrades. Back this directory up before changes. | -| Thumbnail/search cache | `/cache` | Regenerable; safe to delete when troubleshooting. | -| Browsable data | `/mnt/Label` | Each mount appears as a top-level volume with the given label. | -| Personal user data (optional) | `/srv/users` (or any path set as `USER_ROOT`) | When `USER_DIR_ENABLED=true`, each authenticated user gets their own private folder inside this root. | +| Purpose | Container path | Notes | +| ----------------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Accounts, shares, settings | `/config` | Holds `app.db`, `logos/` and `session-secret`. The folder to back up — see [Backups](/admin/guide#backups-persistence). | +| Thumbnails, sessions, indexes | `/cache` | Regenerable and needs no backup, but mount it persistently: it holds `index.db`, the search index and folder sizes, and deleting it signs everyone out and reads every volume again. | +| Browsable data | `/mnt/Label` | Each mount appears as a top-level volume with the given label. | +| Personal user data (optional) | `/srv/users` (or any path set as `USER_ROOT`) | When `USER_DIR_ENABLED=true`, each authenticated user gets their own private folder inside this root. | ## Production compose example ```yaml services: nextexplorer: - image: nxzai/explorer:latest + image: ghcr.io/cerede2000/explorer:latest container_name: nextexplorer restart: unless-stopped ports: @@ -46,7 +74,7 @@ services: ``` - `PUBLIC_URL` informs the backend's cookie settings, CORS, and default OIDC callback (see `backend/src/config/env.js`). -- `SESSION_SECRET` ensures sessions persist across restarts; without it, the app generates a random secret each time. +- `SESSION_SECRET` sets the session secret yourself. Without it, one is generated at the first start and kept in `/config/session-secret`, so sessions survive restarts all the same; set it when several replicas share the sessions. - Optional first-run bootstrap: set `AUTH_ADMIN_EMAIL` and `AUTH_ADMIN_PASSWORD` to auto-create the first local admin on startup (skips the setup wizard). ## Launching and validating @@ -63,8 +91,8 @@ docker compose pull docker compose up -d ``` -- Persistent state (`app.db`, `app-config.json`, extensions) stays inside `/config`. Always back this up before major upgrades. -- The default entrypoint moves legacy config files from `/cache` to `/config` on first run; keep `/config` mounted to avoid data loss. +- Persistent state (`app.db`, `logos/`, `session-secret`) stays inside `/config`. Back it up before upgrading, with the container stopped or together with `app.db-wal`. +- Installations that started on 1.1.7 or earlier kept `app.db` in `/cache`. Nothing moves it any more: copy it to `/config` by hand before upgrading such an installation; the server warns at start when it finds such a file there. Links named `app.db`, `app-config.json` or `extensions` left in `/cache` by 1.1.8 to 2.0.2 are unused and can be deleted. ## Monitoring & logs diff --git a/docs/installation/reverse-proxy.md b/docs/installation/reverse-proxy.md index a3393116..9340a84d 100644 --- a/docs/installation/reverse-proxy.md +++ b/docs/installation/reverse-proxy.md @@ -7,9 +7,156 @@ When exposing nextExplorer on a custom domain, a reverse proxy keeps the UI secu | Variable | Purpose | | ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `PUBLIC_URL` | External URL (no trailing slash) used to set cookies, determine OIDC callbacks, and drive CORS defaults. Example: `https://files.example.com`. | +| `INTERNAL_URL` | Optional comma-separated LAN origins. They are accepted by CORS and can each complete OIDC login without redirecting through `PUBLIC_URL`. | | `TRUST_PROXY` | Controls Express’s trust level; accepts `false`, a number (hops), or lists such as `loopback,uniquelocal`. If unset and `PUBLIC_URL` exists, defaults to `loopback,uniquelocal`. (`backend/config/trustProxy.js` documents this mapping.) | | `CORS_ORIGIN(S)` / `ALLOWED_ORIGINS` | Explicit CORS origins when they differ from `PUBLIC_URL`. Defaults to the origin of `PUBLIC_URL` when provided. | +## HTTPS with a Let's Encrypt certificate + +NextExplorer does not terminate TLS itself, and does not read a certificate +from disk. A Let's Encrypt certificate lasts a few months at most and has to be +renewed before it runs out, and a server that reads the file once at start goes +on serving the old one until somebody restarts it. A reverse proxy asks for the +certificate, renews it on time, redirects port 80 to 443 and speaks HTTP/2 — +all of it without the application knowing — so that is the way to serve it over +HTTPS. + +Two proxies that do all of this on their own are shown below: Traefik, which +reads its routes from Docker labels, and Caddy, which needs two lines. Both +assume: + +- a DNS record for `files.example.com` pointing at the machine; +- ports **80** and **443** reachable from the internet — Let's Encrypt checks + that you own the name through them, at every renewal; +- `PUBLIC_URL=https://files.example.com` on NextExplorer, which is what makes + its cookies `Secure` and its links point at the right place. + +NextExplorer is not published on a port of its own in either example: the +proxy reaches it over the Compose network, and nothing else should. With +`PUBLIC_URL` set, `TRUST_PROXY` defaults to `loopback,uniquelocal`, which +believes a proxy on a Docker network and nobody on the internet — nothing to +set for the addresses in the [activity log](#the-address-that-gets-recorded) +to be the visitors' own. + +### Traefik + +```yaml +services: + traefik: + image: traefik:v3.7 + restart: unless-stopped + command: + - --providers.docker=true + - --providers.docker.exposedbydefault=false + - --entryPoints.web.address=:80 + - --entryPoints.web.http.redirections.entryPoint.to=websecure + - --entryPoints.web.http.redirections.entryPoint.scheme=https + - --entryPoints.websecure.address=:443 + # Traefik gives a request 60 seconds to arrive, body included, and then + # cuts it: a large file sent in one request is refused half-way. 0 is no + # limit, which is what NextExplorer itself applies (HTTP_TIMEOUT). + - --entryPoints.websecure.transport.respondingTimeouts.readTimeout=0 + - --certificatesresolvers.letsencrypt.acme.httpchallenge=true + - --certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web + - --certificatesresolvers.letsencrypt.acme.email=you@example.com + - --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json + ports: + - '80:80' + - '443:443' + volumes: + - ./letsencrypt:/letsencrypt + - /var/run/docker.sock:/var/run/docker.sock:ro + + nextexplorer: + image: ghcr.io/cerede2000/explorer:latest + restart: unless-stopped + environment: + - PUBLIC_URL=https://files.example.com + volumes: + - /srv/nextexplorer/config:/config + - /srv/nextexplorer/cache:/cache + - /srv/data/Projects:/mnt/Projects + labels: + - traefik.enable=true + - traefik.http.routers.nextexplorer.rule=Host(`files.example.com`) + - traefik.http.routers.nextexplorer.entrypoints=websecure + - traefik.http.routers.nextexplorer.tls.certresolver=letsencrypt + - traefik.http.services.nextexplorer.loadbalancer.server.port=3000 +``` + +`./letsencrypt/acme.json` holds the account and the certificates; keep it, or +every restart asks Let's Encrypt again and runs into its rate limits. While +trying things out, add +`--certificatesresolvers.letsencrypt.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory` +to use the staging service, whose certificates browsers do not trust but whose +limits are far wider — and remove it, with `acme.json`, once it works. + +### Caddy + +```yaml +services: + caddy: + image: caddy:2 + restart: unless-stopped + ports: + - '80:80' + - '443:443' + - '443:443/udp' + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data + - caddy_config:/config + + nextexplorer: + image: ghcr.io/cerede2000/explorer:latest + restart: unless-stopped + environment: + - PUBLIC_URL=https://files.example.com + volumes: + - /srv/nextexplorer/config:/config + - /srv/nextexplorer/cache:/cache + - /srv/data/Projects:/mnt/Projects + +volumes: + caddy_data: + caddy_config: +``` + +With this `Caddyfile` beside it: + +``` +{ + email you@example.com +} + +files.example.com { + reverse_proxy nextexplorer:3000 +} +``` + +A site address with a domain name is all Caddy needs to ask Let's Encrypt for +the certificate, renew it and redirect port 80 to 443. `caddy_data` holds the +certificates and must outlive the container, for the same reason as Traefik's +`acme.json`. + +### What NextExplorer asks of the proxy, and where each stands + +| What | Traefik | Caddy | +| ------------------------------------------------------------ | ---------------------------------------------------- | ----------------------------------------------------------------- | +| Large uploads in one request | cut at 60 s unless `readTimeout` is raised, as above | no limit on reading a body, and no size limit | +| Progress of a copy, a move, a deletion — streamed as it goes | sent as it comes | sent as it comes: a response of unknown length is flushed at once | +| The terminal, over a WebSocket at `/api/terminal` | nothing to set | nothing to set | +| `X-Forwarded-For`, `-Proto`, `-Host` | sent | sent | + +A proxy that does limit the size of a request — Cloudflare's, at 100 MB on the +free plan — is what chunked uploads are for: turn them on in **Settings → +Uploads**, or let the automatic fallback find a size that passes + +To check the result: `https://files.example.com/healthz` answers +`{"status":"ok"}` through the proxy, the browser shows the certificate as +issued by Let's Encrypt, and `http://files.example.com` lands on the `https` +address. + ## Sample Nginx Proxy Manager block - Point `files.example.com` to the container’s internal `3000` port. @@ -22,23 +169,69 @@ When exposing nextExplorer on a custom domain, a reverse proxy keeps the UI secu - Override with values such as `1` (trust one hop) or CIDRs (`10.0.0.0/8,172.16.0.0/12`). - Avoid `TRUST_PROXY=true` alone; the entrypoint maps it to `loopback,uniquelocal` for safety. +## The address that gets recorded + +The activity log, share access counters and the server's own warnings all +write down one address per request, and which one that is depends entirely on +this page. + +- **No proxy.** Whoever opened the socket. In a container that is often not the + person: a connection made from the Docker host itself, or relayed by Docker's + userland proxy — which is every connection on Docker Desktop — arrives from + the bridge (`172.17.0.1`, `172.18.0.1`). From another machine on the LAN to a + published port on Linux, the real address survives. Nothing in the + application can recover an address the kernel already replaced; that is a + Docker networking matter, not a setting here. +- **Behind a proxy.** `TRUST_PROXY` decides whether the address the proxy + announces is believed. `loopback` alone is not enough when the proxy is + another container: it speaks from the bridge network, so use + `loopback,uniquelocal` or the proxy's own CIDR. +- **A chain is read from the right**, and stops at the first hop that is not + trusted — a hop nobody vouches for could have written everything to its left. + Trust one proxy and three appear in the chain, and what you get is the third + one, not the person. +- **`CF-Connecting-IP` wins where Cloudflare is in front**, then + `X-Forwarded-For`, then `X-Real-IP` (nginx's own example configuration sends + that one and not the first). `True-Client-IP` is read as well. +- **A Cloudflare tunnel only helps when it carries HTTP.** A public hostname + route goes through Cloudflare's edge, which adds `CF-Connecting-IP`, so the + person is named. A private network route — reaching the machine through WARP + by its own address and port — forwards raw TCP: there is no HTTP for a header + to be added to, the origin sees `cloudflared` itself, and nothing on this + page recovers an address that never arrived. +- **Never trust a proxy that is not yours.** With `TRUST_PROXY` set, anybody who + can reach the port directly can choose what the log says about them. + +When a proxy announces a client and nothing here believes it, the server says +so once in its own log, naming the address it was told and the one it is +recording instead — the alternative is a log where every line says +`172.18.0.1` and nothing anywhere says why. + +To settle it from a browser rather than from the logs, an administrator can +open `/api/activity/address`. It answers with the address that would be +recorded, the machine at the other end of the socket, whether that machine is +believed, the rule in force — which is also how to see that `TRUST_PROXY` never +reached the process — and every forwarding header that arrived. An empty list +of headers is the answer to the hardest version of the question: nobody +announced a client, so there is nothing to believe. + ## CORS & headers - Set `CORS_ORIGINS`/`ALLOWED_ORIGINS` when the app is accessed from multiple domains. - For a full walkthrough (including `PUBLIC_URL` and origin mismatch behavior), see [Fixing CORS errors](/reference/cors). - Ensure the proxy forwards `X-Forwarded-Proto`, `X-Forwarded-Host`, and `X-Forwarded-For` so the backend derives the correct `PUBLIC_URL` origin and TLS state. -- The application sets its own baseline response headers — `X-Content-Type-Options: nosniff`, `X-Frame-Options: SAMEORIGIN`, `Referrer-Policy: same-origin`, a `Permissions-Policy` and `X-Robots-Tag: noindex` — and does not advertise Express. It does not set `Strict-Transport-Security`: whether every hostname is HTTPS-only is the proxy's decision, so set HSTS there if you want it. ## Networking health checklist - Proxy has TLS termination and forwards headers. Without headers, session cookies may appear as `Insecure`. - POST, PUT, DELETE operations work through the proxy; test with uploads and metadata edits. -- If using OIDC, verify the IdP’s redirect URI matches `${PUBLIC_URL}/callback` or your manually supplied `OIDC_CALLBACK_URL`. +- If using OIDC with `INTERNAL_URL`, register `${PUBLIC_URL}/callback` and every `/callback` with the IdP. The browser returns to the exact configured origin where login started. ## Troubleshooting proxies -| Symptom | Fix | -| ---------------------------- | ---------------------------------------------------------------------------------------------- | -| CORS errors | Add the proxy domain to `CORS_ORIGINS` or set `PUBLIC_URL`. | -| Sessions drop | Confirm `TRUST_PROXY` lets Express read `X-Forwarded-Proto` and `COOKIE` is not stripped. | -| Redirect URI mismatch (OIDC) | Ensure the IdP redirect equals `${PUBLIC_URL}/callback` or the configured `OIDC_CALLBACK_URL`. | +| Symptom | Fix | +| ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| CORS errors | Add the proxy domain to `CORS_ORIGINS` or set `PUBLIC_URL`. | +| Sessions drop | Confirm `TRUST_PROXY` lets Express read `X-Forwarded-Proto` and `COOKIE` is not stripped. | +| Redirect URI mismatch (OIDC) | Register `${PUBLIC_URL}/callback` and each configured internal `/callback` with the IdP. | +| Every logged address is the same `172.x` | The proxy is not trusted, or there is no proxy and Docker replaced the source. Set `TRUST_PROXY=loopback,uniquelocal`; the server warns once when it is ignoring an announced address. | diff --git a/docs/reference/contributing.md b/docs/reference/contributing.md index 50a11562..7be3791f 100644 --- a/docs/reference/contributing.md +++ b/docs/reference/contributing.md @@ -19,13 +19,13 @@ Thanks for helping improve nextExplorer! This guide keeps contributions smooth, ## Project Layout - `frontend/` – Vue 3 + Vite app (Pinia, TailwindCSS, Vitest, ESLint). -- `backend/` – Express API (Node 18+, Pino logging, OIDC via express-openid-connect). +- `backend/` – Express API (Node 24, Pino logging, OIDC via express-openid-connect). - `docs/` – VitePress docs (site content and guides). - `Dockerfile` – Multi-stage build packaging the full app. ## Prerequisites -- Node.js 18+ and npm 9+. +- Node.js 24 (the version the image and CI run) and npm 9+. - Docker + Docker Compose v2 (optional, recommended for end-to-end dev). - FFmpeg/ffprobe available if running backend outside Docker. @@ -60,7 +60,7 @@ Environment tips: ## Tests & Linting -- Backend tests (Node test runner): +- Backend tests (Vitest + supertest): ``` cd backend && npm test @@ -78,6 +78,41 @@ cd frontend && npm run test:unit cd frontend && npm run lint ``` +- Browser tests (Playwright). The `app` project starts the real server serving + the real build, on a throwaway install, and walks through setting it up, + signing in, opening a volume, uploading and sharing — so build first: + +``` +npm run build && npm run test:e2e +``` + +### What CI holds every push to + +**A change in behaviour arrives with its test, in the same commit.** A commit +that touches `backend/src` or `frontend/src` without touching a test is +refused. Some changes rightly carry none — a refactor under tests that already +exist, a move, a rename — and those say so with a trailer, so the exception is +a decision written down rather than something nobody noticed: + +``` +Split the share decision into the three questions it asks + +No-test: pure extraction, held by tests/routes/shares.test.js +``` + +Run the same check before pushing with +`scripts/check-commit-tests.sh origin/main..HEAD`. + +**Coverage does not go down.** The floors live in `coverage-thresholds.json` +and fail the test run when a figure drops below them. The frontend floors apply +everywhere; the backend ones apply in CI only, because several backend suites +skip themselves without 7-Zip, ffmpeg, ripgrep or pdftotext, and a machine +without those covers a little less for no fault of the change. Each floor keeps +half a point of room under the CI figure, so a run that happens to miss a few +lines does not turn red. When a figure climbs far enough to raise its floor and +still keep that room, CI says so — raise it in the same pull request, so the +ground gained cannot be lost again. + ## Build - Production container: @@ -95,7 +130,7 @@ cd frontend && npm run build && npm run preview ## Pull Requests - Keep PRs small and atomic. Describe the problem and the approach. -- Include tests for new behavior when practical (backend: Node test runner + supertest; frontend: Vitest). +- A change in behaviour comes with its test in the same commit, or a `No-test:` trailer saying why (see above). - Update docs in `docs/` and user-facing `README.md` when behavior or settings change. - Run tests and linters locally before submitting. diff --git a/docs/reference/faq.md b/docs/reference/faq.md index bd791209..38c12376 100644 --- a/docs/reference/faq.md +++ b/docs/reference/faq.md @@ -3,7 +3,7 @@ ## What do I need before installing? - Docker Engine 24+ and Docker Compose v2. -- Host folders to mount under `/mnt` and persistent storage for `/config` (back it up) plus optional `/cache`. +- Host folders to mount under `/mnt`, persistent storage for `/config` (back it up), and for `/cache` (no backup needed, but it holds the search index and folder sizes). - Optional environment variables for your preferred authentication, reverse proxy, and feature toggles; see the [Environment Reference](../configuration/environment) for the full list. ## How do I unlock the workspace after first setup? @@ -16,11 +16,11 @@ Check the [Troubleshooting](./troubleshooting) page for proxy/CORS tips, session ## How can I keep my deployment updated? -The app stores persistent state in the `/config` bind mount. Back up `/config/app-config.json` and `/config/app.db` before updating. Run `docker compose pull` and `docker compose up -d` to refresh the image, then verify volumes and settings in the UI. +The app stores persistent state in the `/config` bind mount. Back up `/config` — `app.db`, with its `app.db-wal` or with the container stopped, `logos/` and `session-secret` — before updating. Run `docker compose pull` and `docker compose up -d` to refresh the image, then verify volumes and settings in the UI. ## Who handles metadata and search indexing? -Thumbnails and ripgrep backed search results live in `/cache`. You can clear/recreate this mount without losing settings. If thumbnails aren't appearing, ensure FFmpeg/ffprobe are available (provided in the official image) and `FFMPEG_PATH`/`FFPROBE_PATH` point to valid binaries. +Thumbnails, RAW previews, sessions and `index.db` — the search index and folder sizes — live in `/cache`. You can clear or recreate this mount without losing settings; everyone is signed out and the indexes are rebuilt. If thumbnails aren't appearing, ensure FFmpeg/ffprobe are available (provided in the official image) and `FFMPEG_PATH`/`FFPROBE_PATH` point to valid binaries. ## How do I add support for custom file types in the editor? @@ -40,4 +40,36 @@ environment: - EDITOR_MAX_FILESIZE=10M ``` +That is the only setting to change. Saving sends the file back through a JSON +request body, so `MAX_JSON_BODY_SIZE` has to stay above what the editor opens — +it rises on its own to carry it, and says so in the log. + +The exception is where you have set `MAX_JSON_BODY_SIZE` yourself. A ceiling +you chose is never raised behind your back: the editor is lowered to what that +ceiling can carry instead, with a warning naming both values. If you want to +edit large files _and_ keep a body ceiling, set the ceiling to a little over +twice the file size you want to open. + See the [Environment Reference](../configuration/environment#editor) for details. + +## The editor says my text file is binary + +It used to say that about any file written in UTF-16, which is most text +produced on Windows: PowerShell's `Out-File` wrote UTF-16LE by default until +PowerShell 6, and Notepad still offers it as "Unicode". In UTF-16 every ASCII +character is stored with a zero byte beside it, and a zero byte is what the +binary test looks for. + +Files are now read in whatever they are written in — UTF-8, UTF-16LE or +UTF-16BE, with or without a byte-order mark — and saved back in the same +encoding, so a file a script reads with a fixed encoding keeps working. + +If a file you believe is text is still refused, check what it actually starts +with: + +```bash +head -c 16 /path/to/file.txt | xxd +``` + +`ef bb bf` is UTF-8 with a mark, `ff fe` is UTF-16LE, `fe ff` is UTF-16BE. +Anything else with zero bytes early in the file really is binary.