# TikTok Manager

A Dockerized Flask application that manages your TikTok accounts: publish videos (direct post
or draft, from a file or a URL), **schedule posts** (TikTok's API has no scheduler — this app
ships its own), and follow up on performance (views, likes, comments, shares) — all from a web
UI **and** a REST API (`/api/v1`).

Built on the same pattern as the sibling **YouTube Manager** and **Facebook Manager** projects:
one Flask app, a web GUI and an API over the same use cases, OAuth tokens encrypted at rest,
Docker Compose deployment, SQLite or PostgreSQL, full API documentation in
[`DOC/API.md`](DOC/API.md), and the same governance system in `RULEFOLDER/`.

## Features

- **Connect accounts** — TikTok Login (OAuth 2), automatic token refresh (24 h access tokens,
  rotated refresh tokens persisted encrypted).
- **Publish** — direct post or draft, video file upload (mp4/mov/webm, chunked) or public video
  URL (PULL_FROM_URL), caption up to 2,200 characters, privacy level and comment settings.
- **Schedule** — pick any future time; the dedicated scheduler container publishes when due,
  with retry for failed posts (ADR-005).
- **Follow up** — published videos with views/likes/comments/shares, account totals, top videos.
- **REST API** — everything the UI does, with the same validation and a consistent JSON envelope.

## ⚠️ TikTok-specific caveats (read before setup)

1. **Unaudited apps post private videos only.** While your TikTok app has not passed TikTok's
   audit, the Content Posting API only accepts `privacy_level=SELF_ONLY` for direct posts.
   Drafts are unaffected (they land in your TikTok drafts). This is TikTok's rule, not this
   app's — the UI defaults to SELF_ONLY and the API surfaces TikTok's refusal as a clear error.
2. **TikTok has no scheduled publish API.** Scheduling here is app-side (ADR-005): posts wait in
   the database and a dedicated scheduler container publishes them at the chosen time.
3. **Daily posting caps.** TikTok enforces API rate limits and daily posting limits; when hit,
   the API returns a rate-limit error which this app surfaces as `RATE_LIMITED`.
4. **Comments are not included.** TikTok's Comment API requires separate scopes and an audited
   app; it is recorded as a gap in `RULEFOLDER/FEATURE-REGISTRY.md`.

## Technology Stack

| Layer | Technology | Version |
|-------|-----------|---------|
| Language | Python | 3.12 (`python:3.12-slim` container image) |
| Web framework | Flask | 3.1.3 |
| ORM / persistence | Flask-SQLAlchemy (SQLAlchemy) | 3.1.1 (SQLAlchemy 2.1.4) |
| Schema migrations | Flask-Migrate + Alembic (runs on every start) | 4.1.0 / 1.20.0 |
| CSRF protection | Flask-WTF + WTForms | 1.3.0 / 3.2.2 |
| Token encryption at rest | cryptography (Fernet / MultiFernet) | 50.0.1 |
| TikTok API client | requests | 2.33.0 |
| Environment loading | python-dotenv | 1.2.2 |
| WSGI server | gunicorn (2 workers, optional TLS via mkcert certs) | 22.0.0 |
| Scheduler worker | plain Python loop (`python -m app.scheduler`, own container) | — |
| Database | SQLite (default, single file) or PostgreSQL 17+ (`postgres:17.6-alpine` sibling container) | — |
| PostgreSQL driver | psycopg v3 (binary) — postgres mode only | 3.3.6 |
| Containerization | Docker + Docker Compose (app + scheduler + optional `postgres` profile) | — |
| Front end | Bootstrap + Bootstrap Icons (CDN) | 5.3.3 / 1.11.3 |
| Local HTTPS (development) | mkcert — locally-trusted certificates for the OAuth redirect URI | 1.4.4 |
| Testing & dependency audit | pytest + pip-audit | 9.1.1 / 2.10.1 |

All Python dependencies are pinned in `requirements.txt`; `pip-audit` is clean. The detailed
dependency rationale lives in `RULEFOLDER/ARCHITECTURE.md` §5.

## Setup

### 1. Create a TikTok app

1. Go to <https://developers.tiktok.com> and create an app.
2. Add the **Login Kit** and **Content Posting API** products.
3. Under **Login Kit → Settings**, add your redirect URI to *Valid redirect URLs*
   (default: `https://localhost:9506/auth/callback`).
4. Copy the **Client Key** and **Client Secret** into `.env`.

> **TikTok only accepts HTTPS redirect URIs.** For local development the container serves HTTPS
> itself with a locally-trusted [mkcert](https://github.com/FiloSottile/mkcert) certificate:
>
> ```bash
> # once per machine: install the local CA (Windows shows a confirmation dialog)
> mkcert -install
>
> # once per clone: generate the certificate
> mkcert -cert-file dev-certs/localhost.crt -key-file dev-certs/localhost.key localhost 127.0.0.1 ::1
> ```
>
> `docker-compose.yml` mounts `./dev-certs` into the container and `entrypoint.sh` adds
> `--certfile/--keyfile` to gunicorn when `SSL_CRT_FILE`/`SSL_KEY_FILE` are set in `.env`
> (both preconfigured). Leave them empty when a reverse proxy terminates TLS for you.

> **Personal use:** keep the app in **dev status** — the developer's own account can grant every
> requested scope without audit. Moving the app to live status requires TikTok's audit (see the
> caveats above).

### 2. Configure and run

```bash
cp .env.example .env
# fill in: SECRET_KEY, TIKTOK_CLIENT_KEY, TIKTOK_CLIENT_SECRET, API_KEY, TOKEN_ENCRYPTION_KEY
# generate TOKEN_ENCRYPTION_KEY:
#   python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"

docker compose build --no-cache && docker compose up -d
# open https://localhost:9506
```

Two containers start: `tiktok-manager` (web + API) and `tiktok-scheduler` (publishes scheduled
posts every `SCHEDULER_INTERVAL_SECONDS`). For a shared deployment set `DB_ENGINE=postgres`
and `COMPOSE_PROFILES=postgres` in `.env` (PostgreSQL 17+ runs as a sibling container; its port
is not published).

### 3. Scopes used

`user.info.basic`, `user.info.stats`, `video.list`, `video.upload` — configurable via
`TIKTOK_SCOPES` in `.env`. Publishing defaults to drafts sent to the TikTok inbox (`video.upload`);
add `video.publish` only if the app passes TikTok's Direct Post audit.

## REST API

Every endpoint lives under `/api/v1`, authenticated with the `X-API-Key` header.
Full reference with parameters, responses and error codes: [`DOC/API.md`](DOC/API.md).

```bash
curl -H "X-API-Key: $API_KEY" "https://localhost:9506/api/v1/accounts"

# publish now from a public video URL
curl -X POST -H "X-API-Key: $API_KEY" -H "Content-Type: application/json" \
     -d '{"video_url": "https://example.com/lesson.mp4", "title": "New lesson #math"}' \
     "https://localhost:9506/api/v1/posts?account_id=<ACCOUNT_ID>"

# schedule the same video for tomorrow
curl -X POST -H "X-API-Key: $API_KEY" -H "Content-Type: application/json" \
     -d '{"video_url": "https://example.com/lesson.mp4", "scheduled_at": "2026-10-14T09:00:00+00:00"}' \
     "https://localhost:9506/api/v1/scheduled?account_id=<ACCOUNT_ID>"
```

## Development

```bash
python -m venv .venv
.venv/Scripts/pip install -r requirements.txt -r requirements-dev.txt   # Windows
.venv/bin/pip install -r requirements.txt -r requirements-dev.txt       # Linux/macOS

.venv/Scripts/python -m pytest        # test suite (no TikTok calls; fakes everywhere)
```

- App factory: `app/__init__.py` — blueprints in `app/routes/`, use cases in `app/services/`,
  TikTok API wrapper in `app/tiktok_api.py` (Flask-free, DB-free), scheduler in
  `app/scheduler.py` + `app/services/scheduled.py`.
- Schema changes are Alembic migrations (`migrations/`); `entrypoint.sh` runs
  `flask db upgrade` on every start. Never use `db.create_all()`.
- Tokens (access + refresh) are Fernet-encrypted in the database; the key is
  `TOKEN_ENCRYPTION_KEY`.

## Security notes

- OAuth `state` is generated per login and verified on callback; TikTok's rotated refresh
  tokens are persisted encrypted on every refresh.
- All GUI forms are CSRF-protected (Flask-WTF); the API is header-authenticated and CSRF-exempt.
- Mutating API endpoints require an explicit account (`account_id` param or `X-Account-Id`
  header) — no first-account fallback.
- API keys are compared with `hmac.compare_digest`; error responses carry safe messages only
  (details go to the server log).
- Uploaded videos are stored under server-generated names inside `UPLOAD_FOLDER` (immediate
  posts, deleted right after upload) or `MEDIA_FOLDER` (scheduled posts, deleted after
  publishing or deletion).
