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
*.so
*.txt
poetry.lock
rbox.*
.Python
build/
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_PASSWORD: ${POSTGRES_PASSWORD}
restart: unless-stopped
network_mode: host
redis:
image: redis:7-alpine
volumes:
- redis_data:/data
restart: unless-stopped
network_mode: host
app:
network_mode: host
build:
context: .
dockerfile: Dockerfile

View File

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

View File

@ -9,33 +9,40 @@ import bcrypt
from .schemas import TokenData
from .settings import settings
from .models import User
from .two_factor import verify_totp_code # Import verify_totp_code
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
def verify_password(plain_password, hashed_password):
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)
def get_password_hash(password):
password_bytes = password[:72].encode('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)
if not user:
return None
if not verify_password(password, user.hashed_password):
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()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
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)
return encoded_jwt
@ -48,12 +55,29 @@ async def get_current_user(token: str = Depends(oauth2_scheme)):
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
username: str = payload.get("sub")
two_factor_verified: bool = payload.get("2fa_verified", False)
if username is None:
raise credentials_exception
token_data = TokenData(username=username)
token_data = TokenData(username=username, two_factor_verified=two_factor_verified)
except JWTError:
raise credentials_exception
user = await User.get_or_none(username=token_data.username)
if user is None:
raise credentials_exception
user.token_data = token_data # Attach token_data to user for easy access
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 uvicorn
from fastapi import FastAPI
import logging # Import logging
from fastapi import FastAPI, Request, status, HTTPException
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 .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 .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(
title="RBox Cloud Storage",
@ -20,6 +26,8 @@ app.include_router(folders.router)
app.include_router(files.router)
app.include_router(shares.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)
# Mount static files
@ -33,10 +41,18 @@ register_tortoise(
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")
async def startup_event():
print("Starting up...")
print("Database connected.")
logger.info("Starting up...")
logger.info("Database connected.")
@app.on_event("shutdown")
async def shutdown_event():

View File

@ -15,6 +15,8 @@ class User(models.Model):
used_storage_bytes = fields.BigIntField(default=0)
plan_type = fields.CharField(max_length=50, default="free")
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:
table = "users"
@ -30,6 +32,7 @@ class Folder(models.Model):
created_at = fields.DatetimeField(auto_now_add=True)
updated_at = fields.DatetimeField(auto_now=True)
is_deleted = fields.BooleanField(default=False)
is_starred = fields.BooleanField(default=False)
class Meta:
table = "folders"
@ -52,6 +55,8 @@ class File(models.Model):
updated_at = fields.DatetimeField(auto_now=True)
is_deleted = fields.BooleanField(default=False)
deleted_at = fields.DatetimeField(null=True)
is_starred = fields.BooleanField(default=False)
last_accessed_at = fields.DatetimeField(null=True)
class Meta:
table = "files"
@ -128,10 +133,23 @@ class FileRequest(models.Model):
created_at = fields.DatetimeField(auto_now_add=True)
expires_at = fields.DatetimeField(null=True)
is_active = fields.BooleanField(default=True)
# TODO: Add fields for custom form configuration
class Meta:
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")
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 typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, status
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 ..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(
prefix="/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)
async def register_user(user_in: UserCreate):
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"}
@router.post("/token", response_model=Token)
async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends()):
user = await authenticate_user(form_data.username, form_data.password)
if not user:
async def login_for_access_token(user_login: UserLoginWith2FA):
auth_result = await authenticate_user(user_login.username, user_login.password, user_login.two_factor_code)
if not auth_result:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
detail="Incorrect username or password or 2FA code",
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 = 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"}
@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):
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", response_model=FileOut, status_code=status.HTTP_201_CREATED)
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:
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:
# 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:
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)
if not thumbnail_path:
@ -257,3 +270,128 @@ async def list_photos(current_user: User = Depends(get_current_user)):
mime_type__istartswith="image/"
).order_by("-created_at")
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 ..models import User, Folder
from ..schemas import FolderCreate, FolderOut, FolderUpdate
from ..schemas import FolderCreate, FolderOut, FolderUpdate, BatchFolderOperation, BatchMoveCopyPayload
router = APIRouter(
prefix="/folders",
@ -103,3 +103,62 @@ async def delete_folder(folder_id: int, current_user: User = Depends(get_current
folder.is_deleted = True
await folder.save()
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 typing import Optional
from typing import Optional, List
import secrets
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)
@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)
async def get_share_link_info(share_token: str):
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)
@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")
async def access_shared_content(share_token: str, password: Optional[str] = None):
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
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):
access_token: str
token_type: str
class TokenData(BaseModel):
username: str | None = None
two_factor_verified: bool = False
class FolderCreate(BaseModel):
name: str
@ -84,3 +98,19 @@ FolderOut = pydantic_model_creator(Folder, name="FolderOut")
FileOut = pydantic_model_creator(File, name="FileOut")
ShareOut = pydantic_model_creator(Share, name="ShareOut")
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.responses import StreamingResponse
from typing import Optional
from xml.etree import ElementTree as ET
from datetime import datetime
@ -9,7 +10,7 @@ import base64
from urllib.parse import unquote, urlparse
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 .activity import log_activity
@ -126,7 +127,14 @@ def build_href(base_path: str, name: str, is_collection: bool):
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")
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.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.text = status
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"])
async def webdav_options(full_path: str):
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)):
depth = request.headers.get("Depth", "1")
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)
@ -212,7 +257,8 @@ async def handle_propfind(request: Request, full_path: str, current_user: User =
"creationdate": folder.created_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:
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,
"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):
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,
"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"]:
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,
"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:
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,
"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):
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,
"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)
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):
yield chunk
return Response(
return StreamingResponse(
content=file_iterator(),
media_type=resource.mime_type,
headers={
@ -652,3 +703,138 @@ async def handle_unlock(request: Request, full_path: str, current_user: User = D
WebDAVLock.remove_lock(full_path)
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
echo "Starting database services..."
docker-compose up -d db redis
docker compose up -d db redis
echo "Waiting for database to be ready..."
sleep 5

View File

@ -171,6 +171,25 @@ body {
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 {
border: 1px solid var(--border-color);
padding: var(--spacing-unit);
@ -786,3 +805,93 @@ body.dark-mode {
margin: 0 0 calc(var(--spacing-unit) * 2) 0;
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) {
const error = await response.json().catch(() => ({ detail: 'Unknown error' }));
throw new Error(error.detail || 'Request failed');
let errorData;
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) {
@ -200,9 +209,118 @@ class APIClient {
return this.request('files/photos');
}
getThumbnailUrl(fileId) {
async getThumbnailUrl(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();

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.files = [];
this.folders = [];
this.selectedFiles = new Set();
this.selectedFolders = new Set();
}
async connectedCallback() {
@ -17,19 +19,31 @@ export class FileList extends HTMLElement {
try {
this.folders = await api.listFolders(folderId);
this.files = await api.listFiles(folderId);
this.selectedFiles.clear();
this.selectedFolders.clear();
this.render();
} catch (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) {
this.files = files;
this.folders = [];
this.selectedFiles.clear();
this.selectedFolders.clear();
this.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 = `
<div class="file-list-container">
<div class="file-list-header">
@ -40,6 +54,16 @@ export class FileList extends HTMLElement {
</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">
${this.folders.map(folder => this.renderFolder(folder)).join('')}
${this.files.map(file => this.renderFile(file)).join('')}
@ -51,23 +75,32 @@ export class FileList extends HTMLElement {
}
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 `
<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-name">${folder.name}</div>
<div class="file-actions-menu">
<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>
`;
}
renderFile(file) {
const isSelected = this.selectedFiles.has(file.id);
const icon = this.getFileIcon(file.mime_type);
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 `
<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-name">${file.name}</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="delete" data-id="${file.id}">Delete</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>
`;
@ -115,7 +149,7 @@ export class FileList extends HTMLElement {
this.querySelectorAll('.file-item:not(.folder-item)').forEach(item => {
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 file = this.files.find(f => f.id === fileId);
this.dispatchEvent(new CustomEvent('photo-click', {
@ -134,6 +168,112 @@ export class FileList extends HTMLElement {
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() {
@ -147,7 +287,9 @@ export class FileList extends HTMLElement {
await api.createFolder(name, this.currentFolderId);
await this.loadContents(this.currentFolderId);
} 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.click();
URL.revokeObjectURL(url);
document.dispatchEvent(new CustomEvent('show-toast', {
detail: { message: 'File downloaded successfully!', type: 'success' }
}));
break;
case 'rename':
@ -171,6 +316,9 @@ export class FileList extends HTMLElement {
if (newName) {
await api.renameFile(id, newName);
await this.loadContents(this.currentFolderId);
document.dispatchEvent(new CustomEvent('show-toast', {
detail: { message: 'File renamed successfully!', type: 'success' }
}));
}
break;
@ -178,6 +326,9 @@ export class FileList extends HTMLElement {
if (confirm('Are you sure you want to delete this file?')) {
await api.deleteFile(id);
await this.loadContents(this.currentFolderId);
document.dispatchEvent(new CustomEvent('show-toast', {
detail: { message: 'File deleted successfully!', type: 'success' }
}));
}
break;
@ -185,15 +336,48 @@ export class FileList extends HTMLElement {
if (confirm('Are you sure you want to delete this folder?')) {
await api.deleteFolder(id);
await this.loadContents(this.currentFolderId);
document.dispatchEvent(new CustomEvent('show-toast', {
detail: { message: 'Folder deleted successfully!', type: 'success' }
}));
}
break;
case 'share':
this.dispatchEvent(new CustomEvent('share-request', { detail: { fileId: id } }));
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) {
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 './photo-gallery.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';
export class RBoxApp extends HTMLElement {
@ -17,6 +23,22 @@ export class RBoxApp extends HTMLElement {
async connectedCallback() {
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() {
@ -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="shared">Shared Items</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>
<h3 class="nav-title">Quick Access</h3>
<ul class="nav-list">
@ -134,6 +157,12 @@ export class RBoxApp extends HTMLElement {
this.switchView('deleted');
});
shortcuts.register('5', () => {
if (this.user && this.user.is_superuser) {
this.switchView('admin');
}
});
shortcuts.register('f2', () => {
console.log('Rename shortcut - to be implemented');
});
@ -176,6 +205,11 @@ export class RBoxApp extends HTMLElement {
<kbd>4</kbd>
<span>Deleted Files</span>
</div>
${this.user && this.user.is_superuser ? `
<div class="shortcut-item">
<kbd>5</kbd>
<span>Admin Dashboard</span>
</div>` : ''}
<h3>General</h3>
<div class="shortcut-item">
@ -329,16 +363,24 @@ export class RBoxApp extends HTMLElement {
this.attachListeners();
break;
case 'shared':
mainContent.innerHTML = '<div class="placeholder">Shared Items - Coming Soon</div>';
mainContent.innerHTML = '<shared-items></shared-items>';
this.attachListeners();
break;
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;
case 'starred':
mainContent.innerHTML = '<div class="placeholder">Starred Items - Coming Soon</div>';
mainContent.innerHTML = '<starred-items></starred-items>';
this.attachListeners();
break;
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;
}
}

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');
linkInput.select();
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);