From bf49e13e56de5006740435ccfbbeb688b0a1f4eb Mon Sep 17 00:00:00 2001 From: "Gan, Jimmy" Date: Thu, 19 Feb 2026 17:29:03 +0800 Subject: [PATCH] Add settings page with change password, fix terminal ws bug --- backup.sh | 63 +++++++++++++++++ dashboard/backend/auth.py | 32 ++++++--- dashboard/backend/routers/auth.py | 15 +++- dashboard/backend/routers/terminal.py | 6 +- dashboard/frontend/src/App.svelte | 3 + .../frontend/src/components/Sidebar.svelte | 3 + dashboard/frontend/src/routes/Settings.svelte | 62 ++++++++++++++++ immich/docker-compose.yml | 70 +++++++++++++++++++ 8 files changed, 240 insertions(+), 14 deletions(-) create mode 100644 backup.sh create mode 100644 dashboard/frontend/src/routes/Settings.svelte create mode 100644 immich/docker-compose.yml diff --git a/backup.sh b/backup.sh new file mode 100644 index 0000000..e1f8f87 --- /dev/null +++ b/backup.sh @@ -0,0 +1,63 @@ +#!/bin/bash + +# Configuration +BACKUP_DIR="/volume1/backups/nas-tools" +DATE=$(date +%Y%m%d_%H%M%S) +RETENTION_DAYS=7 + +# Ensure backup directory exists +mkdir -p "$BACKUP_DIR" + +log() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" +} + +log "Starting backup..." + +# 1. Database Dumps +log "Dumping databases..." + +# Gitea DB +docker exec gitea-db pg_dump -U gitea gitea > "$BACKUP_DIR/gitea_db_$DATE.sql" +if [ $? -eq 0 ]; then + log "Gitea DB dumped successfully." +else + log "Error dumping Gitea DB!" +fi + +# Immich DB +docker exec immich-db pg_dump -U postgres immich > "$BACKUP_DIR/immich_db_$DATE.sql" +if [ $? -eq 0 ]; then + log "Immich DB dumped successfully." +else + log "Error dumping Immich DB!" +fi + +# 2. File Backups (Configs & Data) +# Exclude heavy media files if handled separately, but for now backing up key configs +log "Compressing configuration files..." + +# Dashboard & Gitea & Immich configs +# Assuming /volume1/docker structure +tar -czf "$BACKUP_DIR/configs_$DATE.tar.gz" \ + -C /volume1/docker \ + nas-dashboard/backend/config.py \ + nas-dashboard/.env \ + gitea/conf \ + immich/.env \ + --exclude='gitea/data' \ + --exclude='immich/upload' \ + --exclude='immich/thumbs' + +if [ $? -eq 0 ]; then + log "Configs compressed successfully." +else + log "Error compressing configs!" +fi + +# 3. Cleanup Old Backups +log "Cleaning up backups older than $RETENTION_DAYS days..." +find "$BACKUP_DIR" -type f -name "*_db_*.sql" -mtime +$RETENTION_DAYS -delete +find "$BACKUP_DIR" -type f -name "configs_*.tar.gz" -mtime +$RETENTION_DAYS -delete + +log "Backup completed." diff --git a/dashboard/backend/auth.py b/dashboard/backend/auth.py index 6dc6b40..eef5d5e 100644 --- a/dashboard/backend/auth.py +++ b/dashboard/backend/auth.py @@ -60,27 +60,39 @@ async def get_current_user_ws(token: str): raise credentials_exception return username -# TOTP Persistenceturn username - # TOTP Persistence AUTH_FILE = config.VOLUME_ROOT + "/docker/nas-dashboard/auth.json" +import json, os -def load_totp_secret(): +def _load_auth_data(): try: - import json if os.path.exists(AUTH_FILE): with open(AUTH_FILE, "r") as f: - data = json.load(f) - return data.get("totp_secret", "") + return json.load(f) except Exception: pass - return config.TOTP_SECRET + return {} -def save_totp_secret(secret: str): - import json +def _save_auth_data(data): os.makedirs(os.path.dirname(AUTH_FILE), exist_ok=True) with open(AUTH_FILE, "w") as f: - json.dump({"totp_secret": secret}, f) + json.dump(data, f) + +def load_totp_secret(): + return _load_auth_data().get("totp_secret", "") or config.TOTP_SECRET + +def save_totp_secret(secret: str): + data = _load_auth_data() + data["totp_secret"] = secret + _save_auth_data(data) + +def load_password_hash(): + return _load_auth_data().get("password_hash", "") or config.ADMIN_PASSWORD_HASH + +def save_password_hash(hashed: str): + data = _load_auth_data() + data["password_hash"] = hashed + _save_auth_data(data) def verify_totp(token: str, secret: str = None): if secret is None: diff --git a/dashboard/backend/routers/auth.py b/dashboard/backend/routers/auth.py index e79043b..0a1d536 100644 --- a/dashboard/backend/routers/auth.py +++ b/dashboard/backend/routers/auth.py @@ -28,7 +28,7 @@ class Verify2FARequest(BaseModel): @router.post("/login", response_model=Token) async def login(creds: LoginRequest): # Verify username/password - if creds.username != config.ADMIN_USER or not auth.verify_password(creds.password, config.ADMIN_PASSWORD_HASH): + if creds.username != config.ADMIN_USER or not auth.verify_password(creds.password, auth.load_password_hash()): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect username or password", @@ -84,3 +84,16 @@ async def verify_2fa_setup(request: Verify2FARequest, current_user: str = Depend async def disable_2fa(current_user: str = Depends(auth.get_current_user)): auth.save_totp_secret("") return {"message": "2FA disabled"} + +class ChangePasswordRequest(BaseModel): + current_password: str + new_password: str + +@router.post("/change-password") +async def change_password(req: ChangePasswordRequest, current_user: str = Depends(auth.get_current_user)): + if not auth.verify_password(req.current_password, auth.load_password_hash()): + raise HTTPException(status_code=400, detail="Current password is incorrect") + if len(req.new_password) < 8: + raise HTTPException(status_code=400, detail="Password must be at least 8 characters") + auth.save_password_hash(auth.get_password_hash(req.new_password)) + return {"message": "Password changed successfully"} diff --git a/dashboard/backend/routers/terminal.py b/dashboard/backend/routers/terminal.py index 8bf202f..c858198 100644 --- a/dashboard/backend/routers/terminal.py +++ b/dashboard/backend/routers/terminal.py @@ -16,7 +16,7 @@ async def ws_endpoint(websocket: WebSocket, user: str = Depends(auth.get_current return - await ws.accept() + await websocket.accept() master_fd, slave_fd = pty.openpty() pid = os.fork() @@ -40,7 +40,7 @@ async def ws_endpoint(websocket: WebSocket, user: str = Depends(auth.get_current data = os.read(master_fd, 4096) if not data: break - await ws.send_bytes(data) + await websocket.send_bytes(data) except (OSError, WebSocketDisconnect): pass @@ -53,7 +53,7 @@ async def ws_endpoint(websocket: WebSocket, user: str = Depends(auth.get_current try: while True: - msg = await ws.receive() + msg = await websocket.receive() if "bytes" in msg and msg["bytes"]: data = msg["bytes"] # Handle resize: first byte 0x01 means resize message diff --git a/dashboard/frontend/src/App.svelte b/dashboard/frontend/src/App.svelte index 67face2..d936f00 100644 --- a/dashboard/frontend/src/App.svelte +++ b/dashboard/frontend/src/App.svelte @@ -5,6 +5,7 @@ import Gitea from "./routes/Gitea.svelte"; import Files from "./routes/Files.svelte"; import Terminal from "./routes/Terminal.svelte"; + import Settings from "./routes/Settings.svelte"; import Login from "./routes/Login.svelte"; import { onMount } from "svelte"; import { getToken, checkAuth, setToken } from "./lib/api.js"; @@ -82,6 +83,8 @@ {:else if page === "terminal"} + {:else if page === "settings"} + {/if} diff --git a/dashboard/frontend/src/components/Sidebar.svelte b/dashboard/frontend/src/components/Sidebar.svelte index beda60c..396f80e 100644 --- a/dashboard/frontend/src/components/Sidebar.svelte +++ b/dashboard/frontend/src/components/Sidebar.svelte @@ -7,6 +7,7 @@ { id: "gitea", label: "Repos", icon: "git" }, { id: "files", label: "Files", icon: "folder" }, { id: "terminal", label: "Terminal", icon: "terminal" }, + { id: "settings", label: "Settings", icon: "settings" }, ]; const external = [ @@ -56,6 +57,8 @@ {:else if link.icon === "terminal"} + {:else if link.icon === "settings"} + {/if} {link.label} diff --git a/dashboard/frontend/src/routes/Settings.svelte b/dashboard/frontend/src/routes/Settings.svelte new file mode 100644 index 0000000..fa8a825 --- /dev/null +++ b/dashboard/frontend/src/routes/Settings.svelte @@ -0,0 +1,62 @@ + + +
+
+

Settings

+

Account and security settings

+
+ +
+

Change Password

+ + {#if message} +
{message}
+ {/if} + {#if error} +
{error}
+ {/if} + +
{ e.preventDefault(); changePassword(); }} class="space-y-3"> +
+ + +
+
+ + +
+
+ + +
+ +
+
+
diff --git a/immich/docker-compose.yml b/immich/docker-compose.yml new file mode 100644 index 0000000..35f36d2 --- /dev/null +++ b/immich/docker-compose.yml @@ -0,0 +1,70 @@ +services: + immich-server: + image: ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-release} + container_name: immich-server + restart: unless-stopped + ports: + - "2283:2283" + volumes: + - ${UPLOAD_LOCATION}:/usr/src/app/upload + - /volume1/photo:/mnt/photo:ro + - /volume1/homes/zjgump/Photos:/mnt/synology-photos:ro + - /etc/localtime:/etc/localtime:ro + environment: + DB_HOSTNAME: immich-db + DB_USERNAME: ${DB_USERNAME} + DB_PASSWORD: ${DB_PASSWORD} + DB_DATABASE_NAME: ${DB_DATABASE_NAME} + REDIS_HOSTNAME: immich-redis + IMMICH_MACHINE_LEARNING_ENABLED: "false" + depends_on: + - immich-redis + - immich-db + networks: + - immich + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + + immich-redis: + image: redis:7-alpine + container_name: immich-redis + restart: unless-stopped + healthcheck: + test: redis-cli ping || exit 1 + networks: + - immich + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + + immich-db: + image: tensorchord/pgvecto-rs:pg16-v0.2.0 + container_name: immich-db + restart: unless-stopped + environment: + POSTGRES_USER: ${DB_USERNAME} + POSTGRES_PASSWORD: ${DB_PASSWORD} + POSTGRES_DB: ${DB_DATABASE_NAME} + POSTGRES_INITDB_ARGS: "--data-checksums" + volumes: + - ${DB_DATA_LOCATION}:/var/lib/postgresql/data + healthcheck: + test: pg_isready --dbname='${DB_DATABASE_NAME}' --username='${DB_USERNAME}' || exit 1 + interval: 30s + timeout: 5s + retries: 5 + networks: + - immich + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + +networks: + immich: