Skip to content

Commit 5352ea2

Browse files
committed
fixed upload bug and progress on multimedia
1 parent 8df06e5 commit 5352ea2

13 files changed

Lines changed: 260 additions & 246 deletions

File tree

backend/amcat4/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ class Settings(BaseSettings):
113113
s3_host: Annotated[str | None, Field(description="S3-compatible object storage host")] = None
114114
s3_access_key: Annotated[str | None, Field(description="S3 access key")] = None
115115
s3_secret_key: Annotated[str | None, Field(description="S3 secret key")] = None
116+
s3_use_proxy: Annotated[str | None, Field(description="If true, use a proxy for s3_host at /s3. (proxy needs to be created by Caddy)")] = None
116117

117118
oidc_url: Annotated[
118119
str | None,

backend/amcat4/connections.py

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,16 +21,18 @@ def __init__(
2121
self,
2222
elastic: AsyncElasticsearch | None = None,
2323
s3_client: S3Client | None = None,
24+
s3_proxy_client: S3Client | None = None,
2425
s3_context_stack: AsyncExitStack | None = None,
2526
http_client: httpx.AsyncClient | None = None,
2627
):
2728
self.elastic = elastic
2829
self.s3_client = s3_client
30+
self.s3_proxy_client = s3_client
2931
self.s3_context_stack = s3_context_stack
3032
self.http_client = http_client
3133

3234

33-
CONNECTIONS = AmcatConnections(s3_client=None, elastic=None, http_client=None) # type: ignore
35+
CONNECTIONS = AmcatConnections(s3_client=None, s3_proxy_client=None, elastic=None, http_client=None) # type: ignore
3436

3537

3638
@asynccontextmanager
@@ -55,7 +57,7 @@ async def amcat_connections() -> AsyncGenerator[None, None]:
5557

5658
def es() -> AsyncElasticsearch:
5759
"""
58-
Use this function to access the elasticsearch connection.
60+
Access the elasticsearch connection.
5961
"""
6062
if CONNECTIONS.elastic is None:
6163
raise ConnectionError("Elasticsearch connection not initialized")
@@ -64,22 +66,38 @@ def es() -> AsyncElasticsearch:
6466

6567
def s3() -> S3Client:
6668
"""
67-
Use this function to access the s3 client.
69+
Access the s3 client.
6870
"""
6971
if CONNECTIONS.s3_client is None:
7072
raise ConnectionError("S3 client not started")
7173
return CONNECTIONS.s3_client
7274

7375

76+
def s3_public() -> S3Client:
77+
"""
78+
Only use this for creating presigned requests for the public
79+
s3 server. If the s3 server is not publicly accessible, set s3_use_proxy
80+
to use the s3_proxy_client, which signs urls
81+
"""
82+
settings = get_settings()
83+
use_proxy = settings.s3_use_proxy and not settings.test_mode
84+
s3 = CONNECTIONS.s3_proxy_client if use_proxy else CONNECTIONS.s3_client
85+
if s3 is None:
86+
raise ConnectionError("S3 client not started")
87+
return s3
88+
89+
90+
def s3_enabled() -> bool:
91+
settings = get_settings()
92+
return all([settings.s3_host, settings.s3_access_key, settings.s3_secret_key])
93+
94+
7495
def http() -> httpx.AsyncClient:
7596
if CONNECTIONS.http_client is None:
7697
raise ConnectionError("HTTP client not started")
7798
return CONNECTIONS.http_client
7899

79100

80-
def s3_enabled() -> bool:
81-
settings = get_settings()
82-
return all([settings.s3_host, settings.s3_access_key, settings.s3_secret_key])
83101

84102

85103
async def _start_elastic():
@@ -127,8 +145,6 @@ async def _start_s3() -> None:
127145
if settings.s3_access_key is None or settings.s3_secret_key is None:
128146
raise ValueError("s3_access_key or s3_secret_key not specified")
129147

130-
## It seems aioboto3 uses some of the sync boto3 types, so we need to hack around typing here.
131-
132148
session = get_session()
133149
client = session.create_client(
134150
service_name="s3",
@@ -138,14 +154,28 @@ async def _start_s3() -> None:
138154
config=AioConfig(signature_version="s3v4"),
139155
)
140156

157+
# If the s3 server is hosted directly with docker compose, fastapi needs to
158+
# use a client with the internal s3_host, but presigned requests need to be created
159+
# for the /s3 proxy.
160+
proxy_url = settings.host + '/s3' if settings.s3_use_proxy else settings.s3_host
161+
proxy_client = session.create_client(
162+
service_name="s3",
163+
endpoint_url=proxy_url,
164+
aws_access_key_id=settings.s3_access_key,
165+
aws_secret_access_key=settings.s3_secret_key,
166+
config=AioConfig(signature_version="s3v4"),
167+
)
168+
141169
CONNECTIONS.s3_context_stack = AsyncExitStack()
142170
CONNECTIONS.s3_client = await CONNECTIONS.s3_context_stack.enter_async_context(client)
171+
CONNECTIONS.s3_proxy_client = await CONNECTIONS.s3_context_stack.enter_async_context(proxy_client)
143172

144173

145174
async def _close_s3():
146175
if CONNECTIONS.s3_context_stack is not None:
147176
await CONNECTIONS.s3_context_stack.aclose()
148177
CONNECTIONS.s3_client = None
178+
CONNECTIONS.s3_proxy_client = None
149179
CONNECTIONS.s3_context_stack = None
150180

151181

backend/amcat4/objectstorage/s3bucket.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from typing_extensions import TypedDict
1717

1818
from amcat4.config import get_settings
19-
from amcat4.connections import s3
19+
from amcat4.connections import s3, s3_public
2020

2121
PRESIGNED_POST_HOURS_VALID = 6
2222

@@ -188,7 +188,7 @@ async def presigned_post(
188188
conditions.append({"success_action_redirect": redirect})
189189
fields["success_action_redirect"] = redirect
190190

191-
pp = await s3().generate_presigned_post(
191+
pp = await s3_public().generate_presigned_post(
192192
Bucket=bucket,
193193
Key=key,
194194
Fields=fields,
@@ -202,9 +202,9 @@ async def presigned_get(bucket: str, key: str, hours_valid=24, **kwargs) -> str:
202202
params = {"Bucket": bucket, "Key": key, **kwargs}
203203
params = {k: v for k, v in params.items() if v is not None}
204204

205-
return await s3().generate_presigned_url("get_object", Params=params, ExpiresIn=hours_valid * 3600)
205+
return await s3_public().generate_presigned_url("get_object", Params=params, ExpiresIn=hours_valid * 3600)
206206

207207

208208
async def get_object_head(bucket: str, key: str) -> HeadObjectOutputTypeDef:
209-
res = await s3().head_object(Bucket=bucket, Key=key)
209+
res = await s3_public().head_object(Bucket=bucket, Key=key)
210210
return res

backend/uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

deploy/docker-compose.yml

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,8 @@ services:
4545
volumes:
4646
- ${ELASTIC_SNAPSHOT_PATH:-snapshot_data}:/snapshots
4747

48-
elasticsearch:
49-
profiles: [deploy, dev]
48+
elasticsearch: &elastic_base
49+
profiles: [deploy]
5050
image: docker.elastic.co/elasticsearch/elasticsearch:8.18.4
5151
depends_on:
5252
elasticsearch-init:
@@ -83,8 +83,14 @@ services:
8383
- ${ELASTIC_DATA_STORAGE:-elasticsearch_data}:/usr/share/elasticsearch/data:rw
8484
- ${ELASTIC_SNAPSHOT_PATH:-snapshot_data}:/usr/share/elasticsearch/data/snapshots:rw
8585

86-
# SeaweedFS S3-compatible storage — only used in local dev (run with --profile dev)
87-
seaweedfs:
86+
elasticsearch-dev:
87+
<<: *elastic_base
88+
profiles: [dev]
89+
ports:
90+
- "127.0.0.1:9200:9200"
91+
92+
# S3-compatible storage — only used in local dev (run with --profile dev)
93+
s3:
8894
profiles: [dev]
8995
image: chrislusf/seaweedfs:latest
9096
container_name: seaweedfs01
@@ -99,8 +105,8 @@ services:
99105
- seaweedfs_data:/data
100106
- ./deploy/s3config.json:/etc/seaweedfs/s3config.json:ro
101107
ports:
102-
- "8333:8333" # S3 Gateway
103-
- "8889:8888" # Web UI
108+
- "127.0.0.1:8333:8333" # S3 Gateway
109+
# - "127.0.0.1:8889:8888" # Web UI
104110
healthcheck:
105111
test: ["CMD", "curl", "-f", "http://localhost:8888/"]
106112
interval: 10s

frontend/Caddyfile

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
11
{$AMCAT4_URL:localhost} {
22
# API - Proxy to the FastAPI container
3-
43
handle /api {
54
reverse_proxy api:5000
65
}
76
handle /api/* {
87
reverse_proxy api:5000
98
}
109

10+
# S3 - Proxy to the S3 container
11+
handle /s3/* {
12+
reverse_proxy {$AMCAT4_S3_HOST} {
13+
header_up Host {host}
14+
}
15+
}
16+
1117
# UI - Static SPA logic
1218
handle {
1319
root * /srv

frontend/package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,10 @@
105105
"vite-tsconfig-paths": "^6.1.1"
106106
},
107107
"pnpm": {
108-
"onlyBuiltDependencies": ["esbuild", "sharp"],
108+
"onlyBuiltDependencies": [
109+
"esbuild",
110+
"sharp"
111+
],
109112
"overrides": {
110113
"minimatch": "^10.0.0",
111114
"flatted": ">=3.4.0"

frontend/src/api/fields.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,11 @@ export function useFields(user?: AmcatSessionUser, projectId?: AmcatProjectId |
3838
});
3939
}
4040

41-
async function getFields(user?: AmcatSessionUser, projectId?: AmcatProjectId) {
41+
async function getFields(user?: AmcatSessionUser, projectId?: AmcatProjectId): Promise<undefined | AmcatField[]> {
4242
if (!user || !projectId) return undefined;
4343
const res = await user.api.get(`/index/${projectId}/fields`);
4444
const fieldsArray = Object.keys(res.data).map((name) => ({ name, ...res.data[name] }));
45-
const fields = z.array(amcatFieldSchema).parse(fieldsArray);
45+
const fields: AmcatField[] = z.array(amcatFieldSchema).parse(fieldsArray);
4646
return fields.map((f) => {
4747
// set default values
4848
const default_settings = DEFAULT_CLIENT_SETTINGS[f.name] || DEFAULT_CLIENT_SETTINGS["__DEFAULT__"];

frontend/src/components/Contexts/AuthProvider.tsx

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,19 +19,21 @@ export interface AmcatSession {
1919
}
2020

2121
async function createUser(signOut: () => void): Promise<AmcatSessionUser> {
22-
const sessionCookie = parseClientSessionCookie();
23-
console.log(sessionCookie);
24-
25-
// if sessionCookie is invalid, break the session.
26-
// This is strict, because the sessioncookie is the only way for the client to know
27-
// if an amcat session cookie (httponly) is present
28-
if (sessionCookie === "broken") signOut();
22+
let sessionCookie = parseClientSessionCookie();
2923

3024
const api = axios.create({
3125
baseURL: "/api/",
3226
withCredentials: true,
3327
});
3428

29+
// if sessionCookie is invalid, break the session.
30+
// This is strict, because the sessioncookie is the only way for the client to know
31+
// if an amcat session cookie (httponly) is present
32+
if (sessionCookie === "broken") {
33+
signOut();
34+
sessionCookie = null;
35+
}
36+
3537
// We use a promise to avoid multiple requests performing the refresh flow
3638
let refreshQueue: Promise<null> | undefined;
3739

0 commit comments

Comments
 (0)