# Vymazineer API (v1)

A small Drogon C++ file-sharing service. Two endpoints are exposed for clients and AI agents; an additional admin API (not documented here) is operated manually out-of-band.

- Base URL: `http://<host>:8088`
- All request/response bodies, where present, are JSON (`Content-Type: application/json`).
- CORS is enabled globally; preflight `OPTIONS` requests are answered for all routes.
- HTTPS is handled by an nginx reverse proxy in front of this service — clients should connect over `https://`, never plain `http://`.

---

## Endpoint 1 — Upload a file

`POST /upload`

Stores a single file on the server, optionally protected by a per-file download password. The caller must present a valid Bearer auth token; the server records which token created the file (for audit and revocation), and returns the new file id plus the on-disk name used. If a per-file password is supplied via `X-SetPassword`, downloads of that file will require the same value (sent to `/file/{FILE}` in the `X-Password` header).

### Request

- **Method**: `POST`
- **Path**: `/upload`
- **Headers**:
  | Header | Required | Description |
  |---|---|---|
  | `Authorization` | ✅ Yes | `Bearer <token>` — token must exist in the `auth_tokens` table. Each successful upload decrements the token's `usage_limit` (NULL = unlimited, 0 = token auto-deleted and rejects). |
  | `X-SetName` | ❌ Optional | Override the on-disk filename. If absent, the multipart filename is used; if that is also absent, the MD5 of the file content + extension is used. Directory components are stripped (path-traversal safe). Must be unique — a name collision returns `409`. |
  | `X-SetPassword` | ❌ Optional | If set, protects downloads with this password. Must be ≥ 256 characters to take effect (shorter values are silently ignored — this is a deliberate rate-limit measure against trivial brute force). Stored as a bcrypt hash (cost 12). |
  | `X-SetDownloadLimit` | ❌ Optional | If `X-SetPassword` is set, this caps the number of successful downloads before the file is locked (default = no limit). Must be a positive integer; non-numeric, zero, or negative values are silently treated as NULL (= unlimited). |
- **Body**: `multipart/form-data` with exactly one file field named `file`.

### Behavior

1. Extract and validate the Bearer token. If invalid → `401`. If valid, capture `tokenID`.
2. Parse the multipart body. Exactly one file must be attached; the file must be non-empty.
3. Determine the on-disk name (`X-SetName` > multipart filename > md5+ext), strip any directory components.
4. Save bytes to `<upload_dir>/<stored_name>` (creating the upload directory if needed).
5. Begin a DB transaction and insert a row into `uploaded_files` (`file_path`, `created_by=tokenID`, `file_size`, `file_type`).
6. If `X-SetPassword` was set and is ≥ 256 chars, insert a row into `file_passwords` (`file_id`, `password=bcrypt_hash`, `download_count=0`, `download_limit` per `X-SetDownloadLimit`).
7. Commit the transaction.
8. If a uniqueness violation occurs (filename already taken), roll back, unlink the saved bytes from disk, and return `409`.

### Success response

`200 OK` (note: not `201`) with `Content-Type: application/json`:

```json
{
  "file_id": 42,
  "filename": "report.pdf",
  "password": "S3cret" or "NOT SET",
  "stored_as": "report.pdf",
  "size": 1022336,
  "md5": "d41d8cd98f00b204e9800998ecf8427e"
}
```

- `filename`: the multipart-supplied client filename (not the on-disk name).
- `stored_as`: the actual on-disk filename. Use this value (not `filename`) as the `{FILE}` parameter when calling `/file/{FILE}`.
- `password`: the plaintext password supplied via `X-SetPassword` echoed back, or `"NOT SET"`. (The client already knows this value; the server stores only the hash. If you change this behavior, also reconsider echoing the plaintext.)

### Error responses

| Status | Body | Reason |
|---|---|---|
| `400` | `{"error":"No file attached"}` | Multipart body had no files. |
| `400` | `{"error":"Only one file per request"}` | More than one file was attached. |
| `400` | `{"error":"Empty file"}` | The attached file has zero bytes. |
| `401` | `{"error":"Missing or invalid Authorization header"}` | No `Authorization: Bearer ...` header. |
| `401` | `{"error":"Invalid token"}` | Token does not exist, or its `usage_limit` has reached 0. |
| `409` | `{"error":"A file with this name already exists"}` | Uniqueness violation on `file_path`. The partially-saved file has been unlinked. |
| `500` | `{"error":"Database error"}` | Any other DB exception. |

---

## Endpoint 2 — Download a file

`GET /file/{FILE}`

Streams a file by its on-disk name. If the file is password-protected, the caller must supply the password via the `X-Password` header; each successful download decrements the per-file `download_count` (and is rejected once it reaches `download_limit`, if set).

### Request

- **Method**: `GET`
- **Path**: `/file/{FILE}` — `{FILE}` is the on-disk name (the `stored_as` value returned at upload time).
- **Path-parameter constraints** (rejected with `403 Access denied`):
  - Must not contain `..`
  - Must not contain `/`
  - Must not contain the null byte (`\0`)
- **Headers**:
  | Header | Required | Description |
  |---|---|---|
  | `X-Password` | ✅ If file is password-protected | The plaintext password. Compared via `bcrypt::verify` against the stored hash. Required if (and only if) the file was uploaded with `X-SetPassword`; otherwise ignored. |

### Behavior

1. Reject path traversal / null-byte injection (no DB I/O).
2. Look up `uploaded_files` by `file_path = {FILE}`. If no match → `404`.
3. Look up `file_passwords` rows for that `file_id`.
4. If a password row exists:
   - If `download_limit != 0` AND `download_count >= download_limit` → `403 Download limit exceeded`.
   - If no `X-Password` header → `401 Password required`.
   - If `bcrypt::verify(provided, stored_hash)` returns false → `401 Invalid password`.
   - Otherwise: increment `download_count` and continue.
5. Verify the file exists on disk; if not → `404`.
6. Stream the file as an attachment (Drogon's `newFileResponse` uses `sendfile(2)` for large files). `Content-Type` is auto-detected from the extension.

### Success response

`200 OK` with body = the raw file bytes. The `Content-Disposition` header is set to `attachment; filename=<FILE>` so browsers download rather than render.

### Error responses

| Status | Body | Reason |
|---|---|---|
| `401` | `Password required` | File is password-protected and no `X-Password` header was sent. |
| `401` | `Invalid password` | `X-Password` value failed bcrypt verification. |
| `403` | `Access denied` | `{FILE}` contains `..`, `/`, or a null byte. |
| `403` | `Download limit exceeded` | `download_count` has reached `download_limit`. |
| `404` | (empty) | File row not found in DB, or the on-disk file is missing. |
| `500` | `Database error` | Other DB exception during lookup or count increment. |

---

## Authentication & security notes

- **Upload tokens** are manually-issued opaque strings, stored hash-free in the `auth_tokens` table alongside an optional `usage_limit` (NULL = unlimited; on reaching 0 the row is hard-deleted). Tokens are passed in the `Authorization: Bearer <token>` header, never in the URL.
- **Per-file passwords** are bcrypt-hashed at cost 12 (~325 ms per verify) on every download of a password-protected file — this is the inherent rate-limit against brute force and is logged at `AUTH_FAIL ip=<ip> file=\"<file>\" reason=<reason>` for fail2ban.
- **`X-Password` is overloaded**: on `/upload`/`/file/{FILE}` it carries the per-file password; the admin API under `/admin/*` uses the same header name for the admin password. There is no collision in practice because the namespaces are disjoint, but AI agents should be aware that the header is contextual, not global.
- All `AUTH_FAIL` log lines have a stable single-line format (`AUTH_FAIL ip=<ip> file="<f>" reason=<reason>`) and are emitted to `server.log` (and stdout). Reason tokens used here: `path_traversal_attempt`, `download_limit_exceeded`, `no_password_provided`, `invalid_password`.

---

## Quickstart examples

### Upload a file with no password

```bash
curl -X POST https://host:8443/upload \
  -H "Authorization: Bearer <token>" \
  -F "file=@./report.pdf"
```

### Upload a file with a password and a download limit of 5

```bash
curl -X POST https://host:8443/upload \
  -H "Authorization: Bearer <token>" \
  -H "X-SetPassword: $(openssl rand -hex 200)" \
  -H "X-SetDownloadLimit: 5" \
  -H "X-SetName: confidential.pdf" \
  -F "file=@./secret.pdf"
```

### Download a file

```bash
# Unprotected
curl -OJ https://host:8443/file/report.pdf

# Password-protected
curl -OJ -H "X-Password: <password>" https://host:8443/file/confidential.pdf
```
