This commit is contained in:
retoor 2025-11-09 23:29:07 +01:00
commit adc861d4b4
39 changed files with 4390 additions and 0 deletions

29
.env.example Normal file
View File

@ -0,0 +1,29 @@
POSTGRES_USER=rbox_user
POSTGRES_PASSWORD=rbox_password
POSTGRES_DB=rbox_db
DATABASE_URL=postgres://rbox_user:rbox_password@db:5432/rbox_db
REDIS_URL=redis://redis:6379/0
SECRET_KEY=change-this-to-a-random-secret-key-in-production
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30
REFRESH_TOKEN_EXPIRE_DAYS=7
DOMAIN_NAME=localhost
CERTBOT_EMAIL=admin@example.com
STORAGE_PATH=/app/data
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
S3_ENDPOINT_URL=
S3_BUCKET_NAME=rbox-storage
SMTP_SERVER=
SMTP_PORT=587
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_FROM_EMAIL=no-reply@example.com
TOTP_ISSUER=RBox

59
.gitignore vendored Normal file
View File

@ -0,0 +1,59 @@
__pycache__/
*.py[cod]
*$py.class
*.md
.*
storage
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
.pytest_cache/
.coverage
htmlcov/
.tox/
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
*.log
.DS_Store
Thumbs.db
*.db
*.sqlite3
node_modules/
.vscode/
.idea/
*.swp
*.swo
data/
uploads/
*.pem
*.key
*.crt

36
Dockerfile Normal file
View File

@ -0,0 +1,36 @@
# Use an official Python runtime as a parent image
FROM python:3.10-slim-buster
# Set the working directory in the container
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
build-essential \
libpq-dev \
libmagic-dev \
libjpeg-dev \
zlib1g-dev \
libwebp-dev \
tesseract-ocr \
ffmpeg \
git \
&& rm -rf /var/lib/apt/lists/*
# Copy pyproject.toml and poetry.lock to the working directory
COPY pyproject.toml poetry.lock* ./
# Install poetry
RUN pip install poetry
# Install project dependencies
RUN poetry install --no-root --no-dev
# Copy the rest of the application code
COPY . .
# Expose port 8000 for the FastAPI application
EXPOSE 8000
# Command to run the application (will be overridden by docker-compose)
CMD ["poetry", "run", "uvicorn", "rbox.main:app", "--host", "0.0.0.0", "--port", "8000"]

67
docker-compose.yml Normal file
View File

@ -0,0 +1,67 @@
version: '3.8'
services:
db:
image: postgres:15-alpine
volumes:
- postgres_data:/var/lib/postgresql/data
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
restart: unless-stopped
redis:
image: redis:7-alpine
volumes:
- redis_data:/data
restart: unless-stopped
app:
build:
context: .
dockerfile: Dockerfile
command: /usr/local/bin/gunicorn rbox.main:app --workers 4 --worker-class uvicorn.workers.UvicornWorker --bind 0.0.0.0:8000
volumes:
- app_data:/app/data # For uploaded files
environment:
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
REDIS_URL: redis://redis:6379/0
SECRET_KEY: ${SECRET_KEY}
DOMAIN_NAME: ${DOMAIN_NAME}
# Add other environment variables as needed
depends_on:
- db
- redis
restart: unless-stopped
nginx:
image: nginx:stable-alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf
- certbot_certs:/etc/letsencrypt
- app_data:/app/data # Serve static files from here
depends_on:
- app
restart: unless-stopped
certbot:
image: certbot/certbot
volumes:
- certbot_certs:/etc/letsencrypt
- ./certbot/conf:/etc/nginx/conf.d
command: certonly --webroot --webroot-path=/var/www/certbot --email ${CERTBOT_EMAIL} --agree-tos --no-eff-email -d ${DOMAIN_NAME}
depends_on:
- nginx
environment:
DOMAIN_NAME: ${DOMAIN_NAME}
CERTBOT_EMAIL: ${CERTBOT_EMAIL}
volumes:
postgres_data:
redis_data:
app_data:
certbot_certs:

52
nginx/nginx.conf Normal file
View File

@ -0,0 +1,52 @@
server {
listen 80;
server_name ${DOMAIN_NAME};
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
server_name ${DOMAIN_NAME};
ssl_certificate /etc/letsencrypt/live/${DOMAIN_NAME}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/${DOMAIN_NAME}/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
client_max_body_size 0; # Allow unlimited file size
location / {
proxy_pass http://app:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /static/ {
alias /app/static/;
}
location /webdav/ {
proxy_pass http://app:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Depth "";
proxy_set_header Destination "";
proxy_set_header Overwrite "";
proxy_set_header If "";
proxy_set_header Content-Type $content_type;
}
}

44
pyproject.toml Normal file
View File

@ -0,0 +1,44 @@
[tool.poetry]
name = "rbox"
version = "0.1.0"
description = "A self-hosted cloud storage web application"
authors = ["Your Name <you@example.com>"]
readme = "README.md"
[tool.poetry.dependencies]
python = "*"
fastapi = "*"
uvicorn = {extras = ["standard"], version = "*"}
tortoise-orm = {extras = ["asyncpg"], version = "*"}
redis = "*"
python-jose = {extras = ["cryptography"], version = "*"}
passlib = {extras = ["bcrypt"], version = "*"}
pyotp = "*"
python-multipart = "*"
aiofiles = "*"
httpx = "*"
pillow = "*"
python-magic = "*"
watchdog = "*"
asyncio-throttle = "*"
python-dotenv = "*"
aiowebdav = "*"
minio = "*"
cryptography = "*"
#tesseract-ocr = "*"
opencv-python = "*"
ffmpeg-python = "*"
gunicorn = "*"
[tool.poetry.group.dev.dependencies]
black = "*"
isort = "*"
flake8 = "*"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
[tool.poetry.scripts]
rbox = "rbox.main:main"

0
rbox/__init__.py Normal file
View File

17
rbox/activity.py Normal file
View File

@ -0,0 +1,17 @@
from typing import Optional
from .models import Activity, User
async def log_activity(
user: Optional[User],
action: str,
target_type: str,
target_id: int,
ip_address: Optional[str] = None
):
await Activity.create(
user=user,
action=action,
target_type=target_type,
target_id=target_id,
ip_address=ip_address
)

59
rbox/auth.py Normal file
View File

@ -0,0 +1,59 @@
from datetime import datetime, timedelta
from typing import Optional
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
import bcrypt
from .schemas import TokenData
from .settings import settings
from .models import User
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
def verify_password(plain_password, hashed_password):
password_bytes = plain_password[:72].encode('utf-8')
hashed_bytes = hashed_password.encode('utf-8') if isinstance(hashed_password, str) else hashed_password
return bcrypt.checkpw(password_bytes, hashed_bytes)
def get_password_hash(password):
password_bytes = password[:72].encode('utf-8')
return bcrypt.hashpw(password_bytes, bcrypt.gensalt()).decode('utf-8')
async def authenticate_user(username: str, password: str):
user = await User.get_or_none(username=username)
if not user:
return None
if not verify_password(password, user.hashed_password):
return None
return user
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return encoded_jwt
async def get_current_user(token: str = Depends(oauth2_scheme)):
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
username: str = payload.get("sub")
if username is None:
raise credentials_exception
token_data = TokenData(username=username)
except JWTError:
raise credentials_exception
user = await User.get_or_none(username=token_data.username)
if user is None:
raise credentials_exception
return user

59
rbox/main.py Normal file
View File

@ -0,0 +1,59 @@
import argparse
import uvicorn
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.responses import HTMLResponse # Import HTMLResponse
from tortoise.contrib.fastapi import register_tortoise
from .settings import settings
from .routers import auth, users, folders, files, shares, search
from . import webdav
app = FastAPI(
title="RBox Cloud Storage",
description="A self-hosted cloud storage web application",
version="0.1.0",
)
app.include_router(auth.router)
app.include_router(users.router)
app.include_router(folders.router)
app.include_router(files.router)
app.include_router(shares.router)
app.include_router(search.router)
app.include_router(webdav.router)
# Mount static files
app.mount("/static", StaticFiles(directory="static"), name="static")
register_tortoise(
app,
db_url=settings.DATABASE_URL,
modules={"models": ["rbox.models"]},
generate_schemas=True,
add_exception_handlers=True,
)
@app.on_event("startup")
async def startup_event():
print("Starting up...")
print("Database connected.")
@app.on_event("shutdown")
async def shutdown_event():
print("Shutting down...")
@app.get("/", response_class=HTMLResponse) # Change response_class to HTMLResponse
async def read_root():
with open("static/index.html", "r") as f:
return f.read()
def main():
parser = argparse.ArgumentParser(description="Run the RBox application.")
parser.add_argument("--host", type=str, default="0.0.0.0", help="Host address to bind to")
parser.add_argument("--port", type=int, default=8000, help="Port to listen on")
args = parser.parse_args()
uvicorn.run(app, host=args.host, port=args.port)
if __name__ == "__main__":
main()

137
rbox/models.py Normal file
View File

@ -0,0 +1,137 @@
from tortoise import fields, models
from tortoise.contrib.pydantic import pydantic_model_creator
from datetime import datetime
class User(models.Model):
id = fields.IntField(pk=True)
username = fields.CharField(max_length=20, unique=True)
email = fields.CharField(max_length=255, unique=True)
hashed_password = fields.CharField(max_length=255)
is_active = fields.BooleanField(default=True)
is_superuser = fields.BooleanField(default=False)
created_at = fields.DatetimeField(auto_now_add=True)
updated_at = fields.DatetimeField(auto_now=True)
storage_quota_bytes = fields.BigIntField(default=10 * 1024 * 1024 * 1024) # 10 GB default
used_storage_bytes = fields.BigIntField(default=0)
plan_type = fields.CharField(max_length=50, default="free")
two_factor_secret = fields.CharField(max_length=255, null=True)
class Meta:
table = "users"
def __str__(self):
return self.username
class Folder(models.Model):
id = fields.IntField(pk=True)
name = fields.CharField(max_length=255)
parent: fields.ForeignKeyRelation["Folder"] = fields.ForeignKeyField("models.Folder", related_name="children", null=True)
owner: fields.ForeignKeyRelation[User] = fields.ForeignKeyField("models.User", related_name="folders")
created_at = fields.DatetimeField(auto_now_add=True)
updated_at = fields.DatetimeField(auto_now=True)
is_deleted = fields.BooleanField(default=False)
class Meta:
table = "folders"
unique_together = (("name", "parent", "owner"),) # Ensure unique folder names within a parent for an owner
def __str__(self):
return self.name
class File(models.Model):
id = fields.IntField(pk=True)
name = fields.CharField(max_length=255)
path = fields.CharField(max_length=1024) # Internal storage path
size = fields.BigIntField()
mime_type = fields.CharField(max_length=255)
file_hash = fields.CharField(max_length=64, null=True) # SHA-256
thumbnail_path = fields.CharField(max_length=1024, null=True)
parent: fields.ForeignKeyRelation[Folder] = fields.ForeignKeyField("models.Folder", related_name="files", null=True)
owner: fields.ForeignKeyRelation[User] = fields.ForeignKeyField("models.User", related_name="files")
created_at = fields.DatetimeField(auto_now_add=True)
updated_at = fields.DatetimeField(auto_now=True)
is_deleted = fields.BooleanField(default=False)
deleted_at = fields.DatetimeField(null=True)
class Meta:
table = "files"
unique_together = (("name", "parent", "owner"),) # Ensure unique file names within a parent for an owner
def __str__(self):
return self.name
class FileVersion(models.Model):
id = fields.IntField(pk=True)
file: fields.ForeignKeyRelation[File] = fields.ForeignKeyField("models.File", related_name="versions")
version_path = fields.CharField(max_length=1024)
size = fields.BigIntField()
created_at = fields.DatetimeField(auto_now_add=True)
class Meta:
table = "file_versions"
class Share(models.Model):
id = fields.IntField(pk=True)
token = fields.CharField(max_length=64, unique=True)
file: fields.ForeignKeyRelation[File] = fields.ForeignKeyField("models.File", related_name="shares", null=True)
folder: fields.ForeignKeyRelation[Folder] = fields.ForeignKeyField("models.Folder", related_name="shares", null=True)
owner: fields.ForeignKeyRelation[User] = fields.ForeignKeyField("models.User", related_name="shares")
created_at = fields.DatetimeField(auto_now_add=True)
expires_at = fields.DatetimeField(null=True)
password_protected = fields.BooleanField(default=False)
hashed_password = fields.CharField(max_length=255, null=True)
access_count = fields.IntField(default=0)
permission_level = fields.CharField(max_length=50, default="viewer") # viewer, uploader, editor
class Meta:
table = "shares"
class Team(models.Model):
id = fields.IntField(pk=True)
name = fields.CharField(max_length=255, unique=True)
owner: fields.ForeignKeyRelation[User] = fields.ForeignKeyField("models.User", related_name="owned_teams")
members: fields.ManyToManyRelation[User] = fields.ManyToManyField("models.User", related_name="teams", through="team_members")
created_at = fields.DatetimeField(auto_now_add=True)
class Meta:
table = "teams"
class TeamMember(models.Model):
id = fields.IntField(pk=True)
team: fields.ForeignKeyRelation[Team] = fields.ForeignKeyField("models.Team", related_name="team_members")
user: fields.ForeignKeyRelation[User] = fields.ForeignKeyField("models.User", related_name="user_teams")
role = fields.CharField(max_length=50, default="member") # owner, admin, member
class Meta:
table = "team_members"
unique_together = (("team", "user"),)
class Activity(models.Model):
id = fields.IntField(pk=True)
user: fields.ForeignKeyRelation[User] = fields.ForeignKeyField("models.User", related_name="activities", null=True)
action = fields.CharField(max_length=255)
target_type = fields.CharField(max_length=50) # file, folder, share, user, team
target_id = fields.IntField()
ip_address = fields.CharField(max_length=45, null=True)
timestamp = fields.DatetimeField(auto_now_add=True)
class Meta:
table = "activities"
class FileRequest(models.Model):
id = fields.IntField(pk=True)
title = fields.CharField(max_length=255)
description = fields.TextField(null=True)
token = fields.CharField(max_length=64, unique=True)
owner: fields.ForeignKeyRelation[User] = fields.ForeignKeyField("models.User", related_name="file_requests")
target_folder: fields.ForeignKeyRelation[Folder] = fields.ForeignKeyField("models.Folder", related_name="file_requests")
created_at = fields.DatetimeField(auto_now_add=True)
expires_at = fields.DatetimeField(null=True)
is_active = fields.BooleanField(default=True)
# TODO: Add fields for custom form configuration
class Meta:
table = "file_requests"
User_Pydantic = pydantic_model_creator(User, name="User_Pydantic")
UserIn_Pydantic = pydantic_model_creator(User, name="UserIn_Pydantic", exclude_readonly=True)

56
rbox/routers/auth.py Normal file
View File

@ -0,0 +1,56 @@
from datetime import timedelta
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from ..auth import authenticate_user, create_access_token, get_password_hash
from ..models import User
from ..schemas import Token, UserCreate
router = APIRouter(
prefix="/auth",
tags=["auth"],
)
@router.post("/register", response_model=Token)
async def register_user(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,
)
access_token_expires = timedelta(minutes=30) # Use settings
access_token = create_access_token(
data={"sub": user.username}, expires_delta=access_token_expires
)
return {"access_token": access_token, "token_type": "bearer"}
@router.post("/token", response_model=Token)
async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends()):
user = await authenticate_user(form_data.username, form_data.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
access_token_expires = timedelta(minutes=30) # Use settings
access_token = create_access_token(
data={"sub": user.username}, expires_delta=access_token_expires
)
return {"access_token": access_token, "token_type": "bearer"}

259
rbox/routers/files.py Normal file
View File

@ -0,0 +1,259 @@
from fastapi import APIRouter, Depends, UploadFile, File as FastAPIFile, HTTPException, status, Response
from fastapi.responses import StreamingResponse
from typing import List, Optional
import mimetypes
import hashlib
import os
from datetime import datetime
from pydantic import BaseModel
from ..auth import get_current_user
from ..models import User, File, Folder
from ..schemas import FileOut
from ..storage import storage_manager
from ..settings import settings
from ..activity import log_activity
from ..thumbnails import generate_thumbnail, delete_thumbnail
router = APIRouter(
prefix="/files",
tags=["files"],
)
class FileMove(BaseModel):
target_folder_id: Optional[int] = None
class FileRename(BaseModel):
new_name: str
class FileCopy(BaseModel):
target_folder_id: Optional[int] = None
@router.post("/upload/{folder_id}", response_model=FileOut, status_code=status.HTTP_201_CREATED)
@router.post("/upload", response_model=FileOut, status_code=status.HTTP_201_CREATED)
async def upload_file(
file: UploadFile = FastAPIFile(...),
folder_id: Optional[int] = None,
current_user: User = Depends(get_current_user)
):
if folder_id:
parent_folder = await Folder.get_or_none(id=folder_id, owner=current_user, is_deleted=False)
if not parent_folder:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Folder not found")
else:
parent_folder = None
existing_file = await File.get_or_none(
name=file.filename, parent=parent_folder, owner=current_user, is_deleted=False
)
if existing_file:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="File with this name already exists in the current folder",
)
file_content = await file.read()
file_size = len(file_content)
file_hash = hashlib.sha256(file_content).hexdigest()
if current_user.used_storage_bytes + file_size > current_user.storage_quota_bytes:
raise HTTPException(
status_code=status.HTTP_507_INSUFFICIENT_STORAGE,
detail="Storage quota exceeded",
)
# Generate a unique path for storage
file_extension = os.path.splitext(file.filename)[1]
unique_filename = f"{file_hash}{file_extension}" # Use hash for unique filename
storage_path = unique_filename
# Save file to storage
await storage_manager.save_file(current_user.id, storage_path, file_content)
# Get mime type
mime_type, _ = mimetypes.guess_type(file.filename)
if not mime_type:
mime_type = "application/octet-stream"
# Create file entry in database
db_file = await File.create(
name=file.filename,
path=storage_path,
size=file_size,
mime_type=mime_type,
file_hash=file_hash,
owner=current_user,
parent=parent_folder,
)
current_user.used_storage_bytes += file_size
await current_user.save()
thumbnail_path = await generate_thumbnail(storage_path, mime_type, current_user.id)
if thumbnail_path:
db_file.thumbnail_path = thumbnail_path
await db_file.save()
return await FileOut.from_tortoise_orm(db_file)
@router.get("/download/{file_id}")
async def download_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")
try:
# file_content_generator = storage_manager.get_file(current_user.id, db_file.path)
# FastAPI's StreamingResponse expects an async generator
async def file_iterator():
async for chunk in storage_manager.get_file(current_user.id, db_file.path):
yield chunk
return StreamingResponse(
file_iterator(),
media_type=db_file.mime_type,
headers={"Content-Disposition": f"attachment; filename=\"{db_file.name}\""}
)
except FileNotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found in storage")
@router.delete("/{file_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_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_deleted = True
db_file.deleted_at = datetime.now()
await db_file.save()
return
@router.post("/{file_id}/move", response_model=FileOut)
async def move_file(file_id: int, move_data: FileMove, 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")
target_folder = None
if move_data.target_folder_id:
target_folder = await Folder.get_or_none(id=move_data.target_folder_id, owner=current_user, is_deleted=False)
if not target_folder:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="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:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="File with this name already exists in target folder")
db_file.parent = target_folder
await db_file.save()
await log_activity(user=current_user, action="file_moved", target_type="file", target_id=file_id)
return await FileOut.from_tortoise_orm(db_file)
@router.post("/{file_id}/rename", response_model=FileOut)
async def rename_file(file_id: int, rename_data: FileRename, 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")
existing_file = await File.get_or_none(
name=rename_data.new_name, parent_id=db_file.parent_id, owner=current_user, is_deleted=False
)
if existing_file and existing_file.id != file_id:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="File with this name already exists in the same folder")
db_file.name = rename_data.new_name
await db_file.save()
await log_activity(user=current_user, action="file_renamed", target_type="file", target_id=file_id)
return await FileOut.from_tortoise_orm(db_file)
@router.post("/{file_id}/copy", response_model=FileOut, status_code=status.HTTP_201_CREATED)
async def copy_file(file_id: int, copy_data: FileCopy, 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")
target_folder = None
if copy_data.target_folder_id:
target_folder = await Folder.get_or_none(id=copy_data.target_folder_id, owner=current_user, is_deleted=False)
if not target_folder:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="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", target_type="file", target_id=new_file.id)
return await FileOut.from_tortoise_orm(new_file)
@router.get("/", response_model=List[FileOut])
async def list_files(folder_id: Optional[int] = None, current_user: User = Depends(get_current_user)):
if folder_id:
parent_folder = await Folder.get_or_none(id=folder_id, owner=current_user, is_deleted=False)
if not parent_folder:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Folder not found")
files = await File.filter(parent=parent_folder, owner=current_user, is_deleted=False).order_by("name")
else:
files = await File.filter(parent=None, owner=current_user, is_deleted=False).order_by("name")
return [await FileOut.from_tortoise_orm(f) for f in files]
@router.get("/thumbnail/{file_id}")
async def get_thumbnail(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")
thumbnail_path = getattr(db_file, 'thumbnail_path', None)
if not thumbnail_path:
thumbnail_path = await generate_thumbnail(db_file.path, db_file.mime_type, current_user.id)
if thumbnail_path:
db_file.thumbnail_path = thumbnail_path
await db_file.save()
else:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Thumbnail not available")
try:
async def thumbnail_iterator():
async for chunk in storage_manager.get_file(current_user.id, thumbnail_path):
yield chunk
return StreamingResponse(
thumbnail_iterator(),
media_type="image/jpeg"
)
except FileNotFoundError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Thumbnail not found in storage")
@router.get("/photos", response_model=List[FileOut])
async def list_photos(current_user: User = Depends(get_current_user)):
files = await File.filter(
owner=current_user,
is_deleted=False,
mime_type__istartswith="image/"
).order_by("-created_at")
return [await FileOut.from_tortoise_orm(f) for f in files]

105
rbox/routers/folders.py Normal file
View File

@ -0,0 +1,105 @@
from fastapi import APIRouter, Depends, HTTPException, status
from typing import List, Optional
from ..auth import get_current_user
from ..models import User, Folder
from ..schemas import FolderCreate, FolderOut, FolderUpdate
router = APIRouter(
prefix="/folders",
tags=["folders"],
)
@router.post("/", response_model=FolderOut, status_code=status.HTTP_201_CREATED)
async def create_folder(folder_in: FolderCreate, current_user: User = Depends(get_current_user)):
# Check if parent folder exists and belongs to the current user
parent_folder = None
if folder_in.parent_id:
parent_folder = await Folder.get_or_none(id=folder_in.parent_id, owner=current_user)
if not parent_folder:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Parent folder not found or does not belong to the current user",
)
# Check for duplicate folder name in the same parent
existing_folder = await Folder.get_or_none(
name=folder_in.name, parent=parent_folder, owner=current_user
)
if existing_folder:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Folder with this name already exists in the current parent folder",
)
folder = await Folder.create(
name=folder_in.name, parent=parent_folder, owner=current_user
)
return await FolderOut.from_tortoise_orm(folder)
@router.get("/{folder_id}", response_model=FolderOut)
async def get_folder(folder_id: int, current_user: User = Depends(get_current_user)):
folder = await Folder.get_or_none(id=folder_id, owner=current_user, is_deleted=False)
if not folder:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Folder not found")
return await FolderOut.from_tortoise_orm(folder)
@router.get("/", response_model=List[FolderOut])
async def list_folders(parent_id: Optional[int] = None, current_user: User = Depends(get_current_user)):
if parent_id:
parent_folder = await Folder.get_or_none(id=parent_id, owner=current_user, is_deleted=False)
if not parent_folder:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Parent folder not found or does not belong to the current user",
)
folders = await Folder.filter(parent=parent_folder, owner=current_user, is_deleted=False).order_by("name")
else:
# List root folders (folders with no parent)
folders = await Folder.filter(parent=None, owner=current_user, is_deleted=False).order_by("name")
return [await FolderOut.from_tortoise_orm(folder) for folder in folders]
@router.put("/{folder_id}", response_model=FolderOut)
async def update_folder(folder_id: int, folder_in: FolderUpdate, current_user: User = Depends(get_current_user)):
folder = await Folder.get_or_none(id=folder_id, owner=current_user, is_deleted=False)
if not folder:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Folder not found")
if folder_in.name:
existing_folder = await Folder.get_or_none(
name=folder_in.name, parent_id=folder.parent_id, owner=current_user
)
if existing_folder and existing_folder.id != folder_id:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Folder with this name already exists in the current parent folder",
)
folder.name = folder_in.name
if folder_in.parent_id is not None:
if folder_in.parent_id == folder_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot set folder as its own parent")
new_parent_folder = None
if folder_in.parent_id != 0: # 0 could represent moving to root
new_parent_folder = await Folder.get_or_none(id=folder_in.parent_id, owner=current_user, is_deleted=False)
if not new_parent_folder:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="New parent folder not found or does not belong to the current user",
)
folder.parent = new_parent_folder
await folder.save()
return await FolderOut.from_tortoise_orm(folder)
@router.delete("/{folder_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_folder(folder_id: int, current_user: User = Depends(get_current_user)):
folder = await Folder.get_or_none(id=folder_id, owner=current_user, is_deleted=False)
if not folder:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Folder not found")
# Soft delete
folder.is_deleted = True
await folder.save()
return

55
rbox/routers/search.py Normal file
View File

@ -0,0 +1,55 @@
from fastapi import APIRouter, Depends, Query
from typing import List, Optional
from datetime import datetime
from ..auth import get_current_user
from ..models import User, File, Folder
from ..schemas import FileOut, FolderOut
router = APIRouter(
prefix="/search",
tags=["search"],
)
@router.get("/files", response_model=List[FileOut])
async def search_files(
q: str = Query(..., min_length=1, description="Search query"),
file_type: Optional[str] = Query(None, description="Filter by MIME type prefix (e.g., 'image', 'video')"),
min_size: Optional[int] = Query(None, description="Minimum file size in bytes"),
max_size: Optional[int] = Query(None, description="Maximum file size in bytes"),
date_from: Optional[datetime] = Query(None, description="Filter files created after this date"),
date_to: Optional[datetime] = Query(None, description="Filter files created before this date"),
current_user: User = Depends(get_current_user)
):
query = File.filter(owner=current_user, is_deleted=False, name__icontains=q)
if file_type:
query = query.filter(mime_type__istartswith=file_type)
if min_size is not None:
query = query.filter(size__gte=min_size)
if max_size is not None:
query = query.filter(size__lte=max_size)
if date_from:
query = query.filter(created_at__gte=date_from)
if date_to:
query = query.filter(created_at__lte=date_to)
files = await query.order_by("-created_at").limit(100)
return [await FileOut.from_tortoise_orm(f) for f in files]
@router.get("/folders", response_model=List[FolderOut])
async def search_folders(
q: str = Query(..., min_length=1, description="Search query"),
current_user: User = Depends(get_current_user)
):
folders = await Folder.filter(
owner=current_user,
is_deleted=False,
name__icontains=q
).order_by("-created_at").limit(100)
return [await FolderOut.from_tortoise_orm(folder) for folder in folders]

94
rbox/routers/shares.py Normal file
View File

@ -0,0 +1,94 @@
from fastapi import APIRouter, Depends, HTTPException, status
from typing import Optional
import secrets
from datetime import datetime, timedelta
from ..auth import get_current_user
from ..models import User, File, Folder, Share
from ..schemas import ShareCreate, ShareOut
from ..auth import get_password_hash, verify_password
router = APIRouter(
prefix="/shares",
tags=["shares"],
)
@router.post("/", response_model=ShareOut, status_code=status.HTTP_201_CREATED)
async def create_share_link(share_in: ShareCreate, current_user: User = Depends(get_current_user)):
if not share_in.file_id and not share_in.folder_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Either file_id or folder_id must be provided")
if share_in.file_id and share_in.folder_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot share both a file and a folder simultaneously")
file = None
folder = None
if share_in.file_id:
file = await File.get_or_none(id=share_in.file_id, owner=current_user, is_deleted=False)
if not file:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found or does not belong to you")
if share_in.folder_id:
folder = await Folder.get_or_none(id=share_in.folder_id, owner=current_user, is_deleted=False)
if not folder:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Folder not found or does not belong to you")
token = secrets.token_urlsafe(16)
hashed_password = None
password_protected = False
if share_in.password:
hashed_password = get_password_hash(share_in.password)
password_protected = True
share = await Share.create(
token=token,
file=file,
folder=folder,
owner=current_user,
expires_at=share_in.expires_at,
password_protected=password_protected,
hashed_password=hashed_password,
permission_level=share_in.permission_level,
)
return await ShareOut.from_tortoise_orm(share)
@router.get("/{share_token}", response_model=ShareOut)
async def get_share_link_info(share_token: str):
share = await Share.get_or_none(token=share_token)
if not share:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Share link not found")
if share.expires_at and share.expires_at < datetime.utcnow():
raise HTTPException(status_code=status.HTTP_410_GONE, detail="Share link has expired")
# Increment access count
share.access_count += 1
await share.save()
return await ShareOut.from_tortoise_orm(share)
@router.post("/{share_token}/access")
async def access_shared_content(share_token: str, password: Optional[str] = None):
share = await Share.get_or_none(token=share_token)
if not share:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Share link not found")
if share.expires_at and share.expires_at < datetime.utcnow():
raise HTTPException(status_code=status.HTTP_410_GONE, detail="Share link has expired")
if share.password_protected:
if not password or not verify_password(password, share.hashed_password):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect password")
# TODO: Return actual content or a link to it based on permission_level
# For now, just indicate successful access
return {"message": "Access granted", "permission_level": share.permission_level}
@router.delete("/{share_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_share_link(share_id: int, 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")
await share.delete()
return

12
rbox/routers/users.py Normal file
View File

@ -0,0 +1,12 @@
from fastapi import APIRouter, Depends
from ..auth import get_current_user
from ..models import User_Pydantic, User
router = APIRouter(
prefix="/users",
tags=["users"],
)
@router.get("/me", response_model=User_Pydantic)
async def read_users_me(current_user: User = Depends(get_current_user)):
return await User_Pydantic.from_tortoise_orm(current_user)

86
rbox/schemas.py Normal file
View File

@ -0,0 +1,86 @@
from datetime import datetime
from pydantic import BaseModel, EmailStr
from typing import Optional, List
from tortoise.contrib.pydantic import pydantic_model_creator
class UserCreate(BaseModel):
username: str
email: EmailStr
password: str
class UserLogin(BaseModel):
username: str
password: str
class Token(BaseModel):
access_token: str
token_type: str
class TokenData(BaseModel):
username: str | None = None
class FolderCreate(BaseModel):
name: str
parent_id: Optional[int] = None
class FolderUpdate(BaseModel):
name: Optional[str] = None
parent_id: Optional[int] = None
class ShareCreate(BaseModel):
file_id: Optional[int] = None
folder_id: Optional[int] = None
expires_at: Optional[datetime] = None
password: Optional[str] = None
permission_level: str = "viewer"
class TeamCreate(BaseModel):
name: str
class TeamOut(BaseModel):
id: int
name: str
owner_id: int
created_at: datetime
class Config:
orm_mode = True
class ActivityOut(BaseModel):
id: int
user_id: Optional[int] = None
action: str
target_type: str
target_id: int
ip_address: Optional[str] = None
timestamp: datetime
class Config:
orm_mode = True
class FileRequestCreate(BaseModel):
title: str
description: Optional[str] = None
target_folder_id: int
expires_at: Optional[datetime] = None
class FileRequestOut(BaseModel):
id: int
title: str
description: Optional[str] = None
token: str
owner_id: int
target_folder_id: int
created_at: datetime
expires_at: Optional[datetime] = None
is_active: bool
class Config:
orm_mode = True
from rbox.models import Folder, File, Share, FileVersion
FolderOut = pydantic_model_creator(Folder, name="FolderOut")
FileOut = pydantic_model_creator(File, name="FileOut")
ShareOut = pydantic_model_creator(Share, name="ShareOut")
FileVersionOut = pydantic_model_creator(FileVersion, name="FileVersionOut")

28
rbox/settings.py Normal file
View File

@ -0,0 +1,28 @@
import os
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file='.env', extra='ignore')
DATABASE_URL: str = "sqlite:///app/rbox.db"
#DATABASE_URL: str = "postgres://rbox_user:rbox_password@db:5432/rbox_db"
REDIS_URL: str = "redis://redis:6379/0"
SECRET_KEY: str = "super_secret_key" # CHANGE THIS IN PRODUCTION
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
DOMAIN_NAME: str = "localhost"
CERTBOT_EMAIL: str = "admin@example.com"
STORAGE_PATH: str = "storage" # Path for local file storage
S3_ACCESS_KEY_ID: str | None = None
S3_SECRET_ACCESS_KEY: str | None = None
S3_ENDPOINT_URL: str | None = None
S3_BUCKET_NAME: str = "rbox-storage"
SMTP_SERVER: str | None = None
SMTP_PORT: int = 557
SMTP_USERNAME: str | None = None
SMTP_PASSWORD: str | None = None
SMTP_FROM_EMAIL: str = "no-reply@example.com"
TOTP_ISSUER: str = "RBox"
settings = Settings()

45
rbox/storage.py Normal file
View File

@ -0,0 +1,45 @@
import os
import aiofiles
from pathlib import Path
from typing import AsyncGenerator
from .settings import settings
class StorageManager:
def __init__(self, base_path: str = settings.STORAGE_PATH):
self.base_path = Path(base_path)
self.base_path.mkdir(parents=True, exist_ok=True)
async def _get_full_path(self, user_id: int, file_path: str) -> Path:
# Ensure file_path is relative and safe
relative_path = Path(file_path).relative_to('/') if str(file_path).startswith('/') else Path(file_path)
full_path = self.base_path / str(user_id) / relative_path
full_path.parent.mkdir(parents=True, exist_ok=True)
return full_path
async def save_file(self, user_id: int, file_path: str, file_content: bytes):
full_path = await self._get_full_path(user_id, file_path)
async with aiofiles.open(full_path, "wb") as f:
await f.write(file_content)
return str(full_path)
async def get_file(self, user_id: int, file_path: str) -> AsyncGenerator:
full_path = await self._get_full_path(user_id, file_path)
if not full_path.exists():
raise FileNotFoundError(f"File not found: {file_path}")
async with aiofiles.open(full_path, "rb") as f:
while chunk := await f.read(8192):
yield chunk
async def delete_file(self, user_id: int, file_path: str):
full_path = await self._get_full_path(user_id, file_path)
if full_path.exists():
os.remove(full_path)
# TODO: Clean up empty directories
async def file_exists(self, user_id: int, file_path: str) -> bool:
full_path = await self._get_full_path(user_id, file_path)
return full_path.exists()
storage_manager = StorageManager()

94
rbox/thumbnails.py Normal file
View File

@ -0,0 +1,94 @@
import os
import asyncio
from pathlib import Path
from PIL import Image
import subprocess
from typing import Optional
from .settings import settings
THUMBNAIL_SIZE = (300, 300)
THUMBNAIL_DIR = "thumbnails"
async def generate_thumbnail(file_path: str, mime_type: str, user_id: int) -> Optional[str]:
try:
if mime_type.startswith("image/"):
return await generate_image_thumbnail(file_path, user_id)
elif mime_type.startswith("video/"):
return await generate_video_thumbnail(file_path, user_id)
return None
except Exception as e:
print(f"Error generating thumbnail for {file_path}: {e}")
return None
async def generate_image_thumbnail(file_path: str, user_id: int) -> Optional[str]:
loop = asyncio.get_event_loop()
def _generate():
base_path = Path(settings.STORAGE_PATH)
thumbnail_dir = base_path / str(user_id) / THUMBNAIL_DIR
thumbnail_dir.mkdir(parents=True, exist_ok=True)
file_name = Path(file_path).name
thumbnail_name = f"thumb_{file_name}"
if not thumbnail_name.lower().endswith(('.jpg', '.jpeg', '.png')):
thumbnail_name += ".jpg"
thumbnail_path = thumbnail_dir / thumbnail_name
actual_file_path = base_path / str(user_id) / file_path if not Path(file_path).is_absolute() else Path(file_path)
with Image.open(actual_file_path) as img:
img.thumbnail(THUMBNAIL_SIZE, Image.Resampling.LANCZOS)
if img.mode in ("RGBA", "LA", "P"):
background = Image.new("RGB", img.size, (255, 255, 255))
if img.mode == "P":
img = img.convert("RGBA")
background.paste(img, mask=img.split()[-1] if img.mode in ("RGBA", "LA") else None)
img = background
img.save(str(thumbnail_path), "JPEG", quality=85, optimize=True)
return str(thumbnail_path.relative_to(base_path / str(user_id)))
return await loop.run_in_executor(None, _generate)
async def generate_video_thumbnail(file_path: str, user_id: int) -> Optional[str]:
loop = asyncio.get_event_loop()
def _generate():
base_path = Path(settings.STORAGE_PATH)
thumbnail_dir = base_path / str(user_id) / THUMBNAIL_DIR
thumbnail_dir.mkdir(parents=True, exist_ok=True)
file_name = Path(file_path).stem
thumbnail_name = f"thumb_{file_name}.jpg"
thumbnail_path = thumbnail_dir / thumbnail_name
actual_file_path = base_path / str(user_id) / file_path if not Path(file_path).is_absolute() else Path(file_path)
subprocess.run([
"ffmpeg",
"-i", str(actual_file_path),
"-ss", "00:00:01",
"-vframes", "1",
"-vf", f"scale={THUMBNAIL_SIZE[0]}:{THUMBNAIL_SIZE[1]}:force_original_aspect_ratio=decrease",
"-y",
str(thumbnail_path)
], check=True, capture_output=True)
return str(thumbnail_path.relative_to(base_path / str(user_id)))
try:
return await loop.run_in_executor(None, _generate)
except subprocess.CalledProcessError:
return None
async def delete_thumbnail(thumbnail_path: str, user_id: int):
try:
base_path = Path(settings.STORAGE_PATH)
full_path = base_path / str(user_id) / thumbnail_path
if full_path.exists():
full_path.unlink()
except Exception as e:
print(f"Error deleting thumbnail {thumbnail_path}: {e}")

654
rbox/webdav.py Normal file
View File

@ -0,0 +1,654 @@
from fastapi import APIRouter, Request, Response, Depends, HTTPException, status, Header
from typing import Optional
from xml.etree import ElementTree as ET
from datetime import datetime
import hashlib
import mimetypes
import os
import base64
from urllib.parse import unquote, urlparse
from .auth import get_current_user, verify_password
from .models import User, File, Folder
from .storage import storage_manager
from .activity import log_activity
router = APIRouter(
prefix="/webdav",
tags=["webdav"],
)
class WebDAVLock:
locks = {}
@classmethod
def create_lock(cls, path: str, user_id: int, timeout: int = 3600):
lock_token = f"opaquelocktoken:{hashlib.md5(f'{path}{user_id}{datetime.now()}'.encode()).hexdigest()}"
cls.locks[path] = {
'token': lock_token,
'user_id': user_id,
'created_at': datetime.now(),
'timeout': timeout
}
return lock_token
@classmethod
def get_lock(cls, path: str):
return cls.locks.get(path)
@classmethod
def remove_lock(cls, path: str):
if path in cls.locks:
del cls.locks[path]
async def basic_auth(authorization: Optional[str] = Header(None)):
if not authorization:
return None
try:
scheme, credentials = authorization.split()
if scheme.lower() != 'basic':
return None
decoded = base64.b64decode(credentials).decode('utf-8')
username, password = decoded.split(':', 1)
user = await User.get_or_none(username=username)
if user and verify_password(password, user.hashed_password):
return user
except:
pass
return None
async def webdav_auth(request: Request, authorization: Optional[str] = Header(None)):
user = await basic_auth(authorization)
if user:
return user
try:
user = await get_current_user(request)
return user
except:
pass
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
headers={'WWW-Authenticate': 'Basic realm="RBox WebDAV"'}
)
async def resolve_path(path_str: str, user: User):
if not path_str or path_str == '/':
return None, None, True
parts = [p for p in path_str.split('/') if p]
if not parts:
return None, None, True
current_folder = None
for i, part in enumerate(parts[:-1]):
folder = await Folder.get_or_none(
name=part,
parent=current_folder,
owner=user,
is_deleted=False
)
if not folder:
return None, None, False
current_folder = folder
last_part = parts[-1]
folder = await Folder.get_or_none(
name=last_part,
parent=current_folder,
owner=user,
is_deleted=False
)
if folder:
return folder, current_folder, True
file = await File.get_or_none(
name=last_part,
parent=current_folder,
owner=user,
is_deleted=False
)
if file:
return file, current_folder, True
return None, current_folder, True
def build_href(base_path: str, name: str, is_collection: bool):
path = f"{base_path.rstrip('/')}/{name}"
if is_collection:
path += '/'
return path
def create_propstat_element(props: dict, status: str = "HTTP/1.1 200 OK"):
propstat = ET.Element("D:propstat")
prop = ET.SubElement(propstat, "D:prop")
for key, value in props.items():
if key == "resourcetype":
resourcetype = ET.SubElement(prop, "D:resourcetype")
if value == "collection":
ET.SubElement(resourcetype, "D:collection")
elif key == "getcontentlength":
elem = ET.SubElement(prop, f"D:{key}")
elem.text = str(value)
elif key == "getcontenttype":
elem = ET.SubElement(prop, f"D:{key}")
elem.text = value
elif key == "getlastmodified":
elem = ET.SubElement(prop, f"D:{key}")
elem.text = value.strftime('%a, %d %b %Y %H:%M:%S GMT')
elif key == "creationdate":
elem = ET.SubElement(prop, f"D:{key}")
elem.text = value.isoformat() + 'Z'
elif key == "displayname":
elem = ET.SubElement(prop, f"D:{key}")
elem.text = value
elif key == "getetag":
elem = ET.SubElement(prop, f"D:{key}")
elem.text = value
status_elem = ET.SubElement(propstat, "D:status")
status_elem.text = status
return propstat
@router.api_route("/{full_path:path}", methods=["OPTIONS"])
async def webdav_options(full_path: str):
return Response(
status_code=200,
headers={
"DAV": "1, 2",
"Allow": "OPTIONS, GET, HEAD, POST, PUT, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK",
"MS-Author-Via": "DAV"
}
)
@router.api_route("/{full_path:path}", methods=["PROPFIND"])
async def handle_propfind(request: Request, full_path: str, current_user: User = Depends(webdav_auth)):
depth = request.headers.get("Depth", "1")
full_path = unquote(full_path).strip('/')
resource, parent, exists = await resolve_path(full_path, current_user)
if not exists and resource is None:
raise HTTPException(status_code=404, detail="Not found")
multistatus = ET.Element("D:multistatus", {"xmlns:D": "DAV:"})
base_href = f"/webdav/{full_path}" if full_path else "/webdav/"
if resource is None:
response = ET.SubElement(multistatus, "D:response")
href = ET.SubElement(response, "D:href")
href.text = base_href if base_href.endswith('/') else base_href + '/'
props = {
"resourcetype": "collection",
"displayname": full_path.split('/')[-1] if full_path else "Root",
"creationdate": datetime.now(),
"getlastmodified": datetime.now()
}
response.append(create_propstat_element(props))
if depth in ["1", "infinity"]:
folders = await Folder.filter(owner=current_user, parent=parent, is_deleted=False)
files = await File.filter(owner=current_user, parent=parent, is_deleted=False)
for folder in folders:
response = ET.SubElement(multistatus, "D:response")
href = ET.SubElement(response, "D:href")
href.text = build_href(base_href, folder.name, True)
props = {
"resourcetype": "collection",
"displayname": folder.name,
"creationdate": folder.created_at,
"getlastmodified": folder.updated_at
}
response.append(create_propstat_element(props))
for file in files:
response = ET.SubElement(multistatus, "D:response")
href = ET.SubElement(response, "D:href")
href.text = build_href(base_href, file.name, False)
props = {
"resourcetype": "",
"displayname": file.name,
"getcontentlength": file.size,
"getcontenttype": file.mime_type,
"creationdate": file.created_at,
"getlastmodified": file.updated_at,
"getetag": f'"{file.file_hash}"'
}
response.append(create_propstat_element(props))
elif isinstance(resource, Folder):
response = ET.SubElement(multistatus, "D:response")
href = ET.SubElement(response, "D:href")
href.text = base_href if base_href.endswith('/') else base_href + '/'
props = {
"resourcetype": "collection",
"displayname": resource.name,
"creationdate": resource.created_at,
"getlastmodified": resource.updated_at
}
response.append(create_propstat_element(props))
if depth in ["1", "infinity"]:
folders = await Folder.filter(owner=current_user, parent=resource, is_deleted=False)
files = await File.filter(owner=current_user, parent=resource, is_deleted=False)
for folder in folders:
response = ET.SubElement(multistatus, "D:response")
href = ET.SubElement(response, "D:href")
href.text = build_href(base_href, folder.name, True)
props = {
"resourcetype": "collection",
"displayname": folder.name,
"creationdate": folder.created_at,
"getlastmodified": folder.updated_at
}
response.append(create_propstat_element(props))
for file in files:
response = ET.SubElement(multistatus, "D:response")
href = ET.SubElement(response, "D:href")
href.text = build_href(base_href, file.name, False)
props = {
"resourcetype": "",
"displayname": file.name,
"getcontentlength": file.size,
"getcontenttype": file.mime_type,
"creationdate": file.created_at,
"getlastmodified": file.updated_at,
"getetag": f'"{file.file_hash}"'
}
response.append(create_propstat_element(props))
elif isinstance(resource, File):
response = ET.SubElement(multistatus, "D:response")
href = ET.SubElement(response, "D:href")
href.text = base_href
props = {
"resourcetype": "",
"displayname": resource.name,
"getcontentlength": resource.size,
"getcontenttype": resource.mime_type,
"creationdate": resource.created_at,
"getlastmodified": resource.updated_at,
"getetag": f'"{resource.file_hash}"'
}
response.append(create_propstat_element(props))
xml_content = ET.tostring(multistatus, encoding="utf-8", xml_declaration=True)
return Response(content=xml_content, media_type="application/xml; charset=utf-8", status_code=207)
@router.api_route("/{full_path:path}", methods=["GET", "HEAD"])
async def handle_get(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 isinstance(resource, File):
raise HTTPException(status_code=404, detail="File not found")
try:
if request.method == "HEAD":
return Response(
status_code=200,
headers={
"Content-Length": str(resource.size),
"Content-Type": resource.mime_type,
"ETag": f'"{resource.file_hash}"',
"Last-Modified": resource.updated_at.strftime('%a, %d %b %Y %H:%M:%S GMT')
}
)
async def file_iterator():
async for chunk in storage_manager.get_file(current_user.id, resource.path):
yield chunk
return Response(
content=file_iterator(),
media_type=resource.mime_type,
headers={
"Content-Disposition": f'attachment; filename="{resource.name}"',
"ETag": f'"{resource.file_hash}"',
"Last-Modified": resource.updated_at.strftime('%a, %d %b %Y %H:%M:%S GMT')
}
)
except FileNotFoundError:
raise HTTPException(status_code=404, detail="File not found in storage")
@router.api_route("/{full_path:path}", methods=["PUT"])
async def handle_put(request: Request, full_path: str, current_user: User = Depends(webdav_auth)):
full_path = unquote(full_path).strip('/')
if not full_path:
raise HTTPException(status_code=400, detail="Cannot PUT to root")
parts = [p for p in full_path.split('/') if p]
file_name = parts[-1]
parent_path = '/'.join(parts[:-1]) if len(parts) > 1 else ''
_, parent_folder, exists = await resolve_path(parent_path, current_user)
if not exists:
raise HTTPException(status_code=409, detail="Parent folder does not exist")
file_content = await request.body()
file_size = len(file_content)
if current_user.used_storage_bytes + file_size > current_user.storage_quota_bytes:
raise HTTPException(status_code=507, detail="Storage quota exceeded")
file_hash = hashlib.sha256(file_content).hexdigest()
file_extension = os.path.splitext(file_name)[1]
unique_filename = f"{file_hash}{file_extension}"
storage_path = os.path.join(str(current_user.id), unique_filename)
await storage_manager.save_file(current_user.id, storage_path, file_content)
mime_type, _ = mimetypes.guess_type(file_name)
if not mime_type:
mime_type = "application/octet-stream"
existing_file = await File.get_or_none(
name=file_name,
parent=parent_folder,
owner=current_user,
is_deleted=False
)
if existing_file:
old_size = existing_file.size
existing_file.path = storage_path
existing_file.size = file_size
existing_file.mime_type = mime_type
existing_file.file_hash = file_hash
existing_file.updated_at = datetime.now()
await existing_file.save()
current_user.used_storage_bytes = current_user.used_storage_bytes - old_size + file_size
await current_user.save()
await log_activity(current_user, "file_updated", "file", existing_file.id)
return Response(status_code=204)
else:
db_file = await File.create(
name=file_name,
path=storage_path,
size=file_size,
mime_type=mime_type,
file_hash=file_hash,
owner=current_user,
parent=parent_folder
)
current_user.used_storage_bytes += file_size
await current_user.save()
await log_activity(current_user, "file_created", "file", db_file.id)
return Response(status_code=201)
@router.api_route("/{full_path:path}", methods=["DELETE"])
async def handle_delete(request: Request, full_path: str, current_user: User = Depends(webdav_auth)):
full_path = unquote(full_path).strip('/')
if not full_path:
raise HTTPException(status_code=400, detail="Cannot DELETE root")
resource, parent, exists = await resolve_path(full_path, current_user)
if not resource:
raise HTTPException(status_code=404, detail="Resource not found")
if isinstance(resource, File):
resource.is_deleted = True
resource.deleted_at = datetime.now()
await resource.save()
await log_activity(current_user, "file_deleted", "file", resource.id)
elif isinstance(resource, Folder):
resource.is_deleted = True
await resource.save()
await log_activity(current_user, "folder_deleted", "folder", resource.id)
return Response(status_code=204)
@router.api_route("/{full_path:path}", methods=["MKCOL"])
async def handle_mkcol(request: Request, full_path: str, current_user: User = Depends(webdav_auth)):
full_path = unquote(full_path).strip('/')
if not full_path:
raise HTTPException(status_code=400, detail="Cannot MKCOL at root")
parts = [p for p in full_path.split('/') if p]
folder_name = parts[-1]
parent_path = '/'.join(parts[:-1]) if len(parts) > 1 else ''
_, parent_folder, exists = await resolve_path(parent_path, current_user)
if not exists:
raise HTTPException(status_code=409, detail="Parent folder does not exist")
existing = await Folder.get_or_none(
name=folder_name,
parent=parent_folder,
owner=current_user,
is_deleted=False
)
if existing:
raise HTTPException(status_code=405, detail="Folder already exists")
folder = await Folder.create(
name=folder_name,
parent=parent_folder,
owner=current_user
)
await log_activity(current_user, "folder_created", "folder", folder.id)
return Response(status_code=201)
@router.api_route("/{full_path:path}", methods=["COPY"])
async def handle_copy(request: Request, full_path: str, current_user: User = Depends(webdav_auth)):
full_path = unquote(full_path).strip('/')
destination = request.headers.get("Destination")
overwrite = request.headers.get("Overwrite", "T")
if not destination:
raise HTTPException(status_code=400, detail="Destination header required")
dest_path = unquote(urlparse(destination).path)
dest_path = dest_path.replace('/webdav/', '').strip('/')
source_resource, _, exists = await resolve_path(full_path, current_user)
if not source_resource:
raise HTTPException(status_code=404, detail="Source not found")
if not isinstance(source_resource, File):
raise HTTPException(status_code=501, detail="Only file copy is implemented")
dest_parts = [p for p in dest_path.split('/') if p]
dest_name = dest_parts[-1]
dest_parent_path = '/'.join(dest_parts[:-1]) if len(dest_parts) > 1 else ''
_, dest_parent, exists = await resolve_path(dest_parent_path, current_user)
if not exists:
raise HTTPException(status_code=409, detail="Destination parent does not exist")
existing_dest = await File.get_or_none(
name=dest_name,
parent=dest_parent,
owner=current_user,
is_deleted=False
)
if existing_dest and overwrite == "F":
raise HTTPException(status_code=412, detail="Destination exists and overwrite is false")
if existing_dest:
await existing_dest.delete()
new_file = await File.create(
name=dest_name,
path=source_resource.path,
size=source_resource.size,
mime_type=source_resource.mime_type,
file_hash=source_resource.file_hash,
owner=current_user,
parent=dest_parent
)
await log_activity(current_user, "file_copied", "file", new_file.id)
return Response(status_code=201 if not existing_dest else 204)
@router.api_route("/{full_path:path}", methods=["MOVE"])
async def handle_move(request: Request, full_path: str, current_user: User = Depends(webdav_auth)):
full_path = unquote(full_path).strip('/')
destination = request.headers.get("Destination")
overwrite = request.headers.get("Overwrite", "T")
if not destination:
raise HTTPException(status_code=400, detail="Destination header required")
dest_path = unquote(urlparse(destination).path)
dest_path = dest_path.replace('/webdav/', '').strip('/')
source_resource, _, exists = await resolve_path(full_path, current_user)
if not source_resource:
raise HTTPException(status_code=404, detail="Source not found")
dest_parts = [p for p in dest_path.split('/') if p]
dest_name = dest_parts[-1]
dest_parent_path = '/'.join(dest_parts[:-1]) if len(dest_parts) > 1 else ''
_, dest_parent, exists = await resolve_path(dest_parent_path, current_user)
if not exists:
raise HTTPException(status_code=409, detail="Destination parent does not exist")
if isinstance(source_resource, File):
existing_dest = await File.get_or_none(
name=dest_name,
parent=dest_parent,
owner=current_user,
is_deleted=False
)
if existing_dest and overwrite == "F":
raise HTTPException(status_code=412, detail="Destination exists and overwrite is false")
if existing_dest:
await existing_dest.delete()
source_resource.name = dest_name
source_resource.parent = dest_parent
await source_resource.save()
await log_activity(current_user, "file_moved", "file", source_resource.id)
return Response(status_code=201 if not existing_dest else 204)
elif isinstance(source_resource, Folder):
existing_dest = await Folder.get_or_none(
name=dest_name,
parent=dest_parent,
owner=current_user,
is_deleted=False
)
if existing_dest and overwrite == "F":
raise HTTPException(status_code=412, detail="Destination exists and overwrite is false")
if existing_dest:
existing_dest.is_deleted = True
await existing_dest.save()
source_resource.name = dest_name
source_resource.parent = dest_parent
await source_resource.save()
await log_activity(current_user, "folder_moved", "folder", source_resource.id)
return Response(status_code=201 if not existing_dest else 204)
@router.api_route("/{full_path:path}", methods=["LOCK"])
async def handle_lock(request: Request, full_path: str, current_user: User = Depends(webdav_auth)):
full_path = unquote(full_path).strip('/')
timeout_header = request.headers.get("Timeout", "Second-3600")
timeout = 3600
if timeout_header.startswith("Second-"):
try:
timeout = int(timeout_header.split("-")[1])
except:
pass
lock_token = WebDAVLock.create_lock(full_path, current_user.id, timeout)
lockinfo = ET.Element("D:prop", {"xmlns:D": "DAV:"})
lockdiscovery = ET.SubElement(lockinfo, "D:lockdiscovery")
activelock = ET.SubElement(lockdiscovery, "D:activelock")
locktype = ET.SubElement(activelock, "D:locktype")
ET.SubElement(locktype, "D:write")
lockscope = ET.SubElement(activelock, "D:lockscope")
ET.SubElement(lockscope, "D:exclusive")
depth_elem = ET.SubElement(activelock, "D:depth")
depth_elem.text = "0"
owner = ET.SubElement(activelock, "D:owner")
owner_href = ET.SubElement(owner, "D:href")
owner_href.text = current_user.username
timeout_elem = ET.SubElement(activelock, "D:timeout")
timeout_elem.text = f"Second-{timeout}"
locktoken_elem = ET.SubElement(activelock, "D:locktoken")
href = ET.SubElement(locktoken_elem, "D:href")
href.text = lock_token
xml_content = ET.tostring(lockinfo, encoding="utf-8", xml_declaration=True)
return Response(
content=xml_content,
media_type="application/xml; charset=utf-8",
status_code=200,
headers={"Lock-Token": f"<{lock_token}>"}
)
@router.api_route("/{full_path:path}", methods=["UNLOCK"])
async def handle_unlock(request: Request, full_path: str, current_user: User = Depends(webdav_auth)):
full_path = unquote(full_path).strip('/')
lock_token_header = request.headers.get("Lock-Token")
if not lock_token_header:
raise HTTPException(status_code=400, detail="Lock-Token header required")
lock_token = lock_token_header.strip('<>')
existing_lock = WebDAVLock.get_lock(full_path)
if not existing_lock or existing_lock['token'] != lock_token:
raise HTTPException(status_code=409, detail="Invalid lock token")
if existing_lock['user_id'] != current_user.id:
raise HTTPException(status_code=403, detail="Not lock owner")
WebDAVLock.remove_lock(full_path)
return Response(status_code=204)

18
run_dev.sh Executable file
View File

@ -0,0 +1,18 @@
#!/bin/bash
echo "Starting RBox development server..."
if [ ! -f .env ]; then
echo "Creating .env file from .env.example..."
cp .env.example .env
echo "Please edit .env with your configuration"
fi
echo "Starting database services..."
docker-compose up -d db redis
echo "Waiting for database to be ready..."
sleep 5
echo "Starting application..."
poetry run uvicorn rbox.main:app --reload --host 0.0.0.0 --port 8000

788
static/css/style.css Normal file
View File

@ -0,0 +1,788 @@
:root {
--primary-color: #003399;
--secondary-color: #CC0000;
--accent-color: #FFFFFF;
--background-color: #F0F2F5;
--text-color: #333333;
--text-color-light: #666666;
--border-color: #DDDDDD;
--shadow-color: rgba(0, 0, 0, 0.1);
--font-family: 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--sidebar-width: 250px;
--header-height: 60px;
--spacing-unit: 8px;
}
* {
box-sizing: border-box;
user-select: none;
}
input, textarea {
user-select: text;
}
body {
margin: 0;
font-family: var(--font-family);
color: var(--text-color);
background-color: var(--background-color);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.app-container {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
.app-header {
height: var(--header-height);
background-color: var(--accent-color);
border-bottom: 2px solid var(--primary-color);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 calc(var(--spacing-unit) * 2);
box-shadow: 0 2px 4px var(--shadow-color);
z-index: 100;
}
.header-left {
display: flex;
align-items: center;
}
.app-title {
margin: 0;
color: var(--primary-color);
font-size: 1.5rem;
font-weight: bold;
}
.header-center {
flex: 1;
max-width: 500px;
margin: 0 calc(var(--spacing-unit) * 2);
}
.search-input {
width: 100%;
padding: var(--spacing-unit);
border: 1px solid var(--border-color);
border-radius: 4px;
font-size: 1rem;
}
.header-right {
display: flex;
align-items: center;
gap: calc(var(--spacing-unit) * 2);
}
.user-info {
color: var(--text-color);
font-weight: 500;
}
.app-body {
display: flex;
flex: 1;
overflow: hidden;
}
.app-sidebar {
width: var(--sidebar-width);
background-color: var(--accent-color);
border-right: 1px solid var(--border-color);
padding: calc(var(--spacing-unit) * 2);
overflow-y: auto;
}
.sidebar-nav {
display: flex;
flex-direction: column;
}
.nav-title {
margin: calc(var(--spacing-unit) * 2) 0 var(--spacing-unit) 0;
font-size: 0.875rem;
font-weight: 600;
color: var(--text-color-light);
text-transform: uppercase;
}
.nav-list {
list-style: none;
padding: 0;
margin: 0;
}
.nav-list li {
margin: 0;
}
.nav-link {
display: block;
padding: var(--spacing-unit) calc(var(--spacing-unit) * 2);
color: var(--text-color);
text-decoration: none;
border-radius: 4px;
transition: background-color 0.2s ease;
}
.nav-link:hover {
background-color: var(--background-color);
}
.nav-link.active {
background-color: var(--primary-color);
color: var(--accent-color);
}
.app-main {
flex: 1;
overflow-y: auto;
padding: calc(var(--spacing-unit) * 3);
}
.button {
background-color: var(--primary-color);
color: var(--accent-color);
border: none;
padding: var(--spacing-unit) calc(var(--spacing-unit) * 2);
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
transition: background-color 0.2s ease;
}
.button:hover {
background-color: var(--secondary-color);
}
.button-primary {
background-color: var(--secondary-color);
}
.button-primary:hover {
background-color: #AA0000;
}
.input-field {
border: 1px solid var(--border-color);
padding: var(--spacing-unit);
border-radius: 4px;
font-size: 1rem;
width: 100%;
}
.auth-container {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
}
.auth-box {
background-color: var(--accent-color);
padding: calc(var(--spacing-unit) * 4);
border-radius: 8px;
box-shadow: 0 4px 8px var(--shadow-color);
width: 100%;
max-width: 400px;
}
.auth-box h1 {
margin: 0 0 var(--spacing-unit) 0;
color: var(--primary-color);
text-align: center;
}
.auth-subtitle {
text-align: center;
color: var(--text-color-light);
margin-bottom: calc(var(--spacing-unit) * 3);
}
.auth-tabs {
display: flex;
gap: var(--spacing-unit);
margin-bottom: calc(var(--spacing-unit) * 2);
}
.auth-tab {
flex: 1;
padding: var(--spacing-unit);
background-color: transparent;
border: 1px solid var(--border-color);
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
}
.auth-tab.active {
background-color: var(--primary-color);
color: var(--accent-color);
border-color: var(--primary-color);
}
.auth-form {
display: flex;
flex-direction: column;
gap: calc(var(--spacing-unit) * 2);
}
.error-message {
color: var(--secondary-color);
font-size: 0.875rem;
min-height: 1em;
}
.file-list-container {
background-color: var(--accent-color);
border-radius: 8px;
padding: calc(var(--spacing-unit) * 2);
}
.file-list-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: calc(var(--spacing-unit) * 2);
padding-bottom: calc(var(--spacing-unit) * 2);
border-bottom: 1px solid var(--border-color);
}
.file-list-header h2 {
margin: 0;
color: var(--primary-color);
}
.file-actions {
display: flex;
gap: var(--spacing-unit);
}
.file-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: calc(var(--spacing-unit) * 2);
}
.file-item {
display: flex;
flex-direction: column;
align-items: center;
padding: calc(var(--spacing-unit) * 2);
border: 1px solid var(--border-color);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
}
.file-item:hover {
box-shadow: 0 2px 8px var(--shadow-color);
border-color: var(--primary-color);
}
.file-icon {
font-size: 3rem;
margin-bottom: var(--spacing-unit);
}
.file-name {
font-weight: 500;
text-align: center;
word-break: break-word;
margin-bottom: var(--spacing-unit);
}
.file-size {
font-size: 0.875rem;
color: var(--text-color-light);
}
.file-actions-menu {
display: flex;
gap: calc(var(--spacing-unit) / 2);
margin-top: var(--spacing-unit);
flex-wrap: wrap;
}
.action-btn {
padding: calc(var(--spacing-unit) / 2) var(--spacing-unit);
font-size: 0.75rem;
background-color: transparent;
border: 1px solid var(--border-color);
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
}
.action-btn:hover {
background-color: var(--primary-color);
color: var(--accent-color);
border-color: var(--primary-color);
}
.upload-modal, .share-modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
align-items: center;
justify-content: center;
z-index: 1000;
}
.upload-modal-content, .share-modal-content {
background-color: var(--accent-color);
border-radius: 8px;
padding: calc(var(--spacing-unit) * 3);
max-width: 600px;
width: 90%;
max-height: 85vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.upload-header, .share-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: calc(var(--spacing-unit) * 2);
padding-bottom: calc(var(--spacing-unit) * 2);
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
}
.upload-header h3, .share-header h3 {
margin: 0;
color: var(--primary-color);
}
.close-btn {
background: none;
border: none;
font-size: 2rem;
cursor: pointer;
color: var(--text-color-light);
padding: 0;
width: 32px;
height: 32px;
}
.close-btn:hover {
color: var(--secondary-color);
}
.drop-zone {
border: 2px dashed var(--border-color);
border-radius: 8px;
padding: calc(var(--spacing-unit) * 4);
text-align: center;
transition: all 0.2s ease;
flex-shrink: 0;
}
.drop-zone.drag-over {
border-color: var(--primary-color);
background-color: var(--background-color);
}
.drop-zone-text p {
margin: var(--spacing-unit) 0;
color: var(--text-color-light);
}
.upload-list {
margin-top: calc(var(--spacing-unit) * 2);
overflow-y: auto;
flex: 1;
min-height: 0;
}
.upload-item {
padding: var(--spacing-unit);
border: 1px solid var(--border-color);
border-radius: 4px;
margin-bottom: var(--spacing-unit);
}
.upload-item-name {
font-weight: 500;
margin-bottom: var(--spacing-unit);
}
.upload-item-progress {
display: flex;
flex-direction: column;
gap: calc(var(--spacing-unit) / 2);
}
.progress-bar {
height: 8px;
background-color: var(--background-color);
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background-color: var(--primary-color);
transition: width 0.3s ease;
}
.upload-status {
font-size: 0.875rem;
color: var(--text-color-light);
}
.upload-status.success {
color: #00AA00;
}
.upload-status.error {
color: var(--secondary-color);
}
.form-group {
margin-bottom: calc(var(--spacing-unit) * 2);
}
.form-group label {
display: block;
margin-bottom: var(--spacing-unit);
font-weight: 500;
}
.share-link-container {
display: flex;
gap: var(--spacing-unit);
}
.share-link-container input {
flex: 1;
}
.placeholder {
text-align: center;
padding: calc(var(--spacing-unit) * 4);
color: var(--text-color-light);
font-size: 1.25rem;
}
@media (max-width: 768px) {
.app-header {
flex-direction: column;
height: auto;
padding: var(--spacing-unit);
}
.header-center {
width: 100%;
max-width: none;
margin: var(--spacing-unit) 0;
}
.header-right {
width: 100%;
justify-content: space-between;
}
.app-body {
flex-direction: column;
}
.app-sidebar {
width: 100%;
border-right: none;
border-bottom: 1px solid var(--border-color);
max-height: 200px;
}
.file-grid {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
}
}
@media (max-width: 480px) {
.file-grid {
grid-template-columns: 1fr;
}
.file-actions {
flex-direction: column;
}
.file-actions button {
width: 100%;
}
}
body.dark-mode {
--background-color: #222222;
--text-color: #EEEEEE;
--text-color-light: #BBBBBB;
--border-color: #444444;
--shadow-color: rgba(255, 255, 255, 0.1);
--accent-color: #333333;
}
.shortcuts-help-content {
background: var(--accent-color);
padding: calc(var(--spacing-unit) * 3);
border-radius: 8px;
max-width: 500px;
width: 90%;
max-height: 85vh;
display: flex;
flex-direction: column;
box-shadow: 0 4px 20px var(--shadow-color);
}
.shortcuts-help-content h2 {
margin: 0 0 calc(var(--spacing-unit) * 2) 0;
color: var(--primary-color);
flex-shrink: 0;
}
.shortcuts-list {
margin-bottom: calc(var(--spacing-unit) * 3);
overflow-y: auto;
flex: 1;
padding-right: calc(var(--spacing-unit) * 1);
}
.shortcuts-help-content .button {
flex-shrink: 0;
margin-top: calc(var(--spacing-unit) * 2);
}
.shortcuts-list h3 {
margin: calc(var(--spacing-unit) * 2) 0 calc(var(--spacing-unit) * 1) 0;
font-size: 0.875rem;
font-weight: 600;
color: var(--text-color);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.shortcuts-list h3:first-child {
margin-top: 0;
}
.shortcut-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: calc(var(--spacing-unit) * 1.5);
border-bottom: 1px solid var(--border-color);
}
.shortcut-item:last-child {
border-bottom: none;
}
.shortcut-item kbd {
background: var(--background-color);
border: 1px solid var(--border-color);
border-radius: 4px;
padding: calc(var(--spacing-unit) * 0.5) calc(var(--spacing-unit) * 1);
font-family: monospace;
font-size: 0.875rem;
box-shadow: 0 2px 4px var(--shadow-color);
}
.shortcut-item span {
color: var(--text-color-light);
}
.photo-gallery {
padding: calc(var(--spacing-unit) * 2);
}
.gallery-header {
margin-bottom: calc(var(--spacing-unit) * 3);
}
.gallery-header h2 {
margin: 0;
color: var(--primary-color);
}
.gallery-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: calc(var(--spacing-unit) * 2);
}
.photo-item {
border-radius: 8px;
overflow: hidden;
background: var(--accent-color);
box-shadow: 0 2px 4px var(--shadow-color);
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.photo-item:hover {
transform: translateY(-4px);
box-shadow: 0 4px 8px var(--shadow-color);
}
.photo-item img {
width: 100%;
height: 200px;
object-fit: cover;
display: block;
}
.photo-info {
padding: calc(var(--spacing-unit) * 1.5);
}
.photo-name {
display: block;
font-weight: 600;
margin-bottom: calc(var(--spacing-unit) * 0.5);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.photo-date {
display: block;
font-size: 0.875rem;
color: var(--text-color-light);
}
.empty-state {
text-align: center;
padding: calc(var(--spacing-unit) * 4);
color: var(--text-color-light);
}
.preview-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.9);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: calc(var(--spacing-unit) * 2);
}
.preview-container {
background: var(--accent-color);
border-radius: 8px;
max-width: 90vw;
max-height: 90vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.preview-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: calc(var(--spacing-unit) * 2);
border-bottom: 1px solid var(--border-color);
}
.preview-info h3 {
margin: 0 0 calc(var(--spacing-unit) * 0.5) 0;
font-size: 1.25rem;
}
.preview-info p {
margin: 0;
color: var(--text-color-light);
font-size: 0.875rem;
}
.preview-actions {
display: flex;
gap: calc(var(--spacing-unit) * 1);
}
.btn-icon {
background: none;
border: 1px solid var(--border-color);
padding: calc(var(--spacing-unit) * 1);
border-radius: 4px;
cursor: pointer;
font-size: 1.2rem;
transition: background 0.2s;
}
.btn-icon:hover {
background: var(--background-color);
}
.preview-content {
flex: 1;
overflow: auto;
display: flex;
align-items: center;
justify-content: center;
padding: calc(var(--spacing-unit) * 2);
background: #000;
}
.preview-content img,
.preview-content video,
.preview-content audio,
.preview-content iframe {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.preview-content iframe {
width: 100%;
height: 70vh;
border: none;
}
.preview-content pre {
background: var(--background-color);
padding: calc(var(--spacing-unit) * 2);
border-radius: 4px;
overflow: auto;
max-width: 100%;
color: var(--text-color);
white-space: pre-wrap;
word-wrap: break-word;
}
.no-preview {
text-align: center;
color: #fff;
}
.no-preview p {
margin-bottom: calc(var(--spacing-unit) * 2);
}
.search-results {
padding: calc(var(--spacing-unit) * 2);
}
.search-results h2 {
margin: 0 0 calc(var(--spacing-unit) * 2) 0;
color: var(--primary-color);
}

View File

View File

14
static/index.html Normal file
View File

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RBox Cloud Storage</title>
<link rel="stylesheet" href="/static/css/style.css">
<link rel="manifest" href="/static/manifest.json">
</head>
<body>
<rbox-app></rbox-app>
<script type="module" src="/static/js/main.js"></script>
</body>
</html>

208
static/js/api.js Normal file
View File

@ -0,0 +1,208 @@
class APIClient {
constructor(baseURL = '/') {
this.baseURL = baseURL;
this.token = localStorage.getItem('token');
}
setToken(token) {
this.token = token;
if (token) {
localStorage.setItem('token', token);
} else {
localStorage.removeItem('token');
}
}
getToken() {
return this.token;
}
async request(endpoint, options = {}) {
const url = `${this.baseURL}${endpoint}`;
const headers = {
...options.headers,
};
if (this.token && !options.skipAuth) {
headers['Authorization'] = `Bearer ${this.token}`;
}
if (!(options.body instanceof FormData) && options.body) {
headers['Content-Type'] = 'application/json';
}
const config = {
...options,
headers,
};
if (config.body && !(config.body instanceof FormData)) {
config.body = JSON.stringify(config.body);
}
const response = await fetch(url, config);
if (response.status === 401) {
this.setToken(null);
window.location.href = '/';
}
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Unknown error' }));
throw new Error(error.detail || 'Request failed');
}
if (response.status === 204) {
return null;
}
return response.json();
}
async register(username, email, password) {
const data = await this.request('auth/register', {
method: 'POST',
body: { username, email, password },
skipAuth: true
});
this.setToken(data.access_token);
return data;
}
async login(username, password) {
const formData = new FormData();
formData.append('username', username);
formData.append('password', password);
const data = await this.request('auth/token', {
method: 'POST',
body: formData,
skipAuth: true
});
this.setToken(data.access_token);
return data;
}
logout() {
this.setToken(null);
window.location.href = '/';
}
async getCurrentUser() {
return this.request('users/me');
}
async listFolders(parentId = null) {
const params = parentId ? `?parent_id=${parentId}` : '';
return this.request(`folders/${params}`);
}
async createFolder(name, parentId = null) {
return this.request('folders/', {
method: 'POST',
body: { name, parent_id: parentId }
});
}
async deleteFolder(folderId) {
return this.request(`folders/${folderId}`, {
method: 'DELETE'
});
}
async uploadFile(file, folderId = null) {
const formData = new FormData();
formData.append('file', file);
const endpoint = folderId ? `files/upload?folder_id=${folderId}` : 'files/upload';
return this.request(endpoint, {
method: 'POST',
body: formData
});
}
async listFiles(folderId = null) {
const params = folderId ? `?folder_id=${folderId}` : '';
return this.request(`files/${params}`);
}
async downloadFile(fileId) {
const url = `${this.baseURL}files/download/${fileId}`;
const response = await fetch(url, {
headers: {
'Authorization': `Bearer ${this.token}`
}
});
if (!response.ok) {
throw new Error('Download failed');
}
return response.blob();
}
async deleteFile(fileId) {
return this.request(`files/${fileId}`, {
method: 'DELETE'
});
}
async moveFile(fileId, targetFolderId) {
return this.request(`files/${fileId}/move`, {
method: 'POST',
body: { target_folder_id: targetFolderId }
});
}
async renameFile(fileId, newName) {
return this.request(`files/${fileId}/rename`, {
method: 'POST',
body: { new_name: newName }
});
}
async copyFile(fileId, targetFolderId) {
return this.request(`files/${fileId}/copy`, {
method: 'POST',
body: { target_folder_id: targetFolderId }
});
}
async createShare(fileId = null, folderId = null, expiresAt = null, password = null, permissionLevel = 'viewer') {
return this.request('shares/', {
method: 'POST',
body: {
file_id: fileId,
folder_id: folderId,
expires_at: expiresAt,
password,
permission_level: permissionLevel
}
});
}
async deleteShare(shareId) {
return this.request(`shares/${shareId}`, {
method: 'DELETE'
});
}
async searchFiles(query, filters = {}) {
const params = new URLSearchParams({ q: query, ...filters });
return this.request(`search/files?${params}`);
}
async searchFolders(query) {
return this.request(`search/folders?q=${encodeURIComponent(query)}`);
}
async getPhotos() {
return this.request('files/photos');
}
getThumbnailUrl(fileId) {
return `${this.baseURL}files/thumbnail/${fileId}`;
}
}
export const api = new APIClient();

View File

@ -0,0 +1,201 @@
import { api } from '../api.js';
export class FileList extends HTMLElement {
constructor() {
super();
this.currentFolderId = null;
this.files = [];
this.folders = [];
}
async connectedCallback() {
await this.loadContents(null);
}
async loadContents(folderId) {
this.currentFolderId = folderId;
try {
this.folders = await api.listFolders(folderId);
this.files = await api.listFiles(folderId);
this.render();
} catch (error) {
console.error('Failed to load contents:', error);
}
}
setFiles(files) {
this.files = files;
this.folders = [];
this.render();
}
render() {
this.innerHTML = `
<div class="file-list-container">
<div class="file-list-header">
<h2>Files</h2>
<div class="file-actions">
<button class="button" id="create-folder-btn">New Folder</button>
<button class="button button-primary" id="upload-btn">Upload</button>
</div>
</div>
<div class="file-grid">
${this.folders.map(folder => this.renderFolder(folder)).join('')}
${this.files.map(file => this.renderFile(file)).join('')}
</div>
</div>
`;
this.attachListeners();
}
renderFolder(folder) {
return `
<div class="file-item folder-item" data-folder-id="${folder.id}">
<div class="file-icon">&#128193;</div>
<div class="file-name">${folder.name}</div>
<div class="file-actions-menu">
<button class="action-btn" data-action="delete-folder" data-id="${folder.id}">Delete</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" data-action="rename" data-id="${file.id}">Rename</button>
<button class="action-btn" data-action="delete" data-id="${file.id}">Delete</button>
<button class="action-btn" data-action="share" data-id="${file.id}">Share</button>
</div>
</div>
`;
}
getFileIcon(mimeType) {
if (mimeType.startsWith('image/')) return '&#128247;';
if (mimeType.startsWith('video/')) return '&#127909;';
if (mimeType.startsWith('audio/')) return '&#127925;';
if (mimeType.includes('pdf')) return '&#128196;';
if (mimeType.includes('text')) return '&#128196;';
return '&#128196;';
}
formatFileSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB';
return (bytes / 1073741824).toFixed(1) + ' GB';
}
attachListeners() {
this.querySelector('#upload-btn')?.addEventListener('click', () => {
this.dispatchEvent(new CustomEvent('upload-request'));
});
this.querySelector('#create-folder-btn')?.addEventListener('click', async () => {
await this.handleCreateFolder();
});
this.querySelectorAll('.folder-item').forEach(item => {
item.addEventListener('dblclick', () => {
const folderId = parseInt(item.dataset.folderId);
this.dispatchEvent(new CustomEvent('folder-open', { detail: { folderId } }));
});
});
this.querySelectorAll('.file-item:not(.folder-item)').forEach(item => {
item.addEventListener('click', (e) => {
if (!e.target.classList.contains('action-btn')) {
const fileId = parseInt(item.dataset.fileId);
const file = this.files.find(f => f.id === fileId);
this.dispatchEvent(new CustomEvent('photo-click', {
detail: { photo: file },
bubbles: true
}));
}
});
});
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);
});
});
}
triggerCreateFolder() {
this.handleCreateFolder();
}
async handleCreateFolder() {
const name = prompt('Enter folder name:');
if (name) {
try {
await api.createFolder(name, this.currentFolderId);
await this.loadContents(this.currentFolderId);
} catch (error) {
alert('Failed to create folder: ' + error.message);
}
}
}
async handleAction(action, id) {
try {
switch (action) {
case 'download':
const blob = await api.downloadFile(id);
const file = this.files.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);
break;
case 'rename':
const newName = prompt('Enter new name:');
if (newName) {
await api.renameFile(id, newName);
await this.loadContents(this.currentFolderId);
}
break;
case 'delete':
if (confirm('Are you sure you want to delete this file?')) {
await api.deleteFile(id);
await this.loadContents(this.currentFolderId);
}
break;
case 'delete-folder':
if (confirm('Are you sure you want to delete this folder?')) {
await api.deleteFolder(id);
await this.loadContents(this.currentFolderId);
}
break;
case 'share':
this.dispatchEvent(new CustomEvent('share-request', { detail: { fileId: id } }));
break;
}
} catch (error) {
alert('Action failed: ' + error.message);
}
}
}
customElements.define('file-list', FileList);

View File

@ -0,0 +1,174 @@
import { api } from '../api.js';
class FilePreview extends HTMLElement {
constructor() {
super();
this.file = null;
this.handleEscape = this.handleEscape.bind(this);
}
connectedCallback() {
this.render();
this.setupEventListeners();
}
setupEventListeners() {
const closeBtn = this.querySelector('.close-preview');
const modal = this.querySelector('.preview-modal');
const downloadBtn = this.querySelector('.download-btn');
const shareBtn = this.querySelector('.share-btn');
closeBtn.addEventListener('click', () => this.close());
modal.addEventListener('click', (e) => {
if (e.target === modal) this.close();
});
downloadBtn.addEventListener('click', () => this.downloadFile());
shareBtn.addEventListener('click', () => this.shareFile());
}
handleEscape(e) {
if (e.key === 'Escape' && this.style.display === 'block') {
this.close();
}
}
async show(file) {
this.file = file;
this.style.display = 'block';
document.addEventListener('keydown', this.handleEscape);
this.renderPreview();
}
close() {
this.style.display = 'none';
this.file = null;
document.removeEventListener('keydown', this.handleEscape);
}
async renderPreview() {
const previewContent = this.querySelector('.preview-content');
const fileName = this.querySelector('.preview-file-name');
const fileInfo = this.querySelector('.preview-file-info');
fileName.textContent = this.file.name;
fileInfo.textContent = `${this.formatFileSize(this.file.size)}${new Date(this.file.created_at).toLocaleDateString()}`;
if (this.file.mime_type.startsWith('image/')) {
previewContent.innerHTML = `<img alt="${this.file.name}">`;
this.loadAuthenticatedMedia(previewContent.querySelector('img'));
} else if (this.file.mime_type.startsWith('video/')) {
previewContent.innerHTML = `
<video controls>
Your browser does not support the video tag.
</video>
`;
this.loadAuthenticatedMedia(previewContent.querySelector('video'));
} else if (this.file.mime_type.startsWith('audio/')) {
previewContent.innerHTML = `
<audio controls>
Your browser does not support the audio tag.
</audio>
`;
this.loadAuthenticatedMedia(previewContent.querySelector('audio'));
} else if (this.file.mime_type === 'application/pdf') {
previewContent.innerHTML = `<iframe type="application/pdf"></iframe>`;
this.loadAuthenticatedMedia(previewContent.querySelector('iframe'));
} else if (this.file.mime_type.startsWith('text/')) {
this.loadTextPreview(previewContent);
} else {
previewContent.innerHTML = `
<div class="no-preview">
<p>Preview not available for this file type</p>
<button class="btn btn-primary" onclick="this.getRootNode().host.downloadFile()">Download File</button>
</div>
`;
}
}
async loadAuthenticatedMedia(element) {
try {
const blob = await api.downloadFile(this.file.id);
const url = URL.createObjectURL(blob);
element.src = url;
} catch (error) {
console.error('Failed to load media:', error);
element.parentElement.innerHTML = '<p>Failed to load preview</p>';
}
}
async loadTextPreview(container) {
try {
const blob = await api.downloadFile(this.file.id);
const text = await blob.text();
container.innerHTML = `<pre>${this.escapeHtml(text)}</pre>`;
} catch (error) {
container.innerHTML = '<p>Failed to load preview</p>';
}
}
async downloadFile() {
try {
const blob = await api.downloadFile(this.file.id);
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = this.file.name;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} catch (error) {
console.error('Failed to download file:', error);
}
}
shareFile() {
this.dispatchEvent(new CustomEvent('share-file', {
detail: { file: this.file },
bubbles: true
}));
}
formatFileSize(bytes) {
const units = ['B', 'KB', 'MB', 'GB'];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(1)} ${units[unitIndex]}`;
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
render() {
this.innerHTML = `
<div class="preview-modal">
<div class="preview-container">
<div class="preview-header">
<div class="preview-info">
<h3 class="preview-file-name"></h3>
<p class="preview-file-info"></p>
</div>
<div class="preview-actions">
<button class="btn btn-icon download-btn" title="Download"></button>
<button class="btn btn-icon share-btn" title="Share">🔗</button>
<button class="btn btn-icon close-preview" title="Close"></button>
</div>
</div>
<div class="preview-content"></div>
</div>
</div>
`;
this.style.display = 'none';
}
}
customElements.define('file-preview', FilePreview);
export { FilePreview };

View File

@ -0,0 +1,125 @@
import { api } from '../api.js';
export class FileUpload extends HTMLElement {
constructor() {
super();
this.folderId = null;
this.handleEscape = this.handleEscape.bind(this);
this.render();
this.attachListeners();
}
setFolder(folderId) {
this.folderId = folderId;
}
render() {
this.innerHTML = `
<div class="upload-modal" id="upload-modal" style="display: none;">
<div class="upload-modal-content">
<div class="upload-header">
<h3>Upload Files</h3>
<button class="close-btn" id="close-upload">&times;</button>
</div>
<div class="drop-zone" id="drop-zone">
<div class="drop-zone-text">
<p>Drag and drop files here</p>
<p>or</p>
<button class="button button-primary" id="select-files-btn">Select Files</button>
<input type="file" id="file-input" multiple style="display: none;">
</div>
</div>
<div class="upload-list" id="upload-list"></div>
</div>
</div>
`;
}
attachListeners() {
const modal = this.querySelector('#upload-modal');
const dropZone = this.querySelector('#drop-zone');
const fileInput = this.querySelector('#file-input');
const selectBtn = this.querySelector('#select-files-btn');
const closeBtn = this.querySelector('#close-upload');
selectBtn.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', (e) => this.handleFiles(e.target.files));
closeBtn.addEventListener('click', () => this.hide());
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('drag-over');
});
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('drag-over');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('drag-over');
this.handleFiles(e.dataTransfer.files);
});
}
handleEscape(e) {
if (e.key === 'Escape') {
const modal = this.querySelector('#upload-modal');
if (modal.style.display === 'flex') {
this.hide();
}
}
}
show() {
this.querySelector('#upload-modal').style.display = 'flex';
document.addEventListener('keydown', this.handleEscape);
}
hide() {
this.querySelector('#upload-modal').style.display = 'none';
this.querySelector('#upload-list').innerHTML = '';
document.removeEventListener('keydown', this.handleEscape);
}
async handleFiles(files) {
const uploadList = this.querySelector('#upload-list');
for (const file of files) {
const itemId = `upload-${Date.now()}-${Math.random()}`;
const item = document.createElement('div');
item.className = 'upload-item';
item.id = itemId;
item.innerHTML = `
<div class="upload-item-name">${file.name}</div>
<div class="upload-item-progress">
<div class="progress-bar">
<div class="progress-fill" style="width: 0%"></div>
</div>
<span class="upload-status">Uploading...</span>
</div>
`;
uploadList.appendChild(item);
try {
await api.uploadFile(file, this.folderId);
const status = item.querySelector('.upload-status');
const progressFill = item.querySelector('.progress-fill');
progressFill.style.width = '100%';
status.textContent = 'Complete';
status.classList.add('success');
} catch (error) {
const status = item.querySelector('.upload-status');
status.textContent = 'Failed: ' + error.message;
status.classList.add('error');
}
}
this.dispatchEvent(new CustomEvent('upload-complete'));
}
}
customElements.define('file-upload', FileUpload);

View File

@ -0,0 +1,102 @@
import { api } from '../api.js';
export class LoginView extends HTMLElement {
constructor() {
super();
this.render();
this.attachListeners();
}
render() {
this.innerHTML = `
<div class="auth-container">
<div class="auth-box">
<h1>RBox</h1>
<p class="auth-subtitle">Self-hosted cloud storage</p>
<div class="auth-tabs">
<button class="auth-tab active" data-tab="login">Login</button>
<button class="auth-tab" data-tab="register">Register</button>
</div>
<form id="login-form" class="auth-form">
<input type="text" name="username" placeholder="Username" required class="input-field">
<input type="password" name="password" placeholder="Password" required class="input-field">
<button type="submit" class="button button-primary">Login</button>
<div class="error-message" id="login-error"></div>
</form>
<form id="register-form" class="auth-form" style="display: none;">
<input type="text" name="username" placeholder="Username" required class="input-field">
<input type="email" name="email" placeholder="Email" required class="input-field">
<input type="password" name="password" placeholder="Password" required class="input-field">
<button type="submit" class="button button-primary">Register</button>
<div class="error-message" id="register-error"></div>
</form>
</div>
</div>
`;
}
attachListeners() {
const tabs = this.querySelectorAll('.auth-tab');
tabs.forEach(tab => {
tab.addEventListener('click', () => this.switchTab(tab.dataset.tab));
});
this.querySelector('#login-form').addEventListener('submit', (e) => this.handleLogin(e));
this.querySelector('#register-form').addEventListener('submit', (e) => this.handleRegister(e));
}
switchTab(tab) {
const tabs = this.querySelectorAll('.auth-tab');
tabs.forEach(t => t.classList.remove('active'));
this.querySelector(`[data-tab="${tab}"]`).classList.add('active');
const loginForm = this.querySelector('#login-form');
const registerForm = this.querySelector('#register-form');
if (tab === 'login') {
loginForm.style.display = 'block';
registerForm.style.display = 'none';
} else {
loginForm.style.display = 'none';
registerForm.style.display = 'block';
}
}
async handleLogin(e) {
e.preventDefault();
const form = e.target;
const formData = new FormData(form);
const username = formData.get('username');
const password = formData.get('password');
const errorDiv = this.querySelector('#login-error');
try {
await api.login(username, password);
this.dispatchEvent(new CustomEvent('auth-success'));
} catch (error) {
errorDiv.textContent = error.message;
}
}
async handleRegister(e) {
e.preventDefault();
const form = e.target;
const formData = new FormData(form);
const username = formData.get('username');
const email = formData.get('email');
const password = formData.get('password');
const errorDiv = this.querySelector('#register-error');
try {
await api.register(username, email, password);
this.dispatchEvent(new CustomEvent('auth-success'));
} catch (error) {
errorDiv.textContent = error.message;
}
}
}
customElements.define('login-view', LoginView);

View File

@ -0,0 +1,90 @@
import { api } from '../api.js';
class PhotoGallery extends HTMLElement {
constructor() {
super();
this.photos = [];
}
connectedCallback() {
this.render();
this.loadPhotos();
}
async loadPhotos() {
try {
this.photos = await api.getPhotos();
this.renderPhotos();
} catch (error) {
console.error('Failed to load photos:', error);
this.querySelector('.gallery-grid').innerHTML = '<p>Failed to load photos</p>';
}
}
async renderPhotos() {
const grid = this.querySelector('.gallery-grid');
if (this.photos.length === 0) {
grid.innerHTML = '<p class="empty-state">No photos yet</p>';
return;
}
grid.innerHTML = this.photos.map(photo => `
<div class="photo-item" data-file-id="${photo.id}">
<img
data-photo-id="${photo.id}"
alt="${photo.name}"
loading="lazy"
style="background: #f0f0f0;"
>
<div class="photo-info">
<span class="photo-name">${photo.name}</span>
<span class="photo-date">${new Date(photo.created_at).toLocaleDateString()}</span>
</div>
</div>
`).join('');
grid.querySelectorAll('img[data-photo-id]').forEach(async (img) => {
const photoId = img.dataset.photoId;
try {
const response = await fetch(`/files/thumbnail/${photoId}`, {
headers: {
'Authorization': `Bearer ${api.getToken()}`
}
});
if (response.ok) {
const blob = await response.blob();
img.src = URL.createObjectURL(blob);
} else {
img.style.display = 'none';
}
} catch (error) {
img.style.display = 'none';
}
});
grid.querySelectorAll('.photo-item').forEach(item => {
item.addEventListener('click', () => {
const fileId = item.dataset.fileId;
const photo = this.photos.find(p => p.id === parseInt(fileId));
this.dispatchEvent(new CustomEvent('photo-click', {
detail: { photo },
bubbles: true
}));
});
});
}
render() {
this.innerHTML = `
<div class="photo-gallery">
<div class="gallery-header">
<h2>Photo Gallery</h2>
</div>
<div class="gallery-grid"></div>
</div>
`;
}
}
customElements.define('photo-gallery', PhotoGallery);
export { PhotoGallery };

View File

@ -0,0 +1,345 @@
import { api } from '../api.js';
import './login-view.js';
import './file-list.js';
import './file-upload.js';
import './share-modal.js';
import './photo-gallery.js';
import './file-preview.js';
import { shortcuts } from '../shortcuts.js';
export class RBoxApp extends HTMLElement {
constructor() {
super();
this.currentView = 'files';
this.currentFolderId = null;
this.user = null;
}
async connectedCallback() {
await this.init();
}
async init() {
if (!api.getToken()) {
this.showLogin();
} else {
try {
this.user = await api.getCurrentUser();
this.render();
} catch (error) {
this.showLogin();
}
}
}
showLogin() {
this.innerHTML = '<login-view></login-view>';
const loginView = this.querySelector('login-view');
loginView.addEventListener('auth-success', () => this.init());
}
render() {
this.innerHTML = `
<div class="app-container">
<header class="app-header">
<div class="header-left">
<h1 class="app-title">RBox</h1>
</div>
<div class="header-center">
<input type="search" placeholder="Search..." class="search-input" id="search-input">
</div>
<div class="header-right">
<span class="user-info">${this.user.username}</span>
<button class="button" id="logout-btn">Logout</button>
</div>
</header>
<div class="app-body">
<aside class="app-sidebar">
<nav class="sidebar-nav">
<h3 class="nav-title">Navigation</h3>
<ul class="nav-list">
<li><a href="#" class="nav-link active" data-view="files">My Files</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="deleted">Deleted Files</a></li>
</ul>
<h3 class="nav-title">Quick Access</h3>
<ul class="nav-list">
<li><a href="#" class="nav-link" data-view="starred">Starred</a></li>
<li><a href="#" class="nav-link" data-view="recent">Recent</a></li>
</ul>
</nav>
</aside>
<main class="app-main">
<div id="main-content">
<file-list></file-list>
</div>
</main>
</div>
<file-upload></file-upload>
<share-modal></share-modal>
<file-preview></file-preview>
</div>
`;
this.attachListeners();
this.registerShortcuts();
}
registerShortcuts() {
shortcuts.register('ctrl+u', () => {
const upload = this.querySelector('file-upload');
if (upload) {
upload.setFolder(this.currentFolderId);
upload.show();
}
});
shortcuts.register('ctrl+f', () => {
const searchInput = this.querySelector('#search-input');
if (searchInput) {
searchInput.focus();
}
});
shortcuts.register('ctrl+/', () => {
this.showShortcutsHelp();
});
shortcuts.register('ctrl+shift+n', () => {
if (this.currentView === 'files') {
const fileList = this.querySelector('file-list');
if (fileList) {
fileList.triggerCreateFolder();
}
}
});
shortcuts.register('1', () => {
this.switchView('files');
});
shortcuts.register('2', () => {
this.switchView('photos');
});
shortcuts.register('3', () => {
this.switchView('shared');
});
shortcuts.register('4', () => {
this.switchView('deleted');
});
shortcuts.register('f2', () => {
console.log('Rename shortcut - to be implemented');
});
}
showShortcutsHelp() {
const helpContent = `
<div class="shortcuts-help-modal">
<div class="shortcuts-help-content">
<h2>Keyboard Shortcuts</h2>
<div class="shortcuts-list">
<h3>File Operations</h3>
<div class="shortcut-item">
<kbd>Ctrl + U</kbd>
<span>Upload files</span>
</div>
<div class="shortcut-item">
<kbd>Ctrl + Shift + N</kbd>
<span>Create new folder</span>
</div>
<div class="shortcut-item">
<kbd>Ctrl + F</kbd>
<span>Focus search</span>
</div>
<h3>Navigation</h3>
<div class="shortcut-item">
<kbd>1</kbd>
<span>My Files</span>
</div>
<div class="shortcut-item">
<kbd>2</kbd>
<span>Photo Gallery</span>
</div>
<div class="shortcut-item">
<kbd>3</kbd>
<span>Shared Items</span>
</div>
<div class="shortcut-item">
<kbd>4</kbd>
<span>Deleted Files</span>
</div>
<h3>General</h3>
<div class="shortcut-item">
<kbd>ESC</kbd>
<span>Close modals</span>
</div>
<div class="shortcut-item">
<kbd>Ctrl + /</kbd>
<span>Show this help</span>
</div>
</div>
<button class="button" id="close-shortcuts-help">Close</button>
</div>
</div>
`;
const helpDiv = document.createElement('div');
helpDiv.innerHTML = helpContent;
helpDiv.querySelector('.shortcuts-help-modal').style.cssText = `
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
`;
document.body.appendChild(helpDiv);
const closeHelp = () => {
document.body.removeChild(helpDiv);
document.removeEventListener('keydown', handleEscape);
};
const handleEscape = (e) => {
if (e.key === 'Escape') {
closeHelp();
}
};
const closeBtn = helpDiv.querySelector('#close-shortcuts-help');
closeBtn.addEventListener('click', closeHelp);
helpDiv.querySelector('.shortcuts-help-modal').addEventListener('click', (e) => {
if (e.target.classList.contains('shortcuts-help-modal')) closeHelp();
});
document.addEventListener('keydown', handleEscape);
}
attachListeners() {
this.querySelector('#logout-btn')?.addEventListener('click', () => {
api.logout();
});
this.querySelectorAll('.nav-link').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
const view = link.dataset.view;
this.switchView(view);
});
});
const fileList = this.querySelector('file-list');
if (fileList) {
fileList.addEventListener('upload-request', () => {
const upload = this.querySelector('file-upload');
upload.setFolder(this.currentFolderId);
upload.show();
});
fileList.addEventListener('folder-open', (e) => {
this.currentFolderId = e.detail.folderId;
fileList.loadContents(this.currentFolderId);
});
fileList.addEventListener('share-request', (e) => {
const modal = this.querySelector('share-modal');
modal.show(e.detail.fileId);
});
}
const upload = this.querySelector('file-upload');
if (upload) {
upload.addEventListener('upload-complete', () => {
const fileList = this.querySelector('file-list');
fileList.loadContents(this.currentFolderId);
});
}
const searchInput = this.querySelector('#search-input');
if (searchInput) {
let searchTimeout;
searchInput.addEventListener('input', (e) => {
clearTimeout(searchTimeout);
const query = e.target.value.trim();
if (query.length > 0) {
searchTimeout = setTimeout(() => this.performSearch(query), 300);
}
});
}
this.addEventListener('photo-click', (e) => {
const preview = this.querySelector('file-preview');
preview.show(e.detail.photo);
});
this.addEventListener('share-file', (e) => {
const modal = this.querySelector('share-modal');
modal.show(e.detail.file.id);
});
}
async performSearch(query) {
try {
const files = await api.searchFiles(query);
const mainContent = this.querySelector('#main-content');
mainContent.innerHTML = `
<div class="search-results">
<h2>Search Results for "${query}"</h2>
<file-list data-search-mode="true"></file-list>
</div>
`;
const fileList = mainContent.querySelector('file-list');
fileList.setFiles(files);
this.attachListeners();
} catch (error) {
console.error('Search failed:', error);
}
}
switchView(view) {
this.currentView = view;
this.querySelectorAll('.nav-link').forEach(link => {
link.classList.remove('active');
});
this.querySelector(`[data-view="${view}"]`)?.classList.add('active');
const mainContent = this.querySelector('#main-content');
switch (view) {
case 'files':
mainContent.innerHTML = '<file-list></file-list>';
this.attachListeners();
break;
case 'photos':
mainContent.innerHTML = '<photo-gallery></photo-gallery>';
this.attachListeners();
break;
case 'shared':
mainContent.innerHTML = '<div class="placeholder">Shared Items - Coming Soon</div>';
break;
case 'deleted':
mainContent.innerHTML = '<div class="placeholder">Deleted Files - Coming Soon</div>';
break;
case 'starred':
mainContent.innerHTML = '<div class="placeholder">Starred Items - Coming Soon</div>';
break;
case 'recent':
mainContent.innerHTML = '<div class="placeholder">Recent Files - Coming Soon</div>';
break;
}
}
}

View File

@ -0,0 +1,133 @@
import { api } from '../api.js';
export class ShareModal extends HTMLElement {
constructor() {
super();
this.fileId = null;
this.folderId = null;
this.handleEscape = this.handleEscape.bind(this);
this.render();
this.attachListeners();
}
render() {
this.innerHTML = `
<div class="share-modal" id="share-modal" style="display: none;">
<div class="share-modal-content">
<div class="share-header">
<h3>Share</h3>
<button class="close-btn" id="close-share">&times;</button>
</div>
<form id="share-form">
<div class="form-group">
<label>Permission Level</label>
<select name="permission_level" class="input-field">
<option value="viewer">Viewer</option>
<option value="uploader">Uploader</option>
<option value="editor">Editor</option>
</select>
</div>
<div class="form-group">
<label>Password Protection (optional)</label>
<input type="password" name="password" class="input-field" placeholder="Leave blank for no password">
</div>
<div class="form-group">
<label>Expiration Date (optional)</label>
<input type="datetime-local" name="expires_at" class="input-field">
</div>
<button type="submit" class="button button-primary">Create Share Link</button>
</form>
<div id="share-result" style="display: none;">
<div class="form-group">
<label>Share Link</label>
<div class="share-link-container">
<input type="text" id="share-link" readonly class="input-field">
<button class="button" id="copy-link-btn">Copy</button>
</div>
</div>
</div>
<div class="error-message" id="share-error"></div>
</div>
</div>
`;
}
attachListeners() {
const closeBtn = this.querySelector('#close-share');
closeBtn.addEventListener('click', () => this.hide());
const form = this.querySelector('#share-form');
form.addEventListener('submit', (e) => this.handleCreateShare(e));
const copyBtn = this.querySelector('#copy-link-btn');
copyBtn.addEventListener('click', () => this.copyLink());
}
handleEscape(e) {
if (e.key === 'Escape') {
const modal = this.querySelector('#share-modal');
if (modal.style.display === 'flex') {
this.hide();
}
}
}
show(fileId = null, folderId = null) {
this.fileId = fileId;
this.folderId = folderId;
this.querySelector('#share-modal').style.display = 'flex';
this.querySelector('#share-result').style.display = 'none';
this.querySelector('#share-form').reset();
document.addEventListener('keydown', this.handleEscape);
}
hide() {
this.querySelector('#share-modal').style.display = 'none';
this.fileId = null;
this.folderId = null;
document.removeEventListener('keydown', this.handleEscape);
}
async handleCreateShare(e) {
e.preventDefault();
const form = e.target;
const formData = new FormData(form);
const errorDiv = this.querySelector('#share-error');
try {
const permissionLevel = formData.get('permission_level');
const password = formData.get('password') || null;
const expiresAt = formData.get('expires_at') || null;
const share = await api.createShare(
this.fileId,
this.folderId,
expiresAt,
password,
permissionLevel
);
const shareLink = `${window.location.origin}/share/${share.token}`;
this.querySelector('#share-link').value = shareLink;
this.querySelector('#share-result').style.display = 'block';
errorDiv.textContent = '';
} catch (error) {
errorDiv.textContent = 'Failed to create share link: ' + error.message;
}
}
copyLink() {
const linkInput = this.querySelector('#share-link');
linkInput.select();
document.execCommand('copy');
alert('Link copied to clipboard');
}
}
customElements.define('share-modal', ShareModal);

23
static/js/main.js Normal file
View File

@ -0,0 +1,23 @@
import { RBoxApp } from './components/rbox-app.js';
// Define the custom element
customElements.define('rbox-app', RBoxApp);
// Instantiate the main application class
const app = new RBoxApp();
// Append the app to the body (if not already in index.html)
// document.body.appendChild(app);
// Register service worker for PWA
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/static/service-worker.js')
.then(registration => {
console.log('Service Worker registered with scope:', registration.scope);
})
.catch(error => {
console.log('Service Worker registration failed:', error);
});
});
}

55
static/js/shortcuts.js Normal file
View File

@ -0,0 +1,55 @@
class KeyboardShortcuts {
constructor() {
this.shortcuts = new Map();
this.enabled = true;
this.init();
}
init() {
document.addEventListener('keydown', (e) => {
if (!this.enabled) return;
const isInput = ['INPUT', 'TEXTAREA'].includes(e.target.tagName);
const key = this.getKeyCombo(e);
if (this.shortcuts.has(key)) {
const { handler, allowInInput } = this.shortcuts.get(key);
if (!isInput || allowInInput) {
e.preventDefault();
handler(e);
}
}
});
}
getKeyCombo(e) {
const parts = [];
if (e.ctrlKey || e.metaKey) parts.push('ctrl');
if (e.altKey) parts.push('alt');
if (e.shiftKey) parts.push('shift');
parts.push(e.key.toLowerCase());
return parts.join('+');
}
register(keyCombo, handler, allowInInput = false) {
this.shortcuts.set(keyCombo.toLowerCase(), { handler, allowInInput });
}
unregister(keyCombo) {
this.shortcuts.delete(keyCombo.toLowerCase());
}
enable() {
this.enabled = true;
}
disable() {
this.enabled = false;
}
getRegisteredShortcuts() {
return Array.from(this.shortcuts.keys());
}
}
export const shortcuts = new KeyboardShortcuts();

21
static/manifest.json Normal file
View File

@ -0,0 +1,21 @@
{
"name": "RBox Cloud Storage",
"short_name": "RBox",
"description": "A self-hosted cloud storage web application",
"start_url": "/",
"display": "standalone",
"background_color": "#F0F2F5",
"theme_color": "#003399",
"icons": [
{
"src": "/static/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/static/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}

46
static/service-worker.js Normal file
View File

@ -0,0 +1,46 @@
const CACHE_NAME = 'rbox-cache-v1';
const urlsToCache = [
'/',
'/static/index.html',
'/static/css/style.css',
'/static/js/main.js',
'/static/js/components/rbox-app.js',
'/static/manifest.json',
'/static/icons/icon-192x192.png',
'/static/icons/icon-512x512.png'
];
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => {
return cache.addAll(urlsToCache);
})
);
});
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request)
.then(response => {
if (response) {
return response;
}
return fetch(event.request);
})
);
});
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames.map(cacheName => {
if (cacheName !== CACHE_NAME) {
return caches.delete(cacheName);
}
})
);
})
);
});