Skip to content

Commit d0dee28

Browse files
Merge pull request #2 from JakubPatkowski/1-feat-file-storage-system
feat: add file storage system
2 parents 91aebb5 + 619006d commit d0dee28

83 files changed

Lines changed: 5281 additions & 582 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎backend/.env.example‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,22 @@ ACCESS_TOKEN_EXPIRE_MINUTES=60
1616
APP_NAME=Forum Wędkarskie API
1717
DEBUG=true
1818

19-
# --- Uploads ---------------------------------------------------------------
19+
# --- Uploads / files -------------------------------------------------------
2020
UPLOAD_DIR=./uploads
21-
MAX_UPLOAD_SIZE_BYTES=10485760
21+
MAX_UPLOAD_SIZE_BYTES=26214400
2222
MAX_COMMENT_DEPTH=5
23+
24+
# --- MinIO / object storage ------------------------------------------------
25+
# MINIO_ENDPOINT — seen by the BACKEND (in docker-compose: "minio:9000").
26+
# MINIO_PUBLIC_ENDPOINT — host put inside presigned URLs; must be reachable by
27+
# the BROWSER (in docker-compose: "localhost:9000"). Empty → MINIO_ENDPOINT.
28+
MINIO_ENDPOINT=localhost:9000
29+
MINIO_PUBLIC_ENDPOINT=localhost:9000
30+
MINIO_ACCESS_KEY=minioadmin
31+
MINIO_SECRET_KEY=minioadmin
32+
MINIO_BUCKET=forum-files
33+
MINIO_SECURE=false
34+
# Presigned URL lifetimes (seconds) and orphan retention (hours).
35+
FILE_DOWNLOAD_URL_TTL_SECONDS=3600
36+
FILE_UPLOAD_URL_TTL_SECONDS=900
37+
FILE_ORPHAN_RETENTION_HOURS=24

‎backend/alembic/env.py‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,8 @@
1111

1212
import app.models # noqa: F401 - side-effect import registers legacy ORM mappers
1313
import app.modules.content.infrastructure.orm # noqa: F401 - phase-2 mappers (tags, post_tags)
14+
import app.modules.files.infrastructure.orm # noqa: F401 - phase-3 mappers (files)
1415
import app.modules.identity.infrastructure.orm # noqa: F401 - phase-1 mappers
15-
# NOTE: when the files module (phase 3) introduces its own tables, add
16-
# import app.modules.files.infrastructure.orm # noqa: F401
17-
# here too — otherwise autogenerate will not see them and may emit a DROP.
1816
from alembic import context
1917
from app.config import settings
2018
from app.shared.infrastructure.db import Base
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
"""create files table + avatar FK, drop legacy attachments (phase 3)
2+
3+
Revision ID: 0006
4+
Revises: 0005
5+
Create Date: 2026-05-31 00:00:00.000000
6+
7+
Phase-3 ``files`` module:
8+
9+
* new ``files`` table — generic metadata for every uploaded object (bytes live
10+
in MinIO, addressed by ``storage_key``). Polymorphic ownership via
11+
``owner_type`` + one of ``owner_{post,comment,user,category}_id``.
12+
* ``file_owner_type`` / ``file_status`` Postgres enums.
13+
* FK ``users.avatar_file_id -> files.id`` (ON DELETE SET NULL) — deleting an
14+
avatar file clears the user's avatar automatically.
15+
* drops the legacy ``attachments`` table (replaced by ``files``).
16+
17+
Dialect-aware (Postgres enums/JSON, SQLite fallbacks for tests), following the
18+
pattern established in 0002/0004.
19+
"""
20+
21+
from __future__ import annotations
22+
23+
from collections.abc import Sequence
24+
25+
import sqlalchemy as sa
26+
from alembic import op
27+
from sqlalchemy.dialects import postgresql
28+
29+
revision: str = "0006"
30+
down_revision: str | Sequence[str] | None = "0005"
31+
branch_labels: str | Sequence[str] | None = None
32+
depends_on: str | Sequence[str] | None = None
33+
34+
_OWNER_VALUES = ("standalone", "post", "comment", "user_avatar", "category")
35+
_STATUS_VALUES = ("pending", "ready")
36+
37+
38+
def upgrade() -> None:
39+
bind = op.get_bind()
40+
is_postgres = bind.dialect.name == "postgresql"
41+
42+
if is_postgres:
43+
uuid_type: sa.types.TypeEngine = postgresql.UUID(as_uuid=True)
44+
owner_enum: sa.types.TypeEngine = postgresql.ENUM(
45+
*_OWNER_VALUES, name="file_owner_type", create_type=False
46+
)
47+
status_enum: sa.types.TypeEngine = postgresql.ENUM(
48+
*_STATUS_VALUES, name="file_status", create_type=False
49+
)
50+
owner_enum.create(bind, checkfirst=True)
51+
status_enum.create(bind, checkfirst=True)
52+
else: # pragma: no cover - sqlite fallback for tests only
53+
uuid_type = sa.String(length=36)
54+
owner_enum = sa.String(length=20)
55+
status_enum = sa.String(length=12)
56+
57+
op.create_table(
58+
"files",
59+
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
60+
sa.Column("public_id", uuid_type, nullable=False),
61+
sa.Column("uploader_id", sa.Integer(), nullable=False),
62+
sa.Column("storage_key", sa.String(length=255), nullable=False),
63+
sa.Column("original_name", sa.String(length=255), nullable=False),
64+
sa.Column("content_type", sa.String(length=150), nullable=False),
65+
sa.Column(
66+
"size_bytes", sa.BigInteger(), nullable=False, server_default="0"
67+
),
68+
sa.Column("sha256", sa.String(length=64), nullable=True),
69+
sa.Column(
70+
"status", status_enum, nullable=False, server_default="pending"
71+
),
72+
sa.Column(
73+
"owner_type", owner_enum, nullable=False, server_default="standalone"
74+
),
75+
sa.Column("owner_post_id", sa.Integer(), nullable=True),
76+
sa.Column("owner_comment_id", sa.Integer(), nullable=True),
77+
sa.Column("owner_user_id", sa.Integer(), nullable=True),
78+
sa.Column("owner_category_id", sa.Integer(), nullable=True),
79+
sa.Column("width", sa.Integer(), nullable=True),
80+
sa.Column("height", sa.Integer(), nullable=True),
81+
sa.Column("variants", sa.JSON(), nullable=True),
82+
sa.Column(
83+
"created_at",
84+
sa.DateTime(timezone=True),
85+
server_default=sa.func.now(),
86+
nullable=False,
87+
),
88+
sa.Column(
89+
"updated_at",
90+
sa.DateTime(timezone=True),
91+
server_default=sa.func.now(),
92+
nullable=False,
93+
),
94+
sa.PrimaryKeyConstraint("id", name="pk_files"),
95+
sa.UniqueConstraint("public_id", name="uq_files_public_id"),
96+
sa.UniqueConstraint("storage_key", name="uq_files_storage_key"),
97+
sa.ForeignKeyConstraint(
98+
["uploader_id"], ["users.id"],
99+
name="fk_files_uploader_id_users", ondelete="CASCADE",
100+
),
101+
sa.ForeignKeyConstraint(
102+
["owner_post_id"], ["posts.id"],
103+
name="fk_files_owner_post_id_posts", ondelete="CASCADE",
104+
),
105+
sa.ForeignKeyConstraint(
106+
["owner_comment_id"], ["comments.id"],
107+
name="fk_files_owner_comment_id_comments", ondelete="CASCADE",
108+
),
109+
sa.ForeignKeyConstraint(
110+
["owner_user_id"], ["users.id"],
111+
name="fk_files_owner_user_id_users", ondelete="CASCADE",
112+
),
113+
sa.ForeignKeyConstraint(
114+
["owner_category_id"], ["categories.id"],
115+
name="fk_files_owner_category_id_categories", ondelete="CASCADE",
116+
),
117+
sa.CheckConstraint(
118+
"(CASE WHEN owner_post_id IS NOT NULL THEN 1 ELSE 0 END) "
119+
"+ (CASE WHEN owner_comment_id IS NOT NULL THEN 1 ELSE 0 END) "
120+
"+ (CASE WHEN owner_user_id IS NOT NULL THEN 1 ELSE 0 END) "
121+
"+ (CASE WHEN owner_category_id IS NOT NULL THEN 1 ELSE 0 END) <= 1",
122+
name="ck_files_owner_single",
123+
),
124+
sa.CheckConstraint("size_bytes >= 0", name="ck_files_size_nonneg"),
125+
)
126+
127+
op.create_index("ix_files_public_id", "files", ["public_id"], unique=False)
128+
op.create_index("ix_files_uploader_id", "files", ["uploader_id"])
129+
op.create_index("ix_files_sha256", "files", ["sha256"])
130+
op.create_index("ix_files_owner_type", "files", ["owner_type"])
131+
op.create_index("ix_files_created_at", "files", ["created_at"])
132+
op.create_index("ix_files_owner_post_id", "files", ["owner_post_id"])
133+
op.create_index("ix_files_owner_comment_id", "files", ["owner_comment_id"])
134+
op.create_index("ix_files_owner_user_id", "files", ["owner_user_id"])
135+
op.create_index("ix_files_owner_category_id", "files", ["owner_category_id"])
136+
137+
# FK: users.avatar_file_id -> files.id (column already exists since phase 1).
138+
# SQLite cannot ALTER ADD FK without table rebuild; skip there (tests).
139+
if is_postgres:
140+
op.create_foreign_key(
141+
"fk_users_avatar_file_id_files",
142+
"users",
143+
"files",
144+
["avatar_file_id"],
145+
["id"],
146+
ondelete="SET NULL",
147+
)
148+
149+
# Drop the legacy attachments table (superseded by files).
150+
if sa.inspect(bind).has_table("attachments"):
151+
op.drop_table("attachments")
152+
153+
154+
def downgrade() -> None:
155+
bind = op.get_bind()
156+
is_postgres = bind.dialect.name == "postgresql"
157+
158+
# Recreate the legacy attachments table (best-effort reversibility).
159+
if not sa.inspect(bind).has_table("attachments"):
160+
op.create_table(
161+
"attachments",
162+
sa.Column("id", sa.Integer(), primary_key=True),
163+
sa.Column("original_filename", sa.String(length=255), nullable=False),
164+
sa.Column(
165+
"stored_filename", sa.String(length=255), nullable=False, unique=True
166+
),
167+
sa.Column("content_type", sa.String(length=100), nullable=False),
168+
sa.Column("size_bytes", sa.BigInteger(), nullable=False),
169+
sa.Column("created_at", sa.DateTime(), nullable=False),
170+
sa.Column("uploader_id", sa.Integer(), nullable=False),
171+
sa.Column("post_id", sa.Integer(), nullable=True),
172+
sa.Column("comment_id", sa.Integer(), nullable=True),
173+
)
174+
175+
if is_postgres:
176+
op.drop_constraint(
177+
"fk_users_avatar_file_id_files", "users", type_="foreignkey"
178+
)
179+
180+
for idx in (
181+
"ix_files_owner_category_id",
182+
"ix_files_owner_user_id",
183+
"ix_files_owner_comment_id",
184+
"ix_files_owner_post_id",
185+
"ix_files_created_at",
186+
"ix_files_owner_type",
187+
"ix_files_sha256",
188+
"ix_files_uploader_id",
189+
"ix_files_public_id",
190+
):
191+
op.drop_index(idx, table_name="files")
192+
op.drop_table("files")
193+
194+
if is_postgres:
195+
op.execute("DROP TYPE IF EXISTS file_status")
196+
op.execute("DROP TYPE IF EXISTS file_owner_type")

‎backend/app/config.py‎

Lines changed: 67 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,25 +33,88 @@ class Settings(BaseSettings):
3333
# Komentarze
3434
MAX_COMMENT_DEPTH: int = 5
3535

36-
# Uploady plików
36+
# Uploady plików — limity i whitelista typów (moduł files / faza 3).
37+
# UPLOAD_DIR zostaje tylko dla zgodności (PVC montaż); od fazy 3 bajty
38+
# trzymamy w MinIO, nie na dysku.
3739
UPLOAD_DIR: str = "/app/uploads"
38-
MAX_UPLOAD_SIZE_BYTES: int = 10 * 1024 * 1024 # 10 MB
40+
MAX_UPLOAD_SIZE_BYTES: int = 25 * 1024 * 1024 # 25 MB (wideo bywa większe)
3941
ALLOWED_MIME_TYPES: list[str] = [
40-
# Obrazy
42+
# Obrazy (wyświetlane inline, generujemy miniatury)
4143
"image/jpeg",
4244
"image/png",
4345
"image/gif",
4446
"image/webp",
45-
# Wideo
47+
"image/avif",
48+
# Wideo (wyświetlane inline w <video>)
4649
"video/mp4",
4750
"video/webm",
51+
"video/quicktime",
52+
# Audio (odtwarzane inline w <audio>)
53+
"audio/mpeg", # mp3
54+
"audio/ogg",
55+
"audio/wav",
56+
"audio/x-wav",
4857
# Dokumenty / pliki do pobrania
4958
"application/pdf",
5059
"application/msword",
5160
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
61+
"application/vnd.ms-excel",
62+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
5263
"application/zip",
64+
"application/json",
5365
"text/plain",
66+
"text/markdown",
67+
"text/csv",
5468
]
69+
# Typy, które sniffing (python-magic) zwraca dla kontenerów OOXML/zip i
70+
# plików tekstowych — akceptowane, gdy zadeklarowany typ jest na whiteliście.
71+
SNIFF_GENERIC_MIME_TYPES: list[str] = [
72+
"application/zip",
73+
"application/octet-stream",
74+
"text/plain",
75+
]
76+
# Typy zawsze odrzucane po sniffingu (wykonywalne / skrypty / HTML z JS).
77+
SNIFF_BLOCKED_MIME_TYPES: list[str] = [
78+
"text/html",
79+
"application/x-dosexec",
80+
"application/x-executable",
81+
"application/x-sharedlib",
82+
"application/x-mach-binary",
83+
"text/x-shellscript",
84+
"application/x-msdownload",
85+
]
86+
87+
# --- MinIO / S3 object storage (moduł files) ---------------------------
88+
# Endpoint widziany przez BACKEND (wewnątrz sieci docker/k8s).
89+
MINIO_ENDPOINT: str = "localhost:9000"
90+
# Endpoint wstawiany do presigned URL-i (musi być osiągalny z PRZEGLĄDARKI).
91+
# Pusty → użyj MINIO_ENDPOINT. W docker-compose: "localhost:9000".
92+
MINIO_PUBLIC_ENDPOINT: str = ""
93+
MINIO_ACCESS_KEY: str = "minioadmin"
94+
MINIO_SECRET_KEY: str = "minioadmin"
95+
MINIO_BUCKET: str = "forum-files"
96+
MINIO_SECURE: bool = False # True za TLS
97+
MINIO_REGION: str = "us-east-1"
98+
99+
# Czas ważności presigned URL-i (sekundy).
100+
FILE_DOWNLOAD_URL_TTL_SECONDS: int = 3600 # 1 h — pobieranie/podgląd
101+
FILE_UPLOAD_URL_TTL_SECONDS: int = 900 # 15 min — okno na PUT do MinIO
102+
103+
# Miniatury obrazów: nazwa wariantu → maks. krawędź (px). Oryginał zawsze
104+
# zostaje; warianty generowane przy finalizacji uploadu (tylko obrazy).
105+
IMAGE_THUMBNAIL_SIZES: dict[str, int] = {
106+
"thumb": 256,
107+
"medium": 1024,
108+
}
109+
110+
# Retencja plików osieroconych (standalone, niepodpiętych) w godzinach.
111+
# Po tym czasie cleanup job usuwa wiersz DB + obiekty z MinIO.
112+
FILE_ORPHAN_RETENTION_HOURS: int = 24
113+
114+
@property
115+
def minio_public_endpoint(self) -> str:
116+
"""Endpoint do presigned URL-i — fallback na wewnętrzny endpoint."""
117+
return self.MINIO_PUBLIC_ENDPOINT or self.MINIO_ENDPOINT
55118

56119
# Panel administratora
57120
ADMIN_COOKIE_NAME: str = "admin_token"

0 commit comments

Comments
 (0)