Rename.
This commit is contained in:
@@ -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"}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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)
|
||||
@@ -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]
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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 {}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user