Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
38524cc
feat(sharing): batch-apply collaborator/permission changes on Done + …
AperturePlus May 24, 2026
ef252f5
feat(storage): reactive storage stats with auto-refresh on file-tree …
AperturePlus May 24, 2026
f0891cc
chore(config): add upload_verify_merged_object_hash toggle, bump sing…
AperturePlus May 24, 2026
bdb1a81
feat(admin): wire hash_computation_enabled to upload_verify_merged_ob…
AperturePlus May 24, 2026
a3294d6
feat(transcode): force stereo audio output + deterministic ffmpeg err…
AperturePlus May 24, 2026
4d8c19c
feat(ui): add showCancelButton option to PromptDialog + auto-select r…
AperturePlus May 24, 2026
28b201e
feat(preview): add signed preview URL endpoint for video streaming
AperturePlus May 24, 2026
34e9628
feat(stream): add ResolvedStreamObject with transcode MIME content-ty…
AperturePlus May 24, 2026
181bd61
feat(upload): make merged-object hash verification conditional on set…
AperturePlus May 24, 2026
f725409
refactor(uploader): extract hash chunk size constants and add backend…
AperturePlus May 24, 2026
4872391
test(upload): add refresh-file-tree emit test and new spec files
AperturePlus May 24, 2026
1637822
feat(config): add DEFAULT_ADMIN_* env vars for production bootstrap
AperturePlus May 25, 2026
84cebf2
feat(seed): support production admin account seeding from env vars
AperturePlus May 25, 2026
3679031
test(settings): add production admin env validation tests
AperturePlus May 25, 2026
d8db5de
test(seed): add production admin account seeding test
AperturePlus May 25, 2026
bb3ec08
test(startup): add fail-fast tests for production admin env validation
AperturePlus May 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions app/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ FF_DB_URI=postgresql://root:password@localhost:5432/fileflash
# DATABASE_URL=postgresql://root:password@localhost:5432/fileflash
APP_ENV=development

# Required when APP_ENV=production or APP_ENV=prod.
DEFAULT_ADMIN_USERNAME=admin
DEFAULT_ADMIN_EMAIL=admin@example.com
DEFAULT_ADMIN_PASSWORD=replace-with-32-bytes-or-longer-password

JWT_SECRET_KEY=please-set-at-least-32-bytes-secret-key
# Optional dedicated HMAC key for token hash persistence.
# Falls back to JWT_SECRET_KEY when omitted.
Expand All @@ -26,6 +31,7 @@ UPLOAD_CHUNK_SIZE_DEFAULT=5242880
UPLOAD_CHUNK_SIZE_MIN=1048576
UPLOAD_CHUNK_SIZE_MAX=16777216
UPLOAD_SINGLE_FILE_SIZE_MAX=5368709120
UPLOAD_VERIFY_MERGED_OBJECT_HASH=false
STARRED_ITEMS_LIMIT=20
UPLOAD_SESSION_TTL_HOURS=24
UPLOAD_TEMP_PREFIX=tmp
Expand Down
29 changes: 29 additions & 0 deletions app/src/fileflash/core/security.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,32 @@ def decode_share_access_token(token: str, settings: Settings) -> dict[str, Any]:
if token_type != "share":
raise jwt.InvalidTokenError("Invalid token type")
return payload


def create_file_preview_token(
*,
user_id: int,
file_id: int,
settings: Settings,
expires_at: datetime,
) -> str:
now = datetime.now(UTC)
payload: dict[str, Any] = {
"sub": str(user_id),
"typ": "file_preview",
"scope": "file.preview",
"fileId": str(file_id),
"iat": int(now.timestamp()),
"exp": int(expires_at.timestamp()),
"jti": str(uuid.uuid4()),
}
return jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)


def decode_file_preview_token(token: str, settings: Settings) -> dict[str, Any]:
payload = jwt.decode(token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm])
token_type = payload.get("typ")
scope = payload.get("scope")
if token_type != "file_preview" or scope != "file.preview":
raise jwt.InvalidTokenError("Invalid token type")
return payload
41 changes: 39 additions & 2 deletions app/src/fileflash/core/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ class Settings(BaseSettings):
api_v1_prefix: str = "/api/v1"
app_env: str = Field(default="production", alias="APP_ENV")

default_admin_username: str | None = Field(default=None, alias="DEFAULT_ADMIN_USERNAME")
default_admin_email: str | None = Field(default=None, alias="DEFAULT_ADMIN_EMAIL")
default_admin_password: str | None = Field(default=None, alias="DEFAULT_ADMIN_PASSWORD")

database_url: str | None = Field(default=None, alias="DATABASE_URL")
ff_db_uri: str | None = Field(default=None, alias="FF_DB_URI")

Expand All @@ -39,6 +43,10 @@ class Settings(BaseSettings):
jwt_algorithm: str = "HS256"
access_token_expire_minutes: int = 60 * 24 * 3
refresh_token_expire_days: int = 7
file_preview_url_ttl_seconds: int = Field(
default=4 * 60 * 60,
alias="FILE_PREVIEW_URL_TTL_SECONDS",
)

refresh_cookie_name: str = "refreshToken"
refresh_cookie_secure: bool = False
Expand Down Expand Up @@ -68,10 +76,20 @@ class Settings(BaseSettings):
object_storage_secure: bool = Field(default=False, alias="OBJECT_STORAGE_SECURE")
object_storage_region: str | None = Field(default=None, alias="OBJECT_STORAGE_REGION")

upload_chunk_size_default: int = Field(default=5 * 1024 * 1024, alias="UPLOAD_CHUNK_SIZE_DEFAULT")
upload_chunk_size_default: int = Field(
default=5 * 1024 * 1024,
alias="UPLOAD_CHUNK_SIZE_DEFAULT",
)
upload_chunk_size_min: int = Field(default=1 * 1024 * 1024, alias="UPLOAD_CHUNK_SIZE_MIN")
upload_chunk_size_max: int = Field(default=16 * 1024 * 1024, alias="UPLOAD_CHUNK_SIZE_MAX")
upload_single_file_size_max: int = Field(default=5 * 1024 * 1024 * 1024, alias="UPLOAD_SINGLE_FILE_SIZE_MAX")
upload_single_file_size_max: int = Field(
default=5 * 1024 * 1024 * 1024,
alias="UPLOAD_SINGLE_FILE_SIZE_MAX",
)
upload_verify_merged_object_hash: bool = Field(
default=False,
alias="UPLOAD_VERIFY_MERGED_OBJECT_HASH",
)
starred_items_limit: int = Field(default=20, alias="STARRED_ITEMS_LIMIT")
upload_session_ttl_hours: int = Field(default=24, alias="UPLOAD_SESSION_TTL_HOURS")
upload_temp_prefix: str = Field(default="tmp", alias="UPLOAD_TEMP_PREFIX")
Expand Down Expand Up @@ -184,6 +202,25 @@ def security_configuration_issues(self) -> tuple[str, ...]:
token_hash_secret = (self.token_hash_secret or "").strip()
if token_hash_secret and len(token_hash_secret.encode("utf-8")) < self.MIN_SECRET_LENGTH:
issues.append(f"TOKEN_HASH_SECRET must be at least {self.MIN_SECRET_LENGTH} bytes")
issues.extend(self.default_admin_configuration_issues)
return tuple(issues)

@property
def default_admin_configuration_issues(self) -> tuple[str, ...]:
if not self.is_production_env:
return ()

issues: list[str] = []
if not (self.default_admin_username or "").strip():
issues.append("DEFAULT_ADMIN_USERNAME is required in production")
if not (self.default_admin_email or "").strip():
issues.append("DEFAULT_ADMIN_EMAIL is required in production")

password = (self.default_admin_password or "").strip()
if not password:
issues.append("DEFAULT_ADMIN_PASSWORD is required in production")
elif len(password.encode("utf-8")) < self.MIN_SECRET_LENGTH:
issues.append(f"DEFAULT_ADMIN_PASSWORD must be at least {self.MIN_SECRET_LENGTH} bytes")
return tuple(issues)

def assert_runtime_security(self) -> None:
Expand Down
70 changes: 68 additions & 2 deletions app/src/fileflash/routers/files.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,24 @@
from __future__ import annotations

import os
from datetime import UTC, datetime, timedelta
from urllib.parse import urlencode

from fastapi import APIRouter, Depends, Header, Query
from fastapi import APIRouter, Depends, Header, Query, Request
from fastapi.responses import FileResponse, StreamingResponse
from jwt import InvalidTokenError
from starlette.background import BackgroundTask

from ..core.deps import get_archive_service, get_current_user, get_file_service
from ..core.deps import get_archive_service, get_current_user, get_file_service, get_settings_dep
from ..core.errors import ApiError, api_success
from ..core.security import create_file_preview_token, decode_file_preview_token
from ..core.settings import Settings
from ..models.tables_identity import User
from ..schemas.archive import ArchiveExtractRequest
from ..schemas.file import (
BatchDownloadRequest,
BatchFilesRequest,
FilePreviewUrlResponse,
GetFilesQuery,
MoveFileRequest,
RenameFileRequest,
Expand Down Expand Up @@ -174,6 +180,66 @@ async def preview_file(
)


@router.post("/{file_id}/preview-url")
async def create_file_preview_url(
file_id: str,
request: Request,
current_user: User = Depends(get_current_user),
file_service: FileService = Depends(get_file_service),
settings: Settings = Depends(get_settings_dep),
):
try:
fid = int(file_id)
except ValueError as exc:
raise ApiError(status_code=400, code=400, message="Invalid fileId") from exc

await file_service.get_file(user_id=current_user.user_id, file_id=fid)
expires_at = datetime.now(UTC) + timedelta(seconds=settings.file_preview_url_ttl_seconds)
token = create_file_preview_token(
user_id=int(current_user.user_id),
file_id=fid,
settings=settings,
expires_at=expires_at,
)
stream_url = str(request.url_for("preview_file_stream", file_id=str(fid)))
result = FilePreviewUrlResponse(
url=f"{stream_url}?{urlencode({'token': token})}",
expires_at=expires_at,
)
return api_success(data=result.model_dump(by_alias=True))


@router.get("/{file_id}/preview-stream", name="preview_file_stream")
async def preview_file_stream(
file_id: str,
token: str = Query(..., min_length=1),
range_header: str | None = Header(default=None, alias="Range"),
file_service: FileService = Depends(get_file_service),
settings: Settings = Depends(get_settings_dep),
):
try:
payload = decode_file_preview_token(token, settings)
user_id = int(payload["sub"])
token_file_id = str(payload["fileId"])
except (InvalidTokenError, KeyError, ValueError):
raise ApiError(status_code=401, code=401, message="Invalid or expired preview token") from None

if token_file_id != str(file_id):
raise ApiError(status_code=403, code=403, message="Preview token does not match file")

result = await file_service.get_preview_stream(
user_id=user_id,
file_id=file_id,
range_header=range_header,
)
return StreamingResponse(
result.stream,
media_type=result.content_type,
headers=result.headers,
status_code=result.status_code,
)


@router.delete("/{file_id}")
async def delete_file(
file_id: str,
Expand Down
5 changes: 5 additions & 0 deletions app/src/fileflash/schemas/file.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,11 @@ class MediaOptimization(CamelModel):
updated_at: datetime


class FilePreviewUrlResponse(CamelModel):
url: str
expires_at: datetime


class RenameFileRequest(CamelModel):
file_name: str = Field(min_length=1, max_length=255)

Expand Down
8 changes: 4 additions & 4 deletions app/src/fileflash/scripts/init_dev_accounts.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,20 @@


def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Initialize development test accounts for FileFlash.")
parser = argparse.ArgumentParser(description="Initialize seeded accounts for FileFlash.")
parser.add_argument(
"--reset-password",
action="store_true",
help="Force reset passwords to defaults (admin/admin123, demo/demo123).",
help="Force reset seeded account passwords from the active environment configuration.",
)
return parser


async def run(reset_password: bool) -> int:
settings = get_settings()
if settings.is_production_env:
logger.warning(
"Manual dev-account initialization is running under APP_ENV=%s. This is not executed automatically in production.",
logger.info(
"Manual account initialization is using DEFAULT_ADMIN_* for APP_ENV=%s.",
settings.app_env,
)

Expand Down
2 changes: 1 addition & 1 deletion app/src/fileflash/services/admin/system.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ async def health(self) -> SystemHealth:
virus_scan_enabled=bool(getattr(self.settings, "virus_scan_enabled", False)),
thumbnail_generation_enabled=bool(getattr(self.settings, "thumbnail_generation_enabled", True)),
registration_mail_enabled=bool(self.settings.mail_server and self.settings.mail_from),
hash_computation_enabled=True,
hash_computation_enabled=bool(self.settings.upload_verify_merged_object_hash),
last_updated_at=datetime.now(UTC),
)

Expand Down
62 changes: 51 additions & 11 deletions app/src/fileflash/services/dev_seed.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,19 +59,23 @@ async def initialize_dev_accounts(
reset_password: bool = False,
auto_run: bool = False,
) -> bool:
if auto_run and not settings.is_development_env:
if settings.is_production_env:
logger.info("Skip dev account auto-initialization in production environment: %s", settings.app_env)
else:
logger.info("Skip dev account auto-initialization for non-dev APP_ENV=%s", settings.app_env)
if auto_run and not settings.is_development_env and not settings.is_production_env:
logger.info("Skip account auto-initialization for non-dev APP_ENV=%s", settings.app_env)
return False

if settings.is_production_env:
settings.assert_runtime_security()

accounts = _seed_accounts_for_settings(settings)

async with SessionLocal() as db:
seeder = DevAccountSeeder(db=db)
seeder = DevAccountSeeder(db=db, accounts=accounts)
summary = await seeder.seed(reset_password=reset_password)

logger.info(
"Dev accounts initialized: createdUsers=%s updatedUsers=%s resetPasswordUsers=%s createdPreferences=%s createdRoots=%s",
"%s initialized: createdUsers=%s updatedUsers=%s resetPasswordUsers=%s "
"createdPreferences=%s createdRoots=%s",
"Production default admin" if settings.is_production_env else "Dev accounts",
summary.created_users,
summary.updated_users,
summary.reset_password_users,
Expand All @@ -81,15 +85,44 @@ async def initialize_dev_accounts(
return True


def _seed_accounts_for_settings(settings: Settings) -> tuple[DevSeedAccount, ...]:
if not settings.is_production_env:
return DEV_SEED_ACCOUNTS

username = (settings.default_admin_username or "").strip()
email = (settings.default_admin_email or "").strip()
password = (settings.default_admin_password or "").strip()
if not username or not email or not password:
raise ValueError(
"DEFAULT_ADMIN_USERNAME, DEFAULT_ADMIN_EMAIL, and DEFAULT_ADMIN_PASSWORD are required"
)

return (
DevSeedAccount(
username=username,
email=email,
password=password,
role=UserRole.ADMIN,
language=UiLanguage.ZH_CN,
),
)


class DevAccountSeeder:
def __init__(self, *, db: AsyncSession) -> None:
def __init__(
self,
*,
db: AsyncSession,
accounts: tuple[DevSeedAccount, ...] = DEV_SEED_ACCOUNTS,
) -> None:
self.db = db
self.accounts = accounts

async def seed(self, *, reset_password: bool = False) -> DevSeedSummary:
summary = DevSeedSummary()
now = datetime.now(UTC)

for spec in DEV_SEED_ACCOUNTS:
for spec in self.accounts:
user = await self._find_existing_user(spec=spec)
created = user is None

Expand Down Expand Up @@ -129,7 +162,12 @@ async def seed(self, *, reset_password: bool = False) -> DevSeedSummary:
if created:
user.password_changed_at = now

await self._ensure_preference(user=user, language=spec.language, now=now, summary=summary)
await self._ensure_preference(
user=user,
language=spec.language,
now=now,
summary=summary,
)
await self._ensure_root_folder(user=user, now=now, summary=summary)

await self.db.commit()
Expand All @@ -156,7 +194,9 @@ async def _ensure_preference(
now: datetime,
summary: DevSeedSummary,
) -> None:
preference = await self.db.scalar(select(UserPreference).where(UserPreference.user_id == user.user_id))
preference = await self.db.scalar(
select(UserPreference).where(UserPreference.user_id == user.user_id)
)
if preference is None:
self.db.add(
UserPreference(
Expand Down
Loading
Loading