From adc861d4b4df46999ed4abcf5bfb5d489e210df6 Mon Sep 17 00:00:00 2001 From: retoor Date: Sun, 9 Nov 2025 23:29:07 +0100 Subject: [PATCH] Update. --- .env.example | 29 + .gitignore | 59 ++ Dockerfile | 36 ++ docker-compose.yml | 67 +++ nginx/nginx.conf | 52 ++ pyproject.toml | 44 ++ rbox/__init__.py | 0 rbox/activity.py | 17 + rbox/auth.py | 59 ++ rbox/main.py | 59 ++ rbox/models.py | 137 +++++ rbox/routers/auth.py | 56 ++ rbox/routers/files.py | 259 +++++++++ rbox/routers/folders.py | 105 ++++ rbox/routers/search.py | 55 ++ rbox/routers/shares.py | 94 +++ rbox/routers/users.py | 12 + rbox/schemas.py | 86 +++ rbox/settings.py | 28 + rbox/storage.py | 45 ++ rbox/thumbnails.py | 94 +++ rbox/webdav.py | 654 +++++++++++++++++++++ run_dev.sh | 18 + static/css/style.css | 788 ++++++++++++++++++++++++++ static/icons/icon-192x192.png | 0 static/icons/icon-512x512.png | 0 static/index.html | 14 + static/js/api.js | 208 +++++++ static/js/components/file-list.js | 201 +++++++ static/js/components/file-preview.js | 174 ++++++ static/js/components/file-upload.js | 125 ++++ static/js/components/login-view.js | 102 ++++ static/js/components/photo-gallery.js | 90 +++ static/js/components/rbox-app.js | 345 +++++++++++ static/js/components/share-modal.js | 133 +++++ static/js/main.js | 23 + static/js/shortcuts.js | 55 ++ static/manifest.json | 21 + static/service-worker.js | 46 ++ 39 files changed, 4390 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 nginx/nginx.conf create mode 100644 pyproject.toml create mode 100644 rbox/__init__.py create mode 100644 rbox/activity.py create mode 100644 rbox/auth.py create mode 100644 rbox/main.py create mode 100644 rbox/models.py create mode 100644 rbox/routers/auth.py create mode 100644 rbox/routers/files.py create mode 100644 rbox/routers/folders.py create mode 100644 rbox/routers/search.py create mode 100644 rbox/routers/shares.py create mode 100644 rbox/routers/users.py create mode 100644 rbox/schemas.py create mode 100644 rbox/settings.py create mode 100644 rbox/storage.py create mode 100644 rbox/thumbnails.py create mode 100644 rbox/webdav.py create mode 100755 run_dev.sh create mode 100644 static/css/style.css create mode 100644 static/icons/icon-192x192.png create mode 100644 static/icons/icon-512x512.png create mode 100644 static/index.html create mode 100644 static/js/api.js create mode 100644 static/js/components/file-list.js create mode 100644 static/js/components/file-preview.js create mode 100644 static/js/components/file-upload.js create mode 100644 static/js/components/login-view.js create mode 100644 static/js/components/photo-gallery.js create mode 100644 static/js/components/rbox-app.js create mode 100644 static/js/components/share-modal.js create mode 100644 static/js/main.js create mode 100644 static/js/shortcuts.js create mode 100644 static/manifest.json create mode 100644 static/service-worker.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2febe26 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..894f7dd --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..19a9346 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ff1e5bc --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000..1b0911e --- /dev/null +++ b/nginx/nginx.conf @@ -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; + } +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..caeb6cb --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,44 @@ +[tool.poetry] +name = "rbox" +version = "0.1.0" +description = "A self-hosted cloud storage web application" +authors = ["Your Name "] +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" + diff --git a/rbox/__init__.py b/rbox/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rbox/activity.py b/rbox/activity.py new file mode 100644 index 0000000..4c497c8 --- /dev/null +++ b/rbox/activity.py @@ -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 + ) diff --git a/rbox/auth.py b/rbox/auth.py new file mode 100644 index 0000000..9061c99 --- /dev/null +++ b/rbox/auth.py @@ -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 diff --git a/rbox/main.py b/rbox/main.py new file mode 100644 index 0000000..1300f08 --- /dev/null +++ b/rbox/main.py @@ -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() diff --git a/rbox/models.py b/rbox/models.py new file mode 100644 index 0000000..93f38e2 --- /dev/null +++ b/rbox/models.py @@ -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) diff --git a/rbox/routers/auth.py b/rbox/routers/auth.py new file mode 100644 index 0000000..11d9f38 --- /dev/null +++ b/rbox/routers/auth.py @@ -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"} diff --git a/rbox/routers/files.py b/rbox/routers/files.py new file mode 100644 index 0000000..060ca85 --- /dev/null +++ b/rbox/routers/files.py @@ -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] diff --git a/rbox/routers/folders.py b/rbox/routers/folders.py new file mode 100644 index 0000000..8355265 --- /dev/null +++ b/rbox/routers/folders.py @@ -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 diff --git a/rbox/routers/search.py b/rbox/routers/search.py new file mode 100644 index 0000000..8c68c62 --- /dev/null +++ b/rbox/routers/search.py @@ -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] diff --git a/rbox/routers/shares.py b/rbox/routers/shares.py new file mode 100644 index 0000000..286005a --- /dev/null +++ b/rbox/routers/shares.py @@ -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 diff --git a/rbox/routers/users.py b/rbox/routers/users.py new file mode 100644 index 0000000..3636115 --- /dev/null +++ b/rbox/routers/users.py @@ -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) diff --git a/rbox/schemas.py b/rbox/schemas.py new file mode 100644 index 0000000..8b2b22c --- /dev/null +++ b/rbox/schemas.py @@ -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") diff --git a/rbox/settings.py b/rbox/settings.py new file mode 100644 index 0000000..a45727b --- /dev/null +++ b/rbox/settings.py @@ -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() diff --git a/rbox/storage.py b/rbox/storage.py new file mode 100644 index 0000000..76e0900 --- /dev/null +++ b/rbox/storage.py @@ -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() diff --git a/rbox/thumbnails.py b/rbox/thumbnails.py new file mode 100644 index 0000000..2fd8b8b --- /dev/null +++ b/rbox/thumbnails.py @@ -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}") diff --git a/rbox/webdav.py b/rbox/webdav.py new file mode 100644 index 0000000..6b0d44a --- /dev/null +++ b/rbox/webdav.py @@ -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) diff --git a/run_dev.sh b/run_dev.sh new file mode 100755 index 0000000..93e1ad6 --- /dev/null +++ b/run_dev.sh @@ -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 diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..998125f --- /dev/null +++ b/static/css/style.css @@ -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); +} diff --git a/static/icons/icon-192x192.png b/static/icons/icon-192x192.png new file mode 100644 index 0000000..e69de29 diff --git a/static/icons/icon-512x512.png b/static/icons/icon-512x512.png new file mode 100644 index 0000000..e69de29 diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..ef0e50a --- /dev/null +++ b/static/index.html @@ -0,0 +1,14 @@ + + + + + + RBox Cloud Storage + + + + + + + + diff --git a/static/js/api.js b/static/js/api.js new file mode 100644 index 0000000..fcd688d --- /dev/null +++ b/static/js/api.js @@ -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(); diff --git a/static/js/components/file-list.js b/static/js/components/file-list.js new file mode 100644 index 0000000..365e415 --- /dev/null +++ b/static/js/components/file-list.js @@ -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 = ` +
+
+

Files

+
+ + +
+
+ +
+ ${this.folders.map(folder => this.renderFolder(folder)).join('')} + ${this.files.map(file => this.renderFile(file)).join('')} +
+
+ `; + + this.attachListeners(); + } + + renderFolder(folder) { + return ` +
+
📁
+
${folder.name}
+
+ +
+
+ `; + } + + renderFile(file) { + const icon = this.getFileIcon(file.mime_type); + const size = this.formatFileSize(file.size); + + return ` +
+
${icon}
+
${file.name}
+
${size}
+
+ + + + +
+
+ `; + } + + getFileIcon(mimeType) { + if (mimeType.startsWith('image/')) return '📷'; + if (mimeType.startsWith('video/')) return '🎥'; + if (mimeType.startsWith('audio/')) return '🎵'; + if (mimeType.includes('pdf')) return '📄'; + if (mimeType.includes('text')) return '📄'; + return '📄'; + } + + formatFileSize(bytes) { + if (bytes < 1024) return bytes + ' B'; + if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB'; + if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB'; + return (bytes / 1073741824).toFixed(1) + ' GB'; + } + + attachListeners() { + this.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); diff --git a/static/js/components/file-preview.js b/static/js/components/file-preview.js new file mode 100644 index 0000000..2ce985c --- /dev/null +++ b/static/js/components/file-preview.js @@ -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 = `${this.file.name}`; + this.loadAuthenticatedMedia(previewContent.querySelector('img')); + } else if (this.file.mime_type.startsWith('video/')) { + previewContent.innerHTML = ` + + `; + this.loadAuthenticatedMedia(previewContent.querySelector('video')); + } else if (this.file.mime_type.startsWith('audio/')) { + previewContent.innerHTML = ` + + `; + this.loadAuthenticatedMedia(previewContent.querySelector('audio')); + } else if (this.file.mime_type === 'application/pdf') { + previewContent.innerHTML = ``; + this.loadAuthenticatedMedia(previewContent.querySelector('iframe')); + } else if (this.file.mime_type.startsWith('text/')) { + this.loadTextPreview(previewContent); + } else { + previewContent.innerHTML = ` +
+

Preview not available for this file type

+ +
+ `; + } + } + + 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 = '

Failed to load preview

'; + } + } + + async loadTextPreview(container) { + try { + const blob = await api.downloadFile(this.file.id); + const text = await blob.text(); + container.innerHTML = `
${this.escapeHtml(text)}
`; + } catch (error) { + container.innerHTML = '

Failed to load preview

'; + } + } + + 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 = ` +
+
+
+
+

+

+
+
+ + + +
+
+
+
+
+ `; + this.style.display = 'none'; + } +} + +customElements.define('file-preview', FilePreview); +export { FilePreview }; diff --git a/static/js/components/file-upload.js b/static/js/components/file-upload.js new file mode 100644 index 0000000..de4bfd6 --- /dev/null +++ b/static/js/components/file-upload.js @@ -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 = ` + + `; + } + + 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 = ` +
${file.name}
+
+
+
+
+ Uploading... +
+ `; + 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); diff --git a/static/js/components/login-view.js b/static/js/components/login-view.js new file mode 100644 index 0000000..e34952d --- /dev/null +++ b/static/js/components/login-view.js @@ -0,0 +1,102 @@ +import { api } from '../api.js'; + +export class LoginView extends HTMLElement { + constructor() { + super(); + this.render(); + this.attachListeners(); + } + + render() { + this.innerHTML = ` +
+
+

RBox

+

Self-hosted cloud storage

+ +
+ + +
+ +
+ + + +
+
+ + +
+
+ `; + } + + 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); diff --git a/static/js/components/photo-gallery.js b/static/js/components/photo-gallery.js new file mode 100644 index 0000000..ac9466a --- /dev/null +++ b/static/js/components/photo-gallery.js @@ -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 = '

Failed to load photos

'; + } + } + + async renderPhotos() { + const grid = this.querySelector('.gallery-grid'); + if (this.photos.length === 0) { + grid.innerHTML = '

No photos yet

'; + return; + } + + grid.innerHTML = this.photos.map(photo => ` +
+ ${photo.name} +
+ ${photo.name} + ${new Date(photo.created_at).toLocaleDateString()} +
+
+ `).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 = ` + + `; + } +} + +customElements.define('photo-gallery', PhotoGallery); +export { PhotoGallery }; diff --git a/static/js/components/rbox-app.js b/static/js/components/rbox-app.js new file mode 100644 index 0000000..19ddaea --- /dev/null +++ b/static/js/components/rbox-app.js @@ -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 = ''; + const loginView = this.querySelector('login-view'); + loginView.addEventListener('auth-success', () => this.init()); + } + + render() { + this.innerHTML = ` +
+
+
+

RBox

+
+
+ +
+
+ + +
+
+ +
+ + +
+
+ +
+
+
+ + + + +
+ `; + + 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 = ` +
+
+

Keyboard Shortcuts

+
+

File Operations

+
+ Ctrl + U + Upload files +
+
+ Ctrl + Shift + N + Create new folder +
+
+ Ctrl + F + Focus search +
+ +

Navigation

+
+ 1 + My Files +
+
+ 2 + Photo Gallery +
+
+ 3 + Shared Items +
+
+ 4 + Deleted Files +
+ +

General

+
+ ESC + Close modals +
+
+ Ctrl + / + Show this help +
+
+ +
+
+ `; + + 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 = ` +
+

Search Results for "${query}"

+ +
+ `; + 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 = ''; + this.attachListeners(); + break; + case 'photos': + mainContent.innerHTML = ''; + this.attachListeners(); + break; + case 'shared': + mainContent.innerHTML = '
Shared Items - Coming Soon
'; + break; + case 'deleted': + mainContent.innerHTML = '
Deleted Files - Coming Soon
'; + break; + case 'starred': + mainContent.innerHTML = '
Starred Items - Coming Soon
'; + break; + case 'recent': + mainContent.innerHTML = '
Recent Files - Coming Soon
'; + break; + } + } +} diff --git a/static/js/components/share-modal.js b/static/js/components/share-modal.js new file mode 100644 index 0000000..3708064 --- /dev/null +++ b/static/js/components/share-modal.js @@ -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 = ` + + `; + } + + 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); diff --git a/static/js/main.js b/static/js/main.js new file mode 100644 index 0000000..b73a3a5 --- /dev/null +++ b/static/js/main.js @@ -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); + }); + }); +} diff --git a/static/js/shortcuts.js b/static/js/shortcuts.js new file mode 100644 index 0000000..2b5230e --- /dev/null +++ b/static/js/shortcuts.js @@ -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(); diff --git a/static/manifest.json b/static/manifest.json new file mode 100644 index 0000000..7a38fb2 --- /dev/null +++ b/static/manifest.json @@ -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" + } + ] +} diff --git a/static/service-worker.js b/static/service-worker.js new file mode 100644 index 0000000..6e11032 --- /dev/null +++ b/static/service-worker.js @@ -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); + } + }) + ); + }) + ); +});