2025-11-09 23:29:07 +01:00
|
|
|
from fastapi import APIRouter, Depends, HTTPException, status
|
2025-11-10 15:46:40 +01:00
|
|
|
from fastapi.responses import StreamingResponse
|
feat: add 2fa support, starred items, batch operations, and logging infrastructure
- Implement two-factor authentication with TOTP verification, recovery codes, and QR code setup in auth flow
- Add is_starred and last_accessed_at fields to File and Folder models for bookmarking and recency tracking
- Introduce batch operation endpoints for files and folders (delete, star, unstar, move, copy)
- Configure structured logging with timestamps and module names, replacing print statements
- Register admin and starred routers, add global HTTPException handler with JSON error responses
- Update authenticate_user and create_access_token to handle 2FA verification and token claims
2025-11-10 01:58:41 +01:00
|
|
|
from typing import Optional, List
|
2025-11-09 23:29:07 +01:00
|
|
|
import secrets
|
2025-11-13 23:22:05 +01:00
|
|
|
from datetime import datetime
|
2025-11-09 23:29:07 +01:00
|
|
|
|
|
|
|
|
from ..auth import get_current_user
|
|
|
|
|
from ..models import User, File, Folder, Share
|
2025-11-10 15:46:40 +01:00
|
|
|
from ..schemas import ShareCreate, ShareOut, FileOut, FolderOut
|
2025-11-09 23:29:07 +01:00
|
|
|
from ..auth import get_password_hash, verify_password
|
2025-11-10 15:46:40 +01:00
|
|
|
from ..storage import storage_manager
|
2025-11-11 15:06:02 +01:00
|
|
|
from ..mail import send_email
|
|
|
|
|
from ..settings import settings
|
2025-11-09 23:29:07 +01:00
|
|
|
|
|
|
|
|
router = APIRouter(
|
|
|
|
|
prefix="/shares",
|
|
|
|
|
tags=["shares"],
|
|
|
|
|
)
|
|
|
|
|
|
2025-11-13 23:22:05 +01:00
|
|
|
|
2025-11-09 23:29:07 +01:00
|
|
|
@router.post("/", response_model=ShareOut, status_code=status.HTTP_201_CREATED)
|
2025-11-13 23:22:05 +01:00
|
|
|
async def create_share_link(
|
|
|
|
|
share_in: ShareCreate, current_user: User = Depends(get_current_user)
|
|
|
|
|
):
|
2025-11-09 23:29:07 +01:00
|
|
|
if not share_in.file_id and not share_in.folder_id:
|
2025-11-13 23:22:05 +01:00
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
|
|
|
detail="Either file_id or folder_id must be provided",
|
|
|
|
|
)
|
2025-11-09 23:29:07 +01:00
|
|
|
if share_in.file_id and share_in.folder_id:
|
2025-11-13 23:22:05 +01:00
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
|
|
|
detail="Cannot share both a file and a folder simultaneously",
|
|
|
|
|
)
|
2025-11-09 23:29:07 +01:00
|
|
|
|
|
|
|
|
file = None
|
|
|
|
|
folder = None
|
|
|
|
|
|
|
|
|
|
if share_in.file_id:
|
2025-11-13 23:22:05 +01:00
|
|
|
file = await File.get_or_none(
|
|
|
|
|
id=share_in.file_id, owner=current_user, is_deleted=False
|
|
|
|
|
)
|
2025-11-09 23:29:07 +01:00
|
|
|
if not file:
|
2025-11-13 23:22:05 +01:00
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
|
|
|
detail="File not found or does not belong to you",
|
|
|
|
|
)
|
|
|
|
|
|
2025-11-09 23:29:07 +01:00
|
|
|
if share_in.folder_id:
|
2025-11-13 23:22:05 +01:00
|
|
|
folder = await Folder.get_or_none(
|
|
|
|
|
id=share_in.folder_id, owner=current_user, is_deleted=False
|
|
|
|
|
)
|
2025-11-09 23:29:07 +01:00
|
|
|
if not folder:
|
2025-11-13 23:22:05 +01:00
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
|
|
|
detail="Folder not found or does not belong to you",
|
|
|
|
|
)
|
2025-11-09 23:29:07 +01:00
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
)
|
2025-11-11 15:06:02 +01:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
2025-11-13 23:22:05 +01:00
|
|
|
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 ""
|
|
|
|
|
)
|
2025-11-11 15:06:02 +01:00
|
|
|
|
|
|
|
|
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}.
|
|
|
|
|
|
|
|
|
|
--
|
chore: rebrand all user-facing strings from RBox to MyWebdav across docs, emails, and config
Replace every occurrence of "RBox" with "MyWebdav" in README.md, invoice templates, welcome emails, share notifications, FastAPI metadata, default database filename, domain name, S3 bucket name, TOTP issuer, and WebDAV realm header to reflect the commercial rebranding of the application.
2025-11-13 12:05:05 +01:00
|
|
|
MyWebdav File Sharing Service"""
|
2025-11-11 15:06:02 +01:00
|
|
|
|
|
|
|
|
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>
|
chore: rebrand all user-facing strings from RBox to MyWebdav across docs, emails, and config
Replace every occurrence of "RBox" with "MyWebdav" in README.md, invoice templates, welcome emails, share notifications, FastAPI metadata, default database filename, domain name, S3 bucket name, TOTP issuer, and WebDAV realm header to reflect the commercial rebranding of the application.
2025-11-13 12:05:05 +01:00
|
|
|
<p><small>MyWebdav File Sharing Service</small></p>
|
2025-11-11 15:06:02 +01:00
|
|
|
</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,
|
2025-11-13 23:22:05 +01:00
|
|
|
html=email_html,
|
2025-11-11 15:06:02 +01:00
|
|
|
)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
print(f"Failed to send invitation email: {e}")
|
|
|
|
|
|
2025-11-09 23:29:07 +01:00
|
|
|
return await ShareOut.from_tortoise_orm(share)
|
|
|
|
|
|
2025-11-13 23:22:05 +01:00
|
|
|
|
feat: add 2fa support, starred items, batch operations, and logging infrastructure
- Implement two-factor authentication with TOTP verification, recovery codes, and QR code setup in auth flow
- Add is_starred and last_accessed_at fields to File and Folder models for bookmarking and recency tracking
- Introduce batch operation endpoints for files and folders (delete, star, unstar, move, copy)
- Configure structured logging with timestamps and module names, replacing print statements
- Register admin and starred routers, add global HTTPException handler with JSON error responses
- Update authenticate_user and create_access_token to handle 2FA verification and token claims
2025-11-10 01:58:41 +01:00
|
|
|
@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]
|
|
|
|
|
|
2025-11-13 23:22:05 +01:00
|
|
|
|
2025-11-09 23:29:07 +01:00
|
|
|
@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:
|
2025-11-13 23:22:05 +01:00
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_404_NOT_FOUND, detail="Share link not found"
|
|
|
|
|
)
|
|
|
|
|
|
2025-11-09 23:29:07 +01:00
|
|
|
if share.expires_at and share.expires_at < datetime.utcnow():
|
2025-11-13 23:22:05 +01:00
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_410_GONE, detail="Share link has expired"
|
|
|
|
|
)
|
|
|
|
|
|
2025-11-09 23:29:07 +01:00
|
|
|
# Increment access count
|
|
|
|
|
share.access_count += 1
|
|
|
|
|
await share.save()
|
|
|
|
|
|
|
|
|
|
return await ShareOut.from_tortoise_orm(share)
|
|
|
|
|
|
2025-11-13 23:22:05 +01:00
|
|
|
|
feat: add 2fa support, starred items, batch operations, and logging infrastructure
- Implement two-factor authentication with TOTP verification, recovery codes, and QR code setup in auth flow
- Add is_starred and last_accessed_at fields to File and Folder models for bookmarking and recency tracking
- Introduce batch operation endpoints for files and folders (delete, star, unstar, move, copy)
- Configure structured logging with timestamps and module names, replacing print statements
- Register admin and starred routers, add global HTTPException handler with JSON error responses
- Update authenticate_user and create_access_token to handle 2FA verification and token claims
2025-11-10 01:58:41 +01:00
|
|
|
@router.put("/{share_id}", response_model=ShareOut)
|
2025-11-13 23:22:05 +01:00
|
|
|
async def update_share(
|
|
|
|
|
share_id: int, share_in: ShareCreate, current_user: User = Depends(get_current_user)
|
|
|
|
|
):
|
feat: add 2fa support, starred items, batch operations, and logging infrastructure
- Implement two-factor authentication with TOTP verification, recovery codes, and QR code setup in auth flow
- Add is_starred and last_accessed_at fields to File and Folder models for bookmarking and recency tracking
- Introduce batch operation endpoints for files and folders (delete, star, unstar, move, copy)
- Configure structured logging with timestamps and module names, replacing print statements
- Register admin and starred routers, add global HTTPException handler with JSON error responses
- Update authenticate_user and create_access_token to handle 2FA verification and token claims
2025-11-10 01:58:41 +01:00
|
|
|
share = await Share.get_or_none(id=share_id, owner=current_user)
|
|
|
|
|
if not share:
|
2025-11-13 23:22:05 +01:00
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
|
|
|
detail="Share link not found or does not belong to you",
|
|
|
|
|
)
|
feat: add 2fa support, starred items, batch operations, and logging infrastructure
- Implement two-factor authentication with TOTP verification, recovery codes, and QR code setup in auth flow
- Add is_starred and last_accessed_at fields to File and Folder models for bookmarking and recency tracking
- Introduce batch operation endpoints for files and folders (delete, star, unstar, move, copy)
- Configure structured logging with timestamps and module names, replacing print statements
- Register admin and starred routers, add global HTTPException handler with JSON error responses
- Update authenticate_user and create_access_token to handle 2FA verification and token claims
2025-11-10 01:58:41 +01:00
|
|
|
|
|
|
|
|
if share_in.expires_at is not None:
|
|
|
|
|
share.expires_at = share_in.expires_at
|
2025-11-13 23:22:05 +01:00
|
|
|
|
feat: add 2fa support, starred items, batch operations, and logging infrastructure
- Implement two-factor authentication with TOTP verification, recovery codes, and QR code setup in auth flow
- Add is_starred and last_accessed_at fields to File and Folder models for bookmarking and recency tracking
- Introduce batch operation endpoints for files and folders (delete, star, unstar, move, copy)
- Configure structured logging with timestamps and module names, replacing print statements
- Register admin and starred routers, add global HTTPException handler with JSON error responses
- Update authenticate_user and create_access_token to handle 2FA verification and token claims
2025-11-10 01:58:41 +01:00
|
|
|
if share_in.password is not None:
|
|
|
|
|
share.hashed_password = get_password_hash(share_in.password)
|
|
|
|
|
share.password_protected = True
|
2025-11-13 23:22:05 +01:00
|
|
|
elif share_in.password == "": # Allow clearing password
|
feat: add 2fa support, starred items, batch operations, and logging infrastructure
- Implement two-factor authentication with TOTP verification, recovery codes, and QR code setup in auth flow
- Add is_starred and last_accessed_at fields to File and Folder models for bookmarking and recency tracking
- Introduce batch operation endpoints for files and folders (delete, star, unstar, move, copy)
- Configure structured logging with timestamps and module names, replacing print statements
- Register admin and starred routers, add global HTTPException handler with JSON error responses
- Update authenticate_user and create_access_token to handle 2FA verification and token claims
2025-11-10 01:58:41 +01:00
|
|
|
share.hashed_password = None
|
|
|
|
|
share.password_protected = False
|
|
|
|
|
|
|
|
|
|
if share_in.permission_level is not None:
|
|
|
|
|
share.permission_level = share_in.permission_level
|
2025-11-13 23:22:05 +01:00
|
|
|
|
feat: add 2fa support, starred items, batch operations, and logging infrastructure
- Implement two-factor authentication with TOTP verification, recovery codes, and QR code setup in auth flow
- Add is_starred and last_accessed_at fields to File and Folder models for bookmarking and recency tracking
- Introduce batch operation endpoints for files and folders (delete, star, unstar, move, copy)
- Configure structured logging with timestamps and module names, replacing print statements
- Register admin and starred routers, add global HTTPException handler with JSON error responses
- Update authenticate_user and create_access_token to handle 2FA verification and token claims
2025-11-10 01:58:41 +01:00
|
|
|
await share.save()
|
|
|
|
|
return await ShareOut.from_tortoise_orm(share)
|
|
|
|
|
|
2025-11-13 23:22:05 +01:00
|
|
|
|
2025-11-09 23:29:07 +01:00
|
|
|
@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:
|
2025-11-13 23:22:05 +01:00
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_404_NOT_FOUND, detail="Share link not found"
|
|
|
|
|
)
|
2025-11-10 15:46:40 +01:00
|
|
|
|
2025-11-09 23:29:07 +01:00
|
|
|
if share.expires_at and share.expires_at < datetime.utcnow():
|
2025-11-13 23:22:05 +01:00
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_410_GONE, detail="Share link has expired"
|
|
|
|
|
)
|
2025-11-09 23:29:07 +01:00
|
|
|
|
|
|
|
|
if share.password_protected:
|
|
|
|
|
if not password or not verify_password(password, share.hashed_password):
|
2025-11-13 23:22:05 +01:00
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect password"
|
|
|
|
|
)
|
2025-11-10 15:46:40 +01:00
|
|
|
|
|
|
|
|
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:
|
2025-11-13 23:22:05 +01:00
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_404_NOT_FOUND, detail="File not found"
|
|
|
|
|
)
|
2025-11-10 15:46:40 +01:00
|
|
|
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:
|
2025-11-13 23:22:05 +01:00
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_404_NOT_FOUND, detail="Folder not found"
|
|
|
|
|
)
|
2025-11-10 15:46:40 +01:00
|
|
|
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
|
|
|
|
|
|
2025-11-13 23:22:05 +01:00
|
|
|
|
2025-11-10 15:46:40 +01:00
|
|
|
@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:
|
2025-11-13 23:22:05 +01:00
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_404_NOT_FOUND, detail="Share link not found"
|
|
|
|
|
)
|
2025-11-10 15:46:40 +01:00
|
|
|
|
|
|
|
|
if share.expires_at and share.expires_at < datetime.utcnow():
|
2025-11-13 23:22:05 +01:00
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_410_GONE, detail="Share link has expired"
|
|
|
|
|
)
|
2025-11-10 15:46:40 +01:00
|
|
|
|
|
|
|
|
if share.password_protected:
|
|
|
|
|
if not password or not verify_password(password, share.hashed_password):
|
2025-11-13 23:22:05 +01:00
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect password"
|
|
|
|
|
)
|
2025-11-10 15:46:40 +01:00
|
|
|
|
|
|
|
|
if not share.file_id:
|
2025-11-13 23:22:05 +01:00
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
|
|
|
detail="This share is not for a file",
|
|
|
|
|
)
|
2025-11-10 15:46:40 +01:00
|
|
|
|
|
|
|
|
file = await File.get_or_none(id=share.file_id, is_deleted=False)
|
|
|
|
|
if not file:
|
2025-11-13 23:22:05 +01:00
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_404_NOT_FOUND, detail="File not found"
|
|
|
|
|
)
|
2025-11-10 15:46:40 +01:00
|
|
|
|
|
|
|
|
owner = await User.get(id=file.owner_id)
|
|
|
|
|
|
|
|
|
|
try:
|
2025-11-13 23:22:05 +01:00
|
|
|
|
2025-11-10 15:46:40 +01:00
|
|
|
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,
|
2025-11-13 23:22:05 +01:00
|
|
|
headers={"Content-Disposition": f'attachment; filename="{file.name}"'},
|
2025-11-10 15:46:40 +01:00
|
|
|
)
|
|
|
|
|
except FileNotFoundError:
|
|
|
|
|
raise HTTPException(status_code=404, detail="File not found in storage")
|
2025-11-09 23:29:07 +01:00
|
|
|
|
2025-11-13 23:22:05 +01:00
|
|
|
|
2025-11-09 23:29:07 +01:00
|
|
|
@router.delete("/{share_id}", status_code=status.HTTP_204_NO_CONTENT)
|
2025-11-13 23:22:05 +01:00
|
|
|
async def delete_share_link(
|
|
|
|
|
share_id: int, current_user: User = Depends(get_current_user)
|
|
|
|
|
):
|
2025-11-09 23:29:07 +01:00
|
|
|
share = await Share.get_or_none(id=share_id, owner=current_user)
|
|
|
|
|
if not share:
|
2025-11-13 23:22:05 +01:00
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
|
|
|
detail="Share link not found or does not belong to you",
|
|
|
|
|
)
|
|
|
|
|
|
2025-11-09 23:29:07 +01:00
|
|
|
await share.delete()
|
|
|
|
|
return
|