This commit is contained in:
2025-11-13 20:42:43 +01:00
parent 160be767aa
commit 74c9fd82d7
53 changed files with 74 additions and 73 deletions
+109
View File
@@ -0,0 +1,109 @@
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from ..auth import get_current_admin_user, get_password_hash
from ..models import User, User_Pydantic
from ..schemas import UserCreate, UserAdminUpdate
router = APIRouter(
prefix="/admin",
tags=["admin"],
dependencies=[Depends(get_current_admin_user)],
responses={403: {"description": "Not enough permissions"}},
)
@router.get("/users", response_model=List[User_Pydantic])
async def get_all_users():
return await User.all()
@router.get("/users/{user_id}", response_model=User_Pydantic)
async def get_user(user_id: int):
user = await User.get_or_none(id=user_id)
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
return user
@router.post("/users", response_model=User_Pydantic, status_code=status.HTTP_201_CREATED)
async def create_user_by_admin(user_in: UserCreate):
user = await User.get_or_none(username=user_in.username)
if user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Username already registered",
)
user = await User.get_or_none(email=user_in.email)
if user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered",
)
hashed_password = get_password_hash(user_in.password)
user = await User.create(
username=user_in.username,
email=user_in.email,
hashed_password=hashed_password,
is_superuser=False, # Admin creates regular users by default
is_active=True,
)
return await User_Pydantic.from_tortoise_orm(user)
@router.put("/users/{user_id}", response_model=User_Pydantic)
async def update_user_by_admin(user_id: int, user_update: UserAdminUpdate):
user = await User.get_or_none(id=user_id)
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
if user_update.username is not None and user_update.username != user.username:
if await User.get_or_none(username=user_update.username):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Username already taken")
user.username = user_update.username
if user_update.email is not None and user_update.email != user.email:
if await User.get_or_none(email=user_update.email):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Email already registered")
user.email = user_update.email
if user_update.password is not None:
user.hashed_password = get_password_hash(user_update.password)
if user_update.is_active is not None:
user.is_active = user_update.is_active
if user_update.is_superuser is not None:
user.is_superuser = user_update.is_superuser
if user_update.storage_quota_bytes is not None:
user.storage_quota_bytes = user_update.storage_quota_bytes
if user_update.plan_type is not None:
user.plan_type = user_update.plan_type
if user_update.is_2fa_enabled is not None:
user.is_2fa_enabled = user_update.is_2fa_enabled
if not user_update.is_2fa_enabled:
user.two_factor_secret = None
user.recovery_codes = None
await user.save()
return await User_Pydantic.from_tortoise_orm(user)
@router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_user_by_admin(user_id: int):
user = await User.get_or_none(id=user_id)
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
await user.delete()
return {"message": "User deleted successfully"}
@router.post("/test-email")
async def send_test_email(to_email: str, subject: str = "Test Email", body: str = "This is a test email"):
from ..mail import queue_email
queue_email(
to_email=to_email,
subject=subject,
body=body,
html=f"<h1>{subject}</h1><p>{body}</p>"
)
return {"message": "Test email queued"}
+116
View File
@@ -0,0 +1,116 @@
from fastapi import APIRouter, Depends, HTTPException
from typing import List
from decimal import Decimal
from pydantic import BaseModel
from ..auth import get_current_user
from ..models import User
from ..billing.models import PricingConfig, Invoice, SubscriptionPlan
from ..billing.invoice_generator import InvoiceGenerator
def require_superuser(current_user: User = Depends(get_current_user)):
if not current_user.is_superuser:
raise HTTPException(status_code=403, detail="Superuser privileges required")
return current_user
router = APIRouter(
prefix="/api/admin/billing",
tags=["admin", "billing"],
dependencies=[Depends(require_superuser)]
)
class PricingConfigUpdate(BaseModel):
config_key: str
config_value: float
class PlanCreate(BaseModel):
name: str
display_name: str
description: str
storage_gb: int
bandwidth_gb: int
price_monthly: float
price_yearly: float = None
@router.get("/pricing")
async def get_all_pricing(current_user: User = Depends(require_superuser)):
configs = await PricingConfig.all()
return [
{
"id": c.id,
"config_key": c.config_key,
"config_value": float(c.config_value),
"description": c.description,
"unit": c.unit,
"updated_at": c.updated_at
}
for c in configs
]
@router.put("/pricing/{config_id}")
async def update_pricing(
config_id: int,
update: PricingConfigUpdate,
current_user: User = Depends(require_superuser)
):
config = await PricingConfig.get_or_none(id=config_id)
if not config:
raise HTTPException(status_code=404, detail="Config not found")
config.config_value = Decimal(str(update.config_value))
config.updated_by = current_user
await config.save()
return {"message": "Pricing updated successfully"}
@router.post("/generate-invoices/{year}/{month}")
async def generate_all_invoices(
year: int,
month: int,
current_user: User = Depends(require_superuser)
):
users = await User.filter(is_active=True).all()
generated = []
skipped = []
for user in users:
invoice = await InvoiceGenerator.generate_monthly_invoice(user, year, month)
if invoice:
generated.append({
"user_id": user.id,
"invoice_id": invoice.id,
"total": float(invoice.total)
})
else:
skipped.append(user.id)
return {
"generated": len(generated),
"skipped": len(skipped),
"invoices": generated
}
@router.post("/plans")
async def create_plan(
plan_data: PlanCreate,
current_user: User = Depends(require_superuser)
):
plan = await SubscriptionPlan.create(**plan_data.dict())
return {"id": plan.id, "message": "Plan created successfully"}
@router.get("/stats")
async def get_billing_stats(current_user: User = Depends(require_superuser)):
from tortoise.functions import Sum, Count
total_revenue = await Invoice.filter(status="paid").annotate(
total_sum=Sum("total")
).values("total_sum")
invoice_count = await Invoice.all().count()
pending_invoices = await Invoice.filter(status="open").count()
return {
"total_revenue": float(total_revenue[0]["total_sum"] or 0),
"total_invoices": invoice_count,
"pending_invoices": pending_invoices
}
+174
View File
@@ -0,0 +1,174 @@
from datetime import timedelta
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from ..auth import authenticate_user, create_access_token, get_password_hash, get_current_user, get_current_verified_user, verify_password
from ..models import User
from ..schemas import Token, UserCreate, TokenData, UserLoginWith2FA
from ..two_factor import (
generate_totp_secret, generate_totp_uri, generate_qr_code_base64,
verify_totp_code, generate_recovery_codes, hash_recovery_codes,
verify_recovery_codes
)
router = APIRouter(
prefix="/auth",
tags=["auth"],
)
class LoginRequest(BaseModel):
username: str
password: str
class TwoFactorLogin(BaseModel):
username: str
password: str
two_factor_code: Optional[str] = None
class TwoFactorSetupResponse(BaseModel):
secret: str
qr_code_base64: str
recovery_codes: List[str]
class TwoFactorCode(BaseModel):
two_factor_code: str
class TwoFactorDisable(BaseModel):
password: str
two_factor_code: str
@router.post("/register", response_model=Token)
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,
)
# Send welcome email
from ..mail import queue_email
queue_email(
to_email=user.email,
subject="Welcome to MyWebdav!",
body=f"Hi {user.username},\n\nWelcome to MyWebdav! Your account has been created successfully.\n\nBest regards,\nThe MyWebdav Team",
html=f"<h1>Welcome to MyWebdav!</h1><p>Hi {user.username},</p><p>Welcome to MyWebdav! Your account has been created successfully.</p><p>Best regards,<br>The MyWebdav Team</p>"
)
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(login_data: LoginRequest):
auth_result = await authenticate_user(login_data.username, login_data.password, None)
if not auth_result:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
user = auth_result["user"]
if auth_result["2fa_required"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Two-factor authentication required",
headers={"X-2FA-Required": "true"},
)
access_token_expires = timedelta(minutes=30)
access_token = create_access_token(
data={"sub": user.username}, expires_delta=access_token_expires, two_factor_verified=True
)
return {"access_token": access_token, "token_type": "bearer"}
@router.post("/2fa/setup", response_model=TwoFactorSetupResponse)
async def setup_two_factor_authentication(current_user: User = Depends(get_current_user)):
if current_user.is_2fa_enabled:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="2FA is already enabled.")
if current_user.two_factor_secret:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="2FA setup already initiated. Verify or disable first.")
secret = generate_totp_secret()
current_user.two_factor_secret = secret
await current_user.save()
totp_uri = generate_totp_uri(secret, current_user.email, "RBox")
qr_code_base64 = generate_qr_code_base64(totp_uri)
recovery_codes = generate_recovery_codes()
hashed_recovery_codes = hash_recovery_codes(recovery_codes)
current_user.recovery_codes = ",".join(hashed_recovery_codes)
await current_user.save()
return TwoFactorSetupResponse(secret=secret, qr_code_base64=qr_code_base64, recovery_codes=recovery_codes)
@router.post("/2fa/verify", response_model=Token)
async def verify_two_factor_authentication(two_factor_code_data: TwoFactorCode, current_user: User = Depends(get_current_user)):
if current_user.is_2fa_enabled:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="2FA is already enabled.")
if not current_user.two_factor_secret:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="2FA setup not initiated.")
if not verify_totp_code(current_user.two_factor_secret, two_factor_code_data.two_factor_code):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid 2FA code.")
current_user.is_2fa_enabled = True
await current_user.save()
access_token_expires = timedelta(minutes=30) # Use settings
access_token = create_access_token(
data={"sub": current_user.username}, expires_delta=access_token_expires, two_factor_verified=True
)
return {"access_token": access_token, "token_type": "bearer"}
@router.post("/2fa/disable", response_model=dict)
async def disable_two_factor_authentication(disable_data: TwoFactorDisable, current_user: User = Depends(get_current_verified_user)):
if not current_user.is_2fa_enabled:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="2FA is not enabled.")
# Verify password
if not verify_password(disable_data.password, current_user.hashed_password):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid password.")
# Verify 2FA code
if not verify_totp_code(current_user.two_factor_secret, disable_data.two_factor_code):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid 2FA code.")
current_user.two_factor_secret = None
current_user.is_2fa_enabled = False
current_user.recovery_codes = None
await current_user.save()
return {"message": "2FA disabled successfully."}
@router.get("/2fa/recovery-codes", response_model=List[str])
async def get_new_recovery_codes(current_user: User = Depends(get_current_verified_user)):
if not current_user.is_2fa_enabled:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="2FA is not enabled.")
recovery_codes = generate_recovery_codes()
hashed_recovery_codes = hash_recovery_codes(recovery_codes)
current_user.recovery_codes = ",".join(hashed_recovery_codes)
await current_user.save()
return recovery_codes
+374
View File
@@ -0,0 +1,374 @@
from fastapi import APIRouter, Depends, HTTPException, status, Request
from fastapi.responses import JSONResponse
from typing import List, Optional
from datetime import datetime, date
from decimal import Decimal
import calendar
from ..auth import get_current_user
from ..models import User
from ..billing.models import (
Invoice, InvoiceLineItem, UserSubscription, PricingConfig,
PaymentMethod, UsageAggregate, SubscriptionPlan
)
from ..billing.usage_tracker import UsageTracker
from ..billing.invoice_generator import InvoiceGenerator
from ..billing.stripe_client import StripeClient
from pydantic import BaseModel
router = APIRouter(
prefix="/api/billing",
tags=["billing"]
)
class UsageResponse(BaseModel):
storage_gb_avg: float
storage_gb_peak: float
bandwidth_up_gb: float
bandwidth_down_gb: float
total_bandwidth_gb: float
period: str
class InvoiceResponse(BaseModel):
id: int
invoice_number: str
period_start: date
period_end: date
subtotal: float
tax: float
total: float
status: str
due_date: Optional[date]
paid_at: Optional[datetime]
line_items: List[dict]
class SubscriptionResponse(BaseModel):
id: int
billing_type: str
plan_name: Optional[str]
status: str
current_period_start: Optional[datetime]
current_period_end: Optional[datetime]
@router.get("/usage/current")
async def get_current_usage(current_user: User = Depends(get_current_user)):
try:
storage_bytes = await UsageTracker.get_current_storage(current_user)
today = date.today()
usage_today = await UsageAggregate.get_or_none(user=current_user, date=today)
if usage_today:
return {
"storage_gb": round(storage_bytes / (1024**3), 4),
"bandwidth_down_gb_today": round(usage_today.bandwidth_down_bytes / (1024**3), 4),
"bandwidth_up_gb_today": round(usage_today.bandwidth_up_bytes / (1024**3), 4),
"as_of": today.isoformat()
}
return {
"storage_gb": round(storage_bytes / (1024**3), 4),
"bandwidth_down_gb_today": 0,
"bandwidth_up_gb_today": 0,
"as_of": today.isoformat()
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to fetch usage data: {str(e)}")
@router.get("/usage/monthly")
async def get_monthly_usage(
year: Optional[int] = None,
month: Optional[int] = None,
current_user: User = Depends(get_current_user)
) -> UsageResponse:
try:
if year is None or month is None:
now = datetime.now()
year = now.year
month = now.month
if not (1 <= month <= 12):
raise HTTPException(status_code=400, detail="Month must be between 1 and 12")
if not (2020 <= year <= 2100):
raise HTTPException(status_code=400, detail="Year must be between 2020 and 2100")
usage = await UsageTracker.get_monthly_usage(current_user, year, month)
return UsageResponse(
**usage,
period=f"{year}-{month:02d}"
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to fetch monthly usage: {str(e)}")
@router.get("/invoices")
async def list_invoices(
limit: int = 50,
offset: int = 0,
current_user: User = Depends(get_current_user)
) -> List[InvoiceResponse]:
try:
if limit < 1 or limit > 100:
raise HTTPException(status_code=400, detail="Limit must be between 1 and 100")
if offset < 0:
raise HTTPException(status_code=400, detail="Offset must be non-negative")
invoices = await Invoice.filter(user=current_user).order_by("-created_at").offset(offset).limit(limit).all()
result = []
for invoice in invoices:
line_items = await invoice.line_items.all()
result.append(InvoiceResponse(
id=invoice.id,
invoice_number=invoice.invoice_number,
period_start=invoice.period_start,
period_end=invoice.period_end,
subtotal=float(invoice.subtotal),
tax=float(invoice.tax),
total=float(invoice.total),
status=invoice.status,
due_date=invoice.due_date,
paid_at=invoice.paid_at,
line_items=[
{
"description": item.description,
"quantity": float(item.quantity),
"unit_price": float(item.unit_price),
"amount": float(item.amount),
"type": item.item_type
}
for item in line_items
]
))
return result
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to fetch invoices: {str(e)}")
@router.get("/invoices/{invoice_id}")
async def get_invoice(
invoice_id: int,
current_user: User = Depends(get_current_user)
) -> InvoiceResponse:
invoice = await Invoice.get_or_none(id=invoice_id, user=current_user)
if not invoice:
raise HTTPException(status_code=404, detail="Invoice not found")
line_items = await invoice.line_items.all()
return InvoiceResponse(
id=invoice.id,
invoice_number=invoice.invoice_number,
period_start=invoice.period_start,
period_end=invoice.period_end,
subtotal=float(invoice.subtotal),
tax=float(invoice.tax),
total=float(invoice.total),
status=invoice.status,
due_date=invoice.due_date,
paid_at=invoice.paid_at,
line_items=[
{
"description": item.description,
"quantity": float(item.quantity),
"unit_price": float(item.unit_price),
"amount": float(item.amount),
"type": item.item_type
}
for item in line_items
]
)
@router.get("/subscription")
async def get_subscription(current_user: User = Depends(get_current_user)) -> SubscriptionResponse:
subscription = await UserSubscription.get_or_none(user=current_user)
if not subscription:
subscription = await UserSubscription.create(
user=current_user,
billing_type="pay_as_you_go",
status="active"
)
plan_name = None
if subscription.plan:
plan = await subscription.plan
plan_name = plan.display_name
return SubscriptionResponse(
id=subscription.id,
billing_type=subscription.billing_type,
plan_name=plan_name,
status=subscription.status,
current_period_start=subscription.current_period_start,
current_period_end=subscription.current_period_end
)
@router.post("/payment-methods/setup-intent")
async def create_setup_intent(current_user: User = Depends(get_current_user)):
try:
from ..settings import settings
if not settings.STRIPE_SECRET_KEY:
raise HTTPException(status_code=503, detail="Payment processing not configured")
subscription = await UserSubscription.get_or_none(user=current_user)
if not subscription or not subscription.stripe_customer_id:
customer_id = await StripeClient.create_customer(
email=current_user.email,
name=current_user.username,
metadata={"user_id": str(current_user.id)}
)
if not subscription:
subscription = await UserSubscription.create(
user=current_user,
billing_type="pay_as_you_go",
stripe_customer_id=customer_id,
status="active"
)
else:
subscription.stripe_customer_id = customer_id
await subscription.save()
import stripe
StripeClient._ensure_api_key()
setup_intent = stripe.SetupIntent.create(
customer=subscription.stripe_customer_id,
payment_method_types=["card"]
)
return {
"client_secret": setup_intent.client_secret,
"customer_id": subscription.stripe_customer_id
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to create setup intent: {str(e)}")
@router.get("/payment-methods")
async def list_payment_methods(current_user: User = Depends(get_current_user)):
methods = await PaymentMethod.filter(user=current_user).all()
return [
{
"id": m.id,
"type": m.type,
"last4": m.last4,
"brand": m.brand,
"exp_month": m.exp_month,
"exp_year": m.exp_year,
"is_default": m.is_default
}
for m in methods
]
@router.post("/webhooks/stripe")
async def stripe_webhook(request: Request):
import stripe
from ..settings import settings
from ..billing.models import BillingEvent
try:
payload = await request.body()
sig_header = request.headers.get("stripe-signature")
if not settings.STRIPE_WEBHOOK_SECRET:
raise HTTPException(status_code=503, detail="Webhook secret not configured")
try:
event = stripe.Webhook.construct_event(
payload, sig_header, settings.STRIPE_WEBHOOK_SECRET
)
except ValueError as e:
raise HTTPException(status_code=400, detail=f"Invalid payload: {str(e)}")
except stripe.error.SignatureVerificationError as e:
raise HTTPException(status_code=400, detail=f"Invalid signature: {str(e)}")
event_id = event.get("id")
existing_event = await BillingEvent.get_or_none(stripe_event_id=event_id)
if existing_event:
return JSONResponse(content={"status": "already_processed"})
await BillingEvent.create(
event_type=event["type"],
stripe_event_id=event_id,
data=event["data"],
processed=False
)
if event["type"] == "invoice.payment_succeeded":
invoice_data = event["data"]["object"]
mywebdav_invoice_id = invoice_data.get("metadata", {}).get("mywebdav_invoice_id")
if mywebdav_invoice_id:
invoice = await Invoice.get_or_none(id=int(mywebdav_invoice_id))
if invoice:
await InvoiceGenerator.mark_invoice_paid(invoice)
elif event["type"] == "invoice.payment_failed":
pass
elif event["type"] == "payment_method.attached":
payment_method = event["data"]["object"]
customer_id = payment_method["customer"]
subscription = await UserSubscription.get_or_none(stripe_customer_id=customer_id)
if subscription:
await PaymentMethod.create(
user=subscription.user,
stripe_payment_method_id=payment_method["id"],
type=payment_method["type"],
last4=payment_method.get("card", {}).get("last4"),
brand=payment_method.get("card", {}).get("brand"),
exp_month=payment_method.get("card", {}).get("exp_month"),
exp_year=payment_method.get("card", {}).get("exp_year"),
is_default=True
)
await BillingEvent.filter(stripe_event_id=event_id).update(processed=True)
return JSONResponse(content={"status": "success"})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Webhook processing failed: {str(e)}")
@router.get("/pricing")
async def get_pricing():
configs = await PricingConfig.all()
return {
config.config_key: {
"value": float(config.config_value),
"description": config.description,
"unit": config.unit
}
for config in configs
}
@router.get("/plans")
async def list_plans():
plans = await SubscriptionPlan.filter(is_active=True).all()
return [
{
"id": plan.id,
"name": plan.name,
"display_name": plan.display_name,
"description": plan.description,
"storage_gb": plan.storage_gb,
"bandwidth_gb": plan.bandwidth_gb,
"price_monthly": float(plan.price_monthly),
"price_yearly": float(plan.price_yearly) if plan.price_yearly else None
}
for plan in plans
]
@router.get("/stripe-key")
async def get_stripe_key():
from ..settings import settings
if not settings.STRIPE_PUBLISHABLE_KEY:
raise HTTPException(status_code=503, detail="Payment processing not configured")
return {"publishable_key": settings.STRIPE_PUBLISHABLE_KEY}
+482
View File
@@ -0,0 +1,482 @@
from fastapi import APIRouter, Depends, UploadFile, File as FastAPIFile, HTTPException, status, Response, Form
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
class BatchFileOperation(BaseModel):
file_ids: List[int]
operation: str # e.g., "delete", "star", "unstar", "move", "copy"
class BatchMoveCopyPayload(BaseModel):
target_folder_id: Optional[int] = None
class FileContentUpdate(BaseModel):
content: str
@router.post("/upload", response_model=FileOut, status_code=status.HTTP_201_CREATED)
async def upload_file(
file: UploadFile = FastAPIFile(...),
folder_id: Optional[int] = Form(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")
db_file.last_accessed_at = datetime.now()
await db_file.save()
try:
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()
await delete_thumbnail(db_file.id)
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")
db_file.last_accessed_at = datetime.now()
await db_file.save()
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]
@router.get("/recent", response_model=List[FileOut])
async def list_recent_files(current_user: User = Depends(get_current_user), limit: int = 10):
files = await File.filter(
owner=current_user,
is_deleted=False,
last_accessed_at__isnull=False
).order_by("-last_accessed_at").limit(limit)
return [await FileOut.from_tortoise_orm(f) for f in files]
@router.post("/{file_id}/star", response_model=FileOut)
async def star_file(file_id: int, current_user: User = Depends(get_current_user)):
db_file = await File.get_or_none(id=file_id, owner=current_user, is_deleted=False)
if not db_file:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
db_file.is_starred = True
await db_file.save()
await log_activity(user=current_user, action="file_starred", target_type="file", target_id=file_id)
return await FileOut.from_tortoise_orm(db_file)
@router.post("/{file_id}/unstar", response_model=FileOut)
async def unstar_file(file_id: int, current_user: User = Depends(get_current_user)):
db_file = await File.get_or_none(id=file_id, owner=current_user, is_deleted=False)
if not db_file:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
db_file.is_starred = False
await db_file.save()
await log_activity(user=current_user, action="file_unstarred", target_type="file", target_id=file_id)
return await FileOut.from_tortoise_orm(db_file)
@router.get("/deleted", response_model=List[FileOut])
async def list_deleted_files(current_user: User = Depends(get_current_user)):
files = await File.filter(owner=current_user, is_deleted=True).order_by("-deleted_at")
return [await FileOut.from_tortoise_orm(f) for f in files]
@router.post("/{file_id}/restore", response_model=FileOut)
async def restore_file(file_id: int, current_user: User = Depends(get_current_user)):
db_file = await File.get_or_none(id=file_id, owner=current_user, is_deleted=True)
if not db_file:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Deleted file not found")
# Check if a file with the same name exists in the parent folder
existing_file = await File.get_or_none(
name=db_file.name, parent=db_file.parent, owner=current_user, is_deleted=False
)
if existing_file:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="A file with the same name already exists in this location. Please rename the existing file or restore to a different location.")
db_file.is_deleted = False
db_file.deleted_at = None
await db_file.save()
await log_activity(user=current_user, action="file_restored", target_type="file", target_id=file_id)
return await FileOut.from_tortoise_orm(db_file)
class BatchOperationResult(BaseModel):
succeeded: List[FileOut]
failed: List[dict]
@router.post("/batch")
async def batch_file_operations(
batch_operation: BatchFileOperation,
payload: Optional[BatchMoveCopyPayload] = None,
current_user: User = Depends(get_current_user)
):
if batch_operation.operation not in ["delete", "star", "unstar", "move", "copy"]:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid operation: {batch_operation.operation}")
updated_files = []
failed_operations = []
for file_id in batch_operation.file_ids:
try:
db_file = await File.get_or_none(id=file_id, owner=current_user, is_deleted=False)
if not db_file:
failed_operations.append({"file_id": file_id, "reason": "File not found or not owned by user"})
continue
if batch_operation.operation == "delete":
db_file.is_deleted = True
db_file.deleted_at = datetime.now()
await db_file.save()
await delete_thumbnail(db_file.id)
await log_activity(user=current_user, action="file_deleted_batch", target_type="file", target_id=file_id)
updated_files.append(db_file)
elif batch_operation.operation == "star":
db_file.is_starred = True
await db_file.save()
await log_activity(user=current_user, action="file_starred_batch", target_type="file", target_id=file_id)
updated_files.append(db_file)
elif batch_operation.operation == "unstar":
db_file.is_starred = False
await db_file.save()
await log_activity(user=current_user, action="file_unstarred_batch", target_type="file", target_id=file_id)
updated_files.append(db_file)
elif batch_operation.operation == "move":
if not payload or payload.target_folder_id is None:
failed_operations.append({"file_id": file_id, "reason": "Target folder not specified"})
continue
target_folder = await Folder.get_or_none(id=payload.target_folder_id, owner=current_user, is_deleted=False)
if not target_folder:
failed_operations.append({"file_id": file_id, "reason": "Target folder not found"})
continue
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:
failed_operations.append({"file_id": file_id, "reason": "File with same name exists in target folder"})
continue
db_file.parent = target_folder
await db_file.save()
await log_activity(user=current_user, action="file_moved_batch", target_type="file", target_id=file_id)
updated_files.append(db_file)
elif batch_operation.operation == "copy":
if not payload or payload.target_folder_id is None:
failed_operations.append({"file_id": file_id, "reason": "Target folder not specified"})
continue
target_folder = await Folder.get_or_none(id=payload.target_folder_id, owner=current_user, is_deleted=False)
if not target_folder:
failed_operations.append({"file_id": file_id, "reason": "Target folder not found"})
continue
base_name = db_file.name
name_parts = os.path.splitext(base_name)
counter = 1
new_name = base_name
while await File.get_or_none(name=new_name, parent=target_folder, owner=current_user, is_deleted=False):
new_name = f"{name_parts[0]} (copy {counter}){name_parts[1]}"
counter += 1
new_file = await File.create(
name=new_name,
path=db_file.path,
size=db_file.size,
mime_type=db_file.mime_type,
file_hash=db_file.file_hash,
owner=current_user,
parent=target_folder
)
await log_activity(user=current_user, action="file_copied_batch", target_type="file", target_id=new_file.id)
updated_files.append(new_file)
except Exception as e:
failed_operations.append({"file_id": file_id, "reason": str(e)})
return {
"succeeded": [await FileOut.from_tortoise_orm(f) for f in updated_files],
"failed": failed_operations
}
@router.put("/{file_id}/content", response_model=FileOut)
async def update_file_content(
file_id: int,
payload: FileContentUpdate,
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")
if not db_file.mime_type or not db_file.mime_type.startswith('text/'):
editableExtensions = [
'txt', 'md', 'log', 'json', 'js', 'py', 'html', 'css',
'xml', 'yaml', 'yml', 'sh', 'bat', 'ini', 'conf', 'cfg'
]
file_extension = os.path.splitext(db_file.name)[1][1:].lower()
if file_extension not in editableExtensions:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="File type is not editable"
)
content_bytes = payload.content.encode('utf-8')
new_size = len(content_bytes)
size_diff = new_size - db_file.size
if current_user.used_storage_bytes + size_diff > current_user.storage_quota_bytes:
raise HTTPException(
status_code=status.HTTP_507_INSUFFICIENT_STORAGE,
detail="Storage quota exceeded"
)
new_hash = hashlib.sha256(content_bytes).hexdigest()
file_extension = os.path.splitext(db_file.name)[1]
new_storage_path = f"{new_hash}{file_extension}"
await storage_manager.save_file(current_user.id, new_storage_path, content_bytes)
if new_storage_path != db_file.path:
try:
await storage_manager.delete_file(current_user.id, db_file.path)
except:
pass
db_file.path = new_storage_path
db_file.size = new_size
db_file.file_hash = new_hash
db_file.updated_at = datetime.utcnow()
await db_file.save()
current_user.used_storage_bytes += size_diff
await current_user.save()
await log_activity(user=current_user, action="file_updated", target_type="file", target_id=file_id)
return await FileOut.from_tortoise_orm(db_file)
+190
View File
@@ -0,0 +1,190 @@
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, BatchFolderOperation, BatchMoveCopyPayload
from ..activity import log_activity
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
)
await log_activity(current_user, "folder_created", "folder", folder.id)
return await FolderOut.from_tortoise_orm(folder)
@router.get("/{folder_id}/path", response_model=List[FolderOut])
async def get_folder_path(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")
path = []
current = folder
while current:
path.insert(0, await FolderOut.from_tortoise_orm(current))
if current.parent_id:
current = await Folder.get_or_none(id=current.parent_id, owner=current_user, is_deleted=False)
else:
current = None
return path
@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()
await log_activity(current_user, "folder_updated", "folder", folder.id)
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")
folder.is_deleted = True
await folder.save()
await log_activity(current_user, "folder_deleted", "folder", folder.id)
return
@router.post("/{folder_id}/star", response_model=FolderOut)
async def star_folder(folder_id: int, current_user: User = Depends(get_current_user)):
db_folder = await Folder.get_or_none(id=folder_id, owner=current_user, is_deleted=False)
if not db_folder:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Folder not found")
db_folder.is_starred = True
await db_folder.save()
await log_activity(current_user, "folder_starred", "folder", folder_id)
return await FolderOut.from_tortoise_orm(db_folder)
@router.post("/{folder_id}/unstar", response_model=FolderOut)
async def unstar_folder(folder_id: int, current_user: User = Depends(get_current_user)):
db_folder = await Folder.get_or_none(id=folder_id, owner=current_user, is_deleted=False)
if not db_folder:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Folder not found")
db_folder.is_starred = False
await db_folder.save()
await log_activity(current_user, "folder_unstarred", "folder", folder_id)
return await FolderOut.from_tortoise_orm(db_folder)
@router.post("/batch", response_model=List[FolderOut])
async def batch_folder_operations(
batch_operation: BatchFolderOperation,
payload: Optional[BatchMoveCopyPayload] = None,
current_user: User = Depends(get_current_user)
):
updated_folders = []
for folder_id in batch_operation.folder_ids:
db_folder = await Folder.get_or_none(id=folder_id, owner=current_user, is_deleted=False)
if not db_folder:
continue # Skip if folder not found or not owned by user
if batch_operation.operation == "delete":
db_folder.is_deleted = True
await db_folder.save()
await log_activity(current_user, "folder_deleted", "folder", folder_id)
updated_folders.append(db_folder)
elif batch_operation.operation == "star":
db_folder.is_starred = True
await db_folder.save()
await log_activity(current_user, "folder_starred", "folder", folder_id)
updated_folders.append(db_folder)
elif batch_operation.operation == "unstar":
db_folder.is_starred = False
await db_folder.save()
await log_activity(current_user, "folder_unstarred", "folder", folder_id)
updated_folders.append(db_folder)
elif batch_operation.operation == "move" and payload and payload.target_folder_id is not None:
target_folder = await Folder.get_or_none(id=payload.target_folder_id, owner=current_user, is_deleted=False)
if not target_folder:
continue
existing_folder = await Folder.get_or_none(
name=db_folder.name, parent=target_folder, owner=current_user, is_deleted=False
)
if existing_folder and existing_folder.id != folder_id:
continue
db_folder.parent = target_folder
await db_folder.save()
await log_activity(current_user, "folder_moved", "folder", folder_id)
updated_folders.append(db_folder)
return [await FolderOut.from_tortoise_orm(f) for f in updated_folders]
+55
View File
@@ -0,0 +1,55 @@
from fastapi import APIRouter, Depends, Query
from typing import List, Optional
from datetime import datetime
from ..auth import get_current_user
from ..models import User, File, Folder
from ..schemas import FileOut, FolderOut
router = APIRouter(
prefix="/search",
tags=["search"],
)
@router.get("/files", response_model=List[FileOut])
async def search_files(
q: str = Query(..., min_length=1, description="Search query"),
file_type: Optional[str] = Query(None, description="Filter by MIME type prefix (e.g., 'image', 'video')"),
min_size: Optional[int] = Query(None, description="Minimum file size in bytes"),
max_size: Optional[int] = Query(None, description="Maximum file size in bytes"),
date_from: Optional[datetime] = Query(None, description="Filter files created after this date"),
date_to: Optional[datetime] = Query(None, description="Filter files created before this date"),
current_user: User = Depends(get_current_user)
):
query = File.filter(owner=current_user, is_deleted=False, name__icontains=q)
if file_type:
query = query.filter(mime_type__istartswith=file_type)
if min_size is not None:
query = query.filter(size__gte=min_size)
if max_size is not None:
query = query.filter(size__lte=max_size)
if date_from:
query = query.filter(created_at__gte=date_from)
if date_to:
query = query.filter(created_at__lte=date_to)
files = await query.order_by("-created_at").limit(100)
return [await FileOut.from_tortoise_orm(f) for f in files]
@router.get("/folders", response_model=List[FolderOut])
async def search_folders(
q: str = Query(..., min_length=1, description="Search query"),
current_user: User = Depends(get_current_user)
):
folders = await Folder.filter(
owner=current_user,
is_deleted=False,
name__icontains=q
).order_by("-created_at").limit(100)
return [await FolderOut.from_tortoise_orm(folder) for folder in folders]
+226
View File
@@ -0,0 +1,226 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import StreamingResponse
from typing import Optional, List
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, FileOut, FolderOut
from ..auth import get_password_hash, verify_password
from ..storage import storage_manager
from ..mail import send_email
from ..settings import settings
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,
)
if share_in.invite_email:
share_url = f"https://{settings.DOMAIN_NAME}/share/{token}"
item_type = "file" if file else "folder"
item_name = file.name if file else folder.name
expiry_text = f" until {share_in.expires_at.strftime('%Y-%m-%d %H:%M')}" if share_in.expires_at else ""
password_text = f"\n\nPassword: {share_in.password}" if share_in.password else ""
email_body = f"""Hello,
{current_user.username} has shared a {item_type} with you: {item_name}
Permission level: {share_in.permission_level}
Access link: {share_url}{password_text}
This link is valid{expiry_text}.
--
MyWebdav File Sharing Service"""
email_html = f"""
<html>
<body>
<p>Hello,</p>
<p><strong>{current_user.username}</strong> has shared a {item_type} with you: <strong>{item_name}</strong></p>
<p><strong>Permission level:</strong> {share_in.permission_level}</p>
<p><a href="{share_url}" style="background-color: #4CAF50; color: white; padding: 10px 20px; text-decoration: none; border-radius: 4px; display: inline-block;">Access {item_type.capitalize()}</a></p>
{f'<p><strong>Password:</strong> {share_in.password}</p>' if share_in.password else ''}
<p><small>This link is valid{expiry_text}.</small></p>
<hr>
<p><small>MyWebdav File Sharing Service</small></p>
</body>
</html>
"""
try:
await send_email(
to_email=share_in.invite_email,
subject=f"{current_user.username} shared {item_name} with you",
body=email_body,
html=email_html
)
except Exception as e:
print(f"Failed to send invitation email: {e}")
return await ShareOut.from_tortoise_orm(share)
@router.get("/my", response_model=List[ShareOut])
async def list_my_shares(current_user: User = Depends(get_current_user)):
shares = await Share.filter(owner=current_user).order_by("-created_at")
return [await ShareOut.from_tortoise_orm(share) for share in shares]
@router.get("/{share_token}", response_model=ShareOut)
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.put("/{share_id}", response_model=ShareOut)
async def update_share(share_id: int, share_in: ShareCreate, current_user: User = Depends(get_current_user)):
share = await Share.get_or_none(id=share_id, owner=current_user)
if not share:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Share link not found or does not belong to you")
if share_in.expires_at is not None:
share.expires_at = share_in.expires_at
if share_in.password is not None:
share.hashed_password = get_password_hash(share_in.password)
share.password_protected = True
elif share_in.password == "": # Allow clearing password
share.hashed_password = None
share.password_protected = False
if share_in.permission_level is not None:
share.permission_level = share_in.permission_level
await share.save()
return await ShareOut.from_tortoise_orm(share)
@router.post("/{share_token}/access")
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")
result = {"message": "Access granted", "permission_level": share.permission_level}
if share.file_id:
file = await File.get_or_none(id=share.file_id, is_deleted=False)
if not file:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
result["file"] = await FileOut.from_tortoise_orm(file)
result["type"] = "file"
elif share.folder_id:
folder = await Folder.get_or_none(id=share.folder_id, is_deleted=False)
if not folder:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Folder not found")
result["folder"] = await FolderOut.from_tortoise_orm(folder)
result["type"] = "folder"
files = await File.filter(parent=folder, is_deleted=False)
subfolders = await Folder.filter(parent=folder, is_deleted=False)
result["files"] = [await FileOut.from_tortoise_orm(f) for f in files]
result["folders"] = [await FolderOut.from_tortoise_orm(f) for f in subfolders]
return result
@router.get("/{share_token}/download")
async def download_shared_file(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")
if not share.file_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="This share is not for a file")
file = await File.get_or_none(id=share.file_id, is_deleted=False)
if not file:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
owner = await User.get(id=file.owner_id)
try:
async def file_iterator():
async for chunk in storage_manager.get_file(owner.id, file.path):
yield chunk
return StreamingResponse(
content=file_iterator(),
media_type=file.mime_type,
headers={
"Content-Disposition": f'attachment; filename="{file.name}"'
}
)
except FileNotFoundError:
raise HTTPException(status_code=404, detail="File not found in storage")
@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
+28
View File
@@ -0,0 +1,28 @@
from typing import List
from fastapi import APIRouter, Depends
from ..auth import get_current_user
from ..models import User, File, Folder
from ..schemas import FileOut, FolderOut
router = APIRouter(
prefix="/starred",
tags=["starred"],
)
@router.get("/files", response_model=List[FileOut])
async def list_starred_files(current_user: User = Depends(get_current_user)):
files = await File.filter(owner=current_user, is_starred=True, is_deleted=False).order_by("name")
return [await FileOut.from_tortoise_orm(f) for f in files]
@router.get("/folders", response_model=List[FolderOut])
async def list_starred_folders(current_user: User = Depends(get_current_user)):
folders = await Folder.filter(owner=current_user, is_starred=True, is_deleted=False).order_by("name")
return [await FolderOut.from_tortoise_orm(f) for f in folders]
@router.get("/all", response_model=List[FileOut]) # This will return files and folders as files for now
async def list_all_starred(current_user: User = Depends(get_current_user)):
starred_files = await File.filter(owner=current_user, is_starred=True, is_deleted=False).order_by("name")
# For simplicity, we'll return files only for now. A more complex solution would involve a union or a custom schema.
return [await FileOut.from_tortoise_orm(f) for f in starred_files]
+52
View File
@@ -0,0 +1,52 @@
from fastapi import APIRouter, Depends
from ..auth import get_current_user
from ..models import User_Pydantic, User, File, Folder
from typing import List, Dict, Any
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)
@router.get("/me/export", response_model=Dict[str, Any])
async def export_my_data(current_user: User = Depends(get_current_user)):
"""
Exports all personal data associated with the current user.
Includes user profile, and metadata for all owned files and folders.
"""
user_data = await User_Pydantic.from_tortoise_orm(current_user)
files = await File.filter(owner=current_user).values(
"id", "name", "size", "created_at", "modified_at", "file_type", "parent_id"
)
folders = await Folder.filter(owner=current_user).values(
"id", "name", "created_at", "modified_at", "parent_id"
)
return {
"user_profile": user_data.dict(),
"files_metadata": files,
"folders_metadata": folders,
# In a more complete implementation, other data like activity logs,
# share information, etc., would also be included.
}
@router.delete("/me", status_code=204)
async def delete_my_account(current_user: User = Depends(get_current_user)):
"""
Deletes the current user's account and all associated data.
This includes all files and folders owned by the user.
"""
# Delete all files and folders owned by the user
await File.filter(owner=current_user).delete()
await Folder.filter(owner=current_user).delete()
# Finally, delete the user account
await current_user.delete()
return {}