Skip to content

Commit 0854a8b

Browse files
feat: move validation to python side, mount rustfs properly
1 parent 5a35e81 commit 0854a8b

File tree

7 files changed

+126
-111
lines changed

7 files changed

+126
-111
lines changed

docker-compose.yml

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,22 +28,33 @@ services:
2828
- postgres
2929
rustfs:
3030
image: rustfs/rustfs:latest
31+
security_opt:
32+
- "no-new-privileges:true"
3133
container_name: rustfs
3234
restart: unless-stopped
3335
ports:
3436
- "9000:9000"
3537
- "9001:9001"
3638
volumes:
37-
- ${PWD}/data:/data
39+
- ./data:/data
3840
environment:
39-
RUSTFS_ACCESS_KEY: rustfsadmin
40-
RUSTFS_SECRET_KEY: rustfsadmin
41+
RUSTFS_ADDRESS: "0.0.0.0:9000"
42+
RUSTFS_CONSOLE_ADDRESS: "0.0.0.0:9001"
43+
RUSTFS_VOLUMES: "/data/rustfs{0..3}"
44+
RUSTFS_ACCESS_KEY: "rustfsadmin"
45+
RUSTFS_SECRET_KEY: "rustfsadmin"
4146
RUSTFS_CONSOLE_ENABLE: "true"
42-
command:
43-
- --address
44-
- :9000
45-
- --console-enable
46-
- /data
47+
healthcheck:
48+
test:
49+
[
50+
"CMD",
51+
"sh", "-c",
52+
"curl -f http://localhost:9000/health && curl -f http://localhost:9001/rustfs/console/health"
53+
]
54+
interval: 30s
55+
timeout: 10s
56+
retries: 3
57+
start_period: 40s
4758

4859
volumes:
4960
postgres_data:

src/backend/alembic/versions/cb303129bc56_.py

Lines changed: 1 addition & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -58,93 +58,12 @@ def upgrade() -> None:
5858
sa.PrimaryKeyConstraint("id"),
5959
)
6060

61-
# 3. Trigger Function: Sync defaults into arrays
62-
op.execute("""
63-
CREATE OR REPLACE FUNCTION sync_config_defaults()
64-
RETURNS trigger AS $$
65-
BEGIN
66-
IF NEW.download_configs IS NULL THEN
67-
NEW.download_configs := ARRAY[NEW.default_number_of_downloads];
68-
ELSIF NOT (NEW.default_number_of_downloads = ANY(NEW.download_configs)) THEN
69-
NEW.download_configs := array_append(NEW.download_configs, NEW.default_number_of_downloads);
70-
END IF;
71-
72-
IF NEW.time_configs IS NULL THEN
73-
NEW.time_configs := ARRAY[NEW.default_expiry];
74-
ELSIF NOT (NEW.default_expiry = ANY(NEW.time_configs)) THEN
75-
NEW.time_configs := array_append(NEW.time_configs, NEW.default_expiry);
76-
END IF;
77-
78-
RETURN NEW;
79-
END;
80-
$$ LANGUAGE plpgsql;
81-
""")
82-
83-
# 4. Trigger Function: Singleton
84-
op.execute("""
85-
CREATE OR REPLACE FUNCTION enforce_singleton()
86-
RETURNS trigger AS $$
87-
BEGIN
88-
IF (SELECT COUNT(*) FROM config) >= 1 THEN
89-
RAISE EXCEPTION 'Only one row is allowed in config';
90-
END IF;
91-
RETURN NEW;
92-
END;
93-
$$ LANGUAGE plpgsql;
94-
""")
95-
96-
# 5. Trigger Function: Delete Prevention
97-
op.execute("""
98-
CREATE OR REPLACE FUNCTION prevent_config_deletion()
99-
RETURNS trigger AS $$
100-
BEGIN
101-
RAISE EXCEPTION 'The configuration record cannot be deleted';
102-
END;
103-
$$ LANGUAGE plpgsql;
104-
""")
105-
106-
# 6. Trigger Function: Validate File Type Exclusivity
107-
# Uses the Postgres overlap operator (&&) to check for common elements
108-
op.execute("""
109-
CREATE OR REPLACE FUNCTION validate_file_types()
110-
RETURNS trigger AS $$
111-
BEGIN
112-
IF NEW.allowed_file_types && NEW.banned_file_types THEN
113-
RAISE EXCEPTION 'Conflict: allowed_file_types and banned_file_types cannot share common extensions';
114-
END IF;
115-
RETURN NEW;
116-
END;
117-
$$ LANGUAGE plpgsql;
118-
""")
119-
120-
# 7. Apply Triggers
121-
op.execute(
122-
"CREATE TRIGGER sync_defaults_trigger BEFORE INSERT OR UPDATE ON config FOR EACH ROW EXECUTE FUNCTION sync_config_defaults();"
123-
)
124-
op.execute(
125-
"CREATE TRIGGER singleton_trigger BEFORE INSERT ON config FOR EACH ROW EXECUTE FUNCTION enforce_singleton();"
126-
)
127-
op.execute(
128-
"CREATE TRIGGER prevent_deletion_trigger BEFORE DELETE ON config FOR EACH ROW EXECUTE FUNCTION prevent_config_deletion();"
129-
)
130-
op.execute(
131-
"CREATE TRIGGER validate_file_types_trigger BEFORE INSERT OR UPDATE ON config FOR EACH ROW EXECUTE FUNCTION validate_file_types();"
132-
)
133-
134-
# 8. Seed Initial Data
61+
# 3. Seed Initial Data
13562
config_instance = Config()
13663
data = config_instance.model_dump(exclude={"id"})
13764
op.bulk_insert(sa.table("config", *[sa.column(k) for k in data.keys()]), [data])
13865

13966

14067
def downgrade() -> None:
141-
op.execute("DROP TRIGGER IF EXISTS validate_file_types_trigger ON config;")
142-
op.execute("DROP TRIGGER IF EXISTS prevent_deletion_trigger ON config;")
143-
op.execute("DROP TRIGGER IF EXISTS singleton_trigger ON config;")
144-
op.execute("DROP TRIGGER IF EXISTS sync_defaults_trigger ON config;")
145-
op.execute("DROP FUNCTION IF EXISTS validate_file_types();")
146-
op.execute("DROP FUNCTION IF EXISTS prevent_config_deletion();")
147-
op.execute("DROP FUNCTION IF EXISTS enforce_singleton();")
148-
op.execute("DROP FUNCTION IF EXISTS sync_config_defaults();")
14968
op.drop_table("file")
15069
op.drop_table("config")
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
"""empty message
2+
3+
Revision ID: dfb12aeef438
4+
Revises: 6d1b8bb8ce78
5+
Create Date: 2025-12-31 14:43:50.158958
6+
7+
"""
8+
9+
from typing import Sequence
10+
11+
import sqlalchemy as sa
12+
from sqlalchemy.dialects import postgresql
13+
14+
from alembic import op
15+
16+
# revision identifiers, used by Alembic.
17+
revision: str = "dfb12aeef438"
18+
down_revision: str | Sequence[str] | None = "6d1b8bb8ce78"
19+
branch_labels: str | Sequence[str] | None = None
20+
depends_on: str | Sequence[str] | None = None
21+
22+
23+
def upgrade() -> None:
24+
"""Upgrade schema."""
25+
# ### commands auto generated by Alembic - please adjust! ###
26+
op.alter_column(
27+
"file",
28+
"created_at",
29+
existing_type=postgresql.TIMESTAMP(timezone=True),
30+
type_=sa.DateTime(),
31+
existing_nullable=False,
32+
existing_server_default=sa.text("CURRENT_TIMESTAMP"),
33+
)
34+
# ### end Alembic commands ###
35+
36+
37+
def downgrade() -> None:
38+
"""Downgrade schema."""
39+
# ### commands auto generated by Alembic - please adjust! ###
40+
op.alter_column(
41+
"file",
42+
"created_at",
43+
existing_type=sa.DateTime(),
44+
type_=postgresql.TIMESTAMP(timezone=True),
45+
existing_nullable=False,
46+
existing_server_default=sa.text("CURRENT_TIMESTAMP"),
47+
)
48+
# ### end Alembic commands ###

src/backend/app/main.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,7 @@
3737
from app.routes.user import router as user_router
3838

3939
app.include_router(user_router)
40+
41+
from app.routes.upload import router as upload_router
42+
43+
app.include_router(upload_router)

src/backend/app/models/config.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
from datetime import timedelta
22
from uuid import UUID
33

4-
from sqlalchemy import BigInteger, Integer, String, text
4+
from pydantic import model_validator
5+
from sqlalchemy import BigInteger, Connection, Integer, String, event, text
56
from sqlalchemy.dialects.postgresql import ARRAY
7+
from sqlalchemy.orm import Mapper
68
from sqlmodel import Column, Field, SQLModel
79

810
from app.converter.bytes import ByteSize
@@ -37,6 +39,27 @@ class ConfigIn(SQLModel):
3739
allowed_file_types: list[str] = Field(default=[], sa_column=Column(ARRAY(String)))
3840
banned_file_types: list[str] = Field(default=[], sa_column=Column(ARRAY(String)))
3941

42+
@model_validator(mode="after")
43+
def sync_defaults(self) -> ConfigIn:
44+
if self.default_number_of_downloads not in self.download_configs:
45+
raise ValueError(
46+
"Conflict: default_number_of_downloads must be one of the values in download_configs"
47+
)
48+
49+
if self.default_expiry not in self.time_configs:
50+
raise ValueError(
51+
"Conflict: default_expiry must be one of the values in time_configs"
52+
)
53+
return self
54+
55+
@model_validator(mode="after")
56+
def validate_file_types_consistency(self) -> "ConfigIn":
57+
if set(self.allowed_file_types) & set(self.banned_file_types):
58+
raise ValueError(
59+
"Conflict: allowed_file_types and banned_file_types cannot share common extensions"
60+
)
61+
return self
62+
4063

4164
class Config(ConfigIn, table=True):
4265
id: UUID = Field(
@@ -48,3 +71,15 @@ class Config(ConfigIn, table=True):
4871
)
4972
},
5073
)
74+
75+
76+
@event.listens_for(Config, "before_insert")
77+
def enforce_singleton(mapper: Mapper, connection: Connection, target: Config):
78+
result = connection.execute(text("SELECT 1 FROM config LIMIT 1"))
79+
if result.fetchone() is not None:
80+
raise ValueError("Only one row is allowed in config")
81+
82+
83+
@event.listens_for(Config, "before_delete")
84+
def prevent_config_deletion(mapper: Mapper, connection: Connection, target: Config):
85+
raise ValueError("The configuration record cannot be deleted")

src/backend/app/models/files.py

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
from datetime import datetime
22
from uuid import UUID
33

4-
from sqlalchemy import Column, DateTime, func, text
4+
from pydantic import model_validator
5+
from sqlalchemy import text
56
from sqlmodel import Field, SQLModel
67

78

@@ -18,10 +19,10 @@ class File(SQLModel, table=True):
1819

1920
# Tracking downloads
2021
download_count: int = 0
21-
created_at: datetime = Field(
22-
sa_column=Column(
23-
DateTime(timezone=True),
24-
server_default=func.now(),
25-
nullable=False,
26-
)
27-
)
22+
created_at: datetime = Field()
23+
24+
@model_validator(mode="after")
25+
def validate_expire(self) -> "File":
26+
if self.expires_at < self.created_at:
27+
raise ValueError("Expiration time cannot be earlier than creation time")
28+
return self

src/backend/app/routes/upload.py

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,21 @@
1+
from typing import Annotated
12
from http import HTTPStatus
23

3-
from fastapi import APIRouter, HTTPException
4+
from fastapi import APIRouter, HTTPException, UploadFile, Form
45
from sqlmodel import select
56

6-
from app.deps import CurrentUser, SessionDep
7+
from app.deps import S3Dep
78
from app.models import User
89
from app.models.user import UserOut
910

1011
router = APIRouter()
1112

1213

13-
@router.get("/upload", response_model=UserOut)
14+
@router.get("/upload")
1415
async def get_current_user(
15-
user: CurrentUser,
16-
session: SessionDep,
16+
file: UploadFile,
17+
expire_after_n_download: Annotated[int, Form()],
18+
expire_after: Annotated[int, Form()],
19+
s3: S3Dep,
1720
):
18-
user_object = select(User).where(User.id == user.id)
19-
result = await session.exec(user_object)
20-
item = result.first()
21-
if not item:
22-
raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="User not found")
23-
24-
return item
21+
pass

0 commit comments

Comments
 (0)