Skip to content

Commit 2d88943

Browse files
committed
feat: presigned-S3 download path for package blobs
API Gateway HTTP API + apig-wsgi base64-rounds binary responses and doesn't stream them, which throttles downloads of the encrypted SQLite to ~17KB/s (a 100MB package would take 100min). Bypass the bottleneck by handing the client a presigned S3 URL. Additive only — nothing existing is changed: - New S3 bucket dumpus-prod-package-data (private, encrypted, 90d lifecycle, CORS for browser fetches). Worker uploads each finished encrypted blob via a VPC gateway endpoint so the bytes don't transit fck-nat. - Worker dual-writes: still inserts the legacy DB row, ALSO uploads to S3. S3 failure is logged-and-ignored so the legacy /data path keeps working. - New endpoint GET /process/<id>/blob returns {url, iv, ttl} — same auth contract as /data, returns 409 if the blob isn't in S3 yet (caller falls back to /data). - /data is untouched. Frontend can migrate to /blob at its own pace, decrypt locally with the UPN, and we deprecate /data later.
1 parent d42e6c7 commit 2d88943

8 files changed

Lines changed: 239 additions & 0 deletions

File tree

infra/terraform/blob_storage.tf

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
# Stores encrypted package blobs out-of-band from Postgres so the API can
2+
# return short presigned URLs instead of streaming hundreds of MB through
3+
# Lambda + API Gateway (which is slow and capped at 6MB).
4+
#
5+
# The blob itself is encrypted client-key-derived AES-CBC by the worker, so
6+
# there's no privacy concern about the bucket holding it — only the holder
7+
# of the UPN can decrypt it.
8+
9+
resource "aws_s3_bucket" "package_data" {
10+
bucket = "${local.name}-package-data"
11+
}
12+
13+
resource "aws_s3_bucket_server_side_encryption_configuration" "package_data" {
14+
bucket = aws_s3_bucket.package_data.id
15+
16+
rule {
17+
apply_server_side_encryption_by_default {
18+
sse_algorithm = "AES256"
19+
}
20+
}
21+
}
22+
23+
resource "aws_s3_bucket_public_access_block" "package_data" {
24+
bucket = aws_s3_bucket.package_data.id
25+
26+
block_public_acls = true
27+
block_public_policy = true
28+
ignore_public_acls = true
29+
restrict_public_buckets = true
30+
}
31+
32+
resource "aws_s3_bucket_versioning" "package_data" {
33+
bucket = aws_s3_bucket.package_data.id
34+
versioning_configuration {
35+
status = "Disabled"
36+
}
37+
}
38+
39+
resource "aws_s3_bucket_lifecycle_configuration" "package_data" {
40+
bucket = aws_s3_bucket.package_data.id
41+
42+
rule {
43+
id = "expire-blobs"
44+
status = "Enabled"
45+
46+
filter {}
47+
48+
expiration {
49+
days = var.package_data_retention_days
50+
}
51+
52+
abort_incomplete_multipart_upload {
53+
days_after_initiation = 1
54+
}
55+
}
56+
}
57+
58+
# CORS so the frontend (web.dumpus.app and dev) can fetch the presigned URL
59+
# from the browser. Only GET is allowed.
60+
resource "aws_s3_bucket_cors_configuration" "package_data" {
61+
bucket = aws_s3_bucket.package_data.id
62+
63+
cors_rule {
64+
allowed_methods = ["GET"]
65+
allowed_origins = ["*"]
66+
allowed_headers = ["*"]
67+
expose_headers = ["ETag", "Content-Length"]
68+
max_age_seconds = 300
69+
}
70+
}
71+
72+
# VPC gateway endpoint for S3 so worker uploads (potentially hundreds of MB)
73+
# don't go through fck-nat. Gateway endpoints are free, route table-based.
74+
resource "aws_vpc_endpoint" "s3" {
75+
vpc_id = aws_vpc.main.id
76+
service_name = "com.amazonaws.${var.region}.s3"
77+
vpc_endpoint_type = "Gateway"
78+
route_table_ids = [aws_route_table.private.id]
79+
80+
tags = { Name = "${local.name}-s3-endpoint" }
81+
}

infra/terraform/iam.tf

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,15 @@ data "aws_iam_policy_document" "api" {
4141
aws_secretsmanager_secret.wh_url.arn,
4242
]
4343
}
44+
45+
# boto3.generate_presigned_url signs with this role's identity, so the
46+
# role itself must be allowed to GetObject — the URL just borrows that
47+
# permission for ~5 minutes.
48+
statement {
49+
sid = "ReadPackageBlobs"
50+
actions = ["s3:GetObject"]
51+
resources = ["${aws_s3_bucket.package_data.arn}/*"]
52+
}
4453
}
4554

4655
resource "aws_iam_role_policy" "api" {
@@ -87,6 +96,13 @@ data "aws_iam_policy_document" "worker" {
8796
aws_secretsmanager_secret.wh_url.arn,
8897
]
8998
}
99+
100+
# Worker uploads each finished encrypted blob.
101+
statement {
102+
sid = "WritePackageBlobs"
103+
actions = ["s3:PutObject"]
104+
resources = ["${aws_s3_bucket.package_data.arn}/*"]
105+
}
90106
}
91107

92108
resource "aws_iam_role_policy" "worker" {

infra/terraform/lambda.tf

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ locals {
1515
DISWHO_JWT_SECRET = aws_secretsmanager_secret.diswho_jwt_secret.arn
1616
WH_URL = aws_secretsmanager_secret.wh_url.arn
1717
})
18+
19+
# Encrypted package blobs live here; API generates presigned URLs.
20+
PACKAGE_DATA_BUCKET = aws_s3_bucket.package_data.id
21+
PACKAGE_DATA_PRESIGNED_URL_TTL_SECONDS = tostring(var.package_data_presigned_url_ttl_seconds)
1822
}
1923
}
2024

infra/terraform/outputs.tf

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,7 @@ output "fck_nat_eip" {
3737
description = "Public IP that all outbound Lambda traffic comes from"
3838
value = aws_eip.fck_nat.public_ip
3939
}
40+
41+
output "package_data_bucket" {
42+
value = aws_s3_bucket.package_data.id
43+
}

infra/terraform/variables.tf

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,18 @@ variable "worker_reserved_concurrency" {
139139

140140
# ---- App secrets / config ----
141141

142+
variable "package_data_retention_days" {
143+
description = "How long to keep encrypted package blobs in S3 before lifecycle deletion. Acts as a passive GDPR-friendly cleanup."
144+
type = number
145+
default = 90
146+
}
147+
148+
variable "package_data_presigned_url_ttl_seconds" {
149+
description = "TTL on the presigned download URL the API hands the client. Short = safer if the URL leaks."
150+
type = number
151+
default = 300
152+
}
153+
142154
variable "wh_url" {
143155
description = "Optional Discord webhook URL the app pings for internal notifications (new package, errored package, etc.). Leave blank to disable."
144156
type = string

src/app.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,56 @@ def get_package_data(package_id):
232232

233233
return Response(data, mimetype='application/octet-stream')
234234

235+
236+
@app.route('/process/<package_id>/blob', methods=['GET'])
237+
def get_package_blob(package_id):
238+
"""Return a presigned S3 URL the client can fetch the encrypted blob from.
239+
240+
Pairs with /data: same auth, same lookup, but instead of streaming the
241+
decrypted SQLite through API Gateway (slow + 6MB cap), the client
242+
downloads encrypted bytes directly from S3 and decrypts them locally
243+
using its UPN.
244+
245+
Response: {"url": <presigned GET>, "iv": <hex>, "ttl": <seconds>}
246+
Errors: 401 (bad bearer), 404 (no row), 409 (row exists but no S3 blob).
247+
"""
248+
import os
249+
import blob_storage
250+
from db import SavedPackageData
251+
252+
(is_auth, _) = check_authorization_bearer(request, package_id)
253+
if not is_auth:
254+
return make_response('', 401)
255+
256+
if not blob_storage.is_enabled():
257+
# /blob is meaningless without S3 — fall through so callers can
258+
# detect and switch back to /data.
259+
return jsonify({'errorMessageCode': 'S3_NOT_CONFIGURED'}), 501
260+
261+
session = Session()
262+
row = session.query(SavedPackageData).filter_by(package_id=package_id).order_by(
263+
SavedPackageData.created_at.desc()
264+
).first()
265+
session.close()
266+
267+
if not row:
268+
return make_response('', 404)
269+
270+
if not blob_storage.exists(package_id):
271+
# Row exists in DB but worker hadn't migrated yet (or upload failed).
272+
# Caller can fall back to /data.
273+
return jsonify({'errorMessageCode': 'BLOB_NOT_IN_S3'}), 409
274+
275+
ttl = int(os.getenv('PACKAGE_DATA_PRESIGNED_URL_TTL_SECONDS', '300'))
276+
url = blob_storage.presigned_url(package_id, ttl_seconds=ttl)
277+
278+
iv_value = row.iv
279+
if isinstance(iv_value, (bytes, bytearray)):
280+
iv_value = iv_value.hex()
281+
282+
return jsonify({'url': url, 'iv': iv_value, 'ttl': ttl}), 200
283+
284+
235285
@app.route('/process/<package_id>', methods=['DELETE'])
236286
def cancel_package(package_id):
237287

src/blob_storage.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
"""S3-backed storage for encrypted package blobs.
2+
3+
The worker uploads the encrypted SQLite directly to S3 (one object per
4+
package). The API generates short presigned GET URLs so clients can
5+
download directly from S3 — bypassing API Gateway, which is slow and
6+
6MB-capped for binary responses.
7+
8+
No-op when PACKAGE_DATA_BUCKET is unset (local dev): callers fall back
9+
to the legacy DB-backed path.
10+
"""
11+
import os
12+
13+
14+
def _bucket():
15+
return os.getenv("PACKAGE_DATA_BUCKET")
16+
17+
18+
def is_enabled() -> bool:
19+
return bool(_bucket())
20+
21+
22+
def _key(package_id: str) -> str:
23+
# Flat layout. Package IDs are MD5 hex digests, so no path-traversal risk.
24+
return f"packages/{package_id}.bin"
25+
26+
27+
def upload_encrypted(package_id: str, encrypted_bytes: bytes) -> None:
28+
"""Push the encrypted blob to S3 under a deterministic key."""
29+
import boto3
30+
31+
boto3.client("s3").put_object(
32+
Bucket=_bucket(),
33+
Key=_key(package_id),
34+
Body=encrypted_bytes,
35+
ContentType="application/octet-stream",
36+
)
37+
38+
39+
def presigned_url(package_id: str, ttl_seconds: int = 300) -> str:
40+
"""Return a short-lived URL the client can GET directly from S3."""
41+
import boto3
42+
43+
return boto3.client("s3").generate_presigned_url(
44+
"get_object",
45+
Params={"Bucket": _bucket(), "Key": _key(package_id)},
46+
ExpiresIn=ttl_seconds,
47+
)
48+
49+
50+
def exists(package_id: str) -> bool:
51+
"""True if a blob has been uploaded for this package."""
52+
import boto3
53+
from botocore.exceptions import ClientError
54+
55+
try:
56+
boto3.client("s3").head_object(Bucket=_bucket(), Key=_key(package_id))
57+
return True
58+
except ClientError as e:
59+
if e.response.get("Error", {}).get("Code") in ("404", "NoSuchKey", "NotFound"):
60+
return False
61+
raise

src/tasks.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -875,6 +875,17 @@ def read_analytics_file(package_status_id, package_id, link, session):
875875
key = extract_key_from_discord_link(link)
876876
(data, iv) = encrypt_sqlite_data(zipped_buffer, key)
877877

878+
# When PACKAGE_DATA_BUCKET is configured, also push the encrypted blob to
879+
# S3 so /blob can serve a presigned URL. Failure here is non-fatal — the
880+
# legacy DB blob still gets written below and the slow /data path keeps
881+
# working.
882+
import blob_storage
883+
if blob_storage.is_enabled():
884+
try:
885+
blob_storage.upload_encrypted(package_id, data)
886+
except Exception as e:
887+
print(f'WARN: S3 blob upload failed for {package_id}: {e}')
888+
878889
session.add(SavedPackageData(package_id=package_id, encrypted_data=data, iv=iv))
879890
session.commit()
880891

0 commit comments

Comments
 (0)