Update.
This commit is contained in:
commit
adc861d4b4
29
.env.example
Normal file
29
.env.example
Normal file
@ -0,0 +1,29 @@
|
||||
POSTGRES_USER=rbox_user
|
||||
POSTGRES_PASSWORD=rbox_password
|
||||
POSTGRES_DB=rbox_db
|
||||
|
||||
DATABASE_URL=postgres://rbox_user:rbox_password@db:5432/rbox_db
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
|
||||
SECRET_KEY=change-this-to-a-random-secret-key-in-production
|
||||
ALGORITHM=HS256
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||
REFRESH_TOKEN_EXPIRE_DAYS=7
|
||||
|
||||
DOMAIN_NAME=localhost
|
||||
CERTBOT_EMAIL=admin@example.com
|
||||
|
||||
STORAGE_PATH=/app/data
|
||||
|
||||
S3_ACCESS_KEY_ID=
|
||||
S3_SECRET_ACCESS_KEY=
|
||||
S3_ENDPOINT_URL=
|
||||
S3_BUCKET_NAME=rbox-storage
|
||||
|
||||
SMTP_SERVER=
|
||||
SMTP_PORT=587
|
||||
SMTP_USERNAME=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM_EMAIL=no-reply@example.com
|
||||
|
||||
TOTP_ISSUER=RBox
|
||||
59
.gitignore
vendored
Normal file
59
.gitignore
vendored
Normal file
@ -0,0 +1,59 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.md
|
||||
.*
|
||||
storage
|
||||
*.so
|
||||
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.tox/
|
||||
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
*.log
|
||||
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
*.db
|
||||
*.sqlite3
|
||||
|
||||
node_modules/
|
||||
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
data/
|
||||
uploads/
|
||||
|
||||
*.pem
|
||||
*.key
|
||||
*.crt
|
||||
36
Dockerfile
Normal file
36
Dockerfile
Normal file
@ -0,0 +1,36 @@
|
||||
# Use an official Python runtime as a parent image
|
||||
FROM python:3.10-slim-buster
|
||||
|
||||
# Set the working directory in the container
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
build-essential \
|
||||
libpq-dev \
|
||||
libmagic-dev \
|
||||
libjpeg-dev \
|
||||
zlib1g-dev \
|
||||
libwebp-dev \
|
||||
tesseract-ocr \
|
||||
ffmpeg \
|
||||
git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy pyproject.toml and poetry.lock to the working directory
|
||||
COPY pyproject.toml poetry.lock* ./
|
||||
|
||||
# Install poetry
|
||||
RUN pip install poetry
|
||||
|
||||
# Install project dependencies
|
||||
RUN poetry install --no-root --no-dev
|
||||
|
||||
# Copy the rest of the application code
|
||||
COPY . .
|
||||
|
||||
# Expose port 8000 for the FastAPI application
|
||||
EXPOSE 8000
|
||||
|
||||
# Command to run the application (will be overridden by docker-compose)
|
||||
CMD ["poetry", "run", "uvicorn", "rbox.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
67
docker-compose.yml
Normal file
67
docker-compose.yml
Normal file
@ -0,0 +1,67 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:15-alpine
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
restart: unless-stopped
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
restart: unless-stopped
|
||||
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
command: /usr/local/bin/gunicorn rbox.main:app --workers 4 --worker-class uvicorn.workers.UvicornWorker --bind 0.0.0.0:8000
|
||||
volumes:
|
||||
- app_data:/app/data # For uploaded files
|
||||
environment:
|
||||
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
||||
REDIS_URL: redis://redis:6379/0
|
||||
SECRET_KEY: ${SECRET_KEY}
|
||||
DOMAIN_NAME: ${DOMAIN_NAME}
|
||||
# Add other environment variables as needed
|
||||
depends_on:
|
||||
- db
|
||||
- redis
|
||||
restart: unless-stopped
|
||||
|
||||
nginx:
|
||||
image: nginx:stable-alpine
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf
|
||||
- certbot_certs:/etc/letsencrypt
|
||||
- app_data:/app/data # Serve static files from here
|
||||
depends_on:
|
||||
- app
|
||||
restart: unless-stopped
|
||||
|
||||
certbot:
|
||||
image: certbot/certbot
|
||||
volumes:
|
||||
- certbot_certs:/etc/letsencrypt
|
||||
- ./certbot/conf:/etc/nginx/conf.d
|
||||
command: certonly --webroot --webroot-path=/var/www/certbot --email ${CERTBOT_EMAIL} --agree-tos --no-eff-email -d ${DOMAIN_NAME}
|
||||
depends_on:
|
||||
- nginx
|
||||
environment:
|
||||
DOMAIN_NAME: ${DOMAIN_NAME}
|
||||
CERTBOT_EMAIL: ${CERTBOT_EMAIL}
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
app_data:
|
||||
certbot_certs:
|
||||
52
nginx/nginx.conf
Normal file
52
nginx/nginx.conf
Normal file
@ -0,0 +1,52 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name ${DOMAIN_NAME};
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name ${DOMAIN_NAME};
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/${DOMAIN_NAME}/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/${DOMAIN_NAME}/privkey.pem;
|
||||
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
|
||||
client_max_body_size 0; # Allow unlimited file size
|
||||
|
||||
location / {
|
||||
proxy_pass http://app:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /static/ {
|
||||
alias /app/static/;
|
||||
}
|
||||
|
||||
location /webdav/ {
|
||||
proxy_pass http://app:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Depth "";
|
||||
proxy_set_header Destination "";
|
||||
proxy_set_header Overwrite "";
|
||||
proxy_set_header If "";
|
||||
proxy_set_header Content-Type $content_type;
|
||||
}
|
||||
}
|
||||
44
pyproject.toml
Normal file
44
pyproject.toml
Normal file
@ -0,0 +1,44 @@
|
||||
[tool.poetry]
|
||||
name = "rbox"
|
||||
version = "0.1.0"
|
||||
description = "A self-hosted cloud storage web application"
|
||||
authors = ["Your Name <you@example.com>"]
|
||||
readme = "README.md"
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "*"
|
||||
fastapi = "*"
|
||||
uvicorn = {extras = ["standard"], version = "*"}
|
||||
tortoise-orm = {extras = ["asyncpg"], version = "*"}
|
||||
redis = "*"
|
||||
python-jose = {extras = ["cryptography"], version = "*"}
|
||||
passlib = {extras = ["bcrypt"], version = "*"}
|
||||
pyotp = "*"
|
||||
python-multipart = "*"
|
||||
aiofiles = "*"
|
||||
httpx = "*"
|
||||
pillow = "*"
|
||||
python-magic = "*"
|
||||
watchdog = "*"
|
||||
asyncio-throttle = "*"
|
||||
python-dotenv = "*"
|
||||
aiowebdav = "*"
|
||||
minio = "*"
|
||||
cryptography = "*"
|
||||
#tesseract-ocr = "*"
|
||||
opencv-python = "*"
|
||||
ffmpeg-python = "*"
|
||||
gunicorn = "*"
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
black = "*"
|
||||
isort = "*"
|
||||
flake8 = "*"
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
|
||||
[tool.poetry.scripts]
|
||||
rbox = "rbox.main:main"
|
||||
|
||||
0
rbox/__init__.py
Normal file
0
rbox/__init__.py
Normal file
17
rbox/activity.py
Normal file
17
rbox/activity.py
Normal file
@ -0,0 +1,17 @@
|
||||
from typing import Optional
|
||||
from .models import Activity, User
|
||||
|
||||
async def log_activity(
|
||||
user: Optional[User],
|
||||
action: str,
|
||||
target_type: str,
|
||||
target_id: int,
|
||||
ip_address: Optional[str] = None
|
||||
):
|
||||
await Activity.create(
|
||||
user=user,
|
||||
action=action,
|
||||
target_type=target_type,
|
||||
target_id=target_id,
|
||||
ip_address=ip_address
|
||||
)
|
||||
59
rbox/auth.py
Normal file
59
rbox/auth.py
Normal file
@ -0,0 +1,59 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from jose import JWTError, jwt
|
||||
import bcrypt
|
||||
|
||||
from .schemas import TokenData
|
||||
from .settings import settings
|
||||
from .models import User
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
|
||||
|
||||
def verify_password(plain_password, hashed_password):
|
||||
password_bytes = plain_password[:72].encode('utf-8')
|
||||
hashed_bytes = hashed_password.encode('utf-8') if isinstance(hashed_password, str) else hashed_password
|
||||
return bcrypt.checkpw(password_bytes, hashed_bytes)
|
||||
|
||||
def get_password_hash(password):
|
||||
password_bytes = password[:72].encode('utf-8')
|
||||
return bcrypt.hashpw(password_bytes, bcrypt.gensalt()).decode('utf-8')
|
||||
|
||||
async def authenticate_user(username: str, password: str):
|
||||
user = await User.get_or_none(username=username)
|
||||
if not user:
|
||||
return None
|
||||
if not verify_password(password, user.hashed_password):
|
||||
return None
|
||||
return user
|
||||
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
|
||||
to_encode = data.copy()
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
to_encode.update({"exp": expire})
|
||||
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
async def get_current_user(token: str = Depends(oauth2_scheme)):
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
try:
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||
username: str = payload.get("sub")
|
||||
if username is None:
|
||||
raise credentials_exception
|
||||
token_data = TokenData(username=username)
|
||||
except JWTError:
|
||||
raise credentials_exception
|
||||
user = await User.get_or_none(username=token_data.username)
|
||||
if user is None:
|
||||
raise credentials_exception
|
||||
return user
|
||||
59
rbox/main.py
Normal file
59
rbox/main.py
Normal file
@ -0,0 +1,59 @@
|
||||
import argparse
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import HTMLResponse # Import HTMLResponse
|
||||
from tortoise.contrib.fastapi import register_tortoise
|
||||
from .settings import settings
|
||||
from .routers import auth, users, folders, files, shares, search
|
||||
from . import webdav
|
||||
|
||||
app = FastAPI(
|
||||
title="RBox Cloud Storage",
|
||||
description="A self-hosted cloud storage web application",
|
||||
version="0.1.0",
|
||||
)
|
||||
|
||||
app.include_router(auth.router)
|
||||
app.include_router(users.router)
|
||||
app.include_router(folders.router)
|
||||
app.include_router(files.router)
|
||||
app.include_router(shares.router)
|
||||
app.include_router(search.router)
|
||||
app.include_router(webdav.router)
|
||||
|
||||
# Mount static files
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
|
||||
register_tortoise(
|
||||
app,
|
||||
db_url=settings.DATABASE_URL,
|
||||
modules={"models": ["rbox.models"]},
|
||||
generate_schemas=True,
|
||||
add_exception_handlers=True,
|
||||
)
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
print("Starting up...")
|
||||
print("Database connected.")
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def shutdown_event():
|
||||
print("Shutting down...")
|
||||
|
||||
@app.get("/", response_class=HTMLResponse) # Change response_class to HTMLResponse
|
||||
async def read_root():
|
||||
with open("static/index.html", "r") as f:
|
||||
return f.read()
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Run the RBox application.")
|
||||
parser.add_argument("--host", type=str, default="0.0.0.0", help="Host address to bind to")
|
||||
parser.add_argument("--port", type=int, default=8000, help="Port to listen on")
|
||||
args = parser.parse_args()
|
||||
|
||||
uvicorn.run(app, host=args.host, port=args.port)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
137
rbox/models.py
Normal file
137
rbox/models.py
Normal file
@ -0,0 +1,137 @@
|
||||
from tortoise import fields, models
|
||||
from tortoise.contrib.pydantic import pydantic_model_creator
|
||||
from datetime import datetime
|
||||
|
||||
class User(models.Model):
|
||||
id = fields.IntField(pk=True)
|
||||
username = fields.CharField(max_length=20, unique=True)
|
||||
email = fields.CharField(max_length=255, unique=True)
|
||||
hashed_password = fields.CharField(max_length=255)
|
||||
is_active = fields.BooleanField(default=True)
|
||||
is_superuser = fields.BooleanField(default=False)
|
||||
created_at = fields.DatetimeField(auto_now_add=True)
|
||||
updated_at = fields.DatetimeField(auto_now=True)
|
||||
storage_quota_bytes = fields.BigIntField(default=10 * 1024 * 1024 * 1024) # 10 GB default
|
||||
used_storage_bytes = fields.BigIntField(default=0)
|
||||
plan_type = fields.CharField(max_length=50, default="free")
|
||||
two_factor_secret = fields.CharField(max_length=255, null=True)
|
||||
|
||||
class Meta:
|
||||
table = "users"
|
||||
|
||||
def __str__(self):
|
||||
return self.username
|
||||
|
||||
class Folder(models.Model):
|
||||
id = fields.IntField(pk=True)
|
||||
name = fields.CharField(max_length=255)
|
||||
parent: fields.ForeignKeyRelation["Folder"] = fields.ForeignKeyField("models.Folder", related_name="children", null=True)
|
||||
owner: fields.ForeignKeyRelation[User] = fields.ForeignKeyField("models.User", related_name="folders")
|
||||
created_at = fields.DatetimeField(auto_now_add=True)
|
||||
updated_at = fields.DatetimeField(auto_now=True)
|
||||
is_deleted = fields.BooleanField(default=False)
|
||||
|
||||
class Meta:
|
||||
table = "folders"
|
||||
unique_together = (("name", "parent", "owner"),) # Ensure unique folder names within a parent for an owner
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
class File(models.Model):
|
||||
id = fields.IntField(pk=True)
|
||||
name = fields.CharField(max_length=255)
|
||||
path = fields.CharField(max_length=1024) # Internal storage path
|
||||
size = fields.BigIntField()
|
||||
mime_type = fields.CharField(max_length=255)
|
||||
file_hash = fields.CharField(max_length=64, null=True) # SHA-256
|
||||
thumbnail_path = fields.CharField(max_length=1024, null=True)
|
||||
parent: fields.ForeignKeyRelation[Folder] = fields.ForeignKeyField("models.Folder", related_name="files", null=True)
|
||||
owner: fields.ForeignKeyRelation[User] = fields.ForeignKeyField("models.User", related_name="files")
|
||||
created_at = fields.DatetimeField(auto_now_add=True)
|
||||
updated_at = fields.DatetimeField(auto_now=True)
|
||||
is_deleted = fields.BooleanField(default=False)
|
||||
deleted_at = fields.DatetimeField(null=True)
|
||||
|
||||
class Meta:
|
||||
table = "files"
|
||||
unique_together = (("name", "parent", "owner"),) # Ensure unique file names within a parent for an owner
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
class FileVersion(models.Model):
|
||||
id = fields.IntField(pk=True)
|
||||
file: fields.ForeignKeyRelation[File] = fields.ForeignKeyField("models.File", related_name="versions")
|
||||
version_path = fields.CharField(max_length=1024)
|
||||
size = fields.BigIntField()
|
||||
created_at = fields.DatetimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
table = "file_versions"
|
||||
|
||||
class Share(models.Model):
|
||||
id = fields.IntField(pk=True)
|
||||
token = fields.CharField(max_length=64, unique=True)
|
||||
file: fields.ForeignKeyRelation[File] = fields.ForeignKeyField("models.File", related_name="shares", null=True)
|
||||
folder: fields.ForeignKeyRelation[Folder] = fields.ForeignKeyField("models.Folder", related_name="shares", null=True)
|
||||
owner: fields.ForeignKeyRelation[User] = fields.ForeignKeyField("models.User", related_name="shares")
|
||||
created_at = fields.DatetimeField(auto_now_add=True)
|
||||
expires_at = fields.DatetimeField(null=True)
|
||||
password_protected = fields.BooleanField(default=False)
|
||||
hashed_password = fields.CharField(max_length=255, null=True)
|
||||
access_count = fields.IntField(default=0)
|
||||
permission_level = fields.CharField(max_length=50, default="viewer") # viewer, uploader, editor
|
||||
|
||||
class Meta:
|
||||
table = "shares"
|
||||
|
||||
class Team(models.Model):
|
||||
id = fields.IntField(pk=True)
|
||||
name = fields.CharField(max_length=255, unique=True)
|
||||
owner: fields.ForeignKeyRelation[User] = fields.ForeignKeyField("models.User", related_name="owned_teams")
|
||||
members: fields.ManyToManyRelation[User] = fields.ManyToManyField("models.User", related_name="teams", through="team_members")
|
||||
created_at = fields.DatetimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
table = "teams"
|
||||
|
||||
class TeamMember(models.Model):
|
||||
id = fields.IntField(pk=True)
|
||||
team: fields.ForeignKeyRelation[Team] = fields.ForeignKeyField("models.Team", related_name="team_members")
|
||||
user: fields.ForeignKeyRelation[User] = fields.ForeignKeyField("models.User", related_name="user_teams")
|
||||
role = fields.CharField(max_length=50, default="member") # owner, admin, member
|
||||
|
||||
class Meta:
|
||||
table = "team_members"
|
||||
unique_together = (("team", "user"),)
|
||||
|
||||
class Activity(models.Model):
|
||||
id = fields.IntField(pk=True)
|
||||
user: fields.ForeignKeyRelation[User] = fields.ForeignKeyField("models.User", related_name="activities", null=True)
|
||||
action = fields.CharField(max_length=255)
|
||||
target_type = fields.CharField(max_length=50) # file, folder, share, user, team
|
||||
target_id = fields.IntField()
|
||||
ip_address = fields.CharField(max_length=45, null=True)
|
||||
timestamp = fields.DatetimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
table = "activities"
|
||||
|
||||
class FileRequest(models.Model):
|
||||
id = fields.IntField(pk=True)
|
||||
title = fields.CharField(max_length=255)
|
||||
description = fields.TextField(null=True)
|
||||
token = fields.CharField(max_length=64, unique=True)
|
||||
owner: fields.ForeignKeyRelation[User] = fields.ForeignKeyField("models.User", related_name="file_requests")
|
||||
target_folder: fields.ForeignKeyRelation[Folder] = fields.ForeignKeyField("models.Folder", related_name="file_requests")
|
||||
created_at = fields.DatetimeField(auto_now_add=True)
|
||||
expires_at = fields.DatetimeField(null=True)
|
||||
is_active = fields.BooleanField(default=True)
|
||||
# TODO: Add fields for custom form configuration
|
||||
|
||||
class Meta:
|
||||
table = "file_requests"
|
||||
|
||||
User_Pydantic = pydantic_model_creator(User, name="User_Pydantic")
|
||||
UserIn_Pydantic = pydantic_model_creator(User, name="UserIn_Pydantic", exclude_readonly=True)
|
||||
56
rbox/routers/auth.py
Normal file
56
rbox/routers/auth.py
Normal file
@ -0,0 +1,56 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
|
||||
from ..auth import authenticate_user, create_access_token, get_password_hash
|
||||
from ..models import User
|
||||
from ..schemas import Token, UserCreate
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/auth",
|
||||
tags=["auth"],
|
||||
)
|
||||
|
||||
@router.post("/register", response_model=Token)
|
||||
async def register_user(user_in: UserCreate):
|
||||
user = await User.get_or_none(username=user_in.username)
|
||||
if user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Username already registered",
|
||||
)
|
||||
user = await User.get_or_none(email=user_in.email)
|
||||
if user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Email already registered",
|
||||
)
|
||||
|
||||
hashed_password = get_password_hash(user_in.password)
|
||||
user = await User.create(
|
||||
username=user_in.username,
|
||||
email=user_in.email,
|
||||
hashed_password=hashed_password,
|
||||
)
|
||||
|
||||
access_token_expires = timedelta(minutes=30) # Use settings
|
||||
access_token = create_access_token(
|
||||
data={"sub": user.username}, expires_delta=access_token_expires
|
||||
)
|
||||
return {"access_token": access_token, "token_type": "bearer"}
|
||||
|
||||
@router.post("/token", response_model=Token)
|
||||
async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends()):
|
||||
user = await authenticate_user(form_data.username, form_data.password)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect username or password",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
access_token_expires = timedelta(minutes=30) # Use settings
|
||||
access_token = create_access_token(
|
||||
data={"sub": user.username}, expires_delta=access_token_expires
|
||||
)
|
||||
return {"access_token": access_token, "token_type": "bearer"}
|
||||
259
rbox/routers/files.py
Normal file
259
rbox/routers/files.py
Normal file
@ -0,0 +1,259 @@
|
||||
from fastapi import APIRouter, Depends, UploadFile, File as FastAPIFile, HTTPException, status, Response
|
||||
from fastapi.responses import StreamingResponse
|
||||
from typing import List, Optional
|
||||
import mimetypes
|
||||
import hashlib
|
||||
import os
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..auth import get_current_user
|
||||
from ..models import User, File, Folder
|
||||
from ..schemas import FileOut
|
||||
from ..storage import storage_manager
|
||||
from ..settings import settings
|
||||
from ..activity import log_activity
|
||||
from ..thumbnails import generate_thumbnail, delete_thumbnail
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/files",
|
||||
tags=["files"],
|
||||
)
|
||||
|
||||
class FileMove(BaseModel):
|
||||
target_folder_id: Optional[int] = None
|
||||
|
||||
class FileRename(BaseModel):
|
||||
new_name: str
|
||||
|
||||
class FileCopy(BaseModel):
|
||||
target_folder_id: Optional[int] = None
|
||||
|
||||
@router.post("/upload/{folder_id}", response_model=FileOut, status_code=status.HTTP_201_CREATED)
|
||||
@router.post("/upload", response_model=FileOut, status_code=status.HTTP_201_CREATED)
|
||||
async def upload_file(
|
||||
file: UploadFile = FastAPIFile(...),
|
||||
folder_id: Optional[int] = None,
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
if folder_id:
|
||||
parent_folder = await Folder.get_or_none(id=folder_id, owner=current_user, is_deleted=False)
|
||||
if not parent_folder:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Folder not found")
|
||||
else:
|
||||
parent_folder = None
|
||||
|
||||
existing_file = await File.get_or_none(
|
||||
name=file.filename, parent=parent_folder, owner=current_user, is_deleted=False
|
||||
)
|
||||
if existing_file:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="File with this name already exists in the current folder",
|
||||
)
|
||||
|
||||
file_content = await file.read()
|
||||
file_size = len(file_content)
|
||||
file_hash = hashlib.sha256(file_content).hexdigest()
|
||||
|
||||
if current_user.used_storage_bytes + file_size > current_user.storage_quota_bytes:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_507_INSUFFICIENT_STORAGE,
|
||||
detail="Storage quota exceeded",
|
||||
)
|
||||
|
||||
# Generate a unique path for storage
|
||||
file_extension = os.path.splitext(file.filename)[1]
|
||||
unique_filename = f"{file_hash}{file_extension}" # Use hash for unique filename
|
||||
storage_path = unique_filename
|
||||
|
||||
# Save file to storage
|
||||
await storage_manager.save_file(current_user.id, storage_path, file_content)
|
||||
|
||||
# Get mime type
|
||||
mime_type, _ = mimetypes.guess_type(file.filename)
|
||||
if not mime_type:
|
||||
mime_type = "application/octet-stream"
|
||||
|
||||
# Create file entry in database
|
||||
db_file = await File.create(
|
||||
name=file.filename,
|
||||
path=storage_path,
|
||||
size=file_size,
|
||||
mime_type=mime_type,
|
||||
file_hash=file_hash,
|
||||
owner=current_user,
|
||||
parent=parent_folder,
|
||||
)
|
||||
|
||||
current_user.used_storage_bytes += file_size
|
||||
await current_user.save()
|
||||
|
||||
thumbnail_path = await generate_thumbnail(storage_path, mime_type, current_user.id)
|
||||
if thumbnail_path:
|
||||
db_file.thumbnail_path = thumbnail_path
|
||||
await db_file.save()
|
||||
|
||||
return await FileOut.from_tortoise_orm(db_file)
|
||||
|
||||
@router.get("/download/{file_id}")
|
||||
async def download_file(file_id: int, current_user: User = Depends(get_current_user)):
|
||||
db_file = await File.get_or_none(id=file_id, owner=current_user, is_deleted=False)
|
||||
if not db_file:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
||||
|
||||
try:
|
||||
# file_content_generator = storage_manager.get_file(current_user.id, db_file.path)
|
||||
|
||||
# FastAPI's StreamingResponse expects an async generator
|
||||
async def file_iterator():
|
||||
async for chunk in storage_manager.get_file(current_user.id, db_file.path):
|
||||
yield chunk
|
||||
|
||||
return StreamingResponse(
|
||||
file_iterator(),
|
||||
media_type=db_file.mime_type,
|
||||
headers={"Content-Disposition": f"attachment; filename=\"{db_file.name}\""}
|
||||
)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found in storage")
|
||||
|
||||
@router.delete("/{file_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_file(file_id: int, current_user: User = Depends(get_current_user)):
|
||||
db_file = await File.get_or_none(id=file_id, owner=current_user, is_deleted=False)
|
||||
if not db_file:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
||||
|
||||
db_file.is_deleted = True
|
||||
db_file.deleted_at = datetime.now()
|
||||
await db_file.save()
|
||||
|
||||
return
|
||||
|
||||
@router.post("/{file_id}/move", response_model=FileOut)
|
||||
async def move_file(file_id: int, move_data: FileMove, current_user: User = Depends(get_current_user)):
|
||||
db_file = await File.get_or_none(id=file_id, owner=current_user, is_deleted=False)
|
||||
if not db_file:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
||||
|
||||
target_folder = None
|
||||
if move_data.target_folder_id:
|
||||
target_folder = await Folder.get_or_none(id=move_data.target_folder_id, owner=current_user, is_deleted=False)
|
||||
if not target_folder:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Target folder not found")
|
||||
|
||||
existing_file = await File.get_or_none(
|
||||
name=db_file.name, parent=target_folder, owner=current_user, is_deleted=False
|
||||
)
|
||||
if existing_file and existing_file.id != file_id:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="File with this name already exists in target folder")
|
||||
|
||||
db_file.parent = target_folder
|
||||
await db_file.save()
|
||||
|
||||
await log_activity(user=current_user, action="file_moved", target_type="file", target_id=file_id)
|
||||
|
||||
return await FileOut.from_tortoise_orm(db_file)
|
||||
|
||||
@router.post("/{file_id}/rename", response_model=FileOut)
|
||||
async def rename_file(file_id: int, rename_data: FileRename, current_user: User = Depends(get_current_user)):
|
||||
db_file = await File.get_or_none(id=file_id, owner=current_user, is_deleted=False)
|
||||
if not db_file:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
||||
|
||||
existing_file = await File.get_or_none(
|
||||
name=rename_data.new_name, parent_id=db_file.parent_id, owner=current_user, is_deleted=False
|
||||
)
|
||||
if existing_file and existing_file.id != file_id:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="File with this name already exists in the same folder")
|
||||
|
||||
db_file.name = rename_data.new_name
|
||||
await db_file.save()
|
||||
|
||||
await log_activity(user=current_user, action="file_renamed", target_type="file", target_id=file_id)
|
||||
|
||||
return await FileOut.from_tortoise_orm(db_file)
|
||||
|
||||
@router.post("/{file_id}/copy", response_model=FileOut, status_code=status.HTTP_201_CREATED)
|
||||
async def copy_file(file_id: int, copy_data: FileCopy, current_user: User = Depends(get_current_user)):
|
||||
db_file = await File.get_or_none(id=file_id, owner=current_user, is_deleted=False)
|
||||
if not db_file:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
||||
|
||||
target_folder = None
|
||||
if copy_data.target_folder_id:
|
||||
target_folder = await Folder.get_or_none(id=copy_data.target_folder_id, owner=current_user, is_deleted=False)
|
||||
if not target_folder:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Target folder not found")
|
||||
|
||||
base_name = db_file.name
|
||||
name_parts = os.path.splitext(base_name)
|
||||
counter = 1
|
||||
new_name = base_name
|
||||
|
||||
while await File.get_or_none(name=new_name, parent=target_folder, owner=current_user, is_deleted=False):
|
||||
new_name = f"{name_parts[0]} (copy {counter}){name_parts[1]}"
|
||||
counter += 1
|
||||
|
||||
new_file = await File.create(
|
||||
name=new_name,
|
||||
path=db_file.path,
|
||||
size=db_file.size,
|
||||
mime_type=db_file.mime_type,
|
||||
file_hash=db_file.file_hash,
|
||||
owner=current_user,
|
||||
parent=target_folder
|
||||
)
|
||||
|
||||
await log_activity(user=current_user, action="file_copied", target_type="file", target_id=new_file.id)
|
||||
|
||||
return await FileOut.from_tortoise_orm(new_file)
|
||||
|
||||
@router.get("/", response_model=List[FileOut])
|
||||
async def list_files(folder_id: Optional[int] = None, current_user: User = Depends(get_current_user)):
|
||||
if folder_id:
|
||||
parent_folder = await Folder.get_or_none(id=folder_id, owner=current_user, is_deleted=False)
|
||||
if not parent_folder:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Folder not found")
|
||||
files = await File.filter(parent=parent_folder, owner=current_user, is_deleted=False).order_by("name")
|
||||
else:
|
||||
files = await File.filter(parent=None, owner=current_user, is_deleted=False).order_by("name")
|
||||
return [await FileOut.from_tortoise_orm(f) for f in files]
|
||||
|
||||
@router.get("/thumbnail/{file_id}")
|
||||
async def get_thumbnail(file_id: int, current_user: User = Depends(get_current_user)):
|
||||
db_file = await File.get_or_none(id=file_id, owner=current_user, is_deleted=False)
|
||||
if not db_file:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
||||
|
||||
thumbnail_path = getattr(db_file, 'thumbnail_path', None)
|
||||
|
||||
if not thumbnail_path:
|
||||
thumbnail_path = await generate_thumbnail(db_file.path, db_file.mime_type, current_user.id)
|
||||
|
||||
if thumbnail_path:
|
||||
db_file.thumbnail_path = thumbnail_path
|
||||
await db_file.save()
|
||||
else:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Thumbnail not available")
|
||||
|
||||
try:
|
||||
async def thumbnail_iterator():
|
||||
async for chunk in storage_manager.get_file(current_user.id, thumbnail_path):
|
||||
yield chunk
|
||||
|
||||
return StreamingResponse(
|
||||
thumbnail_iterator(),
|
||||
media_type="image/jpeg"
|
||||
)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Thumbnail not found in storage")
|
||||
|
||||
@router.get("/photos", response_model=List[FileOut])
|
||||
async def list_photos(current_user: User = Depends(get_current_user)):
|
||||
files = await File.filter(
|
||||
owner=current_user,
|
||||
is_deleted=False,
|
||||
mime_type__istartswith="image/"
|
||||
).order_by("-created_at")
|
||||
return [await FileOut.from_tortoise_orm(f) for f in files]
|
||||
105
rbox/routers/folders.py
Normal file
105
rbox/routers/folders.py
Normal file
@ -0,0 +1,105 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from typing import List, Optional
|
||||
|
||||
from ..auth import get_current_user
|
||||
from ..models import User, Folder
|
||||
from ..schemas import FolderCreate, FolderOut, FolderUpdate
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/folders",
|
||||
tags=["folders"],
|
||||
)
|
||||
|
||||
@router.post("/", response_model=FolderOut, status_code=status.HTTP_201_CREATED)
|
||||
async def create_folder(folder_in: FolderCreate, current_user: User = Depends(get_current_user)):
|
||||
# Check if parent folder exists and belongs to the current user
|
||||
parent_folder = None
|
||||
if folder_in.parent_id:
|
||||
parent_folder = await Folder.get_or_none(id=folder_in.parent_id, owner=current_user)
|
||||
if not parent_folder:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Parent folder not found or does not belong to the current user",
|
||||
)
|
||||
|
||||
# Check for duplicate folder name in the same parent
|
||||
existing_folder = await Folder.get_or_none(
|
||||
name=folder_in.name, parent=parent_folder, owner=current_user
|
||||
)
|
||||
if existing_folder:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Folder with this name already exists in the current parent folder",
|
||||
)
|
||||
|
||||
folder = await Folder.create(
|
||||
name=folder_in.name, parent=parent_folder, owner=current_user
|
||||
)
|
||||
return await FolderOut.from_tortoise_orm(folder)
|
||||
|
||||
@router.get("/{folder_id}", response_model=FolderOut)
|
||||
async def get_folder(folder_id: int, current_user: User = Depends(get_current_user)):
|
||||
folder = await Folder.get_or_none(id=folder_id, owner=current_user, is_deleted=False)
|
||||
if not folder:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Folder not found")
|
||||
return await FolderOut.from_tortoise_orm(folder)
|
||||
|
||||
@router.get("/", response_model=List[FolderOut])
|
||||
async def list_folders(parent_id: Optional[int] = None, current_user: User = Depends(get_current_user)):
|
||||
if parent_id:
|
||||
parent_folder = await Folder.get_or_none(id=parent_id, owner=current_user, is_deleted=False)
|
||||
if not parent_folder:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Parent folder not found or does not belong to the current user",
|
||||
)
|
||||
folders = await Folder.filter(parent=parent_folder, owner=current_user, is_deleted=False).order_by("name")
|
||||
else:
|
||||
# List root folders (folders with no parent)
|
||||
folders = await Folder.filter(parent=None, owner=current_user, is_deleted=False).order_by("name")
|
||||
return [await FolderOut.from_tortoise_orm(folder) for folder in folders]
|
||||
|
||||
@router.put("/{folder_id}", response_model=FolderOut)
|
||||
async def update_folder(folder_id: int, folder_in: FolderUpdate, current_user: User = Depends(get_current_user)):
|
||||
folder = await Folder.get_or_none(id=folder_id, owner=current_user, is_deleted=False)
|
||||
if not folder:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Folder not found")
|
||||
|
||||
if folder_in.name:
|
||||
existing_folder = await Folder.get_or_none(
|
||||
name=folder_in.name, parent_id=folder.parent_id, owner=current_user
|
||||
)
|
||||
if existing_folder and existing_folder.id != folder_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Folder with this name already exists in the current parent folder",
|
||||
)
|
||||
folder.name = folder_in.name
|
||||
|
||||
if folder_in.parent_id is not None:
|
||||
if folder_in.parent_id == folder_id:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot set folder as its own parent")
|
||||
|
||||
new_parent_folder = None
|
||||
if folder_in.parent_id != 0: # 0 could represent moving to root
|
||||
new_parent_folder = await Folder.get_or_none(id=folder_in.parent_id, owner=current_user, is_deleted=False)
|
||||
if not new_parent_folder:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="New parent folder not found or does not belong to the current user",
|
||||
)
|
||||
folder.parent = new_parent_folder
|
||||
|
||||
await folder.save()
|
||||
return await FolderOut.from_tortoise_orm(folder)
|
||||
|
||||
@router.delete("/{folder_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_folder(folder_id: int, current_user: User = Depends(get_current_user)):
|
||||
folder = await Folder.get_or_none(id=folder_id, owner=current_user, is_deleted=False)
|
||||
if not folder:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Folder not found")
|
||||
|
||||
# Soft delete
|
||||
folder.is_deleted = True
|
||||
await folder.save()
|
||||
return
|
||||
55
rbox/routers/search.py
Normal file
55
rbox/routers/search.py
Normal file
@ -0,0 +1,55 @@
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
|
||||
from ..auth import get_current_user
|
||||
from ..models import User, File, Folder
|
||||
from ..schemas import FileOut, FolderOut
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/search",
|
||||
tags=["search"],
|
||||
)
|
||||
|
||||
@router.get("/files", response_model=List[FileOut])
|
||||
async def search_files(
|
||||
q: str = Query(..., min_length=1, description="Search query"),
|
||||
file_type: Optional[str] = Query(None, description="Filter by MIME type prefix (e.g., 'image', 'video')"),
|
||||
min_size: Optional[int] = Query(None, description="Minimum file size in bytes"),
|
||||
max_size: Optional[int] = Query(None, description="Maximum file size in bytes"),
|
||||
date_from: Optional[datetime] = Query(None, description="Filter files created after this date"),
|
||||
date_to: Optional[datetime] = Query(None, description="Filter files created before this date"),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
query = File.filter(owner=current_user, is_deleted=False, name__icontains=q)
|
||||
|
||||
if file_type:
|
||||
query = query.filter(mime_type__istartswith=file_type)
|
||||
|
||||
if min_size is not None:
|
||||
query = query.filter(size__gte=min_size)
|
||||
|
||||
if max_size is not None:
|
||||
query = query.filter(size__lte=max_size)
|
||||
|
||||
if date_from:
|
||||
query = query.filter(created_at__gte=date_from)
|
||||
|
||||
if date_to:
|
||||
query = query.filter(created_at__lte=date_to)
|
||||
|
||||
files = await query.order_by("-created_at").limit(100)
|
||||
return [await FileOut.from_tortoise_orm(f) for f in files]
|
||||
|
||||
@router.get("/folders", response_model=List[FolderOut])
|
||||
async def search_folders(
|
||||
q: str = Query(..., min_length=1, description="Search query"),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
folders = await Folder.filter(
|
||||
owner=current_user,
|
||||
is_deleted=False,
|
||||
name__icontains=q
|
||||
).order_by("-created_at").limit(100)
|
||||
|
||||
return [await FolderOut.from_tortoise_orm(folder) for folder in folders]
|
||||
94
rbox/routers/shares.py
Normal file
94
rbox/routers/shares.py
Normal file
@ -0,0 +1,94 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from typing import Optional
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from ..auth import get_current_user
|
||||
from ..models import User, File, Folder, Share
|
||||
from ..schemas import ShareCreate, ShareOut
|
||||
from ..auth import get_password_hash, verify_password
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/shares",
|
||||
tags=["shares"],
|
||||
)
|
||||
|
||||
@router.post("/", response_model=ShareOut, status_code=status.HTTP_201_CREATED)
|
||||
async def create_share_link(share_in: ShareCreate, current_user: User = Depends(get_current_user)):
|
||||
if not share_in.file_id and not share_in.folder_id:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Either file_id or folder_id must be provided")
|
||||
if share_in.file_id and share_in.folder_id:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot share both a file and a folder simultaneously")
|
||||
|
||||
file = None
|
||||
folder = None
|
||||
|
||||
if share_in.file_id:
|
||||
file = await File.get_or_none(id=share_in.file_id, owner=current_user, is_deleted=False)
|
||||
if not file:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found or does not belong to you")
|
||||
|
||||
if share_in.folder_id:
|
||||
folder = await Folder.get_or_none(id=share_in.folder_id, owner=current_user, is_deleted=False)
|
||||
if not folder:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Folder not found or does not belong to you")
|
||||
|
||||
token = secrets.token_urlsafe(16)
|
||||
hashed_password = None
|
||||
password_protected = False
|
||||
if share_in.password:
|
||||
hashed_password = get_password_hash(share_in.password)
|
||||
password_protected = True
|
||||
|
||||
share = await Share.create(
|
||||
token=token,
|
||||
file=file,
|
||||
folder=folder,
|
||||
owner=current_user,
|
||||
expires_at=share_in.expires_at,
|
||||
password_protected=password_protected,
|
||||
hashed_password=hashed_password,
|
||||
permission_level=share_in.permission_level,
|
||||
)
|
||||
return await ShareOut.from_tortoise_orm(share)
|
||||
|
||||
@router.get("/{share_token}", response_model=ShareOut)
|
||||
async def get_share_link_info(share_token: str):
|
||||
share = await Share.get_or_none(token=share_token)
|
||||
if not share:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Share link not found")
|
||||
|
||||
if share.expires_at and share.expires_at < datetime.utcnow():
|
||||
raise HTTPException(status_code=status.HTTP_410_GONE, detail="Share link has expired")
|
||||
|
||||
# Increment access count
|
||||
share.access_count += 1
|
||||
await share.save()
|
||||
|
||||
return await ShareOut.from_tortoise_orm(share)
|
||||
|
||||
@router.post("/{share_token}/access")
|
||||
async def access_shared_content(share_token: str, password: Optional[str] = None):
|
||||
share = await Share.get_or_none(token=share_token)
|
||||
if not share:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Share link not found")
|
||||
|
||||
if share.expires_at and share.expires_at < datetime.utcnow():
|
||||
raise HTTPException(status_code=status.HTTP_410_GONE, detail="Share link has expired")
|
||||
|
||||
if share.password_protected:
|
||||
if not password or not verify_password(password, share.hashed_password):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect password")
|
||||
|
||||
# TODO: Return actual content or a link to it based on permission_level
|
||||
# For now, just indicate successful access
|
||||
return {"message": "Access granted", "permission_level": share.permission_level}
|
||||
|
||||
@router.delete("/{share_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_share_link(share_id: int, current_user: User = Depends(get_current_user)):
|
||||
share = await Share.get_or_none(id=share_id, owner=current_user)
|
||||
if not share:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Share link not found or does not belong to you")
|
||||
|
||||
await share.delete()
|
||||
return
|
||||
12
rbox/routers/users.py
Normal file
12
rbox/routers/users.py
Normal file
@ -0,0 +1,12 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from ..auth import get_current_user
|
||||
from ..models import User_Pydantic, User
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/users",
|
||||
tags=["users"],
|
||||
)
|
||||
|
||||
@router.get("/me", response_model=User_Pydantic)
|
||||
async def read_users_me(current_user: User = Depends(get_current_user)):
|
||||
return await User_Pydantic.from_tortoise_orm(current_user)
|
||||
86
rbox/schemas.py
Normal file
86
rbox/schemas.py
Normal file
@ -0,0 +1,86 @@
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from typing import Optional, List
|
||||
from tortoise.contrib.pydantic import pydantic_model_creator
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
username: str
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
class UserLogin(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
token_type: str
|
||||
|
||||
class TokenData(BaseModel):
|
||||
username: str | None = None
|
||||
|
||||
class FolderCreate(BaseModel):
|
||||
name: str
|
||||
parent_id: Optional[int] = None
|
||||
|
||||
class FolderUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
parent_id: Optional[int] = None
|
||||
|
||||
class ShareCreate(BaseModel):
|
||||
file_id: Optional[int] = None
|
||||
folder_id: Optional[int] = None
|
||||
expires_at: Optional[datetime] = None
|
||||
password: Optional[str] = None
|
||||
permission_level: str = "viewer"
|
||||
|
||||
class TeamCreate(BaseModel):
|
||||
name: str
|
||||
|
||||
class TeamOut(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
owner_id: int
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
class ActivityOut(BaseModel):
|
||||
id: int
|
||||
user_id: Optional[int] = None
|
||||
action: str
|
||||
target_type: str
|
||||
target_id: int
|
||||
ip_address: Optional[str] = None
|
||||
timestamp: datetime
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
class FileRequestCreate(BaseModel):
|
||||
title: str
|
||||
description: Optional[str] = None
|
||||
target_folder_id: int
|
||||
expires_at: Optional[datetime] = None
|
||||
|
||||
class FileRequestOut(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
description: Optional[str] = None
|
||||
token: str
|
||||
owner_id: int
|
||||
target_folder_id: int
|
||||
created_at: datetime
|
||||
expires_at: Optional[datetime] = None
|
||||
is_active: bool
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
from rbox.models import Folder, File, Share, FileVersion
|
||||
|
||||
FolderOut = pydantic_model_creator(Folder, name="FolderOut")
|
||||
FileOut = pydantic_model_creator(File, name="FileOut")
|
||||
ShareOut = pydantic_model_creator(Share, name="ShareOut")
|
||||
FileVersionOut = pydantic_model_creator(FileVersion, name="FileVersionOut")
|
||||
28
rbox/settings.py
Normal file
28
rbox/settings.py
Normal file
@ -0,0 +1,28 @@
|
||||
import os
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file='.env', extra='ignore')
|
||||
|
||||
DATABASE_URL: str = "sqlite:///app/rbox.db"
|
||||
#DATABASE_URL: str = "postgres://rbox_user:rbox_password@db:5432/rbox_db"
|
||||
REDIS_URL: str = "redis://redis:6379/0"
|
||||
SECRET_KEY: str = "super_secret_key" # CHANGE THIS IN PRODUCTION
|
||||
ALGORITHM: str = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
|
||||
DOMAIN_NAME: str = "localhost"
|
||||
CERTBOT_EMAIL: str = "admin@example.com"
|
||||
STORAGE_PATH: str = "storage" # Path for local file storage
|
||||
S3_ACCESS_KEY_ID: str | None = None
|
||||
S3_SECRET_ACCESS_KEY: str | None = None
|
||||
S3_ENDPOINT_URL: str | None = None
|
||||
S3_BUCKET_NAME: str = "rbox-storage"
|
||||
SMTP_SERVER: str | None = None
|
||||
SMTP_PORT: int = 557
|
||||
SMTP_USERNAME: str | None = None
|
||||
SMTP_PASSWORD: str | None = None
|
||||
SMTP_FROM_EMAIL: str = "no-reply@example.com"
|
||||
TOTP_ISSUER: str = "RBox"
|
||||
|
||||
settings = Settings()
|
||||
45
rbox/storage.py
Normal file
45
rbox/storage.py
Normal file
@ -0,0 +1,45 @@
|
||||
import os
|
||||
import aiofiles
|
||||
from pathlib import Path
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from .settings import settings
|
||||
|
||||
class StorageManager:
|
||||
def __init__(self, base_path: str = settings.STORAGE_PATH):
|
||||
self.base_path = Path(base_path)
|
||||
self.base_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
async def _get_full_path(self, user_id: int, file_path: str) -> Path:
|
||||
# Ensure file_path is relative and safe
|
||||
relative_path = Path(file_path).relative_to('/') if str(file_path).startswith('/') else Path(file_path)
|
||||
full_path = self.base_path / str(user_id) / relative_path
|
||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
return full_path
|
||||
|
||||
async def save_file(self, user_id: int, file_path: str, file_content: bytes):
|
||||
full_path = await self._get_full_path(user_id, file_path)
|
||||
async with aiofiles.open(full_path, "wb") as f:
|
||||
await f.write(file_content)
|
||||
return str(full_path)
|
||||
|
||||
async def get_file(self, user_id: int, file_path: str) -> AsyncGenerator:
|
||||
full_path = await self._get_full_path(user_id, file_path)
|
||||
if not full_path.exists():
|
||||
raise FileNotFoundError(f"File not found: {file_path}")
|
||||
|
||||
async with aiofiles.open(full_path, "rb") as f:
|
||||
while chunk := await f.read(8192):
|
||||
yield chunk
|
||||
|
||||
async def delete_file(self, user_id: int, file_path: str):
|
||||
full_path = await self._get_full_path(user_id, file_path)
|
||||
if full_path.exists():
|
||||
os.remove(full_path)
|
||||
# TODO: Clean up empty directories
|
||||
|
||||
async def file_exists(self, user_id: int, file_path: str) -> bool:
|
||||
full_path = await self._get_full_path(user_id, file_path)
|
||||
return full_path.exists()
|
||||
|
||||
storage_manager = StorageManager()
|
||||
94
rbox/thumbnails.py
Normal file
94
rbox/thumbnails.py
Normal file
@ -0,0 +1,94 @@
|
||||
import os
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from PIL import Image
|
||||
import subprocess
|
||||
from typing import Optional
|
||||
from .settings import settings
|
||||
|
||||
THUMBNAIL_SIZE = (300, 300)
|
||||
THUMBNAIL_DIR = "thumbnails"
|
||||
|
||||
async def generate_thumbnail(file_path: str, mime_type: str, user_id: int) -> Optional[str]:
|
||||
try:
|
||||
if mime_type.startswith("image/"):
|
||||
return await generate_image_thumbnail(file_path, user_id)
|
||||
elif mime_type.startswith("video/"):
|
||||
return await generate_video_thumbnail(file_path, user_id)
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"Error generating thumbnail for {file_path}: {e}")
|
||||
return None
|
||||
|
||||
async def generate_image_thumbnail(file_path: str, user_id: int) -> Optional[str]:
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
def _generate():
|
||||
base_path = Path(settings.STORAGE_PATH)
|
||||
thumbnail_dir = base_path / str(user_id) / THUMBNAIL_DIR
|
||||
thumbnail_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
file_name = Path(file_path).name
|
||||
thumbnail_name = f"thumb_{file_name}"
|
||||
if not thumbnail_name.lower().endswith(('.jpg', '.jpeg', '.png')):
|
||||
thumbnail_name += ".jpg"
|
||||
|
||||
thumbnail_path = thumbnail_dir / thumbnail_name
|
||||
|
||||
actual_file_path = base_path / str(user_id) / file_path if not Path(file_path).is_absolute() else Path(file_path)
|
||||
|
||||
with Image.open(actual_file_path) as img:
|
||||
img.thumbnail(THUMBNAIL_SIZE, Image.Resampling.LANCZOS)
|
||||
|
||||
if img.mode in ("RGBA", "LA", "P"):
|
||||
background = Image.new("RGB", img.size, (255, 255, 255))
|
||||
if img.mode == "P":
|
||||
img = img.convert("RGBA")
|
||||
background.paste(img, mask=img.split()[-1] if img.mode in ("RGBA", "LA") else None)
|
||||
img = background
|
||||
|
||||
img.save(str(thumbnail_path), "JPEG", quality=85, optimize=True)
|
||||
|
||||
return str(thumbnail_path.relative_to(base_path / str(user_id)))
|
||||
|
||||
return await loop.run_in_executor(None, _generate)
|
||||
|
||||
async def generate_video_thumbnail(file_path: str, user_id: int) -> Optional[str]:
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
def _generate():
|
||||
base_path = Path(settings.STORAGE_PATH)
|
||||
thumbnail_dir = base_path / str(user_id) / THUMBNAIL_DIR
|
||||
thumbnail_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
file_name = Path(file_path).stem
|
||||
thumbnail_name = f"thumb_{file_name}.jpg"
|
||||
thumbnail_path = thumbnail_dir / thumbnail_name
|
||||
|
||||
actual_file_path = base_path / str(user_id) / file_path if not Path(file_path).is_absolute() else Path(file_path)
|
||||
|
||||
subprocess.run([
|
||||
"ffmpeg",
|
||||
"-i", str(actual_file_path),
|
||||
"-ss", "00:00:01",
|
||||
"-vframes", "1",
|
||||
"-vf", f"scale={THUMBNAIL_SIZE[0]}:{THUMBNAIL_SIZE[1]}:force_original_aspect_ratio=decrease",
|
||||
"-y",
|
||||
str(thumbnail_path)
|
||||
], check=True, capture_output=True)
|
||||
|
||||
return str(thumbnail_path.relative_to(base_path / str(user_id)))
|
||||
|
||||
try:
|
||||
return await loop.run_in_executor(None, _generate)
|
||||
except subprocess.CalledProcessError:
|
||||
return None
|
||||
|
||||
async def delete_thumbnail(thumbnail_path: str, user_id: int):
|
||||
try:
|
||||
base_path = Path(settings.STORAGE_PATH)
|
||||
full_path = base_path / str(user_id) / thumbnail_path
|
||||
if full_path.exists():
|
||||
full_path.unlink()
|
||||
except Exception as e:
|
||||
print(f"Error deleting thumbnail {thumbnail_path}: {e}")
|
||||
654
rbox/webdav.py
Normal file
654
rbox/webdav.py
Normal file
@ -0,0 +1,654 @@
|
||||
from fastapi import APIRouter, Request, Response, Depends, HTTPException, status, Header
|
||||
from typing import Optional
|
||||
from xml.etree import ElementTree as ET
|
||||
from datetime import datetime
|
||||
import hashlib
|
||||
import mimetypes
|
||||
import os
|
||||
import base64
|
||||
from urllib.parse import unquote, urlparse
|
||||
|
||||
from .auth import get_current_user, verify_password
|
||||
from .models import User, File, Folder
|
||||
from .storage import storage_manager
|
||||
from .activity import log_activity
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/webdav",
|
||||
tags=["webdav"],
|
||||
)
|
||||
|
||||
class WebDAVLock:
|
||||
locks = {}
|
||||
|
||||
@classmethod
|
||||
def create_lock(cls, path: str, user_id: int, timeout: int = 3600):
|
||||
lock_token = f"opaquelocktoken:{hashlib.md5(f'{path}{user_id}{datetime.now()}'.encode()).hexdigest()}"
|
||||
cls.locks[path] = {
|
||||
'token': lock_token,
|
||||
'user_id': user_id,
|
||||
'created_at': datetime.now(),
|
||||
'timeout': timeout
|
||||
}
|
||||
return lock_token
|
||||
|
||||
@classmethod
|
||||
def get_lock(cls, path: str):
|
||||
return cls.locks.get(path)
|
||||
|
||||
@classmethod
|
||||
def remove_lock(cls, path: str):
|
||||
if path in cls.locks:
|
||||
del cls.locks[path]
|
||||
|
||||
async def basic_auth(authorization: Optional[str] = Header(None)):
|
||||
if not authorization:
|
||||
return None
|
||||
|
||||
try:
|
||||
scheme, credentials = authorization.split()
|
||||
if scheme.lower() != 'basic':
|
||||
return None
|
||||
|
||||
decoded = base64.b64decode(credentials).decode('utf-8')
|
||||
username, password = decoded.split(':', 1)
|
||||
|
||||
user = await User.get_or_none(username=username)
|
||||
if user and verify_password(password, user.hashed_password):
|
||||
return user
|
||||
except:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
async def webdav_auth(request: Request, authorization: Optional[str] = Header(None)):
|
||||
user = await basic_auth(authorization)
|
||||
if user:
|
||||
return user
|
||||
|
||||
try:
|
||||
user = await get_current_user(request)
|
||||
return user
|
||||
except:
|
||||
pass
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
headers={'WWW-Authenticate': 'Basic realm="RBox WebDAV"'}
|
||||
)
|
||||
|
||||
async def resolve_path(path_str: str, user: User):
|
||||
if not path_str or path_str == '/':
|
||||
return None, None, True
|
||||
|
||||
parts = [p for p in path_str.split('/') if p]
|
||||
|
||||
if not parts:
|
||||
return None, None, True
|
||||
|
||||
current_folder = None
|
||||
for i, part in enumerate(parts[:-1]):
|
||||
folder = await Folder.get_or_none(
|
||||
name=part,
|
||||
parent=current_folder,
|
||||
owner=user,
|
||||
is_deleted=False
|
||||
)
|
||||
if not folder:
|
||||
return None, None, False
|
||||
current_folder = folder
|
||||
|
||||
last_part = parts[-1]
|
||||
|
||||
folder = await Folder.get_or_none(
|
||||
name=last_part,
|
||||
parent=current_folder,
|
||||
owner=user,
|
||||
is_deleted=False
|
||||
)
|
||||
if folder:
|
||||
return folder, current_folder, True
|
||||
|
||||
file = await File.get_or_none(
|
||||
name=last_part,
|
||||
parent=current_folder,
|
||||
owner=user,
|
||||
is_deleted=False
|
||||
)
|
||||
if file:
|
||||
return file, current_folder, True
|
||||
|
||||
return None, current_folder, True
|
||||
|
||||
def build_href(base_path: str, name: str, is_collection: bool):
|
||||
path = f"{base_path.rstrip('/')}/{name}"
|
||||
if is_collection:
|
||||
path += '/'
|
||||
return path
|
||||
|
||||
def create_propstat_element(props: dict, status: str = "HTTP/1.1 200 OK"):
|
||||
propstat = ET.Element("D:propstat")
|
||||
prop = ET.SubElement(propstat, "D:prop")
|
||||
|
||||
for key, value in props.items():
|
||||
if key == "resourcetype":
|
||||
resourcetype = ET.SubElement(prop, "D:resourcetype")
|
||||
if value == "collection":
|
||||
ET.SubElement(resourcetype, "D:collection")
|
||||
elif key == "getcontentlength":
|
||||
elem = ET.SubElement(prop, f"D:{key}")
|
||||
elem.text = str(value)
|
||||
elif key == "getcontenttype":
|
||||
elem = ET.SubElement(prop, f"D:{key}")
|
||||
elem.text = value
|
||||
elif key == "getlastmodified":
|
||||
elem = ET.SubElement(prop, f"D:{key}")
|
||||
elem.text = value.strftime('%a, %d %b %Y %H:%M:%S GMT')
|
||||
elif key == "creationdate":
|
||||
elem = ET.SubElement(prop, f"D:{key}")
|
||||
elem.text = value.isoformat() + 'Z'
|
||||
elif key == "displayname":
|
||||
elem = ET.SubElement(prop, f"D:{key}")
|
||||
elem.text = value
|
||||
elif key == "getetag":
|
||||
elem = ET.SubElement(prop, f"D:{key}")
|
||||
elem.text = value
|
||||
|
||||
status_elem = ET.SubElement(propstat, "D:status")
|
||||
status_elem.text = status
|
||||
|
||||
return propstat
|
||||
|
||||
@router.api_route("/{full_path:path}", methods=["OPTIONS"])
|
||||
async def webdav_options(full_path: str):
|
||||
return Response(
|
||||
status_code=200,
|
||||
headers={
|
||||
"DAV": "1, 2",
|
||||
"Allow": "OPTIONS, GET, HEAD, POST, PUT, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK",
|
||||
"MS-Author-Via": "DAV"
|
||||
}
|
||||
)
|
||||
|
||||
@router.api_route("/{full_path:path}", methods=["PROPFIND"])
|
||||
async def handle_propfind(request: Request, full_path: str, current_user: User = Depends(webdav_auth)):
|
||||
depth = request.headers.get("Depth", "1")
|
||||
full_path = unquote(full_path).strip('/')
|
||||
|
||||
resource, parent, exists = await resolve_path(full_path, current_user)
|
||||
|
||||
if not exists and resource is None:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
|
||||
multistatus = ET.Element("D:multistatus", {"xmlns:D": "DAV:"})
|
||||
|
||||
base_href = f"/webdav/{full_path}" if full_path else "/webdav/"
|
||||
|
||||
if resource is None:
|
||||
response = ET.SubElement(multistatus, "D:response")
|
||||
href = ET.SubElement(response, "D:href")
|
||||
href.text = base_href if base_href.endswith('/') else base_href + '/'
|
||||
|
||||
props = {
|
||||
"resourcetype": "collection",
|
||||
"displayname": full_path.split('/')[-1] if full_path else "Root",
|
||||
"creationdate": datetime.now(),
|
||||
"getlastmodified": datetime.now()
|
||||
}
|
||||
response.append(create_propstat_element(props))
|
||||
|
||||
if depth in ["1", "infinity"]:
|
||||
folders = await Folder.filter(owner=current_user, parent=parent, is_deleted=False)
|
||||
files = await File.filter(owner=current_user, parent=parent, is_deleted=False)
|
||||
|
||||
for folder in folders:
|
||||
response = ET.SubElement(multistatus, "D:response")
|
||||
href = ET.SubElement(response, "D:href")
|
||||
href.text = build_href(base_href, folder.name, True)
|
||||
|
||||
props = {
|
||||
"resourcetype": "collection",
|
||||
"displayname": folder.name,
|
||||
"creationdate": folder.created_at,
|
||||
"getlastmodified": folder.updated_at
|
||||
}
|
||||
response.append(create_propstat_element(props))
|
||||
|
||||
for file in files:
|
||||
response = ET.SubElement(multistatus, "D:response")
|
||||
href = ET.SubElement(response, "D:href")
|
||||
href.text = build_href(base_href, file.name, False)
|
||||
|
||||
props = {
|
||||
"resourcetype": "",
|
||||
"displayname": file.name,
|
||||
"getcontentlength": file.size,
|
||||
"getcontenttype": file.mime_type,
|
||||
"creationdate": file.created_at,
|
||||
"getlastmodified": file.updated_at,
|
||||
"getetag": f'"{file.file_hash}"'
|
||||
}
|
||||
response.append(create_propstat_element(props))
|
||||
|
||||
elif isinstance(resource, Folder):
|
||||
response = ET.SubElement(multistatus, "D:response")
|
||||
href = ET.SubElement(response, "D:href")
|
||||
href.text = base_href if base_href.endswith('/') else base_href + '/'
|
||||
|
||||
props = {
|
||||
"resourcetype": "collection",
|
||||
"displayname": resource.name,
|
||||
"creationdate": resource.created_at,
|
||||
"getlastmodified": resource.updated_at
|
||||
}
|
||||
response.append(create_propstat_element(props))
|
||||
|
||||
if depth in ["1", "infinity"]:
|
||||
folders = await Folder.filter(owner=current_user, parent=resource, is_deleted=False)
|
||||
files = await File.filter(owner=current_user, parent=resource, is_deleted=False)
|
||||
|
||||
for folder in folders:
|
||||
response = ET.SubElement(multistatus, "D:response")
|
||||
href = ET.SubElement(response, "D:href")
|
||||
href.text = build_href(base_href, folder.name, True)
|
||||
|
||||
props = {
|
||||
"resourcetype": "collection",
|
||||
"displayname": folder.name,
|
||||
"creationdate": folder.created_at,
|
||||
"getlastmodified": folder.updated_at
|
||||
}
|
||||
response.append(create_propstat_element(props))
|
||||
|
||||
for file in files:
|
||||
response = ET.SubElement(multistatus, "D:response")
|
||||
href = ET.SubElement(response, "D:href")
|
||||
href.text = build_href(base_href, file.name, False)
|
||||
|
||||
props = {
|
||||
"resourcetype": "",
|
||||
"displayname": file.name,
|
||||
"getcontentlength": file.size,
|
||||
"getcontenttype": file.mime_type,
|
||||
"creationdate": file.created_at,
|
||||
"getlastmodified": file.updated_at,
|
||||
"getetag": f'"{file.file_hash}"'
|
||||
}
|
||||
response.append(create_propstat_element(props))
|
||||
|
||||
elif isinstance(resource, File):
|
||||
response = ET.SubElement(multistatus, "D:response")
|
||||
href = ET.SubElement(response, "D:href")
|
||||
href.text = base_href
|
||||
|
||||
props = {
|
||||
"resourcetype": "",
|
||||
"displayname": resource.name,
|
||||
"getcontentlength": resource.size,
|
||||
"getcontenttype": resource.mime_type,
|
||||
"creationdate": resource.created_at,
|
||||
"getlastmodified": resource.updated_at,
|
||||
"getetag": f'"{resource.file_hash}"'
|
||||
}
|
||||
response.append(create_propstat_element(props))
|
||||
|
||||
xml_content = ET.tostring(multistatus, encoding="utf-8", xml_declaration=True)
|
||||
return Response(content=xml_content, media_type="application/xml; charset=utf-8", status_code=207)
|
||||
|
||||
@router.api_route("/{full_path:path}", methods=["GET", "HEAD"])
|
||||
async def handle_get(request: Request, full_path: str, current_user: User = Depends(webdav_auth)):
|
||||
full_path = unquote(full_path).strip('/')
|
||||
|
||||
resource, parent, exists = await resolve_path(full_path, current_user)
|
||||
|
||||
if not isinstance(resource, File):
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
try:
|
||||
if request.method == "HEAD":
|
||||
return Response(
|
||||
status_code=200,
|
||||
headers={
|
||||
"Content-Length": str(resource.size),
|
||||
"Content-Type": resource.mime_type,
|
||||
"ETag": f'"{resource.file_hash}"',
|
||||
"Last-Modified": resource.updated_at.strftime('%a, %d %b %Y %H:%M:%S GMT')
|
||||
}
|
||||
)
|
||||
|
||||
async def file_iterator():
|
||||
async for chunk in storage_manager.get_file(current_user.id, resource.path):
|
||||
yield chunk
|
||||
|
||||
return Response(
|
||||
content=file_iterator(),
|
||||
media_type=resource.mime_type,
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="{resource.name}"',
|
||||
"ETag": f'"{resource.file_hash}"',
|
||||
"Last-Modified": resource.updated_at.strftime('%a, %d %b %Y %H:%M:%S GMT')
|
||||
}
|
||||
)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="File not found in storage")
|
||||
|
||||
@router.api_route("/{full_path:path}", methods=["PUT"])
|
||||
async def handle_put(request: Request, full_path: str, current_user: User = Depends(webdav_auth)):
|
||||
full_path = unquote(full_path).strip('/')
|
||||
|
||||
if not full_path:
|
||||
raise HTTPException(status_code=400, detail="Cannot PUT to root")
|
||||
|
||||
parts = [p for p in full_path.split('/') if p]
|
||||
file_name = parts[-1]
|
||||
|
||||
parent_path = '/'.join(parts[:-1]) if len(parts) > 1 else ''
|
||||
_, parent_folder, exists = await resolve_path(parent_path, current_user)
|
||||
|
||||
if not exists:
|
||||
raise HTTPException(status_code=409, detail="Parent folder does not exist")
|
||||
|
||||
file_content = await request.body()
|
||||
file_size = len(file_content)
|
||||
|
||||
if current_user.used_storage_bytes + file_size > current_user.storage_quota_bytes:
|
||||
raise HTTPException(status_code=507, detail="Storage quota exceeded")
|
||||
|
||||
file_hash = hashlib.sha256(file_content).hexdigest()
|
||||
file_extension = os.path.splitext(file_name)[1]
|
||||
unique_filename = f"{file_hash}{file_extension}"
|
||||
storage_path = os.path.join(str(current_user.id), unique_filename)
|
||||
|
||||
await storage_manager.save_file(current_user.id, storage_path, file_content)
|
||||
|
||||
mime_type, _ = mimetypes.guess_type(file_name)
|
||||
if not mime_type:
|
||||
mime_type = "application/octet-stream"
|
||||
|
||||
existing_file = await File.get_or_none(
|
||||
name=file_name,
|
||||
parent=parent_folder,
|
||||
owner=current_user,
|
||||
is_deleted=False
|
||||
)
|
||||
|
||||
if existing_file:
|
||||
old_size = existing_file.size
|
||||
existing_file.path = storage_path
|
||||
existing_file.size = file_size
|
||||
existing_file.mime_type = mime_type
|
||||
existing_file.file_hash = file_hash
|
||||
existing_file.updated_at = datetime.now()
|
||||
await existing_file.save()
|
||||
|
||||
current_user.used_storage_bytes = current_user.used_storage_bytes - old_size + file_size
|
||||
await current_user.save()
|
||||
|
||||
await log_activity(current_user, "file_updated", "file", existing_file.id)
|
||||
return Response(status_code=204)
|
||||
else:
|
||||
db_file = await File.create(
|
||||
name=file_name,
|
||||
path=storage_path,
|
||||
size=file_size,
|
||||
mime_type=mime_type,
|
||||
file_hash=file_hash,
|
||||
owner=current_user,
|
||||
parent=parent_folder
|
||||
)
|
||||
|
||||
current_user.used_storage_bytes += file_size
|
||||
await current_user.save()
|
||||
|
||||
await log_activity(current_user, "file_created", "file", db_file.id)
|
||||
return Response(status_code=201)
|
||||
|
||||
@router.api_route("/{full_path:path}", methods=["DELETE"])
|
||||
async def handle_delete(request: Request, full_path: str, current_user: User = Depends(webdav_auth)):
|
||||
full_path = unquote(full_path).strip('/')
|
||||
|
||||
if not full_path:
|
||||
raise HTTPException(status_code=400, detail="Cannot DELETE root")
|
||||
|
||||
resource, parent, exists = await resolve_path(full_path, current_user)
|
||||
|
||||
if not resource:
|
||||
raise HTTPException(status_code=404, detail="Resource not found")
|
||||
|
||||
if isinstance(resource, File):
|
||||
resource.is_deleted = True
|
||||
resource.deleted_at = datetime.now()
|
||||
await resource.save()
|
||||
await log_activity(current_user, "file_deleted", "file", resource.id)
|
||||
elif isinstance(resource, Folder):
|
||||
resource.is_deleted = True
|
||||
await resource.save()
|
||||
await log_activity(current_user, "folder_deleted", "folder", resource.id)
|
||||
|
||||
return Response(status_code=204)
|
||||
|
||||
@router.api_route("/{full_path:path}", methods=["MKCOL"])
|
||||
async def handle_mkcol(request: Request, full_path: str, current_user: User = Depends(webdav_auth)):
|
||||
full_path = unquote(full_path).strip('/')
|
||||
|
||||
if not full_path:
|
||||
raise HTTPException(status_code=400, detail="Cannot MKCOL at root")
|
||||
|
||||
parts = [p for p in full_path.split('/') if p]
|
||||
folder_name = parts[-1]
|
||||
|
||||
parent_path = '/'.join(parts[:-1]) if len(parts) > 1 else ''
|
||||
_, parent_folder, exists = await resolve_path(parent_path, current_user)
|
||||
|
||||
if not exists:
|
||||
raise HTTPException(status_code=409, detail="Parent folder does not exist")
|
||||
|
||||
existing = await Folder.get_or_none(
|
||||
name=folder_name,
|
||||
parent=parent_folder,
|
||||
owner=current_user,
|
||||
is_deleted=False
|
||||
)
|
||||
|
||||
if existing:
|
||||
raise HTTPException(status_code=405, detail="Folder already exists")
|
||||
|
||||
folder = await Folder.create(
|
||||
name=folder_name,
|
||||
parent=parent_folder,
|
||||
owner=current_user
|
||||
)
|
||||
|
||||
await log_activity(current_user, "folder_created", "folder", folder.id)
|
||||
return Response(status_code=201)
|
||||
|
||||
@router.api_route("/{full_path:path}", methods=["COPY"])
|
||||
async def handle_copy(request: Request, full_path: str, current_user: User = Depends(webdav_auth)):
|
||||
full_path = unquote(full_path).strip('/')
|
||||
destination = request.headers.get("Destination")
|
||||
overwrite = request.headers.get("Overwrite", "T")
|
||||
|
||||
if not destination:
|
||||
raise HTTPException(status_code=400, detail="Destination header required")
|
||||
|
||||
dest_path = unquote(urlparse(destination).path)
|
||||
dest_path = dest_path.replace('/webdav/', '').strip('/')
|
||||
|
||||
source_resource, _, exists = await resolve_path(full_path, current_user)
|
||||
|
||||
if not source_resource:
|
||||
raise HTTPException(status_code=404, detail="Source not found")
|
||||
|
||||
if not isinstance(source_resource, File):
|
||||
raise HTTPException(status_code=501, detail="Only file copy is implemented")
|
||||
|
||||
dest_parts = [p for p in dest_path.split('/') if p]
|
||||
dest_name = dest_parts[-1]
|
||||
dest_parent_path = '/'.join(dest_parts[:-1]) if len(dest_parts) > 1 else ''
|
||||
|
||||
_, dest_parent, exists = await resolve_path(dest_parent_path, current_user)
|
||||
|
||||
if not exists:
|
||||
raise HTTPException(status_code=409, detail="Destination parent does not exist")
|
||||
|
||||
existing_dest = await File.get_or_none(
|
||||
name=dest_name,
|
||||
parent=dest_parent,
|
||||
owner=current_user,
|
||||
is_deleted=False
|
||||
)
|
||||
|
||||
if existing_dest and overwrite == "F":
|
||||
raise HTTPException(status_code=412, detail="Destination exists and overwrite is false")
|
||||
|
||||
if existing_dest:
|
||||
await existing_dest.delete()
|
||||
|
||||
new_file = await File.create(
|
||||
name=dest_name,
|
||||
path=source_resource.path,
|
||||
size=source_resource.size,
|
||||
mime_type=source_resource.mime_type,
|
||||
file_hash=source_resource.file_hash,
|
||||
owner=current_user,
|
||||
parent=dest_parent
|
||||
)
|
||||
|
||||
await log_activity(current_user, "file_copied", "file", new_file.id)
|
||||
return Response(status_code=201 if not existing_dest else 204)
|
||||
|
||||
@router.api_route("/{full_path:path}", methods=["MOVE"])
|
||||
async def handle_move(request: Request, full_path: str, current_user: User = Depends(webdav_auth)):
|
||||
full_path = unquote(full_path).strip('/')
|
||||
destination = request.headers.get("Destination")
|
||||
overwrite = request.headers.get("Overwrite", "T")
|
||||
|
||||
if not destination:
|
||||
raise HTTPException(status_code=400, detail="Destination header required")
|
||||
|
||||
dest_path = unquote(urlparse(destination).path)
|
||||
dest_path = dest_path.replace('/webdav/', '').strip('/')
|
||||
|
||||
source_resource, _, exists = await resolve_path(full_path, current_user)
|
||||
|
||||
if not source_resource:
|
||||
raise HTTPException(status_code=404, detail="Source not found")
|
||||
|
||||
dest_parts = [p for p in dest_path.split('/') if p]
|
||||
dest_name = dest_parts[-1]
|
||||
dest_parent_path = '/'.join(dest_parts[:-1]) if len(dest_parts) > 1 else ''
|
||||
|
||||
_, dest_parent, exists = await resolve_path(dest_parent_path, current_user)
|
||||
|
||||
if not exists:
|
||||
raise HTTPException(status_code=409, detail="Destination parent does not exist")
|
||||
|
||||
if isinstance(source_resource, File):
|
||||
existing_dest = await File.get_or_none(
|
||||
name=dest_name,
|
||||
parent=dest_parent,
|
||||
owner=current_user,
|
||||
is_deleted=False
|
||||
)
|
||||
|
||||
if existing_dest and overwrite == "F":
|
||||
raise HTTPException(status_code=412, detail="Destination exists and overwrite is false")
|
||||
|
||||
if existing_dest:
|
||||
await existing_dest.delete()
|
||||
|
||||
source_resource.name = dest_name
|
||||
source_resource.parent = dest_parent
|
||||
await source_resource.save()
|
||||
|
||||
await log_activity(current_user, "file_moved", "file", source_resource.id)
|
||||
return Response(status_code=201 if not existing_dest else 204)
|
||||
|
||||
elif isinstance(source_resource, Folder):
|
||||
existing_dest = await Folder.get_or_none(
|
||||
name=dest_name,
|
||||
parent=dest_parent,
|
||||
owner=current_user,
|
||||
is_deleted=False
|
||||
)
|
||||
|
||||
if existing_dest and overwrite == "F":
|
||||
raise HTTPException(status_code=412, detail="Destination exists and overwrite is false")
|
||||
|
||||
if existing_dest:
|
||||
existing_dest.is_deleted = True
|
||||
await existing_dest.save()
|
||||
|
||||
source_resource.name = dest_name
|
||||
source_resource.parent = dest_parent
|
||||
await source_resource.save()
|
||||
|
||||
await log_activity(current_user, "folder_moved", "folder", source_resource.id)
|
||||
return Response(status_code=201 if not existing_dest else 204)
|
||||
|
||||
@router.api_route("/{full_path:path}", methods=["LOCK"])
|
||||
async def handle_lock(request: Request, full_path: str, current_user: User = Depends(webdav_auth)):
|
||||
full_path = unquote(full_path).strip('/')
|
||||
|
||||
timeout_header = request.headers.get("Timeout", "Second-3600")
|
||||
timeout = 3600
|
||||
if timeout_header.startswith("Second-"):
|
||||
try:
|
||||
timeout = int(timeout_header.split("-")[1])
|
||||
except:
|
||||
pass
|
||||
|
||||
lock_token = WebDAVLock.create_lock(full_path, current_user.id, timeout)
|
||||
|
||||
lockinfo = ET.Element("D:prop", {"xmlns:D": "DAV:"})
|
||||
lockdiscovery = ET.SubElement(lockinfo, "D:lockdiscovery")
|
||||
activelock = ET.SubElement(lockdiscovery, "D:activelock")
|
||||
|
||||
locktype = ET.SubElement(activelock, "D:locktype")
|
||||
ET.SubElement(locktype, "D:write")
|
||||
|
||||
lockscope = ET.SubElement(activelock, "D:lockscope")
|
||||
ET.SubElement(lockscope, "D:exclusive")
|
||||
|
||||
depth_elem = ET.SubElement(activelock, "D:depth")
|
||||
depth_elem.text = "0"
|
||||
|
||||
owner = ET.SubElement(activelock, "D:owner")
|
||||
owner_href = ET.SubElement(owner, "D:href")
|
||||
owner_href.text = current_user.username
|
||||
|
||||
timeout_elem = ET.SubElement(activelock, "D:timeout")
|
||||
timeout_elem.text = f"Second-{timeout}"
|
||||
|
||||
locktoken_elem = ET.SubElement(activelock, "D:locktoken")
|
||||
href = ET.SubElement(locktoken_elem, "D:href")
|
||||
href.text = lock_token
|
||||
|
||||
xml_content = ET.tostring(lockinfo, encoding="utf-8", xml_declaration=True)
|
||||
|
||||
return Response(
|
||||
content=xml_content,
|
||||
media_type="application/xml; charset=utf-8",
|
||||
status_code=200,
|
||||
headers={"Lock-Token": f"<{lock_token}>"}
|
||||
)
|
||||
|
||||
@router.api_route("/{full_path:path}", methods=["UNLOCK"])
|
||||
async def handle_unlock(request: Request, full_path: str, current_user: User = Depends(webdav_auth)):
|
||||
full_path = unquote(full_path).strip('/')
|
||||
lock_token_header = request.headers.get("Lock-Token")
|
||||
|
||||
if not lock_token_header:
|
||||
raise HTTPException(status_code=400, detail="Lock-Token header required")
|
||||
|
||||
lock_token = lock_token_header.strip('<>')
|
||||
existing_lock = WebDAVLock.get_lock(full_path)
|
||||
|
||||
if not existing_lock or existing_lock['token'] != lock_token:
|
||||
raise HTTPException(status_code=409, detail="Invalid lock token")
|
||||
|
||||
if existing_lock['user_id'] != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Not lock owner")
|
||||
|
||||
WebDAVLock.remove_lock(full_path)
|
||||
return Response(status_code=204)
|
||||
18
run_dev.sh
Executable file
18
run_dev.sh
Executable file
@ -0,0 +1,18 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo "Starting RBox development server..."
|
||||
|
||||
if [ ! -f .env ]; then
|
||||
echo "Creating .env file from .env.example..."
|
||||
cp .env.example .env
|
||||
echo "Please edit .env with your configuration"
|
||||
fi
|
||||
|
||||
echo "Starting database services..."
|
||||
docker-compose up -d db redis
|
||||
|
||||
echo "Waiting for database to be ready..."
|
||||
sleep 5
|
||||
|
||||
echo "Starting application..."
|
||||
poetry run uvicorn rbox.main:app --reload --host 0.0.0.0 --port 8000
|
||||
788
static/css/style.css
Normal file
788
static/css/style.css
Normal file
@ -0,0 +1,788 @@
|
||||
:root {
|
||||
--primary-color: #003399;
|
||||
--secondary-color: #CC0000;
|
||||
--accent-color: #FFFFFF;
|
||||
--background-color: #F0F2F5;
|
||||
--text-color: #333333;
|
||||
--text-color-light: #666666;
|
||||
--border-color: #DDDDDD;
|
||||
--shadow-color: rgba(0, 0, 0, 0.1);
|
||||
--font-family: 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
--sidebar-width: 250px;
|
||||
--header-height: 60px;
|
||||
--spacing-unit: 8px;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
input, textarea {
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--font-family);
|
||||
color: var(--text-color);
|
||||
background-color: var(--background-color);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
height: var(--header-height);
|
||||
background-color: var(--accent-color);
|
||||
border-bottom: 2px solid var(--primary-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 calc(var(--spacing-unit) * 2);
|
||||
box-shadow: 0 2px 4px var(--shadow-color);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.app-title {
|
||||
margin: 0;
|
||||
color: var(--primary-color);
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.header-center {
|
||||
flex: 1;
|
||||
max-width: 500px;
|
||||
margin: 0 calc(var(--spacing-unit) * 2);
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: var(--spacing-unit);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(var(--spacing-unit) * 2);
|
||||
}
|
||||
|
||||
.user-info {
|
||||
color: var(--text-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.app-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-sidebar {
|
||||
width: var(--sidebar-width);
|
||||
background-color: var(--accent-color);
|
||||
border-right: 1px solid var(--border-color);
|
||||
padding: calc(var(--spacing-unit) * 2);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.nav-title {
|
||||
margin: calc(var(--spacing-unit) * 2) 0 var(--spacing-unit) 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-color-light);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.nav-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.nav-list li {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
display: block;
|
||||
padding: var(--spacing-unit) calc(var(--spacing-unit) * 2);
|
||||
color: var(--text-color);
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
background-color: var(--background-color);
|
||||
}
|
||||
|
||||
.nav-link.active {
|
||||
background-color: var(--primary-color);
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.app-main {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: calc(var(--spacing-unit) * 3);
|
||||
}
|
||||
|
||||
.button {
|
||||
background-color: var(--primary-color);
|
||||
color: var(--accent-color);
|
||||
border: none;
|
||||
padding: var(--spacing-unit) calc(var(--spacing-unit) * 2);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
background-color: var(--secondary-color);
|
||||
}
|
||||
|
||||
.button-primary {
|
||||
background-color: var(--secondary-color);
|
||||
}
|
||||
|
||||
.button-primary:hover {
|
||||
background-color: #AA0000;
|
||||
}
|
||||
|
||||
.input-field {
|
||||
border: 1px solid var(--border-color);
|
||||
padding: var(--spacing-unit);
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.auth-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
|
||||
}
|
||||
|
||||
.auth-box {
|
||||
background-color: var(--accent-color);
|
||||
padding: calc(var(--spacing-unit) * 4);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 8px var(--shadow-color);
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.auth-box h1 {
|
||||
margin: 0 0 var(--spacing-unit) 0;
|
||||
color: var(--primary-color);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.auth-subtitle {
|
||||
text-align: center;
|
||||
color: var(--text-color-light);
|
||||
margin-bottom: calc(var(--spacing-unit) * 3);
|
||||
}
|
||||
|
||||
.auth-tabs {
|
||||
display: flex;
|
||||
gap: var(--spacing-unit);
|
||||
margin-bottom: calc(var(--spacing-unit) * 2);
|
||||
}
|
||||
|
||||
.auth-tab {
|
||||
flex: 1;
|
||||
padding: var(--spacing-unit);
|
||||
background-color: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.auth-tab.active {
|
||||
background-color: var(--primary-color);
|
||||
color: var(--accent-color);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(var(--spacing-unit) * 2);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: var(--secondary-color);
|
||||
font-size: 0.875rem;
|
||||
min-height: 1em;
|
||||
}
|
||||
|
||||
.file-list-container {
|
||||
background-color: var(--accent-color);
|
||||
border-radius: 8px;
|
||||
padding: calc(var(--spacing-unit) * 2);
|
||||
}
|
||||
|
||||
.file-list-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: calc(var(--spacing-unit) * 2);
|
||||
padding-bottom: calc(var(--spacing-unit) * 2);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.file-list-header h2 {
|
||||
margin: 0;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.file-actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-unit);
|
||||
}
|
||||
|
||||
.file-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: calc(var(--spacing-unit) * 2);
|
||||
}
|
||||
|
||||
.file-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: calc(var(--spacing-unit) * 2);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.file-item:hover {
|
||||
box-shadow: 0 2px 8px var(--shadow-color);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: var(--spacing-unit);
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
word-break: break-word;
|
||||
margin-bottom: var(--spacing-unit);
|
||||
}
|
||||
|
||||
.file-size {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-color-light);
|
||||
}
|
||||
|
||||
.file-actions-menu {
|
||||
display: flex;
|
||||
gap: calc(var(--spacing-unit) / 2);
|
||||
margin-top: var(--spacing-unit);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: calc(var(--spacing-unit) / 2) var(--spacing-unit);
|
||||
font-size: 0.75rem;
|
||||
background-color: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background-color: var(--primary-color);
|
||||
color: var(--accent-color);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.upload-modal, .share-modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.upload-modal-content, .share-modal-content {
|
||||
background-color: var(--accent-color);
|
||||
border-radius: 8px;
|
||||
padding: calc(var(--spacing-unit) * 3);
|
||||
max-width: 600px;
|
||||
width: 90%;
|
||||
max-height: 85vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.upload-header, .share-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: calc(var(--spacing-unit) * 2);
|
||||
padding-bottom: calc(var(--spacing-unit) * 2);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.upload-header h3, .share-header h3 {
|
||||
margin: 0;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 2rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-color-light);
|
||||
padding: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
color: var(--secondary-color);
|
||||
}
|
||||
|
||||
.drop-zone {
|
||||
border: 2px dashed var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: calc(var(--spacing-unit) * 4);
|
||||
text-align: center;
|
||||
transition: all 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.drop-zone.drag-over {
|
||||
border-color: var(--primary-color);
|
||||
background-color: var(--background-color);
|
||||
}
|
||||
|
||||
.drop-zone-text p {
|
||||
margin: var(--spacing-unit) 0;
|
||||
color: var(--text-color-light);
|
||||
}
|
||||
|
||||
.upload-list {
|
||||
margin-top: calc(var(--spacing-unit) * 2);
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.upload-item {
|
||||
padding: var(--spacing-unit);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
margin-bottom: var(--spacing-unit);
|
||||
}
|
||||
|
||||
.upload-item-name {
|
||||
font-weight: 500;
|
||||
margin-bottom: var(--spacing-unit);
|
||||
}
|
||||
|
||||
.upload-item-progress {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(var(--spacing-unit) / 2);
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 8px;
|
||||
background-color: var(--background-color);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background-color: var(--primary-color);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.upload-status {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-color-light);
|
||||
}
|
||||
|
||||
.upload-status.success {
|
||||
color: #00AA00;
|
||||
}
|
||||
|
||||
.upload-status.error {
|
||||
color: var(--secondary-color);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: calc(var(--spacing-unit) * 2);
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: var(--spacing-unit);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.share-link-container {
|
||||
display: flex;
|
||||
gap: var(--spacing-unit);
|
||||
}
|
||||
|
||||
.share-link-container input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
text-align: center;
|
||||
padding: calc(var(--spacing-unit) * 4);
|
||||
color: var(--text-color-light);
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.app-header {
|
||||
flex-direction: column;
|
||||
height: auto;
|
||||
padding: var(--spacing-unit);
|
||||
}
|
||||
|
||||
.header-center {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
margin: var(--spacing-unit) 0;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.app-body {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.app-sidebar {
|
||||
width: 100%;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
max-height: 200px;
|
||||
}
|
||||
|
||||
.file-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.file-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.file-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.file-actions button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
body.dark-mode {
|
||||
--background-color: #222222;
|
||||
--text-color: #EEEEEE;
|
||||
--text-color-light: #BBBBBB;
|
||||
--border-color: #444444;
|
||||
--shadow-color: rgba(255, 255, 255, 0.1);
|
||||
--accent-color: #333333;
|
||||
}
|
||||
|
||||
.shortcuts-help-content {
|
||||
background: var(--accent-color);
|
||||
padding: calc(var(--spacing-unit) * 3);
|
||||
border-radius: 8px;
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
max-height: 85vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 4px 20px var(--shadow-color);
|
||||
}
|
||||
|
||||
.shortcuts-help-content h2 {
|
||||
margin: 0 0 calc(var(--spacing-unit) * 2) 0;
|
||||
color: var(--primary-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.shortcuts-list {
|
||||
margin-bottom: calc(var(--spacing-unit) * 3);
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
padding-right: calc(var(--spacing-unit) * 1);
|
||||
}
|
||||
|
||||
.shortcuts-help-content .button {
|
||||
flex-shrink: 0;
|
||||
margin-top: calc(var(--spacing-unit) * 2);
|
||||
}
|
||||
|
||||
.shortcuts-list h3 {
|
||||
margin: calc(var(--spacing-unit) * 2) 0 calc(var(--spacing-unit) * 1) 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.shortcuts-list h3:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.shortcut-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: calc(var(--spacing-unit) * 1.5);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.shortcut-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.shortcut-item kbd {
|
||||
background: var(--background-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
padding: calc(var(--spacing-unit) * 0.5) calc(var(--spacing-unit) * 1);
|
||||
font-family: monospace;
|
||||
font-size: 0.875rem;
|
||||
box-shadow: 0 2px 4px var(--shadow-color);
|
||||
}
|
||||
|
||||
.shortcut-item span {
|
||||
color: var(--text-color-light);
|
||||
}
|
||||
|
||||
.photo-gallery {
|
||||
padding: calc(var(--spacing-unit) * 2);
|
||||
}
|
||||
|
||||
.gallery-header {
|
||||
margin-bottom: calc(var(--spacing-unit) * 3);
|
||||
}
|
||||
|
||||
.gallery-header h2 {
|
||||
margin: 0;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.gallery-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: calc(var(--spacing-unit) * 2);
|
||||
}
|
||||
|
||||
.photo-item {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--accent-color);
|
||||
box-shadow: 0 2px 4px var(--shadow-color);
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.photo-item:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 4px 8px var(--shadow-color);
|
||||
}
|
||||
|
||||
.photo-item img {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.photo-info {
|
||||
padding: calc(var(--spacing-unit) * 1.5);
|
||||
}
|
||||
|
||||
.photo-name {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
margin-bottom: calc(var(--spacing-unit) * 0.5);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.photo-date {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-color-light);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: calc(var(--spacing-unit) * 4);
|
||||
color: var(--text-color-light);
|
||||
}
|
||||
|
||||
.preview-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: calc(var(--spacing-unit) * 2);
|
||||
}
|
||||
|
||||
.preview-container {
|
||||
background: var(--accent-color);
|
||||
border-radius: 8px;
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.preview-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: calc(var(--spacing-unit) * 2);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.preview-info h3 {
|
||||
margin: 0 0 calc(var(--spacing-unit) * 0.5) 0;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.preview-info p {
|
||||
margin: 0;
|
||||
color: var(--text-color-light);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.preview-actions {
|
||||
display: flex;
|
||||
gap: calc(var(--spacing-unit) * 1);
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
background: none;
|
||||
border: 1px solid var(--border-color);
|
||||
padding: calc(var(--spacing-unit) * 1);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 1.2rem;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-icon:hover {
|
||||
background: var(--background-color);
|
||||
}
|
||||
|
||||
.preview-content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: calc(var(--spacing-unit) * 2);
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.preview-content img,
|
||||
.preview-content video,
|
||||
.preview-content audio,
|
||||
.preview-content iframe {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.preview-content iframe {
|
||||
width: 100%;
|
||||
height: 70vh;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.preview-content pre {
|
||||
background: var(--background-color);
|
||||
padding: calc(var(--spacing-unit) * 2);
|
||||
border-radius: 4px;
|
||||
overflow: auto;
|
||||
max-width: 100%;
|
||||
color: var(--text-color);
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.no-preview {
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.no-preview p {
|
||||
margin-bottom: calc(var(--spacing-unit) * 2);
|
||||
}
|
||||
|
||||
.search-results {
|
||||
padding: calc(var(--spacing-unit) * 2);
|
||||
}
|
||||
|
||||
.search-results h2 {
|
||||
margin: 0 0 calc(var(--spacing-unit) * 2) 0;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
0
static/icons/icon-192x192.png
Normal file
0
static/icons/icon-192x192.png
Normal file
0
static/icons/icon-512x512.png
Normal file
0
static/icons/icon-512x512.png
Normal file
14
static/index.html
Normal file
14
static/index.html
Normal file
@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>RBox Cloud Storage</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<link rel="manifest" href="/static/manifest.json">
|
||||
</head>
|
||||
<body>
|
||||
<rbox-app></rbox-app>
|
||||
<script type="module" src="/static/js/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
208
static/js/api.js
Normal file
208
static/js/api.js
Normal file
@ -0,0 +1,208 @@
|
||||
class APIClient {
|
||||
constructor(baseURL = '/') {
|
||||
this.baseURL = baseURL;
|
||||
this.token = localStorage.getItem('token');
|
||||
}
|
||||
|
||||
setToken(token) {
|
||||
this.token = token;
|
||||
if (token) {
|
||||
localStorage.setItem('token', token);
|
||||
} else {
|
||||
localStorage.removeItem('token');
|
||||
}
|
||||
}
|
||||
|
||||
getToken() {
|
||||
return this.token;
|
||||
}
|
||||
|
||||
async request(endpoint, options = {}) {
|
||||
const url = `${this.baseURL}${endpoint}`;
|
||||
const headers = {
|
||||
...options.headers,
|
||||
};
|
||||
|
||||
if (this.token && !options.skipAuth) {
|
||||
headers['Authorization'] = `Bearer ${this.token}`;
|
||||
}
|
||||
|
||||
if (!(options.body instanceof FormData) && options.body) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
const config = {
|
||||
...options,
|
||||
headers,
|
||||
};
|
||||
|
||||
if (config.body && !(config.body instanceof FormData)) {
|
||||
config.body = JSON.stringify(config.body);
|
||||
}
|
||||
|
||||
const response = await fetch(url, config);
|
||||
|
||||
if (response.status === 401) {
|
||||
this.setToken(null);
|
||||
window.location.href = '/';
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: 'Unknown error' }));
|
||||
throw new Error(error.detail || 'Request failed');
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async register(username, email, password) {
|
||||
const data = await this.request('auth/register', {
|
||||
method: 'POST',
|
||||
body: { username, email, password },
|
||||
skipAuth: true
|
||||
});
|
||||
this.setToken(data.access_token);
|
||||
return data;
|
||||
}
|
||||
|
||||
async login(username, password) {
|
||||
const formData = new FormData();
|
||||
formData.append('username', username);
|
||||
formData.append('password', password);
|
||||
|
||||
const data = await this.request('auth/token', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
skipAuth: true
|
||||
});
|
||||
this.setToken(data.access_token);
|
||||
return data;
|
||||
}
|
||||
|
||||
logout() {
|
||||
this.setToken(null);
|
||||
window.location.href = '/';
|
||||
}
|
||||
|
||||
async getCurrentUser() {
|
||||
return this.request('users/me');
|
||||
}
|
||||
|
||||
async listFolders(parentId = null) {
|
||||
const params = parentId ? `?parent_id=${parentId}` : '';
|
||||
return this.request(`folders/${params}`);
|
||||
}
|
||||
|
||||
async createFolder(name, parentId = null) {
|
||||
return this.request('folders/', {
|
||||
method: 'POST',
|
||||
body: { name, parent_id: parentId }
|
||||
});
|
||||
}
|
||||
|
||||
async deleteFolder(folderId) {
|
||||
return this.request(`folders/${folderId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
|
||||
async uploadFile(file, folderId = null) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const endpoint = folderId ? `files/upload?folder_id=${folderId}` : 'files/upload';
|
||||
return this.request(endpoint, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
}
|
||||
|
||||
async listFiles(folderId = null) {
|
||||
const params = folderId ? `?folder_id=${folderId}` : '';
|
||||
return this.request(`files/${params}`);
|
||||
}
|
||||
|
||||
async downloadFile(fileId) {
|
||||
const url = `${this.baseURL}files/download/${fileId}`;
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Download failed');
|
||||
}
|
||||
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
async deleteFile(fileId) {
|
||||
return this.request(`files/${fileId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
|
||||
async moveFile(fileId, targetFolderId) {
|
||||
return this.request(`files/${fileId}/move`, {
|
||||
method: 'POST',
|
||||
body: { target_folder_id: targetFolderId }
|
||||
});
|
||||
}
|
||||
|
||||
async renameFile(fileId, newName) {
|
||||
return this.request(`files/${fileId}/rename`, {
|
||||
method: 'POST',
|
||||
body: { new_name: newName }
|
||||
});
|
||||
}
|
||||
|
||||
async copyFile(fileId, targetFolderId) {
|
||||
return this.request(`files/${fileId}/copy`, {
|
||||
method: 'POST',
|
||||
body: { target_folder_id: targetFolderId }
|
||||
});
|
||||
}
|
||||
|
||||
async createShare(fileId = null, folderId = null, expiresAt = null, password = null, permissionLevel = 'viewer') {
|
||||
return this.request('shares/', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
file_id: fileId,
|
||||
folder_id: folderId,
|
||||
expires_at: expiresAt,
|
||||
password,
|
||||
permission_level: permissionLevel
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async deleteShare(shareId) {
|
||||
return this.request(`shares/${shareId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
|
||||
async searchFiles(query, filters = {}) {
|
||||
const params = new URLSearchParams({ q: query, ...filters });
|
||||
return this.request(`search/files?${params}`);
|
||||
}
|
||||
|
||||
async searchFolders(query) {
|
||||
return this.request(`search/folders?q=${encodeURIComponent(query)}`);
|
||||
}
|
||||
|
||||
async getPhotos() {
|
||||
return this.request('files/photos');
|
||||
}
|
||||
|
||||
getThumbnailUrl(fileId) {
|
||||
return `${this.baseURL}files/thumbnail/${fileId}`;
|
||||
}
|
||||
}
|
||||
|
||||
export const api = new APIClient();
|
||||
201
static/js/components/file-list.js
Normal file
201
static/js/components/file-list.js
Normal file
@ -0,0 +1,201 @@
|
||||
import { api } from '../api.js';
|
||||
|
||||
export class FileList extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.currentFolderId = null;
|
||||
this.files = [];
|
||||
this.folders = [];
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
await this.loadContents(null);
|
||||
}
|
||||
|
||||
async loadContents(folderId) {
|
||||
this.currentFolderId = folderId;
|
||||
try {
|
||||
this.folders = await api.listFolders(folderId);
|
||||
this.files = await api.listFiles(folderId);
|
||||
this.render();
|
||||
} catch (error) {
|
||||
console.error('Failed to load contents:', error);
|
||||
}
|
||||
}
|
||||
|
||||
setFiles(files) {
|
||||
this.files = files;
|
||||
this.folders = [];
|
||||
this.render();
|
||||
}
|
||||
|
||||
render() {
|
||||
this.innerHTML = `
|
||||
<div class="file-list-container">
|
||||
<div class="file-list-header">
|
||||
<h2>Files</h2>
|
||||
<div class="file-actions">
|
||||
<button class="button" id="create-folder-btn">New Folder</button>
|
||||
<button class="button button-primary" id="upload-btn">Upload</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="file-grid">
|
||||
${this.folders.map(folder => this.renderFolder(folder)).join('')}
|
||||
${this.files.map(file => this.renderFile(file)).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.attachListeners();
|
||||
}
|
||||
|
||||
renderFolder(folder) {
|
||||
return `
|
||||
<div class="file-item folder-item" data-folder-id="${folder.id}">
|
||||
<div class="file-icon">📁</div>
|
||||
<div class="file-name">${folder.name}</div>
|
||||
<div class="file-actions-menu">
|
||||
<button class="action-btn" data-action="delete-folder" data-id="${folder.id}">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
renderFile(file) {
|
||||
const icon = this.getFileIcon(file.mime_type);
|
||||
const size = this.formatFileSize(file.size);
|
||||
|
||||
return `
|
||||
<div class="file-item" data-file-id="${file.id}">
|
||||
<div class="file-icon">${icon}</div>
|
||||
<div class="file-name">${file.name}</div>
|
||||
<div class="file-size">${size}</div>
|
||||
<div class="file-actions-menu">
|
||||
<button class="action-btn" data-action="download" data-id="${file.id}">Download</button>
|
||||
<button class="action-btn" data-action="rename" data-id="${file.id}">Rename</button>
|
||||
<button class="action-btn" data-action="delete" data-id="${file.id}">Delete</button>
|
||||
<button class="action-btn" data-action="share" data-id="${file.id}">Share</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
getFileIcon(mimeType) {
|
||||
if (mimeType.startsWith('image/')) return '📷';
|
||||
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);
|
||||
174
static/js/components/file-preview.js
Normal file
174
static/js/components/file-preview.js
Normal file
@ -0,0 +1,174 @@
|
||||
import { api } from '../api.js';
|
||||
|
||||
class FilePreview extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.file = null;
|
||||
this.handleEscape = this.handleEscape.bind(this);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.render();
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
const closeBtn = this.querySelector('.close-preview');
|
||||
const modal = this.querySelector('.preview-modal');
|
||||
const downloadBtn = this.querySelector('.download-btn');
|
||||
const shareBtn = this.querySelector('.share-btn');
|
||||
|
||||
closeBtn.addEventListener('click', () => this.close());
|
||||
modal.addEventListener('click', (e) => {
|
||||
if (e.target === modal) this.close();
|
||||
});
|
||||
|
||||
downloadBtn.addEventListener('click', () => this.downloadFile());
|
||||
shareBtn.addEventListener('click', () => this.shareFile());
|
||||
}
|
||||
|
||||
handleEscape(e) {
|
||||
if (e.key === 'Escape' && this.style.display === 'block') {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
async show(file) {
|
||||
this.file = file;
|
||||
this.style.display = 'block';
|
||||
document.addEventListener('keydown', this.handleEscape);
|
||||
this.renderPreview();
|
||||
}
|
||||
|
||||
close() {
|
||||
this.style.display = 'none';
|
||||
this.file = null;
|
||||
document.removeEventListener('keydown', this.handleEscape);
|
||||
}
|
||||
|
||||
async renderPreview() {
|
||||
const previewContent = this.querySelector('.preview-content');
|
||||
const fileName = this.querySelector('.preview-file-name');
|
||||
const fileInfo = this.querySelector('.preview-file-info');
|
||||
|
||||
fileName.textContent = this.file.name;
|
||||
fileInfo.textContent = `${this.formatFileSize(this.file.size)} • ${new Date(this.file.created_at).toLocaleDateString()}`;
|
||||
|
||||
if (this.file.mime_type.startsWith('image/')) {
|
||||
previewContent.innerHTML = `<img alt="${this.file.name}">`;
|
||||
this.loadAuthenticatedMedia(previewContent.querySelector('img'));
|
||||
} else if (this.file.mime_type.startsWith('video/')) {
|
||||
previewContent.innerHTML = `
|
||||
<video controls>
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
`;
|
||||
this.loadAuthenticatedMedia(previewContent.querySelector('video'));
|
||||
} else if (this.file.mime_type.startsWith('audio/')) {
|
||||
previewContent.innerHTML = `
|
||||
<audio controls>
|
||||
Your browser does not support the audio tag.
|
||||
</audio>
|
||||
`;
|
||||
this.loadAuthenticatedMedia(previewContent.querySelector('audio'));
|
||||
} else if (this.file.mime_type === 'application/pdf') {
|
||||
previewContent.innerHTML = `<iframe type="application/pdf"></iframe>`;
|
||||
this.loadAuthenticatedMedia(previewContent.querySelector('iframe'));
|
||||
} else if (this.file.mime_type.startsWith('text/')) {
|
||||
this.loadTextPreview(previewContent);
|
||||
} else {
|
||||
previewContent.innerHTML = `
|
||||
<div class="no-preview">
|
||||
<p>Preview not available for this file type</p>
|
||||
<button class="btn btn-primary" onclick="this.getRootNode().host.downloadFile()">Download File</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
async loadAuthenticatedMedia(element) {
|
||||
try {
|
||||
const blob = await api.downloadFile(this.file.id);
|
||||
const url = URL.createObjectURL(blob);
|
||||
element.src = url;
|
||||
} catch (error) {
|
||||
console.error('Failed to load media:', error);
|
||||
element.parentElement.innerHTML = '<p>Failed to load preview</p>';
|
||||
}
|
||||
}
|
||||
|
||||
async loadTextPreview(container) {
|
||||
try {
|
||||
const blob = await api.downloadFile(this.file.id);
|
||||
const text = await blob.text();
|
||||
container.innerHTML = `<pre>${this.escapeHtml(text)}</pre>`;
|
||||
} catch (error) {
|
||||
container.innerHTML = '<p>Failed to load preview</p>';
|
||||
}
|
||||
}
|
||||
|
||||
async downloadFile() {
|
||||
try {
|
||||
const blob = await api.downloadFile(this.file.id);
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = this.file.name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
} catch (error) {
|
||||
console.error('Failed to download file:', error);
|
||||
}
|
||||
}
|
||||
|
||||
shareFile() {
|
||||
this.dispatchEvent(new CustomEvent('share-file', {
|
||||
detail: { file: this.file },
|
||||
bubbles: true
|
||||
}));
|
||||
}
|
||||
|
||||
formatFileSize(bytes) {
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let size = bytes;
|
||||
let unitIndex = 0;
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
render() {
|
||||
this.innerHTML = `
|
||||
<div class="preview-modal">
|
||||
<div class="preview-container">
|
||||
<div class="preview-header">
|
||||
<div class="preview-info">
|
||||
<h3 class="preview-file-name"></h3>
|
||||
<p class="preview-file-info"></p>
|
||||
</div>
|
||||
<div class="preview-actions">
|
||||
<button class="btn btn-icon download-btn" title="Download">⬇</button>
|
||||
<button class="btn btn-icon share-btn" title="Share">🔗</button>
|
||||
<button class="btn btn-icon close-preview" title="Close">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="preview-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
this.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('file-preview', FilePreview);
|
||||
export { FilePreview };
|
||||
125
static/js/components/file-upload.js
Normal file
125
static/js/components/file-upload.js
Normal file
@ -0,0 +1,125 @@
|
||||
import { api } from '../api.js';
|
||||
|
||||
export class FileUpload extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.folderId = null;
|
||||
this.handleEscape = this.handleEscape.bind(this);
|
||||
this.render();
|
||||
this.attachListeners();
|
||||
}
|
||||
|
||||
setFolder(folderId) {
|
||||
this.folderId = folderId;
|
||||
}
|
||||
|
||||
render() {
|
||||
this.innerHTML = `
|
||||
<div class="upload-modal" id="upload-modal" style="display: none;">
|
||||
<div class="upload-modal-content">
|
||||
<div class="upload-header">
|
||||
<h3>Upload Files</h3>
|
||||
<button class="close-btn" id="close-upload">×</button>
|
||||
</div>
|
||||
|
||||
<div class="drop-zone" id="drop-zone">
|
||||
<div class="drop-zone-text">
|
||||
<p>Drag and drop files here</p>
|
||||
<p>or</p>
|
||||
<button class="button button-primary" id="select-files-btn">Select Files</button>
|
||||
<input type="file" id="file-input" multiple style="display: none;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="upload-list" id="upload-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
attachListeners() {
|
||||
const modal = this.querySelector('#upload-modal');
|
||||
const dropZone = this.querySelector('#drop-zone');
|
||||
const fileInput = this.querySelector('#file-input');
|
||||
const selectBtn = this.querySelector('#select-files-btn');
|
||||
const closeBtn = this.querySelector('#close-upload');
|
||||
|
||||
selectBtn.addEventListener('click', () => fileInput.click());
|
||||
fileInput.addEventListener('change', (e) => this.handleFiles(e.target.files));
|
||||
|
||||
closeBtn.addEventListener('click', () => this.hide());
|
||||
|
||||
dropZone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.add('drag-over');
|
||||
});
|
||||
|
||||
dropZone.addEventListener('dragleave', () => {
|
||||
dropZone.classList.remove('drag-over');
|
||||
});
|
||||
|
||||
dropZone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('drag-over');
|
||||
this.handleFiles(e.dataTransfer.files);
|
||||
});
|
||||
}
|
||||
|
||||
handleEscape(e) {
|
||||
if (e.key === 'Escape') {
|
||||
const modal = this.querySelector('#upload-modal');
|
||||
if (modal.style.display === 'flex') {
|
||||
this.hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
show() {
|
||||
this.querySelector('#upload-modal').style.display = 'flex';
|
||||
document.addEventListener('keydown', this.handleEscape);
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.querySelector('#upload-modal').style.display = 'none';
|
||||
this.querySelector('#upload-list').innerHTML = '';
|
||||
document.removeEventListener('keydown', this.handleEscape);
|
||||
}
|
||||
|
||||
async handleFiles(files) {
|
||||
const uploadList = this.querySelector('#upload-list');
|
||||
|
||||
for (const file of files) {
|
||||
const itemId = `upload-${Date.now()}-${Math.random()}`;
|
||||
const item = document.createElement('div');
|
||||
item.className = 'upload-item';
|
||||
item.id = itemId;
|
||||
item.innerHTML = `
|
||||
<div class="upload-item-name">${file.name}</div>
|
||||
<div class="upload-item-progress">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" style="width: 0%"></div>
|
||||
</div>
|
||||
<span class="upload-status">Uploading...</span>
|
||||
</div>
|
||||
`;
|
||||
uploadList.appendChild(item);
|
||||
|
||||
try {
|
||||
await api.uploadFile(file, this.folderId);
|
||||
const status = item.querySelector('.upload-status');
|
||||
const progressFill = item.querySelector('.progress-fill');
|
||||
progressFill.style.width = '100%';
|
||||
status.textContent = 'Complete';
|
||||
status.classList.add('success');
|
||||
} catch (error) {
|
||||
const status = item.querySelector('.upload-status');
|
||||
status.textContent = 'Failed: ' + error.message;
|
||||
status.classList.add('error');
|
||||
}
|
||||
}
|
||||
|
||||
this.dispatchEvent(new CustomEvent('upload-complete'));
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('file-upload', FileUpload);
|
||||
102
static/js/components/login-view.js
Normal file
102
static/js/components/login-view.js
Normal file
@ -0,0 +1,102 @@
|
||||
import { api } from '../api.js';
|
||||
|
||||
export class LoginView extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.render();
|
||||
this.attachListeners();
|
||||
}
|
||||
|
||||
render() {
|
||||
this.innerHTML = `
|
||||
<div class="auth-container">
|
||||
<div class="auth-box">
|
||||
<h1>RBox</h1>
|
||||
<p class="auth-subtitle">Self-hosted cloud storage</p>
|
||||
|
||||
<div class="auth-tabs">
|
||||
<button class="auth-tab active" data-tab="login">Login</button>
|
||||
<button class="auth-tab" data-tab="register">Register</button>
|
||||
</div>
|
||||
|
||||
<form id="login-form" class="auth-form">
|
||||
<input type="text" name="username" placeholder="Username" required class="input-field">
|
||||
<input type="password" name="password" placeholder="Password" required class="input-field">
|
||||
<button type="submit" class="button button-primary">Login</button>
|
||||
<div class="error-message" id="login-error"></div>
|
||||
</form>
|
||||
|
||||
<form id="register-form" class="auth-form" style="display: none;">
|
||||
<input type="text" name="username" placeholder="Username" required class="input-field">
|
||||
<input type="email" name="email" placeholder="Email" required class="input-field">
|
||||
<input type="password" name="password" placeholder="Password" required class="input-field">
|
||||
<button type="submit" class="button button-primary">Register</button>
|
||||
<div class="error-message" id="register-error"></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
attachListeners() {
|
||||
const tabs = this.querySelectorAll('.auth-tab');
|
||||
tabs.forEach(tab => {
|
||||
tab.addEventListener('click', () => this.switchTab(tab.dataset.tab));
|
||||
});
|
||||
|
||||
this.querySelector('#login-form').addEventListener('submit', (e) => this.handleLogin(e));
|
||||
this.querySelector('#register-form').addEventListener('submit', (e) => this.handleRegister(e));
|
||||
}
|
||||
|
||||
switchTab(tab) {
|
||||
const tabs = this.querySelectorAll('.auth-tab');
|
||||
tabs.forEach(t => t.classList.remove('active'));
|
||||
this.querySelector(`[data-tab="${tab}"]`).classList.add('active');
|
||||
|
||||
const loginForm = this.querySelector('#login-form');
|
||||
const registerForm = this.querySelector('#register-form');
|
||||
|
||||
if (tab === 'login') {
|
||||
loginForm.style.display = 'block';
|
||||
registerForm.style.display = 'none';
|
||||
} else {
|
||||
loginForm.style.display = 'none';
|
||||
registerForm.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
async handleLogin(e) {
|
||||
e.preventDefault();
|
||||
const form = e.target;
|
||||
const formData = new FormData(form);
|
||||
const username = formData.get('username');
|
||||
const password = formData.get('password');
|
||||
const errorDiv = this.querySelector('#login-error');
|
||||
|
||||
try {
|
||||
await api.login(username, password);
|
||||
this.dispatchEvent(new CustomEvent('auth-success'));
|
||||
} catch (error) {
|
||||
errorDiv.textContent = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
async handleRegister(e) {
|
||||
e.preventDefault();
|
||||
const form = e.target;
|
||||
const formData = new FormData(form);
|
||||
const username = formData.get('username');
|
||||
const email = formData.get('email');
|
||||
const password = formData.get('password');
|
||||
const errorDiv = this.querySelector('#register-error');
|
||||
|
||||
try {
|
||||
await api.register(username, email, password);
|
||||
this.dispatchEvent(new CustomEvent('auth-success'));
|
||||
} catch (error) {
|
||||
errorDiv.textContent = error.message;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('login-view', LoginView);
|
||||
90
static/js/components/photo-gallery.js
Normal file
90
static/js/components/photo-gallery.js
Normal file
@ -0,0 +1,90 @@
|
||||
import { api } from '../api.js';
|
||||
|
||||
class PhotoGallery extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.photos = [];
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.render();
|
||||
this.loadPhotos();
|
||||
}
|
||||
|
||||
async loadPhotos() {
|
||||
try {
|
||||
this.photos = await api.getPhotos();
|
||||
this.renderPhotos();
|
||||
} catch (error) {
|
||||
console.error('Failed to load photos:', error);
|
||||
this.querySelector('.gallery-grid').innerHTML = '<p>Failed to load photos</p>';
|
||||
}
|
||||
}
|
||||
|
||||
async renderPhotos() {
|
||||
const grid = this.querySelector('.gallery-grid');
|
||||
if (this.photos.length === 0) {
|
||||
grid.innerHTML = '<p class="empty-state">No photos yet</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
grid.innerHTML = this.photos.map(photo => `
|
||||
<div class="photo-item" data-file-id="${photo.id}">
|
||||
<img
|
||||
data-photo-id="${photo.id}"
|
||||
alt="${photo.name}"
|
||||
loading="lazy"
|
||||
style="background: #f0f0f0;"
|
||||
>
|
||||
<div class="photo-info">
|
||||
<span class="photo-name">${photo.name}</span>
|
||||
<span class="photo-date">${new Date(photo.created_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
grid.querySelectorAll('img[data-photo-id]').forEach(async (img) => {
|
||||
const photoId = img.dataset.photoId;
|
||||
try {
|
||||
const response = await fetch(`/files/thumbnail/${photoId}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${api.getToken()}`
|
||||
}
|
||||
});
|
||||
if (response.ok) {
|
||||
const blob = await response.blob();
|
||||
img.src = URL.createObjectURL(blob);
|
||||
} else {
|
||||
img.style.display = 'none';
|
||||
}
|
||||
} catch (error) {
|
||||
img.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
grid.querySelectorAll('.photo-item').forEach(item => {
|
||||
item.addEventListener('click', () => {
|
||||
const fileId = item.dataset.fileId;
|
||||
const photo = this.photos.find(p => p.id === parseInt(fileId));
|
||||
this.dispatchEvent(new CustomEvent('photo-click', {
|
||||
detail: { photo },
|
||||
bubbles: true
|
||||
}));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
this.innerHTML = `
|
||||
<div class="photo-gallery">
|
||||
<div class="gallery-header">
|
||||
<h2>Photo Gallery</h2>
|
||||
</div>
|
||||
<div class="gallery-grid"></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('photo-gallery', PhotoGallery);
|
||||
export { PhotoGallery };
|
||||
345
static/js/components/rbox-app.js
Normal file
345
static/js/components/rbox-app.js
Normal file
@ -0,0 +1,345 @@
|
||||
import { api } from '../api.js';
|
||||
import './login-view.js';
|
||||
import './file-list.js';
|
||||
import './file-upload.js';
|
||||
import './share-modal.js';
|
||||
import './photo-gallery.js';
|
||||
import './file-preview.js';
|
||||
import { shortcuts } from '../shortcuts.js';
|
||||
|
||||
export class RBoxApp extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.currentView = 'files';
|
||||
this.currentFolderId = null;
|
||||
this.user = null;
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
await this.init();
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (!api.getToken()) {
|
||||
this.showLogin();
|
||||
} else {
|
||||
try {
|
||||
this.user = await api.getCurrentUser();
|
||||
this.render();
|
||||
} catch (error) {
|
||||
this.showLogin();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
showLogin() {
|
||||
this.innerHTML = '<login-view></login-view>';
|
||||
const loginView = this.querySelector('login-view');
|
||||
loginView.addEventListener('auth-success', () => this.init());
|
||||
}
|
||||
|
||||
render() {
|
||||
this.innerHTML = `
|
||||
<div class="app-container">
|
||||
<header class="app-header">
|
||||
<div class="header-left">
|
||||
<h1 class="app-title">RBox</h1>
|
||||
</div>
|
||||
<div class="header-center">
|
||||
<input type="search" placeholder="Search..." class="search-input" id="search-input">
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<span class="user-info">${this.user.username}</span>
|
||||
<button class="button" id="logout-btn">Logout</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="app-body">
|
||||
<aside class="app-sidebar">
|
||||
<nav class="sidebar-nav">
|
||||
<h3 class="nav-title">Navigation</h3>
|
||||
<ul class="nav-list">
|
||||
<li><a href="#" class="nav-link active" data-view="files">My Files</a></li>
|
||||
<li><a href="#" class="nav-link" data-view="photos">Photo Gallery</a></li>
|
||||
<li><a href="#" class="nav-link" data-view="shared">Shared Items</a></li>
|
||||
<li><a href="#" class="nav-link" data-view="deleted">Deleted Files</a></li>
|
||||
</ul>
|
||||
<h3 class="nav-title">Quick Access</h3>
|
||||
<ul class="nav-list">
|
||||
<li><a href="#" class="nav-link" data-view="starred">Starred</a></li>
|
||||
<li><a href="#" class="nav-link" data-view="recent">Recent</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<main class="app-main">
|
||||
<div id="main-content">
|
||||
<file-list></file-list>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<file-upload></file-upload>
|
||||
<share-modal></share-modal>
|
||||
<file-preview></file-preview>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.attachListeners();
|
||||
this.registerShortcuts();
|
||||
}
|
||||
|
||||
registerShortcuts() {
|
||||
shortcuts.register('ctrl+u', () => {
|
||||
const upload = this.querySelector('file-upload');
|
||||
if (upload) {
|
||||
upload.setFolder(this.currentFolderId);
|
||||
upload.show();
|
||||
}
|
||||
});
|
||||
|
||||
shortcuts.register('ctrl+f', () => {
|
||||
const searchInput = this.querySelector('#search-input');
|
||||
if (searchInput) {
|
||||
searchInput.focus();
|
||||
}
|
||||
});
|
||||
|
||||
shortcuts.register('ctrl+/', () => {
|
||||
this.showShortcutsHelp();
|
||||
});
|
||||
|
||||
shortcuts.register('ctrl+shift+n', () => {
|
||||
if (this.currentView === 'files') {
|
||||
const fileList = this.querySelector('file-list');
|
||||
if (fileList) {
|
||||
fileList.triggerCreateFolder();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
shortcuts.register('1', () => {
|
||||
this.switchView('files');
|
||||
});
|
||||
|
||||
shortcuts.register('2', () => {
|
||||
this.switchView('photos');
|
||||
});
|
||||
|
||||
shortcuts.register('3', () => {
|
||||
this.switchView('shared');
|
||||
});
|
||||
|
||||
shortcuts.register('4', () => {
|
||||
this.switchView('deleted');
|
||||
});
|
||||
|
||||
shortcuts.register('f2', () => {
|
||||
console.log('Rename shortcut - to be implemented');
|
||||
});
|
||||
}
|
||||
|
||||
showShortcutsHelp() {
|
||||
const helpContent = `
|
||||
<div class="shortcuts-help-modal">
|
||||
<div class="shortcuts-help-content">
|
||||
<h2>Keyboard Shortcuts</h2>
|
||||
<div class="shortcuts-list">
|
||||
<h3>File Operations</h3>
|
||||
<div class="shortcut-item">
|
||||
<kbd>Ctrl + U</kbd>
|
||||
<span>Upload files</span>
|
||||
</div>
|
||||
<div class="shortcut-item">
|
||||
<kbd>Ctrl + Shift + N</kbd>
|
||||
<span>Create new folder</span>
|
||||
</div>
|
||||
<div class="shortcut-item">
|
||||
<kbd>Ctrl + F</kbd>
|
||||
<span>Focus search</span>
|
||||
</div>
|
||||
|
||||
<h3>Navigation</h3>
|
||||
<div class="shortcut-item">
|
||||
<kbd>1</kbd>
|
||||
<span>My Files</span>
|
||||
</div>
|
||||
<div class="shortcut-item">
|
||||
<kbd>2</kbd>
|
||||
<span>Photo Gallery</span>
|
||||
</div>
|
||||
<div class="shortcut-item">
|
||||
<kbd>3</kbd>
|
||||
<span>Shared Items</span>
|
||||
</div>
|
||||
<div class="shortcut-item">
|
||||
<kbd>4</kbd>
|
||||
<span>Deleted Files</span>
|
||||
</div>
|
||||
|
||||
<h3>General</h3>
|
||||
<div class="shortcut-item">
|
||||
<kbd>ESC</kbd>
|
||||
<span>Close modals</span>
|
||||
</div>
|
||||
<div class="shortcut-item">
|
||||
<kbd>Ctrl + /</kbd>
|
||||
<span>Show this help</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="button" id="close-shortcuts-help">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const helpDiv = document.createElement('div');
|
||||
helpDiv.innerHTML = helpContent;
|
||||
helpDiv.querySelector('.shortcuts-help-modal').style.cssText = `
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
`;
|
||||
document.body.appendChild(helpDiv);
|
||||
|
||||
const closeHelp = () => {
|
||||
document.body.removeChild(helpDiv);
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
};
|
||||
|
||||
const handleEscape = (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
closeHelp();
|
||||
}
|
||||
};
|
||||
|
||||
const closeBtn = helpDiv.querySelector('#close-shortcuts-help');
|
||||
closeBtn.addEventListener('click', closeHelp);
|
||||
helpDiv.querySelector('.shortcuts-help-modal').addEventListener('click', (e) => {
|
||||
if (e.target.classList.contains('shortcuts-help-modal')) closeHelp();
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
}
|
||||
|
||||
attachListeners() {
|
||||
this.querySelector('#logout-btn')?.addEventListener('click', () => {
|
||||
api.logout();
|
||||
});
|
||||
|
||||
this.querySelectorAll('.nav-link').forEach(link => {
|
||||
link.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const view = link.dataset.view;
|
||||
this.switchView(view);
|
||||
});
|
||||
});
|
||||
|
||||
const fileList = this.querySelector('file-list');
|
||||
if (fileList) {
|
||||
fileList.addEventListener('upload-request', () => {
|
||||
const upload = this.querySelector('file-upload');
|
||||
upload.setFolder(this.currentFolderId);
|
||||
upload.show();
|
||||
});
|
||||
|
||||
fileList.addEventListener('folder-open', (e) => {
|
||||
this.currentFolderId = e.detail.folderId;
|
||||
fileList.loadContents(this.currentFolderId);
|
||||
});
|
||||
|
||||
fileList.addEventListener('share-request', (e) => {
|
||||
const modal = this.querySelector('share-modal');
|
||||
modal.show(e.detail.fileId);
|
||||
});
|
||||
}
|
||||
|
||||
const upload = this.querySelector('file-upload');
|
||||
if (upload) {
|
||||
upload.addEventListener('upload-complete', () => {
|
||||
const fileList = this.querySelector('file-list');
|
||||
fileList.loadContents(this.currentFolderId);
|
||||
});
|
||||
}
|
||||
|
||||
const searchInput = this.querySelector('#search-input');
|
||||
if (searchInput) {
|
||||
let searchTimeout;
|
||||
searchInput.addEventListener('input', (e) => {
|
||||
clearTimeout(searchTimeout);
|
||||
const query = e.target.value.trim();
|
||||
if (query.length > 0) {
|
||||
searchTimeout = setTimeout(() => this.performSearch(query), 300);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.addEventListener('photo-click', (e) => {
|
||||
const preview = this.querySelector('file-preview');
|
||||
preview.show(e.detail.photo);
|
||||
});
|
||||
|
||||
this.addEventListener('share-file', (e) => {
|
||||
const modal = this.querySelector('share-modal');
|
||||
modal.show(e.detail.file.id);
|
||||
});
|
||||
}
|
||||
|
||||
async performSearch(query) {
|
||||
try {
|
||||
const files = await api.searchFiles(query);
|
||||
const mainContent = this.querySelector('#main-content');
|
||||
mainContent.innerHTML = `
|
||||
<div class="search-results">
|
||||
<h2>Search Results for "${query}"</h2>
|
||||
<file-list data-search-mode="true"></file-list>
|
||||
</div>
|
||||
`;
|
||||
const fileList = mainContent.querySelector('file-list');
|
||||
fileList.setFiles(files);
|
||||
this.attachListeners();
|
||||
} catch (error) {
|
||||
console.error('Search failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
switchView(view) {
|
||||
this.currentView = view;
|
||||
|
||||
this.querySelectorAll('.nav-link').forEach(link => {
|
||||
link.classList.remove('active');
|
||||
});
|
||||
this.querySelector(`[data-view="${view}"]`)?.classList.add('active');
|
||||
|
||||
const mainContent = this.querySelector('#main-content');
|
||||
|
||||
switch (view) {
|
||||
case 'files':
|
||||
mainContent.innerHTML = '<file-list></file-list>';
|
||||
this.attachListeners();
|
||||
break;
|
||||
case 'photos':
|
||||
mainContent.innerHTML = '<photo-gallery></photo-gallery>';
|
||||
this.attachListeners();
|
||||
break;
|
||||
case 'shared':
|
||||
mainContent.innerHTML = '<div class="placeholder">Shared Items - Coming Soon</div>';
|
||||
break;
|
||||
case 'deleted':
|
||||
mainContent.innerHTML = '<div class="placeholder">Deleted Files - Coming Soon</div>';
|
||||
break;
|
||||
case 'starred':
|
||||
mainContent.innerHTML = '<div class="placeholder">Starred Items - Coming Soon</div>';
|
||||
break;
|
||||
case 'recent':
|
||||
mainContent.innerHTML = '<div class="placeholder">Recent Files - Coming Soon</div>';
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
133
static/js/components/share-modal.js
Normal file
133
static/js/components/share-modal.js
Normal file
@ -0,0 +1,133 @@
|
||||
import { api } from '../api.js';
|
||||
|
||||
export class ShareModal extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.fileId = null;
|
||||
this.folderId = null;
|
||||
this.handleEscape = this.handleEscape.bind(this);
|
||||
this.render();
|
||||
this.attachListeners();
|
||||
}
|
||||
|
||||
render() {
|
||||
this.innerHTML = `
|
||||
<div class="share-modal" id="share-modal" style="display: none;">
|
||||
<div class="share-modal-content">
|
||||
<div class="share-header">
|
||||
<h3>Share</h3>
|
||||
<button class="close-btn" id="close-share">×</button>
|
||||
</div>
|
||||
|
||||
<form id="share-form">
|
||||
<div class="form-group">
|
||||
<label>Permission Level</label>
|
||||
<select name="permission_level" class="input-field">
|
||||
<option value="viewer">Viewer</option>
|
||||
<option value="uploader">Uploader</option>
|
||||
<option value="editor">Editor</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Password Protection (optional)</label>
|
||||
<input type="password" name="password" class="input-field" placeholder="Leave blank for no password">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Expiration Date (optional)</label>
|
||||
<input type="datetime-local" name="expires_at" class="input-field">
|
||||
</div>
|
||||
|
||||
<button type="submit" class="button button-primary">Create Share Link</button>
|
||||
</form>
|
||||
|
||||
<div id="share-result" style="display: none;">
|
||||
<div class="form-group">
|
||||
<label>Share Link</label>
|
||||
<div class="share-link-container">
|
||||
<input type="text" id="share-link" readonly class="input-field">
|
||||
<button class="button" id="copy-link-btn">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="error-message" id="share-error"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
attachListeners() {
|
||||
const closeBtn = this.querySelector('#close-share');
|
||||
closeBtn.addEventListener('click', () => this.hide());
|
||||
|
||||
const form = this.querySelector('#share-form');
|
||||
form.addEventListener('submit', (e) => this.handleCreateShare(e));
|
||||
|
||||
const copyBtn = this.querySelector('#copy-link-btn');
|
||||
copyBtn.addEventListener('click', () => this.copyLink());
|
||||
}
|
||||
|
||||
handleEscape(e) {
|
||||
if (e.key === 'Escape') {
|
||||
const modal = this.querySelector('#share-modal');
|
||||
if (modal.style.display === 'flex') {
|
||||
this.hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
show(fileId = null, folderId = null) {
|
||||
this.fileId = fileId;
|
||||
this.folderId = folderId;
|
||||
this.querySelector('#share-modal').style.display = 'flex';
|
||||
this.querySelector('#share-result').style.display = 'none';
|
||||
this.querySelector('#share-form').reset();
|
||||
document.addEventListener('keydown', this.handleEscape);
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.querySelector('#share-modal').style.display = 'none';
|
||||
this.fileId = null;
|
||||
this.folderId = null;
|
||||
document.removeEventListener('keydown', this.handleEscape);
|
||||
}
|
||||
|
||||
async handleCreateShare(e) {
|
||||
e.preventDefault();
|
||||
const form = e.target;
|
||||
const formData = new FormData(form);
|
||||
const errorDiv = this.querySelector('#share-error');
|
||||
|
||||
try {
|
||||
const permissionLevel = formData.get('permission_level');
|
||||
const password = formData.get('password') || null;
|
||||
const expiresAt = formData.get('expires_at') || null;
|
||||
|
||||
const share = await api.createShare(
|
||||
this.fileId,
|
||||
this.folderId,
|
||||
expiresAt,
|
||||
password,
|
||||
permissionLevel
|
||||
);
|
||||
|
||||
const shareLink = `${window.location.origin}/share/${share.token}`;
|
||||
this.querySelector('#share-link').value = shareLink;
|
||||
this.querySelector('#share-result').style.display = 'block';
|
||||
errorDiv.textContent = '';
|
||||
} catch (error) {
|
||||
errorDiv.textContent = 'Failed to create share link: ' + error.message;
|
||||
}
|
||||
}
|
||||
|
||||
copyLink() {
|
||||
const linkInput = this.querySelector('#share-link');
|
||||
linkInput.select();
|
||||
document.execCommand('copy');
|
||||
alert('Link copied to clipboard');
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('share-modal', ShareModal);
|
||||
23
static/js/main.js
Normal file
23
static/js/main.js
Normal file
@ -0,0 +1,23 @@
|
||||
import { RBoxApp } from './components/rbox-app.js';
|
||||
|
||||
// Define the custom element
|
||||
customElements.define('rbox-app', RBoxApp);
|
||||
|
||||
// Instantiate the main application class
|
||||
const app = new RBoxApp();
|
||||
|
||||
// Append the app to the body (if not already in index.html)
|
||||
// document.body.appendChild(app);
|
||||
|
||||
// Register service worker for PWA
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/static/service-worker.js')
|
||||
.then(registration => {
|
||||
console.log('Service Worker registered with scope:', registration.scope);
|
||||
})
|
||||
.catch(error => {
|
||||
console.log('Service Worker registration failed:', error);
|
||||
});
|
||||
});
|
||||
}
|
||||
55
static/js/shortcuts.js
Normal file
55
static/js/shortcuts.js
Normal file
@ -0,0 +1,55 @@
|
||||
class KeyboardShortcuts {
|
||||
constructor() {
|
||||
this.shortcuts = new Map();
|
||||
this.enabled = true;
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (!this.enabled) return;
|
||||
|
||||
const isInput = ['INPUT', 'TEXTAREA'].includes(e.target.tagName);
|
||||
const key = this.getKeyCombo(e);
|
||||
|
||||
if (this.shortcuts.has(key)) {
|
||||
const { handler, allowInInput } = this.shortcuts.get(key);
|
||||
if (!isInput || allowInInput) {
|
||||
e.preventDefault();
|
||||
handler(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getKeyCombo(e) {
|
||||
const parts = [];
|
||||
if (e.ctrlKey || e.metaKey) parts.push('ctrl');
|
||||
if (e.altKey) parts.push('alt');
|
||||
if (e.shiftKey) parts.push('shift');
|
||||
parts.push(e.key.toLowerCase());
|
||||
return parts.join('+');
|
||||
}
|
||||
|
||||
register(keyCombo, handler, allowInInput = false) {
|
||||
this.shortcuts.set(keyCombo.toLowerCase(), { handler, allowInInput });
|
||||
}
|
||||
|
||||
unregister(keyCombo) {
|
||||
this.shortcuts.delete(keyCombo.toLowerCase());
|
||||
}
|
||||
|
||||
enable() {
|
||||
this.enabled = true;
|
||||
}
|
||||
|
||||
disable() {
|
||||
this.enabled = false;
|
||||
}
|
||||
|
||||
getRegisteredShortcuts() {
|
||||
return Array.from(this.shortcuts.keys());
|
||||
}
|
||||
}
|
||||
|
||||
export const shortcuts = new KeyboardShortcuts();
|
||||
21
static/manifest.json
Normal file
21
static/manifest.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "RBox Cloud Storage",
|
||||
"short_name": "RBox",
|
||||
"description": "A self-hosted cloud storage web application",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#F0F2F5",
|
||||
"theme_color": "#003399",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/static/icons/icon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/static/icons/icon-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
46
static/service-worker.js
Normal file
46
static/service-worker.js
Normal file
@ -0,0 +1,46 @@
|
||||
const CACHE_NAME = 'rbox-cache-v1';
|
||||
const urlsToCache = [
|
||||
'/',
|
||||
'/static/index.html',
|
||||
'/static/css/style.css',
|
||||
'/static/js/main.js',
|
||||
'/static/js/components/rbox-app.js',
|
||||
'/static/manifest.json',
|
||||
'/static/icons/icon-192x192.png',
|
||||
'/static/icons/icon-512x512.png'
|
||||
];
|
||||
|
||||
self.addEventListener('install', event => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME)
|
||||
.then(cache => {
|
||||
return cache.addAll(urlsToCache);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', event => {
|
||||
event.respondWith(
|
||||
caches.match(event.request)
|
||||
.then(response => {
|
||||
if (response) {
|
||||
return response;
|
||||
}
|
||||
return fetch(event.request);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('activate', event => {
|
||||
event.waitUntil(
|
||||
caches.keys().then(cacheNames => {
|
||||
return Promise.all(
|
||||
cacheNames.map(cacheName => {
|
||||
if (cacheName !== CACHE_NAME) {
|
||||
return caches.delete(cacheName);
|
||||
}
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user