feat(sharing): add document sharing with expiring links

- Add SharedLink model with token, expiry, view limit, password hash
- Add migration 025_add_shared_links
- Add API endpoints: create, list, revoke (auth) + public info/download
- Add management UI at /shared-links with revoke controls
- Add public share landing page at /share/{token}
- Add Share button on file_view.html
- Add Shared Links to user dropdown in common.js
- Write 35 unit tests covering all scenarios
- Update UserGuide.md with sharing documentation

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-08 21:51:30 +00:00
parent 93b3959d2f
commit 0f91b8bb7c
13 changed files with 2170 additions and 0 deletions
+4
View File
@@ -30,6 +30,8 @@ from app.api.queue import router as queue_router
from app.api.saved_searches import router as saved_searches_router
from app.api.search import router as search_router
from app.api.settings import router as settings_router
from app.api.shared_links import public_router as shared_links_public_router
from app.api.shared_links import router as shared_links_router
from app.api.similarity import router as similarity_router
from app.api.subscriptions import router as subscriptions_router
from app.api.url_upload import router as url_upload_router
@@ -64,6 +66,8 @@ router.include_router(search_router)
router.include_router(queue_router)
router.include_router(saved_searches_router)
router.include_router(similarity_router)
router.include_router(shared_links_router)
router.include_router(shared_links_public_router)
router.include_router(duplicates_router)
router.include_router(webhooks_router)
router.include_router(database_router)
+442
View File
@@ -0,0 +1,442 @@
"""API endpoints for document sharing via expiring links.
Authenticated users can create time-limited or view-limited shareable
links for their documents. Each link has a cryptographically random
token that forms a public ``/share/<token>`` URL. Optional password
protection is supported; only a PBKDF2-HMAC-SHA256 hash is stored.
Public consumers access files through the ``/share/<token>/download``
and ``/share/<token>/info`` endpoints — no authentication required.
"""
import hashlib
import logging
import os
import secrets
from datetime import datetime, timezone
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field, field_validator
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import FileRecord, SharedLink
from app.utils.user_scope import apply_owner_filter, get_current_owner_id
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/shared-links", tags=["shared-links"])
public_router = APIRouter(tags=["shared-links-public"])
DbSession = Annotated[Session, Depends(get_db)]
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
#: PBKDF2 salt for shared-link password hashing (not secret, but fixed).
_PWD_HASH_SALT = b"shared-link-v1"
#: PBKDF2 iteration count.
_PWD_HASH_ITERATIONS = 100_000
# Valid expiry durations (in hours) presented in the UI.
EXPIRY_OPTIONS: dict[str, int] = {
"1h": 1,
"6h": 6,
"12h": 12,
"24h": 24,
"3d": 72,
"7d": 168,
"14d": 336,
"30d": 720,
}
# ---------------------------------------------------------------------------
# Auth helper
# ---------------------------------------------------------------------------
def _get_owner_id(request: Request) -> str:
"""Return the current user's owner ID, raising 401 if unauthenticated."""
owner_id = get_current_owner_id(request)
if not owner_id:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
return owner_id
CurrentOwner = Annotated[str, Depends(_get_owner_id)]
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _generate_token() -> str:
"""Generate a 43-character URL-safe random token."""
return secrets.token_urlsafe(32)
def _hash_password(password: str) -> str:
"""Return a PBKDF2-HMAC-SHA256 hex digest of *password*.
Args:
password: Plaintext password string.
Returns:
128-character lowercase hex string.
"""
dk = hashlib.pbkdf2_hmac(
"sha256",
password.encode("utf-8"),
_PWD_HASH_SALT,
_PWD_HASH_ITERATIONS,
)
return dk.hex()
def _verify_password(password: str, stored_hash: str) -> bool:
"""Check *password* against *stored_hash* using constant-time comparison."""
return secrets.compare_digest(_hash_password(password), stored_hash)
def _is_link_valid(link: SharedLink) -> bool:
"""Return True when *link* is active, unexpired, and within view limit."""
if not link.is_active:
return False
now = datetime.now(timezone.utc)
if link.expires_at is not None:
exp = link.expires_at
if exp.tzinfo is None:
exp = exp.replace(tzinfo=timezone.utc)
if now > exp:
return False
if link.max_views is not None and link.view_count >= link.max_views:
return False
return True
def _resolve_file_path(file_record: FileRecord) -> str | None:
"""Return the best available file path for *file_record*.
Checks processed path first, then original, then local (tmp) path.
Returns ``None`` when no file exists on disk.
"""
from app.config import settings
workdir = os.path.realpath(settings.workdir)
candidates = [
file_record.processed_file_path,
file_record.original_file_path,
file_record.local_filename,
]
for path in candidates:
if not path:
continue
# Guard against path traversal in DB values.
real = os.path.realpath(path)
if not real.startswith(workdir + os.sep) and real != workdir:
logger.warning("Shared link file path outside workdir rejected: %s", path)
continue
if os.path.exists(real):
return real
return None
# ---------------------------------------------------------------------------
# Pydantic schemas
# ---------------------------------------------------------------------------
class SharedLinkCreate(BaseModel):
"""Schema for creating a new shared link."""
file_id: int = Field(..., description="ID of the file to share")
expires_in_hours: int | None = Field(
None,
ge=1,
le=720,
description="Expiry in hours (1720). NULL means the link never expires.",
)
max_views: int | None = Field(
None,
ge=1,
le=10_000,
description="Maximum number of downloads/views. NULL means unlimited.",
)
password: str | None = Field(
None,
min_length=1,
max_length=128,
description="Optional password protecting the link.",
)
label: str | None = Field(
None,
max_length=255,
description="Optional human-readable label for the link.",
)
@field_validator("expires_in_hours")
@classmethod
def validate_expiry(cls, v: int | None) -> int | None:
if v is not None and v not in range(1, 721):
raise ValueError("expires_in_hours must be between 1 and 720")
return v
class SharedLinkResponse(BaseModel):
"""Shared link info returned to the authenticated owner."""
id: int
token: str
file_id: int
label: str | None
expires_at: datetime | None
max_views: int | None
view_count: int
has_password: bool
is_active: bool
created_at: datetime | None
revoked_at: datetime | None
# Filled in by the endpoint, not stored in DB.
share_url: str = ""
original_filename: str | None = None
model_config = {"from_attributes": True}
class SharedLinkInfoResponse(BaseModel):
"""Public metadata about a shared link (used on the share landing page)."""
token: str
label: str | None
original_filename: str | None
expires_at: datetime | None
max_views: int | None
view_count: int
has_password: bool
is_valid: bool
# ---------------------------------------------------------------------------
# Private (authenticated) endpoints
# ---------------------------------------------------------------------------
@router.post("/", status_code=status.HTTP_201_CREATED, response_model=SharedLinkResponse)
async def create_shared_link(
body: SharedLinkCreate,
request: Request,
owner_id: CurrentOwner,
db: DbSession,
) -> dict[str, Any]:
"""Create a new shareable link for a document.
The caller must own the file (or be in single-user mode).
Returns the full link metadata including the generated token.
"""
# Verify the file exists and belongs to the caller.
q = db.query(FileRecord).filter(FileRecord.id == body.file_id)
q = apply_owner_filter(q, request)
file_record = q.first()
if not file_record:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
token = _generate_token()
expires_at = None
if body.expires_in_hours is not None:
expires_at = datetime.now(timezone.utc).replace(microsecond=0)
from datetime import timedelta
expires_at = expires_at + timedelta(hours=body.expires_in_hours)
password_hash = _hash_password(body.password) if body.password else None
db_link = SharedLink(
token=token,
file_id=body.file_id,
owner_id=owner_id,
label=body.label,
expires_at=expires_at,
max_views=body.max_views,
view_count=0,
password_hash=password_hash,
)
try:
db.add(db_link)
db.commit()
db.refresh(db_link)
except Exception:
db.rollback()
raise
logger.info("Shared link created: id=%s owner=%s file_id=%s", db_link.id, owner_id, body.file_id)
base_url = str(request.base_url).rstrip("/")
return _link_to_dict(db_link, base_url, file_record.original_filename)
@router.get("/", response_model=list[SharedLinkResponse])
async def list_shared_links(
request: Request,
owner_id: CurrentOwner,
db: DbSession,
active_only: bool = Query(False, description="When true, only return active (non-revoked) links"),
) -> list[dict[str, Any]]:
"""List all shared links created by the authenticated user."""
q = db.query(SharedLink).filter(SharedLink.owner_id == owner_id)
if active_only:
q = q.filter(SharedLink.is_active.is_(True))
links = q.order_by(SharedLink.created_at.desc()).all()
base_url = str(request.base_url).rstrip("/")
result = []
for link in links:
file_record = db.query(FileRecord).filter(FileRecord.id == link.file_id).first()
filename = file_record.original_filename if file_record else None
result.append(_link_to_dict(link, base_url, filename))
return result
@router.delete("/{link_id}", status_code=status.HTTP_200_OK)
async def revoke_shared_link(
link_id: int,
owner_id: CurrentOwner,
db: DbSession,
) -> dict[str, str]:
"""Revoke (soft-delete) a shared link.
The record is kept for audit purposes but the link immediately
stops working for recipients.
"""
db_link = db.query(SharedLink).filter(SharedLink.id == link_id, SharedLink.owner_id == owner_id).first()
if not db_link:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Shared link not found")
if not db_link.is_active:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Link is already revoked")
try:
db_link.is_active = False
db_link.revoked_at = datetime.now(timezone.utc)
db.commit()
except Exception:
db.rollback()
raise
logger.info("Shared link revoked: id=%s owner=%s", link_id, owner_id)
return {"detail": "Link revoked"}
# ---------------------------------------------------------------------------
# Public endpoints (no authentication required)
# ---------------------------------------------------------------------------
@public_router.get("/share/{token}/info", response_model=SharedLinkInfoResponse)
def get_shared_link_info(
token: str,
db: DbSession,
) -> dict[str, Any]:
"""Return public metadata about a shared link.
Used by the share landing page to decide whether to show a password
prompt or a direct download button. Never returns sensitive data.
"""
link = db.query(SharedLink).filter(SharedLink.token == token).first()
if not link:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Link not found")
file_record = db.query(FileRecord).filter(FileRecord.id == link.file_id).first()
filename = file_record.original_filename if file_record else None
return {
"token": link.token,
"label": link.label,
"original_filename": filename,
"expires_at": link.expires_at,
"max_views": link.max_views,
"view_count": link.view_count,
"has_password": link.password_hash is not None,
"is_valid": _is_link_valid(link),
}
@public_router.get("/share/{token}/download")
def download_via_shared_link(
token: str,
db: DbSession,
password: str | None = Query(None, description="Password (if the link is password-protected)"),
) -> FileResponse:
"""Download a file via a shared link (no authentication required).
Increments the view counter and validates expiry / view limit before
serving the file.
"""
link = db.query(SharedLink).filter(SharedLink.token == token).first()
if not link:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Link not found or expired")
if not _is_link_valid(link):
raise HTTPException(status_code=status.HTTP_410_GONE, detail="Link has expired or reached its view limit")
# Password check
if link.password_hash is not None:
if not password:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="This link requires a password",
)
if not _verify_password(password, link.password_hash):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Incorrect password")
file_record = db.query(FileRecord).filter(FileRecord.id == link.file_id).first()
if not file_record:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
file_path = _resolve_file_path(file_record)
if not file_path:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not available on disk")
# Increment view count (best-effort — don't fail the request if this errors).
try:
link.view_count = (link.view_count or 0) + 1
db.commit()
except Exception:
db.rollback()
logger.warning("Failed to increment view_count for shared link id=%s", link.id)
return FileResponse(
path=file_path,
media_type=file_record.mime_type or "application/octet-stream",
headers={
"Content-Disposition": f'attachment; filename="{file_record.original_filename or "document"}"',
},
)
# ---------------------------------------------------------------------------
# Internal helper
# ---------------------------------------------------------------------------
def _link_to_dict(link: SharedLink, base_url: str, original_filename: str | None) -> dict[str, Any]:
"""Serialise a ``SharedLink`` ORM row to a plain dict."""
return {
"id": link.id,
"token": link.token,
"file_id": link.file_id,
"label": link.label,
"expires_at": link.expires_at,
"max_views": link.max_views,
"view_count": link.view_count,
"has_password": link.password_hash is not None,
"is_active": link.is_active,
"created_at": link.created_at,
"revoked_at": link.revoked_at,
"share_url": f"{base_url}/share/{link.token}",
"original_filename": original_filename,
}
+47
View File
@@ -637,3 +637,50 @@ class ApiToken(Base):
is_active = Column(Boolean, nullable=False, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
revoked_at = Column(DateTime(timezone=True), nullable=True)
class SharedLink(Base):
"""Shareable, time-limited or view-limited document link.
A ``SharedLink`` grants unauthenticated access to one ``FileRecord``
via a cryptographically random URL token. The link may optionally
expire after a given datetime, be limited to a fixed number of views,
and require a password. Only a PBKDF2-HMAC-SHA256 hash of the
password is stored.
Owners can view and revoke their active links from the management UI.
"""
__tablename__ = "shared_links"
id = Column(Integer, primary_key=True, index=True)
# Unique URL-safe token — forms the public /share/<token> URL.
token = Column(String(64), nullable=False, unique=True, index=True)
# File this link grants access to.
file_id = Column(Integer, ForeignKey(_FILES_ID_FK), nullable=False, index=True)
# Owner who created the link (matches FileRecord.owner_id).
owner_id = Column(String, nullable=False, index=True)
# Optional human-readable description chosen by the creator.
label = Column(String(255), nullable=True)
# Time-based expiration (NULL = never expires).
expires_at = Column(DateTime(timezone=True), nullable=True)
# View-count limit (NULL = unlimited).
max_views = Column(Integer, nullable=True)
# Cumulative view count (incremented on every successful access).
view_count = Column(Integer, nullable=False, default=0)
# Optional password protection — stores PBKDF2-HMAC-SHA256 hex digest.
password_hash = Column(String(128), nullable=True)
# Whether the link is still valid (set to False to revoke immediately).
is_active = Column(Boolean, nullable=False, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
revoked_at = Column(DateTime(timezone=True), nullable=True)
+4
View File
@@ -25,6 +25,8 @@ from app.views.plans import router as plans_router # Admin Plan Designer
from app.views.queue import router as queue_router
from app.views.search import router as search_router
from app.views.settings import router as settings_router
from app.views.share import router as share_router
from app.views.shared_links import router as shared_links_router
from app.views.status import router as status_router
from app.views.subscriptions import router as subscriptions_router # Pricing + subscription pages
from app.views.wizard import router as wizard_router
@@ -35,6 +37,8 @@ router.include_router(wizard_router) # Wizard first (for /setup)
router.include_router(db_wizard_router) # Database wizard
router.include_router(admin_users_router) # Admin user management
router.include_router(api_tokens_router) # API token management
router.include_router(shared_links_router) # Shared links management
router.include_router(share_router) # Public share landing pages (no auth)
router.include_router(backup_router) # Backup dashboard
router.include_router(general_router)
router.include_router(status_router)
+28
View File
@@ -0,0 +1,28 @@
"""Public view for accessing a shared document link.
This route does NOT require authentication — it is the landing page
that link recipients visit. The page fetches link metadata via the
public ``/api/share/{token}/info`` JSON endpoint and then renders the
appropriate download UI (password gate or direct download button).
"""
import logging
import pathlib
from fastapi import APIRouter, Request
from fastapi.templating import Jinja2Templates
logger = logging.getLogger(__name__)
router = APIRouter()
_templates_dir = pathlib.Path(__file__).parents[2] / "frontend" / "templates"
templates = Jinja2Templates(directory=str(_templates_dir))
@router.get("/share/{token}")
async def shared_link_view(request: Request, token: str):
"""Render the public share landing page for a given token."""
return templates.TemplateResponse(
"shared_link_view.html",
{"request": request, "token": token},
)
+24
View File
@@ -0,0 +1,24 @@
"""View route for the Shared Links management page.
Renders the ``shared_links.html`` template where authenticated users can
create, view, and revoke their document share links.
"""
import logging
from fastapi import APIRouter, Request
from app.views.base import require_login, templates
logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("/shared-links")
@require_login
async def shared_links_page(request: Request):
"""Render the Shared Links management page."""
return templates.TemplateResponse(
"shared_links.html",
{"request": request, "page_title": "Shared Links"},
)
+49
View File
@@ -240,6 +240,55 @@ can create multiple tokens, each with a descriptive name.
> **Security tip:** Create a dedicated token for each integration and revoke it
> immediately if compromised. Never share tokens or commit them to source control.
## Sharing Documents
DocuElevate lets you share individual documents with anyone via a secure,
time-limited link. Recipients do not need a DocuElevate account.
### Creating a Shared Link
1. Open the document detail page (`/files/{id}`) and click the **Share** button
in the Actions panel — this opens the Shared Links page with the file ID
pre-filled. Alternatively, go to **Avatar → Shared Links** in the navigation.
2. Enter the **File ID** you want to share (shown in the document detail URL).
3. Choose an optional **label** (e.g. "For Alice") to identify the link later.
4. Set an **expiry** — choose from 1 hour up to 30 days, or leave blank for a
link that never expires.
5. Optionally set a **max downloads** limit. Once that number is reached the
link is automatically invalidated.
6. Optionally set a **password**. Recipients will be prompted to enter it before
they can download the file.
7. Click **Create Link** and copy the generated URL to share with the recipient.
### What Recipients See
When a recipient visits the `/share/{token}` URL they see a simple landing page:
- The document filename and optional label.
- Expiry date and remaining download count (if set).
- If the link is password-protected they must enter the password first.
- A **Download** button that serves the file directly.
If the link has expired, been revoked, or reached its download limit, the
recipient sees a clear "Link Unavailable" message.
### Managing Your Shared Links
The **Shared Links** management page (`/shared-links`) shows all links you have
created with their current status:
| Status | Meaning |
|--------|---------|
| **Active** | Link is valid and can be used by recipients |
| **Expired** | Link's expiry date has passed |
| **Limit reached** | View/download count has been exhausted |
| **Revoked** | You manually deactivated the link |
Click **Revoke** next to any active link to immediately invalidate it.
> **Security tip:** Revoke shared links as soon as they are no longer needed.
> Use short expiry times and download limits for sensitive documents.
## Managing Documents
The **Files** page provides access to all processed documents:
+15
View File
@@ -212,6 +212,9 @@ function _makeMenuLink(href, iconClass, label, extraClasses = '') {
linksDiv.appendChild(
_makeMenuLink('/api-tokens', 'fas fa-key text-yellow-500', 'API Tokens', 'text-gray-700')
);
linksDiv.appendChild(
_makeMenuLink('/shared-links', 'fas fa-share-alt text-blue-400', 'Shared Links', 'text-gray-700')
);
// Divider + Sign Out
const divider = document.createElement('div');
@@ -293,6 +296,18 @@ function _makeMenuLink(href, iconClass, label, extraClasses = '') {
tokensLink.appendChild(document.createTextNode('API Tokens'));
mobileAuthSection.appendChild(tokensLink);
// Shared Links link
const sharedLinksLink = document.createElement('a');
sharedLinksLink.href = '/shared-links';
sharedLinksLink.className =
'flex items-center px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50';
const sharedLinksIcon = document.createElement('i');
sharedLinksIcon.className = 'fas fa-share-alt mr-2 text-blue-400';
sharedLinksIcon.setAttribute('aria-hidden', 'true');
sharedLinksLink.appendChild(sharedLinksIcon);
sharedLinksLink.appendChild(document.createTextNode('Shared Links'));
mobileAuthSection.appendChild(sharedLinksLink);
// Logout link
const logoutLink = document.createElement('a');
logoutLink.href = '/logout';
+3
View File
@@ -341,6 +341,9 @@
<i class="fas fa-external-link-alt" aria-hidden="true"></i> Open original
</a>
{% endif %}
<a href="/shared-links?file_id={{ file.id }}" class="action-btn btn-secondary">
<i class="fas fa-share-alt" aria-hidden="true"></i> Share
</a>
</div>
</div>
</div>
+232
View File
@@ -0,0 +1,232 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Shared Document DocuElevate</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- Tailwind CSS -->
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet" />
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
crossorigin="anonymous" referrerpolicy="no-referrer" />
<style>
body { font-family: system-ui, sans-serif; }
</style>
</head>
<body class="bg-gray-50 min-h-screen flex flex-col items-center justify-center p-4">
<div class="w-full max-w-md">
<!-- Card -->
<div class="bg-white rounded-2xl shadow-lg p-8 space-y-6" id="share-card">
<!-- Brand header -->
<div class="flex items-center justify-center gap-2 mb-2">
<i class="fas fa-file-alt text-blue-500 text-xl" aria-hidden="true"></i>
<span class="text-lg font-bold text-gray-700">DocuElevate</span>
</div>
<!-- Loading state -->
<div id="loading-state" class="text-center py-4">
<i class="fas fa-spinner fa-spin text-blue-400 text-2xl" aria-hidden="true"></i>
<p class="mt-2 text-sm text-gray-500">Loading link…</p>
</div>
<!-- Valid link state (hidden initially) -->
<div id="valid-state" class="hidden space-y-5">
<div class="text-center">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-blue-100 mb-4">
<i class="fas fa-file-download text-blue-500 text-2xl" aria-hidden="true"></i>
</div>
<h1 class="text-xl font-bold text-gray-900" id="file-title">Document</h1>
<p class="text-sm text-gray-500 mt-1" id="link-label"></p>
</div>
<!-- Expiry / view info -->
<div id="meta-info" class="bg-gray-50 rounded-lg p-3 text-sm text-gray-600 space-y-1"></div>
<!-- Password gate -->
<div id="password-section" class="hidden space-y-3">
<label for="pwd-input" class="block text-sm font-medium text-gray-700">
<i class="fas fa-lock text-yellow-500 mr-1" aria-hidden="true"></i>
This link is password-protected
</label>
<div class="flex gap-2">
<input
id="pwd-input"
type="password"
placeholder="Enter password"
autocomplete="current-password"
class="flex-1 px-4 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
id="pwd-submit"
type="button"
onclick="downloadWithPassword()"
class="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
style="min-height:40px;min-width:44px;"
>
Download
</button>
</div>
<p id="pwd-error" class="text-xs text-red-600 hidden" role="alert">Incorrect password. Please try again.</p>
</div>
<!-- Direct download button (shown when no password) -->
<div id="download-section" class="hidden">
<a
id="download-btn"
href="#"
class="flex items-center justify-center gap-2 w-full px-5 py-3 bg-blue-600 text-white font-medium rounded-lg
hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors"
style="min-height:48px;"
>
<i class="fas fa-download" aria-hidden="true"></i>
Download
</a>
</div>
</div>
<!-- Expired / invalid state (hidden initially) -->
<div id="invalid-state" class="hidden text-center space-y-3">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-red-100">
<i class="fas fa-link-slash text-red-500 text-2xl" aria-hidden="true"></i>
</div>
<h1 class="text-xl font-bold text-gray-900">Link Unavailable</h1>
<p id="invalid-reason" class="text-sm text-gray-500">This link has expired or been revoked.</p>
</div>
</div>
<p class="text-center text-xs text-gray-400 mt-6">
Shared via <a href="/" class="underline hover:text-gray-600">DocuElevate</a>
</p>
</div>
<script>
const TOKEN = {{ token | tojson }};
const INFO_URL = `/api/share/${TOKEN}/info`;
const DOWNLOAD_URL = `/api/share/${TOKEN}/download`;
async function loadLinkInfo() {
try {
const resp = await fetch(INFO_URL);
const data = await resp.json();
document.getElementById('loading-state').classList.add('hidden');
if (!resp.ok || !data.is_valid) {
showInvalid(data.is_valid === false ? null : data.detail);
return;
}
showValid(data);
} catch (err) {
document.getElementById('loading-state').classList.add('hidden');
showInvalid('Could not load link information.');
}
}
function showValid(data) {
document.getElementById('valid-state').classList.remove('hidden');
// File name / title
const titleEl = document.getElementById('file-title');
titleEl.textContent = data.original_filename || 'Document';
// Label
const labelEl = document.getElementById('link-label');
if (data.label) {
labelEl.textContent = data.label;
} else {
labelEl.classList.add('hidden');
}
// Meta info
const metaEl = document.getElementById('meta-info');
const rows = [];
if (data.expires_at) {
const d = new Date(data.expires_at);
rows.push(`<div><i class="fas fa-clock mr-1 text-gray-400" aria-hidden="true"></i> Expires: ${d.toLocaleDateString(undefined, {year:'numeric',month:'short',day:'numeric'})} ${d.toLocaleTimeString(undefined, {hour:'2-digit',minute:'2-digit'})}</div>`);
}
if (data.max_views) {
const remaining = data.max_views - data.view_count;
rows.push(`<div><i class="fas fa-download mr-1 text-gray-400" aria-hidden="true"></i> ${remaining} download${remaining !== 1 ? 's' : ''} remaining</div>`);
}
if (rows.length > 0) {
metaEl.innerHTML = rows.join('');
} else {
metaEl.classList.add('hidden');
}
if (data.has_password) {
document.getElementById('password-section').classList.remove('hidden');
} else {
const dlBtn = document.getElementById('download-btn');
dlBtn.href = DOWNLOAD_URL;
document.getElementById('download-section').classList.remove('hidden');
}
}
function showInvalid(reason) {
const el = document.getElementById('invalid-state');
el.classList.remove('hidden');
if (reason) {
document.getElementById('invalid-reason').textContent = reason;
}
}
function downloadWithPassword() {
const pwd = document.getElementById('pwd-input').value;
const errEl = document.getElementById('pwd-error');
if (!pwd) {
errEl.classList.remove('hidden');
errEl.textContent = 'Please enter the password.';
return;
}
// Attempt download — if password is wrong the server returns 403.
const url = `${DOWNLOAD_URL}?password=${encodeURIComponent(pwd)}`;
// Use a hidden iframe trick to detect errors vs. successful binary downloads.
fetch(url)
.then(async (resp) => {
if (resp.ok) {
errEl.classList.add('hidden');
// Trigger download via blob URL.
const blob = await resp.blob();
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
const disposition = resp.headers.get('content-disposition') || '';
const match = disposition.match(/filename="([^"]+)"/);
a.download = match ? match[1] : 'document';
document.body.appendChild(a);
a.click();
setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1000);
} else if (resp.status === 403) {
errEl.classList.remove('hidden');
errEl.textContent = 'Incorrect password. Please try again.';
} else {
errEl.classList.remove('hidden');
errEl.textContent = 'Download failed. The link may have expired.';
}
})
.catch(() => {
errEl.classList.remove('hidden');
errEl.textContent = 'Network error. Please try again.';
});
}
// Allow pressing Enter in the password field.
document.addEventListener('DOMContentLoaded', function () {
const pwdInput = document.getElementById('pwd-input');
if (pwdInput) {
pwdInput.addEventListener('keydown', function (e) {
if (e.key === 'Enter') downloadWithPassword();
});
}
loadLinkInfo();
});
</script>
</body>
</html>
+451
View File
@@ -0,0 +1,451 @@
{% extends "base.html" %}
{% block title %}Shared Links DocuElevate{% endblock %}
{% block content %}
<div x-data="sharedLinks()" x-init="init(); loadLinks()" class="container mx-auto px-4 py-8 max-w-5xl">
<header class="mb-8">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
<i class="fas fa-share-alt text-blue-500" aria-hidden="true"></i>
Shared Links
</h1>
<p class="mt-2 text-gray-600 dark:text-gray-400 text-sm leading-relaxed max-w-2xl">
Share documents with anyone via a time-limited or view-limited link.
Recipients do not need a DocuElevate account. Links can be password-protected
and revoked at any time.
</p>
</header>
<!-- Create link section -->
<section class="bg-white dark:bg-gray-800 shadow rounded-lg p-6 mb-6" aria-labelledby="create-link-heading">
<h2 id="create-link-heading" class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Create New Shared Link</h2>
<form @submit.prevent="createLink()" class="space-y-4">
<!-- File ID -->
<div class="flex flex-col sm:flex-row gap-3 items-start sm:items-end">
<div class="flex-1">
<label for="file-id-input" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
File ID <span class="text-red-500" aria-hidden="true">*</span>
</label>
<input
id="file-id-input"
type="number"
x-model.number="newLink.file_id"
placeholder="e.g. 42"
required
min="1"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm
focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white text-sm"
aria-required="true"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Find the file ID on the <a href="/files" class="underline hover:text-blue-600">Files</a> page or in the document detail URL.
</p>
</div>
<!-- Label (optional) -->
<div class="flex-1">
<label for="link-label-input" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Label <span class="text-gray-400 font-normal">(optional)</span>
</label>
<input
id="link-label-input"
type="text"
x-model="newLink.label"
placeholder="e.g. Shared with Bob"
maxlength="255"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm
focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white text-sm"
/>
</div>
</div>
<!-- Expiry + max views row -->
<div class="flex flex-col sm:flex-row gap-4">
<!-- Expiry -->
<div class="flex-1">
<label for="expires-select" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Expiry
</label>
<select
id="expires-select"
x-model.number="newLink.expires_in_hours"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm
focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white text-sm"
>
<option :value="null">Never</option>
<option value="1">1 hour</option>
<option value="6">6 hours</option>
<option value="12">12 hours</option>
<option value="24">24 hours (1 day)</option>
<option value="72">3 days</option>
<option value="168">7 days</option>
<option value="336">14 days</option>
<option value="720">30 days</option>
</select>
</div>
<!-- Max views -->
<div class="flex-1">
<label for="max-views-input" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Max downloads <span class="text-gray-400 font-normal">(optional)</span>
</label>
<input
id="max-views-input"
type="number"
x-model.number="newLink.max_views"
placeholder="Unlimited"
min="1"
max="10000"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm
focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white text-sm"
/>
</div>
<!-- Password -->
<div class="flex-1">
<label for="link-password-input" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Password <span class="text-gray-400 font-normal">(optional)</span>
</label>
<input
id="link-password-input"
type="password"
x-model="newLink.password"
placeholder="Leave blank for no password"
maxlength="128"
autocomplete="new-password"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm
focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white text-sm"
/>
</div>
</div>
<!-- Error message -->
<template x-if="createError">
<p class="text-sm text-red-600 dark:text-red-400" role="alert" x-text="createError"></p>
</template>
<div class="flex justify-end">
<button
type="submit"
:disabled="creating || !newLink.file_id"
class="inline-flex items-center px-5 py-2 bg-blue-600 text-white text-sm font-medium rounded-md
hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50
transition-colors"
style="min-height:40px; min-width:44px;"
>
<i class="fas fa-share-alt mr-2" aria-hidden="true"></i>
<span x-text="creating ? 'Creating…' : 'Create Link'"></span>
</button>
</div>
</form>
<!-- Newly created link display -->
<template x-if="newlyCreatedLink">
<div class="mt-4 bg-green-50 dark:bg-green-900/30 border border-green-300 dark:border-green-700 rounded-lg p-4" role="alert">
<div class="flex items-start gap-3">
<i class="fas fa-check-circle text-green-600 dark:text-green-400 mt-0.5 text-lg" aria-hidden="true"></i>
<div class="flex-1">
<p class="font-semibold text-green-800 dark:text-green-200 text-sm">Shared link created!</p>
<p class="text-green-700 dark:text-green-300 text-xs mt-1">
Copy and send this link to the recipient.
</p>
<div class="mt-3 flex items-center gap-2">
<code
class="flex-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded px-3 py-2
text-sm font-mono text-gray-900 dark:text-gray-100 select-all break-all"
x-text="newlyCreatedLink.share_url"
></code>
<button
type="button"
@click="copyLink()"
class="inline-flex items-center px-3 py-2 bg-gray-100 dark:bg-gray-700 border border-gray-300
dark:border-gray-600 rounded-md text-sm font-medium text-gray-700 dark:text-gray-200
hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500
transition-colors"
style="min-height:40px; min-width:44px;"
:aria-label="copied ? 'Copied!' : 'Copy link to clipboard'"
>
<i :class="copied ? 'fas fa-check text-green-600' : 'fas fa-copy'" aria-hidden="true"></i>
<span class="ml-1 hidden sm:inline" x-text="copied ? 'Copied!' : 'Copy'"></span>
</button>
</div>
</div>
</div>
</div>
</template>
</section>
<!-- Active links table -->
<section class="bg-white dark:bg-gray-800 shadow rounded-lg p-6" aria-labelledby="links-heading">
<div class="flex items-center justify-between mb-4">
<h2 id="links-heading" class="text-lg font-semibold text-gray-900 dark:text-white">
Your Shared Links
<span
x-show="links.length > 0"
class="ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"
x-text="links.length"
aria-label="number of links"
></span>
</h2>
<button
@click="loadLinks()"
type="button"
class="text-sm text-blue-600 hover:underline focus:outline-none focus:ring-2 focus:ring-blue-500 rounded"
aria-label="Refresh shared links list"
>
<i class="fas fa-sync-alt mr-1" aria-hidden="true"></i>Refresh
</button>
</div>
<!-- Loading state -->
<template x-if="loading">
<p class="text-sm text-gray-400 dark:text-gray-500 py-4 text-center">
<i class="fas fa-spinner fa-spin mr-2" aria-hidden="true"></i>Loading…
</p>
</template>
<!-- Empty state -->
<template x-if="!loading && links.length === 0">
<div class="text-center py-10 text-gray-400 dark:text-gray-500">
<i class="fas fa-share-alt text-3xl mb-3" aria-hidden="true"></i>
<p class="text-sm">No shared links yet. Create one above to get started.</p>
</div>
</template>
<!-- Links list -->
<template x-if="!loading && links.length > 0">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm" aria-label="Shared links">
<thead>
<tr class="text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
<th scope="col" class="pb-3 pr-4">File / Label</th>
<th scope="col" class="pb-3 pr-4">Link</th>
<th scope="col" class="pb-3 pr-4">Expiry</th>
<th scope="col" class="pb-3 pr-4">Views</th>
<th scope="col" class="pb-3 pr-4">Status</th>
<th scope="col" class="pb-3">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
<template x-for="link in links" :key="link.id">
<tr class="hover:bg-gray-50 dark:hover:bg-gray-750">
<!-- File / Label -->
<td class="py-3 pr-4 max-w-xs">
<p class="font-medium text-gray-800 dark:text-gray-200 truncate" x-text="link.original_filename || ('File #' + link.file_id)"></p>
<p x-show="link.label" class="text-xs text-gray-500 dark:text-gray-400 truncate" x-text="link.label"></p>
<p x-show="link.has_password" class="text-xs text-yellow-600 dark:text-yellow-400 mt-0.5">
<i class="fas fa-lock text-xs" aria-hidden="true"></i> Password protected
</p>
</td>
<!-- Link -->
<td class="py-3 pr-4">
<div class="flex items-center gap-1">
<code class="text-xs text-blue-600 dark:text-blue-400 truncate max-w-[140px]" x-text="link.share_url"></code>
<button
type="button"
@click="copyUrl(link.share_url)"
class="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 focus:outline-none focus:ring-1 focus:ring-blue-500 rounded"
aria-label="Copy link"
title="Copy link"
style="min-height:32px;min-width:32px;"
>
<i class="fas fa-copy text-xs" aria-hidden="true"></i>
</button>
<a
:href="link.share_url"
target="_blank"
rel="noopener noreferrer"
class="p-1 text-gray-400 hover:text-blue-600 focus:outline-none focus:ring-1 focus:ring-blue-500 rounded"
aria-label="Open shared link"
title="Open in new tab"
style="min-height:32px;min-width:32px;"
>
<i class="fas fa-external-link-alt text-xs" aria-hidden="true"></i>
</a>
</div>
</td>
<!-- Expiry -->
<td class="py-3 pr-4 whitespace-nowrap text-gray-600 dark:text-gray-400">
<span x-text="formatExpiry(link)"></span>
</td>
<!-- Views -->
<td class="py-3 pr-4 whitespace-nowrap text-gray-600 dark:text-gray-400">
<span x-text="link.view_count"></span>
<span x-show="link.max_views" x-text="' / ' + link.max_views"></span>
</td>
<!-- Status badge -->
<td class="py-3 pr-4 whitespace-nowrap">
<template x-if="!link.is_active">
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300">
Revoked
</span>
</template>
<template x-if="link.is_active && isExpired(link)">
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300">
Expired
</span>
</template>
<template x-if="link.is_active && isViewLimitReached(link)">
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300">
Limit reached
</span>
</template>
<template x-if="link.is_active && !isExpired(link) && !isViewLimitReached(link)">
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300">
Active
</span>
</template>
</td>
<!-- Actions -->
<td class="py-3 whitespace-nowrap">
<button
x-show="link.is_active"
type="button"
@click="revokeLink(link.id)"
class="inline-flex items-center px-3 py-1.5 text-xs font-medium text-red-600 dark:text-red-400
border border-red-300 dark:border-red-700 rounded hover:bg-red-50 dark:hover:bg-red-900/20
focus:outline-none focus:ring-2 focus:ring-red-500 transition-colors"
style="min-height:32px;min-width:44px;"
:aria-label="'Revoke shared link for ' + (link.original_filename || 'file')"
>
<i class="fas fa-ban mr-1" aria-hidden="true"></i> Revoke
</button>
<span x-show="!link.is_active" class="text-xs text-gray-400 dark:text-gray-500"></span>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</template>
</section>
</div>
<script>
function sharedLinks() {
return {
links: [],
loading: false,
creating: false,
createError: null,
newlyCreatedLink: null,
copied: false,
newLink: {
file_id: null,
label: '',
expires_in_hours: null,
max_views: null,
password: '',
},
init() {
// Pre-fill file_id from URL query parameter ?file_id=N
const params = new URLSearchParams(window.location.search);
const fid = params.get('file_id');
if (fid) this.newLink.file_id = parseInt(fid, 10);
},
async loadLinks() {
this.loading = true;
try {
const resp = await fetch('/api/shared-links/', {
headers: { 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || '' },
});
if (!resp.ok) throw new Error('Failed to load links');
this.links = await resp.json();
} catch (err) {
console.error('Failed to load shared links', err);
} finally {
this.loading = false;
}
},
async createLink() {
this.creating = true;
this.createError = null;
this.newlyCreatedLink = null;
try {
const body = {
file_id: this.newLink.file_id,
};
if (this.newLink.label && this.newLink.label.trim()) body.label = this.newLink.label.trim();
if (this.newLink.expires_in_hours) body.expires_in_hours = parseInt(this.newLink.expires_in_hours);
if (this.newLink.max_views) body.max_views = parseInt(this.newLink.max_views);
if (this.newLink.password && this.newLink.password.trim()) body.password = this.newLink.password;
const resp = await fetch('/api/shared-links/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || '',
},
body: JSON.stringify(body),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
this.createError = err.detail || 'Failed to create link';
return;
}
const data = await resp.json();
this.newlyCreatedLink = data;
this.newLink = { file_id: null, label: '', expires_in_hours: null, max_views: null, password: '' };
await this.loadLinks();
} finally {
this.creating = false;
}
},
async revokeLink(id) {
if (!confirm('Revoke this link? Recipients will no longer be able to use it.')) return;
try {
const resp = await fetch(`/api/shared-links/${id}`, {
method: 'DELETE',
headers: { 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || '' },
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
alert(err.detail || 'Failed to revoke link');
return;
}
await this.loadLinks();
} catch (err) {
alert('Error revoking link');
}
},
copyLink() {
if (!this.newlyCreatedLink) return;
navigator.clipboard.writeText(this.newlyCreatedLink.share_url).then(() => {
this.copied = true;
setTimeout(() => { this.copied = false; }, 2000);
});
},
copyUrl(url) {
navigator.clipboard.writeText(url).then(() => {
// Brief visual feedback handled by the button icon change.
});
},
formatExpiry(link) {
if (!link.expires_at) return 'Never';
const d = new Date(link.expires_at);
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) +
' ' + d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
},
isExpired(link) {
if (!link.expires_at) return false;
return new Date(link.expires_at) < new Date();
},
isViewLimitReached(link) {
return link.max_views !== null && link.view_count >= link.max_views;
},
};
}
</script>
{% endblock %}
@@ -0,0 +1,50 @@
"""Add shared_links table for document sharing with expiring links
Revision ID: 025_add_shared_links
Revises: 024_add_api_tokens
Create Date: 2026-03-08
"""
from typing import Union
import sqlalchemy as sa
from alembic import op
revision: str = "025_add_shared_links"
down_revision: Union[str, None] = "024_add_api_tokens"
depends_on: Union[str, None] = None
def upgrade() -> None:
"""Create shared_links table."""
op.create_table(
"shared_links",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("token", sa.String(64), nullable=False),
sa.Column("file_id", sa.Integer(), nullable=False),
sa.Column("owner_id", sa.String(), nullable=False),
sa.Column("label", sa.String(255), nullable=True),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("max_views", sa.Integer(), nullable=True),
sa.Column("view_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("password_hash", sa.String(128), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="1"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["file_id"], ["files.id"]),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("token"),
)
op.create_index("ix_shared_links_id", "shared_links", ["id"])
op.create_index("ix_shared_links_token", "shared_links", ["token"])
op.create_index("ix_shared_links_file_id", "shared_links", ["file_id"])
op.create_index("ix_shared_links_owner_id", "shared_links", ["owner_id"])
def downgrade() -> None:
"""Drop shared_links table."""
op.drop_index("ix_shared_links_owner_id", "shared_links")
op.drop_index("ix_shared_links_file_id", "shared_links")
op.drop_index("ix_shared_links_token", "shared_links")
op.drop_index("ix_shared_links_id", "shared_links")
op.drop_table("shared_links")
+821
View File
@@ -0,0 +1,821 @@
"""Tests for document sharing via expiring links (app/api/shared_links.py)."""
from datetime import datetime, timedelta, timezone
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.database import Base, get_db
from app.models import FileRecord, SharedLink
# ---------------------------------------------------------------------------
# Test data
# ---------------------------------------------------------------------------
_OWNER = "shareuser@example.com"
_OTHER_OWNER = "other@example.com"
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture()
def sl_engine():
"""In-memory SQLite engine with all tables created."""
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
yield engine
Base.metadata.drop_all(bind=engine)
@pytest.fixture()
def sl_session(sl_engine):
"""DB session scoped to one test."""
Session = sessionmaker(bind=sl_engine)
session = Session()
yield session
session.close()
def _make_file(session, owner_id: str = _OWNER, filename: str = "test.pdf") -> FileRecord:
"""Insert a minimal FileRecord and return it."""
record = FileRecord(
owner_id=owner_id,
filehash="abc123",
original_filename=filename,
local_filename="/tmp/test.pdf",
file_size=1024,
mime_type="application/pdf",
)
session.add(record)
session.commit()
session.refresh(record)
return record
def _make_client(sl_engine, owner_id: str = _OWNER) -> TestClient:
"""Return a TestClient with *owner_id* injected as the authenticated user."""
from app.api.shared_links import _get_owner_id
from app.main import app
Session = sessionmaker(bind=sl_engine)
def _override_get_db():
session = Session()
try:
yield session
finally:
session.close()
def _override_owner():
return owner_id
app.dependency_overrides[get_db] = _override_get_db
app.dependency_overrides[_get_owner_id] = _override_owner
client = TestClient(app, base_url="http://localhost", raise_server_exceptions=False)
return client
def _cleanup(app):
"""Remove dependency overrides after test."""
app.dependency_overrides.clear()
# ---------------------------------------------------------------------------
# Tests Link CRUD (authenticated)
# ---------------------------------------------------------------------------
class TestCreateSharedLink:
"""Tests for POST /api/shared-links/."""
@pytest.mark.unit
def test_create_link_basic(self, sl_engine, sl_session):
"""Creating a link returns the token and share URL."""
from app.main import app
file_record = _make_file(sl_session)
client = _make_client(sl_engine)
try:
resp = client.post("/api/shared-links/", json={"file_id": file_record.id})
assert resp.status_code == 201, resp.text
data = resp.json()
assert "token" in data
assert data["share_url"].startswith("http")
assert data["token"] in data["share_url"]
assert data["is_active"] is True
assert data["has_password"] is False
assert data["expires_at"] is None
finally:
_cleanup(app)
@pytest.mark.unit
def test_create_link_with_expiry(self, sl_engine, sl_session):
"""Creating a link with expires_in_hours sets expires_at."""
from app.main import app
file_record = _make_file(sl_session)
client = _make_client(sl_engine)
try:
resp = client.post("/api/shared-links/", json={"file_id": file_record.id, "expires_in_hours": 24})
assert resp.status_code == 201
data = resp.json()
assert data["expires_at"] is not None
finally:
_cleanup(app)
@pytest.mark.unit
def test_create_link_with_password(self, sl_engine, sl_session):
"""Creating a link with a password stores a hash (not plaintext)."""
from app.main import app
file_record = _make_file(sl_session)
client = _make_client(sl_engine)
try:
resp = client.post("/api/shared-links/", json={"file_id": file_record.id, "password": "secret"})
assert resp.status_code == 201
data = resp.json()
assert data["has_password"] is True
# Verify plaintext is not stored.
from app.models import SharedLink
Session = sessionmaker(bind=sl_engine)
with Session() as sess:
db_link = sess.query(SharedLink).filter(SharedLink.token == data["token"]).first()
assert db_link is not None
assert db_link.password_hash != "secret"
assert len(db_link.password_hash) == 64 # hex digest length
finally:
_cleanup(app)
@pytest.mark.unit
def test_create_link_with_max_views(self, sl_engine, sl_session):
"""Creating a link with max_views stores the limit."""
from app.main import app
file_record = _make_file(sl_session)
client = _make_client(sl_engine)
try:
resp = client.post("/api/shared-links/", json={"file_id": file_record.id, "max_views": 5})
assert resp.status_code == 201
assert resp.json()["max_views"] == 5
finally:
_cleanup(app)
@pytest.mark.unit
def test_create_link_wrong_file_returns_404(self, sl_engine, sl_session):
"""Creating a link for a non-existent file returns 404."""
from app.main import app
client = _make_client(sl_engine)
try:
resp = client.post("/api/shared-links/", json={"file_id": 99999})
assert resp.status_code == 404
finally:
_cleanup(app)
@pytest.mark.unit
def test_create_link_other_users_file_returns_404(self, sl_engine, sl_session):
"""Creating a link for another user's file returns 404."""
from app.main import app
other_file = _make_file(sl_session, owner_id=_OTHER_OWNER)
client = _make_client(sl_engine, _OWNER)
try:
from app.config import settings
original = settings.multi_user_enabled
settings.multi_user_enabled = True
try:
resp = client.post("/api/shared-links/", json={"file_id": other_file.id})
assert resp.status_code == 404
finally:
settings.multi_user_enabled = original
finally:
_cleanup(app)
@pytest.mark.unit
def test_create_link_with_label(self, sl_engine, sl_session):
"""A label is returned when provided."""
from app.main import app
file_record = _make_file(sl_session)
client = _make_client(sl_engine)
try:
resp = client.post("/api/shared-links/", json={"file_id": file_record.id, "label": "For Alice"})
assert resp.status_code == 201
assert resp.json()["label"] == "For Alice"
finally:
_cleanup(app)
class TestListSharedLinks:
"""Tests for GET /api/shared-links/."""
@pytest.mark.unit
def test_list_empty(self, sl_engine):
"""Listing when no links exist returns an empty list."""
from app.main import app
client = _make_client(sl_engine)
try:
resp = client.get("/api/shared-links/")
assert resp.status_code == 200
assert resp.json() == []
finally:
_cleanup(app)
@pytest.mark.unit
def test_list_returns_own_links(self, sl_engine, sl_session):
"""Listing returns all links created by the current user."""
from app.main import app
file_record = _make_file(sl_session)
client = _make_client(sl_engine)
try:
client.post("/api/shared-links/", json={"file_id": file_record.id, "label": "Link A"})
client.post("/api/shared-links/", json={"file_id": file_record.id, "label": "Link B"})
resp = client.get("/api/shared-links/")
assert resp.status_code == 200
assert len(resp.json()) == 2
finally:
_cleanup(app)
@pytest.mark.unit
def test_list_isolation(self, sl_engine, sl_session):
"""Users can only list their own links."""
from app.main import app
file_a = _make_file(sl_session, owner_id=_OWNER)
client_a = _make_client(sl_engine, _OWNER)
try:
client_a.post("/api/shared-links/", json={"file_id": file_a.id})
finally:
_cleanup(app)
file_b = _make_file(sl_session, owner_id=_OTHER_OWNER)
client_b = _make_client(sl_engine, _OTHER_OWNER)
try:
resp = client_b.get("/api/shared-links/")
assert resp.status_code == 200
assert resp.json() == []
finally:
_cleanup(app)
class TestRevokeSharedLink:
"""Tests for DELETE /api/shared-links/{id}."""
@pytest.mark.unit
def test_revoke_link(self, sl_engine, sl_session):
"""Revoking a link marks it inactive."""
from app.main import app
file_record = _make_file(sl_session)
client = _make_client(sl_engine)
try:
create_resp = client.post("/api/shared-links/", json={"file_id": file_record.id})
link_id = create_resp.json()["id"]
resp = client.delete(f"/api/shared-links/{link_id}")
assert resp.status_code == 200
list_resp = client.get("/api/shared-links/")
revoked = [link for link in list_resp.json() if link["id"] == link_id][0]
assert revoked["is_active"] is False
assert revoked["revoked_at"] is not None
finally:
_cleanup(app)
@pytest.mark.unit
def test_revoke_already_revoked(self, sl_engine, sl_session):
"""Revoking an already-revoked link returns 400."""
from app.main import app
file_record = _make_file(sl_session)
client = _make_client(sl_engine)
try:
create_resp = client.post("/api/shared-links/", json={"file_id": file_record.id})
link_id = create_resp.json()["id"]
client.delete(f"/api/shared-links/{link_id}")
resp = client.delete(f"/api/shared-links/{link_id}")
assert resp.status_code == 400
finally:
_cleanup(app)
@pytest.mark.unit
def test_revoke_nonexistent(self, sl_engine):
"""Revoking a non-existent link returns 404."""
from app.main import app
client = _make_client(sl_engine)
try:
resp = client.delete("/api/shared-links/99999")
assert resp.status_code == 404
finally:
_cleanup(app)
@pytest.mark.unit
def test_revoke_other_users_link(self, sl_engine, sl_session):
"""A user cannot revoke another user's link."""
from app.main import app
file_a = _make_file(sl_session, owner_id=_OWNER)
client_a = _make_client(sl_engine, _OWNER)
try:
create_resp = client_a.post("/api/shared-links/", json={"file_id": file_a.id})
link_id = create_resp.json()["id"]
finally:
_cleanup(app)
client_b = _make_client(sl_engine, _OTHER_OWNER)
try:
resp = client_b.delete(f"/api/shared-links/{link_id}")
assert resp.status_code == 404
finally:
_cleanup(app)
# ---------------------------------------------------------------------------
# Tests Public endpoints
# ---------------------------------------------------------------------------
class TestPublicInfo:
"""Tests for GET /api/share/{token}/info."""
@pytest.mark.unit
def test_info_for_valid_link(self, sl_engine, sl_session):
"""Public info endpoint returns metadata for a valid link."""
from app.main import app
file_record = _make_file(sl_session, filename="report.pdf")
client = _make_client(sl_engine)
try:
create_resp = client.post(
"/api/shared-links/",
json={"file_id": file_record.id, "label": "For Bob"},
)
token = create_resp.json()["token"]
finally:
_cleanup(app)
# Public client (no auth override)
from app.main import app as main_app
Session = sessionmaker(bind=sl_engine)
def _override_db():
session = Session()
try:
yield session
finally:
session.close()
main_app.dependency_overrides[get_db] = _override_db
pub_client = TestClient(main_app, base_url="http://localhost", raise_server_exceptions=False)
try:
resp = pub_client.get(f"/api/share/{token}/info")
assert resp.status_code == 200
data = resp.json()
assert data["token"] == token
assert data["is_valid"] is True
assert data["original_filename"] == "report.pdf"
assert data["label"] == "For Bob"
assert data["has_password"] is False
finally:
main_app.dependency_overrides.clear()
@pytest.mark.unit
def test_info_for_unknown_token(self, sl_engine):
"""Unknown token returns 404."""
from app.main import app
Session = sessionmaker(bind=sl_engine)
def _override_db():
session = Session()
try:
yield session
finally:
session.close()
app.dependency_overrides[get_db] = _override_db
client = TestClient(app, base_url="http://localhost", raise_server_exceptions=False)
try:
resp = client.get("/api/share/nonexistenttoken/info")
assert resp.status_code == 404
finally:
app.dependency_overrides.clear()
@pytest.mark.unit
def test_info_shows_has_password(self, sl_engine, sl_session):
"""Info for a password-protected link shows has_password=True."""
from app.main import app
file_record = _make_file(sl_session)
client = _make_client(sl_engine)
try:
create_resp = client.post(
"/api/shared-links/",
json={"file_id": file_record.id, "password": "hunter2"},
)
token = create_resp.json()["token"]
finally:
_cleanup(app)
Session = sessionmaker(bind=sl_engine)
def _override_db():
session = Session()
try:
yield session
finally:
session.close()
app.dependency_overrides[get_db] = _override_db
pub_client = TestClient(app, base_url="http://localhost", raise_server_exceptions=False)
try:
resp = pub_client.get(f"/api/share/{token}/info")
assert resp.status_code == 200
assert resp.json()["has_password"] is True
finally:
app.dependency_overrides.clear()
@pytest.mark.unit
def test_expired_link_shows_invalid(self, sl_engine, sl_session):
"""An expired link reports is_valid=False in info response."""
now = datetime.now(timezone.utc)
past = now - timedelta(hours=1)
link = SharedLink(
token="expiredtoken123",
file_id=_make_file(sl_session).id,
owner_id=_OWNER,
expires_at=past,
view_count=0,
is_active=True,
)
sl_session.add(link)
sl_session.commit()
from app.main import app
Session = sessionmaker(bind=sl_engine)
def _override_db():
session = Session()
try:
yield session
finally:
session.close()
app.dependency_overrides[get_db] = _override_db
client = TestClient(app, base_url="http://localhost", raise_server_exceptions=False)
try:
resp = client.get("/api/share/expiredtoken123/info")
assert resp.status_code == 200
assert resp.json()["is_valid"] is False
finally:
app.dependency_overrides.clear()
@pytest.mark.unit
def test_view_limit_reached_shows_invalid(self, sl_engine, sl_session):
"""A link that has reached its view limit reports is_valid=False."""
link = SharedLink(
token="limitedtoken456",
file_id=_make_file(sl_session).id,
owner_id=_OWNER,
max_views=3,
view_count=3,
is_active=True,
)
sl_session.add(link)
sl_session.commit()
from app.main import app
Session = sessionmaker(bind=sl_engine)
def _override_db():
session = Session()
try:
yield session
finally:
session.close()
app.dependency_overrides[get_db] = _override_db
client = TestClient(app, base_url="http://localhost", raise_server_exceptions=False)
try:
resp = client.get("/api/share/limitedtoken456/info")
assert resp.status_code == 200
assert resp.json()["is_valid"] is False
finally:
app.dependency_overrides.clear()
class TestPublicDownload:
"""Tests for GET /api/share/{token}/download."""
@pytest.mark.unit
def test_download_no_file_on_disk_returns_404(self, sl_engine, sl_session):
"""Download endpoint returns 404 when file not on disk."""
link = SharedLink(
token="dltoken001",
file_id=_make_file(sl_session, filename="missing.pdf").id,
owner_id=_OWNER,
view_count=0,
is_active=True,
)
sl_session.add(link)
sl_session.commit()
from app.main import app
Session = sessionmaker(bind=sl_engine)
def _override_db():
session = Session()
try:
yield session
finally:
session.close()
app.dependency_overrides[get_db] = _override_db
client = TestClient(app, base_url="http://localhost", raise_server_exceptions=False)
try:
resp = client.get("/api/share/dltoken001/download")
assert resp.status_code == 404
finally:
app.dependency_overrides.clear()
@pytest.mark.unit
def test_download_expired_returns_410(self, sl_engine, sl_session):
"""Download endpoint returns 410 when link has expired."""
past = datetime.now(timezone.utc) - timedelta(hours=1)
link = SharedLink(
token="expiredlink999",
file_id=_make_file(sl_session).id,
owner_id=_OWNER,
expires_at=past,
view_count=0,
is_active=True,
)
sl_session.add(link)
sl_session.commit()
from app.main import app
Session = sessionmaker(bind=sl_engine)
def _override_db():
session = Session()
try:
yield session
finally:
session.close()
app.dependency_overrides[get_db] = _override_db
client = TestClient(app, base_url="http://localhost", raise_server_exceptions=False)
try:
resp = client.get("/api/share/expiredlink999/download")
assert resp.status_code == 410
finally:
app.dependency_overrides.clear()
@pytest.mark.unit
def test_download_revoked_returns_410(self, sl_engine, sl_session):
"""Download endpoint returns 410 when link has been revoked."""
link = SharedLink(
token="revokedlink777",
file_id=_make_file(sl_session).id,
owner_id=_OWNER,
view_count=0,
is_active=False,
revoked_at=datetime.now(timezone.utc),
)
sl_session.add(link)
sl_session.commit()
from app.main import app
Session = sessionmaker(bind=sl_engine)
def _override_db():
session = Session()
try:
yield session
finally:
session.close()
app.dependency_overrides[get_db] = _override_db
client = TestClient(app, base_url="http://localhost", raise_server_exceptions=False)
try:
resp = client.get("/api/share/revokedlink777/download")
assert resp.status_code == 410
finally:
app.dependency_overrides.clear()
@pytest.mark.unit
def test_download_password_required(self, sl_engine, sl_session):
"""Download endpoint returns 401 when password is required but not supplied."""
from app.api.shared_links import _hash_password
link = SharedLink(
token="pwdlink111",
file_id=_make_file(sl_session).id,
owner_id=_OWNER,
password_hash=_hash_password("correct"),
view_count=0,
is_active=True,
)
sl_session.add(link)
sl_session.commit()
from app.main import app
Session = sessionmaker(bind=sl_engine)
def _override_db():
session = Session()
try:
yield session
finally:
session.close()
app.dependency_overrides[get_db] = _override_db
client = TestClient(app, base_url="http://localhost", raise_server_exceptions=False)
try:
resp = client.get("/api/share/pwdlink111/download")
assert resp.status_code == 401
finally:
app.dependency_overrides.clear()
@pytest.mark.unit
def test_download_wrong_password(self, sl_engine, sl_session):
"""Download endpoint returns 403 for wrong password."""
from app.api.shared_links import _hash_password
link = SharedLink(
token="pwdlink222",
file_id=_make_file(sl_session).id,
owner_id=_OWNER,
password_hash=_hash_password("correct"),
view_count=0,
is_active=True,
)
sl_session.add(link)
sl_session.commit()
from app.main import app
Session = sessionmaker(bind=sl_engine)
def _override_db():
session = Session()
try:
yield session
finally:
session.close()
app.dependency_overrides[get_db] = _override_db
client = TestClient(app, base_url="http://localhost", raise_server_exceptions=False)
try:
resp = client.get("/api/share/pwdlink222/download?password=wrong")
assert resp.status_code == 403
finally:
app.dependency_overrides.clear()
@pytest.mark.unit
def test_download_view_limit_reached(self, sl_engine, sl_session):
"""Download endpoint returns 410 when view limit is already reached."""
link = SharedLink(
token="limitedlink333",
file_id=_make_file(sl_session).id,
owner_id=_OWNER,
max_views=2,
view_count=2,
is_active=True,
)
sl_session.add(link)
sl_session.commit()
from app.main import app
Session = sessionmaker(bind=sl_engine)
def _override_db():
session = Session()
try:
yield session
finally:
session.close()
app.dependency_overrides[get_db] = _override_db
client = TestClient(app, base_url="http://localhost", raise_server_exceptions=False)
try:
resp = client.get("/api/share/limitedlink333/download")
assert resp.status_code == 410
finally:
app.dependency_overrides.clear()
# ---------------------------------------------------------------------------
# Tests Helper utilities
# ---------------------------------------------------------------------------
class TestHelpers:
"""Tests for internal helper functions."""
@pytest.mark.unit
def test_generate_token_unique(self):
"""Generated tokens should be unique."""
from app.api.shared_links import _generate_token
tokens = {_generate_token() for _ in range(100)}
assert len(tokens) == 100
@pytest.mark.unit
def test_hash_password_deterministic(self):
"""Hashing the same password always produces the same hex digest."""
from app.api.shared_links import _hash_password
h = _hash_password("mysecret")
assert h == _hash_password("mysecret")
assert len(h) == 64
@pytest.mark.unit
def test_verify_password_correct(self):
"""_verify_password returns True for a matching password."""
from app.api.shared_links import _hash_password, _verify_password
h = _hash_password("correct")
assert _verify_password("correct", h) is True
@pytest.mark.unit
def test_verify_password_wrong(self):
"""_verify_password returns False for a wrong password."""
from app.api.shared_links import _hash_password, _verify_password
h = _hash_password("correct")
assert _verify_password("wrong", h) is False
@pytest.mark.unit
def test_is_link_valid_active(self):
"""_is_link_valid returns True for a basic active link."""
from app.api.shared_links import _is_link_valid
link = SharedLink(is_active=True, view_count=0)
assert _is_link_valid(link) is True
@pytest.mark.unit
def test_is_link_valid_revoked(self):
"""_is_link_valid returns False for a revoked link."""
from app.api.shared_links import _is_link_valid
link = SharedLink(is_active=False, view_count=0)
assert _is_link_valid(link) is False
@pytest.mark.unit
def test_is_link_valid_expired(self):
"""_is_link_valid returns False when expires_at is in the past."""
from app.api.shared_links import _is_link_valid
past = datetime.now(timezone.utc) - timedelta(seconds=1)
link = SharedLink(is_active=True, expires_at=past, view_count=0)
assert _is_link_valid(link) is False
@pytest.mark.unit
def test_is_link_valid_not_yet_expired(self):
"""_is_link_valid returns True when expires_at is in the future."""
from app.api.shared_links import _is_link_valid
future = datetime.now(timezone.utc) + timedelta(hours=1)
link = SharedLink(is_active=True, expires_at=future, view_count=0)
assert _is_link_valid(link) is True
@pytest.mark.unit
def test_is_link_valid_view_limit_hit(self):
"""_is_link_valid returns False when view_count >= max_views."""
from app.api.shared_links import _is_link_valid
link = SharedLink(is_active=True, max_views=3, view_count=3)
assert _is_link_valid(link) is False
@pytest.mark.unit
def test_is_link_valid_view_limit_not_hit(self):
"""_is_link_valid returns True when view_count < max_views."""
from app.api.shared_links import _is_link_valid
link = SharedLink(is_active=True, max_views=3, view_count=2)
assert _is_link_valid(link) is True