Compare commits

...

4 Commits

Author SHA1 Message Date
1ddb2c609d Update. 2025-11-10 01:58:41 +01:00
17de53b9c2 feat: Implement admin dashboard user management (CRUD) 2025-11-10 01:56:44 +01:00
6fdd4b9f0c . 2025-11-10 00:28:56 +01:00
d90b7ba852 Update. 2025-11-10 00:28:48 +01:00
28 changed files with 2050 additions and 45 deletions

4
.gitignore vendored
View File

@ -5,7 +5,9 @@ __pycache__/
.* .*
storage storage
*.so *.so
*.txt
poetry.lock
rbox.*
.Python .Python
build/ build/
develop-eggs/ develop-eggs/

5
Makefile Normal file
View File

@ -0,0 +1,5 @@
PYTHON=".venv/bin/python3"
RBOX=".venv/bin/rbox"
all:
$(RBOX) --port 9004

View File

@ -10,14 +10,17 @@ services:
POSTGRES_USER: ${POSTGRES_USER} POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
restart: unless-stopped restart: unless-stopped
network_mode: host
redis: redis:
image: redis:7-alpine image: redis:7-alpine
volumes: volumes:
- redis_data:/data - redis_data:/data
restart: unless-stopped restart: unless-stopped
network_mode: host
app: app:
network_mode: host
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile

View File

@ -6,7 +6,7 @@ authors = ["Your Name <you@example.com>"]
readme = "README.md" readme = "README.md"
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = "*" python = "^3.12"
fastapi = "*" fastapi = "*"
uvicorn = {extras = ["standard"], version = "*"} uvicorn = {extras = ["standard"], version = "*"}
tortoise-orm = {extras = ["asyncpg"], version = "*"} tortoise-orm = {extras = ["asyncpg"], version = "*"}
@ -14,6 +14,7 @@ redis = "*"
python-jose = {extras = ["cryptography"], version = "*"} python-jose = {extras = ["cryptography"], version = "*"}
passlib = {extras = ["bcrypt"], version = "*"} passlib = {extras = ["bcrypt"], version = "*"}
pyotp = "*" pyotp = "*"
qrcode = "*"
python-multipart = "*" python-multipart = "*"
aiofiles = "*" aiofiles = "*"
httpx = "*" httpx = "*"

View File

@ -9,33 +9,40 @@ import bcrypt
from .schemas import TokenData from .schemas import TokenData
from .settings import settings from .settings import settings
from .models import User from .models import User
from .two_factor import verify_totp_code # Import verify_totp_code
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
def verify_password(plain_password, hashed_password): def verify_password(plain_password, hashed_password):
password_bytes = plain_password[:72].encode('utf-8') password_bytes = plain_password[:72].encode('utf-8')
hashed_bytes = hashed_password.encode('utf-8') if isinstance(hashed_password, str) else hashed_password hashed_bytes = hashed_password.encode('utf-8') if isinstance(hashed_password, str) else hashed_bytes
return bcrypt.checkpw(password_bytes, hashed_bytes) return bcrypt.checkpw(password_bytes, hashed_bytes)
def get_password_hash(password): def get_password_hash(password):
password_bytes = password[:72].encode('utf-8') password_bytes = password[:72].encode('utf-8')
return bcrypt.hashpw(password_bytes, bcrypt.gensalt()).decode('utf-8') return bcrypt.hashpw(password_bytes, bcrypt.gensalt()).decode('utf-8')
async def authenticate_user(username: str, password: str): async def authenticate_user(username: str, password: str, two_factor_code: Optional[str] = None):
user = await User.get_or_none(username=username) user = await User.get_or_none(username=username)
if not user: if not user:
return None return None
if not verify_password(password, user.hashed_password): if not verify_password(password, user.hashed_password):
return None return None
return user
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None): if user.is_2fa_enabled:
if not two_factor_code:
return {"user": user, "2fa_required": True}
if not verify_totp_code(user.two_factor_secret, two_factor_code):
return None # 2FA code is incorrect
return {"user": user, "2fa_required": False}
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None, two_factor_verified: bool = False):
to_encode = data.copy() to_encode = data.copy()
if expires_delta: if expires_delta:
expire = datetime.utcnow() + expires_delta expire = datetime.utcnow() + expires_delta
else: else:
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire}) to_encode.update({"exp": expire, "2fa_verified": two_factor_verified})
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return encoded_jwt return encoded_jwt
@ -48,12 +55,29 @@ async def get_current_user(token: str = Depends(oauth2_scheme)):
try: try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
username: str = payload.get("sub") username: str = payload.get("sub")
two_factor_verified: bool = payload.get("2fa_verified", False)
if username is None: if username is None:
raise credentials_exception raise credentials_exception
token_data = TokenData(username=username) token_data = TokenData(username=username, two_factor_verified=two_factor_verified)
except JWTError: except JWTError:
raise credentials_exception raise credentials_exception
user = await User.get_or_none(username=token_data.username) user = await User.get_or_none(username=token_data.username)
if user is None: if user is None:
raise credentials_exception raise credentials_exception
user.token_data = token_data # Attach token_data to user for easy access
return user return user
async def get_current_active_user(current_user: User = Depends(get_current_user)):
if not current_user.is_active:
raise HTTPException(status_code=400, detail="Inactive user")
return current_user
async def get_current_verified_user(current_user: User = Depends(get_current_user)):
if current_user.is_2fa_enabled and not current_user.token_data.two_factor_verified:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="2FA required and not verified")
return current_user
async def get_current_admin_user(current_user: User = Depends(get_current_verified_user)):
if not current_user.is_superuser:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not enough permissions")
return current_user

View File

@ -1,12 +1,18 @@
import argparse import argparse
import uvicorn import uvicorn
from fastapi import FastAPI import logging # Import logging
from fastapi import FastAPI, Request, status, HTTPException
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.responses import HTMLResponse # Import HTMLResponse from fastapi.responses import HTMLResponse, JSONResponse
from tortoise.contrib.fastapi import register_tortoise from tortoise.contrib.fastapi import register_tortoise
from .settings import settings from .settings import settings
from .routers import auth, users, folders, files, shares, search from .routers import auth, users, folders, files, shares, search, admin, starred
from . import webdav from . import webdav
from .schemas import ErrorResponse # Import ErrorResponse
# Configure logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
app = FastAPI( app = FastAPI(
title="RBox Cloud Storage", title="RBox Cloud Storage",
@ -20,6 +26,8 @@ app.include_router(folders.router)
app.include_router(files.router) app.include_router(files.router)
app.include_router(shares.router) app.include_router(shares.router)
app.include_router(search.router) app.include_router(search.router)
app.include_router(admin.router) # Include the admin router
app.include_router(starred.router) # Include the starred router
app.include_router(webdav.router) app.include_router(webdav.router)
# Mount static files # Mount static files
@ -33,10 +41,18 @@ register_tortoise(
add_exception_handlers=True, add_exception_handlers=True,
) )
@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException):
logger.error(f"HTTPException: {exc.status_code} - {exc.detail} for URL: {request.url}")
return JSONResponse(
status_code=exc.status_code,
content=ErrorResponse(code=exc.status_code, message=exc.detail).dict(),
)
@app.on_event("startup") @app.on_event("startup")
async def startup_event(): async def startup_event():
print("Starting up...") logger.info("Starting up...")
print("Database connected.") logger.info("Database connected.")
@app.on_event("shutdown") @app.on_event("shutdown")
async def shutdown_event(): async def shutdown_event():

View File

@ -15,6 +15,8 @@ class User(models.Model):
used_storage_bytes = fields.BigIntField(default=0) used_storage_bytes = fields.BigIntField(default=0)
plan_type = fields.CharField(max_length=50, default="free") plan_type = fields.CharField(max_length=50, default="free")
two_factor_secret = fields.CharField(max_length=255, null=True) two_factor_secret = fields.CharField(max_length=255, null=True)
is_2fa_enabled = fields.BooleanField(default=False)
recovery_codes = fields.TextField(null=True)
class Meta: class Meta:
table = "users" table = "users"
@ -30,6 +32,7 @@ class Folder(models.Model):
created_at = fields.DatetimeField(auto_now_add=True) created_at = fields.DatetimeField(auto_now_add=True)
updated_at = fields.DatetimeField(auto_now=True) updated_at = fields.DatetimeField(auto_now=True)
is_deleted = fields.BooleanField(default=False) is_deleted = fields.BooleanField(default=False)
is_starred = fields.BooleanField(default=False)
class Meta: class Meta:
table = "folders" table = "folders"
@ -52,6 +55,8 @@ class File(models.Model):
updated_at = fields.DatetimeField(auto_now=True) updated_at = fields.DatetimeField(auto_now=True)
is_deleted = fields.BooleanField(default=False) is_deleted = fields.BooleanField(default=False)
deleted_at = fields.DatetimeField(null=True) deleted_at = fields.DatetimeField(null=True)
is_starred = fields.BooleanField(default=False)
last_accessed_at = fields.DatetimeField(null=True)
class Meta: class Meta:
table = "files" table = "files"
@ -128,10 +133,23 @@ class FileRequest(models.Model):
created_at = fields.DatetimeField(auto_now_add=True) created_at = fields.DatetimeField(auto_now_add=True)
expires_at = fields.DatetimeField(null=True) expires_at = fields.DatetimeField(null=True)
is_active = fields.BooleanField(default=True) is_active = fields.BooleanField(default=True)
# TODO: Add fields for custom form configuration
class Meta: class Meta:
table = "file_requests" table = "file_requests"
class WebDAVProperty(models.Model):
id = fields.IntField(pk=True)
resource_type = fields.CharField(max_length=10)
resource_id = fields.IntField()
namespace = fields.CharField(max_length=255)
name = fields.CharField(max_length=255)
value = fields.TextField()
created_at = fields.DatetimeField(auto_now_add=True)
updated_at = fields.DatetimeField(auto_now=True)
class Meta:
table = "webdav_properties"
unique_together = (("resource_type", "resource_id", "namespace", "name"),)
User_Pydantic = pydantic_model_creator(User, name="User_Pydantic") User_Pydantic = pydantic_model_creator(User, name="User_Pydantic")
UserIn_Pydantic = pydantic_model_creator(User, name="UserIn_Pydantic", exclude_readonly=True) UserIn_Pydantic = pydantic_model_creator(User, name="UserIn_Pydantic", exclude_readonly=True)

98
rbox/routers/admin.py Normal file
View File

@ -0,0 +1,98 @@
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from ..auth import get_current_admin_user, get_password_hash
from ..models import User, User_Pydantic
from ..schemas import UserCreate, UserAdminUpdate
router = APIRouter(
prefix="/admin",
tags=["admin"],
dependencies=[Depends(get_current_admin_user)],
responses={403: {"description": "Not enough permissions"}},
)
@router.get("/users", response_model=List[User_Pydantic])
async def get_all_users():
return await User.all()
@router.get("/users/{user_id}", response_model=User_Pydantic)
async def get_user(user_id: int):
user = await User.get_or_none(id=user_id)
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
return user
@router.post("/users", response_model=User_Pydantic, status_code=status.HTTP_201_CREATED)
async def create_user_by_admin(user_in: UserCreate):
user = await User.get_or_none(username=user_in.username)
if user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Username already registered",
)
user = await User.get_or_none(email=user_in.email)
if user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered",
)
hashed_password = get_password_hash(user_in.password)
user = await User.create(
username=user_in.username,
email=user_in.email,
hashed_password=hashed_password,
is_superuser=False, # Admin creates regular users by default
is_active=True,
)
return await User_Pydantic.from_tortoise_orm(user)
@router.put("/users/{user_id}", response_model=User_Pydantic)
async def update_user_by_admin(user_id: int, user_update: UserAdminUpdate):
user = await User.get_or_none(id=user_id)
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
if user_update.username is not None and user_update.username != user.username:
if await User.get_or_none(username=user_update.username):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Username already taken")
user.username = user_update.username
if user_update.email is not None and user_update.email != user.email:
if await User.get_or_none(email=user_update.email):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Email already registered")
user.email = user_update.email
if user_update.password is not None:
user.hashed_password = get_password_hash(user_update.password)
if user_update.is_active is not None:
user.is_active = user_update.is_active
if user_update.is_superuser is not None:
user.is_superuser = user_update.is_superuser
if user_update.storage_quota_bytes is not None:
user.storage_quota_bytes = user_update.storage_quota_bytes
if user_update.plan_type is not None:
user.plan_type = user_update.plan_type
if user_update.is_2fa_enabled is not None:
user.is_2fa_enabled = user_update.is_2fa_enabled
if not user_update.is_2fa_enabled:
user.two_factor_secret = None
user.recovery_codes = None
await user.save()
return await User_Pydantic.from_tortoise_orm(user)
@router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_user_by_admin(user_id: int):
user = await User.get_or_none(id=user_id)
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
await user.delete()
return {"message": "User deleted successfully"}

View File

@ -1,17 +1,41 @@
from datetime import timedelta from datetime import timedelta
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm from fastapi.security import OAuth2PasswordRequestForm
from pydantic import BaseModel
from ..auth import authenticate_user, create_access_token, get_password_hash from ..auth import authenticate_user, create_access_token, get_password_hash, get_current_user, get_current_verified_user
from ..models import User from ..models import User
from ..schemas import Token, UserCreate from ..schemas import Token, UserCreate, TokenData, UserLoginWith2FA
from ..two_factor import (
generate_totp_secret, generate_totp_uri, generate_qr_code_base64,
verify_totp_code, generate_recovery_codes, hash_recovery_codes,
verify_recovery_codes
)
router = APIRouter( router = APIRouter(
prefix="/auth", prefix="/auth",
tags=["auth"], tags=["auth"],
) )
class TwoFactorLogin(BaseModel):
username: str
password: str
two_factor_code: Optional[str] = None
class TwoFactorSetupResponse(BaseModel):
secret: str
qr_code_base64: str
recovery_codes: List[str]
class TwoFactorCode(BaseModel):
two_factor_code: str
class TwoFactorDisable(BaseModel):
password: str
two_factor_code: str
@router.post("/register", response_model=Token) @router.post("/register", response_model=Token)
async def register_user(user_in: UserCreate): async def register_user(user_in: UserCreate):
user = await User.get_or_none(username=user_in.username) user = await User.get_or_none(username=user_in.username)
@ -41,16 +65,98 @@ async def register_user(user_in: UserCreate):
return {"access_token": access_token, "token_type": "bearer"} return {"access_token": access_token, "token_type": "bearer"}
@router.post("/token", response_model=Token) @router.post("/token", response_model=Token)
async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends()): async def login_for_access_token(user_login: UserLoginWith2FA):
user = await authenticate_user(form_data.username, form_data.password) auth_result = await authenticate_user(user_login.username, user_login.password, user_login.two_factor_code)
if not user:
if not auth_result:
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 or 2FA code",
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
user = auth_result["user"]
if auth_result["2fa_required"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Two-factor authentication required",
headers={"X-2FA-Required": "true"},
)
access_token_expires = timedelta(minutes=30) # Use settings access_token_expires = timedelta(minutes=30) # Use settings
access_token = create_access_token( access_token = create_access_token(
data={"sub": user.username}, expires_delta=access_token_expires data={"sub": user.username}, expires_delta=access_token_expires, two_factor_verified=True
) )
return {"access_token": access_token, "token_type": "bearer"} return {"access_token": access_token, "token_type": "bearer"}
@router.post("/2fa/setup", response_model=TwoFactorSetupResponse)
async def setup_two_factor_authentication(current_user: User = Depends(get_current_user)):
if current_user.is_2fa_enabled:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="2FA is already enabled.")
if current_user.two_factor_secret:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="2FA setup already initiated. Verify or disable first.")
secret = generate_totp_secret()
current_user.two_factor_secret = secret
await current_user.save()
totp_uri = generate_totp_uri(secret, current_user.email, "RBox")
qr_code_base64 = generate_qr_code_base64(totp_uri)
recovery_codes = generate_recovery_codes()
hashed_recovery_codes = hash_recovery_codes(recovery_codes)
current_user.recovery_codes = ",".join(hashed_recovery_codes)
await current_user.save()
return TwoFactorSetupResponse(secret=secret, qr_code_base64=qr_code_base64, recovery_codes=recovery_codes)
@router.post("/2fa/verify", response_model=Token)
async def verify_two_factor_authentication(two_factor_code_data: TwoFactorCode, current_user: User = Depends(get_current_user)):
if current_user.is_2fa_enabled:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="2FA is already enabled.")
if not current_user.two_factor_secret:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="2FA setup not initiated.")
if not verify_totp_code(current_user.two_factor_secret, two_factor_code_data.two_factor_code):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid 2FA code.")
current_user.is_2fa_enabled = True
await current_user.save()
access_token_expires = timedelta(minutes=30) # Use settings
access_token = create_access_token(
data={"sub": current_user.username}, expires_delta=access_token_expires, two_factor_verified=True
)
return {"access_token": access_token, "token_type": "bearer"}
@router.post("/2fa/disable", response_model=dict)
async def disable_two_factor_authentication(disable_data: TwoFactorDisable, current_user: User = Depends(get_current_verified_user)):
if not current_user.is_2fa_enabled:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="2FA is not enabled.")
# Verify password
if not verify_password(disable_data.password, current_user.hashed_password):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid password.")
# Verify 2FA code
if not verify_totp_code(current_user.two_factor_secret, disable_data.two_factor_code):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid 2FA code.")
current_user.two_factor_secret = None
current_user.is_2fa_enabled = False
current_user.recovery_codes = None
await current_user.save()
return {"message": "2FA disabled successfully."}
@router.get("/2fa/recovery-codes", response_model=List[str])
async def get_new_recovery_codes(current_user: User = Depends(get_current_verified_user)):
if not current_user.is_2fa_enabled:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="2FA is not enabled.")
recovery_codes = generate_recovery_codes()
hashed_recovery_codes = hash_recovery_codes(recovery_codes)
current_user.recovery_codes = ",".join(hashed_recovery_codes)
await current_user.save()
return recovery_codes

View File

@ -29,6 +29,13 @@ class FileRename(BaseModel):
class FileCopy(BaseModel): class FileCopy(BaseModel):
target_folder_id: Optional[int] = None target_folder_id: Optional[int] = None
class BatchFileOperation(BaseModel):
file_ids: List[int]
operation: str # e.g., "delete", "star", "unstar", "move", "copy"
class BatchMoveCopyPayload(BaseModel):
target_folder_id: Optional[int] = None
@router.post("/upload/{folder_id}", response_model=FileOut, status_code=status.HTTP_201_CREATED) @router.post("/upload/{folder_id}", response_model=FileOut, status_code=status.HTTP_201_CREATED)
@router.post("/upload", response_model=FileOut, status_code=status.HTTP_201_CREATED) @router.post("/upload", response_model=FileOut, status_code=status.HTTP_201_CREATED)
async def upload_file( async def upload_file(
@ -102,6 +109,9 @@ async def download_file(file_id: int, current_user: User = Depends(get_current_u
if not db_file: if not db_file:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
db_file.last_accessed_at = datetime.now()
await db_file.save()
try: try:
# file_content_generator = storage_manager.get_file(current_user.id, db_file.path) # file_content_generator = storage_manager.get_file(current_user.id, db_file.path)
@ -226,6 +236,9 @@ async def get_thumbnail(file_id: int, current_user: User = Depends(get_current_u
if not db_file: if not db_file:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
db_file.last_accessed_at = datetime.now()
await db_file.save()
thumbnail_path = getattr(db_file, 'thumbnail_path', None) thumbnail_path = getattr(db_file, 'thumbnail_path', None)
if not thumbnail_path: if not thumbnail_path:
@ -257,3 +270,128 @@ async def list_photos(current_user: User = Depends(get_current_user)):
mime_type__istartswith="image/" mime_type__istartswith="image/"
).order_by("-created_at") ).order_by("-created_at")
return [await FileOut.from_tortoise_orm(f) for f in files] return [await FileOut.from_tortoise_orm(f) for f in files]
@router.get("/recent", response_model=List[FileOut])
async def list_recent_files(current_user: User = Depends(get_current_user), limit: int = 10):
files = await File.filter(
owner=current_user,
is_deleted=False,
last_accessed_at__isnull=False
).order_by("-last_accessed_at").limit(limit)
return [await FileOut.from_tortoise_orm(f) for f in files]
@router.post("/{file_id}/star", response_model=FileOut)
async def star_file(file_id: int, current_user: User = Depends(get_current_user)):
db_file = await File.get_or_none(id=file_id, owner=current_user, is_deleted=False)
if not db_file:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
db_file.is_starred = True
await db_file.save()
await log_activity(user=current_user, action="file_starred", target_type="file", target_id=file_id)
return await FileOut.from_tortoise_orm(db_file)
@router.post("/{file_id}/unstar", response_model=FileOut)
async def unstar_file(file_id: int, current_user: User = Depends(get_current_user)):
db_file = await File.get_or_none(id=file_id, owner=current_user, is_deleted=False)
if not db_file:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
db_file.is_starred = False
await db_file.save()
await log_activity(user=current_user, action="file_unstarred", target_type="file", target_id=file_id)
return await FileOut.from_tortoise_orm(db_file)
@router.get("/deleted", response_model=List[FileOut])
async def list_deleted_files(current_user: User = Depends(get_current_user)):
files = await File.filter(owner=current_user, is_deleted=True).order_by("-deleted_at")
return [await FileOut.from_tortoise_orm(f) for f in files]
@router.post("/{file_id}/restore", response_model=FileOut)
async def restore_file(file_id: int, current_user: User = Depends(get_current_user)):
db_file = await File.get_or_none(id=file_id, owner=current_user, is_deleted=True)
if not db_file:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deleted file not found")
# Check if a file with the same name exists in the parent folder
existing_file = await File.get_or_none(
name=db_file.name, parent=db_file.parent, owner=current_user, is_deleted=False
)
if existing_file:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="A file with the same name already exists in this location. Please rename the existing file or restore to a different location.")
db_file.is_deleted = False
db_file.deleted_at = None
await db_file.save()
await log_activity(user=current_user, action="file_restored", target_type="file", target_id=file_id)
return await FileOut.from_tortoise_orm(db_file)
@router.post("/batch", response_model=List[FileOut])
async def batch_file_operations(
batch_operation: BatchFileOperation,
payload: Optional[BatchMoveCopyPayload] = None,
current_user: User = Depends(get_current_user)
):
updated_files = []
for file_id in batch_operation.file_ids:
db_file = await File.get_or_none(id=file_id, owner=current_user, is_deleted=False)
if not db_file:
# Skip if file not found or not owned by user
continue
if batch_operation.operation == "delete":
db_file.is_deleted = True
db_file.deleted_at = datetime.now()
await db_file.save()
await log_activity(user=current_user, action="file_deleted_batch", target_type="file", target_id=file_id)
updated_files.append(db_file)
elif batch_operation.operation == "star":
db_file.is_starred = True
await db_file.save()
await log_activity(user=current_user, action="file_starred_batch", target_type="file", target_id=file_id)
updated_files.append(db_file)
elif batch_operation.operation == "unstar":
db_file.is_starred = False
await db_file.save()
await log_activity(user=current_user, action="file_unstarred_batch", target_type="file", target_id=file_id)
updated_files.append(db_file)
elif batch_operation.operation == "move" and payload and payload.target_folder_id is not None:
target_folder = await Folder.get_or_none(id=payload.target_folder_id, owner=current_user, is_deleted=False)
if not target_folder:
continue # Skip if target folder not found
existing_file = await File.get_or_none(
name=db_file.name, parent=target_folder, owner=current_user, is_deleted=False
)
if existing_file and existing_file.id != file_id:
continue # Skip if file with same name exists
db_file.parent = target_folder
await db_file.save()
await log_activity(user=current_user, action="file_moved_batch", target_type="file", target_id=file_id)
updated_files.append(db_file)
elif batch_operation.operation == "copy" and payload and payload.target_folder_id is not None:
target_folder = await Folder.get_or_none(id=payload.target_folder_id, owner=current_user, is_deleted=False)
if not target_folder:
continue # Skip if target folder not found
base_name = db_file.name
name_parts = os.path.splitext(base_name)
counter = 1
new_name = base_name
while await File.get_or_none(name=new_name, parent=target_folder, owner=current_user, is_deleted=False):
new_name = f"{name_parts[0]} (copy {counter}){name_parts[1]}"
counter += 1
new_file = await File.create(
name=new_name,
path=db_file.path,
size=db_file.size,
mime_type=db_file.mime_type,
file_hash=db_file.file_hash,
owner=current_user,
parent=target_folder
)
await log_activity(user=current_user, action="file_copied_batch", target_type="file", target_id=new_file.id)
updated_files.append(new_file)
return [await FileOut.from_tortoise_orm(f) for f in updated_files]

View File

@ -3,7 +3,7 @@ from typing import List, Optional
from ..auth import get_current_user from ..auth import get_current_user
from ..models import User, Folder from ..models import User, Folder
from ..schemas import FolderCreate, FolderOut, FolderUpdate from ..schemas import FolderCreate, FolderOut, FolderUpdate, BatchFolderOperation, BatchMoveCopyPayload
router = APIRouter( router = APIRouter(
prefix="/folders", prefix="/folders",
@ -103,3 +103,62 @@ async def delete_folder(folder_id: int, current_user: User = Depends(get_current
folder.is_deleted = True folder.is_deleted = True
await folder.save() await folder.save()
return return
@router.post("/{folder_id}/star", response_model=FolderOut)
async def star_folder(folder_id: int, current_user: User = Depends(get_current_user)):
db_folder = await Folder.get_or_none(id=folder_id, owner=current_user, is_deleted=False)
if not db_folder:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Folder not found")
db_folder.is_starred = True
await db_folder.save()
return await FolderOut.from_tortoise_orm(db_folder)
@router.post("/{folder_id}/unstar", response_model=FolderOut)
async def unstar_folder(folder_id: int, current_user: User = Depends(get_current_user)):
db_folder = await Folder.get_or_none(id=folder_id, owner=current_user, is_deleted=False)
if not db_folder:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Folder not found")
db_folder.is_starred = False
await db_folder.save()
return await FolderOut.from_tortoise_orm(db_folder)
@router.post("/batch", response_model=List[FolderOut])
async def batch_folder_operations(
batch_operation: BatchFolderOperation,
payload: Optional[BatchMoveCopyPayload] = None,
current_user: User = Depends(get_current_user)
):
updated_folders = []
for folder_id in batch_operation.folder_ids:
db_folder = await Folder.get_or_none(id=folder_id, owner=current_user, is_deleted=False)
if not db_folder:
continue # Skip if folder not found or not owned by user
if batch_operation.operation == "delete":
db_folder.is_deleted = True
await db_folder.save()
updated_folders.append(db_folder)
elif batch_operation.operation == "star":
db_folder.is_starred = True
await db_folder.save()
updated_folders.append(db_folder)
elif batch_operation.operation == "unstar":
db_folder.is_starred = False
await db_folder.save()
updated_folders.append(db_folder)
elif batch_operation.operation == "move" and payload and payload.target_folder_id is not None:
target_folder = await Folder.get_or_none(id=payload.target_folder_id, owner=current_user, is_deleted=False)
if not target_folder:
continue # Skip if target folder not found
existing_folder = await Folder.get_or_none(
name=db_folder.name, parent=target_folder, owner=current_user, is_deleted=False
)
if existing_folder and existing_folder.id != folder_id:
continue # Skip if folder with same name exists
db_folder.parent = target_folder
await db_folder.save()
updated_folders.append(db_folder)
return [await FolderOut.from_tortoise_orm(f) for f in updated_folders]

View File

@ -1,5 +1,5 @@
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from typing import Optional from typing import Optional, List
import secrets import secrets
from datetime import datetime, timedelta from datetime import datetime, timedelta
@ -52,6 +52,11 @@ async def create_share_link(share_in: ShareCreate, current_user: User = Depends(
) )
return await ShareOut.from_tortoise_orm(share) return await ShareOut.from_tortoise_orm(share)
@router.get("/my", response_model=List[ShareOut])
async def list_my_shares(current_user: User = Depends(get_current_user)):
shares = await Share.filter(owner=current_user).order_by("-created_at")
return [await ShareOut.from_tortoise_orm(share) for share in shares]
@router.get("/{share_token}", response_model=ShareOut) @router.get("/{share_token}", response_model=ShareOut)
async def get_share_link_info(share_token: str): async def get_share_link_info(share_token: str):
share = await Share.get_or_none(token=share_token) share = await Share.get_or_none(token=share_token)
@ -67,6 +72,28 @@ async def get_share_link_info(share_token: str):
return await ShareOut.from_tortoise_orm(share) return await ShareOut.from_tortoise_orm(share)
@router.put("/{share_id}", response_model=ShareOut)
async def update_share(share_id: int, share_in: ShareCreate, current_user: User = Depends(get_current_user)):
share = await Share.get_or_none(id=share_id, owner=current_user)
if not share:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Share link not found or does not belong to you")
if share_in.expires_at is not None:
share.expires_at = share_in.expires_at
if share_in.password is not None:
share.hashed_password = get_password_hash(share_in.password)
share.password_protected = True
elif share_in.password == "": # Allow clearing password
share.hashed_password = None
share.password_protected = False
if share_in.permission_level is not None:
share.permission_level = share_in.permission_level
await share.save()
return await ShareOut.from_tortoise_orm(share)
@router.post("/{share_token}/access") @router.post("/{share_token}/access")
async def access_shared_content(share_token: str, password: Optional[str] = None): async def access_shared_content(share_token: str, password: Optional[str] = None):
share = await Share.get_or_none(token=share_token) share = await Share.get_or_none(token=share_token)

28
rbox/routers/starred.py Normal file
View File

@ -0,0 +1,28 @@
from typing import List
from fastapi import APIRouter, Depends
from ..auth import get_current_user
from ..models import User, File, Folder
from ..schemas import FileOut, FolderOut
router = APIRouter(
prefix="/starred",
tags=["starred"],
)
@router.get("/files", response_model=List[FileOut])
async def list_starred_files(current_user: User = Depends(get_current_user)):
files = await File.filter(owner=current_user, is_starred=True, is_deleted=False).order_by("name")
return [await FileOut.from_tortoise_orm(f) for f in files]
@router.get("/folders", response_model=List[FolderOut])
async def list_starred_folders(current_user: User = Depends(get_current_user)):
folders = await Folder.filter(owner=current_user, is_starred=True, is_deleted=False).order_by("name")
return [await FolderOut.from_tortoise_orm(f) for f in folders]
@router.get("/all", response_model=List[FileOut]) # This will return files and folders as files for now
async def list_all_starred(current_user: User = Depends(get_current_user)):
starred_files = await File.filter(owner=current_user, is_starred=True, is_deleted=False).order_by("name")
# For simplicity, we'll return files only for now. A more complex solution would involve a union or a custom schema.
return [await FileOut.from_tortoise_orm(f) for f in starred_files]

View File

@ -12,12 +12,26 @@ class UserLogin(BaseModel):
username: str username: str
password: str password: str
class UserLoginWith2FA(UserLogin):
two_factor_code: str
class UserAdminUpdate(BaseModel):
username: Optional[str] = None
email: Optional[EmailStr] = None
password: Optional[str] = None
is_active: Optional[bool] = None
is_superuser: Optional[bool] = None
storage_quota_bytes: Optional[int] = None
plan_type: Optional[str] = None
is_2fa_enabled: Optional[bool] = None
class Token(BaseModel): class Token(BaseModel):
access_token: str access_token: str
token_type: str token_type: str
class TokenData(BaseModel): class TokenData(BaseModel):
username: str | None = None username: str | None = None
two_factor_verified: bool = False
class FolderCreate(BaseModel): class FolderCreate(BaseModel):
name: str name: str
@ -84,3 +98,19 @@ FolderOut = pydantic_model_creator(Folder, name="FolderOut")
FileOut = pydantic_model_creator(File, name="FileOut") FileOut = pydantic_model_creator(File, name="FileOut")
ShareOut = pydantic_model_creator(Share, name="ShareOut") ShareOut = pydantic_model_creator(Share, name="ShareOut")
FileVersionOut = pydantic_model_creator(FileVersion, name="FileVersionOut") FileVersionOut = pydantic_model_creator(FileVersion, name="FileVersionOut")
class ErrorResponse(BaseModel):
code: int
message: str
details: Optional[str] = None
class BatchFileOperation(BaseModel):
file_ids: List[int]
operation: str # e.g., "delete", "move", "copy", "star", "unstar"
class BatchFolderOperation(BaseModel):
folder_ids: List[int]
operation: str # e.g., "delete", "move", "star", "unstar"
class BatchMoveCopyPayload(BaseModel):
target_folder_id: Optional[int] = None

60
rbox/two_factor.py Normal file
View File

@ -0,0 +1,60 @@
import pyotp
import qrcode
import io
import base64
import secrets
import hashlib
from typing import List, Optional
def generate_totp_secret() -> str:
"""Generates a random base32 TOTP secret."""
return pyotp.random_base32()
def generate_totp_uri(secret: str, account_name: str, issuer_name: str) -> str:
"""Generates a Google Authenticator-compatible TOTP URI."""
return pyotp.totp.TOTP(secret).provisioning_uri(name=account_name, issuer_name=issuer_name)
def generate_qr_code_base64(uri: str) -> str:
"""Generates a base64 encoded QR code image for a given URI."""
qr = qrcode.QRCode(
version=1,
error_correction=qrcode.constants.ERROR_CORRECT_L,
box_size=10,
border=4,
)
qr.add_data(uri)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
buffered = io.BytesIO()
img.save(buffered, format="PNG")
return base64.b64encode(buffered.getvalue()).decode("utf-8")
def verify_totp_code(secret: str, code: str) -> bool:
"""Verifies a TOTP code against a secret."""
totp = pyotp.TOTP(secret)
return totp.verify(code)
def generate_recovery_codes(num_codes: int = 10) -> List[str]:
"""Generates a list of random recovery codes."""
return [secrets.token_urlsafe(16) for _ in range(num_codes)]
def hash_recovery_code(code: str) -> str:
"""Hashes a single recovery code using SHA256."""
return hashlib.sha256(code.encode('utf-8')).hexdigest()
def verify_recovery_code(plain_code: str, hashed_code: str) -> bool:
"""Verifies a plain recovery code against its hashed version."""
return hash_recovery_code(plain_code) == hashed_code
def hash_recovery_codes(codes: List[str]) -> List[str]:
"""Hashes a list of recovery codes."""
return [hash_recovery_code(code) for code in codes]
def verify_recovery_codes(plain_code: str, hashed_codes: List[str]) -> bool:
"""Verifies if a plain recovery code matches any of the hashed recovery codes."""
for hashed_code in hashed_codes:
if verify_recovery_code(plain_code, hashed_code):
return True
return False

View File

@ -1,4 +1,5 @@
from fastapi import APIRouter, Request, Response, Depends, HTTPException, status, Header from fastapi import APIRouter, Request, Response, Depends, HTTPException, status, Header
from fastapi.responses import StreamingResponse
from typing import Optional from typing import Optional
from xml.etree import ElementTree as ET from xml.etree import ElementTree as ET
from datetime import datetime from datetime import datetime
@ -9,7 +10,7 @@ import base64
from urllib.parse import unquote, urlparse from urllib.parse import unquote, urlparse
from .auth import get_current_user, verify_password from .auth import get_current_user, verify_password
from .models import User, File, Folder from .models import User, File, Folder, WebDAVProperty
from .storage import storage_manager from .storage import storage_manager
from .activity import log_activity from .activity import log_activity
@ -126,7 +127,14 @@ def build_href(base_path: str, name: str, is_collection: bool):
path += '/' path += '/'
return path return path
def create_propstat_element(props: dict, status: str = "HTTP/1.1 200 OK"): async def get_custom_properties(resource_type: str, resource_id: int):
props = await WebDAVProperty.filter(
resource_type=resource_type,
resource_id=resource_id
)
return {(prop.namespace, prop.name): prop.value for prop in props}
def create_propstat_element(props: dict, custom_props: dict = None, status: str = "HTTP/1.1 200 OK"):
propstat = ET.Element("D:propstat") propstat = ET.Element("D:propstat")
prop = ET.SubElement(propstat, "D:prop") prop = ET.SubElement(propstat, "D:prop")
@ -154,11 +162,46 @@ def create_propstat_element(props: dict, status: str = "HTTP/1.1 200 OK"):
elem = ET.SubElement(prop, f"D:{key}") elem = ET.SubElement(prop, f"D:{key}")
elem.text = value elem.text = value
if custom_props:
for (namespace, name), value in custom_props.items():
if namespace == "DAV:":
continue
elem = ET.SubElement(prop, f"{{{namespace}}}{name}")
elem.text = value
status_elem = ET.SubElement(propstat, "D:status") status_elem = ET.SubElement(propstat, "D:status")
status_elem.text = status status_elem.text = status
return propstat return propstat
def parse_propfind_body(body: bytes):
if not body:
return None
try:
root = ET.fromstring(body)
allprop = root.find(".//{DAV:}allprop")
if allprop is not None:
return "allprop"
propname = root.find(".//{DAV:}propname")
if propname is not None:
return "propname"
prop = root.find(".//{DAV:}prop")
if prop is not None:
requested_props = []
for child in prop:
ns = child.tag.split('}')[0][1:] if '}' in child.tag else "DAV:"
name = child.tag.split('}')[1] if '}' in child.tag else child.tag
requested_props.append((ns, name))
return requested_props
except:
pass
return None
@router.api_route("/{full_path:path}", methods=["OPTIONS"]) @router.api_route("/{full_path:path}", methods=["OPTIONS"])
async def webdav_options(full_path: str): async def webdav_options(full_path: str):
return Response( return Response(
@ -174,6 +217,8 @@ async def webdav_options(full_path: str):
async def handle_propfind(request: Request, full_path: str, current_user: User = Depends(webdav_auth)): async def handle_propfind(request: Request, full_path: str, current_user: User = Depends(webdav_auth)):
depth = request.headers.get("Depth", "1") depth = request.headers.get("Depth", "1")
full_path = unquote(full_path).strip('/') full_path = unquote(full_path).strip('/')
body = await request.body()
requested_props = parse_propfind_body(body)
resource, parent, exists = await resolve_path(full_path, current_user) resource, parent, exists = await resolve_path(full_path, current_user)
@ -212,7 +257,8 @@ async def handle_propfind(request: Request, full_path: str, current_user: User =
"creationdate": folder.created_at, "creationdate": folder.created_at,
"getlastmodified": folder.updated_at "getlastmodified": folder.updated_at
} }
response.append(create_propstat_element(props)) custom_props = await get_custom_properties("folder", folder.id) if requested_props == "allprop" or (isinstance(requested_props, list) and any(ns != "DAV:" for ns, _ in requested_props)) else None
response.append(create_propstat_element(props, custom_props))
for file in files: for file in files:
response = ET.SubElement(multistatus, "D:response") response = ET.SubElement(multistatus, "D:response")
@ -228,7 +274,8 @@ async def handle_propfind(request: Request, full_path: str, current_user: User =
"getlastmodified": file.updated_at, "getlastmodified": file.updated_at,
"getetag": f'"{file.file_hash}"' "getetag": f'"{file.file_hash}"'
} }
response.append(create_propstat_element(props)) custom_props = await get_custom_properties("file", file.id) if requested_props == "allprop" or (isinstance(requested_props, list) and any(ns != "DAV:" for ns, _ in requested_props)) else None
response.append(create_propstat_element(props, custom_props))
elif isinstance(resource, Folder): elif isinstance(resource, Folder):
response = ET.SubElement(multistatus, "D:response") response = ET.SubElement(multistatus, "D:response")
@ -241,7 +288,8 @@ async def handle_propfind(request: Request, full_path: str, current_user: User =
"creationdate": resource.created_at, "creationdate": resource.created_at,
"getlastmodified": resource.updated_at "getlastmodified": resource.updated_at
} }
response.append(create_propstat_element(props)) custom_props = await get_custom_properties("folder", resource.id) if requested_props == "allprop" or (isinstance(requested_props, list) and any(ns != "DAV:" for ns, _ in requested_props)) else None
response.append(create_propstat_element(props, custom_props))
if depth in ["1", "infinity"]: if depth in ["1", "infinity"]:
folders = await Folder.filter(owner=current_user, parent=resource, is_deleted=False) folders = await Folder.filter(owner=current_user, parent=resource, is_deleted=False)
@ -258,7 +306,8 @@ async def handle_propfind(request: Request, full_path: str, current_user: User =
"creationdate": folder.created_at, "creationdate": folder.created_at,
"getlastmodified": folder.updated_at "getlastmodified": folder.updated_at
} }
response.append(create_propstat_element(props)) custom_props = await get_custom_properties("folder", folder.id) if requested_props == "allprop" or (isinstance(requested_props, list) and any(ns != "DAV:" for ns, _ in requested_props)) else None
response.append(create_propstat_element(props, custom_props))
for file in files: for file in files:
response = ET.SubElement(multistatus, "D:response") response = ET.SubElement(multistatus, "D:response")
@ -274,7 +323,8 @@ async def handle_propfind(request: Request, full_path: str, current_user: User =
"getlastmodified": file.updated_at, "getlastmodified": file.updated_at,
"getetag": f'"{file.file_hash}"' "getetag": f'"{file.file_hash}"'
} }
response.append(create_propstat_element(props)) custom_props = await get_custom_properties("file", file.id) if requested_props == "allprop" or (isinstance(requested_props, list) and any(ns != "DAV:" for ns, _ in requested_props)) else None
response.append(create_propstat_element(props, custom_props))
elif isinstance(resource, File): elif isinstance(resource, File):
response = ET.SubElement(multistatus, "D:response") response = ET.SubElement(multistatus, "D:response")
@ -290,7 +340,8 @@ async def handle_propfind(request: Request, full_path: str, current_user: User =
"getlastmodified": resource.updated_at, "getlastmodified": resource.updated_at,
"getetag": f'"{resource.file_hash}"' "getetag": f'"{resource.file_hash}"'
} }
response.append(create_propstat_element(props)) custom_props = await get_custom_properties("file", resource.id) if requested_props == "allprop" or (isinstance(requested_props, list) and any(ns != "DAV:" for ns, _ in requested_props)) else None
response.append(create_propstat_element(props, custom_props))
xml_content = ET.tostring(multistatus, encoding="utf-8", xml_declaration=True) xml_content = ET.tostring(multistatus, encoding="utf-8", xml_declaration=True)
return Response(content=xml_content, media_type="application/xml; charset=utf-8", status_code=207) return Response(content=xml_content, media_type="application/xml; charset=utf-8", status_code=207)
@ -320,7 +371,7 @@ async def handle_get(request: Request, full_path: str, current_user: User = Depe
async for chunk in storage_manager.get_file(current_user.id, resource.path): async for chunk in storage_manager.get_file(current_user.id, resource.path):
yield chunk yield chunk
return Response( return StreamingResponse(
content=file_iterator(), content=file_iterator(),
media_type=resource.mime_type, media_type=resource.mime_type,
headers={ headers={
@ -652,3 +703,138 @@ async def handle_unlock(request: Request, full_path: str, current_user: User = D
WebDAVLock.remove_lock(full_path) WebDAVLock.remove_lock(full_path)
return Response(status_code=204) return Response(status_code=204)
@router.api_route("/{full_path:path}", methods=["PROPPATCH"])
async def handle_proppatch(request: Request, full_path: str, current_user: User = Depends(webdav_auth)):
full_path = unquote(full_path).strip('/')
resource, parent, exists = await resolve_path(full_path, current_user)
if not resource:
raise HTTPException(status_code=404, detail="Resource not found")
body = await request.body()
if not body:
raise HTTPException(status_code=400, detail="Request body required")
try:
root = ET.fromstring(body)
except:
raise HTTPException(status_code=400, detail="Invalid XML")
resource_type = "file" if isinstance(resource, File) else "folder"
resource_id = resource.id
set_props = []
remove_props = []
failed_props = []
set_element = root.find(".//{DAV:}set")
if set_element is not None:
prop_element = set_element.find(".//{DAV:}prop")
if prop_element is not None:
for child in prop_element:
ns = child.tag.split('}')[0][1:] if '}' in child.tag else "DAV:"
name = child.tag.split('}')[1] if '}' in child.tag else child.tag
value = child.text or ""
if ns == "DAV:":
live_props = ["creationdate", "getcontentlength", "getcontenttype",
"getetag", "getlastmodified", "resourcetype"]
if name in live_props:
failed_props.append((ns, name, "409 Conflict"))
continue
try:
existing_prop = await WebDAVProperty.get_or_none(
resource_type=resource_type,
resource_id=resource_id,
namespace=ns,
name=name
)
if existing_prop:
existing_prop.value = value
await existing_prop.save()
else:
await WebDAVProperty.create(
resource_type=resource_type,
resource_id=resource_id,
namespace=ns,
name=name,
value=value
)
set_props.append((ns, name))
except Exception as e:
failed_props.append((ns, name, "500 Internal Server Error"))
remove_element = root.find(".//{DAV:}remove")
if remove_element is not None:
prop_element = remove_element.find(".//{DAV:}prop")
if prop_element is not None:
for child in prop_element:
ns = child.tag.split('}')[0][1:] if '}' in child.tag else "DAV:"
name = child.tag.split('}')[1] if '}' in child.tag else child.tag
if ns == "DAV:":
failed_props.append((ns, name, "409 Conflict"))
continue
try:
existing_prop = await WebDAVProperty.get_or_none(
resource_type=resource_type,
resource_id=resource_id,
namespace=ns,
name=name
)
if existing_prop:
await existing_prop.delete()
remove_props.append((ns, name))
else:
failed_props.append((ns, name, "404 Not Found"))
except Exception as e:
failed_props.append((ns, name, "500 Internal Server Error"))
multistatus = ET.Element("D:multistatus", {"xmlns:D": "DAV:"})
response_elem = ET.SubElement(multistatus, "D:response")
href = ET.SubElement(response_elem, "D:href")
href.text = f"/webdav/{full_path}"
if set_props or remove_props:
propstat = ET.SubElement(response_elem, "D:propstat")
prop = ET.SubElement(propstat, "D:prop")
for ns, name in set_props + remove_props:
if ns == "DAV:":
ET.SubElement(prop, f"D:{name}")
else:
ET.SubElement(prop, f"{{{ns}}}{name}")
status_elem = ET.SubElement(propstat, "D:status")
status_elem.text = "HTTP/1.1 200 OK"
if failed_props:
prop_by_status = {}
for ns, name, status_text in failed_props:
if status_text not in prop_by_status:
prop_by_status[status_text] = []
prop_by_status[status_text].append((ns, name))
for status_text, props_list in prop_by_status.items():
propstat = ET.SubElement(response_elem, "D:propstat")
prop = ET.SubElement(propstat, "D:prop")
for ns, name in props_list:
if ns == "DAV:":
ET.SubElement(prop, f"D:{name}")
else:
ET.SubElement(prop, f"{{{ns}}}{name}")
status_elem = ET.SubElement(propstat, "D:status")
status_elem.text = f"HTTP/1.1 {status_text}"
await log_activity(current_user, "properties_modified", resource_type, resource_id)
xml_content = ET.tostring(multistatus, encoding="utf-8", xml_declaration=True)
return Response(content=xml_content, media_type="application/xml; charset=utf-8", status_code=207)

View File

@ -9,7 +9,7 @@ if [ ! -f .env ]; then
fi fi
echo "Starting database services..." echo "Starting database services..."
docker-compose up -d db redis docker compose up -d db redis
echo "Waiting for database to be ready..." echo "Waiting for database to be ready..."
sleep 5 sleep 5

View File

@ -171,6 +171,25 @@ body {
background-color: #AA0000; background-color: #AA0000;
} }
.button-danger {
background-color: var(--secondary-color);
color: var(--accent-color);
}
.button-danger:hover {
background-color: #AA0000;
}
.deleted-item {
border-color: var(--secondary-color);
background-color: rgba(255, 0, 0, 0.05); /* Light red tint */
}
.deleted-item .file-name,
.deleted-item .file-deleted-date {
color: var(--secondary-color);
}
.input-field { .input-field {
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
padding: var(--spacing-unit); padding: var(--spacing-unit);
@ -786,3 +805,93 @@ body.dark-mode {
margin: 0 0 calc(var(--spacing-unit) * 2) 0; margin: 0 0 calc(var(--spacing-unit) * 2) 0;
color: var(--primary-color); color: var(--primary-color);
} }
.admin-dashboard-container {
background-color: var(--accent-color);
border-radius: 8px;
padding: calc(var(--spacing-unit) * 2);
}
.admin-dashboard-container h2 {
margin: 0 0 calc(var(--spacing-unit) * 2) 0;
color: var(--primary-color);
border-bottom: 1px solid var(--border-color);
padding-bottom: calc(var(--spacing-unit) * 2);
}
.admin-section {
margin-bottom: calc(var(--spacing-unit) * 3);
}
.admin-section h3 {
margin: 0 0 calc(var(--spacing-unit) * 1.5) 0;
color: var(--text-color);
font-size: 1.2rem;
}
.user-list {
display: flex;
flex-direction: column;
gap: var(--spacing-unit);
}
.user-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-unit) calc(var(--spacing-unit) * 2);
border: 1px solid var(--border-color);
border-radius: 4px;
background-color: var(--background-color);
}
.user-actions {
display: flex;
gap: var(--spacing-unit);
}
.button-small {
padding: calc(var(--spacing-unit) / 2) var(--spacing-unit);
font-size: 0.8rem;
}
.star-btn {
color: gold; /* Gold color for stars */
border: none;
background: none;
font-size: 1.2rem;
cursor: pointer;
padding: 0;
margin-left: var(--spacing-unit);
}
.star-btn:hover {
transform: scale(1.1);
}
.batch-actions {
display: flex;
align-items: center;
gap: var(--spacing-unit);
background-color: var(--background-color);
padding: var(--spacing-unit);
border-radius: 8px;
margin-bottom: calc(var(--spacing-unit) * 2);
flex-wrap: wrap;
}
.batch-actions label {
font-weight: 500;
margin-right: var(--spacing-unit);
}
.select-item {
margin-right: var(--spacing-unit);
}
.file-item .select-item {
position: absolute;
top: var(--spacing-unit);
left: var(--spacing-unit);
z-index: 1;
}

View File

@ -48,8 +48,17 @@ class APIClient {
} }
if (!response.ok) { if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Unknown error' })); let errorData;
throw new Error(error.detail || 'Request failed'); try {
errorData = await response.json();
} catch (e) {
errorData = { message: 'Unknown error' };
}
const errorMessage = errorData.detail || errorData.message || 'Request failed';
document.dispatchEvent(new CustomEvent('show-toast', {
detail: { message: errorMessage, type: 'error' }
}));
throw new Error(errorMessage);
} }
if (response.status === 204) { if (response.status === 204) {
@ -200,9 +209,118 @@ class APIClient {
return this.request('files/photos'); return this.request('files/photos');
} }
getThumbnailUrl(fileId) { async getThumbnailUrl(fileId) {
return `${this.baseURL}files/thumbnail/${fileId}`; return `${this.baseURL}files/thumbnail/${fileId}`;
} }
async listDeletedFiles() {
return this.request('files/deleted');
}
async restoreFile(fileId) {
return this.request(`files/${fileId}/restore`, {
method: 'POST'
});
}
async listUsers() {
return this.request('admin/users');
}
async createUser(userData) {
return this.request('admin/users', {
method: 'POST',
body: userData
});
}
async updateUser(userId, userData) {
return this.request(`admin/users/${userId}`, {
method: 'PUT',
body: userData
});
}
async deleteUser(userId) {
return this.request(`admin/users/${userId}`, {
method: 'DELETE'
});
}
async starFile(fileId) {
return this.request(`files/${fileId}/star`, {
method: 'POST'
});
}
async unstarFile(fileId) {
return this.request(`files/${fileId}/unstar`, {
method: 'POST'
});
}
async starFolder(folderId) {
return this.request(`folders/${folderId}/star`, {
method: 'POST'
});
}
async unstarFolder(folderId) {
return this.request(`folders/${folderId}/unstar`, {
method: 'POST'
});
}
async listStarredFiles() {
return this.request('starred/files');
}
async listStarredFolders() {
return this.request('starred/folders');
}
async listRecentFiles() {
return this.request('files/recent');
}
async listMyShares() {
return this.request('shares/my');
}
async updateShare(shareId, shareData) {
return this.request(`shares/${shareId}`, {
method: 'PUT',
body: shareData
});
}
async deleteShare(shareId) {
return this.request(`shares/${shareId}`, {
method: 'DELETE'
});
}
async batchFileOperations(operation, fileIds, targetFolderId = null) {
const payload = { file_ids: fileIds, operation: operation };
if (targetFolderId !== null) {
payload.target_folder_id = targetFolderId;
}
return this.request('files/batch', {
method: 'POST',
body: payload
});
}
async batchFolderOperations(operation, folderIds, targetFolderId = null) {
const payload = { folder_ids: folderIds, operation: operation };
if (targetFolderId !== null) {
payload.target_folder_id = targetFolderId;
}
return this.request('folders/batch', {
method: 'POST',
body: payload
});
}
} }
export const api = new APIClient(); export const api = new APIClient();

View File

@ -0,0 +1,174 @@
import { api } from '../api.js';
export class AdminDashboard extends HTMLElement {
constructor() {
super();
this.users = [];
}
async connectedCallback() {
await this.loadUsers();
}
async loadUsers() {
try {
this.users = await api.listUsers();
this.render();
} catch (error) {
console.error('Failed to load users:', error);
this.innerHTML = '<p class="error-message">Failed to load users. Do you have admin privileges?</p>';
}
}
render() {
this.innerHTML = `
<div class="admin-dashboard-container">
<h2>Admin Dashboard</h2>
<div class="admin-section">
<h3>User Management</h3>
<button id="createUserButton" class="button button-primary">Create New User</button>
<div class="user-list">
${this.users.map(user => this.renderUser(user)).join('')}
</div>
</div>
<div id="userModal" class="modal">
<div class="modal-content">
<span class="close-button">&times;</span>
<h3>Edit User</h3>
<form id="userForm">
<input type="hidden" id="userId">
<label for="username">Username:</label>
<input type="text" id="username" required>
<label for="email">Email:</label>
<input type="email" id="email" required>
<label for="password">Password (leave blank to keep current):</label>
<input type="password" id="password">
<label for="isSuperuser">Superuser:</label>
<input type="checkbox" id="isSuperuser">
<label for="isActive">Active:</label>
<input type="checkbox" id="isActive">
<label for="is2faEnabled">2FA Enabled:</label>
<input type="checkbox" id="is2faEnabled">
<label for="storageQuotaBytes">Storage Quota (Bytes):</label>
<input type="number" id="storageQuotaBytes">
<label for="planType">Plan Type:</label>
<input type="text" id="planType">
<button type="submit" class="button button-primary">Save</button>
</form>
</div>
</div>
</div>
`;
this.querySelector('#createUserButton').addEventListener('click', () => this._showUserModal());
this.querySelector('.user-list').addEventListener('click', this._handleUserAction.bind(this));
this.querySelector('.close-button').addEventListener('click', () => this.querySelector('#userModal').style.display = 'none');
this.querySelector('#userForm').addEventListener('submit', this._handleUserFormSubmit.bind(this));
}
_handleUserAction(event) {
const target = event.target;
const userItem = target.closest('.user-item');
if (!userItem) return;
const userId = userItem.dataset.userId; // Assuming user ID will be stored in data-userId attribute
if (target.classList.contains('button-danger')) {
this._deleteUser(userId);
} else if (target.classList.contains('button')) { // Edit button
this._showUserModal(userId);
}
}
_showUserModal(userId = null) {
const modal = this.querySelector('#userModal');
const form = this.querySelector('#userForm');
form.reset(); // Clear previous form data
if (userId) {
const user = this.users.find(u => u.id == userId);
if (user) {
this.querySelector('#userId').value = user.id;
this.querySelector('#username').value = user.username;
this.querySelector('#email').value = user.email;
this.querySelector('#isSuperuser').checked = user.is_superuser;
this.querySelector('#isActive').checked = user.is_active;
this.querySelector('#is2faEnabled').checked = user.is_2fa_enabled;
this.querySelector('#storageQuotaBytes').value = user.storage_quota_bytes;
this.querySelector('#planType').value = user.plan_type;
this.querySelector('h3').textContent = 'Edit User';
}
} else {
this.querySelector('#userId').value = '';
this.querySelector('h3').textContent = 'Create New User';
}
modal.style.display = 'block';
}
async _handleUserFormSubmit(event) {
event.preventDefault();
const userId = this.querySelector('#userId').value;
const userData = {
username: this.querySelector('#username').value,
email: this.querySelector('#email').value,
password: this.querySelector('#password').value || undefined, // Only send if not empty
is_superuser: this.querySelector('#isSuperuser').checked,
is_active: this.querySelector('#isActive').checked,
is_2fa_enabled: this.querySelector('#is2faEnabled').checked,
storage_quota_bytes: parseInt(this.querySelector('#storageQuotaBytes').value),
plan_type: this.querySelector('#planType').value,
};
try {
if (userId) {
await api.updateUser(userId, userData);
} else {
await api.createUser(userData);
}
this.querySelector('#userModal').style.display = 'none';
await this.loadUsers(); // Refresh the list
} catch (error) {
console.error('Failed to save user:', error);
alert('Failed to save user: ' + error.message);
}
}
async _deleteUser(userId) {
if (!confirm('Are you sure you want to delete this user?')) {
return;
}
try {
await api.deleteUser(userId);
await this.loadUsers(); // Refresh the list
} catch (error) {
console.error('Failed to delete user:', error);
alert('Failed to delete user: ' + error.message);
}
}
renderUser(user) {
const formatBytes = (bytes) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
return `
<div class="user-item" data-user-id="${user.id}">
<span>
${user.username} (${user.email}) - Superuser: ${user.is_superuser ? 'Yes' : 'No'} - 2FA: ${user.is_2fa_enabled ? 'Yes' : 'No'} - Active: ${user.is_active ? 'Yes' : 'No'} - Storage: ${formatBytes(user.storage_quota_bytes)} - Plan: ${user.plan_type}
</span>
<div class="user-actions">
<button class="button button-small">Edit</button>
<button class="button button-small button-danger">Delete</button>
</div>
</div>
`;
}
}
customElements.define('admin-dashboard', AdminDashboard);

View File

@ -0,0 +1,109 @@
import { api } from '../api.js';
export class DeletedFiles extends HTMLElement {
constructor() {
super();
this.deletedFiles = [];
}
async connectedCallback() {
await this.loadDeletedFiles();
}
async loadDeletedFiles() {
try {
this.deletedFiles = await api.listDeletedFiles();
this.render();
} catch (error) {
console.error('Failed to load deleted files:', error);
this.innerHTML = '<p class="error-message">Failed to load deleted files.</p>';
}
}
render() {
if (this.deletedFiles.length === 0) {
this.innerHTML = `
<div class="deleted-files-container">
<h2>Deleted Files</h2>
<p class="empty-state">No deleted files found.</p>
</div>
`;
return;
}
this.innerHTML = `
<div class="deleted-files-container">
<h2>Deleted Files</h2>
<div class="file-grid">
${this.deletedFiles.map(file => this.renderDeletedFile(file)).join('')}
</div>
</div>
`;
this.attachListeners();
}
renderDeletedFile(file) {
const icon = this.getFileIcon(file.mime_type);
const size = this.formatFileSize(file.size);
const deletedDate = new Date(file.deleted_at).toLocaleDateString();
return `
<div class="file-item deleted-item" data-file-id="${file.id}">
<div class="file-icon">${icon}</div>
<div class="file-name">${file.name}</div>
<div class="file-size">${size}</div>
<div class="file-deleted-date">Deleted: ${deletedDate}</div>
<div class="file-actions-menu">
<button class="button button-danger action-btn" data-action="restore" data-id="${file.id}">Restore</button>
</div>
</div>
`;
}
getFileIcon(mimeType) {
if (mimeType.startsWith('image/')) return '&#128247;';
if (mimeType.startsWith('video/')) return '&#127909;';
if (mimeType.startsWith('audio/')) return '&#127925;';
if (mimeType.includes('pdf')) return '&#128196;';
if (mimeType.includes('text')) return '&#128196;';
return '&#128196;';
}
formatFileSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB';
return (bytes / 1073741824).toFixed(1) + ' GB';
}
attachListeners() {
this.querySelectorAll('.action-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const action = btn.dataset.action;
const id = parseInt(btn.dataset.id);
if (action === 'restore') {
await this.handleRestore(id);
}
});
});
}
async handleRestore(fileId) {
if (confirm('Are you sure you want to restore this file?')) {
try {
await api.restoreFile(fileId);
document.dispatchEvent(new CustomEvent('show-toast', {
detail: { message: 'File restored successfully!', type: 'success' }
}));
await this.loadDeletedFiles(); // Reload the list
} catch (error) {
document.dispatchEvent(new CustomEvent('show-toast', {
detail: { message: 'Failed to restore file: ' + error.message, type: 'error' }
}));
}
}
}
}
customElements.define('deleted-files', DeletedFiles);

View File

@ -6,6 +6,8 @@ export class FileList extends HTMLElement {
this.currentFolderId = null; this.currentFolderId = null;
this.files = []; this.files = [];
this.folders = []; this.folders = [];
this.selectedFiles = new Set();
this.selectedFolders = new Set();
} }
async connectedCallback() { async connectedCallback() {
@ -17,19 +19,31 @@ export class FileList extends HTMLElement {
try { try {
this.folders = await api.listFolders(folderId); this.folders = await api.listFolders(folderId);
this.files = await api.listFiles(folderId); this.files = await api.listFiles(folderId);
this.selectedFiles.clear();
this.selectedFolders.clear();
this.render(); this.render();
} catch (error) { } catch (error) {
console.error('Failed to load contents:', error); console.error('Failed to load contents:', error);
document.dispatchEvent(new CustomEvent('show-toast', {
detail: { message: 'Failed to load contents: ' + error.message, type: 'error' }
}));
} }
} }
setFiles(files) { setFiles(files) {
this.files = files; this.files = files;
this.folders = []; this.folders = [];
this.selectedFiles.clear();
this.selectedFolders.clear();
this.render(); this.render();
} }
render() { render() {
const hasSelected = this.selectedFiles.size > 0 || this.selectedFolders.size > 0;
const allFilesSelected = this.files.length > 0 && this.selectedFiles.size === this.files.length;
const allFoldersSelected = this.folders.length > 0 && this.selectedFolders.size === this.folders.length;
const allSelected = (this.files.length + this.folders.length) > 0 && allFilesSelected && allFoldersSelected;
this.innerHTML = ` this.innerHTML = `
<div class="file-list-container"> <div class="file-list-container">
<div class="file-list-header"> <div class="file-list-header">
@ -40,6 +54,16 @@ export class FileList extends HTMLElement {
</div> </div>
</div> </div>
<div class="batch-actions" style="display: ${hasSelected ? 'flex' : 'none'};">
<input type="checkbox" id="select-all" ${allSelected ? 'checked' : ''}>
<label for="select-all">Select All</label>
<button class="button button-small button-danger" id="batch-delete-btn">Delete Selected</button>
<button class="button button-small" id="batch-move-btn">Move Selected</button>
<button class="button button-small" id="batch-copy-btn">Copy Selected</button>
<button class="button button-small" id="batch-star-btn">Star Selected</button>
<button class="button button-small" id="batch-unstar-btn">Unstar Selected</button>
</div>
<div class="file-grid"> <div class="file-grid">
${this.folders.map(folder => this.renderFolder(folder)).join('')} ${this.folders.map(folder => this.renderFolder(folder)).join('')}
${this.files.map(file => this.renderFile(file)).join('')} ${this.files.map(file => this.renderFile(file)).join('')}
@ -51,23 +75,32 @@ export class FileList extends HTMLElement {
} }
renderFolder(folder) { renderFolder(folder) {
const isSelected = this.selectedFolders.has(folder.id);
const starIcon = folder.is_starred ? '&#9733;' : '&#9734;'; // Filled star or empty star
const starAction = folder.is_starred ? 'unstar-folder' : 'star-folder';
return ` return `
<div class="file-item folder-item" data-folder-id="${folder.id}"> <div class="file-item folder-item" data-folder-id="${folder.id}">
<input type="checkbox" class="select-item" data-type="folder" data-id="${folder.id}" ${isSelected ? 'checked' : ''}>
<div class="file-icon">&#128193;</div> <div class="file-icon">&#128193;</div>
<div class="file-name">${folder.name}</div> <div class="file-name">${folder.name}</div>
<div class="file-actions-menu"> <div class="file-actions-menu">
<button class="action-btn" data-action="delete-folder" data-id="${folder.id}">Delete</button> <button class="action-btn" data-action="delete-folder" data-id="${folder.id}">Delete</button>
<button class="action-btn star-btn" data-action="${starAction}" data-id="${folder.id}">${starIcon}</button>
</div> </div>
</div> </div>
`; `;
} }
renderFile(file) { renderFile(file) {
const isSelected = this.selectedFiles.has(file.id);
const icon = this.getFileIcon(file.mime_type); const icon = this.getFileIcon(file.mime_type);
const size = this.formatFileSize(file.size); const size = this.formatFileSize(file.size);
const starIcon = file.is_starred ? '&#9733;' : '&#9734;'; // Filled star or empty star
const starAction = file.is_starred ? 'unstar-file' : 'star-file';
return ` return `
<div class="file-item" data-file-id="${file.id}"> <div class="file-item" data-file-id="${file.id}">
<input type="checkbox" class="select-item" data-type="file" data-id="${file.id}" ${isSelected ? 'checked' : ''}>
<div class="file-icon">${icon}</div> <div class="file-icon">${icon}</div>
<div class="file-name">${file.name}</div> <div class="file-name">${file.name}</div>
<div class="file-size">${size}</div> <div class="file-size">${size}</div>
@ -76,6 +109,7 @@ export class FileList extends HTMLElement {
<button class="action-btn" data-action="rename" data-id="${file.id}">Rename</button> <button class="action-btn" data-action="rename" data-id="${file.id}">Rename</button>
<button class="action-btn" data-action="delete" data-id="${file.id}">Delete</button> <button class="action-btn" data-action="delete" data-id="${file.id}">Delete</button>
<button class="action-btn" data-action="share" data-id="${file.id}">Share</button> <button class="action-btn" data-action="share" data-id="${file.id}">Share</button>
<button class="action-btn star-btn" data-action="${starAction}" data-id="${file.id}">${starIcon}</button>
</div> </div>
</div> </div>
`; `;
@ -115,7 +149,7 @@ export class FileList extends HTMLElement {
this.querySelectorAll('.file-item:not(.folder-item)').forEach(item => { this.querySelectorAll('.file-item:not(.folder-item)').forEach(item => {
item.addEventListener('click', (e) => { item.addEventListener('click', (e) => {
if (!e.target.classList.contains('action-btn')) { if (!e.target.classList.contains('action-btn') && !e.target.classList.contains('select-item')) {
const fileId = parseInt(item.dataset.fileId); const fileId = parseInt(item.dataset.fileId);
const file = this.files.find(f => f.id === fileId); const file = this.files.find(f => f.id === fileId);
this.dispatchEvent(new CustomEvent('photo-click', { this.dispatchEvent(new CustomEvent('photo-click', {
@ -134,6 +168,112 @@ export class FileList extends HTMLElement {
await this.handleAction(action, id); await this.handleAction(action, id);
}); });
}); });
this.querySelectorAll('.select-item').forEach(checkbox => {
checkbox.addEventListener('change', (e) => {
const type = e.target.dataset.type;
const id = parseInt(e.target.dataset.id);
this.toggleSelectItem(type, id, e.target.checked);
});
});
this.querySelector('#select-all')?.addEventListener('change', (e) => {
this.toggleSelectAll(e.target.checked);
});
this.querySelector('#batch-delete-btn')?.addEventListener('click', () => this.handleBatchAction('delete'));
this.querySelector('#batch-move-btn')?.addEventListener('click', () => this.handleBatchAction('move'));
this.querySelector('#batch-copy-btn')?.addEventListener('click', () => this.handleBatchAction('copy'));
this.querySelector('#batch-star-btn')?.addEventListener('click', () => this.handleBatchAction('star'));
this.querySelector('#batch-unstar-btn')?.addEventListener('click', () => this.handleBatchAction('unstar'));
this.updateBatchActionVisibility();
}
toggleSelectItem(type, id, checked) {
if (type === 'file') {
if (checked) {
this.selectedFiles.add(id);
} else {
this.selectedFiles.delete(id);
}
} else if (type === 'folder') {
if (checked) {
this.selectedFolders.add(id);
} else {
this.selectedFolders.delete(id);
}
}
this.updateBatchActionVisibility();
}
toggleSelectAll(checked) {
this.selectedFiles.clear();
this.selectedFolders.clear();
if (checked) {
this.files.forEach(file => this.selectedFiles.add(file.id));
this.folders.forEach(folder => this.selectedFolders.add(folder.id));
}
this.render(); // Re-render to update checkboxes
}
updateBatchActionVisibility() {
const batchActionsDiv = this.querySelector('.batch-actions');
if (batchActionsDiv) {
if (this.selectedFiles.size > 0 || this.selectedFolders.size > 0) {
batchActionsDiv.style.display = 'flex';
} else {
batchActionsDiv.style.display = 'none';
}
}
}
async handleBatchAction(action) {
if ((this.selectedFiles.size === 0 && this.selectedFolders.size === 0)) {
document.dispatchEvent(new CustomEvent('show-toast', {
detail: { message: 'No items selected for batch operation.', type: 'info' }
}));
return;
}
if (!confirm(`Are you sure you want to ${action} ${this.selectedFiles.size + this.selectedFolders.size} items?`)) {
return;
}
try {
let targetFolderId = null;
if (action === 'move' || action === 'copy') {
const folderName = prompt('Enter target folder ID (leave empty for root):');
if (folderName !== null) {
targetFolderId = folderName === '' ? null : parseInt(folderName);
if (folderName !== '' && isNaN(targetFolderId)) {
document.dispatchEvent(new CustomEvent('show-toast', {
detail: { message: 'Invalid folder ID.', type: 'error' }
}));
return;
}
} else {
return; // User cancelled
}
}
if (this.selectedFiles.size > 0) {
await api.batchFileOperations(action, Array.from(this.selectedFiles), targetFolderId);
}
if (this.selectedFolders.size > 0) {
await api.batchFolderOperations(action, Array.from(this.selectedFolders), targetFolderId);
}
document.dispatchEvent(new CustomEvent('show-toast', {
detail: { message: `Batch ${action} successful!`, type: 'success' }
}));
await this.loadContents(this.currentFolderId); // Reload contents
} catch (error) {
document.dispatchEvent(new CustomEvent('show-toast', {
detail: { message: `Batch ${action} failed: ` + error.message, type: 'error' }
}));
}
} }
triggerCreateFolder() { triggerCreateFolder() {
@ -147,7 +287,9 @@ export class FileList extends HTMLElement {
await api.createFolder(name, this.currentFolderId); await api.createFolder(name, this.currentFolderId);
await this.loadContents(this.currentFolderId); await this.loadContents(this.currentFolderId);
} catch (error) { } catch (error) {
alert('Failed to create folder: ' + error.message); document.dispatchEvent(new CustomEvent('show-toast', {
detail: { message: 'Failed to create folder: ' + error.message, type: 'error' }
}));
} }
} }
} }
@ -164,6 +306,9 @@ export class FileList extends HTMLElement {
a.download = file.name; a.download = file.name;
a.click(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
document.dispatchEvent(new CustomEvent('show-toast', {
detail: { message: 'File downloaded successfully!', type: 'success' }
}));
break; break;
case 'rename': case 'rename':
@ -171,6 +316,9 @@ export class FileList extends HTMLElement {
if (newName) { if (newName) {
await api.renameFile(id, newName); await api.renameFile(id, newName);
await this.loadContents(this.currentFolderId); await this.loadContents(this.currentFolderId);
document.dispatchEvent(new CustomEvent('show-toast', {
detail: { message: 'File renamed successfully!', type: 'success' }
}));
} }
break; break;
@ -178,6 +326,9 @@ export class FileList extends HTMLElement {
if (confirm('Are you sure you want to delete this file?')) { if (confirm('Are you sure you want to delete this file?')) {
await api.deleteFile(id); await api.deleteFile(id);
await this.loadContents(this.currentFolderId); await this.loadContents(this.currentFolderId);
document.dispatchEvent(new CustomEvent('show-toast', {
detail: { message: 'File deleted successfully!', type: 'success' }
}));
} }
break; break;
@ -185,15 +336,48 @@ export class FileList extends HTMLElement {
if (confirm('Are you sure you want to delete this folder?')) { if (confirm('Are you sure you want to delete this folder?')) {
await api.deleteFolder(id); await api.deleteFolder(id);
await this.loadContents(this.currentFolderId); await this.loadContents(this.currentFolderId);
document.dispatchEvent(new CustomEvent('show-toast', {
detail: { message: 'Folder deleted successfully!', type: 'success' }
}));
} }
break; break;
case 'share': case 'share':
this.dispatchEvent(new CustomEvent('share-request', { detail: { fileId: id } })); this.dispatchEvent(new CustomEvent('share-request', { detail: { fileId: id } }));
break; break;
case 'star-file':
await api.starFile(id);
document.dispatchEvent(new CustomEvent('show-toast', {
detail: { message: 'File starred successfully!', type: 'success' }
}));
await this.loadContents(this.currentFolderId);
break;
case 'unstar-file':
await api.unstarFile(id);
document.dispatchEvent(new CustomEvent('show-toast', {
detail: { message: 'File unstarred successfully!', type: 'success' }
}));
await this.loadContents(this.currentFolderId);
break;
case 'star-folder':
await api.starFolder(id);
document.dispatchEvent(new CustomEvent('show-toast', {
detail: { message: 'Folder starred successfully!', type: 'success' }
}));
await this.loadContents(this.currentFolderId);
break;
case 'unstar-folder':
await api.unstarFolder(id);
document.dispatchEvent(new CustomEvent('show-toast', {
detail: { message: 'Folder unstarred successfully!', type: 'success' }
}));
await this.loadContents(this.currentFolderId);
break;
} }
} catch (error) { } catch (error) {
alert('Action failed: ' + error.message); document.dispatchEvent(new CustomEvent('show-toast', {
detail: { message: 'Action failed: ' + error.message, type: 'error' }
}));
} }
} }
} }

View File

@ -5,6 +5,12 @@ import './file-upload.js';
import './share-modal.js'; import './share-modal.js';
import './photo-gallery.js'; import './photo-gallery.js';
import './file-preview.js'; import './file-preview.js';
import './deleted-files.js';
import './admin-dashboard.js';
import './toast-notification.js';
import './starred-items.js';
import './recent-files.js';
import './shared-items.js'; // Import the new component
import { shortcuts } from '../shortcuts.js'; import { shortcuts } from '../shortcuts.js';
export class RBoxApp extends HTMLElement { export class RBoxApp extends HTMLElement {
@ -17,6 +23,22 @@ export class RBoxApp extends HTMLElement {
async connectedCallback() { async connectedCallback() {
await this.init(); await this.init();
this.addEventListener('show-toast', this.handleShowToast);
}
disconnectedCallback() {
this.removeEventListener('show-toast', this.handleShowToast);
}
handleShowToast = (event) => {
const { message, type, duration } = event.detail;
this.showToast(message, type, duration);
}
showToast(message, type = 'info', duration = 3000) {
const toast = document.createElement('toast-notification');
document.body.appendChild(toast);
toast.show(message, type, duration);
} }
async init() { async init() {
@ -63,6 +85,7 @@ export class RBoxApp extends HTMLElement {
<li><a href="#" class="nav-link" data-view="photos">Photo Gallery</a></li> <li><a href="#" class="nav-link" data-view="photos">Photo Gallery</a></li>
<li><a href="#" class="nav-link" data-view="shared">Shared Items</a></li> <li><a href="#" class="nav-link" data-view="shared">Shared Items</a></li>
<li><a href="#" class="nav-link" data-view="deleted">Deleted Files</a></li> <li><a href="#" class="nav-link" data-view="deleted">Deleted Files</a></li>
${this.user && this.user.is_superuser ? `<li><a href="#" class="nav-link" data-view="admin">Admin Dashboard</a></li>` : ''}
</ul> </ul>
<h3 class="nav-title">Quick Access</h3> <h3 class="nav-title">Quick Access</h3>
<ul class="nav-list"> <ul class="nav-list">
@ -134,6 +157,12 @@ export class RBoxApp extends HTMLElement {
this.switchView('deleted'); this.switchView('deleted');
}); });
shortcuts.register('5', () => {
if (this.user && this.user.is_superuser) {
this.switchView('admin');
}
});
shortcuts.register('f2', () => { shortcuts.register('f2', () => {
console.log('Rename shortcut - to be implemented'); console.log('Rename shortcut - to be implemented');
}); });
@ -176,6 +205,11 @@ export class RBoxApp extends HTMLElement {
<kbd>4</kbd> <kbd>4</kbd>
<span>Deleted Files</span> <span>Deleted Files</span>
</div> </div>
${this.user && this.user.is_superuser ? `
<div class="shortcut-item">
<kbd>5</kbd>
<span>Admin Dashboard</span>
</div>` : ''}
<h3>General</h3> <h3>General</h3>
<div class="shortcut-item"> <div class="shortcut-item">
@ -329,16 +363,24 @@ export class RBoxApp extends HTMLElement {
this.attachListeners(); this.attachListeners();
break; break;
case 'shared': case 'shared':
mainContent.innerHTML = '<div class="placeholder">Shared Items - Coming Soon</div>'; mainContent.innerHTML = '<shared-items></shared-items>';
this.attachListeners();
break; break;
case 'deleted': case 'deleted':
mainContent.innerHTML = '<div class="placeholder">Deleted Files - Coming Soon</div>'; mainContent.innerHTML = '<deleted-files></deleted-files>';
this.attachListeners(); // Re-attach listeners for the new component
break; break;
case 'starred': case 'starred':
mainContent.innerHTML = '<div class="placeholder">Starred Items - Coming Soon</div>'; mainContent.innerHTML = '<starred-items></starred-items>';
this.attachListeners();
break; break;
case 'recent': case 'recent':
mainContent.innerHTML = '<div class="placeholder">Recent Files - Coming Soon</div>'; mainContent.innerHTML = '<recent-files></recent-files>';
this.attachListeners();
break;
case 'admin':
mainContent.innerHTML = '<admin-dashboard></admin-dashboard>';
this.attachListeners();
break; break;
} }
} }

View File

@ -0,0 +1,115 @@
import { api } from '../api.js';
export class RecentFiles extends HTMLElement {
constructor() {
super();
this.recentFiles = [];
}
async connectedCallback() {
await this.loadRecentFiles();
}
async loadRecentFiles() {
try {
this.recentFiles = await api.listRecentFiles();
this.render();
} catch (error) {
console.error('Failed to load recent files:', error);
document.dispatchEvent(new CustomEvent('show-toast', {
detail: { message: 'Failed to load recent files: ' + error.message, type: 'error' }
}));
}
}
render() {
if (this.recentFiles.length === 0) {
this.innerHTML = `
<div class="recent-files-container">
<h2>Recent Files</h2>
<p class="empty-state">No recent files found.</p>
</div>
`;
return;
}
this.innerHTML = `
<div class="recent-files-container">
<h2>Recent Files</h2>
<div class="file-grid">
${this.recentFiles.map(file => this.renderFile(file)).join('')}
</div>
</div>
`;
this.attachListeners();
}
renderFile(file) {
const icon = this.getFileIcon(file.mime_type);
const size = this.formatFileSize(file.size);
const lastAccessed = file.last_accessed_at ? new Date(file.last_accessed_at).toLocaleString() : 'N/A';
return `
<div class="file-item" data-file-id="${file.id}">
<div class="file-icon">${icon}</div>
<div class="file-name">${file.name}</div>
<div class="file-size">${size}</div>
<div class="file-last-accessed">Accessed: ${lastAccessed}</div>
<div class="file-actions-menu">
<button class="action-btn" data-action="download" data-id="${file.id}">Download</button>
</div>
</div>
`;
}
getFileIcon(mimeType) {
if (mimeType.startsWith('image/')) return '&#128247;';
if (mimeType.startsWith('video/')) return '&#127909;';
if (mimeType.startsWith('audio/')) return '&#127925;';
if (mimeType.includes('pdf')) return '&#128196;';
if (mimeType.includes('text')) return '&#128196;';
return '&#128196;';
}
formatFileSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB';
return (bytes / 1073741824).toFixed(1) + ' GB';
}
attachListeners() {
this.querySelectorAll('.action-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const action = btn.dataset.action;
const id = parseInt(btn.dataset.id);
if (action === 'download') {
await this.handleDownload(id);
}
});
});
}
async handleDownload(fileId) {
try {
const blob = await api.downloadFile(fileId);
const file = this.recentFiles.find(f => f.id === fileId);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = file.name;
a.click();
URL.revokeObjectURL(url);
document.dispatchEvent(new CustomEvent('show-toast', {
detail: { message: 'File downloaded successfully!', type: 'success' }
}));
} catch (error) {
document.dispatchEvent(new CustomEvent('show-toast', {
detail: { message: 'Failed to download file: ' + error.message, type: 'error' }
}));
}
}
}
customElements.define('recent-files', RecentFiles);

View File

@ -126,7 +126,9 @@ export class ShareModal extends HTMLElement {
const linkInput = this.querySelector('#share-link'); const linkInput = this.querySelector('#share-link');
linkInput.select(); linkInput.select();
document.execCommand('copy'); document.execCommand('copy');
alert('Link copied to clipboard'); document.dispatchEvent(new CustomEvent('show-toast', {
detail: { message: 'Link copied to clipboard!', type: 'success' }
}));
} }
} }

View File

@ -0,0 +1,128 @@
import { api } from '../api.js';
export class SharedItems extends HTMLElement {
constructor() {
super();
this.myShares = [];
}
async connectedCallback() {
await this.loadMyShares();
}
async loadMyShares() {
try {
this.myShares = await api.listMyShares();
this.render();
} catch (error) {
console.error('Failed to load shared items:', error);
document.dispatchEvent(new CustomEvent('show-toast', {
detail: { message: 'Failed to load shared items: ' + error.message, type: 'error' }
}));
}
}
render() {
if (this.myShares.length === 0) {
this.innerHTML = `
<div class="shared-items-container">
<h2>Shared Items</h2>
<p class="empty-state">No shared items found.</p>
</div>
`;
return;
}
this.innerHTML = `
<div class="shared-items-container">
<h2>Shared Items</h2>
<div class="share-list">
${this.myShares.map(share => this.renderShare(share)).join('')}
</div>
</div>
`;
this.attachListeners();
}
renderShare(share) {
const shareLink = `${window.location.origin}/share/${share.token}`;
const expiresAt = share.expires_at ? new Date(share.expires_at).toLocaleString() : 'Never';
const targetName = share.file ? share.file.name : (share.folder ? share.folder.name : 'N/A');
const targetType = share.file ? 'File' : (share.folder ? 'Folder' : 'N/A');
return `
<div class="share-item" data-share-id="${share.id}">
<div class="share-info">
<p><strong>${targetType}:</strong> ${targetName}</p>
<p><strong>Permission:</strong> ${share.permission_level}</p>
<p><strong>Expires:</strong> ${expiresAt}</p>
<p><strong>Password Protected:</strong> ${share.password_protected ? 'Yes' : 'No'}</p>
<p><strong>Access Count:</strong> ${share.access_count}</p>
<input type="text" value="${shareLink}" readonly class="input-field share-link-input">
</div>
<div class="share-actions">
<button class="button button-small" data-action="copy-link" data-link="${shareLink}">Copy Link</button>
<button class="button button-small" data-action="edit-share" data-id="${share.id}">Edit</button>
<button class="button button-small button-danger" data-action="delete-share" data-id="${share.id}">Delete</button>
</div>
</div>
`;
}
attachListeners() {
this.querySelectorAll('.share-actions .button').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const action = btn.dataset.action;
const id = parseInt(btn.dataset.id);
const link = btn.dataset.link;
if (action === 'copy-link') {
this.copyLink(link);
} else if (action === 'edit-share') {
this.handleEditShare(id);
} else if (action === 'delete-share') {
await this.handleDeleteShare(id);
}
});
});
}
copyLink(link) {
navigator.clipboard.writeText(link).then(() => {
document.dispatchEvent(new CustomEvent('show-toast', {
detail: { message: 'Share link copied to clipboard!', type: 'success' }
}));
}).catch(err => {
console.error('Failed to copy link: ', err);
document.dispatchEvent(new CustomEvent('show-toast', {
detail: { message: 'Failed to copy link.', type: 'error' }
}));
});
}
handleEditShare(shareId) {
// For now, we'll just show a toast. A full implementation would open a modal for editing.
document.dispatchEvent(new CustomEvent('show-toast', {
detail: { message: `Edit share ${shareId} - functionality to be implemented.`, type: 'info' }
}));
}
async handleDeleteShare(shareId) {
if (confirm('Are you sure you want to delete this share link?')) {
try {
await api.deleteShare(shareId);
document.dispatchEvent(new CustomEvent('show-toast', {
detail: { message: 'Share link deleted successfully!', type: 'success' }
}));
await this.loadMyShares(); // Reload the list
} catch (error) {
document.dispatchEvent(new CustomEvent('show-toast', {
detail: { message: 'Failed to delete share link: ' + error.message, type: 'error' }
}));
}
}
}
}
customElements.define('shared-items', SharedItems);

View File

@ -0,0 +1,147 @@
import { api } from '../api.js';
export class StarredItems extends HTMLElement {
constructor() {
super();
this.starredFiles = [];
this.starredFolders = [];
}
async connectedCallback() {
await this.loadStarredItems();
}
async loadStarredItems() {
try {
this.starredFiles = await api.listStarredFiles();
this.starredFolders = await api.listStarredFolders();
this.render();
} catch (error) {
console.error('Failed to load starred items:', error);
document.dispatchEvent(new CustomEvent('show-toast', {
detail: { message: 'Failed to load starred items: ' + error.message, type: 'error' }
}));
}
}
render() {
const allStarred = [...this.starredFolders, ...this.starredFiles];
if (allStarred.length === 0) {
this.innerHTML = `
<div class="starred-items-container">
<h2>Starred Items</h2>
<p class="empty-state">No starred items found.</p>
</div>
`;
return;
}
this.innerHTML = `
<div class="starred-items-container">
<h2>Starred Items</h2>
<div class="file-grid">
${this.starredFolders.map(folder => this.renderFolder(folder)).join('')}
${this.starredFiles.map(file => this.renderFile(file)).join('')}
</div>
</div>
`;
this.attachListeners();
}
renderFolder(folder) {
return `
<div class="file-item folder-item" data-folder-id="${folder.id}">
<div class="file-icon">&#128193;</div>
<div class="file-name">${folder.name}</div>
<div class="file-actions-menu">
<button class="action-btn star-btn" data-action="unstar-folder" data-id="${folder.id}">&#9733;</button>
</div>
</div>
`;
}
renderFile(file) {
const icon = this.getFileIcon(file.mime_type);
const size = this.formatFileSize(file.size);
return `
<div class="file-item" data-file-id="${file.id}">
<div class="file-icon">${icon}</div>
<div class="file-name">${file.name}</div>
<div class="file-size">${size}</div>
<div class="file-actions-menu">
<button class="action-btn" data-action="download" data-id="${file.id}">Download</button>
<button class="action-btn star-btn" data-action="unstar-file" data-id="${file.id}">&#9733;</button>
</div>
</div>
`;
}
getFileIcon(mimeType) {
if (mimeType.startsWith('image/')) return '&#128247;';
if (mimeType.startsWith('video/')) return '&#127909;';
if (mimeType.startsWith('audio/')) return '&#127925;';
if (mimeType.includes('pdf')) return '&#128196;';
if (mimeType.includes('text')) return '&#128196;';
return '&#128196;';
}
formatFileSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB';
return (bytes / 1073741824).toFixed(1) + ' GB';
}
attachListeners() {
this.querySelectorAll('.action-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const action = btn.dataset.action;
const id = parseInt(btn.dataset.id);
await this.handleAction(action, id);
});
});
}
async handleAction(action, id) {
try {
switch (action) {
case 'download':
const blob = await api.downloadFile(id);
const file = this.starredFiles.find(f => f.id === id);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = file.name;
a.click();
URL.revokeObjectURL(url);
document.dispatchEvent(new CustomEvent('show-toast', {
detail: { message: 'File downloaded successfully!', type: 'success' }
}));
break;
case 'unstar-file':
await api.unstarFile(id);
document.dispatchEvent(new CustomEvent('show-toast', {
detail: { message: 'File unstarred successfully!', type: 'success' }
}));
await this.loadStarredItems();
break;
case 'unstar-folder':
await api.unstarFolder(id);
document.dispatchEvent(new CustomEvent('show-toast', {
detail: { message: 'Folder unstarred successfully!', type: 'success' }
}));
await this.loadStarredItems();
break;
}
} catch (error) {
document.dispatchEvent(new CustomEvent('show-toast', {
detail: { message: 'Action failed: ' + error.message, type: 'error' }
}));
}
}
}
customElements.define('starred-items', StarredItems);

View File

@ -0,0 +1,76 @@
export class ToastNotification extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
background-color: var(--primary-color);
color: var(--accent-color);
padding: 15px 20px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
font-family: var(--font-family);
font-size: 1rem;
opacity: 0;
transition: opacity 0.3s ease-in-out, transform 0.3s ease-in-out;
z-index: 10000;
min-width: 250px;
text-align: center;
}
:host(.show) {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
:host(.hide) {
opacity: 0;
transform: translateX(-50%) translateY(20px);
}
:host(.error) {
background-color: var(--secondary-color);
}
:host(.success) {
background-color: #4CAF50; /* Green for success */
}
</style>
<div id="message"></div>
`;
}
connectedCallback() {
// Ensure variables are defined, fallback if not
if (!this.style.getPropertyValue('--primary-color')) {
this.style.setProperty('--primary-color', '#003399');
this.style.setProperty('--secondary-color', '#CC0000');
this.style.setProperty('--accent-color', '#FFFFFF');
this.style.setProperty('--font-family', 'sans-serif');
}
}
show(message, type = 'info', duration = 3000) {
const messageDiv = this.shadowRoot.getElementById('message');
messageDiv.textContent = message;
this.className = ''; // Clear previous classes
this.classList.add('show');
if (type === 'error') {
this.classList.add('error');
} else if (type === 'success') {
this.classList.add('success');
}
setTimeout(() => {
this.classList.remove('show');
this.classList.add('hide');
// Remove element after transition
this.addEventListener('transitionend', () => this.remove(), { once: true });
}, duration);
}
}
customElements.define('toast-notification', ToastNotification);