IMPROVE: cho phép lưu dự án và phục hồi dự án vừa tắt
This commit is contained in:
+30
-1
@@ -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()
|
||||
|
||||
+118
-6
@@ -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
|
||||
|
||||
|
||||
+24
-9
@@ -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 = {
|
||||
|
||||
+25
-1
@@ -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()
|
||||
|
||||
@@ -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' }),
|
||||
|
||||
@@ -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
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user