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