Compare commits
No commits in common. "1ddb2c609dca0a0087710e95e73704003e9eaa81" and "adc861d4b4df46999ed4abcf5bfb5d489e210df6" have entirely different histories.
1ddb2c609d
...
adc861d4b4
4
.gitignore
vendored
4
.gitignore
vendored
@ -5,9 +5,7 @@ __pycache__/
|
||||
.*
|
||||
storage
|
||||
*.so
|
||||
*.txt
|
||||
poetry.lock
|
||||
rbox.*
|
||||
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
|
||||
5
Makefile
5
Makefile
@ -1,5 +0,0 @@
|
||||
PYTHON=".venv/bin/python3"
|
||||
RBOX=".venv/bin/rbox"
|
||||
|
||||
all:
|
||||
$(RBOX) --port 9004
|
||||
@ -10,17 +10,14 @@ 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
|
||||
|
||||
@ -6,7 +6,7 @@ authors = ["Your Name <you@example.com>"]
|
||||
readme = "README.md"
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.12"
|
||||
python = "*"
|
||||
fastapi = "*"
|
||||
uvicorn = {extras = ["standard"], version = "*"}
|
||||
tortoise-orm = {extras = ["asyncpg"], version = "*"}
|
||||
@ -14,7 +14,6 @@ redis = "*"
|
||||
python-jose = {extras = ["cryptography"], version = "*"}
|
||||
passlib = {extras = ["bcrypt"], version = "*"}
|
||||
pyotp = "*"
|
||||
qrcode = "*"
|
||||
python-multipart = "*"
|
||||
aiofiles = "*"
|
||||
httpx = "*"
|
||||
|
||||
36
rbox/auth.py
36
rbox/auth.py
@ -9,40 +9,33 @@ 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_bytes
|
||||
hashed_bytes = hashed_password.encode('utf-8') if isinstance(hashed_password, str) else hashed_password
|
||||
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, two_factor_code: Optional[str] = None):
|
||||
async def authenticate_user(username: str, password: str):
|
||||
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
|
||||
|
||||
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):
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
|
||||
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, "2fa_verified": two_factor_verified})
|
||||
to_encode.update({"exp": expire})
|
||||
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
@ -55,29 +48,12 @@ 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, two_factor_verified=two_factor_verified)
|
||||
token_data = TokenData(username=username)
|
||||
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
|
||||
|
||||
26
rbox/main.py
26
rbox/main.py
@ -1,18 +1,12 @@
|
||||
import argparse
|
||||
import uvicorn
|
||||
import logging # Import logging
|
||||
from fastapi import FastAPI, Request, status, HTTPException
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from fastapi.responses import HTMLResponse # Import HTMLResponse
|
||||
from tortoise.contrib.fastapi import register_tortoise
|
||||
from .settings import settings
|
||||
from .routers import auth, users, folders, files, shares, search, admin, starred
|
||||
from .routers import auth, users, folders, files, shares, search
|
||||
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",
|
||||
@ -26,8 +20,6 @@ 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
|
||||
@ -41,18 +33,10 @@ 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():
|
||||
logger.info("Starting up...")
|
||||
logger.info("Database connected.")
|
||||
print("Starting up...")
|
||||
print("Database connected.")
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def shutdown_event():
|
||||
|
||||
@ -15,8 +15,6 @@ 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"
|
||||
@ -32,7 +30,6 @@ 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"
|
||||
@ -55,8 +52,6 @@ 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"
|
||||
@ -133,23 +128,10 @@ 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)
|
||||
|
||||
@ -1,98 +0,0 @@
|
||||
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"}
|
||||
@ -1,41 +1,17 @@
|
||||
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, get_current_user, get_current_verified_user
|
||||
from ..auth import authenticate_user, create_access_token, get_password_hash
|
||||
from ..models import User
|
||||
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
|
||||
)
|
||||
from ..schemas import Token, UserCreate
|
||||
|
||||
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)
|
||||
@ -65,98 +41,16 @@ 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(user_login: UserLoginWith2FA):
|
||||
auth_result = await authenticate_user(user_login.username, user_login.password, user_login.two_factor_code)
|
||||
|
||||
if not auth_result:
|
||||
async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends()):
|
||||
user = await authenticate_user(form_data.username, form_data.password)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect username or password or 2FA code",
|
||||
detail="Incorrect username or password",
|
||||
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, two_factor_verified=True
|
||||
data={"sub": user.username}, expires_delta=access_token_expires
|
||||
)
|
||||
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
|
||||
|
||||
@ -29,13 +29,6 @@ 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(
|
||||
@ -109,9 +102,6 @@ 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)
|
||||
|
||||
@ -236,9 +226,6 @@ 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:
|
||||
@ -270,128 +257,3 @@ 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]
|
||||
|
||||
@ -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, BatchFolderOperation, BatchMoveCopyPayload
|
||||
from ..schemas import FolderCreate, FolderOut, FolderUpdate
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/folders",
|
||||
@ -103,62 +103,3 @@ 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]
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from typing import Optional, List
|
||||
from typing import Optional
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
@ -52,11 +52,6 @@ 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)
|
||||
@ -72,28 +67,6 @@ 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)
|
||||
|
||||
@ -1,28 +0,0 @@
|
||||
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]
|
||||
@ -12,26 +12,12 @@ 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
|
||||
@ -98,19 +84,3 @@ 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
|
||||
|
||||
@ -1,60 +0,0 @@
|
||||
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
|
||||
204
rbox/webdav.py
204
rbox/webdav.py
@ -1,5 +1,4 @@
|
||||
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
|
||||
@ -10,7 +9,7 @@ import base64
|
||||
from urllib.parse import unquote, urlparse
|
||||
|
||||
from .auth import get_current_user, verify_password
|
||||
from .models import User, File, Folder, WebDAVProperty
|
||||
from .models import User, File, Folder
|
||||
from .storage import storage_manager
|
||||
from .activity import log_activity
|
||||
|
||||
@ -127,14 +126,7 @@ def build_href(base_path: str, name: str, is_collection: bool):
|
||||
path += '/'
|
||||
return path
|
||||
|
||||
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"):
|
||||
def create_propstat_element(props: dict, status: str = "HTTP/1.1 200 OK"):
|
||||
propstat = ET.Element("D:propstat")
|
||||
prop = ET.SubElement(propstat, "D:prop")
|
||||
|
||||
@ -162,46 +154,11 @@ def create_propstat_element(props: dict, custom_props: dict = None, status: str
|
||||
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(
|
||||
@ -217,8 +174,6 @@ 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)
|
||||
|
||||
@ -257,8 +212,7 @@ async def handle_propfind(request: Request, full_path: str, current_user: User =
|
||||
"creationdate": folder.created_at,
|
||||
"getlastmodified": folder.updated_at
|
||||
}
|
||||
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))
|
||||
response.append(create_propstat_element(props))
|
||||
|
||||
for file in files:
|
||||
response = ET.SubElement(multistatus, "D:response")
|
||||
@ -274,8 +228,7 @@ async def handle_propfind(request: Request, full_path: str, current_user: User =
|
||||
"getlastmodified": file.updated_at,
|
||||
"getetag": f'"{file.file_hash}"'
|
||||
}
|
||||
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))
|
||||
response.append(create_propstat_element(props))
|
||||
|
||||
elif isinstance(resource, Folder):
|
||||
response = ET.SubElement(multistatus, "D:response")
|
||||
@ -288,8 +241,7 @@ async def handle_propfind(request: Request, full_path: str, current_user: User =
|
||||
"creationdate": resource.created_at,
|
||||
"getlastmodified": resource.updated_at
|
||||
}
|
||||
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))
|
||||
response.append(create_propstat_element(props))
|
||||
|
||||
if depth in ["1", "infinity"]:
|
||||
folders = await Folder.filter(owner=current_user, parent=resource, is_deleted=False)
|
||||
@ -306,8 +258,7 @@ async def handle_propfind(request: Request, full_path: str, current_user: User =
|
||||
"creationdate": folder.created_at,
|
||||
"getlastmodified": folder.updated_at
|
||||
}
|
||||
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))
|
||||
response.append(create_propstat_element(props))
|
||||
|
||||
for file in files:
|
||||
response = ET.SubElement(multistatus, "D:response")
|
||||
@ -323,8 +274,7 @@ async def handle_propfind(request: Request, full_path: str, current_user: User =
|
||||
"getlastmodified": file.updated_at,
|
||||
"getetag": f'"{file.file_hash}"'
|
||||
}
|
||||
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))
|
||||
response.append(create_propstat_element(props))
|
||||
|
||||
elif isinstance(resource, File):
|
||||
response = ET.SubElement(multistatus, "D:response")
|
||||
@ -340,8 +290,7 @@ async def handle_propfind(request: Request, full_path: str, current_user: User =
|
||||
"getlastmodified": resource.updated_at,
|
||||
"getetag": f'"{resource.file_hash}"'
|
||||
}
|
||||
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))
|
||||
response.append(create_propstat_element(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)
|
||||
@ -371,7 +320,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 StreamingResponse(
|
||||
return Response(
|
||||
content=file_iterator(),
|
||||
media_type=resource.mime_type,
|
||||
headers={
|
||||
@ -703,138 +652,3 @@ 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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -171,25 +171,6 @@ 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);
|
||||
@ -805,93 +786,3 @@ 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;
|
||||
}
|
||||
|
||||
124
static/js/api.js
124
static/js/api.js
@ -48,17 +48,8 @@ class APIClient {
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
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);
|
||||
const error = await response.json().catch(() => ({ detail: 'Unknown error' }));
|
||||
throw new Error(error.detail || 'Request failed');
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
@ -209,118 +200,9 @@ class APIClient {
|
||||
return this.request('files/photos');
|
||||
}
|
||||
|
||||
async getThumbnailUrl(fileId) {
|
||||
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();
|
||||
|
||||
@ -1,174 +0,0 @@
|
||||
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">×</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);
|
||||
@ -1,109 +0,0 @@
|
||||
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 '📷';
|
||||
if (mimeType.startsWith('video/')) return '🎥';
|
||||
if (mimeType.startsWith('audio/')) return '🎵';
|
||||
if (mimeType.includes('pdf')) return '📄';
|
||||
if (mimeType.includes('text')) return '📄';
|
||||
return '📄';
|
||||
}
|
||||
|
||||
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);
|
||||
@ -6,8 +6,6 @@ export class FileList extends HTMLElement {
|
||||
this.currentFolderId = null;
|
||||
this.files = [];
|
||||
this.folders = [];
|
||||
this.selectedFiles = new Set();
|
||||
this.selectedFolders = new Set();
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
@ -19,31 +17,19 @@ 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">
|
||||
@ -54,16 +40,6 @@ 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('')}
|
||||
@ -75,32 +51,23 @@ export class FileList extends HTMLElement {
|
||||
}
|
||||
|
||||
renderFolder(folder) {
|
||||
const isSelected = this.selectedFolders.has(folder.id);
|
||||
const starIcon = folder.is_starred ? '★' : '☆'; // 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">📁</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 ? '★' : '☆'; // 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>
|
||||
@ -109,7 +76,6 @@ 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>
|
||||
`;
|
||||
@ -149,7 +115,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') && !e.target.classList.contains('select-item')) {
|
||||
if (!e.target.classList.contains('action-btn')) {
|
||||
const fileId = parseInt(item.dataset.fileId);
|
||||
const file = this.files.find(f => f.id === fileId);
|
||||
this.dispatchEvent(new CustomEvent('photo-click', {
|
||||
@ -168,112 +134,6 @@ 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() {
|
||||
@ -287,9 +147,7 @@ export class FileList extends HTMLElement {
|
||||
await api.createFolder(name, this.currentFolderId);
|
||||
await this.loadContents(this.currentFolderId);
|
||||
} catch (error) {
|
||||
document.dispatchEvent(new CustomEvent('show-toast', {
|
||||
detail: { message: 'Failed to create folder: ' + error.message, type: 'error' }
|
||||
}));
|
||||
alert('Failed to create folder: ' + error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -306,9 +164,6 @@ 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':
|
||||
@ -316,9 +171,6 @@ 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;
|
||||
|
||||
@ -326,9 +178,6 @@ 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;
|
||||
|
||||
@ -336,48 +185,15 @@ 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) {
|
||||
document.dispatchEvent(new CustomEvent('show-toast', {
|
||||
detail: { message: 'Action failed: ' + error.message, type: 'error' }
|
||||
}));
|
||||
alert('Action failed: ' + error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,12 +5,6 @@ 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 {
|
||||
@ -23,22 +17,6 @@ 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() {
|
||||
@ -85,7 +63,6 @@ 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">
|
||||
@ -157,12 +134,6 @@ 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');
|
||||
});
|
||||
@ -205,11 +176,6 @@ 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">
|
||||
@ -363,24 +329,16 @@ export class RBoxApp extends HTMLElement {
|
||||
this.attachListeners();
|
||||
break;
|
||||
case 'shared':
|
||||
mainContent.innerHTML = '<shared-items></shared-items>';
|
||||
this.attachListeners();
|
||||
mainContent.innerHTML = '<div class="placeholder">Shared Items - Coming Soon</div>';
|
||||
break;
|
||||
case 'deleted':
|
||||
mainContent.innerHTML = '<deleted-files></deleted-files>';
|
||||
this.attachListeners(); // Re-attach listeners for the new component
|
||||
mainContent.innerHTML = '<div class="placeholder">Deleted Files - Coming Soon</div>';
|
||||
break;
|
||||
case 'starred':
|
||||
mainContent.innerHTML = '<starred-items></starred-items>';
|
||||
this.attachListeners();
|
||||
mainContent.innerHTML = '<div class="placeholder">Starred Items - Coming Soon</div>';
|
||||
break;
|
||||
case 'recent':
|
||||
mainContent.innerHTML = '<recent-files></recent-files>';
|
||||
this.attachListeners();
|
||||
break;
|
||||
case 'admin':
|
||||
mainContent.innerHTML = '<admin-dashboard></admin-dashboard>';
|
||||
this.attachListeners();
|
||||
mainContent.innerHTML = '<div class="placeholder">Recent Files - Coming Soon</div>';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,115 +0,0 @@
|
||||
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 '📷';
|
||||
if (mimeType.startsWith('video/')) return '🎥';
|
||||
if (mimeType.startsWith('audio/')) return '🎵';
|
||||
if (mimeType.includes('pdf')) return '📄';
|
||||
if (mimeType.includes('text')) return '📄';
|
||||
return '📄';
|
||||
}
|
||||
|
||||
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);
|
||||
@ -126,9 +126,7 @@ export class ShareModal extends HTMLElement {
|
||||
const linkInput = this.querySelector('#share-link');
|
||||
linkInput.select();
|
||||
document.execCommand('copy');
|
||||
document.dispatchEvent(new CustomEvent('show-toast', {
|
||||
detail: { message: 'Link copied to clipboard!', type: 'success' }
|
||||
}));
|
||||
alert('Link copied to clipboard');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,128 +0,0 @@
|
||||
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);
|
||||
@ -1,147 +0,0 @@
|
||||
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">📁</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}">★</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}">★</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
getFileIcon(mimeType) {
|
||||
if (mimeType.startsWith('image/')) return '📷';
|
||||
if (mimeType.startsWith('video/')) return '🎥';
|
||||
if (mimeType.startsWith('audio/')) return '🎵';
|
||||
if (mimeType.includes('pdf')) return '📄';
|
||||
if (mimeType.includes('text')) return '📄';
|
||||
return '📄';
|
||||
}
|
||||
|
||||
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);
|
||||
@ -1,76 +0,0 @@
|
||||
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);
|
||||
Loading…
Reference in New Issue
Block a user