---
title: 'Presigned download/preview URL endpoints'
type: 'feature'
created: '2026-04-15'
status: 'done'
jira: 'TEXTIQ-368'
baseline_commit: '2412084'
context: []
---

<frozen-after-approval reason="human-owned intent — do not modify unless human renegotiates">

## Intent

**Problem:** APA currently needs SeaweedFS credentials (AWS_ENDPOINT_URL, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY) to serve file downloads and previews. This couples APA to a DE implementation detail and makes credential rotation a cross-service change.

**Approach:** Add two GET endpoints to the Airflow upload plugin that look up a document by ID (scoped by tenant/node via RLS), then generate a time-limited presigned S3 URL with the appropriate Content-Disposition (attachment for download, inline for previewable PDFs).

## Boundaries & Constraints

**Always:** HMAC auth + RLS tenant isolation on every request. Return 404 for wrong-tenant access (never leak existence). Use sync boto3 `generate_presigned_url` (Airflow plugin is sync). Validate S3 path prefix matches tenant/node scope.

**Ask First:** Changing the presigned URL TTL default from 3600s. Adding content_type column to the documents table.

**Never:** Stream file bytes through DE. Bypass HMAC middleware. Return raw S3 credentials or bucket names in responses.

## I/O & Edge-Case Matrix

| Scenario | Input / State | Expected Output / Behavior | Error Handling |
|----------|--------------|---------------------------|----------------|
| Download happy path | Valid doc ID, correct tenant | `{url, filename, content_type, file_size_bytes, expires_in: 3600, inline: false}` | N/A |
| Preview PDF | Valid doc ID, file is .pdf | `{url, ..., inline: true}` with `ResponseContentDisposition=inline` | N/A |
| Preview non-PDF | Valid doc ID, file is .xlsx | `{url, ..., inline: false}` fallback to attachment disposition | N/A |
| Wrong tenant | Doc exists but tenant_id mismatch | 404 NOT_FOUND | RLS + WHERE clause prevents row visibility |
| Doc not found | Non-existent UUID | 404 NOT_FOUND | Same error shape as wrong-tenant |
| Invalid UUID | `"abc"` as document_id | 422 INVALID_ID | Early validation before DB query |
| S3 object missing | DB row exists but S3 key deleted | 404 FILE_NOT_FOUND | `head_object` check before presign |

</frozen-after-approval>

## Code Map

- `airflow/plugins/api/upload.py` -- Add GET `/{document_id}/download-url` and `/{document_id}/preview-url` endpoints
- `airflow/plugins/api/s3.py` -- Already provides `get_s3_client()` and `validate_s3_path()`
- `airflow/plugins/api/config.py` -- Add `get_presigned_url_ttl()` helper
- `airflow/plugins/api/rls.py` -- Existing `rls_connection()` context manager
- `airflow/plugins/api/responses.py` -- Existing `success_response()` / `error_response()`

## Tasks & Acceptance

**Execution:**
- [x] `airflow/plugins/api/config.py` -- Add `get_presigned_url_ttl()` reading `PRESIGNED_URL_TTL` env var (default 3600)
- [x] `airflow/plugins/api/upload.py` -- Add `_infer_content_type(filename)` helper using `mimetypes.guess_type`, defaulting to `application/octet-stream`
- [x] `airflow/plugins/api/upload.py` -- Add `_generate_presigned_response(document_id, request, inline_preference)` shared helper: validate UUID → extract HMAC ctx → RLS query for doc row → `head_object` to verify S3 existence → `generate_presigned_url` with appropriate `ResponseContentDisposition` → return response dict
- [x] `airflow/plugins/api/upload.py` -- Add `GET /{document_id}/download-url` endpoint calling shared helper with `inline_preference=False`
- [x] `airflow/plugins/api/upload.py` -- Add `GET /{document_id}/preview-url` endpoint calling shared helper with `inline_preference=True` (only applies to PDF content type)

**Acceptance Criteria:**
- Given a valid document ID and correct tenant headers, when GET `/download-url`, then response contains presigned URL with `attachment` disposition and `inline: false`
- Given a valid document ID for a PDF file, when GET `/preview-url`, then response contains presigned URL with `inline` disposition and `inline: true`
- Given a valid document ID for a non-PDF file, when GET `/preview-url`, then response falls back to `attachment` disposition and `inline: false`
- Given a document ID belonging to another tenant, when GET either endpoint, then 404 is returned
- Given a non-existent document ID, when GET either endpoint, then 404 is returned
- Given a document whose S3 object was deleted, when GET either endpoint, then 404 FILE_NOT_FOUND is returned

## Verification

**Commands:**
- `curl -X GET http://localhost:8080/api/v1/documents/{id}/download-url -H "X-Tenant-ID: ..." -H "X-Node-ID: ..." -H "X-Timestamp: ..." -H "X-Signature: ..."` -- expected: 200 with presigned URL JSON
- `curl -X GET http://localhost:8080/api/v1/documents/{id}/preview-url ...` -- expected: 200 with inline=true for PDFs

## Suggested Review Order

**Core presigned URL logic**

- Shared helper: UUID validation → HMAC ctx → RLS query → S3 head_object → presigned URL generation
  [`upload.py:490`](../../airflow/plugins/api/upload.py#L490)

- Content-type inference from filename extension with safe default
  [`upload.py:484`](../../airflow/plugins/api/upload.py#L484)

- RFC 5987 Content-Disposition encoding prevents header injection
  [`upload.py:541`](../../airflow/plugins/api/upload.py#L541)

- SeaweedFS-safe error code handling for missing S3 objects
  [`upload.py:531`](../../airflow/plugins/api/upload.py#L531)

**Endpoint registration**

- Download endpoint — always attachment disposition
  [`upload.py:578`](../../airflow/plugins/api/upload.py#L578)

- Preview endpoint — inline for PDFs, attachment fallback
  [`upload.py:584`](../../airflow/plugins/api/upload.py#L584)

**Configuration**

- TTL config getter following existing pattern
  [`config.py:29`](../../airflow/plugins/api/config.py#L29)

- Env var documented in .env.example
  [`.env.example:138`](../../.env.example#L138)
