diff --git a/app/api/v1/admin.py b/app/api/v1/admin.py index 4962aaf..c4bdf6e 100644 --- a/app/api/v1/admin.py +++ b/app/api/v1/admin.py @@ -20,6 +20,12 @@ class UpdateUserRoleRequest(BaseModel): role: str # 'admin', 'standard', 'premium' is_active: Optional[bool] = True +class CreateUserRequest(BaseModel): + username: str + email: str + password: str + role: Optional[str] = 'standard' + @router.get("/users") async def list_users(admin: dict = Depends(require_admin)): conn = get_db_connection() @@ -52,6 +58,29 @@ async def list_users(admin: dict = Depends(require_admin)): }) return users +@router.post("/users") +async def create_user(req: CreateUserRequest, admin: dict = Depends(require_admin)): + import uuid, time + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute("SELECT id FROM users WHERE username = ? OR email = ?", (req.username, req.email)) + if cursor.fetchone(): + conn.close() + raise HTTPException(status_code=409, detail="Tên đăng nhập hoặc email đã tồn tại") + user_id = str(uuid.uuid4()) + hashed = hash_password(req.password) + now = time.time() + cursor.execute(""" + INSERT INTO users (id, username, email, hashed_password, role, must_change_password, created_at, is_active) + VALUES (?, ?, ?, ?, ?, 1, ?, 1) + """, (user_id, req.username, req.email, hashed, req.role, now)) + cursor.execute(""" + INSERT INTO user_quotas (user_id, storage_limit_mb, max_tracks) VALUES (?, 500, 16) + """, (user_id,)) + conn.commit() + conn.close() + return {"message": f"Đã tạo người dùng '{req.username}' thành công", "user_id": user_id} + @router.put("/users/{user_id}/role") async def update_user_role(user_id: str, req: UpdateUserRoleRequest, admin: dict = Depends(require_admin)): conn = get_db_connection() @@ -81,7 +110,7 @@ async def delete_user(user_id: str, admin: dict = Depends(require_admin)): conn = get_db_connection() cursor = conn.cursor() cursor.execute("DELETE FROM users WHERE id = ?", (user_id,)) - cursor.execute("DELETE FROM user_quotas WHERE id = ?", (user_id,)) + cursor.execute("DELETE FROM user_quotas WHERE user_id = ?", (user_id,)) cursor.execute("DELETE FROM projects WHERE user_id = ?", (user_id,)) conn.commit() conn.close() diff --git a/app/api/v1/projects.py b/app/api/v1/projects.py index 62953cb..49b4479 100644 --- a/app/api/v1/projects.py +++ b/app/api/v1/projects.py @@ -2,9 +2,9 @@ import time import json import uuid import os -from fastapi import APIRouter, HTTPException, Depends, Header +from fastapi import APIRouter, HTTPException, Depends, Header, Query from pydantic import BaseModel -from typing import Optional, Any, Dict +from typing import Optional, Any, Dict, List from jsonschema import validate, ValidationError from app.models.user import get_db_connection from app.api.v1.auth import get_current_user, decode_token @@ -228,9 +228,11 @@ async def list_cloud_projects(current_user: dict = Depends(get_current_user)): conn = get_db_connection() cursor = conn.cursor() cursor.execute(""" - SELECT id, name, size_bytes, updated_at FROM projects - WHERE user_id = ? AND is_temp = 0 - ORDER BY updated_at DESC + SELECT p.id, p.name, p.size_bytes, p.updated_at, + (SELECT COUNT(*) FROM project_backups pb WHERE pb.project_id = p.id AND pb.user_id = p.user_id) as backup_count + FROM projects p + WHERE p.user_id = ? AND p.is_temp = 0 + ORDER BY p.updated_at DESC """, (user_id,)) rows = cursor.fetchall() conn.close() @@ -240,7 +242,8 @@ async def list_cloud_projects(current_user: dict = Depends(get_current_user)): "id": r["id"], "name": r["name"], "size_mb": round(r["size_bytes"] / (1024 * 1024), 2), - "updated_at": r["updated_at"] + "updated_at": r["updated_at"], + "backup_count": r["backup_count"] } for r in rows ] @@ -298,6 +301,115 @@ async def update_cloud_project(project_id: str, req: SaveProjectRequest, current conn.close() return {"success": True, "message": "Đã cập nhật dự án thành công"} +class BackupConfigRequest(BaseModel): + max_count: int = 10 + +@router.post("/cloud/{project_id}/backup") +async def create_project_backup(project_id: str, current_user: dict = Depends(get_current_user)): + user_id = current_user["user_id"] + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute("SELECT name, data_json FROM projects WHERE id = ? AND user_id = ? AND is_temp = 0", (project_id, user_id)) + row = cursor.fetchone() + if not row: + conn.close() + raise HTTPException(status_code=404, detail="Không tìm thấy dự án") + + backup_id = f"backup_{uuid.uuid4().hex[:12]}" + now = time.time() + size_bytes = len(row["data_json"].encode("utf-8")) + backup_name = f"[Backup] {row['name']} ({time.strftime('%Y-%m-%d %H:%M', time.localtime(now))})" + + cursor.execute(""" + INSERT INTO project_backups (id, user_id, project_id, name, data_json, size_bytes, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, (backup_id, user_id, project_id, backup_name, row["data_json"], size_bytes, now)) + + # Enforce retention limit: lấy max_count từ query param mặc định 10 + cursor.execute("SELECT COUNT(*) as cnt FROM project_backups WHERE project_id = ? AND user_id = ?", (project_id, user_id)) + count = cursor.fetchone()["cnt"] + max_backup = 10 # default + if count > max_backup: + excess = count - max_backup + cursor.execute(""" + DELETE FROM project_backups WHERE id IN ( + SELECT id FROM project_backups WHERE project_id = ? AND user_id = ? + ORDER BY created_at ASC LIMIT ? + ) + """, (project_id, user_id, excess)) + + conn.commit() + conn.close() + return { + "backup_id": backup_id, + "name": backup_name, + "created_at": now, + "max_backups": max_backup + } + +@router.get("/cloud/{project_id}/backups") +async def list_project_backups(project_id: str, current_user: dict = Depends(get_current_user)): + user_id = current_user["user_id"] + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + SELECT id, name, size_bytes, created_at FROM project_backups + WHERE project_id = ? AND user_id = ? + ORDER BY created_at DESC + """, (project_id, user_id)) + rows = cursor.fetchall() + conn.close() + return [ + { + "id": r["id"], + "name": r["name"], + "size_mb": round(r["size_bytes"] / (1024 * 1024), 2), + "created_at": r["created_at"] + } for r in rows + ] + +@router.delete("/cloud/backups/{backup_id}") +async def delete_project_backup(backup_id: str, current_user: dict = Depends(get_current_user)): + user_id = current_user["user_id"] + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute("DELETE FROM project_backups WHERE id = ? AND user_id = ?", (backup_id, user_id)) + conn.commit() + conn.close() + return {"success": True, "message": "Đã xóa bản backup"} + +class CleanupBackupsRequest(BaseModel): + keep: int = 10 + +@router.post("/cloud/backups/cleanup") +async def cleanup_all_backups(req: CleanupBackupsRequest, current_user: dict = Depends(get_current_user)): + user_id = current_user["user_id"] + keep = max(5, min(20, req.keep)) + conn = get_db_connection() + cursor = conn.cursor() + + # Lấy tất cả project_id của user + cursor.execute("SELECT DISTINCT project_id FROM project_backups WHERE user_id = ?", (user_id,)) + projects = cursor.fetchall() + total_deleted = 0 + for p in projects: + pid = p["project_id"] + cursor.execute("SELECT COUNT(*) as cnt FROM project_backups WHERE project_id = ? AND user_id = ?", (pid, user_id)) + cnt = cursor.fetchone()["cnt"] + if cnt > keep: + excess = cnt - keep + cursor.execute(""" + DELETE FROM project_backups WHERE id IN ( + SELECT id FROM project_backups WHERE project_id = ? AND user_id = ? + ORDER BY created_at ASC LIMIT ? + ) + """, (pid, user_id, excess)) + total_deleted += excess + + conn.commit() + conn.close() + return {"success": True, "deleted": total_deleted, "keep": keep} + class RenderProjectRequest(BaseModel): sample_rate: Optional[int] = 44100 diff --git a/app/core/auth.py b/app/core/auth.py index 8c81fe4..37498e9 100644 --- a/app/core/auth.py +++ b/app/core/auth.py @@ -5,25 +5,40 @@ import time import base64 import uuid import os +import secrets from typing import Optional, Dict, Any from app.models.user import get_db_connection from app.config import settings SECRET_KEY = os.getenv("SECRET_KEY", "sonicforge_secret_key_super_secure_2026") -def hash_password(password: str) -> str: +def hash_password(password: str, salt: Optional[str] = None) -> str: """ - Hash password using PBKDF2 HMAC SHA-256 with salt. + Hash password using PBKDF2 HMAC SHA-256 with a per-user random salt. Guarantees raw passwords are NEVER stored or exposed in plaintext. + Returns format: 'salt_hex:hashed_key_hex' (colon-delimited, stores both values). + If salt is provided, uses that salt (for verification). + If salt is None, generates a new 32-byte random salt. """ - salt = b"sonicforge_crypto_salt_2026_secure_" - key = hashlib.pbkdf2_hmac('sha256', password.encode('utf-8'), salt, 100000) - return key.hex() + if salt is None: + salt = secrets.token_hex(32) + key = hashlib.pbkdf2_hmac('sha256', password.encode('utf-8'), salt.encode('utf-8'), 600000) + return f"{salt}:{key.hex()}" -def verify_password(plain_password: str, hashed_password: str) -> bool: - """Verify plain password against PBKDF2 hashed password using constant-time comparison.""" - computed_hash = hash_password(plain_password) - return hmac.compare_digest(computed_hash, hashed_password) +def verify_password(plain_password: str, stored_value: str) -> bool: + """Verify plain password against stored 'salt:hash' using constant-time comparison. + Also supports legacy-format hash (hex only, without salt) for backward compatibility.""" + if ':' in stored_value: + parts = stored_value.split(':', 1) + salt = parts[0] + expected_hash = parts[1] + computed = hash_password(plain_password, salt) + computed_hash = computed.split(':', 1)[1] + return hmac.compare_digest(computed_hash, expected_hash) + else: + expected_hash = stored_value + computed_hash = hashlib.pbkdf2_hmac('sha256', plain_password.encode('utf-8'), b"sonicforge_crypto_salt_2026_secure_", 600000).hex() + return hmac.compare_digest(computed_hash, expected_hash) def create_token(user_id: str, username: str, role: str, must_change_password: bool) -> str: payload = { diff --git a/app/models/user.py b/app/models/user.py index 7b65c1e..bdf2e1c 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -65,8 +65,32 @@ def init_db(): ); """) + # Migration: thêm cột backup nếu chưa tồn tại + try: + cursor.execute("ALTER TABLE projects ADD COLUMN is_backup INTEGER DEFAULT 0") + except Exception: + pass # column already exists + try: + cursor.execute("ALTER TABLE projects ADD COLUMN original_id TEXT DEFAULT NULL") + except Exception: + pass + + # Bảng Project Backups (snapshot riêng, không lẫn với projects chính) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS project_backups ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + project_id TEXT NOT NULL, + name TEXT NOT NULL, + data_json TEXT NOT NULL, + size_bytes INTEGER DEFAULT 0, + created_at REAL NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + """) + conn.commit() conn.close() - + # Tự động khởi tạo DB khi module được import init_db() diff --git a/app/static/js/services/api.js b/app/static/js/services/api.js index 3959f22..c3ffc72 100644 --- a/app/static/js/services/api.js +++ b/app/static/js/services/api.js @@ -41,6 +41,7 @@ window.API_BASE_URL = window.API_BASE_URL || window.location.origin; updateUserQuota: (userId, storageLimitMb, maxTracks = 16) => apiRequest(`/api/v1/admin/quotas/${userId}`, { method: 'PUT', body: JSON.stringify({ storage_limit_mb: storageLimitMb, max_tracks: maxTracks }) }), updateUserRole: (userId, role, isActive = true) => apiRequest(`/api/v1/admin/users/${userId}/role`, { method: 'PUT', body: JSON.stringify({ role, is_active: isActive }) }), deleteUser: (userId) => apiRequest(`/api/v1/admin/users/${userId}`, { method: 'DELETE' }), + createUser: (username, email, password, role = 'standard') => apiRequest('/api/v1/admin/users', { method: 'POST', body: JSON.stringify({ username, email, password, role }) }), saveTempProject: (dataJson) => apiRequest('/api/v1/projects/temp', { method: 'POST', body: JSON.stringify({ data_json: dataJson }) }), getTempProject: () => apiRequest('/api/v1/projects/temp', { method: 'GET' }), diff --git a/app/static/js/services/storage.js b/app/static/js/services/storage.js index a0e0211..86c428a 100644 --- a/app/static/js/services/storage.js +++ b/app/static/js/services/storage.js @@ -21,7 +21,11 @@ solo: t.solo, color: t.color, markers: t.markers || [], - serverFileId: t.serverFileId || null + serverFileId: t.serverFileId || null, + clips: t.clips || [], + sections: t.sections || [], + midiItems: t.midiItems || [], + channelInfo: t.channelInfo || null })) }; } diff --git a/md/49_AI_PROMPT_GENERATOR.md b/md/49_AI_PROMPT_GENERATOR.md new file mode 100644 index 0000000..537f59e --- /dev/null +++ b/md/49_AI_PROMPT_GENERATOR.md @@ -0,0 +1,868 @@ + + +
+ + +Công cụ tạo mẫu Prompt nhạc lý ép AI sinh đủ nốt phủ kín số ô nhịp
+