Add settings page with change password, fix terminal ws bug
Deploy Dashboard / deploy (push) Failing after 2m5s

This commit is contained in:
Gan, Jimmy
2026-02-19 17:29:03 +08:00
parent 4036292273
commit bf49e13e56
8 changed files with 240 additions and 14 deletions
+63
View File
@@ -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."
+22 -10
View File
@@ -60,27 +60,39 @@ async def get_current_user_ws(token: str):
raise credentials_exception raise credentials_exception
return username return username
# TOTP Persistenceturn username
# TOTP Persistence # TOTP Persistence
AUTH_FILE = config.VOLUME_ROOT + "/docker/nas-dashboard/auth.json" AUTH_FILE = config.VOLUME_ROOT + "/docker/nas-dashboard/auth.json"
import json, os
def load_totp_secret(): def _load_auth_data():
try: try:
import json
if os.path.exists(AUTH_FILE): if os.path.exists(AUTH_FILE):
with open(AUTH_FILE, "r") as f: with open(AUTH_FILE, "r") as f:
data = json.load(f) return json.load(f)
return data.get("totp_secret", "")
except Exception: except Exception:
pass pass
return config.TOTP_SECRET return {}
def save_totp_secret(secret: str): def _save_auth_data(data):
import json
os.makedirs(os.path.dirname(AUTH_FILE), exist_ok=True) os.makedirs(os.path.dirname(AUTH_FILE), exist_ok=True)
with open(AUTH_FILE, "w") as f: 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): def verify_totp(token: str, secret: str = None):
if secret is None: if secret is None:
+14 -1
View File
@@ -28,7 +28,7 @@ class Verify2FARequest(BaseModel):
@router.post("/login", response_model=Token) @router.post("/login", response_model=Token)
async def login(creds: LoginRequest): async def login(creds: LoginRequest):
# Verify username/password # 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( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password", 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)): async def disable_2fa(current_user: str = Depends(auth.get_current_user)):
auth.save_totp_secret("") auth.save_totp_secret("")
return {"message": "2FA disabled"} 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"}
+3 -3
View File
@@ -16,7 +16,7 @@ async def ws_endpoint(websocket: WebSocket, user: str = Depends(auth.get_current
return return
await ws.accept() await websocket.accept()
master_fd, slave_fd = pty.openpty() master_fd, slave_fd = pty.openpty()
pid = os.fork() 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) data = os.read(master_fd, 4096)
if not data: if not data:
break break
await ws.send_bytes(data) await websocket.send_bytes(data)
except (OSError, WebSocketDisconnect): except (OSError, WebSocketDisconnect):
pass pass
@@ -53,7 +53,7 @@ async def ws_endpoint(websocket: WebSocket, user: str = Depends(auth.get_current
try: try:
while True: while True:
msg = await ws.receive() msg = await websocket.receive()
if "bytes" in msg and msg["bytes"]: if "bytes" in msg and msg["bytes"]:
data = msg["bytes"] data = msg["bytes"]
# Handle resize: first byte 0x01 means resize message # Handle resize: first byte 0x01 means resize message
+3
View File
@@ -5,6 +5,7 @@
import Gitea from "./routes/Gitea.svelte"; import Gitea from "./routes/Gitea.svelte";
import Files from "./routes/Files.svelte"; import Files from "./routes/Files.svelte";
import Terminal from "./routes/Terminal.svelte"; import Terminal from "./routes/Terminal.svelte";
import Settings from "./routes/Settings.svelte";
import Login from "./routes/Login.svelte"; import Login from "./routes/Login.svelte";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { getToken, checkAuth, setToken } from "./lib/api.js"; import { getToken, checkAuth, setToken } from "./lib/api.js";
@@ -82,6 +83,8 @@
<Files /> <Files />
{:else if page === "terminal"} {:else if page === "terminal"}
<Terminal /> <Terminal />
{:else if page === "settings"}
<Settings />
{/if} {/if}
</div> </div>
</main> </main>
@@ -7,6 +7,7 @@
{ id: "gitea", label: "Repos", icon: "git" }, { id: "gitea", label: "Repos", icon: "git" },
{ id: "files", label: "Files", icon: "folder" }, { id: "files", label: "Files", icon: "folder" },
{ id: "terminal", label: "Terminal", icon: "terminal" }, { id: "terminal", label: "Terminal", icon: "terminal" },
{ id: "settings", label: "Settings", icon: "settings" },
]; ];
const external = [ const external = [
@@ -56,6 +57,8 @@
<svg class="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" /></svg> <svg class="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" /></svg>
{:else if link.icon === "terminal"} {:else if link.icon === "terminal"}
<svg class="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg> <svg class="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
{:else if link.icon === "settings"}
<svg class="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
{/if} {/if}
</span> </span>
{link.label} {link.label}
@@ -0,0 +1,62 @@
<script>
import { post } from "../lib/api.js";
let currentPassword = $state("");
let newPassword = $state("");
let confirmPassword = $state("");
let message = $state("");
let error = $state("");
let saving = $state(false);
async function changePassword() {
error = "";
message = "";
if (newPassword !== confirmPassword) { error = "Passwords do not match"; return; }
if (newPassword.length < 8) { error = "Password must be at least 8 characters"; return; }
saving = true;
try {
await post("/auth/change-password", { current_password: currentPassword, new_password: newPassword });
message = "Password changed successfully";
currentPassword = ""; newPassword = ""; confirmPassword = "";
} catch (e) {
error = e.body?.detail || "Failed to change password";
}
saving = false;
}
</script>
<div class="space-y-6">
<div>
<h1 class="text-2xl font-bold text-surface-900 tracking-tight">Settings</h1>
<p class="text-sm text-surface-400 mt-1">Account and security settings</p>
</div>
<div class="bg-white rounded-xl border border-surface-200 p-6 shadow-sm max-w-md">
<h3 class="text-sm font-semibold text-surface-700 mb-4">Change Password</h3>
{#if message}
<div class="text-sm text-emerald-600 bg-emerald-50 px-3 py-2 rounded-lg mb-4">{message}</div>
{/if}
{#if error}
<div class="text-sm text-rose-600 bg-rose-50 px-3 py-2 rounded-lg mb-4">{error}</div>
{/if}
<form onsubmit={(e) => { e.preventDefault(); changePassword(); }} class="space-y-3">
<div>
<label class="block text-xs font-medium text-surface-500 mb-1">Current Password</label>
<input type="password" bind:value={currentPassword} required class="w-full px-3 py-2 text-sm border border-surface-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" />
</div>
<div>
<label class="block text-xs font-medium text-surface-500 mb-1">New Password</label>
<input type="password" bind:value={newPassword} required class="w-full px-3 py-2 text-sm border border-surface-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" />
</div>
<div>
<label class="block text-xs font-medium text-surface-500 mb-1">Confirm New Password</label>
<input type="password" bind:value={confirmPassword} required class="w-full px-3 py-2 text-sm border border-surface-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" />
</div>
<button type="submit" disabled={saving} class="w-full px-4 py-2 text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 rounded-lg transition-colors disabled:opacity-50">
{saving ? "Saving..." : "Change Password"}
</button>
</form>
</div>
</div>
+70
View File
@@ -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: