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:
@@ -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.saved_searches import router as saved_searches_router
|
||||||
from app.api.search import router as search_router
|
from app.api.search import router as search_router
|
||||||
from app.api.settings import router as settings_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.similarity import router as similarity_router
|
||||||
from app.api.subscriptions import router as subscriptions_router
|
from app.api.subscriptions import router as subscriptions_router
|
||||||
from app.api.url_upload import router as url_upload_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(queue_router)
|
||||||
router.include_router(saved_searches_router)
|
router.include_router(saved_searches_router)
|
||||||
router.include_router(similarity_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(duplicates_router)
|
||||||
router.include_router(webhooks_router)
|
router.include_router(webhooks_router)
|
||||||
router.include_router(database_router)
|
router.include_router(database_router)
|
||||||
|
|||||||
@@ -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 (1–720). 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,
|
||||||
|
}
|
||||||
@@ -637,3 +637,50 @@ class ApiToken(Base):
|
|||||||
is_active = Column(Boolean, nullable=False, default=True)
|
is_active = Column(Boolean, nullable=False, default=True)
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
revoked_at = Column(DateTime(timezone=True), nullable=True)
|
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)
|
||||||
|
|||||||
@@ -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.queue import router as queue_router
|
||||||
from app.views.search import router as search_router
|
from app.views.search import router as search_router
|
||||||
from app.views.settings import router as settings_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.status import router as status_router
|
||||||
from app.views.subscriptions import router as subscriptions_router # Pricing + subscription pages
|
from app.views.subscriptions import router as subscriptions_router # Pricing + subscription pages
|
||||||
from app.views.wizard import router as wizard_router
|
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(db_wizard_router) # Database wizard
|
||||||
router.include_router(admin_users_router) # Admin user management
|
router.include_router(admin_users_router) # Admin user management
|
||||||
router.include_router(api_tokens_router) # API token 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(backup_router) # Backup dashboard
|
||||||
router.include_router(general_router)
|
router.include_router(general_router)
|
||||||
router.include_router(status_router)
|
router.include_router(status_router)
|
||||||
|
|||||||
@@ -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},
|
||||||
|
)
|
||||||
@@ -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"},
|
||||||
|
)
|
||||||
@@ -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
|
> **Security tip:** Create a dedicated token for each integration and revoke it
|
||||||
> immediately if compromised. Never share tokens or commit them to source control.
|
> 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
|
## Managing Documents
|
||||||
|
|
||||||
The **Files** page provides access to all processed documents:
|
The **Files** page provides access to all processed documents:
|
||||||
|
|||||||
@@ -212,6 +212,9 @@ function _makeMenuLink(href, iconClass, label, extraClasses = '') {
|
|||||||
linksDiv.appendChild(
|
linksDiv.appendChild(
|
||||||
_makeMenuLink('/api-tokens', 'fas fa-key text-yellow-500', 'API Tokens', 'text-gray-700')
|
_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
|
// Divider + Sign Out
|
||||||
const divider = document.createElement('div');
|
const divider = document.createElement('div');
|
||||||
@@ -293,6 +296,18 @@ function _makeMenuLink(href, iconClass, label, extraClasses = '') {
|
|||||||
tokensLink.appendChild(document.createTextNode('API Tokens'));
|
tokensLink.appendChild(document.createTextNode('API Tokens'));
|
||||||
mobileAuthSection.appendChild(tokensLink);
|
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
|
// Logout link
|
||||||
const logoutLink = document.createElement('a');
|
const logoutLink = document.createElement('a');
|
||||||
logoutLink.href = '/logout';
|
logoutLink.href = '/logout';
|
||||||
|
|||||||
@@ -341,6 +341,9 @@
|
|||||||
<i class="fas fa-external-link-alt" aria-hidden="true"></i> Open original
|
<i class="fas fa-external-link-alt" aria-hidden="true"></i> Open original
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% 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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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")
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user