├── frontend ├── src │ ├── utils │ │ ├── datetime.js │ │ ├── uploads.js │ │ ├── keyboard.js │ │ ├── index.js │ │ └── ellipses.js │ ├── layouts │ │ ├── SettingsLayout.vue │ │ └── EditorLayout.vue │ ├── assets │ │ ├── main.css │ │ └── base.css │ ├── App.vue │ ├── api │ │ ├── features.api.js │ │ ├── terminal.api.js │ │ ├── settings.api.js │ │ ├── onlyoffice.api.js │ │ ├── index.js │ │ ├── auth.api.js │ │ ├── favorites.api.js │ │ └── users.api.js │ ├── components │ │ ├── __tests__ │ │ │ └── HelloWorld.spec.js │ │ ├── HeaderLogo.vue │ │ ├── NavButtons.vue │ │ ├── MiddleEllipsis.vue │ │ ├── SearchBar.vue │ │ ├── NotificationToastContainer.vue │ │ ├── NotificationBell.vue │ │ ├── MapPreview.vue │ │ ├── ModalDialog.vue │ │ ├── ProgressBar.vue │ │ ├── MenuClipboard.vue │ │ ├── TerminalMenu.vue │ │ ├── MenuShare.vue │ │ ├── PhotoSizeControl.vue │ │ ├── NotificationToast.vue │ │ ├── LanguageSelector.vue │ │ ├── BreadCrumb.vue │ │ └── MenuItemInfo.vue │ ├── views │ │ └── settings │ │ │ ├── SettingsComingSoon.vue │ │ │ └── SettingsAbout.vue │ ├── composables │ │ ├── contextMenu.js │ │ ├── useDeleteConfirm.js │ │ ├── useViewConfig.js │ │ ├── clipboardShortcuts.js │ │ ├── useFileDialog.js │ │ ├── useCodemirror.js │ │ ├── itemSelection.js │ │ └── useFavoriteEditor.js │ ├── plugins │ │ ├── pdf │ │ │ ├── pdfPreview.js │ │ │ └── PdfPreview.vue │ │ ├── audio │ │ │ ├── audioPreview.js │ │ │ └── AudioPreview.vue │ │ ├── image │ │ │ ├── imagePreview.js │ │ │ └── ImagePreview.vue │ │ ├── markdown │ │ │ ├── markdownPreview.js │ │ │ └── MarkdownPreview.vue │ │ ├── onlyoffice │ │ │ ├── onlyofficePreview.js │ │ │ └── OnlyOfficePreview.vue │ │ ├── video │ │ │ ├── videoPreview.js │ │ │ └── VideoPreview.vue │ │ └── index.js │ ├── stores │ │ ├── spotlight.js │ │ ├── terminal.js │ │ ├── uppyStore.js │ │ ├── infoPanel.js │ │ └── appSettings.js │ ├── main.js │ ├── icons │ │ ├── files │ │ │ ├── txt-icon.vue │ │ │ ├── audio-icon.vue │ │ │ ├── code-icon.vue │ │ │ ├── video-icon.vue │ │ │ ├── archive-icon.vue │ │ │ ├── image-icon.vue │ │ │ ├── pdf-icon.vue │ │ │ ├── directory-icon.vue │ │ │ └── FileBadgeIcon.vue │ │ ├── LoadingIcon.vue │ │ └── IconDrive.vue │ ├── config │ │ ├── editor.js │ │ └── media.js │ └── i18n │ │ └── index.js ├── public │ ├── next.png │ ├── favicon.ico │ ├── favicon-96x96.png │ ├── apple-touch-icon.png │ ├── web-app-manifest-192x192.png │ ├── web-app-manifest-512x512.png │ ├── folder.svg │ ├── site.webmanifest │ └── check-pattern.svg ├── jsconfig.json ├── Dockerfile ├── .eslintrc.cjs ├── vitest.config.js ├── .gitignore ├── .env.example ├── index.html ├── vite.config.js └── package.json ├── .DS_Store ├── backend ├── .DS_Store ├── src │ ├── middleware │ │ ├── cors.js │ │ ├── trustProxy.js │ │ ├── logging.js │ │ ├── httpsWarning.js │ │ └── session.js │ ├── utils │ │ ├── fsUtils.js │ │ ├── requestUtils.js │ │ ├── logger.js │ │ ├── asyncHandler.js │ │ ├── env.js │ │ ├── sessionStore.js │ │ └── staticServer.js │ ├── routes │ │ ├── health.js │ │ ├── terminal.js │ │ ├── features.js │ │ ├── upload.js │ │ ├── settings.js │ │ ├── volumes.js │ │ ├── index.js │ │ ├── usage.js │ │ └── favorites.js │ ├── config │ │ ├── logging.js │ │ ├── trustProxy.js │ │ └── constants.js │ ├── services │ │ ├── accessControlService.js │ │ └── storage │ │ │ └── jsonStorage.js │ └── app.js ├── Dockerfile ├── tests │ ├── routes │ │ ├── health.test.js │ │ └── features.test.js │ ├── README.md │ ├── services │ │ ├── access-control.test.js │ │ └── settings.test.js │ └── helpers │ │ └── env-test-utils.js └── package.json ├── docs ├── images │ ├── 1.png │ ├── 2.png │ ├── 3.png │ ├── 4.png │ ├── 5.png │ ├── 6.png │ ├── js-editor.png │ ├── list-view.png │ ├── md-preview.png │ ├── context-menu.png │ ├── home-screen.png │ ├── login-screen.png │ ├── downloads-view.png │ ├── favorites-added.png │ ├── settings-about.png │ ├── word-preview-tech.png │ └── image-preview-modal.png ├── public │ └── images │ │ ├── logo.png │ │ ├── editor-theme-1.png │ │ ├── user-volumes-1.png │ │ └── user-volumes-2.png ├── package.json ├── integrations │ ├── onlyoffice.md │ └── authelia.md ├── configuration │ └── settings.md ├── reference │ ├── faq.md │ └── troubleshooting.md ├── admin │ └── user-volumes.md └── installation │ └── reverse-proxy.md ├── .github ├── dependabot.yml └── workflows │ └── docs.yml ├── healthcheck.js ├── .dockerignore ├── .devcontainer └── devcontainer.json ├── docker-compose.yml ├── Dockerfile ├── entrypoint.sh ├── docker-compose.dev.yml └── .gitignore /frontend/src/utils/datetime.js: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /frontend/src/layouts/SettingsLayout.vue: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/assets/main.css: -------------------------------------------------------------------------------- 1 | @import './base.css'; 2 | -------------------------------------------------------------------------------- /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikramsoni2/nextExplorer/HEAD/.DS_Store -------------------------------------------------------------------------------- /backend/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikramsoni2/nextExplorer/HEAD/backend/.DS_Store -------------------------------------------------------------------------------- /docs/images/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikramsoni2/nextExplorer/HEAD/docs/images/1.png -------------------------------------------------------------------------------- /docs/images/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikramsoni2/nextExplorer/HEAD/docs/images/2.png -------------------------------------------------------------------------------- /docs/images/3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikramsoni2/nextExplorer/HEAD/docs/images/3.png -------------------------------------------------------------------------------- /docs/images/4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikramsoni2/nextExplorer/HEAD/docs/images/4.png -------------------------------------------------------------------------------- /docs/images/5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikramsoni2/nextExplorer/HEAD/docs/images/5.png -------------------------------------------------------------------------------- /docs/images/6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikramsoni2/nextExplorer/HEAD/docs/images/6.png -------------------------------------------------------------------------------- /frontend/public/next.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikramsoni2/nextExplorer/HEAD/frontend/public/next.png -------------------------------------------------------------------------------- /docs/images/js-editor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikramsoni2/nextExplorer/HEAD/docs/images/js-editor.png -------------------------------------------------------------------------------- /docs/images/list-view.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikramsoni2/nextExplorer/HEAD/docs/images/list-view.png -------------------------------------------------------------------------------- /docs/images/md-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikramsoni2/nextExplorer/HEAD/docs/images/md-preview.png -------------------------------------------------------------------------------- /docs/images/context-menu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikramsoni2/nextExplorer/HEAD/docs/images/context-menu.png -------------------------------------------------------------------------------- /docs/images/home-screen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikramsoni2/nextExplorer/HEAD/docs/images/home-screen.png -------------------------------------------------------------------------------- /docs/images/login-screen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikramsoni2/nextExplorer/HEAD/docs/images/login-screen.png -------------------------------------------------------------------------------- /docs/public/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikramsoni2/nextExplorer/HEAD/docs/public/images/logo.png -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikramsoni2/nextExplorer/HEAD/frontend/public/favicon.ico -------------------------------------------------------------------------------- /docs/images/downloads-view.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikramsoni2/nextExplorer/HEAD/docs/images/downloads-view.png -------------------------------------------------------------------------------- /docs/images/favorites-added.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikramsoni2/nextExplorer/HEAD/docs/images/favorites-added.png -------------------------------------------------------------------------------- /docs/images/settings-about.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikramsoni2/nextExplorer/HEAD/docs/images/settings-about.png -------------------------------------------------------------------------------- /docs/images/word-preview-tech.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikramsoni2/nextExplorer/HEAD/docs/images/word-preview-tech.png -------------------------------------------------------------------------------- /frontend/public/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikramsoni2/nextExplorer/HEAD/frontend/public/favicon-96x96.png -------------------------------------------------------------------------------- /docs/images/image-preview-modal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikramsoni2/nextExplorer/HEAD/docs/images/image-preview-modal.png -------------------------------------------------------------------------------- /frontend/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikramsoni2/nextExplorer/HEAD/frontend/public/apple-touch-icon.png -------------------------------------------------------------------------------- /docs/public/images/editor-theme-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikramsoni2/nextExplorer/HEAD/docs/public/images/editor-theme-1.png -------------------------------------------------------------------------------- /docs/public/images/user-volumes-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikramsoni2/nextExplorer/HEAD/docs/public/images/user-volumes-1.png -------------------------------------------------------------------------------- /docs/public/images/user-volumes-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikramsoni2/nextExplorer/HEAD/docs/public/images/user-volumes-2.png -------------------------------------------------------------------------------- /frontend/public/web-app-manifest-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikramsoni2/nextExplorer/HEAD/frontend/public/web-app-manifest-192x192.png -------------------------------------------------------------------------------- /frontend/public/web-app-manifest-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vikramsoni2/nextExplorer/HEAD/frontend/public/web-app-manifest-512x512.png -------------------------------------------------------------------------------- /frontend/src/App.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 8 | -------------------------------------------------------------------------------- /frontend/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "paths": { 4 | "@/*": ["./src/*"] 5 | } 6 | }, 7 | "exclude": ["node_modules", "dist"] 8 | } 9 | -------------------------------------------------------------------------------- /frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM public.ecr.aws/docker/library/node:20-bookworm 2 | WORKDIR /app 3 | ENV NODE_ENV=development 4 | EXPOSE 3000 5 | CMD ["bash", "-lc", "npm ci && npm run dev -- --host 0.0.0.0 --port 3000"] 6 | -------------------------------------------------------------------------------- /frontend/src/api/features.api.js: -------------------------------------------------------------------------------- 1 | // /api/features.api.js 2 | 3 | import { requestJson } from './http'; 4 | 5 | export async function fetchFeatures() { 6 | return requestJson('/api/features', { method: 'GET' }); 7 | } -------------------------------------------------------------------------------- /frontend/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | module.exports = { 3 | root: true, 4 | 'extends': [ 5 | 'plugin:vue/vue3-essential', 6 | 'eslint:recommended' 7 | ], 8 | parserOptions: { 9 | ecmaVersion: 'latest' 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /frontend/src/layouts/EditorLayout.vue: -------------------------------------------------------------------------------- 1 | 4 | 9 | -------------------------------------------------------------------------------- /frontend/public/folder.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /frontend/src/api/terminal.api.js: -------------------------------------------------------------------------------- 1 | import { requestJson } from './http'; 2 | 3 | // Issue a short-lived terminal session token (admin only) 4 | export async function createTerminalSession() { 5 | return requestJson('/api/terminal/session', { 6 | method: 'POST', 7 | }); 8 | } 9 | 10 | -------------------------------------------------------------------------------- /backend/src/middleware/cors.js: -------------------------------------------------------------------------------- 1 | const cors = require('cors'); 2 | const { corsOptions } = require('../config/index'); 3 | const logger = require('../utils/logger'); 4 | 5 | const configureCors = (app) => { 6 | app.use(cors(corsOptions)); 7 | logger.debug('CORS middleware configured'); 8 | }; 9 | 10 | module.exports = { configureCors }; -------------------------------------------------------------------------------- /frontend/src/utils/uploads.js: -------------------------------------------------------------------------------- 1 | // Centralized helpers for upload filtering 2 | 3 | export const DISALLOWED_FILE_NAMES = new Set([ 4 | '.ds_store', 5 | 'thumbs.db', 6 | ]); 7 | 8 | export function isDisallowedUpload(name) { 9 | if (!name || typeof name !== 'string') return false; 10 | return DISALLOWED_FILE_NAMES.has(name.toLowerCase()); 11 | } 12 | 13 | -------------------------------------------------------------------------------- /frontend/src/api/settings.api.js: -------------------------------------------------------------------------------- 1 | // /api/settings.api.js 2 | 3 | import { requestJson } from './http'; 4 | 5 | export async function getSettings() { 6 | return requestJson('/api/settings', { method: 'GET' }); 7 | } 8 | 9 | export async function patchSettings(partial) { 10 | return requestJson('/api/settings', { 11 | method: 'PATCH', 12 | body: JSON.stringify(partial || {}), 13 | }); 14 | } -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nextExplorer-docs", 3 | "private": true, 4 | "version": "0.0.0", 5 | "description": "VitePress docs for nextExplorer", 6 | "license": "MIT", 7 | "scripts": { 8 | "docs:dev": "vitepress dev", 9 | "docs:build": "vitepress build", 10 | "docs:preview": "vitepress preview --host" 11 | }, 12 | "devDependencies": { 13 | "vitepress": "^1.6.4" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /frontend/src/components/__tests__/HelloWorld.spec.js: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest' 2 | 3 | import { mount } from '@vue/test-utils' 4 | import HelloWorld from '../HelloWorld.vue' 5 | 6 | describe('HelloWorld', () => { 7 | it('renders properly', () => { 8 | const wrapper = mount(HelloWorld, { props: { msg: 'Hello Vitest' } }) 9 | expect(wrapper.text()).toContain('Hello Vitest') 10 | }) 11 | }) 12 | -------------------------------------------------------------------------------- /frontend/src/utils/keyboard.js: -------------------------------------------------------------------------------- 1 | export const isMac = typeof navigator !== 'undefined' 2 | ? /Mac|iPhone|iPod|iPad/.test(navigator.platform || '') 3 | : false; 4 | 5 | export const modKeyLabel = isMac ? '⌘' : 'Ctrl'; 6 | 7 | // Platform-aware label for the delete/backspace key shown in UI hints. 8 | // Use the backspace symbol on macOS and 'Del' elsewhere. 9 | export const deleteKeyLabel = isMac ? '⌫' : 'Del'; 10 | 11 | -------------------------------------------------------------------------------- /backend/src/utils/fsUtils.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs/promises'); 2 | 3 | const ensureDir = async (targetPath) => { 4 | await fs.mkdir(targetPath, { recursive: true }); 5 | }; 6 | 7 | const pathExists = async (targetPath) => { 8 | try { 9 | await fs.access(targetPath); 10 | return true; 11 | } catch (error) { 12 | return false; 13 | } 14 | }; 15 | 16 | module.exports = { 17 | ensureDir, 18 | pathExists, 19 | }; 20 | -------------------------------------------------------------------------------- /frontend/src/views/settings/SettingsComingSoon.vue: -------------------------------------------------------------------------------- 1 | 9 | -------------------------------------------------------------------------------- /frontend/src/components/HeaderLogo.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 14 | -------------------------------------------------------------------------------- /frontend/vitest.config.js: -------------------------------------------------------------------------------- 1 | import { fileURLToPath } from 'node:url' 2 | import { mergeConfig, defineConfig, configDefaults } from 'vitest/config' 3 | import viteConfig from './vite.config' 4 | 5 | export default mergeConfig( 6 | viteConfig, 7 | defineConfig({ 8 | test: { 9 | environment: 'jsdom', 10 | exclude: [...configDefaults.exclude, 'e2e/**'], 11 | root: fileURLToPath(new URL('./', import.meta.url)) 12 | } 13 | }) 14 | ) 15 | -------------------------------------------------------------------------------- /frontend/src/composables/contextMenu.js: -------------------------------------------------------------------------------- 1 | import { inject } from 'vue'; 2 | 3 | export const explorerContextMenuSymbol = Symbol('ExplorerContextMenu'); 4 | 5 | export function useExplorerContextMenu(options = {}) { 6 | const context = inject(explorerContextMenuSymbol, null); 7 | if (!context && options.required) { 8 | throw new Error('Explorer context menu is not available. Wrap content with .'); 9 | } 10 | return context; 11 | } 12 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | .DS_Store 12 | dist 13 | dist-ssr 14 | coverage 15 | *.local 16 | 17 | /cypress/videos/ 18 | /cypress/screenshots/ 19 | 20 | # Editor directories and files 21 | .vscode/* 22 | !.vscode/extensions.json 23 | .idea 24 | *.suo 25 | *.ntvs* 26 | *.njsproj 27 | *.sln 28 | *.sw? 29 | 30 | *.tsbuildinfo 31 | -------------------------------------------------------------------------------- /frontend/src/api/onlyoffice.api.js: -------------------------------------------------------------------------------- 1 | // /api/onlyoffice.api.js 2 | 3 | import { requestJson, normalizePath } from './http'; 4 | 5 | export async function fetchOnlyOfficeConfig(path, mode = 'edit') { 6 | const normalizedPath = normalizePath(path || ''); 7 | if (!normalizedPath) throw new Error('Path is required.'); 8 | 9 | return requestJson('/api/onlyoffice/config', { 10 | method: 'POST', 11 | body: JSON.stringify({ path: normalizedPath, mode }), 12 | }); 13 | } -------------------------------------------------------------------------------- /frontend/src/plugins/pdf/pdfPreview.js: -------------------------------------------------------------------------------- 1 | export const pdfPreviewPlugin = () => ({ 2 | id: 'pdf-preview', 3 | label: 'PDF Preview', 4 | priority: 25, 5 | 6 | match: (context) => { 7 | return context.extension === 'pdf'; 8 | }, 9 | 10 | component: () => import('./PdfPreview.vue'), 11 | 12 | actions: (context) => [ 13 | { 14 | id: 'download', 15 | label: 'Download', 16 | run: () => context.api.download(), 17 | }, 18 | ], 19 | }); -------------------------------------------------------------------------------- /frontend/src/stores/spotlight.js: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia'; 2 | import { ref } from 'vue'; 3 | 4 | export const useSpotlightStore = defineStore('spotlight', () => { 5 | const isOpen = ref(false); 6 | 7 | const open = () => { isOpen.value = true; }; 8 | const close = () => { isOpen.value = false; }; 9 | const toggle = () => { isOpen.value = !isOpen.value; }; 10 | 11 | return { 12 | isOpen, 13 | open, 14 | close, 15 | toggle, 16 | }; 17 | }); 18 | 19 | -------------------------------------------------------------------------------- /backend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM public.ecr.aws/docker/library/node:20-bookworm 2 | RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg ripgrep imagemagick curl unzip && rm -rf /var/lib/apt/lists/* 3 | WORKDIR /app 4 | ENV NODE_ENV=development 5 | EXPOSE 3001 6 | HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ 7 | CMD wget -q -T 2 -t 1 -O /dev/null "http://127.0.0.1:${PORT:-3001}/healthz" || exit 1 8 | 9 | CMD ["bash", "-lc", "npm ci && npm run start"] 10 | -------------------------------------------------------------------------------- /backend/src/utils/requestUtils.js: -------------------------------------------------------------------------------- 1 | const readMetaField = (req, key, fallback = '') => { 2 | if (!req || !req.body) return fallback; 3 | 4 | if (typeof req.body[key] === 'string') { 5 | return req.body[key]; 6 | } 7 | 8 | const bracketKey = `meta[${key}]`; 9 | const bracketValue = req.body[bracketKey]; 10 | if (typeof bracketValue === 'string') { 11 | return bracketValue; 12 | } 13 | 14 | return fallback; 15 | }; 16 | 17 | module.exports = { 18 | readMetaField, 19 | }; 20 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for more information: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | # https://containers.dev/guide/dependabot 6 | 7 | version: 2 8 | updates: 9 | - package-ecosystem: "devcontainers" 10 | directory: "/" 11 | schedule: 12 | interval: weekly 13 | -------------------------------------------------------------------------------- /backend/src/routes/health.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | 4 | // Basic liveness probe - process is up and serving HTTP 5 | router.get('/healthz', (req, res) => { 6 | res.status(200).json({ status: 'ok' }); 7 | }); 8 | 9 | // Basic readiness probe - app has started successfully 10 | // If you add dependencies (DB, cache, etc.), check them here. 11 | router.get('/readyz', (req, res) => { 12 | res.status(200).json({ status: 'ready' }); 13 | }); 14 | 15 | module.exports = router; 16 | 17 | -------------------------------------------------------------------------------- /frontend/src/stores/terminal.js: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia'; 2 | import { ref } from 'vue'; 3 | 4 | export const useTerminalStore = defineStore('terminal', () => { 5 | const isOpen = ref(false); 6 | 7 | const open = () => { 8 | isOpen.value = true; 9 | }; 10 | 11 | const close = () => { 12 | isOpen.value = false; 13 | }; 14 | 15 | const toggle = () => { 16 | isOpen.value = !isOpen.value; 17 | }; 18 | 19 | return { 20 | isOpen, 21 | open, 22 | close, 23 | toggle, 24 | }; 25 | }); 26 | -------------------------------------------------------------------------------- /frontend/src/api/index.js: -------------------------------------------------------------------------------- 1 | // /api/index.js 2 | 3 | // Export core helpers (optional, but can be useful) 4 | export { apiBase, buildUrl, normalizePath, encodePath } from './http'; 5 | 6 | // Export all domain-specific functions 7 | export * from './files.api'; 8 | export * from './shares.api'; 9 | export * from './auth.api'; 10 | export * from './users.api'; 11 | export * from './favorites.api'; 12 | export * from './settings.api'; 13 | export * from './onlyoffice.api'; 14 | export * from './features.api'; 15 | export * from './terminal.api'; 16 | -------------------------------------------------------------------------------- /frontend/public/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "MyWebSite", 3 | "short_name": "MySite", 4 | "icons": [ 5 | { 6 | "src": "/web-app-manifest-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png", 9 | "purpose": "maskable" 10 | }, 11 | { 12 | "src": "/web-app-manifest-512x512.png", 13 | "sizes": "512x512", 14 | "type": "image/png", 15 | "purpose": "maskable" 16 | } 17 | ], 18 | "theme_color": "#ffffff", 19 | "background_color": "#ffffff", 20 | "display": "standalone" 21 | } -------------------------------------------------------------------------------- /healthcheck.js: -------------------------------------------------------------------------------- 1 | const http = require('http'); 2 | 3 | const options = { 4 | host: 'localhost', 5 | port: process.env.PORT || 3000, 6 | timeout: 2000, 7 | path: '/healthz' 8 | }; 9 | 10 | const request = http.request(options, (res) => { 11 | console.log(`STATUS: ${res.statusCode}`); 12 | if (res.statusCode === 200) { 13 | process.exit(0); 14 | } else { 15 | process.exit(1); 16 | } 17 | }); 18 | 19 | request.on('error', (err) => { 20 | console.log(`ERROR: ${err.message}`); 21 | process.exit(1); 22 | }); 23 | 24 | request.end(); -------------------------------------------------------------------------------- /frontend/src/plugins/audio/audioPreview.js: -------------------------------------------------------------------------------- 1 | import { isPreviewableAudio } from '@/config/media'; 2 | 3 | /** 4 | * Audio preview plugin 5 | */ 6 | export const audioPreviewPlugin = () => ({ 7 | id: 'core-audio-preview', 8 | label: 'Audio Player', 9 | priority: 10, 10 | 11 | match: (context) => isPreviewableAudio(context.extension), 12 | 13 | component: () => import('./AudioPreview.vue'), 14 | 15 | actions: (context) => [ 16 | { 17 | id: 'download', 18 | label: 'Download', 19 | run: () => context.api.download(), 20 | }, 21 | ], 22 | }); 23 | 24 | -------------------------------------------------------------------------------- /frontend/src/plugins/image/imagePreview.js: -------------------------------------------------------------------------------- 1 | // imagePreview.js 2 | import { isPreviewableImage } from '@/config/media'; 3 | 4 | /** 5 | * Image preview plugin using vue-easy-lightbox 6 | * Standalone mode - renders its own modal 7 | */ 8 | export const imagePreviewPlugin = () => ({ 9 | id: 'core-image-preview', 10 | label: 'Image Preview', 11 | priority: 20, 12 | standalone: true, // Renders own modal, bypasses PreviewHost 13 | 14 | match: (context) => { 15 | return isPreviewableImage(context.extension); 16 | }, 17 | 18 | component: () => import('./ImagePreview.vue'), 19 | }); -------------------------------------------------------------------------------- /backend/src/utils/logger.js: -------------------------------------------------------------------------------- 1 | const pino = require('pino'); 2 | const loggingConfig = require('../config/logging'); 3 | 4 | const logger = pino({ 5 | level: loggingConfig.level, 6 | base: { service: 'nextExplorer-backend' }, 7 | timestamp: pino.stdTimeFunctions.isoTime, 8 | transport: loggingConfig.isDebug ? { 9 | target: 'pino-pretty', 10 | options: { 11 | colorize: true, 12 | levelFirst: true, 13 | translateTime: 'SYS:standard', 14 | ignore: 'pid,hostname', 15 | } 16 | } : undefined, 17 | }); 18 | 19 | logger.debug({ level: loggingConfig.level }, 'Logger initialized'); 20 | 21 | module.exports = logger; 22 | -------------------------------------------------------------------------------- /frontend/src/plugins/markdown/markdownPreview.js: -------------------------------------------------------------------------------- 1 | export const markdownPreviewPlugin = () => ({ 2 | id: 'markdown-preview', 3 | label: 'Markdown Preview', 4 | priority: 30, 5 | 6 | match: (context) => { 7 | return ['md', 'markdown'].includes(context.extension); 8 | }, 9 | 10 | component: () => import('./MarkdownPreview.vue'), 11 | 12 | actions: (context) => [ 13 | { 14 | id: 'edit', 15 | label: 'Open in Editor', 16 | run: () => context.api.openEditor(), 17 | }, 18 | { 19 | id: 'download', 20 | label: 'Download', 21 | run: () => context.api.download(), 22 | }, 23 | ], 24 | }); -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .gitignore 3 | node_modules 4 | frontend/node_modules 5 | frontend/dist 6 | backend/node_modules 7 | cache 8 | screenshots 9 | npm-debug.log 10 | **/node_modules 11 | **/.cache 12 | **/.next 13 | **/dist-ssr 14 | **/coverage 15 | **/.git 16 | **/.DS_Store 17 | **/README.md 18 | **/tests 19 | **/*.log 20 | .env 21 | **/.env 22 | 23 | 24 | # Ignore development and local environment files 25 | .git 26 | .gitignore 27 | README.md 28 | node_modules 29 | npm-debug.log 30 | 31 | # Ignore frontend build artifacts and dependencies 32 | frontend/node_modules 33 | frontend/dist 34 | 35 | # Ignore backend dependencies 36 | backend/node_modules 37 | -------------------------------------------------------------------------------- /backend/src/utils/asyncHandler.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Async handler wrapper for Express routes 3 | * Automatically catches errors in async route handlers and passes them to error middleware 4 | * 5 | * Usage: 6 | * router.get('/example', asyncHandler(async (req, res) => { 7 | * const data = await someAsyncOperation(); 8 | * res.json(data); 9 | * })); 10 | * 11 | * @param {Function} fn - Async route handler function 12 | * @returns {Function} Express middleware function 13 | */ 14 | const asyncHandler = (fn) => { 15 | return (req, res, next) => { 16 | Promise.resolve(fn(req, res, next)).catch(next); 17 | }; 18 | }; 19 | 20 | module.exports = asyncHandler; 21 | -------------------------------------------------------------------------------- /backend/src/middleware/trustProxy.js: -------------------------------------------------------------------------------- 1 | const logger = require('../utils/logger'); 2 | 3 | const configureTrustProxy = (app) => { 4 | try { 5 | const { getTrustProxySetting } = require('../config/trustProxy'); 6 | const tp = getTrustProxySetting(); 7 | 8 | if (tp.set) { 9 | app.set('trust proxy', tp.value); 10 | } 11 | 12 | if (tp.message) { 13 | logger.info({ message: tp.message }, 'Trust proxy configured'); 14 | } else { 15 | logger.debug('Trust proxy not explicitly configured'); 16 | } 17 | } catch (e) { 18 | logger.warn({ err: e }, 'Failed to configure trust proxy'); 19 | } 20 | }; 21 | 22 | module.exports = { configureTrustProxy }; -------------------------------------------------------------------------------- /backend/src/middleware/logging.js: -------------------------------------------------------------------------------- 1 | const pinoHttp = require('pino-http'); 2 | const { logging } = require('../config/index'); 3 | const logger = require('../utils/logger'); 4 | 5 | const configureHttpLogging = (app) => { 6 | if (!logging.enableHttpLogging) { 7 | logger.debug('HTTP logging is disabled'); 8 | return; 9 | } 10 | 11 | app.use( 12 | pinoHttp({ 13 | logger: logger.child({ context: 'http' }), 14 | customLogLevel: (res, err) => { 15 | if (err || res.statusCode >= 500) return 'error'; 16 | if (res.statusCode >= 400) return 'warn'; 17 | return logging.isDebug ? 'debug' : 'info'; 18 | }, 19 | }) 20 | ); 21 | 22 | logger.debug('HTTP logging middleware configured'); 23 | }; 24 | 25 | module.exports = { configureHttpLogging }; -------------------------------------------------------------------------------- /frontend/src/stores/uppyStore.js: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia'; 2 | 3 | export const useUppyStore = defineStore({ 4 | id: 'uppyStore', 5 | state: () => ({ 6 | uppy: null, 7 | state: {} 8 | }), 9 | 10 | actions: { 11 | 12 | getState() { 13 | return this.state; 14 | }, 15 | 16 | setState(patch) { 17 | const prevState = this.state; 18 | const nextState = { ...prevState, ...patch }; 19 | 20 | this.state = nextState; 21 | 22 | this.$patch({ 23 | state: nextState 24 | }); 25 | }, 26 | subscribe(listener) { 27 | const unsubscribe = this.$subscribe((mutation, state) => { 28 | const patch = mutation.payload; 29 | listener(mutation.oldState, state, patch); 30 | }, { immediate: true }); 31 | 32 | return unsubscribe; 33 | } 34 | } 35 | }); 36 | -------------------------------------------------------------------------------- /frontend/src/utils/index.js: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs'; 2 | 3 | function formatDate(unixTimestamp) { 4 | return dayjs(unixTimestamp).format('YYYY-MM-DD HH:mm:ss'); 5 | } 6 | 7 | function formatBytes(bytes, decimals) { 8 | if (bytes == 0) return '0 Bytes'; 9 | var k = 1024, 10 | dm = decimals || 2, 11 | sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'], 12 | i = Math.floor(Math.log(bytes) / Math.log(k)); 13 | return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; 14 | } 15 | 16 | 17 | function withViewTransition(func) { 18 | return function (...args) { 19 | if (!document.startViewTransition) { 20 | func(...args); 21 | return; 22 | } 23 | document.startViewTransition(() => func(...args)); 24 | }; 25 | } 26 | 27 | 28 | 29 | export { 30 | formatDate, 31 | formatBytes, 32 | withViewTransition 33 | } -------------------------------------------------------------------------------- /backend/src/config/logging.js: -------------------------------------------------------------------------------- 1 | const { normalizeBoolean } = require('../utils/env'); 2 | 3 | const supportedLevels = new Set(['trace', 'debug', 'info', 'warn', 'error', 'fatal', 'silent']); 4 | 5 | const sanitizeLevel = (value) => { 6 | if (typeof value !== 'string') return null; 7 | const normalized = value.trim().toLowerCase(); 8 | return supportedLevels.has(normalized) ? normalized : null; 9 | }; 10 | 11 | const resolvedLogLevel = 12 | sanitizeLevel(process.env.LOG_LEVEL) || 13 | (normalizeBoolean(process.env.DEBUG) === true ? 'debug' : 'info'); 14 | 15 | const isDebugLevel = resolvedLogLevel === 'debug' || resolvedLogLevel === 'trace'; 16 | const enableHttpLogging = normalizeBoolean(process.env.ENABLE_HTTP_LOGGING) || false; 17 | 18 | module.exports = { 19 | level: resolvedLogLevel, 20 | isDebug: isDebugLevel, 21 | enableHttpLogging: enableHttpLogging, 22 | }; 23 | -------------------------------------------------------------------------------- /frontend/.env.example: -------------------------------------------------------------------------------- 1 | # With Vite proxy, leave VITE_API_URL unset in dev 2 | # Optional: comma-separated list of file extensions (without dots) that should open in the editor 3 | VITE_EDITOR_EXTENSIONS=txt,md,markdown,csv,tsv,json,json5,yml,yaml,xml,log,ini,cfg,conf,env,properties,js,jsx,mjs,cjs,ts,tsx,py,rb,php,java,c,h,cpp,hpp,cs,go,rs,swift,kt,scala,sh,bash,zsh,ps1,bat,vue,svelte,astro,html,css,scss,sass,less 4 | 5 | # Optional: comma-separated list of file extensions (without dots) that can be previewed as audio 6 | # VITE_AUDIO_PREVIEW_EXTENSIONS=mp3,wav,flac,aac,m4a,ogg,opus,wma 7 | # Optional: override app version shown in Settings → About 8 | # VITE_APP_VERSION=1.2.3 9 | 10 | # Optional: embed git info in About (for local builds) 11 | # VITE_GIT_COMMIT=deadbeefdeadbeefdeadbeefdeadbeefdeadbeef 12 | # VITE_GIT_BRANCH=feature/my-branch 13 | # VITE_REPO_URL=https://github.com/owner/repo 14 | -------------------------------------------------------------------------------- /frontend/src/stores/infoPanel.js: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia'; 2 | import { ref, computed } from 'vue'; 3 | import { normalizePath } from '@/api'; 4 | 5 | export const useInfoPanelStore = defineStore('infoPanel', () => { 6 | const isOpen = ref(false); 7 | const item = ref(null); 8 | 9 | const open = (target) => { 10 | item.value = target || null; 11 | isOpen.value = Boolean(target); 12 | }; 13 | 14 | const close = () => { 15 | isOpen.value = false; 16 | }; 17 | 18 | const relativePath = computed(() => { 19 | const it = item.value; 20 | if (!it || !it.name) return ''; 21 | const parent = normalizePath(it.path || ''); 22 | const combined = parent ? `${parent}/${it.name}` : it.name; 23 | return normalizePath(combined); 24 | }); 25 | 26 | return { 27 | isOpen, 28 | item, 29 | open, 30 | close, 31 | relativePath, 32 | }; 33 | }); 34 | 35 | -------------------------------------------------------------------------------- /backend/tests/routes/health.test.js: -------------------------------------------------------------------------------- 1 | const test = require('node:test'); 2 | const assert = require('node:assert/strict'); 3 | const express = require('express'); 4 | const request = require('supertest'); 5 | 6 | const { clearModuleCache } = require('../helpers/env-test-utils'); 7 | 8 | const buildApp = () => { 9 | clearModuleCache('src/routes/health'); 10 | 11 | const healthRoutes = require('../../src/routes/health'); 12 | const app = express(); 13 | app.use('/', healthRoutes); 14 | return app; 15 | }; 16 | 17 | test('GET /healthz returns ok', async () => { 18 | const app = buildApp(); 19 | const response = await request(app).get('/healthz').expect(200); 20 | 21 | assert.deepEqual(response.body, { status: 'ok' }); 22 | }); 23 | 24 | test('GET /readyz returns ready', async () => { 25 | const app = buildApp(); 26 | const response = await request(app).get('/readyz').expect(200); 27 | 28 | assert.deepEqual(response.body, { status: 'ready' }); 29 | }); 30 | 31 | -------------------------------------------------------------------------------- /backend/src/routes/terminal.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const asyncHandler = require('../utils/asyncHandler'); 3 | const terminalService = require('../services/terminalService'); 4 | const { UnauthorizedError, ForbiddenError } = require('../errors/AppError'); 5 | 6 | const router = express.Router(); 7 | 8 | // POST /api/terminal/session - issue short-lived terminal session token (admin only) 9 | router.post('/terminal/session', asyncHandler(async (req, res) => { 10 | const user = req.user; 11 | 12 | if (!user) { 13 | throw new UnauthorizedError('Authentication required.'); 14 | } 15 | 16 | const roles = Array.isArray(user.roles) ? user.roles : []; 17 | const isAdmin = roles.includes('admin'); 18 | 19 | if (!isAdmin) { 20 | throw new ForbiddenError('Admin privileges required to open terminal.'); 21 | } 22 | 23 | const token = terminalService.createSessionToken(user); 24 | 25 | res.json({ token }); 26 | })); 27 | 28 | module.exports = router; 29 | 30 | -------------------------------------------------------------------------------- /frontend/src/plugins/onlyoffice/onlyofficePreview.js: -------------------------------------------------------------------------------- 1 | const DEFAULT_EXTS = [ 2 | 'docx', 'doc', 'odt', 'rtf', 3 | 'xlsx', 'xls', 'ods', 'csv', 4 | 'pptx', 'ppt', 'odp' 5 | ]; 6 | 7 | export const onlyofficePreviewPlugin = (extensions) => ({ 8 | id: 'onlyoffice-editor', 9 | label: 'ONLYOFFICE', 10 | priority: 50, 11 | // Render with minimal chrome in the overlay host 12 | minimalHeader: true, 13 | 14 | match: (context) => { 15 | const ext = String(context.extension || '').toLowerCase(); 16 | const list = Array.isArray(extensions) && extensions.length > 0 ? extensions : DEFAULT_EXTS; 17 | 18 | //console.log('ONLYOFFICE checking extension:', ext, list); 19 | return list.includes(ext); 20 | }, 21 | 22 | component: () => import('./OnlyOfficePreview.vue'), 23 | 24 | actions: (context) => [ 25 | { 26 | id: 'download', 27 | label: 'Download', 28 | run: () => context.api.download(), 29 | }, 30 | ], 31 | }); 32 | 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the 2 | // README at: https://github.com/devcontainers/templates/tree/main/src/typescript-node 3 | { 4 | "name": "Node.js & TypeScript", 5 | // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile 6 | "image": "mcr.microsoft.com/devcontainers/typescript-node:1-22-bookworm", 7 | 8 | // Features to add to the dev container. More info: https://containers.dev/features. 9 | // "features": {}, 10 | 11 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 12 | // "forwardPorts": [], 13 | 14 | // Use 'postCreateCommand' to run commands after the container is created. 15 | // "postCreateCommand": "yarn install", 16 | 17 | // Configure tool-specific properties. 18 | // "customizations": {}, 19 | 20 | // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. 21 | // "remoteUser": "root" 22 | } 23 | -------------------------------------------------------------------------------- /frontend/public/check-pattern.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/plugins/video/videoPreview.js: -------------------------------------------------------------------------------- 1 | import { isPreviewableVideo } from '@/config/media'; 2 | 3 | /** 4 | * Video preview plugin - simplified structure 5 | */ 6 | export const videoPreviewPlugin = () => ({ 7 | // Required fields 8 | id: 'core-video-preview', 9 | label: 'Video Preview', 10 | 11 | // Optional configuration 12 | priority: 10, 13 | 14 | // Match function - receives simple context 15 | match: (context) => { 16 | return isPreviewableVideo(context.extension); 17 | }, 18 | 19 | // Component loader - can be sync or async 20 | component: () => import('./VideoPreview.vue'), 21 | 22 | actions: (context) => [ 23 | { 24 | id: 'download', 25 | label: 'Download', 26 | run: () => context.api.download(), 27 | }, 28 | ], 29 | 30 | // Optional lifecycle hooks 31 | onOpen: (context) => { 32 | console.log('Opening video:', context.item.name); 33 | }, 34 | 35 | onClose: (context) => { 36 | console.log('Closing video:', context.item.name); 37 | }, 38 | }); -------------------------------------------------------------------------------- /frontend/src/components/NavButtons.vue: -------------------------------------------------------------------------------- 1 | 8 | 30 | -------------------------------------------------------------------------------- /backend/src/middleware/httpsWarning.js: -------------------------------------------------------------------------------- 1 | const logger = require('../utils/logger'); 2 | 3 | /** 4 | * One-time warning middleware for HTTPS detection 5 | * Warns operators when HTTPS is detected to ensure proper configuration 6 | */ 7 | const configureHttpsWarning = (app) => { 8 | let warnedInsecureOverHttps = false; 9 | 10 | app.use((req, _res, next) => { 11 | try { 12 | const isHttps = req.secure || 13 | (req.headers['x-forwarded-proto'] || '').toString().split(',')[0].trim() === 'https'; 14 | 15 | if (isHttps && !warnedInsecureOverHttps) { 16 | warnedInsecureOverHttps = true; 17 | logger.warn( 18 | 'HTTPS detected. Ensure upstream proxy is trusted and OIDC cookies are set secure when OIDC is enabled.' 19 | ); 20 | } else if (!isHttps) { 21 | logger.debug('Non-HTTPS request detected'); 22 | } 23 | } catch (_) {} 24 | next(); 25 | }); 26 | 27 | logger.debug('HTTPS warning middleware configured'); 28 | }; 29 | 30 | module.exports = { configureHttpsWarning }; -------------------------------------------------------------------------------- /backend/src/middleware/session.js: -------------------------------------------------------------------------------- 1 | const crypto = require('crypto'); 2 | const session = require('express-session'); 3 | 4 | const { auth: envAuthConfig } = require('../config/index'); 5 | const { localStore } = require('../utils/sessionStore'); 6 | const logger = require('../utils/logger'); 7 | 8 | const configureSession = (app) => { 9 | const sessionSecret = (envAuthConfig && envAuthConfig.sessionSecret) 10 | || process.env.SESSION_SECRET 11 | || crypto.randomBytes(32).toString('hex'); 12 | 13 | logger.debug({ hasSessionSecret: Boolean(sessionSecret) }, 'Session secret resolved'); 14 | 15 | app.locals.sessionStore = localStore; 16 | 17 | app.use(session({ 18 | secret: sessionSecret, 19 | resave: false, 20 | saveUninitialized: false, 21 | store: localStore, 22 | cookie: { 23 | httpOnly: true, 24 | sameSite: 'lax', 25 | secure: 'auto', 26 | }, 27 | })); 28 | 29 | logger.debug('Express session middleware configured with shared SQLite store'); 30 | }; 31 | 32 | module.exports = { configureSession }; 33 | -------------------------------------------------------------------------------- /frontend/src/components/MiddleEllipsis.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 44 | -------------------------------------------------------------------------------- /frontend/src/api/auth.api.js: -------------------------------------------------------------------------------- 1 | // /api/auth.api.js 2 | 3 | import { requestJson } from './http'; 4 | 5 | const fetchAuthStatus = () => requestJson('/api/auth/status', { method: 'GET' }); 6 | 7 | const setupAccount = ({ email, username, password }) => requestJson('/api/auth/setup', { 8 | method: 'POST', 9 | body: JSON.stringify({ email, username, password }), 10 | }); 11 | 12 | const fetchCurrentUser = () => requestJson('/api/auth/me', { method: 'GET' }); 13 | 14 | const login = ({ email, password }) => requestJson('/api/auth/login', { 15 | method: 'POST', 16 | body: JSON.stringify({ email, password }), 17 | }); 18 | 19 | const logout = () => requestJson('/api/auth/logout', { 20 | method: 'POST', 21 | }); 22 | 23 | async function changePassword({ currentPassword, newPassword }) { 24 | return requestJson('/api/auth/password', { 25 | method: 'POST', 26 | body: JSON.stringify({ currentPassword, newPassword }), 27 | }); 28 | } 29 | 30 | export { 31 | fetchAuthStatus, 32 | setupAccount, 33 | fetchCurrentUser, 34 | login, 35 | logout, 36 | changePassword 37 | }; -------------------------------------------------------------------------------- /frontend/src/main.js: -------------------------------------------------------------------------------- 1 | import './assets/main.css' 2 | 3 | import { createApp } from 'vue' 4 | import { createPinia } from 'pinia' 5 | 6 | import App from './App.vue' 7 | import router from './router' 8 | import { installPreviewPlugins } from '@/plugins'; 9 | import { useFeaturesStore } from '@/stores/features'; 10 | import i18n from './i18n' 11 | 12 | const pinia = createPinia() 13 | const app = createApp(App) 14 | 15 | app.use(pinia) 16 | 17 | const featuresStore = useFeaturesStore(pinia) 18 | featuresStore.initialize().catch(err => { 19 | console.debug('Failed to load features at startup:', err) 20 | }) 21 | 22 | // Install preview plugins 23 | // Option 1: Basic usage (installs all core + ONLYOFFICE) 24 | installPreviewPlugins(pinia) 25 | 26 | // Option 2: With custom plugins 27 | // import { myCustomPlugin } from './plugins/custom/myCustomPlugin' 28 | // installPreviewPlugins(pinia, { 29 | // plugins: [myCustomPlugin()], 30 | // }) 31 | 32 | // Option 3: Skip ONLYOFFICE (faster startup) 33 | // installPreviewPlugins(pinia, { skipOnlyOffice: true }) 34 | 35 | app.use(router) 36 | app.use(i18n) 37 | 38 | app.mount('#app') 39 | -------------------------------------------------------------------------------- /frontend/src/components/SearchBar.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 28 | -------------------------------------------------------------------------------- /frontend/src/utils/ellipses.js: -------------------------------------------------------------------------------- 1 | // Utility to truncate long strings with a middle ellipsis. 2 | // Example: truncateMiddle('verylongfoldername', 12) => 'verylo…rname' 3 | // Options: 4 | // - ellipsis: string to use between parts (default: '…') 5 | // - keepStart: number of chars to keep from start 6 | // - keepEnd: number of chars to keep from end 7 | export function ellipses(input, max = 30, options = {}) { 8 | if (input == null) return ''; 9 | const str = String(input); 10 | const { ellipsis = '…', keepStart, keepEnd } = options; 11 | 12 | if (max <= 0) return ''; 13 | if (str.length <= max) return str; 14 | 15 | const ellipsisLen = ellipsis.length; 16 | if (max <= ellipsisLen) return ellipsis.slice(0, max); 17 | 18 | let startLen = typeof keepStart === 'number' ? keepStart : Math.ceil((max - ellipsisLen) * 0.6); 19 | let endLen = typeof keepEnd === 'number' ? keepEnd : (max - ellipsisLen - startLen); 20 | 21 | if (startLen < 0) startLen = 0; 22 | if (endLen < 0) endLen = 0; 23 | 24 | const start = str.slice(0, startLen); 25 | const end = str.slice(str.length - endLen); 26 | return `${start}${ellipsis}${end}`; 27 | } 28 | 29 | -------------------------------------------------------------------------------- /frontend/src/icons/files/txt-icon.vue: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/src/config/trustProxy.js: -------------------------------------------------------------------------------- 1 | // Returns: { set: boolean, value: any, message?: string } 2 | function getTrustProxySetting() { 3 | const trustProxy = process.env.TRUST_PROXY?.trim().toLowerCase(); 4 | const publicUrl = process.env.PUBLIC_URL?.trim(); 5 | 6 | if (trustProxy !== undefined) { 7 | let value; 8 | let message; 9 | 10 | if (trustProxy === 'true') { 11 | value = 'loopback,uniquelocal'; 12 | message = "trust proxy: mapped 'true' to 'loopback,uniquelocal' (safer)"; 13 | } else if (trustProxy === 'false') { 14 | value = false; 15 | message = 'trust proxy disabled via TRUST_PROXY=false'; 16 | } else if (/^\d+$/.test(trustProxy)) { 17 | value = Number(trustProxy); 18 | message = `trust proxy set to '${value}'`; 19 | } else { 20 | value = trustProxy; 21 | message = `trust proxy set to '${value}'`; 22 | } 23 | 24 | return { set: true, value, message }; 25 | } 26 | 27 | // Default only when PUBLIC_URL is provided 28 | if (publicUrl) { 29 | return { 30 | set: true, 31 | value: 'loopback,uniquelocal', 32 | message: "trust proxy defaulted to 'loopback,uniquelocal' (PUBLIC_URL set)", 33 | }; 34 | } 35 | 36 | return { set: false, value: false }; 37 | } 38 | 39 | module.exports = { getTrustProxySetting }; -------------------------------------------------------------------------------- /backend/src/utils/env.js: -------------------------------------------------------------------------------- 1 | const normalizeBoolean = (value) => { 2 | if (typeof value !== 'string') return null; 3 | const normalized = value.trim().toLowerCase(); 4 | 5 | if (['1', 'true', 'yes', 'on'].includes(normalized)) { 6 | return true; 7 | } 8 | 9 | if (['0', 'false', 'no', 'off'].includes(normalized)) { 10 | return false; 11 | } 12 | 13 | return null; 14 | }; 15 | 16 | // Parse sizes like "512", "512k", "10M", "1g" into bytes (number). 17 | // Supports K, M, G, T suffixes (base 1024). Returns null if cannot parse. 18 | const parseByteSize = (value) => { 19 | if (value == null) return null; 20 | if (typeof value === 'number' && Number.isFinite(value)) return Math.max(0, Math.floor(value)); 21 | if (typeof value !== 'string') return null; 22 | 23 | const s = value.trim(); 24 | if (!s) return null; 25 | const m = s.match(/^([0-9]+)\s*([kKmMgGtT]?)b?$/); 26 | if (!m) return null; 27 | const num = Number(m[1]); 28 | if (!Number.isFinite(num)) return null; 29 | const unit = (m[2] || '').toUpperCase(); 30 | const pow = unit === 'K' ? 1 : unit === 'M' ? 2 : unit === 'G' ? 3 : unit === 'T' ? 4 : 0; 31 | const factor = 1024 ** pow; 32 | return Math.max(0, Math.floor(num * factor)); 33 | }; 34 | 35 | module.exports = { 36 | normalizeBoolean, 37 | parseByteSize, 38 | }; 39 | -------------------------------------------------------------------------------- /backend/src/routes/features.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const { onlyoffice, editor, features } = require('../config/index'); 3 | const packageJson = require('../../package.json'); 4 | 5 | const router = express.Router(); 6 | 7 | // GET /api/features -> returns enabled/disabled feature flags derived from env 8 | router.get('/features', (_req, res) => { 9 | const payload = { 10 | onlyoffice: { 11 | enabled: Boolean(onlyoffice && onlyoffice.serverUrl), 12 | extensions: Array.isArray(onlyoffice?.extensions) ? onlyoffice.extensions : [], 13 | }, 14 | editor: { 15 | extensions: Array.isArray(editor?.extensions) ? editor.extensions : [], 16 | }, 17 | volumeUsage: { 18 | enabled: Boolean(features?.volumeUsage), 19 | }, 20 | personal: { 21 | enabled: Boolean(features?.personalFolders), 22 | }, 23 | userVolumes: { 24 | enabled: Boolean(features?.userVolumes), 25 | }, 26 | navigation: { 27 | skipHome: Boolean(features?.skipHome), 28 | }, 29 | version: { 30 | app: packageJson.version || '1.0.0', 31 | gitCommit: process.env.GIT_COMMIT || '', 32 | gitBranch: process.env.GIT_BRANCH || '', 33 | repoUrl: process.env.REPO_URL || '', 34 | }, 35 | }; 36 | 37 | res.json(payload); 38 | }); 39 | 40 | module.exports = router; 41 | -------------------------------------------------------------------------------- /frontend/src/components/NotificationToastContainer.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 33 | -------------------------------------------------------------------------------- /frontend/src/icons/files/audio-icon.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | -------------------------------------------------------------------------------- /frontend/src/composables/useDeleteConfirm.js: -------------------------------------------------------------------------------- 1 | import { ref } from 'vue' 2 | import { useFileActions } from '@/composables/fileActions' 3 | 4 | // Singleton instance so multiple callers share the same modal state 5 | let instance = null; 6 | 7 | export function useDeleteConfirm() { 8 | if (instance) return instance; 9 | 10 | const actions = useFileActions(); 11 | 12 | const isDeleteConfirmOpen = ref(false); 13 | const isDeleting = ref(false); 14 | 15 | const openDeleteConfirm = () => { 16 | if (!actions.canDelete.value) return; 17 | isDeleteConfirmOpen.value = true; 18 | }; 19 | 20 | const closeDeleteConfirm = () => { 21 | isDeleteConfirmOpen.value = false; 22 | }; 23 | 24 | const requestDelete = () => { 25 | openDeleteConfirm(); 26 | }; 27 | 28 | const confirmDelete = async () => { 29 | if (!actions.canDelete.value || isDeleting.value) return; 30 | isDeleting.value = true; 31 | try { 32 | await actions.deleteNow(); 33 | isDeleteConfirmOpen.value = false; 34 | } catch (err) { 35 | console.error('Delete operation failed', err); 36 | } finally { 37 | isDeleting.value = false; 38 | } 39 | }; 40 | 41 | instance = { 42 | isDeleteConfirmOpen, 43 | isDeleting, 44 | openDeleteConfirm, 45 | closeDeleteConfirm, 46 | requestDelete, 47 | confirmDelete, 48 | }; 49 | 50 | return instance; 51 | } 52 | -------------------------------------------------------------------------------- /frontend/src/icons/files/code-icon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | Explorer 30 | 31 | 32 |
33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /frontend/src/icons/files/video-icon.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | -------------------------------------------------------------------------------- /backend/src/config/constants.js: -------------------------------------------------------------------------------- 1 | const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg', 'ico', 'tif', 'tiff', 'avif', 'heic']; 2 | const VIDEO_EXTENSIONS = ['mp4', 'mov', 'mkv', 'webm', 'm4v', 'avi', 'wmv', 'flv', 'mpg', 'mpeg']; 3 | const AUDIO_EXTENSIONS = ['mp3', 'wav', 'flac', 'aac', 'm4a', 'ogg', 'opus', 'wma']; 4 | const DOCUMENT_EXTENSIONS = ['pdf']; 5 | const EXCLUDED_FILES = ['thumbs.db', '.DS_Store', '_users']; 6 | 7 | const MIME_TYPES = { 8 | jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', 9 | webp: 'image/webp', bmp: 'image/bmp', svg: 'image/svg+xml', ico: 'image/x-icon', 10 | tif: 'image/tiff', tiff: 'image/tiff', avif: 'image/avif', heic: 'image/heic', 11 | mp4: 'video/mp4', mov: 'video/quicktime', mkv: 'video/x-matroska', webm: 'video/webm', 12 | m4v: 'video/x-m4v', avi: 'video/x-msvideo', wmv: 'video/x-ms-wmv', flv: 'video/x-flv', 13 | mpg: 'video/mpeg', mpeg: 'video/mpeg', pdf: 'application/pdf', 14 | mp3: 'audio/mpeg', wav: 'audio/wav', flac: 'audio/flac', aac: 'audio/aac', 15 | m4a: 'audio/mp4', ogg: 'audio/ogg', opus: 'audio/opus', wma: 'audio/x-ms-wma', 16 | }; 17 | 18 | module.exports = { 19 | IMAGE_EXTENSIONS, 20 | VIDEO_EXTENSIONS, 21 | AUDIO_EXTENSIONS, 22 | DOCUMENT_EXTENSIONS, 23 | EXCLUDED_FILES, 24 | MIME_TYPES, 25 | PREVIEWABLE_EXTENSIONS: new Set([...IMAGE_EXTENSIONS, ...VIDEO_EXTENSIONS, ...AUDIO_EXTENSIONS, ...DOCUMENT_EXTENSIONS]), 26 | }; 27 | -------------------------------------------------------------------------------- /backend/tests/README.md: -------------------------------------------------------------------------------- 1 | # Backend Test Suites 2 | 3 | ## Layout 4 | 5 | - `routes/` — endpoint-level suites that mount Express routers and exercise HTTP flows (auth, features, upcoming search/files routes). 6 | - `services/` — business logic, storage, and configuration helpers (users, settings, access control, etc). 7 | - `utils/` — low-level utilities such as `pathUtils` that enforce safe file operations and normalization. 8 | - `helpers/` — shared fixtures or helpers (e.g., `env-test-utils`) used across suites. 9 | 10 | Keep file names aligned with the code under test (e.g., `services/settings.test.js` targets `src/services/settingsService.js`). 11 | 12 | ## Writing a New Test 13 | 14 | 1. Pick the appropriate domain folder (`routes`, `services`, or `utils`). 15 | 2. Use `node:test` + `assert` (matching the existing pattern) and keep suites focused on one module/feature. 16 | 3. Reuse the environment helper (`helpers/env-test-utils.js`) to create transient config/cache/volume directories, reset `process.env`, and clear module caches. This keeps migrations/setup deterministic. 17 | 4. Clean up async resources via `await envContext.cleanup()` or `test.after`. 18 | 19 | ## Running the Suite 20 | 21 | ```sh 22 | cd backend 23 | npm test 24 | ``` 25 | 26 | `npm test` executes all files under `backend/tests` via `node --test`. The suite already applies SQLite migrations before each run, so expect a brief migration phase at the start of the output. 27 | -------------------------------------------------------------------------------- /backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "finder", 3 | "version": "2.0.7", 4 | "description": "explorer", 5 | "main": "src/app.js", 6 | "scripts": { 7 | "download_samples": "node src/scripts/downloadSamples.js", 8 | "test": "node --test", 9 | "start": "node --watch src/app.js" 10 | }, 11 | "author": "Vikram Soni", 12 | "license": "ISC", 13 | "dependencies": { 14 | "@homebridge/node-pty-prebuilt-multiarch": "^0.13.1", 15 | "archiver": "^6.0.2", 16 | "axios": "^1.7.7", 17 | "bcryptjs": "^2.4.3", 18 | "better-sqlite3": "^12.5.0", 19 | "body-parser": "^1.20.2", 20 | "connect-sqlite3": "^0.9.16", 21 | "cookie-parser": "^1.4.7", 22 | "cors": "^2.8.5", 23 | "exifr": "^7.1.3", 24 | "express": "^4.19.2", 25 | "express-openid-connect": "^2.19.2", 26 | "express-rate-limit": "^7.2.0", 27 | "express-session": "^1.17.3", 28 | "fluent-ffmpeg": "^2.1.2", 29 | "jsonwebtoken": "^9.0.2", 30 | "memorystore": "^1.6.7", 31 | "multer": "^2.0.2", 32 | "p-queue": "^7.4.1", 33 | "pino": "^10.1.0", 34 | "pino-http": "^11.0.0", 35 | "pino-pretty": "^13.1.2", 36 | "sharp": "^0.34.4", 37 | "uuid": "^13.0.0", 38 | "ws": "^8.17.0", 39 | "yauzl": "^2.10.0" 40 | }, 41 | "devDependencies": { 42 | "supertest": "^7.1.4" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /backend/src/utils/sessionStore.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const logger = require('../utils/logger'); 3 | const session = require('express-session'); 4 | const { directories } = require('../config/index'); 5 | 6 | 7 | const cacheDir = (directories && directories.cache) || '/cache'; 8 | const dbPath = path.join(cacheDir, 'sessions.db'); 9 | 10 | const SQLiteStore = require('connect-sqlite3')(session); 11 | 12 | const baseStore = new SQLiteStore({ 13 | db: path.basename(dbPath), 14 | dir: path.dirname(dbPath), 15 | createDirIfNotExists: true, 16 | }); 17 | 18 | logger.debug({ dbPath }, 'Initialized shared SQLite session store'); 19 | 20 | 21 | const localStore = baseStore; 22 | 23 | // OIDC sessions use a thin wrapper around the same store to ensure that 24 | // express-openid-connect's safePromisify treats the methods as callback-based 25 | // and never calls them without a callback argument. 26 | const oidcStore = { 27 | get(sid, cb) { 28 | const callback = typeof cb === 'function' ? cb : () => {}; 29 | return baseStore.get(sid, callback); 30 | }, 31 | set(sid, sess, cb) { 32 | const callback = typeof cb === 'function' ? cb : () => {}; 33 | return baseStore.set(sid, sess, callback); 34 | }, 35 | destroy(sid, cb) { 36 | const callback = typeof cb === 'function' ? cb : () => {}; 37 | return baseStore.destroy(sid, callback); 38 | }, 39 | }; 40 | 41 | module.exports = { 42 | localStore, 43 | oidcStore, 44 | dbPath, 45 | }; 46 | 47 | -------------------------------------------------------------------------------- /backend/src/services/accessControlService.js: -------------------------------------------------------------------------------- 1 | const { normalizeRelativePath } = require('../utils/pathUtils'); 2 | const { getSettings, setSettings } = require('../services/settingsService'); 3 | 4 | // Determine permission for a given relative path: 'rw' | 'ro' | 'hidden' 5 | const getPermissionForPath = async (relativePath) => { 6 | const rel = normalizeRelativePath(relativePath || ''); 7 | const settings = await getSettings(); 8 | const rules = Array.isArray(settings?.access?.rules) ? settings.access.rules : []; 9 | 10 | // first match wins 11 | for (const rule of rules) { 12 | const rulePath = normalizeRelativePath(rule.path || ''); 13 | if (!rulePath) continue; 14 | 15 | if (rule.recursive) { 16 | if (rel === rulePath || rel.startsWith(rulePath + '/')) { 17 | return rule.permissions || 'rw'; 18 | } 19 | } else { 20 | if (rel === rulePath) { 21 | return rule.permissions || 'rw'; 22 | } 23 | } 24 | } 25 | 26 | return 'rw'; 27 | }; 28 | 29 | const getRules = async () => { 30 | const settings = await getSettings(); 31 | return Array.isArray(settings?.access?.rules) ? settings.access.rules : []; 32 | }; 33 | 34 | const setRules = async (rules) => { 35 | const next = await setSettings({ 36 | access: { 37 | rules: Array.isArray(rules) ? rules : [], 38 | }, 39 | }); 40 | return next.access.rules; 41 | }; 42 | 43 | module.exports = { 44 | getPermissionForPath, 45 | getRules, 46 | setRules, 47 | }; 48 | -------------------------------------------------------------------------------- /frontend/src/api/favorites.api.js: -------------------------------------------------------------------------------- 1 | // /api/favorites.api.js 2 | 3 | import { requestJson, normalizePath } from './http'; 4 | 5 | async function fetchFavorites() { 6 | return requestJson('/api/favorites', { method: 'GET' }); 7 | } 8 | 9 | async function addFavorite(path, { label, icon, color } = {}) { 10 | const normalizedPath = normalizePath(path || ''); 11 | return requestJson('/api/favorites', { 12 | method: 'POST', 13 | body: JSON.stringify({ 14 | path: normalizedPath, 15 | label, 16 | icon, 17 | color, 18 | }), 19 | }); 20 | } 21 | 22 | async function updateFavorite(id, { label, icon, color, position } = {}) { 23 | return requestJson(`/api/favorites/${encodeURIComponent(id)}`, { 24 | method: 'PATCH', 25 | body: JSON.stringify({ 26 | label, 27 | icon, 28 | color, 29 | position, 30 | }), 31 | }); 32 | } 33 | 34 | async function reorderFavorites(order) { 35 | return requestJson('/api/favorites/reorder', { 36 | method: 'PATCH', 37 | body: JSON.stringify({ 38 | order, 39 | }), 40 | }); 41 | } 42 | 43 | async function removeFavorite(path) { 44 | const normalizedPath = normalizePath(path || ''); 45 | return requestJson('/api/favorites', { 46 | method: 'DELETE', 47 | body: JSON.stringify({ 48 | path: normalizedPath, 49 | }), 50 | }); 51 | } 52 | 53 | export { 54 | fetchFavorites, 55 | addFavorite, 56 | updateFavorite, 57 | reorderFavorites, 58 | removeFavorite, 59 | } 60 | -------------------------------------------------------------------------------- /frontend/src/icons/files/archive-icon.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Deploy VitePress docs to GitHub Pages 2 | 3 | on: 4 | push: 5 | branches: 6 | - main # or 'master' or whatever your default branch is 7 | workflow_dispatch: # allows manual triggers from the Actions tab 8 | 9 | permissions: 10 | contents: read 11 | pages: write 12 | id-token: write 13 | 14 | concurrency: 15 | group: "pages" 16 | cancel-in-progress: false 17 | 18 | jobs: 19 | build: 20 | runs-on: ubuntu-latest 21 | steps: 22 | - name: Checkout 23 | uses: actions/checkout@v4 24 | 25 | - name: Setup Node.js 26 | uses: actions/setup-node@v4 27 | with: 28 | node-version: 20 # or 20/18 if that's what your project uses 29 | cache: npm 30 | cache-dependency-path: docs/package-lock.json 31 | 32 | - name: Install dependencies 33 | working-directory: ./docs 34 | run: npm ci 35 | 36 | - name: Build VitePress site 37 | working-directory: ./docs 38 | run: | 39 | npm install 40 | npx vitepress build 41 | 42 | - name: Upload artifact 43 | uses: actions/upload-pages-artifact@v3 44 | with: 45 | path: docs/.vitepress/dist 46 | 47 | deploy: 48 | needs: build 49 | runs-on: ubuntu-latest 50 | environment: 51 | name: github-pages 52 | url: ${{ steps.deployment.outputs.page_url }} 53 | steps: 54 | - name: Deploy to GitHub Pages 55 | id: deployment 56 | uses: actions/deploy-pages@v4 57 | -------------------------------------------------------------------------------- /frontend/src/plugins/pdf/PdfPreview.vue: -------------------------------------------------------------------------------- 1 | 2 | 43 | 44 | 47 | 48 | -------------------------------------------------------------------------------- /frontend/src/icons/IconDrive.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | -------------------------------------------------------------------------------- /frontend/src/plugins/image/ImagePreview.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 60 | -------------------------------------------------------------------------------- /frontend/src/icons/files/FileBadgeIcon.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 33 | 34 | -------------------------------------------------------------------------------- /frontend/src/plugins/markdown/MarkdownPreview.vue: -------------------------------------------------------------------------------- 1 | 2 | 23 | 24 | 63 | -------------------------------------------------------------------------------- /frontend/vite.config.js: -------------------------------------------------------------------------------- 1 | import { fileURLToPath, URL } from 'node:url' 2 | import tailwindcss from '@tailwindcss/vite' 3 | import { defineConfig } from 'vite' 4 | import vue from '@vitejs/plugin-vue' 5 | import VueDevTools from 'vite-plugin-vue-devtools' 6 | 7 | const backendOrigin = process.env.VITE_BACKEND_ORIGIN || 'http://localhost:3001' 8 | const port = Number(process.env.PORT || 3000) 9 | 10 | // https://vitejs.dev/config/ 11 | export default defineConfig({ 12 | plugins: [ 13 | vue(), 14 | VueDevTools(), 15 | tailwindcss(), 16 | ], 17 | define: { 18 | // Legacy: Version info is now served from backend /api/features endpoint 19 | // These build-time constants are kept for backwards compatibility but no longer used 20 | __APP_VERSION__: JSON.stringify(process.env.VITE_APP_VERSION || process.env.npm_package_version || '1.0.5'), 21 | __GIT_COMMIT__: JSON.stringify(process.env.VITE_GIT_COMMIT || ''), 22 | __GIT_BRANCH__: JSON.stringify(process.env.VITE_GIT_BRANCH || ''), 23 | __REPO_URL__: JSON.stringify(process.env.VITE_REPO_URL || ''), 24 | }, 25 | resolve: { 26 | alias: { 27 | '@': fileURLToPath(new URL('./src', import.meta.url)) 28 | } 29 | }, 30 | server: { 31 | host: '0.0.0.0', 32 | port, 33 | strictPort: true, 34 | allowedHosts: ['files.vsoni.com'], 35 | proxy: { 36 | '/api': { target: backendOrigin, changeOrigin: true, ws: true, secure: false }, 37 | '/static/thumbnails': { target: backendOrigin, changeOrigin: true, secure: false }, 38 | // Proxy EOC routes to backend so /login, /callback, /logout work on the public origin 39 | '/login': { target: backendOrigin, changeOrigin: true, secure: false }, 40 | '/callback': { target: backendOrigin, changeOrigin: true, secure: false }, 41 | '/logout': { target: backendOrigin, changeOrigin: true, secure: false }, 42 | } 43 | } 44 | 45 | }) 46 | -------------------------------------------------------------------------------- /backend/src/routes/volumes.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const fs = require('fs/promises'); 3 | 4 | const { directories, excludedFiles, features } = require('../config/index'); 5 | const asyncHandler = require('../utils/asyncHandler'); 6 | const { getVolumesForUser } = require('../services/userVolumesService'); 7 | 8 | const router = express.Router(); 9 | 10 | /** 11 | * Get all volumes from VOLUME_ROOT (admin view or when USER_VOLUMES is disabled) 12 | */ 13 | const getAllVolumes = async () => { 14 | const entries = await fs.readdir(directories.volume, { withFileTypes: true }); 15 | 16 | return entries 17 | .filter((entry) => entry.isDirectory()) 18 | .map((entry) => entry.name) 19 | .filter((name) => !excludedFiles.includes(name)) 20 | .map((name) => ({ 21 | name, 22 | path: name, 23 | kind: 'volume', 24 | })); 25 | }; 26 | 27 | router.get('/volumes', asyncHandler(async (req, res) => { 28 | const user = req.user; 29 | const isAdmin = user?.roles?.includes('admin'); 30 | const userVolumesEnabled = features.userVolumes; 31 | 32 | // If USER_VOLUMES is disabled or user is admin, show all volumes from VOLUME_ROOT 33 | if (!userVolumesEnabled || isAdmin) { 34 | const volumeData = await getAllVolumes(); 35 | return res.json(volumeData); 36 | } 37 | 38 | // For regular users when USER_VOLUMES is enabled, show only assigned volumes 39 | if (!user || !user.id) { 40 | return res.json([]); 41 | } 42 | 43 | const userVolumes = await getVolumesForUser(user.id); 44 | 45 | const volumeData = userVolumes.map((vol) => ({ 46 | name: vol.label, 47 | path: vol.label, // Use label as the path identifier for navigation 48 | kind: 'volume', 49 | accessMode: vol.accessMode, 50 | actualPath: vol.path, // Include actual path for reference 51 | })); 52 | 53 | res.json(volumeData); 54 | })); 55 | 56 | module.exports = router; 57 | -------------------------------------------------------------------------------- /frontend/src/components/ModalDialog.vue: -------------------------------------------------------------------------------- 1 | 25 | -------------------------------------------------------------------------------- /frontend/src/components/ProgressBar.vue: -------------------------------------------------------------------------------- 1 | 41 | 42 | 61 | -------------------------------------------------------------------------------- /frontend/src/components/MenuClipboard.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 63 | -------------------------------------------------------------------------------- /docs/integrations/onlyoffice.md: -------------------------------------------------------------------------------- 1 | # ONLYOFFICE Integration 2 | 3 | Use ONLYOFFICE Document Server to edit office files (DOCX, XLSX, PPTX, ODT, ODS, ODP) from within nextExplorer. The integration relies on server-to-server API calls and a shared JWT secret. 4 | 5 | ## Environment variables 6 | 7 | | Variable | Required? | Description | 8 | | --- | --- | --- | 9 | | `ONLYOFFICE_URL` | Yes | Public URL of your Document Server (e.g., `https://office.example.com`). | 10 | | `PUBLIC_URL` | Yes | nextExplorer’s public URL so ONLYOFFICE knows where to download files and post callbacks. | 11 | | `ONLYOFFICE_SECRET` | Yes | JWT secret shared between nextExplorer and ONLYOFFICE for signing requests/responses. | 12 | | `ONLYOFFICE_LANG` | No (default `en`) | Language code for the editor UI. | 13 | | `ONLYOFFICE_FORCE_SAVE` | No | When true, users must use the editor’s Save button rather than relying on autosave. | 14 | | `ONLYOFFICE_FILE_EXTENSIONS` | No | Comma-separated list of extensions you want to surface beyond the defaults. | 15 | 16 | ## How it works 17 | 18 | 1. Opening a compatible file triggers a call to `/api/onlyoffice/config`, which returns editor configuration and a signed `config.token` when `ONLYOFFICE_SECRET` is set. 19 | 2. ONLYOFFICE fetches the file through `/api/onlyoffice/file?path=...` with an `Authorization: Bearer ` header. 20 | 3. After editing, ONLYOFFICE posts to `/api/onlyoffice/callback?path=...`, again authorized with the token; nextExplorer saves the changes automatically. 21 | 22 | ## Security notes 23 | 24 | - Tokens are signed with HS256 using `ONLYOFFICE_SECRET`. Keep this secret in sync with the Document Server’s `services.CoAuthoring.secret` (`local.json`). 25 | - To inspect the secret, run inside the Document Server container: 26 | ```bash 27 | jq -r '.services.CoAuthoring.secret.session.string' /etc/onlyoffice/documentserver/local.json 28 | ``` 29 | - Disable ONLYOFFICE JWT on the Document Server only if you completely trust the network; otherwise, mismatched tokens result in “document security token is not correctly configured.” 30 | -------------------------------------------------------------------------------- /frontend/src/composables/clipboardShortcuts.js: -------------------------------------------------------------------------------- 1 | import { useMagicKeys, whenever } from '@vueuse/core' 2 | import { computed } from 'vue' 3 | import { useFileActions } from '@/composables/fileActions' 4 | import { useDeleteConfirm } from '@/composables/useDeleteConfirm'; 5 | 6 | export function useClipboardShortcuts() { 7 | const actions = useFileActions(); 8 | const keys = useMagicKeys(); 9 | 10 | // Small helper: ignore when focus is inside editable elements (inputs, textareas, contenteditable) 11 | const shouldIgnore = () => { 12 | const active = document.activeElement; 13 | return actions.isEditableElement ? actions.isEditableElement(active) : false; 14 | } 15 | 16 | const cutPressed = computed(() => (keys['Ctrl+X']?.value || keys['Meta+X']?.value)); 17 | whenever(cutPressed, () => { 18 | if (shouldIgnore()) return; 19 | if (!actions.canCut.value) return; 20 | actions.runCut(); 21 | }); 22 | 23 | const copyPressed = computed(() => (keys['Ctrl+C']?.value || keys['Meta+C']?.value)); 24 | whenever(copyPressed, () => { 25 | if (shouldIgnore()) return; 26 | if (!actions.canCopy.value) return; 27 | actions.runCopy(); 28 | }); 29 | 30 | const pastePressed = computed(() => (keys['Ctrl+V']?.value || keys['Meta+V']?.value)); 31 | whenever(pastePressed, async () => { 32 | if (shouldIgnore()) return; 33 | if (!actions.canPaste.value) return; 34 | try { 35 | await actions.runPasteIntoCurrent(); 36 | } catch (err) { 37 | // Surface errors to console only; UI can handle feedback separately 38 | console.error('Paste failed', err); 39 | } 40 | }); 41 | 42 | // Delete / Backspace -> delete selected items (when focus not in editable) 43 | const { requestDelete } = useDeleteConfirm(); 44 | 45 | const deletePressed = computed(() => (keys.delete?.value || keys.backspace?.value)); 46 | whenever(deletePressed, async () => { 47 | if (shouldIgnore()) return; 48 | // Open the centralized delete confirmation dialog instead of deleting immediately 49 | requestDelete(); 50 | }); 51 | } 52 | -------------------------------------------------------------------------------- /docs/configuration/settings.md: -------------------------------------------------------------------------------- 1 | # Runtime Settings 2 | 3 | In-app settings expose many server-side toggles you’ll also find in the environment reference. Admin sections unlock once your user has the `admin` role or matches `OIDC_ADMIN_GROUPS`. 4 | 5 | ## Files & Thumbnails 6 | 7 | - **Enable thumbnails:** Toggle thumbnail generation (uses Sharp/FFmpeg). Disable to reduce CPU usage when browsing large volumes. 8 | - **Thumbnail quality:** 1–100 (default 70) to control JPEG compression level. 9 | - **Max dimension:** Longest side in pixels (default 200) for generated thumbnails. 10 | - **Video previews:** Require FFmpeg/ffprobe; binaries are included but you can override paths via environment variables. 11 | 12 | ## Security & Authentication 13 | 14 | - **Authentication toggle:** Turn on/off authentication for trusted, internal networks (not recommended for public deployments). 15 | - **OIDC configuration:** The fields here mirror the environment variables in the reference section. Use them to enable SSO once you’ve configured your IdP. 16 | - **Session locking:** Enable/disable workspace password prompts that gate the entire UI. 17 | 18 | ## Access Control 19 | 20 | - **Rule editor:** Define per-folder rules with `path`, `type` (`rw`, `ro`, `hidden`), and recursion options. 21 | - **First-match wins:** Rules are evaluated top to bottom; the first matching path governs browser behavior. 22 | - **Hidden folders:** Use `hidden` to keep folders out of listings while still accessible via direct URLs. 23 | 24 | ## Admin Users 25 | 26 | - **Create or edit local users:** Manage usernames, passwords, and roles (user vs admin). 27 | - **Password reset:** Reset passwords for local accounts without needing direct OS access. 28 | - **Admin safeguarding:** The UI prevents demoting or deleting the last admin to avoid lockouts. 29 | 30 | ## Additional hints 31 | 32 | - Most settings persist in `/config/app-config.json`. Back up `/config` before making sweeping changes. 33 | - Favorites and access control settings sync with the sidebar, so once you pin a favorite it surfaces immediately for all sessions. 34 | -------------------------------------------------------------------------------- /frontend/src/plugins/audio/AudioPreview.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 72 | 73 | -------------------------------------------------------------------------------- /frontend/src/composables/useFileDialog.js: -------------------------------------------------------------------------------- 1 | import { ref, onMounted, onBeforeUnmount } from "vue"; 2 | 3 | const defaultDialogOptions = { 4 | multiple: true, 5 | accept: '*', 6 | directory: true, 7 | }; 8 | 9 | export function useFileDialog() { 10 | const inputRef = ref(null); 11 | const files = ref([]); 12 | 13 | onMounted(() => { 14 | const input = document.createElement("input"); 15 | input.type = "file"; 16 | input.className = "hidden"; 17 | document.body.appendChild(input); 18 | inputRef.value = input; 19 | }); 20 | 21 | onBeforeUnmount(() => { 22 | inputRef.value?.remove(); 23 | }); 24 | 25 | function openFileDialog(opts) { 26 | return new Promise((resolve) => { 27 | if (!inputRef.value) { 28 | return; 29 | } 30 | files.value = []; 31 | 32 | const options = { ...defaultDialogOptions, ...opts }; 33 | inputRef.value.accept = options.accept; 34 | inputRef.value.multiple = options.multiple; 35 | 36 | if (options.directory) { 37 | inputRef.value.webkitdirectory = !!options.directory; 38 | inputRef.value.directory = !!options.directory; 39 | inputRef.value.mozdirectory = !!options.directory; 40 | } 41 | 42 | inputRef.value.onchange = (e) => { 43 | if (options.directory) { 44 | // Process directory files 45 | const items = Array.from(e.target.files); 46 | const directories = {}; 47 | items.forEach(file => { 48 | const path = file.webkitRelativePath.split('/'); 49 | const dir = path[0]; 50 | if (!directories[dir]) { 51 | directories[dir] = []; 52 | } 53 | directories[dir].push(file); 54 | }); 55 | files.value = directories; 56 | } else { 57 | // Process individual files 58 | files.value = Array.from(e.target.files); 59 | } 60 | resolve(); 61 | }; 62 | 63 | inputRef.value.click(); 64 | }); 65 | } 66 | 67 | return { 68 | openFileDialog, 69 | files, 70 | }; 71 | } 72 | -------------------------------------------------------------------------------- /backend/src/services/storage/jsonStorage.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs/promises'); 2 | const { directories, files } = require('../../config/index'); 3 | const { ensureDir } = require('../../utils/fsUtils'); 4 | const logger = require('../../utils/logger'); 5 | 6 | const CONFIG_FILE = files.passwordConfig; 7 | const ENCODING = 'utf8'; 8 | 9 | let cache = null; 10 | let initialized = false; 11 | 12 | /** 13 | * Default structure for app-config.json 14 | */ 15 | const DEFAULT_DATA = { 16 | version: 4, 17 | settings: { 18 | thumbnails: { enabled: true, size: 200, quality: 70 }, 19 | access: { rules: [] }, 20 | }, 21 | favorites: [], 22 | }; 23 | 24 | /** 25 | * Ensure config directory and file exist 26 | */ 27 | const init = async () => { 28 | if (initialized) return; 29 | 30 | await ensureDir(directories.config); 31 | 32 | try { 33 | await fs.access(CONFIG_FILE); 34 | } catch (error) { 35 | if (error?.code === 'ENOENT') { 36 | logger.info('Creating default config file'); 37 | await fs.writeFile(CONFIG_FILE, JSON.stringify(DEFAULT_DATA, null, 2) + '\n', ENCODING); 38 | } 39 | } 40 | 41 | cache = await read(); 42 | initialized = true; 43 | }; 44 | 45 | /** 46 | * Read from disk 47 | */ 48 | const read = async () => { 49 | try { 50 | const raw = await fs.readFile(CONFIG_FILE, ENCODING); 51 | return JSON.parse(raw); 52 | } catch (error) { 53 | logger.warn({ err: error }, 'Failed to read config, using defaults'); 54 | return { ...DEFAULT_DATA }; 55 | } 56 | }; 57 | 58 | /** 59 | * Write to disk and update cache 60 | */ 61 | const write = async (data) => { 62 | await fs.writeFile(CONFIG_FILE, JSON.stringify(data, null, 2) + '\n', ENCODING); 63 | cache = data; 64 | return data; 65 | }; 66 | 67 | /** 68 | * Get entire config 69 | */ 70 | const get = async () => { 71 | await init(); 72 | return JSON.parse(JSON.stringify(cache)); // Deep clone 73 | }; 74 | 75 | /** 76 | * Update config with an updater function 77 | */ 78 | const update = async (updater) => { 79 | await init(); 80 | const current = await get(); 81 | const next = updater(current); 82 | return write(next); 83 | }; 84 | 85 | module.exports = { get, update }; -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | app: 3 | 4 | build: 5 | context: . 6 | dockerfile: Dockerfile 7 | ports: 8 | - 3000:3000 9 | volumes: 10 | - /Users/vikram/projects:/mnt/Projects 11 | - /Users/vikram/Downloads:/mnt/Downloads 12 | - /Users/vikram/Downloads/config:/config 13 | - /Users/vikram/Downloads/cache:/cache 14 | environment: 15 | - LOG_LEVEL=debug 16 | - DEBUG=openid-client* 17 | 18 | # Match these to your host user/group IDs (check with `id -u` / `id -g`) 19 | - PUID=1000 20 | - PGID=1000 21 | # Centralized public URL (frontend + backend behind proxy) 22 | - PUBLIC_URL=https://files.vsoni.com 23 | 24 | 25 | # OIDC configuration (fill with your identity provider details) 26 | - OIDC_ENABLED=true 27 | - OIDC_ISSUER=https://auth.vsoni.com/application/o/next/ 28 | - OIDC_CLIENT_ID=6HBh3lMneUwWCAeutdEgwNZ6myXzFc93clojdfmQ 29 | - OIDC_CLIENT_SECRET=w2ACQfUhhk2vZ14m2GVvYbWqkCQTiXyMsowpLQE2JtiPfaCTrBKhZHwZACgHOCWgSs9zhJW0j8y1TYCrLs0hsMmM0xdIz5tRmce6o4ymVOCQByPRIr9xMxtBt1Spg0I1 30 | - OIDC_SCOPES=openid,profile,email,groups 31 | - OIDC_ADMIN_GROUPS=next-admin,admins 32 | 33 | - ONLYOFFICE_URL=https://office.vsoni.com 34 | - ONLYOFFICE_SECRET=ha96M546Hl5oSSvrjvabmXK1D6qlRH15 35 | - ONLYOFFICE_LANG=en 36 | - ONLYOFFICE_FILE_EXTENSIONS=doc,docx,xls,xlsx,ppt,pptx,odt,ods,odp,rtf,txt,pdf 37 | 38 | # Authentication configuration 39 | # AUTH_MODE: Controls which authentication methods are available 40 | # - 'local': Only username/password authentication 41 | # - 'oidc': Only OIDC/SSO authentication 42 | # - 'both': Both local and OIDC (default if not specified) 43 | # - 'disabled': Skip authentication entirely (same as AUTH_ENABLED=false) 44 | # - AUTH_MODE=disabled 45 | 46 | # Legacy auth toggle (deprecated, use AUTH_MODE=disabled instead) 47 | # - AUTH_ENABLED=false 48 | 49 | # Optional: bootstrap the first local admin (skips setup wizard) 50 | # - AUTH_ADMIN_EMAIL=admin@example.com 51 | # - AUTH_ADMIN_PASSWORD=please-change-me 52 | 53 | restart: unless-stopped 54 | -------------------------------------------------------------------------------- /frontend/src/assets/base.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss'; 2 | @plugin "@tailwindcss/typography"; 3 | 4 | @custom-variant dark (&:where(.dark, .dark *)); 5 | 6 | 7 | /* Themed, thin scrollbars (global) */ 8 | @layer base { 9 | /* Default (light) theme variables */ 10 | :root { 11 | --scrollbar-track: transparent; /* nextgray-200 */ 12 | --scrollbar-thumb: #b7b4b4; /* nextgray-400 */ 13 | --scrollbar-thumb-hover: #BDBDBD; 14 | 15 | --clr-base: #fff; 16 | --clr-base-muted: #f9f9f9; 17 | } 18 | 19 | .dark { 20 | --scrollbar-track: transparent; /* nextslate-900 */ 21 | --scrollbar-thumb: #3d3d3f; /* nextgray-700 */ 22 | --scrollbar-thumb-hover: #3a3a3d; 23 | 24 | --clr-base: #212121; 25 | --clr-base-muted: #181818; 26 | } 27 | 28 | *, 29 | ::after, 30 | ::before, 31 | ::backdrop, 32 | ::file-selector-button { 33 | border-color: var(--color-gray-200, currentcolor); 34 | } 35 | 36 | /* Firefox */ 37 | html { 38 | scrollbar-width: auto; 39 | scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track); 40 | } 41 | 42 | /* WebKit (Chrome, Edge, Safari) */ 43 | *::-webkit-scrollbar { 44 | width: 10px; 45 | height: 8px; 46 | } 47 | *::-webkit-scrollbar-track { 48 | background: var(--scrollbar-track); 49 | } 50 | *::-webkit-scrollbar-thumb { 51 | background-color: var(--scrollbar-thumb); 52 | border-radius: 9999px; 53 | border: 2px solid var(--scrollbar-track); 54 | } 55 | *::-webkit-scrollbar-thumb:hover { 56 | background-color: var(--scrollbar-thumb-hover); 57 | } 58 | } 59 | 60 | @layer components { 61 | /* Make vue-drag-select behave like its “Drag To Scroll” story: 62 | root wrapper is the scroll container, and the inner 63 | .drag-select area fills its height so you can start a drag 64 | anywhere in the visible region (even with few items). */ 65 | .drag-select__wrapper { 66 | @apply h-full; 67 | } 68 | 69 | .drag-select { 70 | @apply min-h-full; 71 | } 72 | } 73 | 74 | 75 | 76 | @theme { 77 | /* Avoid collision with Tailwind's built-in `text-base` font-size utility. */ 78 | --color-default: var(--clr-base); 79 | --color-default-muted: var(--clr-base-muted); 80 | } 81 | -------------------------------------------------------------------------------- /frontend/src/composables/useCodemirror.js: -------------------------------------------------------------------------------- 1 | import { markRaw, onMounted, reactive, toRefs, unref } from "vue"; 2 | 3 | import CodeMirror from "codemirror"; 4 | import "codemirror/mode/htmlmixed/htmlmixed"; 5 | import "codemirror/lib/codemirror.css"; 6 | import "codemirror/theme/monokai.css"; 7 | import "codemirror/theme/dracula.css"; 8 | import "codemirror/theme/ambiance.css"; 9 | import "codemirror/theme/material.css"; 10 | import "codemirror/addon/lint/lint"; 11 | import "codemirror/addon/lint/lint.css"; 12 | 13 | export default function useCodeMirror({ 14 | emit, 15 | initialValue = "", 16 | options = {}, 17 | textareaRef 18 | }) { 19 | const state = reactive({ 20 | cm: null, 21 | dirty: null, 22 | generation: null 23 | }); 24 | 25 | const hasErrors = () => !!state.cm.state.lint.marked.length; 26 | 27 | const setValue = (val) => { 28 | state.cm.setValue(val); 29 | state.generation = state.cm.doc.changeGeneration(true); 30 | state.cm.markClean(); 31 | state.dirty = false; 32 | }; 33 | 34 | const append = (val) => { 35 | state.cm.doc.replaceRange(val, { line: Infinity }); 36 | }; 37 | 38 | const initialize = () => { 39 | 40 | // CodeMirror.registerHelper("lint", options.mode, function (text) { 41 | // return options.lint.getAnnotations; 42 | // }); 43 | 44 | // create code mirror instance 45 | state.cm = markRaw( 46 | CodeMirror.fromTextArea(textareaRef.value, { 47 | ...unref(options) 48 | }) 49 | ); 50 | 51 | // initialize editor with initial value 52 | state.cm.setValue(initialValue); 53 | 54 | // mark clean and prep for change tracking 55 | state.generation = state.cm.doc.changeGeneration(true); 56 | state.dirty = !state.cm.doc.isClean(state.generation); 57 | 58 | // synchronize with state (if dirty) 59 | state.cm.on("change", (cm) => { 60 | state.dirty = !state.cm.doc.isClean(state.generation); 61 | emit("change", { value: cm.getValue() }); 62 | }); 63 | }; 64 | 65 | onMounted(() => initialize()); 66 | 67 | const { cm: editor, ...rest } = toRefs(state); 68 | 69 | return { 70 | ...rest, 71 | append, 72 | editor, 73 | hasErrors, 74 | setValue 75 | }; 76 | } 77 | -------------------------------------------------------------------------------- /docs/reference/faq.md: -------------------------------------------------------------------------------- 1 | # FAQ 2 | 3 | ## What do I need before installing? 4 | - Docker Engine 24+ and Docker Compose v2. 5 | - Host folders to mount under `/mnt` and persistent storage for `/config` (back it up) plus optional `/cache`. 6 | - Optional environment variables for your preferred authentication, reverse proxy, and feature toggles; see the [Environment Reference](../configuration/environment) for the full list. 7 | 8 | ## How do I unlock the workspace after first setup? 9 | The Setup screen creates the first admin (unless you bootstrap one via `AUTH_ADMIN_EMAIL`/`AUTH_ADMIN_PASSWORD`). Once the workspace password is set, use that login to add local users or configure OIDC. Admin-only settings live under Settings → Admin. 10 | 11 | ## Where do I troubleshoot deployment issues? 12 | Check the [Troubleshooting](./troubleshooting) page for proxy/CORS tips, session secret advice, volume permissions, and thumbnail/search behavior. 13 | 14 | ## How can I keep my deployment updated? 15 | 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. 16 | 17 | ## Who handles metadata and search indexing? 18 | 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. 19 | 20 | ## How do I add support for custom file types in the editor? 21 | The inline editor supports 50+ file types by default (txt, md, json, js, ts, py, yml, html, css, and many more). To add support for additional file extensions at runtime, set the `EDITOR_EXTENSIONS` environment variable with a comma-separated list: 22 | 23 | ```yaml 24 | environment: 25 | - EDITOR_EXTENSIONS=toml,proto,graphql,dockerfile,makefile 26 | ``` 27 | 28 | Custom extensions are **added to** the default list (they don't replace it), and changes take effect immediately on container restart—no frontend rebuild required. See the [Environment Reference](../configuration/environment#editor) for details. 29 | -------------------------------------------------------------------------------- /frontend/src/components/TerminalMenu.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 66 | -------------------------------------------------------------------------------- /frontend/src/components/MenuShare.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 65 | -------------------------------------------------------------------------------- /backend/src/routes/index.js: -------------------------------------------------------------------------------- 1 | const authRoutes = require('./auth'); 2 | const uploadRoutes = require('./upload'); 3 | const fileRoutes = require('./files'); 4 | const browseRoutes = require('./browse'); 5 | const thumbnailRoutes = require('./thumbnails'); 6 | const editorRoutes = require('./editor'); 7 | const volumeRoutes = require('./volumes'); 8 | const usageRoutes = require('./usage'); 9 | const favoritesRoutes = require('./favorites'); 10 | const settingsRoutes = require('./settings'); 11 | const searchRoutes = require('./search'); 12 | const usersRoutes = require('./users'); 13 | const metadataRoutes = require('./metadata'); 14 | const onlyofficeRoutes = require('./onlyoffice'); 15 | const featuresRoutes = require('./features'); 16 | const terminalRoutes = require('./terminal'); 17 | const permissionsRoutes = require('./permissions'); 18 | const sharesRoutes = require('./shares'); 19 | const healthRoutes = require('./health'); 20 | const userVolumesRoutes = require('./userVolumes'); 21 | const { onlyoffice } = require('../config/index'); 22 | 23 | const registerRoutes = (app) => { 24 | // Health endpoints (no /api prefix, unauthenticated) 25 | app.use('/', healthRoutes); 26 | 27 | app.use('/api/auth', authRoutes); 28 | app.use('/api', uploadRoutes); 29 | app.use('/api', fileRoutes); 30 | app.use('/api', browseRoutes); 31 | app.use('/api', editorRoutes); 32 | app.use('/api', volumeRoutes); 33 | app.use('/api', usageRoutes); 34 | app.use('/api', favoritesRoutes); 35 | app.use('/api', settingsRoutes); 36 | app.use('/api', thumbnailRoutes); 37 | app.use('/api', searchRoutes); 38 | app.use('/api', usersRoutes); 39 | app.use('/api', metadataRoutes); 40 | app.use('/api', permissionsRoutes); 41 | // User volumes management (admin only, requires USER_VOLUMES feature) 42 | app.use('/api', userVolumesRoutes); 43 | // Share routes (supports guest sessions) 44 | app.use('/api/shares', sharesRoutes); 45 | app.use('/api/share', sharesRoutes); 46 | // Public features endpoint (always available) 47 | app.use('/api', featuresRoutes); 48 | // Admin-only terminal session endpoint 49 | app.use('/api', terminalRoutes); 50 | // Mount ONLYOFFICE routes only when configured 51 | if (onlyoffice && onlyoffice.serverUrl) { 52 | app.use('/api', onlyofficeRoutes); 53 | } 54 | }; 55 | 56 | module.exports = registerRoutes; 57 | -------------------------------------------------------------------------------- /backend/src/routes/usage.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const { promisify } = require('util'); 3 | const { exec } = require('child_process'); 4 | const { normalizeRelativePath } = require('../utils/pathUtils'); 5 | const { resolvePathWithAccess } = require('../services/accessManager'); 6 | const logger = require('../utils/logger'); 7 | const asyncHandler = require('../utils/asyncHandler'); 8 | const execp = promisify(exec); 9 | const router = express.Router(); 10 | 11 | // Fast directory size using du command 12 | const dirSize = async (root) => { 13 | try { 14 | // -sb: summarize in bytes, don't follow symlinks 15 | // This is orders of magnitude faster than fs.stat() recursion 16 | const { stdout } = await execp(`du -sb "${root}"`, { 17 | maxBuffer: 1024 * 1024 * 10 // 10MB buffer for large outputs 18 | }); 19 | 20 | // Output format: "12345\t/path/to/dir" 21 | const size = parseInt(stdout.split('\t')[0], 10); 22 | return size || 0; 23 | } catch (err) { 24 | logger.debug(err) 25 | return 0; 26 | } 27 | }; 28 | 29 | router.get('/usage/*', asyncHandler(async (req, res) => { 30 | const raw = req.params[0] || ''; 31 | const inputRel = normalizeRelativePath(raw); 32 | const context = { user: req.user, guestSession: req.guestSession }; 33 | 34 | const { accessInfo, resolved } = await resolvePathWithAccess(context, inputRel); 35 | 36 | if (!accessInfo || !accessInfo.canAccess || !accessInfo.canRead) { 37 | // Treat denied access the same as du failing; zero usage 38 | return res.json({ path: inputRel, size: 0, free: 0, total: 0 }); 39 | } 40 | 41 | const { absolutePath: abs, relativePath: rel } = resolved; 42 | 43 | // Run both commands in parallel for maximum speed 44 | const [size, dfResult] = await Promise.all([ 45 | dirSize(abs), 46 | execp(`df -Pk "${abs}"`).catch(() => ({ stdout: '' })) 47 | ]); 48 | 49 | let total = 0, free = 0; 50 | 51 | if (dfResult.stdout) { 52 | const line = dfResult.stdout.trim().split('\n').pop(); 53 | const parts = line.trim().split(/\s+/); 54 | const totalKb = parseInt(parts[1], 10) || 0; 55 | const availKb = parseInt(parts[3], 10) || 0; 56 | total = totalKb * 1024; 57 | free = availKb * 1024; 58 | } 59 | 60 | res.json({ path: rel, size, free, total }); 61 | })); 62 | 63 | module.exports = router; 64 | -------------------------------------------------------------------------------- /backend/src/routes/favorites.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const { 3 | getFavorites, 4 | addFavorite, 5 | removeFavorite, 6 | updateFavorite, 7 | reorderFavorites, 8 | } = require('../services/favoritesService'); 9 | const logger = require('../utils/logger'); 10 | const asyncHandler = require('../utils/asyncHandler'); 11 | const { UnauthorizedError } = require('../errors/AppError'); 12 | 13 | const router = express.Router(); 14 | 15 | const requireAuth = (req, res, next) => { 16 | const userId = req.user?.id; 17 | if (!userId) { 18 | throw new UnauthorizedError('Authentication required'); 19 | } 20 | return next(); 21 | }; 22 | 23 | router.use('/favorites', requireAuth); 24 | 25 | /** 26 | * GET /api/favorites 27 | * Get all favorites for the current user 28 | */ 29 | router.get('/favorites', asyncHandler(async (req, res) => { 30 | const favorites = await getFavorites(req.user.id); 31 | res.json(favorites); 32 | })); 33 | 34 | /** 35 | * POST /api/favorites 36 | * Add a new favorite for the current user 37 | */ 38 | router.post('/favorites', asyncHandler(async (req, res) => { 39 | const { path, label, icon, color } = req.body || {}; 40 | const favorite = await addFavorite(req.user, { path, label, icon, color }); 41 | res.json(favorite); 42 | })); 43 | 44 | /** 45 | * PATCH /api/favorites/reorder 46 | * Reorder favorites for the current user 47 | */ 48 | router.patch('/favorites/reorder', asyncHandler(async (req, res) => { 49 | const { order: orderedIds } = req.body || {}; 50 | const favorites = await reorderFavorites(req.user.id, orderedIds); 51 | res.json(favorites); 52 | })); 53 | 54 | /** 55 | * PATCH /api/favorites/:id 56 | * Update a favorite's label or icon 57 | */ 58 | router.patch('/favorites/:id', asyncHandler(async (req, res) => { 59 | const { id } = req.params; 60 | const { label, icon, color, position } = req.body || {}; 61 | 62 | const favorite = await updateFavorite(req.user.id, id, { label, icon, color, position }); 63 | res.json(favorite); 64 | })); 65 | 66 | /** 67 | * DELETE /api/favorites 68 | * Remove a favorite for the current user 69 | */ 70 | router.delete('/favorites', asyncHandler(async (req, res) => { 71 | const { path } = req.body || {}; 72 | const favorites = await removeFavorite(req.user.id, path); 73 | res.json(favorites); 74 | })); 75 | 76 | module.exports = router; 77 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM public.ecr.aws/docker/library/node:24-bookworm-slim AS base 2 | 3 | WORKDIR /app 4 | 5 | # Backend dependencies (production only) 6 | FROM base AS backend_deps 7 | ENV NODE_ENV=production 8 | WORKDIR /app 9 | COPY backend/package*.json ./ 10 | RUN npm ci --omit=dev 11 | 12 | # Frontend build (needs dev dependencies) 13 | FROM base AS frontend_build 14 | ENV NODE_ENV=development 15 | WORKDIR /app/frontend 16 | COPY frontend/package*.json ./ 17 | RUN npm ci 18 | COPY frontend/ ./ 19 | RUN npm run build -- --sourcemap false 20 | 21 | # Final runtime image 22 | FROM base AS runtime 23 | ENV NODE_ENV=production 24 | 25 | # Create the baseline app user; UID/GID may be mutated at runtime via entrypoint.sh. 26 | RUN groupadd --system appuser && \ 27 | useradd --system --gid appuser --shell /bin/bash --create-home appuser 28 | 29 | # Install runtime tooling (ffmpeg, gosu for UID remapping, ripgrep for searches, imagemagick for HEIC thumbnails). 30 | RUN apt-get update \ 31 | && apt-get install -y --no-install-recommends ffmpeg gosu ripgrep imagemagick curl unzip \ 32 | && rm -rf /var/lib/apt/lists/* 33 | 34 | WORKDIR /app 35 | 36 | # Make git metadata available at runtime for backend /api/features endpoint 37 | ARG GIT_COMMIT="" 38 | ARG GIT_BRANCH="" 39 | ARG REPO_URL="" 40 | ENV GIT_COMMIT=${GIT_COMMIT} 41 | ENV GIT_BRANCH=${GIT_BRANCH} 42 | ENV REPO_URL=${REPO_URL} 43 | 44 | # Bring in the backend source and production dependencies. 45 | COPY --from=backend_deps /app/node_modules ./node_modules 46 | COPY --from=backend_deps /app/package.json ./ 47 | COPY backend/src ./src 48 | COPY healthcheck.js ./healthcheck.js 49 | 50 | # Copy the built frontend assets only. 51 | RUN mkdir -p src/public 52 | COPY --from=frontend_build /app/frontend/dist/ ./src/public/ 53 | 54 | # Host checkouts can have restrictive umasks (e.g. 077) resulting in 0600 source files. 55 | # Ensure the runtime user can always read/traverse the app source tree. 56 | RUN chmod -R a+rX /app/src 57 | 58 | # Bootstrap entrypoint script responsible for dynamic user mapping. 59 | COPY entrypoint.sh /usr/local/bin/ 60 | RUN chmod +x /usr/local/bin/entrypoint.sh 61 | 62 | VOLUME ["/config", "/cache"] 63 | 64 | HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ 65 | CMD [ "node", "healthcheck.js" ] 66 | 67 | EXPOSE 3000 68 | ENTRYPOINT ["entrypoint.sh"] 69 | CMD ["node", "src/app.js"] 70 | -------------------------------------------------------------------------------- /backend/tests/services/settings.test.js: -------------------------------------------------------------------------------- 1 | const test = require('node:test'); 2 | const assert = require('node:assert/strict'); 3 | const { setupTestEnv } = require('../helpers/env-test-utils'); 4 | 5 | const SETTINGS_MODULES = [ 6 | 'src/services/storage/jsonStorage', 7 | 'src/services/settingsService', 8 | ]; 9 | 10 | const createSettingsContext = async () => { 11 | const envContext = await setupTestEnv({ 12 | tag: 'settings-test-', 13 | modules: SETTINGS_MODULES, 14 | }); 15 | const settingsService = envContext.requireFresh('src/services/settingsService'); 16 | return { envContext, settingsService }; 17 | }; 18 | 19 | test('settingsService returns defaults when no config exists', async () => { 20 | const { envContext, settingsService } = await createSettingsContext(); 21 | try { 22 | const settings = await settingsService.getSettings(); 23 | assert.deepEqual(settings.access.rules, []); 24 | assert.strictEqual(settings.thumbnails.enabled, true); 25 | assert.strictEqual(settings.thumbnails.size, 200); 26 | assert.strictEqual(settings.thumbnails.quality, 70); 27 | assert.strictEqual(settings.thumbnails.concurrency, undefined); 28 | } finally { 29 | await envContext.cleanup(); 30 | } 31 | }); 32 | 33 | test('setSettings sanitizes thumbnails and filters access rules', async () => { 34 | const { envContext, settingsService } = await createSettingsContext(); 35 | try { 36 | const payload = { 37 | thumbnails: { size: 5000, quality: 150, concurrency: -2 }, 38 | access: { 39 | rules: [ 40 | { path: '/Projects', permissions: 'ro', recursive: true }, 41 | { path: 'uploads', permissions: 'invalid', recursive: false }, 42 | { path: '../bad', permissions: 'hidden' }, 43 | ], 44 | }, 45 | }; 46 | 47 | const updated = await settingsService.setSettings(payload); 48 | assert.strictEqual(updated.thumbnails.size, 1024); 49 | assert.strictEqual(updated.thumbnails.quality, 100); 50 | assert.strictEqual(updated.thumbnails.concurrency, 1); 51 | assert.strictEqual(updated.thumbnails.enabled, true); 52 | assert.strictEqual(updated.access.rules.length, 2); 53 | assert.strictEqual(updated.access.rules[0].path, 'Projects'); 54 | assert.strictEqual(updated.access.rules[1].permissions, 'rw'); 55 | } finally { 56 | await envContext.cleanup(); 57 | } 58 | }); 59 | -------------------------------------------------------------------------------- /frontend/src/components/PhotoSizeControl.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 61 | -------------------------------------------------------------------------------- /frontend/src/plugins/onlyoffice/OnlyOfficePreview.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 63 | 64 | 70 | -------------------------------------------------------------------------------- /docs/admin/user-volumes.md: -------------------------------------------------------------------------------- 1 | # User volumes (per-user volume assignments) 2 | 3 | When `USER_VOLUMES=true`, nextExplorer stops showing *all* mounted volumes to everyone. Admins still see all volumes under `VOLUME_ROOT`, but regular users only see volumes explicitly assigned to them by an admin. 4 | 5 | ## Enable the feature 6 | 7 | Set the environment variable and restart the container: 8 | 9 | ```bash 10 | USER_VOLUMES=true 11 | ``` 12 | 13 | ## What changes when enabled 14 | 15 | - **Admins**: continue to see all volumes mounted under `VOLUME_ROOT` (default `/mnt`). 16 | - **Non-admin users**: the root volume list is filtered down to only the volumes assigned to their user profile. 17 | - **No assignments = no volumes**: if a user has no assigned volumes, they’ll see an empty volume list. 18 | 19 | ## Assign volumes to a user (admin) 20 | 21 | 1. Go to **Settings → Admin → Users**. 22 | 2. Click a user (or create a new one). 23 | 3. Open the **Volumes** tab. 24 | 4. Click **Add volume**, browse to a directory, choose an access mode, and save. 25 | 26 | ![Add volume dialog](/images/user-volumes-2.png) 27 | 28 | ![User profile volumes tab](/images/user-volumes-1.png) 29 | 30 | ### Volume fields 31 | 32 | - **Label**: the name the user sees in the sidebar (must be unique per user). 33 | - **Directory**: an existing directory path on the server/container (must be readable by the container user). 34 | - **Access mode**: 35 | - `readwrite`: user can upload, create folders, rename, move, and delete. 36 | - `readonly`: user can browse and download, but cannot modify content. 37 | 38 | 39 | ## Notes & interactions 40 | 41 | - **Directory picker**: the admin directory browser starts at `VOLUME_ROOT`, hides dot-directories, and excludes reserved names like `_users`. 42 | - **Access Control still applies**: if Access Control marks a path `ro` or `hidden`, that restriction also applies on top of the user volume assignment. 43 | - **Persistence**: assignments are stored in the SQLite DB under `CONFIG_DIR` (`/config` by default). 44 | 45 | ## Troubleshooting 46 | 47 | - **Volumes tab missing**: confirm `USER_VOLUMES=true` and refresh the app (feature flags are loaded from `/api/features`). 48 | - **User can’t see a volume**: verify the user has an assigned volume, and that the chosen label matches what they’re navigating to. 49 | - **“Path does not exist or is not accessible”**: the server process inside the container can’t `stat()` the directory; fix mount/permissions and try again. 50 | -------------------------------------------------------------------------------- /frontend/src/components/NotificationToast.vue: -------------------------------------------------------------------------------- 1 | 39 | 40 | 78 | -------------------------------------------------------------------------------- /entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | # Provide sensible defaults to avoid surprises on first run. 5 | PUID=${PUID:-1000} 6 | PGID=${PGID:-1000} 7 | 8 | CONFIG_DIR=${CONFIG_DIR:-/config} 9 | CACHE_DIR=${CACHE_DIR:-/cache} 10 | 11 | ensure_dir() { 12 | mkdir -p "$1" 13 | } 14 | 15 | # Ensure the base appuser exists before attempting modifications. 16 | if ! id appuser >/dev/null 2>&1; then 17 | echo "ERROR: Expected user 'appuser' to be present in the image." 18 | exit 1 19 | fi 20 | 21 | CURRENT_UID=$(id -u appuser) 22 | CURRENT_GID=$(id -g appuser) 23 | 24 | # Update user/group IDs only when they differ from the requested values. 25 | if [ "$CURRENT_UID" != "$PUID" ] || [ "$CURRENT_GID" != "$PGID" ]; then 26 | echo "INFO: Updating appuser UID:GID from ${CURRENT_UID}:${CURRENT_GID} to ${PUID}:${PGID}" 27 | groupmod -o -g "$PGID" appuser 28 | usermod -o -u "$PUID" appuser 29 | fi 30 | 31 | # Guarantee every host-facing directory exists before touching it. 32 | ensure_dir "/app" 33 | ensure_dir "$CONFIG_DIR" 34 | ensure_dir "$CACHE_DIR" 35 | ensure_dir "${CACHE_DIR}/thumbnails" 36 | ensure_dir "${CONFIG_DIR}/extensions" 37 | ensure_dir "${CONFIG_DIR}/extensions/icons" 38 | ensure_dir "${CONFIG_DIR}/extensions/brand" 39 | 40 | # Fix ownership on key directories that map to host volumes. 41 | for path in "$CONFIG_DIR" "$CACHE_DIR"; do 42 | if [ -e "$path" ]; then 43 | chown -R appuser:appuser "$path" 44 | fi 45 | done 46 | 47 | is_true() { 48 | case "${1:-}" in 49 | 1|true|TRUE|yes|YES|on|ON) return 0 ;; 50 | *) return 1 ;; 51 | esac 52 | } 53 | 54 | DEMO_MODE="${DEMO_MODE:-false}" 55 | SAMPLE_URL="${SAMPLE_URL:-https://github.com/vikramsoni2/nextExplorer/releases/download/v2.0.0/samples.zip}" 56 | SAMPLES_DIR="${SAMPLES_DIR:-/mnt/Samples}" 57 | 58 | 59 | if is_true "$DEMO_MODE"; then 60 | echo "INFO: DEMO_MODE enabled; seeding demo samples into ${SAMPLES_DIR} (read-only)" 61 | mkdir -p "$SAMPLES_DIR" 62 | 63 | if command -v npm >/dev/null 2>&1; then 64 | if ! SAMPLE_URL="$SAMPLE_URL" SAMPLES_DIR="$SAMPLES_DIR" npm run --silent download_samples; then 65 | echo "WARN: DEMO_MODE sample download failed; continuing without seeded samples" 66 | fi 67 | else 68 | if ! SAMPLE_URL="$SAMPLE_URL" SAMPLES_DIR="$SAMPLES_DIR" node /app/src/scripts/downloadSamples.js; then 69 | echo "WARN: DEMO_MODE sample download failed; continuing without seeded samples" 70 | fi 71 | fi 72 | fi 73 | 74 | echo "INFO: Launching process as appuser (${PUID}:${PGID})" 75 | exec gosu appuser "$@" 76 | -------------------------------------------------------------------------------- /frontend/src/config/media.js: -------------------------------------------------------------------------------- 1 | const DEFAULT_IMAGE_PREVIEW_EXTENSIONS = [ 2 | 'jpg', 3 | 'jpeg', 4 | 'png', 5 | 'gif', 6 | 'webp', 7 | 'bmp', 8 | 'svg', 9 | 'ico', 10 | 'tif', 11 | 'tiff', 12 | 'avif', 13 | 'heic', 14 | ]; 15 | 16 | const envImageExtensions = (import.meta.env.VITE_IMAGE_PREVIEW_EXTENSIONS || '') 17 | .split(',') 18 | .map((value) => value.trim().toLowerCase()) 19 | .filter(Boolean); 20 | 21 | const imagePreviewExtensionsSet = new Set([ 22 | ...DEFAULT_IMAGE_PREVIEW_EXTENSIONS, 23 | ...envImageExtensions, 24 | ]); 25 | 26 | const DEFAULT_VIDEO_PREVIEW_EXTENSIONS = [ 27 | 'mp4', 28 | 'mov', 29 | 'mkv', 30 | 'webm', 31 | 'm4v', 32 | 'avi', 33 | 'wmv', 34 | 'flv', 35 | 'mpg', 36 | 'mpeg', 37 | ]; 38 | 39 | const envVideoExtensions = (import.meta.env.VITE_VIDEO_PREVIEW_EXTENSIONS || '') 40 | .split(',') 41 | .map((value) => value.trim().toLowerCase()) 42 | .filter(Boolean); 43 | 44 | const videoPreviewExtensionsSet = new Set([ 45 | ...DEFAULT_VIDEO_PREVIEW_EXTENSIONS, 46 | ...envVideoExtensions, 47 | ]); 48 | 49 | const DEFAULT_AUDIO_PREVIEW_EXTENSIONS = [ 50 | 'mp3', 51 | 'wav', 52 | 'flac', 53 | 'aac', 54 | 'm4a', 55 | 'ogg', 56 | 'opus', 57 | 'wma', 58 | ]; 59 | 60 | const envAudioExtensions = (import.meta.env.VITE_AUDIO_PREVIEW_EXTENSIONS || '') 61 | .split(',') 62 | .map((value) => value.trim().toLowerCase()) 63 | .filter(Boolean); 64 | 65 | const audioPreviewExtensionsSet = new Set([ 66 | ...DEFAULT_AUDIO_PREVIEW_EXTENSIONS, 67 | ...envAudioExtensions, 68 | ]); 69 | 70 | const getImagePreviewExtensions = () => Array.from(imagePreviewExtensionsSet.values()); 71 | 72 | const isPreviewableImage = (extension = '') => { 73 | if (!extension) return false; 74 | return imagePreviewExtensionsSet.has(extension.toLowerCase()); 75 | }; 76 | 77 | const getVideoPreviewExtensions = () => Array.from(videoPreviewExtensionsSet.values()); 78 | 79 | const isPreviewableVideo = (extension = '') => { 80 | if (!extension) return false; 81 | return videoPreviewExtensionsSet.has(extension.toLowerCase()); 82 | }; 83 | 84 | const getAudioPreviewExtensions = () => Array.from(audioPreviewExtensionsSet.values()); 85 | 86 | const isPreviewableAudio = (extension = '') => { 87 | if (!extension) return false; 88 | return audioPreviewExtensionsSet.has(extension.toLowerCase()); 89 | }; 90 | 91 | export { 92 | getImagePreviewExtensions, 93 | isPreviewableImage, 94 | getVideoPreviewExtensions, 95 | isPreviewableVideo, 96 | getAudioPreviewExtensions, 97 | isPreviewableAudio, 98 | }; 99 | -------------------------------------------------------------------------------- /docker-compose.dev.yml: -------------------------------------------------------------------------------- 1 | services: 2 | backend: 3 | build: 4 | context: ./backend 5 | dockerfile: Dockerfile 6 | volumes: 7 | - ./backend:/app 8 | - backend_node_modules:/app/node_modules 9 | - /Users/vikram/projects:/mnt/Projects 10 | - /Users/vikram/Downloads:/mnt/Downloads 11 | - /Users/vikram/Pictures:/mnt/Pictures 12 | - /Users/vikram/Downloads/config:/config 13 | - /Users/vikram/Downloads/cache:/cache 14 | - /Users/vikram/Downloads/user_vols:/srv/users 15 | # user: "${UID:-1000}:${GID:-1000}" 16 | environment: 17 | - PORT=3001 18 | - VOLUME_ROOT=/mnt 19 | - CONFIG_DIR=/config 20 | - LOG_LEVEL=debug 21 | - CACHE_DIR=/cache 22 | - PUBLIC_URL=https://files.vsoni.com 23 | - SESSION_SECRET=dev-change-me 24 | 25 | - OIDC_ENABLED=true 26 | - OIDC_ISSUER=https://auth.vsoni.com/application/o/next/ 27 | - OIDC_CLIENT_ID=6HBh3lMneUwWCAeutdEgwNZ6myXzFc93clojdfmQ 28 | - OIDC_CLIENT_SECRET=w2ACQfUhhk2vZ14m2GVvYbWqkCQTiXyMsowpLQE2JtiPfaCTrBKhZHwZACgHOCWgSs9zhJW0j8y1TYCrLs0hsMmM0xdIz5tRmce6o4ymVOCQByPRIr9xMxtBt1Spg0I1 29 | - OIDC_SCOPES=openid,profile,email,groups 30 | - OIDC_ADMIN_GROUPS=next-admin,admins 31 | 32 | - ONLYOFFICE_URL=https://office.vsoni.com 33 | - ONLYOFFICE_SECRET=ha96M546Hl5oSSvrjvabmXK1D6qlRH15 34 | - ONLYOFFICE_LANG=en 35 | 36 | - USER_DIR_ENABLED=true 37 | - USER_ROOT=/srv/users 38 | 39 | # - OIDC_TOKEN_URL=https://auth.vsoni.com/application/o/token/ 40 | # - OIDC_USERINFO_URL=https://auth.vsoni.com/application/o/userinfo/ 41 | # - OIDC_AUTHORIZATION_URL=https://auth.vsoni.com/application/o/authorize/ 42 | # - OIDC_ISSUER=http://192.168.1.2:8087/realms/next 43 | # - OIDC_CLIENT_ID=next-app 44 | # - OIDC_CLIENT_SECRET=PpIuYcL8NIshpjDWl3w2H6bwDIhyTjJ4 45 | # - OIDC_SCOPES=openid profile email 46 | # - OIDC_ADMIN_GROUPS=next-admin admins 47 | 48 | frontend: 49 | build: 50 | context: ./frontend 51 | dockerfile: Dockerfile 52 | ports: 53 | - "3000:3000" 54 | environment: 55 | # Vite proxies API requests to the backend container 56 | - VITE_BACKEND_ORIGIN=http://backend:3001 57 | # Often needed on macOS/Windows for file change detection: 58 | - CHOKIDAR_USEPOLLING=1 59 | depends_on: 60 | - backend 61 | volumes: 62 | - ./frontend:/app 63 | - frontend_node_modules:/app/node_modules 64 | # user: "${UID:-1000}:${GID:-1000}" 65 | 66 | volumes: 67 | backend_node_modules: 68 | frontend_node_modules: 69 | -------------------------------------------------------------------------------- /frontend/src/composables/itemSelection.js: -------------------------------------------------------------------------------- 1 | import { normalizePath } from '@/api'; 2 | import { useFileStore } from '@/stores/fileStore'; 3 | 4 | const getItemKey = (item) => { 5 | if (!item || !item.name) return ''; 6 | const parent = normalizePath(item.path || ''); 7 | return `${parent}::${item.name}`; 8 | }; 9 | 10 | export function useSelection() { 11 | const fileStore = useFileStore(); 12 | 13 | const findInCurrentItems = (item) => { 14 | const key = getItemKey(item); 15 | return fileStore.getCurrentPathItems.find((candidate) => getItemKey(candidate) === key) || item; 16 | }; 17 | 18 | const isSelected = (item) => fileStore.selectedItems 19 | .some((selected) => getItemKey(selected) === getItemKey(item)); 20 | 21 | const toggleSelection = (item) => { 22 | const key = getItemKey(item); 23 | const index = fileStore.selectedItems.findIndex((selected) => getItemKey(selected) === key); 24 | 25 | if (index === -1) { 26 | const resolved = findInCurrentItems(item); 27 | fileStore.selectedItems = [...fileStore.selectedItems, resolved]; 28 | } else { 29 | const nextSelection = [...fileStore.selectedItems]; 30 | nextSelection.splice(index, 1); 31 | fileStore.selectedItems = nextSelection; 32 | } 33 | }; 34 | 35 | const selectRange = (item) => { 36 | const currentItems = fileStore.getCurrentPathItems; 37 | const targetKey = getItemKey(item); 38 | const endIndex = currentItems.findIndex((entry) => getItemKey(entry) === targetKey); 39 | 40 | if (endIndex === -1) { 41 | return; 42 | } 43 | 44 | const anchor = fileStore.selectedItems[fileStore.selectedItems.length - 1] || item; 45 | const anchorKey = getItemKey(anchor); 46 | const startIndex = currentItems.findIndex((entry) => getItemKey(entry) === anchorKey); 47 | 48 | if (startIndex === -1) { 49 | fileStore.selectedItems = [currentItems[endIndex]]; 50 | return; 51 | } 52 | 53 | const [start, end] = startIndex < endIndex ? [startIndex, endIndex] : [endIndex, startIndex]; 54 | fileStore.selectedItems = currentItems.slice(start, end + 1); 55 | }; 56 | 57 | const clearSelection = () => { 58 | fileStore.selectedItems = []; 59 | }; 60 | 61 | const handleSelection = (item, event) => { 62 | if (event?.ctrlKey || event?.metaKey) { 63 | toggleSelection(item); 64 | } else if (event?.shiftKey && fileStore.selectedItems.length > 0) { 65 | selectRange(item); 66 | } else { 67 | clearSelection(); 68 | toggleSelection(item); 69 | } 70 | }; 71 | 72 | return { 73 | isSelected, 74 | handleSelection, 75 | clearSelection, 76 | }; 77 | } 78 | -------------------------------------------------------------------------------- /docs/installation/reverse-proxy.md: -------------------------------------------------------------------------------- 1 | # Reverse Proxy & Networking 2 | 3 | When exposing nextExplorer on a custom domain, a reverse proxy keeps the UI secure behind TLS while forwarding requests to the container. Use this guide to align `PUBLIC_URL`, trusted proxies, and CORS behavior. 4 | 5 | ## Key environment variables 6 | 7 | | Variable | Purpose | 8 | | --- | --- | 9 | | `PUBLIC_URL` | External URL (no trailing slash) used to set cookies, determine OIDC callbacks, and drive CORS defaults. Example: `https://files.example.com`. | 10 | | `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.) | 11 | | `CORS_ORIGIN(S)` / `ALLOWED_ORIGINS` | Explicit CORS origins when they differ from `PUBLIC_URL`. Defaults to the origin of `PUBLIC_URL` when provided. | 12 | 13 | ## Sample Nginx Proxy Manager block 14 | 15 | - Point `files.example.com` to the container’s internal `3000` port. 16 | - Enable WebSockets and preserve `X-Forwarded-*` headers (usually automatic). 17 | - Terminate TLS at the proxy; nextExplorer marks cookies as `Secure` whenever `PUBLIC_URL` uses `https`. 18 | 19 | ## Trusted Proxy notes 20 | 21 | - Default when `PUBLIC_URL` is set: `loopback,uniquelocal`, which trusts local or private Docker networks without opening up to the public internet. 22 | - Override with values such as `1` (trust one hop) or CIDRs (`10.0.0.0/8,172.16.0.0/12`). 23 | - Avoid `TRUST_PROXY=true` alone; the entrypoint maps it to `loopback,uniquelocal` for safety. 24 | 25 | ## CORS & headers 26 | 27 | - Set `CORS_ORIGINS`/`ALLOWED_ORIGINS` when the app is accessed from multiple domains. 28 | - 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. 29 | 30 | ## Networking health checklist 31 | 32 | - Proxy has TLS termination and forwards headers. Without headers, session cookies may appear as `Insecure`. 33 | - POST, PUT, DELETE operations work through the proxy; test with uploads and metadata edits. 34 | - If using OIDC, verify the IdP’s redirect URI matches `${PUBLIC_URL}/callback` or your manually supplied `OIDC_CALLBACK_URL`. 35 | 36 | ## Troubleshooting proxies 37 | 38 | | Symptom | Fix | 39 | | --- | --- | 40 | | CORS errors | Add the proxy domain to `CORS_ORIGINS` or set `PUBLIC_URL`. | 41 | | Sessions drop | Confirm `TRUST_PROXY` lets Express read `X-Forwarded-Proto` and `COOKIE` is not stripped. | 42 | | Redirect URI mismatch (OIDC) | Ensure the IdP redirect equals `${PUBLIC_URL}/callback` or the configured `OIDC_CALLBACK_URL`. | 43 | -------------------------------------------------------------------------------- /frontend/src/components/LanguageSelector.vue: -------------------------------------------------------------------------------- 1 | 44 | 45 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "explorer", 3 | "version": "1.1.9", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "preview": "vite preview", 10 | "test:unit": "vitest", 11 | "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore" 12 | }, 13 | "dependencies": { 14 | "@codemirror/lang-javascript": "^6.2.2", 15 | "@codemirror/language-data": "^6.3.2", 16 | "@codemirror/theme-one-dark": "^6.1.2", 17 | "@coleqiu/vue-drag-select": "^2.0.6-beta.1", 18 | "@floating-ui/vue": "^1.1.9", 19 | "@fsegurai/codemirror-theme-bundle": "^6.3.0", 20 | "@fsegurai/codemirror-theme-github-dark": "^6.2.2", 21 | "@headlessui/vue": "^1.7.22", 22 | "@heroicons/vue": "^2.1.3", 23 | "@onlyoffice/document-editor-vue": "^1.6.1", 24 | "@tailwindcss/vite": "^4.1.17", 25 | "@uppy/core": "^3.11.3", 26 | "@uppy/drag-drop": "^3.1.0", 27 | "@uppy/drop-target": "^2.1.0", 28 | "@uppy/progress-bar": "^3.1.1", 29 | "@uppy/status-bar": "^3.3.3", 30 | "@uppy/xhr-upload": "^3.6.6", 31 | "@vueuse/components": "^10.9.0", 32 | "@vueuse/core": "^10.9.0", 33 | "@xterm/addon-fit": "^0.10.0", 34 | "@xterm/xterm": "^5.5.0", 35 | "axios": "^1.6.8", 36 | "browser-fs-access": "^0.35.0", 37 | "codemirror": "^6.0.1", 38 | "dayjs": "^1.11.11", 39 | "dompurify": "^3.0.8", 40 | "dropzone": "^6.0.0-beta.2", 41 | "flatpickr": "^4.6.13", 42 | "marked": "^12.0.2", 43 | "nanoid": "^5.0.7", 44 | "pinia": "^2.1.7", 45 | "tippy.js": "^6.3.7", 46 | "vue": "^3.4.21", 47 | "vue-codemirror": "^6.1.1", 48 | "vue-easy-lightbox": "^1.19.0", 49 | "vue-i18n": "^9.14.0", 50 | "vue-router": "^4.3.0", 51 | "vuedraggable": "^4.1.0" 52 | }, 53 | "devDependencies": { 54 | "@tailwindcss/typography": "^0.5.19", 55 | "@vicons/antd": "^0.12.0", 56 | "@vicons/carbon": "^0.12.0", 57 | "@vicons/fluent": "^0.12.0", 58 | "@vicons/ionicons4": "^0.12.0", 59 | "@vicons/ionicons5": "^0.12.0", 60 | "@vicons/material": "^0.12.0", 61 | "@vicons/tabler": "^0.12.0", 62 | "@vicons/utils": "^0.1.4", 63 | "@vitejs/plugin-vue": "^5.0.4", 64 | "@vue/test-utils": "^2.4.5", 65 | "eslint": "^8.57.0", 66 | "eslint-plugin-vue": "^9.23.0", 67 | "jsdom": "^24.0.0", 68 | "postcss": "^8.4.38", 69 | "sass": "^1.77.2", 70 | "tailwindcss": "^4.1.17", 71 | "vite": "^5.2.8", 72 | "vite-plugin-vue-devtools": "^7.0.25", 73 | "vitest": "^1.4.0" 74 | }, 75 | "packageManager": "pnpm@10.22.0+sha512.bf049efe995b28f527fd2b41ae0474ce29186f7edcb3bf545087bd61fbbebb2bf75362d1307fda09c2d288e1e499787ac12d4fcb617a974718a6051f2eee741c" 76 | } 77 | -------------------------------------------------------------------------------- /docs/reference/troubleshooting.md: -------------------------------------------------------------------------------- 1 | # Troubleshooting 2 | 3 | Keep this page handy when deployment, authentication, or UI behaviors need quick fixes. 4 | 5 | ## Authentication & sessions 6 | 7 | - **OIDC redirect errors:** Make sure your identity provider uses `${PUBLIC_URL}/callback` (or `OIDC_CALLBACK_URL`) as the redirect URI. 8 | - **Session resets after restart:** Set `SESSION_SECRET` so the app doesn’t regenerate a new secret each start. 9 | - **Users not admin:** Confirm that the user’s `groups`, `roles`, or `entitlements` include a value listed in `OIDC_ADMIN_GROUPS` (case-insensitive). 10 | - **Cookies marked insecure behind HTTPS:** Ensure `PUBLIC_URL` uses `https` and your proxy forwards `X-Forwarded-Proto` and `Host`. 11 | 12 | ## Access & permissions 13 | 14 | - **Path marked read-only or hidden:** Check Settings → Access Control for matching rules; `hidden` and `ro` rules block writes even if user has permission. 15 | - **Missing volume entries:** Confirm your `docker-compose` mounts include `/mnt/Label` entries and the container has read access. 16 | - **Path not found after remounting:** Restart the container whenever you change `docker-compose.yml` mounts so the app rescans volumes. 17 | 18 | ## Search & thumbnails 19 | 20 | - **Slow or missing search results:** Install or enable ripgrep. The official image bundles `rg`; custom builds need either the tool or fallback search (which may skip large files controlled by `SEARCH_MAX_FILESIZE`). 21 | - **Thumbnails not generating:** Verify FFmpeg/ffprobe are available (paths override via `FFMPEG_PATH`/`FFPROBE_PATH`) and that `/cache` is writable. 22 | - **Cache rebuild:** Clearing `/cache` removes thumbnails/indexes but keeps user data. The app regenerates thumbnails when you revisit folders. 23 | 24 | ## Reverse proxy issues 25 | 26 | - **CORS errors:** Set `PUBLIC_URL`, `CORS_ORIGINS`, or `ALLOWED_ORIGINS` to include the domain you access from. 27 | - **Websocket or upload failures:** Ensure the proxy forwards WebSocket upgrades and `X-Forwarded-*` headers. 28 | - **Trust proxy misconfiguration:** Set `TRUST_PROXY` to `loopback,uniquelocal`, a number of hops, or explicit CIDRs depending on your topology. 29 | 30 | ## Updates & persistence 31 | 32 | - **Settings lost after update:** Mount `/config` persistently; it contains `app.db`, `app-config.json`, and extensions. Back it up before upgrading. 33 | - **`/cache` filling disk:** `/cache` holds thumbnails and indexes; delete it if you need to reclaim space (the app rebuilds contents as needed). 34 | 35 | ## ONLYOFFICE token errors 36 | 37 | - “Document security token is not correctly configured” typically means the Document Server and nextExplorer share mismatched `ONLYOFFICE_SECRET`. Double-check the secret stored in `/etc/onlyoffice/documentserver/local.json` and update both sides to match. 38 | -------------------------------------------------------------------------------- /frontend/src/api/users.api.js: -------------------------------------------------------------------------------- 1 | // /api/users.api.js 2 | 3 | import { requestJson } from './http'; 4 | 5 | export async function fetchUsers() { 6 | return requestJson('/api/users', { method: 'GET' }); 7 | } 8 | 9 | export async function fetchShareableUsers() { 10 | return requestJson('/api/users/shareable', { method: 'GET' }); 11 | } 12 | 13 | export async function updateUserRoles(userId, roles) { 14 | return requestJson(`/api/users/${encodeURIComponent(userId)}`, { 15 | method: 'PATCH', 16 | body: JSON.stringify({ roles: Array.isArray(roles) ? roles : [] }), 17 | }); 18 | } 19 | 20 | export async function updateUser(userId, data) { 21 | return requestJson(`/api/users/${encodeURIComponent(userId)}`, { 22 | method: 'PATCH', 23 | body: JSON.stringify(data || {}), 24 | }); 25 | } 26 | 27 | export async function createUser({ email, username, password, displayName, roles = [] }) { 28 | return requestJson('/api/users', { 29 | method: 'POST', 30 | body: JSON.stringify({ email, username, password, displayName, roles: Array.isArray(roles) ? roles : [] }), 31 | }); 32 | } 33 | 34 | export async function adminSetUserPassword(userId, newPassword) { 35 | return requestJson(`/api/users/${encodeURIComponent(userId)}/password`, { 36 | method: 'POST', 37 | body: JSON.stringify({ newPassword }), 38 | }); 39 | } 40 | 41 | export async function deleteUser(userId) { 42 | return requestJson(`/api/users/${encodeURIComponent(userId)}`, { 43 | method: 'DELETE', 44 | }); 45 | } 46 | 47 | // User Volumes API (admin only, requires USER_VOLUMES feature) 48 | 49 | export async function fetchUserVolumes(userId) { 50 | return requestJson(`/api/users/${encodeURIComponent(userId)}/volumes`, { 51 | method: 'GET', 52 | }); 53 | } 54 | 55 | export async function addUserVolume(userId, { label, path, accessMode = 'readwrite' }) { 56 | return requestJson(`/api/users/${encodeURIComponent(userId)}/volumes`, { 57 | method: 'POST', 58 | body: JSON.stringify({ label, path, accessMode }), 59 | }); 60 | } 61 | 62 | export async function updateUserVolume(userId, volumeId, { label, accessMode }) { 63 | return requestJson(`/api/users/${encodeURIComponent(userId)}/volumes/${encodeURIComponent(volumeId)}`, { 64 | method: 'PATCH', 65 | body: JSON.stringify({ label, accessMode }), 66 | }); 67 | } 68 | 69 | export async function removeUserVolume(userId, volumeId) { 70 | return requestJson(`/api/users/${encodeURIComponent(userId)}/volumes/${encodeURIComponent(volumeId)}`, { 71 | method: 'DELETE', 72 | }); 73 | } 74 | 75 | export async function browseAdminDirectories(dirPath = '') { 76 | const params = dirPath ? `?path=${encodeURIComponent(dirPath)}` : ''; 77 | return requestJson(`/api/admin/browse-directories${params}`, { 78 | method: 'GET', 79 | }); 80 | } 81 | -------------------------------------------------------------------------------- /backend/tests/helpers/env-test-utils.js: -------------------------------------------------------------------------------- 1 | const fs = require('node:fs/promises'); 2 | const os = require('node:os'); 3 | const path = require('node:path'); 4 | 5 | const REPO_ROOT = path.join(__dirname, '..', '..'); 6 | const DEFAULT_MODULES = ['src/config/env', 'src/config/index']; 7 | 8 | const overrideEnv = (values) => { 9 | const previous = {}; 10 | Object.entries(values).forEach(([key, value]) => { 11 | previous[key] = process.env[key]; 12 | if (value === undefined) { 13 | delete process.env[key]; 14 | } else { 15 | process.env[key] = value; 16 | } 17 | }); 18 | return () => { 19 | Object.entries(previous).forEach(([key, value]) => { 20 | if (value === undefined) { 21 | delete process.env[key]; 22 | } else { 23 | process.env[key] = value; 24 | } 25 | }); 26 | }; 27 | }; 28 | 29 | const modulePath = (relative) => path.join(REPO_ROOT, relative); 30 | 31 | const clearModuleCache = (moduleSource) => { 32 | try { 33 | const resolved = require.resolve(modulePath(moduleSource)); 34 | delete require.cache[resolved]; 35 | } catch (error) { 36 | if (error.code !== 'MODULE_NOT_FOUND') { 37 | throw error; 38 | } 39 | } 40 | }; 41 | 42 | const createTempDirs = async (tag = 'backend-tests-') => { 43 | const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), tag)); 44 | const configDir = path.join(tmpRoot, 'config'); 45 | const cacheDir = path.join(tmpRoot, 'cache'); 46 | const volumeDir = path.join(tmpRoot, 'volume'); 47 | await Promise.all([ 48 | fs.mkdir(configDir, { recursive: true }), 49 | fs.mkdir(cacheDir, { recursive: true }), 50 | fs.mkdir(volumeDir, { recursive: true }), 51 | ]); 52 | return { tmpRoot, configDir, cacheDir, volumeDir }; 53 | }; 54 | 55 | const setupTestEnv = async ({ tag, modules = [], env = {} } = {}) => { 56 | const dirs = await createTempDirs(tag); 57 | const envOverrides = { 58 | CONFIG_DIR: dirs.configDir, 59 | CACHE_DIR: dirs.cacheDir, 60 | VOLUME_ROOT: dirs.volumeDir, 61 | SESSION_SECRET: 'test-secret', 62 | ...env, 63 | }; 64 | const restoreEnv = overrideEnv(envOverrides); 65 | 66 | const modulesToClear = Array.from(new Set([...DEFAULT_MODULES, ...modules])); 67 | const clearAll = () => modulesToClear.forEach(clearModuleCache); 68 | clearAll(); 69 | 70 | return { 71 | ...dirs, 72 | envOverrides, 73 | cleanup: async () => { 74 | restoreEnv(); 75 | clearAll(); 76 | await fs.rm(dirs.tmpRoot, { recursive: true, force: true }); 77 | }, 78 | requireFresh: (moduleSource) => { 79 | clearModuleCache(moduleSource); 80 | return require(modulePath(moduleSource)); 81 | }, 82 | }; 83 | }; 84 | 85 | module.exports = { 86 | overrideEnv, 87 | clearModuleCache, 88 | setupTestEnv, 89 | }; 90 | -------------------------------------------------------------------------------- /frontend/src/composables/useFavoriteEditor.js: -------------------------------------------------------------------------------- 1 | import { ref } from 'vue'; 2 | import { useFavoritesStore } from '@/stores/favorites'; 3 | 4 | // Singleton instance so multiple callers share the same modal state 5 | let instance = null; 6 | 7 | export function useFavoriteEditor() { 8 | if (instance) return instance; 9 | 10 | const favoritesStore = useFavoritesStore(); 11 | 12 | const isFavoriteEditorOpen = ref(false); 13 | const currentFavorite = ref(null); 14 | const editorName = ref(''); 15 | const editorPath = ref(''); 16 | const editorIcon = ref(''); 17 | const editorIconVariant = ref('outline-solid'); 18 | const editorColor = ref(null); 19 | const isSaving = ref(false); 20 | 21 | const openEditorForFavorite = (favorite) => { 22 | if (!favorite || !favorite.path) return; 23 | currentFavorite.value = favorite; 24 | editorPath.value = favorite.path || ''; 25 | const autoName = favorite.path.split('/').pop() || favorite.path; 26 | editorName.value = favorite.label || autoName; 27 | 28 | // Parse icon to extract variant and icon name 29 | const iconStr = favorite.icon || 'outline:StarIcon'; 30 | if (iconStr.includes(':')) { 31 | const [variant, iconName] = iconStr.split(':', 2); 32 | editorIconVariant.value = variant.toLowerCase(); 33 | editorIcon.value = iconName.trim(); 34 | } else { 35 | editorIconVariant.value = 'outline-solid'; 36 | editorIcon.value = iconStr; 37 | } 38 | 39 | editorColor.value = favorite.color || null; 40 | isFavoriteEditorOpen.value = true; 41 | }; 42 | 43 | const closeFavoriteEditor = () => { 44 | isFavoriteEditorOpen.value = false; 45 | currentFavorite.value = null; 46 | }; 47 | 48 | const saveFavoriteEditor = async () => { 49 | if (!currentFavorite.value || !currentFavorite.value.id || isSaving.value) { 50 | closeFavoriteEditor(); 51 | return; 52 | } 53 | 54 | isSaving.value = true; 55 | try { 56 | // Combine variant and icon name 57 | const iconValue = `${editorIconVariant.value}:${editorIcon.value}`; 58 | 59 | await favoritesStore.updateFavorite(currentFavorite.value.id, { 60 | label: editorName.value, 61 | icon: iconValue, 62 | color: editorColor.value, 63 | }); 64 | } catch (err) { 65 | console.error('Failed to update favorite', err); 66 | } finally { 67 | isSaving.value = false; 68 | closeFavoriteEditor(); 69 | } 70 | }; 71 | 72 | instance = { 73 | isFavoriteEditorOpen, 74 | currentFavorite, 75 | editorName, 76 | editorPath, 77 | editorIcon, 78 | editorIconVariant, 79 | editorColor, 80 | isSaving, 81 | openEditorForFavorite, 82 | closeFavoriteEditor, 83 | saveFavoriteEditor, 84 | }; 85 | 86 | return instance; 87 | } 88 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | tmp/ 19 | frontend/test-results/ 20 | 21 | # Directory for instrumented libs generated by jscoverage/JSCover 22 | lib-cov 23 | 24 | # Coverage directory used by tools like istanbul 25 | coverage 26 | *.lcov 27 | 28 | # nyc test coverage 29 | .nyc_output 30 | 31 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 32 | .grunt 33 | 34 | # Bower dependency directory (https://bower.io/) 35 | bower_components 36 | 37 | # node-waf configuration 38 | .lock-wscript 39 | 40 | # Compiled binary addons (https://nodejs.org/api/addons.html) 41 | build/Release 42 | 43 | # Dependency directories 44 | node_modules/ 45 | jspm_packages/ 46 | 47 | # Snowpack dependency directory (https://snowpack.dev/) 48 | web_modules/ 49 | 50 | # TypeScript cache 51 | *.tsbuildinfo 52 | 53 | # Optional npm cache directory 54 | .npm 55 | 56 | # Optional eslint cache 57 | .eslintcache 58 | 59 | # Optional stylelint cache 60 | .stylelintcache 61 | 62 | # Microbundle cache 63 | .rpt2_cache/ 64 | .rts2_cache_cjs/ 65 | .rts2_cache_es/ 66 | .rts2_cache_umd/ 67 | 68 | # Optional REPL history 69 | .node_repl_history 70 | 71 | # Output of 'npm pack' 72 | *.tgz 73 | 74 | # Yarn Integrity file 75 | .yarn-integrity 76 | 77 | # dotenv environment variable files 78 | .env 79 | .env.development.local 80 | .env.test.local 81 | .env.production.local 82 | .env.local 83 | 84 | # parcel-bundler cache (https://parceljs.org/) 85 | .cache 86 | .parcel-cache 87 | 88 | # Next.js build output 89 | .next 90 | out 91 | 92 | # Nuxt.js build / generate output 93 | .nuxt 94 | dist 95 | 96 | # Gatsby files 97 | .cache/ 98 | # Comment in the public line in if your project uses Gatsby and not Next.js 99 | # https://nextjs.org/blog/next-9-1#public-directory-support 100 | # public 101 | 102 | # vuepress build output 103 | .vuepress/dist 104 | 105 | # vuepress v2.x temp and cache directory 106 | .temp 107 | .cache 108 | 109 | # VitePress build and cache 110 | docs/.vitepress/cache 111 | docs/.vitepress/dist 112 | 113 | # Docusaurus cache and generated files 114 | .docusaurus 115 | 116 | # Serverless directories 117 | .serverless/ 118 | 119 | # FuseBox cache 120 | .fusebox/ 121 | 122 | # DynamoDB Local files 123 | .dynamodb/ 124 | 125 | # TernJS port file 126 | .tern-port 127 | 128 | # Stores VSCode versions used for testing VSCode extensions 129 | .vscode-test 130 | .vscode 131 | # yarn v2 132 | .yarn/cache 133 | .yarn/unplugged 134 | .yarn/build-state.yml 135 | .yarn/install-state.gz 136 | .pnp.* 137 | -------------------------------------------------------------------------------- /backend/src/app.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const session = require('express-session'); 3 | const cookieParser = require('cookie-parser'); 4 | 5 | const { port } = require('./config/index'); 6 | const { configureTrustProxy } = require('./middleware/trustProxy'); 7 | const { configureHttpLogging } = require('./middleware/logging'); 8 | const { configureCors } = require('./middleware/cors'); 9 | const { configureOidc } = require('./middleware/oidc'); 10 | const { configureHttpsWarning } = require('./middleware/httpsWarning'); 11 | const authMiddleware = require('./middleware/authMiddleware'); 12 | const registerRoutes = require('./routes'); 13 | const { configureStaticFiles } = require('./utils/staticServer'); 14 | const { bootstrap } = require('./utils/bootstrap'); 15 | const { configureSession } = require('./middleware/session'); 16 | const logger = require('./utils/logger'); 17 | const { errorHandler, notFoundHandler } = require('./middleware/errorHandler'); 18 | const terminalService = require('./services/terminalService'); 19 | 20 | const app = express(); 21 | let server = null; 22 | 23 | const initializeApp = async () => { 24 | logger.debug('Application initialization started'); 25 | 26 | configureTrustProxy(app); 27 | configureHttpLogging(app); 28 | 29 | configureCors(app); 30 | app.use(express.json()); 31 | app.use(express.urlencoded({ extended: true })); 32 | app.use(cookieParser()); 33 | logger.debug('Mounted cookie parser middleware'); 34 | 35 | await bootstrap(); 36 | 37 | configureSession(app); 38 | await configureOidc(app); 39 | configureHttpsWarning(app); 40 | 41 | app.use(authMiddleware); 42 | logger.debug('Mounted auth middleware'); 43 | registerRoutes(app); 44 | logger.debug('Registered application routes'); 45 | 46 | 47 | configureStaticFiles(app); 48 | 49 | // Error handling middleware (must be after all routes) 50 | app.use(notFoundHandler); 51 | app.use(errorHandler); 52 | logger.debug('Mounted error handling middleware'); 53 | 54 | server = app.listen(port, '0.0.0.0', () => { 55 | logger.info({ port }, 'Server is running'); 56 | logger.debug('HTTP server listen callback executed'); 57 | }); 58 | 59 | // Initialize WebSocket server for terminal 60 | terminalService.createWebSocketServer(server); 61 | logger.debug('Terminal WebSocket server initialized'); 62 | 63 | // Cleanup on process termination 64 | const cleanup = () => { 65 | logger.info('Shutting down server...'); 66 | terminalService.cleanup(); 67 | server.close(() => { 68 | logger.info('Server closed'); 69 | process.exit(0); 70 | }); 71 | }; 72 | 73 | process.on('SIGTERM', cleanup); 74 | process.on('SIGINT', cleanup); 75 | }; 76 | 77 | initializeApp().catch((error) => { 78 | logger.error({ err: error }, 'Failed to initialize application'); 79 | process.exit(1); 80 | }); 81 | 82 | module.exports = { 83 | app, 84 | get server() { 85 | return server; 86 | }, 87 | }; -------------------------------------------------------------------------------- /docs/integrations/authelia.md: -------------------------------------------------------------------------------- 1 | # Authelia (OIDC) Setup 2 | 3 | Authelia can act as the OIDC Identity Provider for nextExplorer using the Authorization Code flow. This guide shows how to stand up Authelia and connect it via environment variables. 4 | 5 | ## Authelia prerequisites 6 | 7 | - Two hostnames (e.g., `auth.example.com` and `files.example.com`). Both should serve TLS. 8 | - Docker Compose (Authelia + Redis stack) or an existing Authelia deployment. 9 | - Secrets for JWT/session, storage encryption, and the Authelia OIDC issuer private key. 10 | 11 | ## Example Authelia stack 12 | 13 | ```yaml 14 | services: 15 | redis: 16 | image: redis:7-alpine 17 | command: ["redis-server", "--appendonly", "yes"] 18 | authelia: 19 | image: authelia/authelia:latest 20 | depends_on: [redis] 21 | volumes: 22 | - ./authelia/config:/config 23 | ports: 24 | - "9091:9091" 25 | ``` 26 | 27 | Generate secrets (example commands): 28 | 29 | ```bash 30 | openssl rand -hex 64 > authelia/config/jwt_secret.txt 31 | openssl rand -hex 64 > authelia/config/session_secret.txt 32 | openssl rand -hex 64 > authelia/config/storage_encryption_key.txt 33 | openssl genrsa -out authelia/config/oidc_issuer_key.pem 2048 34 | ``` 35 | 36 | Create `authelia/config/configuration.yml` with an OIDC provider client (see the existing sections for scopes, redirect URIs, and grant types). Include `groups` in the scopes so nextExplorer can map admins. 37 | 38 | ## nextExplorer configuration 39 | 40 | Add these variables to your nextExplorer service: 41 | 42 | ```yaml 43 | environment: 44 | - PUBLIC_URL=https://files.example.com 45 | - SESSION_SECRET=please-change-me 46 | - OIDC_ENABLED=true 47 | - OIDC_ISSUER=https://auth.example.com 48 | - OIDC_CLIENT_ID=nextexplorer 49 | - OIDC_CLIENT_SECRET=CHANGE_ME_STRONG_SECRET 50 | - OIDC_SCOPES=openid,profile,email,groups 51 | - OIDC_ADMIN_GROUPS=next-admin,admins 52 | ``` 53 | 54 | The backend discovers `/login`, `/callback`, `/logout` automatically once OIDC is on. Admin elevation occurs when the user’s group claim intersects `OIDC_ADMIN_GROUPS`. 55 | 56 | ## Testing & validation 57 | 58 | - Start Authelia and nextExplorer: `docker compose up -d authelia redis nextexplorer`. 59 | - Visit `https://files.example.com`, click “Continue with Single Sign-On,” authenticate via Authelia, and confirm you return to the app. 60 | - If you belong to `next-admin` (or whatever groups you configured), the `admin` role is granted automatically. 61 | 62 | ## Troubleshooting tips 63 | 64 | | Symptom | Fix | 65 | | --- | --- | 66 | | Redirect URI mismatch | Ensure Authelia’s `redirect_uris` entry equals `${PUBLIC_URL}/callback` (or `OIDC_CALLBACK_URL` if overridden). | 67 | | Users not admin | Include the group claim in Authelia (via `groups` scope/mapping) and add the group name to `OIDC_ADMIN_GROUPS`. | 68 | | Session issue after restart | Set a fixed `SESSION_SECRET` so the Express session stays valid. | 69 | | Behind proxy | Set `PUBLIC_URL`, configure `TRUST_PROXY`, and forward `X-Forwarded-*` headers from your proxy. 70 | -------------------------------------------------------------------------------- /frontend/src/components/BreadCrumb.vue: -------------------------------------------------------------------------------- 1 | 65 | 66 | 82 | -------------------------------------------------------------------------------- /frontend/src/components/MenuItemInfo.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 80 | -------------------------------------------------------------------------------- /frontend/src/plugins/index.js: -------------------------------------------------------------------------------- 1 | import { usePreviewManager } from '@/plugins/preview/manager'; 2 | import { imagePreviewPlugin } from '@/plugins/image/imagePreview'; 3 | import { videoPreviewPlugin } from '@/plugins/video/videoPreview'; 4 | import { audioPreviewPlugin } from '@/plugins/audio/audioPreview'; 5 | import { markdownPreviewPlugin } from '@/plugins/markdown/markdownPreview'; 6 | import { pdfPreviewPlugin } from '@/plugins/pdf/pdfPreview'; 7 | import { onlyofficePreviewPlugin } from '@/plugins/onlyoffice/onlyofficePreview'; 8 | import { useFeaturesStore } from '@/stores/features'; 9 | 10 | /** 11 | * @param {import('pinia').Pinia} pinia - Pinia instance 12 | * @param {Object} options - Installation options 13 | * @param {Array} options.plugins - Additional custom plugins to register 14 | * @param {boolean} options.skipOnlyOffice - Skip ONLYOFFICE plugin loading 15 | */ 16 | export const installPreviewPlugins = (pinia, options = {}) => { 17 | const { plugins: customPlugins = [], skipOnlyOffice = false } = options; 18 | const manager = usePreviewManager(pinia); 19 | 20 | // Register core plugins (synchronous - blocks app startup) 21 | registerCorePlugins(manager); 22 | 23 | if (customPlugins.length > 0) { 24 | customPlugins.forEach(plugin => { 25 | try { 26 | manager.register(plugin); 27 | } catch (error) { 28 | console.error('Failed to register custom plugin:', error); 29 | } 30 | }); 31 | } 32 | 33 | // Load ONLYOFFICE asynchronously (doesn't block startup) 34 | if (!skipOnlyOffice) { 35 | loadOnlyOfficePlugin(manager); 36 | } 37 | }; 38 | 39 | /** 40 | * Register core preview plugins 41 | * These are bundled and always available 42 | */ 43 | function registerCorePlugins(manager) { 44 | const plugins = [ 45 | imagePreviewPlugin(), 46 | videoPreviewPlugin(), 47 | audioPreviewPlugin(), 48 | pdfPreviewPlugin(), 49 | markdownPreviewPlugin(), 50 | ]; 51 | 52 | plugins.forEach(plugin => manager.register(plugin)); 53 | } 54 | 55 | /** 56 | * Load ONLYOFFICE plugin conditionally based on server features 57 | * Runs async to avoid blocking app startup 58 | */ 59 | async function loadOnlyOfficePlugin(manager) { 60 | try { 61 | const featuresStore = useFeaturesStore(); 62 | await featuresStore.ensureLoaded(); 63 | 64 | if (!featuresStore.onlyofficeEnabled) return; 65 | 66 | // Get supported extensions from store 67 | const extensions = normalizeExtensions(featuresStore.onlyofficeExtensions); 68 | manager.register(onlyofficePreviewPlugin(extensions)); 69 | 70 | console.info(`ONLYOFFICE plugin loaded (${extensions.length} extensions)`); 71 | } catch (error) { 72 | console.debug('ONLYOFFICE plugin unavailable:', error.message); 73 | } 74 | } 75 | 76 | /** 77 | * Normalize extension list from server 78 | */ 79 | function normalizeExtensions(extensions) { 80 | if (!Array.isArray(extensions)) { 81 | return []; 82 | } 83 | 84 | return extensions 85 | .filter(ext => ext && typeof ext === 'string') 86 | .map(ext => ext.toLowerCase().trim()) 87 | .filter(ext => ext.length > 0); 88 | } 89 | -------------------------------------------------------------------------------- /frontend/src/views/settings/SettingsAbout.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 77 | -------------------------------------------------------------------------------- /backend/tests/routes/features.test.js: -------------------------------------------------------------------------------- 1 | const test = require('node:test'); 2 | const assert = require('node:assert/strict'); 3 | const express = require('express'); 4 | const request = require('supertest'); 5 | 6 | const backendPackage = require('../../package.json'); 7 | const { clearModuleCache, overrideEnv } = require('../helpers/env-test-utils'); 8 | 9 | const buildApp = () => { 10 | clearModuleCache('src/config/env'); 11 | clearModuleCache('src/config/index'); 12 | clearModuleCache('src/routes/features'); 13 | 14 | const featureRoutes = require('../../src/routes/features'); 15 | const app = express(); 16 | app.use('/api', featureRoutes); 17 | return app; 18 | }; 19 | 20 | test('features route exposes default feature flags and version metadata', async () => { 21 | const restoreEnv = overrideEnv({ 22 | ONLYOFFICE_URL: undefined, 23 | ONLYOFFICE_FILE_EXTENSIONS: undefined, 24 | EDITOR_EXTENSIONS: undefined, 25 | SHOW_VOLUME_USAGE: undefined, 26 | SKIP_HOME: undefined, 27 | GIT_COMMIT: undefined, 28 | GIT_BRANCH: undefined, 29 | REPO_URL: undefined, 30 | }); 31 | 32 | try { 33 | const app = buildApp(); 34 | const response = await request(app).get('/api/features').expect(200); 35 | 36 | assert.strictEqual(response.body.onlyoffice.enabled, false); 37 | assert.deepEqual(response.body.onlyoffice.extensions, []); 38 | assert.deepEqual(response.body.editor.extensions, []); 39 | assert.strictEqual(response.body.volumeUsage.enabled, false); 40 | assert.strictEqual(response.body.navigation.skipHome, false); 41 | assert.strictEqual(response.body.version.app, backendPackage.version); 42 | assert.strictEqual(response.body.version.gitCommit, ''); 43 | assert.strictEqual(response.body.version.gitBranch, ''); 44 | assert.strictEqual(response.body.version.repoUrl, ''); 45 | } finally { 46 | restoreEnv(); 47 | } 48 | }); 49 | 50 | test('features route reflects enabled editors, onlyoffice, and volume usage', async () => { 51 | const restoreEnv = overrideEnv({ 52 | ONLYOFFICE_URL: 'https://desk.example.com', 53 | ONLYOFFICE_FILE_EXTENSIONS: '.docx, .XLSX', 54 | EDITOR_EXTENSIONS: '.MD,.txt', 55 | SHOW_VOLUME_USAGE: 'true', 56 | SKIP_HOME: 'true', 57 | GIT_COMMIT: 'abc123', 58 | GIT_BRANCH: 'main', 59 | REPO_URL: 'https://example.com/repo', 60 | }); 61 | 62 | try { 63 | const app = buildApp(); 64 | const response = await request(app).get('/api/features').expect(200); 65 | 66 | assert.strictEqual(response.body.onlyoffice.enabled, true); 67 | assert.deepEqual(response.body.onlyoffice.extensions, ['.docx', '.xlsx']); 68 | assert.deepEqual(response.body.editor.extensions, ['.md', '.txt']); 69 | assert.strictEqual(response.body.volumeUsage.enabled, true); 70 | assert.strictEqual(response.body.navigation.skipHome, true); 71 | assert.strictEqual(response.body.version.gitCommit, 'abc123'); 72 | assert.strictEqual(response.body.version.gitBranch, 'main'); 73 | assert.strictEqual(response.body.version.repoUrl, 'https://example.com/repo'); 74 | } finally { 75 | restoreEnv(); 76 | } 77 | }); 78 | --------------------------------------------------------------------------------