Compare commits
4 Commits
adc861d4b4
...
1ddb2c609d
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ddb2c609d | |||
| 17de53b9c2 | |||
| 6fdd4b9f0c | |||
| d90b7ba852 |
4
.gitignore
vendored
4
.gitignore
vendored
@ -5,7 +5,9 @@ __pycache__/
|
|||||||
.*
|
.*
|
||||||
storage
|
storage
|
||||||
*.so
|
*.so
|
||||||
|
*.txt
|
||||||
|
poetry.lock
|
||||||
|
rbox.*
|
||||||
.Python
|
.Python
|
||||||
build/
|
build/
|
||||||
develop-eggs/
|
develop-eggs/
|
||||||
|
|||||||
5
Makefile
Normal file
5
Makefile
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
PYTHON=".venv/bin/python3"
|
||||||
|
RBOX=".venv/bin/rbox"
|
||||||
|
|
||||||
|
all:
|
||||||
|
$(RBOX) --port 9004
|
||||||
@ -10,14 +10,17 @@ services:
|
|||||||
POSTGRES_USER: ${POSTGRES_USER}
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
network_mode: host
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
volumes:
|
volumes:
|
||||||
- redis_data:/data
|
- redis_data:/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
network_mode: host
|
||||||
|
|
||||||
app:
|
app:
|
||||||
|
network_mode: host
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
|||||||
@ -6,7 +6,7 @@ authors = ["Your Name <you@example.com>"]
|
|||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
|
|
||||||
[tool.poetry.dependencies]
|
[tool.poetry.dependencies]
|
||||||
python = "*"
|
python = "^3.12"
|
||||||
fastapi = "*"
|
fastapi = "*"
|
||||||
uvicorn = {extras = ["standard"], version = "*"}
|
uvicorn = {extras = ["standard"], version = "*"}
|
||||||
tortoise-orm = {extras = ["asyncpg"], version = "*"}
|
tortoise-orm = {extras = ["asyncpg"], version = "*"}
|
||||||
@ -14,6 +14,7 @@ redis = "*"
|
|||||||
python-jose = {extras = ["cryptography"], version = "*"}
|
python-jose = {extras = ["cryptography"], version = "*"}
|
||||||
passlib = {extras = ["bcrypt"], version = "*"}
|
passlib = {extras = ["bcrypt"], version = "*"}
|
||||||
pyotp = "*"
|
pyotp = "*"
|
||||||
|
qrcode = "*"
|
||||||
python-multipart = "*"
|
python-multipart = "*"
|
||||||
aiofiles = "*"
|
aiofiles = "*"
|
||||||
httpx = "*"
|
httpx = "*"
|
||||||
|
|||||||
36
rbox/auth.py
36
rbox/auth.py
@ -9,33 +9,40 @@ import bcrypt
|
|||||||
from .schemas import TokenData
|
from .schemas import TokenData
|
||||||
from .settings import settings
|
from .settings import settings
|
||||||
from .models import User
|
from .models import User
|
||||||
|
from .two_factor import verify_totp_code # Import verify_totp_code
|
||||||
|
|
||||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
|
||||||
|
|
||||||
def verify_password(plain_password, hashed_password):
|
def verify_password(plain_password, hashed_password):
|
||||||
password_bytes = plain_password[:72].encode('utf-8')
|
password_bytes = plain_password[:72].encode('utf-8')
|
||||||
hashed_bytes = hashed_password.encode('utf-8') if isinstance(hashed_password, str) else hashed_password
|
hashed_bytes = hashed_password.encode('utf-8') if isinstance(hashed_password, str) else hashed_bytes
|
||||||
return bcrypt.checkpw(password_bytes, hashed_bytes)
|
return bcrypt.checkpw(password_bytes, hashed_bytes)
|
||||||
|
|
||||||
def get_password_hash(password):
|
def get_password_hash(password):
|
||||||
password_bytes = password[:72].encode('utf-8')
|
password_bytes = password[:72].encode('utf-8')
|
||||||
return bcrypt.hashpw(password_bytes, bcrypt.gensalt()).decode('utf-8')
|
return bcrypt.hashpw(password_bytes, bcrypt.gensalt()).decode('utf-8')
|
||||||
|
|
||||||
async def authenticate_user(username: str, password: str):
|
async def authenticate_user(username: str, password: str, two_factor_code: Optional[str] = None):
|
||||||
user = await User.get_or_none(username=username)
|
user = await User.get_or_none(username=username)
|
||||||
if not user:
|
if not user:
|
||||||
return None
|
return None
|
||||||
if not verify_password(password, user.hashed_password):
|
if not verify_password(password, user.hashed_password):
|
||||||
return None
|
return None
|
||||||
return user
|
|
||||||
|
|
||||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
|
if user.is_2fa_enabled:
|
||||||
|
if not two_factor_code:
|
||||||
|
return {"user": user, "2fa_required": True}
|
||||||
|
if not verify_totp_code(user.two_factor_secret, two_factor_code):
|
||||||
|
return None # 2FA code is incorrect
|
||||||
|
return {"user": user, "2fa_required": False}
|
||||||
|
|
||||||
|
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None, two_factor_verified: bool = False):
|
||||||
to_encode = data.copy()
|
to_encode = data.copy()
|
||||||
if expires_delta:
|
if expires_delta:
|
||||||
expire = datetime.utcnow() + expires_delta
|
expire = datetime.utcnow() + expires_delta
|
||||||
else:
|
else:
|
||||||
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||||
to_encode.update({"exp": expire})
|
to_encode.update({"exp": expire, "2fa_verified": two_factor_verified})
|
||||||
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||||
return encoded_jwt
|
return encoded_jwt
|
||||||
|
|
||||||
@ -48,12 +55,29 @@ async def get_current_user(token: str = Depends(oauth2_scheme)):
|
|||||||
try:
|
try:
|
||||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||||
username: str = payload.get("sub")
|
username: str = payload.get("sub")
|
||||||
|
two_factor_verified: bool = payload.get("2fa_verified", False)
|
||||||
if username is None:
|
if username is None:
|
||||||
raise credentials_exception
|
raise credentials_exception
|
||||||
token_data = TokenData(username=username)
|
token_data = TokenData(username=username, two_factor_verified=two_factor_verified)
|
||||||
except JWTError:
|
except JWTError:
|
||||||
raise credentials_exception
|
raise credentials_exception
|
||||||
user = await User.get_or_none(username=token_data.username)
|
user = await User.get_or_none(username=token_data.username)
|
||||||
if user is None:
|
if user is None:
|
||||||
raise credentials_exception
|
raise credentials_exception
|
||||||
|
user.token_data = token_data # Attach token_data to user for easy access
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
async def get_current_active_user(current_user: User = Depends(get_current_user)):
|
||||||
|
if not current_user.is_active:
|
||||||
|
raise HTTPException(status_code=400, detail="Inactive user")
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
async def get_current_verified_user(current_user: User = Depends(get_current_user)):
|
||||||
|
if current_user.is_2fa_enabled and not current_user.token_data.two_factor_verified:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="2FA required and not verified")
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
async def get_current_admin_user(current_user: User = Depends(get_current_verified_user)):
|
||||||
|
if not current_user.is_superuser:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not enough permissions")
|
||||||
|
return current_user
|
||||||
|
|||||||
26
rbox/main.py
26
rbox/main.py
@ -1,12 +1,18 @@
|
|||||||
import argparse
|
import argparse
|
||||||
import uvicorn
|
import uvicorn
|
||||||
from fastapi import FastAPI
|
import logging # Import logging
|
||||||
|
from fastapi import FastAPI, Request, status, HTTPException
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.responses import HTMLResponse # Import HTMLResponse
|
from fastapi.responses import HTMLResponse, JSONResponse
|
||||||
from tortoise.contrib.fastapi import register_tortoise
|
from tortoise.contrib.fastapi import register_tortoise
|
||||||
from .settings import settings
|
from .settings import settings
|
||||||
from .routers import auth, users, folders, files, shares, search
|
from .routers import auth, users, folders, files, shares, search, admin, starred
|
||||||
from . import webdav
|
from . import webdav
|
||||||
|
from .schemas import ErrorResponse # Import ErrorResponse
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="RBox Cloud Storage",
|
title="RBox Cloud Storage",
|
||||||
@ -20,6 +26,8 @@ app.include_router(folders.router)
|
|||||||
app.include_router(files.router)
|
app.include_router(files.router)
|
||||||
app.include_router(shares.router)
|
app.include_router(shares.router)
|
||||||
app.include_router(search.router)
|
app.include_router(search.router)
|
||||||
|
app.include_router(admin.router) # Include the admin router
|
||||||
|
app.include_router(starred.router) # Include the starred router
|
||||||
app.include_router(webdav.router)
|
app.include_router(webdav.router)
|
||||||
|
|
||||||
# Mount static files
|
# Mount static files
|
||||||
@ -33,10 +41,18 @@ register_tortoise(
|
|||||||
add_exception_handlers=True,
|
add_exception_handlers=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@app.exception_handler(HTTPException)
|
||||||
|
async def http_exception_handler(request: Request, exc: HTTPException):
|
||||||
|
logger.error(f"HTTPException: {exc.status_code} - {exc.detail} for URL: {request.url}")
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=exc.status_code,
|
||||||
|
content=ErrorResponse(code=exc.status_code, message=exc.detail).dict(),
|
||||||
|
)
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
async def startup_event():
|
async def startup_event():
|
||||||
print("Starting up...")
|
logger.info("Starting up...")
|
||||||
print("Database connected.")
|
logger.info("Database connected.")
|
||||||
|
|
||||||
@app.on_event("shutdown")
|
@app.on_event("shutdown")
|
||||||
async def shutdown_event():
|
async def shutdown_event():
|
||||||
|
|||||||
@ -15,6 +15,8 @@ class User(models.Model):
|
|||||||
used_storage_bytes = fields.BigIntField(default=0)
|
used_storage_bytes = fields.BigIntField(default=0)
|
||||||
plan_type = fields.CharField(max_length=50, default="free")
|
plan_type = fields.CharField(max_length=50, default="free")
|
||||||
two_factor_secret = fields.CharField(max_length=255, null=True)
|
two_factor_secret = fields.CharField(max_length=255, null=True)
|
||||||
|
is_2fa_enabled = fields.BooleanField(default=False)
|
||||||
|
recovery_codes = fields.TextField(null=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
table = "users"
|
table = "users"
|
||||||
@ -30,6 +32,7 @@ class Folder(models.Model):
|
|||||||
created_at = fields.DatetimeField(auto_now_add=True)
|
created_at = fields.DatetimeField(auto_now_add=True)
|
||||||
updated_at = fields.DatetimeField(auto_now=True)
|
updated_at = fields.DatetimeField(auto_now=True)
|
||||||
is_deleted = fields.BooleanField(default=False)
|
is_deleted = fields.BooleanField(default=False)
|
||||||
|
is_starred = fields.BooleanField(default=False)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
table = "folders"
|
table = "folders"
|
||||||
@ -52,6 +55,8 @@ class File(models.Model):
|
|||||||
updated_at = fields.DatetimeField(auto_now=True)
|
updated_at = fields.DatetimeField(auto_now=True)
|
||||||
is_deleted = fields.BooleanField(default=False)
|
is_deleted = fields.BooleanField(default=False)
|
||||||
deleted_at = fields.DatetimeField(null=True)
|
deleted_at = fields.DatetimeField(null=True)
|
||||||
|
is_starred = fields.BooleanField(default=False)
|
||||||
|
last_accessed_at = fields.DatetimeField(null=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
table = "files"
|
table = "files"
|
||||||
@ -128,10 +133,23 @@ class FileRequest(models.Model):
|
|||||||
created_at = fields.DatetimeField(auto_now_add=True)
|
created_at = fields.DatetimeField(auto_now_add=True)
|
||||||
expires_at = fields.DatetimeField(null=True)
|
expires_at = fields.DatetimeField(null=True)
|
||||||
is_active = fields.BooleanField(default=True)
|
is_active = fields.BooleanField(default=True)
|
||||||
# TODO: Add fields for custom form configuration
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
table = "file_requests"
|
table = "file_requests"
|
||||||
|
|
||||||
|
class WebDAVProperty(models.Model):
|
||||||
|
id = fields.IntField(pk=True)
|
||||||
|
resource_type = fields.CharField(max_length=10)
|
||||||
|
resource_id = fields.IntField()
|
||||||
|
namespace = fields.CharField(max_length=255)
|
||||||
|
name = fields.CharField(max_length=255)
|
||||||
|
value = fields.TextField()
|
||||||
|
created_at = fields.DatetimeField(auto_now_add=True)
|
||||||
|
updated_at = fields.DatetimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
table = "webdav_properties"
|
||||||
|
unique_together = (("resource_type", "resource_id", "namespace", "name"),)
|
||||||
|
|
||||||
User_Pydantic = pydantic_model_creator(User, name="User_Pydantic")
|
User_Pydantic = pydantic_model_creator(User, name="User_Pydantic")
|
||||||
UserIn_Pydantic = pydantic_model_creator(User, name="UserIn_Pydantic", exclude_readonly=True)
|
UserIn_Pydantic = pydantic_model_creator(User, name="UserIn_Pydantic", exclude_readonly=True)
|
||||||
|
|||||||
98
rbox/routers/admin.py
Normal file
98
rbox/routers/admin.py
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
|
||||||
|
from ..auth import get_current_admin_user, get_password_hash
|
||||||
|
from ..models import User, User_Pydantic
|
||||||
|
from ..schemas import UserCreate, UserAdminUpdate
|
||||||
|
|
||||||
|
router = APIRouter(
|
||||||
|
prefix="/admin",
|
||||||
|
tags=["admin"],
|
||||||
|
dependencies=[Depends(get_current_admin_user)],
|
||||||
|
responses={403: {"description": "Not enough permissions"}},
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("/users", response_model=List[User_Pydantic])
|
||||||
|
async def get_all_users():
|
||||||
|
return await User.all()
|
||||||
|
|
||||||
|
@router.get("/users/{user_id}", response_model=User_Pydantic)
|
||||||
|
async def get_user(user_id: int):
|
||||||
|
user = await User.get_or_none(id=user_id)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||||
|
return user
|
||||||
|
|
||||||
|
@router.post("/users", response_model=User_Pydantic, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_user_by_admin(user_in: UserCreate):
|
||||||
|
user = await User.get_or_none(username=user_in.username)
|
||||||
|
if user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Username already registered",
|
||||||
|
)
|
||||||
|
user = await User.get_or_none(email=user_in.email)
|
||||||
|
if user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Email already registered",
|
||||||
|
)
|
||||||
|
|
||||||
|
hashed_password = get_password_hash(user_in.password)
|
||||||
|
user = await User.create(
|
||||||
|
username=user_in.username,
|
||||||
|
email=user_in.email,
|
||||||
|
hashed_password=hashed_password,
|
||||||
|
is_superuser=False, # Admin creates regular users by default
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
return await User_Pydantic.from_tortoise_orm(user)
|
||||||
|
|
||||||
|
@router.put("/users/{user_id}", response_model=User_Pydantic)
|
||||||
|
async def update_user_by_admin(user_id: int, user_update: UserAdminUpdate):
|
||||||
|
user = await User.get_or_none(id=user_id)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||||
|
|
||||||
|
if user_update.username is not None and user_update.username != user.username:
|
||||||
|
if await User.get_or_none(username=user_update.username):
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Username already taken")
|
||||||
|
user.username = user_update.username
|
||||||
|
|
||||||
|
if user_update.email is not None and user_update.email != user.email:
|
||||||
|
if await User.get_or_none(email=user_update.email):
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Email already registered")
|
||||||
|
user.email = user_update.email
|
||||||
|
|
||||||
|
if user_update.password is not None:
|
||||||
|
user.hashed_password = get_password_hash(user_update.password)
|
||||||
|
|
||||||
|
if user_update.is_active is not None:
|
||||||
|
user.is_active = user_update.is_active
|
||||||
|
|
||||||
|
if user_update.is_superuser is not None:
|
||||||
|
user.is_superuser = user_update.is_superuser
|
||||||
|
|
||||||
|
if user_update.storage_quota_bytes is not None:
|
||||||
|
user.storage_quota_bytes = user_update.storage_quota_bytes
|
||||||
|
|
||||||
|
if user_update.plan_type is not None:
|
||||||
|
user.plan_type = user_update.plan_type
|
||||||
|
|
||||||
|
if user_update.is_2fa_enabled is not None:
|
||||||
|
user.is_2fa_enabled = user_update.is_2fa_enabled
|
||||||
|
if not user_update.is_2fa_enabled:
|
||||||
|
user.two_factor_secret = None
|
||||||
|
user.recovery_codes = None
|
||||||
|
|
||||||
|
await user.save()
|
||||||
|
return await User_Pydantic.from_tortoise_orm(user)
|
||||||
|
|
||||||
|
@router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_user_by_admin(user_id: int):
|
||||||
|
user = await User.get_or_none(id=user_id)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||||
|
await user.delete()
|
||||||
|
return {"message": "User deleted successfully"}
|
||||||
@ -1,17 +1,41 @@
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
from typing import Optional, List
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from fastapi.security import OAuth2PasswordRequestForm
|
from fastapi.security import OAuth2PasswordRequestForm
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from ..auth import authenticate_user, create_access_token, get_password_hash
|
from ..auth import authenticate_user, create_access_token, get_password_hash, get_current_user, get_current_verified_user
|
||||||
from ..models import User
|
from ..models import User
|
||||||
from ..schemas import Token, UserCreate
|
from ..schemas import Token, UserCreate, TokenData, UserLoginWith2FA
|
||||||
|
from ..two_factor import (
|
||||||
|
generate_totp_secret, generate_totp_uri, generate_qr_code_base64,
|
||||||
|
verify_totp_code, generate_recovery_codes, hash_recovery_codes,
|
||||||
|
verify_recovery_codes
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
prefix="/auth",
|
prefix="/auth",
|
||||||
tags=["auth"],
|
tags=["auth"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
class TwoFactorLogin(BaseModel):
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
two_factor_code: Optional[str] = None
|
||||||
|
|
||||||
|
class TwoFactorSetupResponse(BaseModel):
|
||||||
|
secret: str
|
||||||
|
qr_code_base64: str
|
||||||
|
recovery_codes: List[str]
|
||||||
|
|
||||||
|
class TwoFactorCode(BaseModel):
|
||||||
|
two_factor_code: str
|
||||||
|
|
||||||
|
class TwoFactorDisable(BaseModel):
|
||||||
|
password: str
|
||||||
|
two_factor_code: str
|
||||||
|
|
||||||
@router.post("/register", response_model=Token)
|
@router.post("/register", response_model=Token)
|
||||||
async def register_user(user_in: UserCreate):
|
async def register_user(user_in: UserCreate):
|
||||||
user = await User.get_or_none(username=user_in.username)
|
user = await User.get_or_none(username=user_in.username)
|
||||||
@ -41,16 +65,98 @@ async def register_user(user_in: UserCreate):
|
|||||||
return {"access_token": access_token, "token_type": "bearer"}
|
return {"access_token": access_token, "token_type": "bearer"}
|
||||||
|
|
||||||
@router.post("/token", response_model=Token)
|
@router.post("/token", response_model=Token)
|
||||||
async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends()):
|
async def login_for_access_token(user_login: UserLoginWith2FA):
|
||||||
user = await authenticate_user(form_data.username, form_data.password)
|
auth_result = await authenticate_user(user_login.username, user_login.password, user_login.two_factor_code)
|
||||||
if not user:
|
|
||||||
|
if not auth_result:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Incorrect username or password",
|
detail="Incorrect username or password or 2FA code",
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
user = auth_result["user"]
|
||||||
|
if auth_result["2fa_required"]:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Two-factor authentication required",
|
||||||
|
headers={"X-2FA-Required": "true"},
|
||||||
|
)
|
||||||
|
|
||||||
access_token_expires = timedelta(minutes=30) # Use settings
|
access_token_expires = timedelta(minutes=30) # Use settings
|
||||||
access_token = create_access_token(
|
access_token = create_access_token(
|
||||||
data={"sub": user.username}, expires_delta=access_token_expires
|
data={"sub": user.username}, expires_delta=access_token_expires, two_factor_verified=True
|
||||||
)
|
)
|
||||||
return {"access_token": access_token, "token_type": "bearer"}
|
return {"access_token": access_token, "token_type": "bearer"}
|
||||||
|
|
||||||
|
@router.post("/2fa/setup", response_model=TwoFactorSetupResponse)
|
||||||
|
async def setup_two_factor_authentication(current_user: User = Depends(get_current_user)):
|
||||||
|
if current_user.is_2fa_enabled:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="2FA is already enabled.")
|
||||||
|
if current_user.two_factor_secret:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="2FA setup already initiated. Verify or disable first.")
|
||||||
|
|
||||||
|
secret = generate_totp_secret()
|
||||||
|
current_user.two_factor_secret = secret
|
||||||
|
await current_user.save()
|
||||||
|
|
||||||
|
totp_uri = generate_totp_uri(secret, current_user.email, "RBox")
|
||||||
|
qr_code_base64 = generate_qr_code_base64(totp_uri)
|
||||||
|
|
||||||
|
recovery_codes = generate_recovery_codes()
|
||||||
|
hashed_recovery_codes = hash_recovery_codes(recovery_codes)
|
||||||
|
current_user.recovery_codes = ",".join(hashed_recovery_codes)
|
||||||
|
await current_user.save()
|
||||||
|
|
||||||
|
return TwoFactorSetupResponse(secret=secret, qr_code_base64=qr_code_base64, recovery_codes=recovery_codes)
|
||||||
|
|
||||||
|
@router.post("/2fa/verify", response_model=Token)
|
||||||
|
async def verify_two_factor_authentication(two_factor_code_data: TwoFactorCode, current_user: User = Depends(get_current_user)):
|
||||||
|
if current_user.is_2fa_enabled:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="2FA is already enabled.")
|
||||||
|
if not current_user.two_factor_secret:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="2FA setup not initiated.")
|
||||||
|
|
||||||
|
if not verify_totp_code(current_user.two_factor_secret, two_factor_code_data.two_factor_code):
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid 2FA code.")
|
||||||
|
|
||||||
|
current_user.is_2fa_enabled = True
|
||||||
|
await current_user.save()
|
||||||
|
|
||||||
|
access_token_expires = timedelta(minutes=30) # Use settings
|
||||||
|
access_token = create_access_token(
|
||||||
|
data={"sub": current_user.username}, expires_delta=access_token_expires, two_factor_verified=True
|
||||||
|
)
|
||||||
|
return {"access_token": access_token, "token_type": "bearer"}
|
||||||
|
|
||||||
|
@router.post("/2fa/disable", response_model=dict)
|
||||||
|
async def disable_two_factor_authentication(disable_data: TwoFactorDisable, current_user: User = Depends(get_current_verified_user)):
|
||||||
|
if not current_user.is_2fa_enabled:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="2FA is not enabled.")
|
||||||
|
|
||||||
|
# Verify password
|
||||||
|
if not verify_password(disable_data.password, current_user.hashed_password):
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid password.")
|
||||||
|
|
||||||
|
# Verify 2FA code
|
||||||
|
if not verify_totp_code(current_user.two_factor_secret, disable_data.two_factor_code):
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid 2FA code.")
|
||||||
|
|
||||||
|
current_user.two_factor_secret = None
|
||||||
|
current_user.is_2fa_enabled = False
|
||||||
|
current_user.recovery_codes = None
|
||||||
|
await current_user.save()
|
||||||
|
|
||||||
|
return {"message": "2FA disabled successfully."}
|
||||||
|
|
||||||
|
@router.get("/2fa/recovery-codes", response_model=List[str])
|
||||||
|
async def get_new_recovery_codes(current_user: User = Depends(get_current_verified_user)):
|
||||||
|
if not current_user.is_2fa_enabled:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="2FA is not enabled.")
|
||||||
|
|
||||||
|
recovery_codes = generate_recovery_codes()
|
||||||
|
hashed_recovery_codes = hash_recovery_codes(recovery_codes)
|
||||||
|
current_user.recovery_codes = ",".join(hashed_recovery_codes)
|
||||||
|
await current_user.save()
|
||||||
|
|
||||||
|
return recovery_codes
|
||||||
|
|||||||
@ -29,6 +29,13 @@ class FileRename(BaseModel):
|
|||||||
class FileCopy(BaseModel):
|
class FileCopy(BaseModel):
|
||||||
target_folder_id: Optional[int] = None
|
target_folder_id: Optional[int] = None
|
||||||
|
|
||||||
|
class BatchFileOperation(BaseModel):
|
||||||
|
file_ids: List[int]
|
||||||
|
operation: str # e.g., "delete", "star", "unstar", "move", "copy"
|
||||||
|
|
||||||
|
class BatchMoveCopyPayload(BaseModel):
|
||||||
|
target_folder_id: Optional[int] = None
|
||||||
|
|
||||||
@router.post("/upload/{folder_id}", response_model=FileOut, status_code=status.HTTP_201_CREATED)
|
@router.post("/upload/{folder_id}", response_model=FileOut, status_code=status.HTTP_201_CREATED)
|
||||||
@router.post("/upload", response_model=FileOut, status_code=status.HTTP_201_CREATED)
|
@router.post("/upload", response_model=FileOut, status_code=status.HTTP_201_CREATED)
|
||||||
async def upload_file(
|
async def upload_file(
|
||||||
@ -102,6 +109,9 @@ async def download_file(file_id: int, current_user: User = Depends(get_current_u
|
|||||||
if not db_file:
|
if not db_file:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
||||||
|
|
||||||
|
db_file.last_accessed_at = datetime.now()
|
||||||
|
await db_file.save()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# file_content_generator = storage_manager.get_file(current_user.id, db_file.path)
|
# file_content_generator = storage_manager.get_file(current_user.id, db_file.path)
|
||||||
|
|
||||||
@ -226,6 +236,9 @@ async def get_thumbnail(file_id: int, current_user: User = Depends(get_current_u
|
|||||||
if not db_file:
|
if not db_file:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
||||||
|
|
||||||
|
db_file.last_accessed_at = datetime.now()
|
||||||
|
await db_file.save()
|
||||||
|
|
||||||
thumbnail_path = getattr(db_file, 'thumbnail_path', None)
|
thumbnail_path = getattr(db_file, 'thumbnail_path', None)
|
||||||
|
|
||||||
if not thumbnail_path:
|
if not thumbnail_path:
|
||||||
@ -257,3 +270,128 @@ async def list_photos(current_user: User = Depends(get_current_user)):
|
|||||||
mime_type__istartswith="image/"
|
mime_type__istartswith="image/"
|
||||||
).order_by("-created_at")
|
).order_by("-created_at")
|
||||||
return [await FileOut.from_tortoise_orm(f) for f in files]
|
return [await FileOut.from_tortoise_orm(f) for f in files]
|
||||||
|
|
||||||
|
@router.get("/recent", response_model=List[FileOut])
|
||||||
|
async def list_recent_files(current_user: User = Depends(get_current_user), limit: int = 10):
|
||||||
|
files = await File.filter(
|
||||||
|
owner=current_user,
|
||||||
|
is_deleted=False,
|
||||||
|
last_accessed_at__isnull=False
|
||||||
|
).order_by("-last_accessed_at").limit(limit)
|
||||||
|
return [await FileOut.from_tortoise_orm(f) for f in files]
|
||||||
|
|
||||||
|
@router.post("/{file_id}/star", response_model=FileOut)
|
||||||
|
async def star_file(file_id: int, current_user: User = Depends(get_current_user)):
|
||||||
|
db_file = await File.get_or_none(id=file_id, owner=current_user, is_deleted=False)
|
||||||
|
if not db_file:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
||||||
|
db_file.is_starred = True
|
||||||
|
await db_file.save()
|
||||||
|
await log_activity(user=current_user, action="file_starred", target_type="file", target_id=file_id)
|
||||||
|
return await FileOut.from_tortoise_orm(db_file)
|
||||||
|
|
||||||
|
@router.post("/{file_id}/unstar", response_model=FileOut)
|
||||||
|
async def unstar_file(file_id: int, current_user: User = Depends(get_current_user)):
|
||||||
|
db_file = await File.get_or_none(id=file_id, owner=current_user, is_deleted=False)
|
||||||
|
if not db_file:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
||||||
|
db_file.is_starred = False
|
||||||
|
await db_file.save()
|
||||||
|
await log_activity(user=current_user, action="file_unstarred", target_type="file", target_id=file_id)
|
||||||
|
return await FileOut.from_tortoise_orm(db_file)
|
||||||
|
|
||||||
|
@router.get("/deleted", response_model=List[FileOut])
|
||||||
|
async def list_deleted_files(current_user: User = Depends(get_current_user)):
|
||||||
|
files = await File.filter(owner=current_user, is_deleted=True).order_by("-deleted_at")
|
||||||
|
return [await FileOut.from_tortoise_orm(f) for f in files]
|
||||||
|
|
||||||
|
@router.post("/{file_id}/restore", response_model=FileOut)
|
||||||
|
async def restore_file(file_id: int, current_user: User = Depends(get_current_user)):
|
||||||
|
db_file = await File.get_or_none(id=file_id, owner=current_user, is_deleted=True)
|
||||||
|
if not db_file:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deleted file not found")
|
||||||
|
|
||||||
|
# Check if a file with the same name exists in the parent folder
|
||||||
|
existing_file = await File.get_or_none(
|
||||||
|
name=db_file.name, parent=db_file.parent, owner=current_user, is_deleted=False
|
||||||
|
)
|
||||||
|
if existing_file:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="A file with the same name already exists in this location. Please rename the existing file or restore to a different location.")
|
||||||
|
|
||||||
|
db_file.is_deleted = False
|
||||||
|
db_file.deleted_at = None
|
||||||
|
await db_file.save()
|
||||||
|
await log_activity(user=current_user, action="file_restored", target_type="file", target_id=file_id)
|
||||||
|
return await FileOut.from_tortoise_orm(db_file)
|
||||||
|
|
||||||
|
@router.post("/batch", response_model=List[FileOut])
|
||||||
|
async def batch_file_operations(
|
||||||
|
batch_operation: BatchFileOperation,
|
||||||
|
payload: Optional[BatchMoveCopyPayload] = None,
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
updated_files = []
|
||||||
|
for file_id in batch_operation.file_ids:
|
||||||
|
db_file = await File.get_or_none(id=file_id, owner=current_user, is_deleted=False)
|
||||||
|
if not db_file:
|
||||||
|
# Skip if file not found or not owned by user
|
||||||
|
continue
|
||||||
|
|
||||||
|
if batch_operation.operation == "delete":
|
||||||
|
db_file.is_deleted = True
|
||||||
|
db_file.deleted_at = datetime.now()
|
||||||
|
await db_file.save()
|
||||||
|
await log_activity(user=current_user, action="file_deleted_batch", target_type="file", target_id=file_id)
|
||||||
|
updated_files.append(db_file)
|
||||||
|
elif batch_operation.operation == "star":
|
||||||
|
db_file.is_starred = True
|
||||||
|
await db_file.save()
|
||||||
|
await log_activity(user=current_user, action="file_starred_batch", target_type="file", target_id=file_id)
|
||||||
|
updated_files.append(db_file)
|
||||||
|
elif batch_operation.operation == "unstar":
|
||||||
|
db_file.is_starred = False
|
||||||
|
await db_file.save()
|
||||||
|
await log_activity(user=current_user, action="file_unstarred_batch", target_type="file", target_id=file_id)
|
||||||
|
updated_files.append(db_file)
|
||||||
|
elif batch_operation.operation == "move" and payload and payload.target_folder_id is not None:
|
||||||
|
target_folder = await Folder.get_or_none(id=payload.target_folder_id, owner=current_user, is_deleted=False)
|
||||||
|
if not target_folder:
|
||||||
|
continue # Skip if target folder not found
|
||||||
|
|
||||||
|
existing_file = await File.get_or_none(
|
||||||
|
name=db_file.name, parent=target_folder, owner=current_user, is_deleted=False
|
||||||
|
)
|
||||||
|
if existing_file and existing_file.id != file_id:
|
||||||
|
continue # Skip if file with same name exists
|
||||||
|
|
||||||
|
db_file.parent = target_folder
|
||||||
|
await db_file.save()
|
||||||
|
await log_activity(user=current_user, action="file_moved_batch", target_type="file", target_id=file_id)
|
||||||
|
updated_files.append(db_file)
|
||||||
|
elif batch_operation.operation == "copy" and payload and payload.target_folder_id is not None:
|
||||||
|
target_folder = await Folder.get_or_none(id=payload.target_folder_id, owner=current_user, is_deleted=False)
|
||||||
|
if not target_folder:
|
||||||
|
continue # Skip if target folder not found
|
||||||
|
|
||||||
|
base_name = db_file.name
|
||||||
|
name_parts = os.path.splitext(base_name)
|
||||||
|
counter = 1
|
||||||
|
new_name = base_name
|
||||||
|
|
||||||
|
while await File.get_or_none(name=new_name, parent=target_folder, owner=current_user, is_deleted=False):
|
||||||
|
new_name = f"{name_parts[0]} (copy {counter}){name_parts[1]}"
|
||||||
|
counter += 1
|
||||||
|
|
||||||
|
new_file = await File.create(
|
||||||
|
name=new_name,
|
||||||
|
path=db_file.path,
|
||||||
|
size=db_file.size,
|
||||||
|
mime_type=db_file.mime_type,
|
||||||
|
file_hash=db_file.file_hash,
|
||||||
|
owner=current_user,
|
||||||
|
parent=target_folder
|
||||||
|
)
|
||||||
|
await log_activity(user=current_user, action="file_copied_batch", target_type="file", target_id=new_file.id)
|
||||||
|
updated_files.append(new_file)
|
||||||
|
|
||||||
|
return [await FileOut.from_tortoise_orm(f) for f in updated_files]
|
||||||
|
|||||||
@ -3,7 +3,7 @@ from typing import List, Optional
|
|||||||
|
|
||||||
from ..auth import get_current_user
|
from ..auth import get_current_user
|
||||||
from ..models import User, Folder
|
from ..models import User, Folder
|
||||||
from ..schemas import FolderCreate, FolderOut, FolderUpdate
|
from ..schemas import FolderCreate, FolderOut, FolderUpdate, BatchFolderOperation, BatchMoveCopyPayload
|
||||||
|
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
prefix="/folders",
|
prefix="/folders",
|
||||||
@ -103,3 +103,62 @@ async def delete_folder(folder_id: int, current_user: User = Depends(get_current
|
|||||||
folder.is_deleted = True
|
folder.is_deleted = True
|
||||||
await folder.save()
|
await folder.save()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
@router.post("/{folder_id}/star", response_model=FolderOut)
|
||||||
|
async def star_folder(folder_id: int, current_user: User = Depends(get_current_user)):
|
||||||
|
db_folder = await Folder.get_or_none(id=folder_id, owner=current_user, is_deleted=False)
|
||||||
|
if not db_folder:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Folder not found")
|
||||||
|
db_folder.is_starred = True
|
||||||
|
await db_folder.save()
|
||||||
|
return await FolderOut.from_tortoise_orm(db_folder)
|
||||||
|
|
||||||
|
@router.post("/{folder_id}/unstar", response_model=FolderOut)
|
||||||
|
async def unstar_folder(folder_id: int, current_user: User = Depends(get_current_user)):
|
||||||
|
db_folder = await Folder.get_or_none(id=folder_id, owner=current_user, is_deleted=False)
|
||||||
|
if not db_folder:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Folder not found")
|
||||||
|
db_folder.is_starred = False
|
||||||
|
await db_folder.save()
|
||||||
|
return await FolderOut.from_tortoise_orm(db_folder)
|
||||||
|
|
||||||
|
@router.post("/batch", response_model=List[FolderOut])
|
||||||
|
async def batch_folder_operations(
|
||||||
|
batch_operation: BatchFolderOperation,
|
||||||
|
payload: Optional[BatchMoveCopyPayload] = None,
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
updated_folders = []
|
||||||
|
for folder_id in batch_operation.folder_ids:
|
||||||
|
db_folder = await Folder.get_or_none(id=folder_id, owner=current_user, is_deleted=False)
|
||||||
|
if not db_folder:
|
||||||
|
continue # Skip if folder not found or not owned by user
|
||||||
|
|
||||||
|
if batch_operation.operation == "delete":
|
||||||
|
db_folder.is_deleted = True
|
||||||
|
await db_folder.save()
|
||||||
|
updated_folders.append(db_folder)
|
||||||
|
elif batch_operation.operation == "star":
|
||||||
|
db_folder.is_starred = True
|
||||||
|
await db_folder.save()
|
||||||
|
updated_folders.append(db_folder)
|
||||||
|
elif batch_operation.operation == "unstar":
|
||||||
|
db_folder.is_starred = False
|
||||||
|
await db_folder.save()
|
||||||
|
updated_folders.append(db_folder)
|
||||||
|
elif batch_operation.operation == "move" and payload and payload.target_folder_id is not None:
|
||||||
|
target_folder = await Folder.get_or_none(id=payload.target_folder_id, owner=current_user, is_deleted=False)
|
||||||
|
if not target_folder:
|
||||||
|
continue # Skip if target folder not found
|
||||||
|
|
||||||
|
existing_folder = await Folder.get_or_none(
|
||||||
|
name=db_folder.name, parent=target_folder, owner=current_user, is_deleted=False
|
||||||
|
)
|
||||||
|
if existing_folder and existing_folder.id != folder_id:
|
||||||
|
continue # Skip if folder with same name exists
|
||||||
|
|
||||||
|
db_folder.parent = target_folder
|
||||||
|
await db_folder.save()
|
||||||
|
updated_folders.append(db_folder)
|
||||||
|
|
||||||
|
return [await FolderOut.from_tortoise_orm(f) for f in updated_folders]
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from typing import Optional
|
from typing import Optional, List
|
||||||
import secrets
|
import secrets
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
@ -52,6 +52,11 @@ async def create_share_link(share_in: ShareCreate, current_user: User = Depends(
|
|||||||
)
|
)
|
||||||
return await ShareOut.from_tortoise_orm(share)
|
return await ShareOut.from_tortoise_orm(share)
|
||||||
|
|
||||||
|
@router.get("/my", response_model=List[ShareOut])
|
||||||
|
async def list_my_shares(current_user: User = Depends(get_current_user)):
|
||||||
|
shares = await Share.filter(owner=current_user).order_by("-created_at")
|
||||||
|
return [await ShareOut.from_tortoise_orm(share) for share in shares]
|
||||||
|
|
||||||
@router.get("/{share_token}", response_model=ShareOut)
|
@router.get("/{share_token}", response_model=ShareOut)
|
||||||
async def get_share_link_info(share_token: str):
|
async def get_share_link_info(share_token: str):
|
||||||
share = await Share.get_or_none(token=share_token)
|
share = await Share.get_or_none(token=share_token)
|
||||||
@ -67,6 +72,28 @@ async def get_share_link_info(share_token: str):
|
|||||||
|
|
||||||
return await ShareOut.from_tortoise_orm(share)
|
return await ShareOut.from_tortoise_orm(share)
|
||||||
|
|
||||||
|
@router.put("/{share_id}", response_model=ShareOut)
|
||||||
|
async def update_share(share_id: int, share_in: ShareCreate, current_user: User = Depends(get_current_user)):
|
||||||
|
share = await Share.get_or_none(id=share_id, owner=current_user)
|
||||||
|
if not share:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Share link not found or does not belong to you")
|
||||||
|
|
||||||
|
if share_in.expires_at is not None:
|
||||||
|
share.expires_at = share_in.expires_at
|
||||||
|
|
||||||
|
if share_in.password is not None:
|
||||||
|
share.hashed_password = get_password_hash(share_in.password)
|
||||||
|
share.password_protected = True
|
||||||
|
elif share_in.password == "": # Allow clearing password
|
||||||
|
share.hashed_password = None
|
||||||
|
share.password_protected = False
|
||||||
|
|
||||||
|
if share_in.permission_level is not None:
|
||||||
|
share.permission_level = share_in.permission_level
|
||||||
|
|
||||||
|
await share.save()
|
||||||
|
return await ShareOut.from_tortoise_orm(share)
|
||||||
|
|
||||||
@router.post("/{share_token}/access")
|
@router.post("/{share_token}/access")
|
||||||
async def access_shared_content(share_token: str, password: Optional[str] = None):
|
async def access_shared_content(share_token: str, password: Optional[str] = None):
|
||||||
share = await Share.get_or_none(token=share_token)
|
share = await Share.get_or_none(token=share_token)
|
||||||
|
|||||||
28
rbox/routers/starred.py
Normal file
28
rbox/routers/starred.py
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
from typing import List
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
|
from ..auth import get_current_user
|
||||||
|
from ..models import User, File, Folder
|
||||||
|
from ..schemas import FileOut, FolderOut
|
||||||
|
|
||||||
|
router = APIRouter(
|
||||||
|
prefix="/starred",
|
||||||
|
tags=["starred"],
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("/files", response_model=List[FileOut])
|
||||||
|
async def list_starred_files(current_user: User = Depends(get_current_user)):
|
||||||
|
files = await File.filter(owner=current_user, is_starred=True, is_deleted=False).order_by("name")
|
||||||
|
return [await FileOut.from_tortoise_orm(f) for f in files]
|
||||||
|
|
||||||
|
@router.get("/folders", response_model=List[FolderOut])
|
||||||
|
async def list_starred_folders(current_user: User = Depends(get_current_user)):
|
||||||
|
folders = await Folder.filter(owner=current_user, is_starred=True, is_deleted=False).order_by("name")
|
||||||
|
return [await FolderOut.from_tortoise_orm(f) for f in folders]
|
||||||
|
|
||||||
|
@router.get("/all", response_model=List[FileOut]) # This will return files and folders as files for now
|
||||||
|
async def list_all_starred(current_user: User = Depends(get_current_user)):
|
||||||
|
starred_files = await File.filter(owner=current_user, is_starred=True, is_deleted=False).order_by("name")
|
||||||
|
# For simplicity, we'll return files only for now. A more complex solution would involve a union or a custom schema.
|
||||||
|
return [await FileOut.from_tortoise_orm(f) for f in starred_files]
|
||||||
@ -12,12 +12,26 @@ class UserLogin(BaseModel):
|
|||||||
username: str
|
username: str
|
||||||
password: str
|
password: str
|
||||||
|
|
||||||
|
class UserLoginWith2FA(UserLogin):
|
||||||
|
two_factor_code: str
|
||||||
|
|
||||||
|
class UserAdminUpdate(BaseModel):
|
||||||
|
username: Optional[str] = None
|
||||||
|
email: Optional[EmailStr] = None
|
||||||
|
password: Optional[str] = None
|
||||||
|
is_active: Optional[bool] = None
|
||||||
|
is_superuser: Optional[bool] = None
|
||||||
|
storage_quota_bytes: Optional[int] = None
|
||||||
|
plan_type: Optional[str] = None
|
||||||
|
is_2fa_enabled: Optional[bool] = None
|
||||||
|
|
||||||
class Token(BaseModel):
|
class Token(BaseModel):
|
||||||
access_token: str
|
access_token: str
|
||||||
token_type: str
|
token_type: str
|
||||||
|
|
||||||
class TokenData(BaseModel):
|
class TokenData(BaseModel):
|
||||||
username: str | None = None
|
username: str | None = None
|
||||||
|
two_factor_verified: bool = False
|
||||||
|
|
||||||
class FolderCreate(BaseModel):
|
class FolderCreate(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
@ -84,3 +98,19 @@ FolderOut = pydantic_model_creator(Folder, name="FolderOut")
|
|||||||
FileOut = pydantic_model_creator(File, name="FileOut")
|
FileOut = pydantic_model_creator(File, name="FileOut")
|
||||||
ShareOut = pydantic_model_creator(Share, name="ShareOut")
|
ShareOut = pydantic_model_creator(Share, name="ShareOut")
|
||||||
FileVersionOut = pydantic_model_creator(FileVersion, name="FileVersionOut")
|
FileVersionOut = pydantic_model_creator(FileVersion, name="FileVersionOut")
|
||||||
|
|
||||||
|
class ErrorResponse(BaseModel):
|
||||||
|
code: int
|
||||||
|
message: str
|
||||||
|
details: Optional[str] = None
|
||||||
|
|
||||||
|
class BatchFileOperation(BaseModel):
|
||||||
|
file_ids: List[int]
|
||||||
|
operation: str # e.g., "delete", "move", "copy", "star", "unstar"
|
||||||
|
|
||||||
|
class BatchFolderOperation(BaseModel):
|
||||||
|
folder_ids: List[int]
|
||||||
|
operation: str # e.g., "delete", "move", "star", "unstar"
|
||||||
|
|
||||||
|
class BatchMoveCopyPayload(BaseModel):
|
||||||
|
target_folder_id: Optional[int] = None
|
||||||
|
|||||||
60
rbox/two_factor.py
Normal file
60
rbox/two_factor.py
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import pyotp
|
||||||
|
import qrcode
|
||||||
|
import io
|
||||||
|
import base64
|
||||||
|
import secrets
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
def generate_totp_secret() -> str:
|
||||||
|
"""Generates a random base32 TOTP secret."""
|
||||||
|
return pyotp.random_base32()
|
||||||
|
|
||||||
|
def generate_totp_uri(secret: str, account_name: str, issuer_name: str) -> str:
|
||||||
|
"""Generates a Google Authenticator-compatible TOTP URI."""
|
||||||
|
return pyotp.totp.TOTP(secret).provisioning_uri(name=account_name, issuer_name=issuer_name)
|
||||||
|
|
||||||
|
def generate_qr_code_base64(uri: str) -> str:
|
||||||
|
"""Generates a base64 encoded QR code image for a given URI."""
|
||||||
|
qr = qrcode.QRCode(
|
||||||
|
version=1,
|
||||||
|
error_correction=qrcode.constants.ERROR_CORRECT_L,
|
||||||
|
box_size=10,
|
||||||
|
border=4,
|
||||||
|
)
|
||||||
|
qr.add_data(uri)
|
||||||
|
qr.make(fit=True)
|
||||||
|
|
||||||
|
img = qr.make_image(fill_color="black", back_color="white")
|
||||||
|
buffered = io.BytesIO()
|
||||||
|
img.save(buffered, format="PNG")
|
||||||
|
return base64.b64encode(buffered.getvalue()).decode("utf-8")
|
||||||
|
|
||||||
|
def verify_totp_code(secret: str, code: str) -> bool:
|
||||||
|
"""Verifies a TOTP code against a secret."""
|
||||||
|
totp = pyotp.TOTP(secret)
|
||||||
|
return totp.verify(code)
|
||||||
|
|
||||||
|
def generate_recovery_codes(num_codes: int = 10) -> List[str]:
|
||||||
|
"""Generates a list of random recovery codes."""
|
||||||
|
return [secrets.token_urlsafe(16) for _ in range(num_codes)]
|
||||||
|
|
||||||
|
def hash_recovery_code(code: str) -> str:
|
||||||
|
"""Hashes a single recovery code using SHA256."""
|
||||||
|
return hashlib.sha256(code.encode('utf-8')).hexdigest()
|
||||||
|
|
||||||
|
def verify_recovery_code(plain_code: str, hashed_code: str) -> bool:
|
||||||
|
"""Verifies a plain recovery code against its hashed version."""
|
||||||
|
return hash_recovery_code(plain_code) == hashed_code
|
||||||
|
|
||||||
|
def hash_recovery_codes(codes: List[str]) -> List[str]:
|
||||||
|
"""Hashes a list of recovery codes."""
|
||||||
|
return [hash_recovery_code(code) for code in codes]
|
||||||
|
|
||||||
|
def verify_recovery_codes(plain_code: str, hashed_codes: List[str]) -> bool:
|
||||||
|
"""Verifies if a plain recovery code matches any of the hashed recovery codes."""
|
||||||
|
for hashed_code in hashed_codes:
|
||||||
|
if verify_recovery_code(plain_code, hashed_code):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
204
rbox/webdav.py
204
rbox/webdav.py
@ -1,4 +1,5 @@
|
|||||||
from fastapi import APIRouter, Request, Response, Depends, HTTPException, status, Header
|
from fastapi import APIRouter, Request, Response, Depends, HTTPException, status, Header
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from xml.etree import ElementTree as ET
|
from xml.etree import ElementTree as ET
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@ -9,7 +10,7 @@ import base64
|
|||||||
from urllib.parse import unquote, urlparse
|
from urllib.parse import unquote, urlparse
|
||||||
|
|
||||||
from .auth import get_current_user, verify_password
|
from .auth import get_current_user, verify_password
|
||||||
from .models import User, File, Folder
|
from .models import User, File, Folder, WebDAVProperty
|
||||||
from .storage import storage_manager
|
from .storage import storage_manager
|
||||||
from .activity import log_activity
|
from .activity import log_activity
|
||||||
|
|
||||||
@ -126,7 +127,14 @@ def build_href(base_path: str, name: str, is_collection: bool):
|
|||||||
path += '/'
|
path += '/'
|
||||||
return path
|
return path
|
||||||
|
|
||||||
def create_propstat_element(props: dict, status: str = "HTTP/1.1 200 OK"):
|
async def get_custom_properties(resource_type: str, resource_id: int):
|
||||||
|
props = await WebDAVProperty.filter(
|
||||||
|
resource_type=resource_type,
|
||||||
|
resource_id=resource_id
|
||||||
|
)
|
||||||
|
return {(prop.namespace, prop.name): prop.value for prop in props}
|
||||||
|
|
||||||
|
def create_propstat_element(props: dict, custom_props: dict = None, status: str = "HTTP/1.1 200 OK"):
|
||||||
propstat = ET.Element("D:propstat")
|
propstat = ET.Element("D:propstat")
|
||||||
prop = ET.SubElement(propstat, "D:prop")
|
prop = ET.SubElement(propstat, "D:prop")
|
||||||
|
|
||||||
@ -154,11 +162,46 @@ def create_propstat_element(props: dict, status: str = "HTTP/1.1 200 OK"):
|
|||||||
elem = ET.SubElement(prop, f"D:{key}")
|
elem = ET.SubElement(prop, f"D:{key}")
|
||||||
elem.text = value
|
elem.text = value
|
||||||
|
|
||||||
|
if custom_props:
|
||||||
|
for (namespace, name), value in custom_props.items():
|
||||||
|
if namespace == "DAV:":
|
||||||
|
continue
|
||||||
|
elem = ET.SubElement(prop, f"{{{namespace}}}{name}")
|
||||||
|
elem.text = value
|
||||||
|
|
||||||
status_elem = ET.SubElement(propstat, "D:status")
|
status_elem = ET.SubElement(propstat, "D:status")
|
||||||
status_elem.text = status
|
status_elem.text = status
|
||||||
|
|
||||||
return propstat
|
return propstat
|
||||||
|
|
||||||
|
def parse_propfind_body(body: bytes):
|
||||||
|
if not body:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
root = ET.fromstring(body)
|
||||||
|
|
||||||
|
allprop = root.find(".//{DAV:}allprop")
|
||||||
|
if allprop is not None:
|
||||||
|
return "allprop"
|
||||||
|
|
||||||
|
propname = root.find(".//{DAV:}propname")
|
||||||
|
if propname is not None:
|
||||||
|
return "propname"
|
||||||
|
|
||||||
|
prop = root.find(".//{DAV:}prop")
|
||||||
|
if prop is not None:
|
||||||
|
requested_props = []
|
||||||
|
for child in prop:
|
||||||
|
ns = child.tag.split('}')[0][1:] if '}' in child.tag else "DAV:"
|
||||||
|
name = child.tag.split('}')[1] if '}' in child.tag else child.tag
|
||||||
|
requested_props.append((ns, name))
|
||||||
|
return requested_props
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
@router.api_route("/{full_path:path}", methods=["OPTIONS"])
|
@router.api_route("/{full_path:path}", methods=["OPTIONS"])
|
||||||
async def webdav_options(full_path: str):
|
async def webdav_options(full_path: str):
|
||||||
return Response(
|
return Response(
|
||||||
@ -174,6 +217,8 @@ async def webdav_options(full_path: str):
|
|||||||
async def handle_propfind(request: Request, full_path: str, current_user: User = Depends(webdav_auth)):
|
async def handle_propfind(request: Request, full_path: str, current_user: User = Depends(webdav_auth)):
|
||||||
depth = request.headers.get("Depth", "1")
|
depth = request.headers.get("Depth", "1")
|
||||||
full_path = unquote(full_path).strip('/')
|
full_path = unquote(full_path).strip('/')
|
||||||
|
body = await request.body()
|
||||||
|
requested_props = parse_propfind_body(body)
|
||||||
|
|
||||||
resource, parent, exists = await resolve_path(full_path, current_user)
|
resource, parent, exists = await resolve_path(full_path, current_user)
|
||||||
|
|
||||||
@ -212,7 +257,8 @@ async def handle_propfind(request: Request, full_path: str, current_user: User =
|
|||||||
"creationdate": folder.created_at,
|
"creationdate": folder.created_at,
|
||||||
"getlastmodified": folder.updated_at
|
"getlastmodified": folder.updated_at
|
||||||
}
|
}
|
||||||
response.append(create_propstat_element(props))
|
custom_props = await get_custom_properties("folder", folder.id) if requested_props == "allprop" or (isinstance(requested_props, list) and any(ns != "DAV:" for ns, _ in requested_props)) else None
|
||||||
|
response.append(create_propstat_element(props, custom_props))
|
||||||
|
|
||||||
for file in files:
|
for file in files:
|
||||||
response = ET.SubElement(multistatus, "D:response")
|
response = ET.SubElement(multistatus, "D:response")
|
||||||
@ -228,7 +274,8 @@ async def handle_propfind(request: Request, full_path: str, current_user: User =
|
|||||||
"getlastmodified": file.updated_at,
|
"getlastmodified": file.updated_at,
|
||||||
"getetag": f'"{file.file_hash}"'
|
"getetag": f'"{file.file_hash}"'
|
||||||
}
|
}
|
||||||
response.append(create_propstat_element(props))
|
custom_props = await get_custom_properties("file", file.id) if requested_props == "allprop" or (isinstance(requested_props, list) and any(ns != "DAV:" for ns, _ in requested_props)) else None
|
||||||
|
response.append(create_propstat_element(props, custom_props))
|
||||||
|
|
||||||
elif isinstance(resource, Folder):
|
elif isinstance(resource, Folder):
|
||||||
response = ET.SubElement(multistatus, "D:response")
|
response = ET.SubElement(multistatus, "D:response")
|
||||||
@ -241,7 +288,8 @@ async def handle_propfind(request: Request, full_path: str, current_user: User =
|
|||||||
"creationdate": resource.created_at,
|
"creationdate": resource.created_at,
|
||||||
"getlastmodified": resource.updated_at
|
"getlastmodified": resource.updated_at
|
||||||
}
|
}
|
||||||
response.append(create_propstat_element(props))
|
custom_props = await get_custom_properties("folder", resource.id) if requested_props == "allprop" or (isinstance(requested_props, list) and any(ns != "DAV:" for ns, _ in requested_props)) else None
|
||||||
|
response.append(create_propstat_element(props, custom_props))
|
||||||
|
|
||||||
if depth in ["1", "infinity"]:
|
if depth in ["1", "infinity"]:
|
||||||
folders = await Folder.filter(owner=current_user, parent=resource, is_deleted=False)
|
folders = await Folder.filter(owner=current_user, parent=resource, is_deleted=False)
|
||||||
@ -258,7 +306,8 @@ async def handle_propfind(request: Request, full_path: str, current_user: User =
|
|||||||
"creationdate": folder.created_at,
|
"creationdate": folder.created_at,
|
||||||
"getlastmodified": folder.updated_at
|
"getlastmodified": folder.updated_at
|
||||||
}
|
}
|
||||||
response.append(create_propstat_element(props))
|
custom_props = await get_custom_properties("folder", folder.id) if requested_props == "allprop" or (isinstance(requested_props, list) and any(ns != "DAV:" for ns, _ in requested_props)) else None
|
||||||
|
response.append(create_propstat_element(props, custom_props))
|
||||||
|
|
||||||
for file in files:
|
for file in files:
|
||||||
response = ET.SubElement(multistatus, "D:response")
|
response = ET.SubElement(multistatus, "D:response")
|
||||||
@ -274,7 +323,8 @@ async def handle_propfind(request: Request, full_path: str, current_user: User =
|
|||||||
"getlastmodified": file.updated_at,
|
"getlastmodified": file.updated_at,
|
||||||
"getetag": f'"{file.file_hash}"'
|
"getetag": f'"{file.file_hash}"'
|
||||||
}
|
}
|
||||||
response.append(create_propstat_element(props))
|
custom_props = await get_custom_properties("file", file.id) if requested_props == "allprop" or (isinstance(requested_props, list) and any(ns != "DAV:" for ns, _ in requested_props)) else None
|
||||||
|
response.append(create_propstat_element(props, custom_props))
|
||||||
|
|
||||||
elif isinstance(resource, File):
|
elif isinstance(resource, File):
|
||||||
response = ET.SubElement(multistatus, "D:response")
|
response = ET.SubElement(multistatus, "D:response")
|
||||||
@ -290,7 +340,8 @@ async def handle_propfind(request: Request, full_path: str, current_user: User =
|
|||||||
"getlastmodified": resource.updated_at,
|
"getlastmodified": resource.updated_at,
|
||||||
"getetag": f'"{resource.file_hash}"'
|
"getetag": f'"{resource.file_hash}"'
|
||||||
}
|
}
|
||||||
response.append(create_propstat_element(props))
|
custom_props = await get_custom_properties("file", resource.id) if requested_props == "allprop" or (isinstance(requested_props, list) and any(ns != "DAV:" for ns, _ in requested_props)) else None
|
||||||
|
response.append(create_propstat_element(props, custom_props))
|
||||||
|
|
||||||
xml_content = ET.tostring(multistatus, encoding="utf-8", xml_declaration=True)
|
xml_content = ET.tostring(multistatus, encoding="utf-8", xml_declaration=True)
|
||||||
return Response(content=xml_content, media_type="application/xml; charset=utf-8", status_code=207)
|
return Response(content=xml_content, media_type="application/xml; charset=utf-8", status_code=207)
|
||||||
@ -320,7 +371,7 @@ async def handle_get(request: Request, full_path: str, current_user: User = Depe
|
|||||||
async for chunk in storage_manager.get_file(current_user.id, resource.path):
|
async for chunk in storage_manager.get_file(current_user.id, resource.path):
|
||||||
yield chunk
|
yield chunk
|
||||||
|
|
||||||
return Response(
|
return StreamingResponse(
|
||||||
content=file_iterator(),
|
content=file_iterator(),
|
||||||
media_type=resource.mime_type,
|
media_type=resource.mime_type,
|
||||||
headers={
|
headers={
|
||||||
@ -652,3 +703,138 @@ async def handle_unlock(request: Request, full_path: str, current_user: User = D
|
|||||||
|
|
||||||
WebDAVLock.remove_lock(full_path)
|
WebDAVLock.remove_lock(full_path)
|
||||||
return Response(status_code=204)
|
return Response(status_code=204)
|
||||||
|
|
||||||
|
@router.api_route("/{full_path:path}", methods=["PROPPATCH"])
|
||||||
|
async def handle_proppatch(request: Request, full_path: str, current_user: User = Depends(webdav_auth)):
|
||||||
|
full_path = unquote(full_path).strip('/')
|
||||||
|
|
||||||
|
resource, parent, exists = await resolve_path(full_path, current_user)
|
||||||
|
|
||||||
|
if not resource:
|
||||||
|
raise HTTPException(status_code=404, detail="Resource not found")
|
||||||
|
|
||||||
|
body = await request.body()
|
||||||
|
if not body:
|
||||||
|
raise HTTPException(status_code=400, detail="Request body required")
|
||||||
|
|
||||||
|
try:
|
||||||
|
root = ET.fromstring(body)
|
||||||
|
except:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid XML")
|
||||||
|
|
||||||
|
resource_type = "file" if isinstance(resource, File) else "folder"
|
||||||
|
resource_id = resource.id
|
||||||
|
|
||||||
|
set_props = []
|
||||||
|
remove_props = []
|
||||||
|
failed_props = []
|
||||||
|
|
||||||
|
set_element = root.find(".//{DAV:}set")
|
||||||
|
if set_element is not None:
|
||||||
|
prop_element = set_element.find(".//{DAV:}prop")
|
||||||
|
if prop_element is not None:
|
||||||
|
for child in prop_element:
|
||||||
|
ns = child.tag.split('}')[0][1:] if '}' in child.tag else "DAV:"
|
||||||
|
name = child.tag.split('}')[1] if '}' in child.tag else child.tag
|
||||||
|
value = child.text or ""
|
||||||
|
|
||||||
|
if ns == "DAV:":
|
||||||
|
live_props = ["creationdate", "getcontentlength", "getcontenttype",
|
||||||
|
"getetag", "getlastmodified", "resourcetype"]
|
||||||
|
if name in live_props:
|
||||||
|
failed_props.append((ns, name, "409 Conflict"))
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
existing_prop = await WebDAVProperty.get_or_none(
|
||||||
|
resource_type=resource_type,
|
||||||
|
resource_id=resource_id,
|
||||||
|
namespace=ns,
|
||||||
|
name=name
|
||||||
|
)
|
||||||
|
|
||||||
|
if existing_prop:
|
||||||
|
existing_prop.value = value
|
||||||
|
await existing_prop.save()
|
||||||
|
else:
|
||||||
|
await WebDAVProperty.create(
|
||||||
|
resource_type=resource_type,
|
||||||
|
resource_id=resource_id,
|
||||||
|
namespace=ns,
|
||||||
|
name=name,
|
||||||
|
value=value
|
||||||
|
)
|
||||||
|
set_props.append((ns, name))
|
||||||
|
except Exception as e:
|
||||||
|
failed_props.append((ns, name, "500 Internal Server Error"))
|
||||||
|
|
||||||
|
remove_element = root.find(".//{DAV:}remove")
|
||||||
|
if remove_element is not None:
|
||||||
|
prop_element = remove_element.find(".//{DAV:}prop")
|
||||||
|
if prop_element is not None:
|
||||||
|
for child in prop_element:
|
||||||
|
ns = child.tag.split('}')[0][1:] if '}' in child.tag else "DAV:"
|
||||||
|
name = child.tag.split('}')[1] if '}' in child.tag else child.tag
|
||||||
|
|
||||||
|
if ns == "DAV:":
|
||||||
|
failed_props.append((ns, name, "409 Conflict"))
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
existing_prop = await WebDAVProperty.get_or_none(
|
||||||
|
resource_type=resource_type,
|
||||||
|
resource_id=resource_id,
|
||||||
|
namespace=ns,
|
||||||
|
name=name
|
||||||
|
)
|
||||||
|
|
||||||
|
if existing_prop:
|
||||||
|
await existing_prop.delete()
|
||||||
|
remove_props.append((ns, name))
|
||||||
|
else:
|
||||||
|
failed_props.append((ns, name, "404 Not Found"))
|
||||||
|
except Exception as e:
|
||||||
|
failed_props.append((ns, name, "500 Internal Server Error"))
|
||||||
|
|
||||||
|
multistatus = ET.Element("D:multistatus", {"xmlns:D": "DAV:"})
|
||||||
|
response_elem = ET.SubElement(multistatus, "D:response")
|
||||||
|
href = ET.SubElement(response_elem, "D:href")
|
||||||
|
href.text = f"/webdav/{full_path}"
|
||||||
|
|
||||||
|
if set_props or remove_props:
|
||||||
|
propstat = ET.SubElement(response_elem, "D:propstat")
|
||||||
|
prop = ET.SubElement(propstat, "D:prop")
|
||||||
|
|
||||||
|
for ns, name in set_props + remove_props:
|
||||||
|
if ns == "DAV:":
|
||||||
|
ET.SubElement(prop, f"D:{name}")
|
||||||
|
else:
|
||||||
|
ET.SubElement(prop, f"{{{ns}}}{name}")
|
||||||
|
|
||||||
|
status_elem = ET.SubElement(propstat, "D:status")
|
||||||
|
status_elem.text = "HTTP/1.1 200 OK"
|
||||||
|
|
||||||
|
if failed_props:
|
||||||
|
prop_by_status = {}
|
||||||
|
for ns, name, status_text in failed_props:
|
||||||
|
if status_text not in prop_by_status:
|
||||||
|
prop_by_status[status_text] = []
|
||||||
|
prop_by_status[status_text].append((ns, name))
|
||||||
|
|
||||||
|
for status_text, props_list in prop_by_status.items():
|
||||||
|
propstat = ET.SubElement(response_elem, "D:propstat")
|
||||||
|
prop = ET.SubElement(propstat, "D:prop")
|
||||||
|
|
||||||
|
for ns, name in props_list:
|
||||||
|
if ns == "DAV:":
|
||||||
|
ET.SubElement(prop, f"D:{name}")
|
||||||
|
else:
|
||||||
|
ET.SubElement(prop, f"{{{ns}}}{name}")
|
||||||
|
|
||||||
|
status_elem = ET.SubElement(propstat, "D:status")
|
||||||
|
status_elem.text = f"HTTP/1.1 {status_text}"
|
||||||
|
|
||||||
|
await log_activity(current_user, "properties_modified", resource_type, resource_id)
|
||||||
|
|
||||||
|
xml_content = ET.tostring(multistatus, encoding="utf-8", xml_declaration=True)
|
||||||
|
return Response(content=xml_content, media_type="application/xml; charset=utf-8", status_code=207)
|
||||||
|
|||||||
@ -9,7 +9,7 @@ if [ ! -f .env ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Starting database services..."
|
echo "Starting database services..."
|
||||||
docker-compose up -d db redis
|
docker compose up -d db redis
|
||||||
|
|
||||||
echo "Waiting for database to be ready..."
|
echo "Waiting for database to be ready..."
|
||||||
sleep 5
|
sleep 5
|
||||||
|
|||||||
@ -171,6 +171,25 @@ body {
|
|||||||
background-color: #AA0000;
|
background-color: #AA0000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.button-danger {
|
||||||
|
background-color: var(--secondary-color);
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-danger:hover {
|
||||||
|
background-color: #AA0000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deleted-item {
|
||||||
|
border-color: var(--secondary-color);
|
||||||
|
background-color: rgba(255, 0, 0, 0.05); /* Light red tint */
|
||||||
|
}
|
||||||
|
|
||||||
|
.deleted-item .file-name,
|
||||||
|
.deleted-item .file-deleted-date {
|
||||||
|
color: var(--secondary-color);
|
||||||
|
}
|
||||||
|
|
||||||
.input-field {
|
.input-field {
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
padding: var(--spacing-unit);
|
padding: var(--spacing-unit);
|
||||||
@ -786,3 +805,93 @@ body.dark-mode {
|
|||||||
margin: 0 0 calc(var(--spacing-unit) * 2) 0;
|
margin: 0 0 calc(var(--spacing-unit) * 2) 0;
|
||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-dashboard-container {
|
||||||
|
background-color: var(--accent-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: calc(var(--spacing-unit) * 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-dashboard-container h2 {
|
||||||
|
margin: 0 0 calc(var(--spacing-unit) * 2) 0;
|
||||||
|
color: var(--primary-color);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
padding-bottom: calc(var(--spacing-unit) * 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-section {
|
||||||
|
margin-bottom: calc(var(--spacing-unit) * 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-section h3 {
|
||||||
|
margin: 0 0 calc(var(--spacing-unit) * 1.5) 0;
|
||||||
|
color: var(--text-color);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-unit);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--spacing-unit) calc(var(--spacing-unit) * 2);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: var(--background-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-unit);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-small {
|
||||||
|
padding: calc(var(--spacing-unit) / 2) var(--spacing-unit);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.star-btn {
|
||||||
|
color: gold; /* Gold color for stars */
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
margin-left: var(--spacing-unit);
|
||||||
|
}
|
||||||
|
|
||||||
|
.star-btn:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-unit);
|
||||||
|
background-color: var(--background-color);
|
||||||
|
padding: var(--spacing-unit);
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: calc(var(--spacing-unit) * 2);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-actions label {
|
||||||
|
font-weight: 500;
|
||||||
|
margin-right: var(--spacing-unit);
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-item {
|
||||||
|
margin-right: var(--spacing-unit);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item .select-item {
|
||||||
|
position: absolute;
|
||||||
|
top: var(--spacing-unit);
|
||||||
|
left: var(--spacing-unit);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|||||||
124
static/js/api.js
124
static/js/api.js
@ -48,8 +48,17 @@ class APIClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json().catch(() => ({ detail: 'Unknown error' }));
|
let errorData;
|
||||||
throw new Error(error.detail || 'Request failed');
|
try {
|
||||||
|
errorData = await response.json();
|
||||||
|
} catch (e) {
|
||||||
|
errorData = { message: 'Unknown error' };
|
||||||
|
}
|
||||||
|
const errorMessage = errorData.detail || errorData.message || 'Request failed';
|
||||||
|
document.dispatchEvent(new CustomEvent('show-toast', {
|
||||||
|
detail: { message: errorMessage, type: 'error' }
|
||||||
|
}));
|
||||||
|
throw new Error(errorMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status === 204) {
|
if (response.status === 204) {
|
||||||
@ -200,9 +209,118 @@ class APIClient {
|
|||||||
return this.request('files/photos');
|
return this.request('files/photos');
|
||||||
}
|
}
|
||||||
|
|
||||||
getThumbnailUrl(fileId) {
|
async getThumbnailUrl(fileId) {
|
||||||
return `${this.baseURL}files/thumbnail/${fileId}`;
|
return `${this.baseURL}files/thumbnail/${fileId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listDeletedFiles() {
|
||||||
|
return this.request('files/deleted');
|
||||||
|
}
|
||||||
|
|
||||||
|
async restoreFile(fileId) {
|
||||||
|
return this.request(`files/${fileId}/restore`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async listUsers() {
|
||||||
|
return this.request('admin/users');
|
||||||
|
}
|
||||||
|
|
||||||
|
async createUser(userData) {
|
||||||
|
return this.request('admin/users', {
|
||||||
|
method: 'POST',
|
||||||
|
body: userData
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateUser(userId, userData) {
|
||||||
|
return this.request(`admin/users/${userId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: userData
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteUser(userId) {
|
||||||
|
return this.request(`admin/users/${userId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async starFile(fileId) {
|
||||||
|
return this.request(`files/${fileId}/star`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async unstarFile(fileId) {
|
||||||
|
return this.request(`files/${fileId}/unstar`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async starFolder(folderId) {
|
||||||
|
return this.request(`folders/${folderId}/star`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async unstarFolder(folderId) {
|
||||||
|
return this.request(`folders/${folderId}/unstar`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async listStarredFiles() {
|
||||||
|
return this.request('starred/files');
|
||||||
|
}
|
||||||
|
|
||||||
|
async listStarredFolders() {
|
||||||
|
return this.request('starred/folders');
|
||||||
|
}
|
||||||
|
|
||||||
|
async listRecentFiles() {
|
||||||
|
return this.request('files/recent');
|
||||||
|
}
|
||||||
|
|
||||||
|
async listMyShares() {
|
||||||
|
return this.request('shares/my');
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateShare(shareId, shareData) {
|
||||||
|
return this.request(`shares/${shareId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: shareData
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteShare(shareId) {
|
||||||
|
return this.request(`shares/${shareId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async batchFileOperations(operation, fileIds, targetFolderId = null) {
|
||||||
|
const payload = { file_ids: fileIds, operation: operation };
|
||||||
|
if (targetFolderId !== null) {
|
||||||
|
payload.target_folder_id = targetFolderId;
|
||||||
|
}
|
||||||
|
return this.request('files/batch', {
|
||||||
|
method: 'POST',
|
||||||
|
body: payload
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async batchFolderOperations(operation, folderIds, targetFolderId = null) {
|
||||||
|
const payload = { folder_ids: folderIds, operation: operation };
|
||||||
|
if (targetFolderId !== null) {
|
||||||
|
payload.target_folder_id = targetFolderId;
|
||||||
|
}
|
||||||
|
return this.request('folders/batch', {
|
||||||
|
method: 'POST',
|
||||||
|
body: payload
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const api = new APIClient();
|
export const api = new APIClient();
|
||||||
|
|||||||
174
static/js/components/admin-dashboard.js
Normal file
174
static/js/components/admin-dashboard.js
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
import { api } from '../api.js';
|
||||||
|
|
||||||
|
export class AdminDashboard extends HTMLElement {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.users = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async connectedCallback() {
|
||||||
|
await this.loadUsers();
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadUsers() {
|
||||||
|
try {
|
||||||
|
this.users = await api.listUsers();
|
||||||
|
this.render();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load users:', error);
|
||||||
|
this.innerHTML = '<p class="error-message">Failed to load users. Do you have admin privileges?</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
this.innerHTML = `
|
||||||
|
<div class="admin-dashboard-container">
|
||||||
|
<h2>Admin Dashboard</h2>
|
||||||
|
|
||||||
|
<div class="admin-section">
|
||||||
|
<h3>User Management</h3>
|
||||||
|
<button id="createUserButton" class="button button-primary">Create New User</button>
|
||||||
|
<div class="user-list">
|
||||||
|
${this.users.map(user => this.renderUser(user)).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="userModal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<span class="close-button">×</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);
|
||||||
109
static/js/components/deleted-files.js
Normal file
109
static/js/components/deleted-files.js
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
import { api } from '../api.js';
|
||||||
|
|
||||||
|
export class DeletedFiles extends HTMLElement {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.deletedFiles = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async connectedCallback() {
|
||||||
|
await this.loadDeletedFiles();
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadDeletedFiles() {
|
||||||
|
try {
|
||||||
|
this.deletedFiles = await api.listDeletedFiles();
|
||||||
|
this.render();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load deleted files:', error);
|
||||||
|
this.innerHTML = '<p class="error-message">Failed to load deleted files.</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.deletedFiles.length === 0) {
|
||||||
|
this.innerHTML = `
|
||||||
|
<div class="deleted-files-container">
|
||||||
|
<h2>Deleted Files</h2>
|
||||||
|
<p class="empty-state">No deleted files found.</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.innerHTML = `
|
||||||
|
<div class="deleted-files-container">
|
||||||
|
<h2>Deleted Files</h2>
|
||||||
|
<div class="file-grid">
|
||||||
|
${this.deletedFiles.map(file => this.renderDeletedFile(file)).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
this.attachListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
renderDeletedFile(file) {
|
||||||
|
const icon = this.getFileIcon(file.mime_type);
|
||||||
|
const size = this.formatFileSize(file.size);
|
||||||
|
const deletedDate = new Date(file.deleted_at).toLocaleDateString();
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="file-item deleted-item" data-file-id="${file.id}">
|
||||||
|
<div class="file-icon">${icon}</div>
|
||||||
|
<div class="file-name">${file.name}</div>
|
||||||
|
<div class="file-size">${size}</div>
|
||||||
|
<div class="file-deleted-date">Deleted: ${deletedDate}</div>
|
||||||
|
<div class="file-actions-menu">
|
||||||
|
<button class="button button-danger action-btn" data-action="restore" data-id="${file.id}">Restore</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
getFileIcon(mimeType) {
|
||||||
|
if (mimeType.startsWith('image/')) return '📷';
|
||||||
|
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,6 +6,8 @@ export class FileList extends HTMLElement {
|
|||||||
this.currentFolderId = null;
|
this.currentFolderId = null;
|
||||||
this.files = [];
|
this.files = [];
|
||||||
this.folders = [];
|
this.folders = [];
|
||||||
|
this.selectedFiles = new Set();
|
||||||
|
this.selectedFolders = new Set();
|
||||||
}
|
}
|
||||||
|
|
||||||
async connectedCallback() {
|
async connectedCallback() {
|
||||||
@ -17,19 +19,31 @@ export class FileList extends HTMLElement {
|
|||||||
try {
|
try {
|
||||||
this.folders = await api.listFolders(folderId);
|
this.folders = await api.listFolders(folderId);
|
||||||
this.files = await api.listFiles(folderId);
|
this.files = await api.listFiles(folderId);
|
||||||
|
this.selectedFiles.clear();
|
||||||
|
this.selectedFolders.clear();
|
||||||
this.render();
|
this.render();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load contents:', error);
|
console.error('Failed to load contents:', error);
|
||||||
|
document.dispatchEvent(new CustomEvent('show-toast', {
|
||||||
|
detail: { message: 'Failed to load contents: ' + error.message, type: 'error' }
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setFiles(files) {
|
setFiles(files) {
|
||||||
this.files = files;
|
this.files = files;
|
||||||
this.folders = [];
|
this.folders = [];
|
||||||
|
this.selectedFiles.clear();
|
||||||
|
this.selectedFolders.clear();
|
||||||
this.render();
|
this.render();
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
const hasSelected = this.selectedFiles.size > 0 || this.selectedFolders.size > 0;
|
||||||
|
const allFilesSelected = this.files.length > 0 && this.selectedFiles.size === this.files.length;
|
||||||
|
const allFoldersSelected = this.folders.length > 0 && this.selectedFolders.size === this.folders.length;
|
||||||
|
const allSelected = (this.files.length + this.folders.length) > 0 && allFilesSelected && allFoldersSelected;
|
||||||
|
|
||||||
this.innerHTML = `
|
this.innerHTML = `
|
||||||
<div class="file-list-container">
|
<div class="file-list-container">
|
||||||
<div class="file-list-header">
|
<div class="file-list-header">
|
||||||
@ -40,6 +54,16 @@ export class FileList extends HTMLElement {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="batch-actions" style="display: ${hasSelected ? 'flex' : 'none'};">
|
||||||
|
<input type="checkbox" id="select-all" ${allSelected ? 'checked' : ''}>
|
||||||
|
<label for="select-all">Select All</label>
|
||||||
|
<button class="button button-small button-danger" id="batch-delete-btn">Delete Selected</button>
|
||||||
|
<button class="button button-small" id="batch-move-btn">Move Selected</button>
|
||||||
|
<button class="button button-small" id="batch-copy-btn">Copy Selected</button>
|
||||||
|
<button class="button button-small" id="batch-star-btn">Star Selected</button>
|
||||||
|
<button class="button button-small" id="batch-unstar-btn">Unstar Selected</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="file-grid">
|
<div class="file-grid">
|
||||||
${this.folders.map(folder => this.renderFolder(folder)).join('')}
|
${this.folders.map(folder => this.renderFolder(folder)).join('')}
|
||||||
${this.files.map(file => this.renderFile(file)).join('')}
|
${this.files.map(file => this.renderFile(file)).join('')}
|
||||||
@ -51,23 +75,32 @@ export class FileList extends HTMLElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
renderFolder(folder) {
|
renderFolder(folder) {
|
||||||
|
const isSelected = this.selectedFolders.has(folder.id);
|
||||||
|
const starIcon = folder.is_starred ? '★' : '☆'; // Filled star or empty star
|
||||||
|
const starAction = folder.is_starred ? 'unstar-folder' : 'star-folder';
|
||||||
return `
|
return `
|
||||||
<div class="file-item folder-item" data-folder-id="${folder.id}">
|
<div class="file-item folder-item" data-folder-id="${folder.id}">
|
||||||
|
<input type="checkbox" class="select-item" data-type="folder" data-id="${folder.id}" ${isSelected ? 'checked' : ''}>
|
||||||
<div class="file-icon">📁</div>
|
<div class="file-icon">📁</div>
|
||||||
<div class="file-name">${folder.name}</div>
|
<div class="file-name">${folder.name}</div>
|
||||||
<div class="file-actions-menu">
|
<div class="file-actions-menu">
|
||||||
<button class="action-btn" data-action="delete-folder" data-id="${folder.id}">Delete</button>
|
<button class="action-btn" data-action="delete-folder" data-id="${folder.id}">Delete</button>
|
||||||
|
<button class="action-btn star-btn" data-action="${starAction}" data-id="${folder.id}">${starIcon}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
renderFile(file) {
|
renderFile(file) {
|
||||||
|
const isSelected = this.selectedFiles.has(file.id);
|
||||||
const icon = this.getFileIcon(file.mime_type);
|
const icon = this.getFileIcon(file.mime_type);
|
||||||
const size = this.formatFileSize(file.size);
|
const size = this.formatFileSize(file.size);
|
||||||
|
const starIcon = file.is_starred ? '★' : '☆'; // Filled star or empty star
|
||||||
|
const starAction = file.is_starred ? 'unstar-file' : 'star-file';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="file-item" data-file-id="${file.id}">
|
<div class="file-item" data-file-id="${file.id}">
|
||||||
|
<input type="checkbox" class="select-item" data-type="file" data-id="${file.id}" ${isSelected ? 'checked' : ''}>
|
||||||
<div class="file-icon">${icon}</div>
|
<div class="file-icon">${icon}</div>
|
||||||
<div class="file-name">${file.name}</div>
|
<div class="file-name">${file.name}</div>
|
||||||
<div class="file-size">${size}</div>
|
<div class="file-size">${size}</div>
|
||||||
@ -76,6 +109,7 @@ export class FileList extends HTMLElement {
|
|||||||
<button class="action-btn" data-action="rename" data-id="${file.id}">Rename</button>
|
<button class="action-btn" data-action="rename" data-id="${file.id}">Rename</button>
|
||||||
<button class="action-btn" data-action="delete" data-id="${file.id}">Delete</button>
|
<button class="action-btn" data-action="delete" data-id="${file.id}">Delete</button>
|
||||||
<button class="action-btn" data-action="share" data-id="${file.id}">Share</button>
|
<button class="action-btn" data-action="share" data-id="${file.id}">Share</button>
|
||||||
|
<button class="action-btn star-btn" data-action="${starAction}" data-id="${file.id}">${starIcon}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@ -115,7 +149,7 @@ export class FileList extends HTMLElement {
|
|||||||
|
|
||||||
this.querySelectorAll('.file-item:not(.folder-item)').forEach(item => {
|
this.querySelectorAll('.file-item:not(.folder-item)').forEach(item => {
|
||||||
item.addEventListener('click', (e) => {
|
item.addEventListener('click', (e) => {
|
||||||
if (!e.target.classList.contains('action-btn')) {
|
if (!e.target.classList.contains('action-btn') && !e.target.classList.contains('select-item')) {
|
||||||
const fileId = parseInt(item.dataset.fileId);
|
const fileId = parseInt(item.dataset.fileId);
|
||||||
const file = this.files.find(f => f.id === fileId);
|
const file = this.files.find(f => f.id === fileId);
|
||||||
this.dispatchEvent(new CustomEvent('photo-click', {
|
this.dispatchEvent(new CustomEvent('photo-click', {
|
||||||
@ -134,6 +168,112 @@ export class FileList extends HTMLElement {
|
|||||||
await this.handleAction(action, id);
|
await this.handleAction(action, id);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.querySelectorAll('.select-item').forEach(checkbox => {
|
||||||
|
checkbox.addEventListener('change', (e) => {
|
||||||
|
const type = e.target.dataset.type;
|
||||||
|
const id = parseInt(e.target.dataset.id);
|
||||||
|
this.toggleSelectItem(type, id, e.target.checked);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.querySelector('#select-all')?.addEventListener('change', (e) => {
|
||||||
|
this.toggleSelectAll(e.target.checked);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.querySelector('#batch-delete-btn')?.addEventListener('click', () => this.handleBatchAction('delete'));
|
||||||
|
this.querySelector('#batch-move-btn')?.addEventListener('click', () => this.handleBatchAction('move'));
|
||||||
|
this.querySelector('#batch-copy-btn')?.addEventListener('click', () => this.handleBatchAction('copy'));
|
||||||
|
this.querySelector('#batch-star-btn')?.addEventListener('click', () => this.handleBatchAction('star'));
|
||||||
|
this.querySelector('#batch-unstar-btn')?.addEventListener('click', () => this.handleBatchAction('unstar'));
|
||||||
|
|
||||||
|
this.updateBatchActionVisibility();
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleSelectItem(type, id, checked) {
|
||||||
|
if (type === 'file') {
|
||||||
|
if (checked) {
|
||||||
|
this.selectedFiles.add(id);
|
||||||
|
} else {
|
||||||
|
this.selectedFiles.delete(id);
|
||||||
|
}
|
||||||
|
} else if (type === 'folder') {
|
||||||
|
if (checked) {
|
||||||
|
this.selectedFolders.add(id);
|
||||||
|
} else {
|
||||||
|
this.selectedFolders.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.updateBatchActionVisibility();
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleSelectAll(checked) {
|
||||||
|
this.selectedFiles.clear();
|
||||||
|
this.selectedFolders.clear();
|
||||||
|
|
||||||
|
if (checked) {
|
||||||
|
this.files.forEach(file => this.selectedFiles.add(file.id));
|
||||||
|
this.folders.forEach(folder => this.selectedFolders.add(folder.id));
|
||||||
|
}
|
||||||
|
this.render(); // Re-render to update checkboxes
|
||||||
|
}
|
||||||
|
|
||||||
|
updateBatchActionVisibility() {
|
||||||
|
const batchActionsDiv = this.querySelector('.batch-actions');
|
||||||
|
if (batchActionsDiv) {
|
||||||
|
if (this.selectedFiles.size > 0 || this.selectedFolders.size > 0) {
|
||||||
|
batchActionsDiv.style.display = 'flex';
|
||||||
|
} else {
|
||||||
|
batchActionsDiv.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleBatchAction(action) {
|
||||||
|
if ((this.selectedFiles.size === 0 && this.selectedFolders.size === 0)) {
|
||||||
|
document.dispatchEvent(new CustomEvent('show-toast', {
|
||||||
|
detail: { message: 'No items selected for batch operation.', type: 'info' }
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!confirm(`Are you sure you want to ${action} ${this.selectedFiles.size + this.selectedFolders.size} items?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let targetFolderId = null;
|
||||||
|
if (action === 'move' || action === 'copy') {
|
||||||
|
const folderName = prompt('Enter target folder ID (leave empty for root):');
|
||||||
|
if (folderName !== null) {
|
||||||
|
targetFolderId = folderName === '' ? null : parseInt(folderName);
|
||||||
|
if (folderName !== '' && isNaN(targetFolderId)) {
|
||||||
|
document.dispatchEvent(new CustomEvent('show-toast', {
|
||||||
|
detail: { message: 'Invalid folder ID.', type: 'error' }
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return; // User cancelled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.selectedFiles.size > 0) {
|
||||||
|
await api.batchFileOperations(action, Array.from(this.selectedFiles), targetFolderId);
|
||||||
|
}
|
||||||
|
if (this.selectedFolders.size > 0) {
|
||||||
|
await api.batchFolderOperations(action, Array.from(this.selectedFolders), targetFolderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.dispatchEvent(new CustomEvent('show-toast', {
|
||||||
|
detail: { message: `Batch ${action} successful!`, type: 'success' }
|
||||||
|
}));
|
||||||
|
await this.loadContents(this.currentFolderId); // Reload contents
|
||||||
|
} catch (error) {
|
||||||
|
document.dispatchEvent(new CustomEvent('show-toast', {
|
||||||
|
detail: { message: `Batch ${action} failed: ` + error.message, type: 'error' }
|
||||||
|
}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
triggerCreateFolder() {
|
triggerCreateFolder() {
|
||||||
@ -147,7 +287,9 @@ export class FileList extends HTMLElement {
|
|||||||
await api.createFolder(name, this.currentFolderId);
|
await api.createFolder(name, this.currentFolderId);
|
||||||
await this.loadContents(this.currentFolderId);
|
await this.loadContents(this.currentFolderId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert('Failed to create folder: ' + error.message);
|
document.dispatchEvent(new CustomEvent('show-toast', {
|
||||||
|
detail: { message: 'Failed to create folder: ' + error.message, type: 'error' }
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -164,6 +306,9 @@ export class FileList extends HTMLElement {
|
|||||||
a.download = file.name;
|
a.download = file.name;
|
||||||
a.click();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
|
document.dispatchEvent(new CustomEvent('show-toast', {
|
||||||
|
detail: { message: 'File downloaded successfully!', type: 'success' }
|
||||||
|
}));
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'rename':
|
case 'rename':
|
||||||
@ -171,6 +316,9 @@ export class FileList extends HTMLElement {
|
|||||||
if (newName) {
|
if (newName) {
|
||||||
await api.renameFile(id, newName);
|
await api.renameFile(id, newName);
|
||||||
await this.loadContents(this.currentFolderId);
|
await this.loadContents(this.currentFolderId);
|
||||||
|
document.dispatchEvent(new CustomEvent('show-toast', {
|
||||||
|
detail: { message: 'File renamed successfully!', type: 'success' }
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@ -178,6 +326,9 @@ export class FileList extends HTMLElement {
|
|||||||
if (confirm('Are you sure you want to delete this file?')) {
|
if (confirm('Are you sure you want to delete this file?')) {
|
||||||
await api.deleteFile(id);
|
await api.deleteFile(id);
|
||||||
await this.loadContents(this.currentFolderId);
|
await this.loadContents(this.currentFolderId);
|
||||||
|
document.dispatchEvent(new CustomEvent('show-toast', {
|
||||||
|
detail: { message: 'File deleted successfully!', type: 'success' }
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@ -185,15 +336,48 @@ export class FileList extends HTMLElement {
|
|||||||
if (confirm('Are you sure you want to delete this folder?')) {
|
if (confirm('Are you sure you want to delete this folder?')) {
|
||||||
await api.deleteFolder(id);
|
await api.deleteFolder(id);
|
||||||
await this.loadContents(this.currentFolderId);
|
await this.loadContents(this.currentFolderId);
|
||||||
|
document.dispatchEvent(new CustomEvent('show-toast', {
|
||||||
|
detail: { message: 'Folder deleted successfully!', type: 'success' }
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'share':
|
case 'share':
|
||||||
this.dispatchEvent(new CustomEvent('share-request', { detail: { fileId: id } }));
|
this.dispatchEvent(new CustomEvent('share-request', { detail: { fileId: id } }));
|
||||||
break;
|
break;
|
||||||
|
case 'star-file':
|
||||||
|
await api.starFile(id);
|
||||||
|
document.dispatchEvent(new CustomEvent('show-toast', {
|
||||||
|
detail: { message: 'File starred successfully!', type: 'success' }
|
||||||
|
}));
|
||||||
|
await this.loadContents(this.currentFolderId);
|
||||||
|
break;
|
||||||
|
case 'unstar-file':
|
||||||
|
await api.unstarFile(id);
|
||||||
|
document.dispatchEvent(new CustomEvent('show-toast', {
|
||||||
|
detail: { message: 'File unstarred successfully!', type: 'success' }
|
||||||
|
}));
|
||||||
|
await this.loadContents(this.currentFolderId);
|
||||||
|
break;
|
||||||
|
case 'star-folder':
|
||||||
|
await api.starFolder(id);
|
||||||
|
document.dispatchEvent(new CustomEvent('show-toast', {
|
||||||
|
detail: { message: 'Folder starred successfully!', type: 'success' }
|
||||||
|
}));
|
||||||
|
await this.loadContents(this.currentFolderId);
|
||||||
|
break;
|
||||||
|
case 'unstar-folder':
|
||||||
|
await api.unstarFolder(id);
|
||||||
|
document.dispatchEvent(new CustomEvent('show-toast', {
|
||||||
|
detail: { message: 'Folder unstarred successfully!', type: 'success' }
|
||||||
|
}));
|
||||||
|
await this.loadContents(this.currentFolderId);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert('Action failed: ' + error.message);
|
document.dispatchEvent(new CustomEvent('show-toast', {
|
||||||
|
detail: { message: 'Action failed: ' + error.message, type: 'error' }
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,12 @@ import './file-upload.js';
|
|||||||
import './share-modal.js';
|
import './share-modal.js';
|
||||||
import './photo-gallery.js';
|
import './photo-gallery.js';
|
||||||
import './file-preview.js';
|
import './file-preview.js';
|
||||||
|
import './deleted-files.js';
|
||||||
|
import './admin-dashboard.js';
|
||||||
|
import './toast-notification.js';
|
||||||
|
import './starred-items.js';
|
||||||
|
import './recent-files.js';
|
||||||
|
import './shared-items.js'; // Import the new component
|
||||||
import { shortcuts } from '../shortcuts.js';
|
import { shortcuts } from '../shortcuts.js';
|
||||||
|
|
||||||
export class RBoxApp extends HTMLElement {
|
export class RBoxApp extends HTMLElement {
|
||||||
@ -17,6 +23,22 @@ export class RBoxApp extends HTMLElement {
|
|||||||
|
|
||||||
async connectedCallback() {
|
async connectedCallback() {
|
||||||
await this.init();
|
await this.init();
|
||||||
|
this.addEventListener('show-toast', this.handleShowToast);
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback() {
|
||||||
|
this.removeEventListener('show-toast', this.handleShowToast);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleShowToast = (event) => {
|
||||||
|
const { message, type, duration } = event.detail;
|
||||||
|
this.showToast(message, type, duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast(message, type = 'info', duration = 3000) {
|
||||||
|
const toast = document.createElement('toast-notification');
|
||||||
|
document.body.appendChild(toast);
|
||||||
|
toast.show(message, type, duration);
|
||||||
}
|
}
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
@ -63,6 +85,7 @@ export class RBoxApp extends HTMLElement {
|
|||||||
<li><a href="#" class="nav-link" data-view="photos">Photo Gallery</a></li>
|
<li><a href="#" class="nav-link" data-view="photos">Photo Gallery</a></li>
|
||||||
<li><a href="#" class="nav-link" data-view="shared">Shared Items</a></li>
|
<li><a href="#" class="nav-link" data-view="shared">Shared Items</a></li>
|
||||||
<li><a href="#" class="nav-link" data-view="deleted">Deleted Files</a></li>
|
<li><a href="#" class="nav-link" data-view="deleted">Deleted Files</a></li>
|
||||||
|
${this.user && this.user.is_superuser ? `<li><a href="#" class="nav-link" data-view="admin">Admin Dashboard</a></li>` : ''}
|
||||||
</ul>
|
</ul>
|
||||||
<h3 class="nav-title">Quick Access</h3>
|
<h3 class="nav-title">Quick Access</h3>
|
||||||
<ul class="nav-list">
|
<ul class="nav-list">
|
||||||
@ -134,6 +157,12 @@ export class RBoxApp extends HTMLElement {
|
|||||||
this.switchView('deleted');
|
this.switchView('deleted');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
shortcuts.register('5', () => {
|
||||||
|
if (this.user && this.user.is_superuser) {
|
||||||
|
this.switchView('admin');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
shortcuts.register('f2', () => {
|
shortcuts.register('f2', () => {
|
||||||
console.log('Rename shortcut - to be implemented');
|
console.log('Rename shortcut - to be implemented');
|
||||||
});
|
});
|
||||||
@ -176,6 +205,11 @@ export class RBoxApp extends HTMLElement {
|
|||||||
<kbd>4</kbd>
|
<kbd>4</kbd>
|
||||||
<span>Deleted Files</span>
|
<span>Deleted Files</span>
|
||||||
</div>
|
</div>
|
||||||
|
${this.user && this.user.is_superuser ? `
|
||||||
|
<div class="shortcut-item">
|
||||||
|
<kbd>5</kbd>
|
||||||
|
<span>Admin Dashboard</span>
|
||||||
|
</div>` : ''}
|
||||||
|
|
||||||
<h3>General</h3>
|
<h3>General</h3>
|
||||||
<div class="shortcut-item">
|
<div class="shortcut-item">
|
||||||
@ -329,16 +363,24 @@ export class RBoxApp extends HTMLElement {
|
|||||||
this.attachListeners();
|
this.attachListeners();
|
||||||
break;
|
break;
|
||||||
case 'shared':
|
case 'shared':
|
||||||
mainContent.innerHTML = '<div class="placeholder">Shared Items - Coming Soon</div>';
|
mainContent.innerHTML = '<shared-items></shared-items>';
|
||||||
|
this.attachListeners();
|
||||||
break;
|
break;
|
||||||
case 'deleted':
|
case 'deleted':
|
||||||
mainContent.innerHTML = '<div class="placeholder">Deleted Files - Coming Soon</div>';
|
mainContent.innerHTML = '<deleted-files></deleted-files>';
|
||||||
|
this.attachListeners(); // Re-attach listeners for the new component
|
||||||
break;
|
break;
|
||||||
case 'starred':
|
case 'starred':
|
||||||
mainContent.innerHTML = '<div class="placeholder">Starred Items - Coming Soon</div>';
|
mainContent.innerHTML = '<starred-items></starred-items>';
|
||||||
|
this.attachListeners();
|
||||||
break;
|
break;
|
||||||
case 'recent':
|
case 'recent':
|
||||||
mainContent.innerHTML = '<div class="placeholder">Recent Files - Coming Soon</div>';
|
mainContent.innerHTML = '<recent-files></recent-files>';
|
||||||
|
this.attachListeners();
|
||||||
|
break;
|
||||||
|
case 'admin':
|
||||||
|
mainContent.innerHTML = '<admin-dashboard></admin-dashboard>';
|
||||||
|
this.attachListeners();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
115
static/js/components/recent-files.js
Normal file
115
static/js/components/recent-files.js
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
import { api } from '../api.js';
|
||||||
|
|
||||||
|
export class RecentFiles extends HTMLElement {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.recentFiles = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async connectedCallback() {
|
||||||
|
await this.loadRecentFiles();
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadRecentFiles() {
|
||||||
|
try {
|
||||||
|
this.recentFiles = await api.listRecentFiles();
|
||||||
|
this.render();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load recent files:', error);
|
||||||
|
document.dispatchEvent(new CustomEvent('show-toast', {
|
||||||
|
detail: { message: 'Failed to load recent files: ' + error.message, type: 'error' }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.recentFiles.length === 0) {
|
||||||
|
this.innerHTML = `
|
||||||
|
<div class="recent-files-container">
|
||||||
|
<h2>Recent Files</h2>
|
||||||
|
<p class="empty-state">No recent files found.</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.innerHTML = `
|
||||||
|
<div class="recent-files-container">
|
||||||
|
<h2>Recent Files</h2>
|
||||||
|
<div class="file-grid">
|
||||||
|
${this.recentFiles.map(file => this.renderFile(file)).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
this.attachListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
renderFile(file) {
|
||||||
|
const icon = this.getFileIcon(file.mime_type);
|
||||||
|
const size = this.formatFileSize(file.size);
|
||||||
|
const lastAccessed = file.last_accessed_at ? new Date(file.last_accessed_at).toLocaleString() : 'N/A';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="file-item" data-file-id="${file.id}">
|
||||||
|
<div class="file-icon">${icon}</div>
|
||||||
|
<div class="file-name">${file.name}</div>
|
||||||
|
<div class="file-size">${size}</div>
|
||||||
|
<div class="file-last-accessed">Accessed: ${lastAccessed}</div>
|
||||||
|
<div class="file-actions-menu">
|
||||||
|
<button class="action-btn" data-action="download" data-id="${file.id}">Download</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
getFileIcon(mimeType) {
|
||||||
|
if (mimeType.startsWith('image/')) return '📷';
|
||||||
|
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,7 +126,9 @@ export class ShareModal extends HTMLElement {
|
|||||||
const linkInput = this.querySelector('#share-link');
|
const linkInput = this.querySelector('#share-link');
|
||||||
linkInput.select();
|
linkInput.select();
|
||||||
document.execCommand('copy');
|
document.execCommand('copy');
|
||||||
alert('Link copied to clipboard');
|
document.dispatchEvent(new CustomEvent('show-toast', {
|
||||||
|
detail: { message: 'Link copied to clipboard!', type: 'success' }
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
128
static/js/components/shared-items.js
Normal file
128
static/js/components/shared-items.js
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
import { api } from '../api.js';
|
||||||
|
|
||||||
|
export class SharedItems extends HTMLElement {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.myShares = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async connectedCallback() {
|
||||||
|
await this.loadMyShares();
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadMyShares() {
|
||||||
|
try {
|
||||||
|
this.myShares = await api.listMyShares();
|
||||||
|
this.render();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load shared items:', error);
|
||||||
|
document.dispatchEvent(new CustomEvent('show-toast', {
|
||||||
|
detail: { message: 'Failed to load shared items: ' + error.message, type: 'error' }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.myShares.length === 0) {
|
||||||
|
this.innerHTML = `
|
||||||
|
<div class="shared-items-container">
|
||||||
|
<h2>Shared Items</h2>
|
||||||
|
<p class="empty-state">No shared items found.</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.innerHTML = `
|
||||||
|
<div class="shared-items-container">
|
||||||
|
<h2>Shared Items</h2>
|
||||||
|
<div class="share-list">
|
||||||
|
${this.myShares.map(share => this.renderShare(share)).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
this.attachListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
renderShare(share) {
|
||||||
|
const shareLink = `${window.location.origin}/share/${share.token}`;
|
||||||
|
const expiresAt = share.expires_at ? new Date(share.expires_at).toLocaleString() : 'Never';
|
||||||
|
const targetName = share.file ? share.file.name : (share.folder ? share.folder.name : 'N/A');
|
||||||
|
const targetType = share.file ? 'File' : (share.folder ? 'Folder' : 'N/A');
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="share-item" data-share-id="${share.id}">
|
||||||
|
<div class="share-info">
|
||||||
|
<p><strong>${targetType}:</strong> ${targetName}</p>
|
||||||
|
<p><strong>Permission:</strong> ${share.permission_level}</p>
|
||||||
|
<p><strong>Expires:</strong> ${expiresAt}</p>
|
||||||
|
<p><strong>Password Protected:</strong> ${share.password_protected ? 'Yes' : 'No'}</p>
|
||||||
|
<p><strong>Access Count:</strong> ${share.access_count}</p>
|
||||||
|
<input type="text" value="${shareLink}" readonly class="input-field share-link-input">
|
||||||
|
</div>
|
||||||
|
<div class="share-actions">
|
||||||
|
<button class="button button-small" data-action="copy-link" data-link="${shareLink}">Copy Link</button>
|
||||||
|
<button class="button button-small" data-action="edit-share" data-id="${share.id}">Edit</button>
|
||||||
|
<button class="button button-small button-danger" data-action="delete-share" data-id="${share.id}">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
attachListeners() {
|
||||||
|
this.querySelectorAll('.share-actions .button').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const action = btn.dataset.action;
|
||||||
|
const id = parseInt(btn.dataset.id);
|
||||||
|
const link = btn.dataset.link;
|
||||||
|
|
||||||
|
if (action === 'copy-link') {
|
||||||
|
this.copyLink(link);
|
||||||
|
} else if (action === 'edit-share') {
|
||||||
|
this.handleEditShare(id);
|
||||||
|
} else if (action === 'delete-share') {
|
||||||
|
await this.handleDeleteShare(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
copyLink(link) {
|
||||||
|
navigator.clipboard.writeText(link).then(() => {
|
||||||
|
document.dispatchEvent(new CustomEvent('show-toast', {
|
||||||
|
detail: { message: 'Share link copied to clipboard!', type: 'success' }
|
||||||
|
}));
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('Failed to copy link: ', err);
|
||||||
|
document.dispatchEvent(new CustomEvent('show-toast', {
|
||||||
|
detail: { message: 'Failed to copy link.', type: 'error' }
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleEditShare(shareId) {
|
||||||
|
// For now, we'll just show a toast. A full implementation would open a modal for editing.
|
||||||
|
document.dispatchEvent(new CustomEvent('show-toast', {
|
||||||
|
detail: { message: `Edit share ${shareId} - functionality to be implemented.`, type: 'info' }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleDeleteShare(shareId) {
|
||||||
|
if (confirm('Are you sure you want to delete this share link?')) {
|
||||||
|
try {
|
||||||
|
await api.deleteShare(shareId);
|
||||||
|
document.dispatchEvent(new CustomEvent('show-toast', {
|
||||||
|
detail: { message: 'Share link deleted successfully!', type: 'success' }
|
||||||
|
}));
|
||||||
|
await this.loadMyShares(); // Reload the list
|
||||||
|
} catch (error) {
|
||||||
|
document.dispatchEvent(new CustomEvent('show-toast', {
|
||||||
|
detail: { message: 'Failed to delete share link: ' + error.message, type: 'error' }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('shared-items', SharedItems);
|
||||||
147
static/js/components/starred-items.js
Normal file
147
static/js/components/starred-items.js
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
import { api } from '../api.js';
|
||||||
|
|
||||||
|
export class StarredItems extends HTMLElement {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.starredFiles = [];
|
||||||
|
this.starredFolders = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async connectedCallback() {
|
||||||
|
await this.loadStarredItems();
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadStarredItems() {
|
||||||
|
try {
|
||||||
|
this.starredFiles = await api.listStarredFiles();
|
||||||
|
this.starredFolders = await api.listStarredFolders();
|
||||||
|
this.render();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load starred items:', error);
|
||||||
|
document.dispatchEvent(new CustomEvent('show-toast', {
|
||||||
|
detail: { message: 'Failed to load starred items: ' + error.message, type: 'error' }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const allStarred = [...this.starredFolders, ...this.starredFiles];
|
||||||
|
|
||||||
|
if (allStarred.length === 0) {
|
||||||
|
this.innerHTML = `
|
||||||
|
<div class="starred-items-container">
|
||||||
|
<h2>Starred Items</h2>
|
||||||
|
<p class="empty-state">No starred items found.</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.innerHTML = `
|
||||||
|
<div class="starred-items-container">
|
||||||
|
<h2>Starred Items</h2>
|
||||||
|
<div class="file-grid">
|
||||||
|
${this.starredFolders.map(folder => this.renderFolder(folder)).join('')}
|
||||||
|
${this.starredFiles.map(file => this.renderFile(file)).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
this.attachListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
renderFolder(folder) {
|
||||||
|
return `
|
||||||
|
<div class="file-item folder-item" data-folder-id="${folder.id}">
|
||||||
|
<div class="file-icon">📁</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);
|
||||||
76
static/js/components/toast-notification.js
Normal file
76
static/js/components/toast-notification.js
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
export class ToastNotification extends HTMLElement {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.attachShadow({ mode: 'open' });
|
||||||
|
this.shadowRoot.innerHTML = `
|
||||||
|
<style>
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
position: fixed;
|
||||||
|
bottom: 20px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: var(--accent-color);
|
||||||
|
padding: 15px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||||
|
font-family: var(--font-family);
|
||||||
|
font-size: 1rem;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease-in-out, transform 0.3s ease-in-out;
|
||||||
|
z-index: 10000;
|
||||||
|
min-width: 250px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
:host(.show) {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(-50%) translateY(0);
|
||||||
|
}
|
||||||
|
:host(.hide) {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-50%) translateY(20px);
|
||||||
|
}
|
||||||
|
:host(.error) {
|
||||||
|
background-color: var(--secondary-color);
|
||||||
|
}
|
||||||
|
:host(.success) {
|
||||||
|
background-color: #4CAF50; /* Green for success */
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<div id="message"></div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
// Ensure variables are defined, fallback if not
|
||||||
|
if (!this.style.getPropertyValue('--primary-color')) {
|
||||||
|
this.style.setProperty('--primary-color', '#003399');
|
||||||
|
this.style.setProperty('--secondary-color', '#CC0000');
|
||||||
|
this.style.setProperty('--accent-color', '#FFFFFF');
|
||||||
|
this.style.setProperty('--font-family', 'sans-serif');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
show(message, type = 'info', duration = 3000) {
|
||||||
|
const messageDiv = this.shadowRoot.getElementById('message');
|
||||||
|
messageDiv.textContent = message;
|
||||||
|
|
||||||
|
this.className = ''; // Clear previous classes
|
||||||
|
this.classList.add('show');
|
||||||
|
if (type === 'error') {
|
||||||
|
this.classList.add('error');
|
||||||
|
} else if (type === 'success') {
|
||||||
|
this.classList.add('success');
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
this.classList.remove('show');
|
||||||
|
this.classList.add('hide');
|
||||||
|
// Remove element after transition
|
||||||
|
this.addEventListener('transitionend', () => this.remove(), { once: true });
|
||||||
|
}, duration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('toast-notification', ToastNotification);
|
||||||
Loading…
Reference in New Issue
Block a user