Skip to main content

max / makenotwork

Upload trust tiers, git repos, expanded test suite, and CI scripts Add upload trust tier system (Phase 10D): untrusted creators' uploads are held for admin review, trusted creators auto-publish. Includes admin review queue, trust/untrust toggles, and HeldForReview scan status. Add git_repos DB module and migration. Expand integration test coverage with adversarial, media upload, promo code, waitlist, appeal, broadcast, category, chapter, content insertion, preferences, project management, session revocation, and Stripe disconnect tests. Add CI runner and git SSH setup scripts. Remove sourcehut .build.yml. Various route and template refinements. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-10 02:15 UTC
Commit: b8175647779656cb7a44c087dfdf20fc8b3c305a
Parent: 1d1f998
103 files changed, +7682 insertions, -1114 deletions
D .build.yml -40
@@ -1,40 +0,0 @@
1 - image: archlinux
2 - packages:
3 - - rust
4 - - cmake
5 - - clang
6 - - git
7 - - pkg-config
8 - - perl
9 - sources:
10 - - https://git.sr.ht/~maxmj/makenotwork
11 - environment:
12 - SQLX_OFFLINE: "true"
13 - CARGO_INCREMENTAL: "0"
14 - RUST_BACKTRACE: "1"
15 - tasks:
16 - - check: |
17 - cd makenotwork/server_code/makenotwork
18 - cargo check 2>&1
19 - - test: |
20 - cd makenotwork/server_code/makenotwork
21 - cargo test --lib 2>&1
22 - - integration: |
23 - if [ -z "$TEST_DATABASE_URL" ]; then
24 - echo "TEST_DATABASE_URL not set, skipping integration tests"
25 - exit 0
26 - fi
27 - cd makenotwork/server_code/makenotwork
28 - cargo test --test integration 2>&1
29 - - clippy: |
30 - cd makenotwork/server_code/makenotwork
31 - cargo clippy --all-targets -- -D warnings 2>&1
32 - - audit: |
33 - cargo install --locked cargo-audit
34 - cd makenotwork/server_code/makenotwork
35 - cargo audit 2>&1
36 - - build: |
37 - cd makenotwork/server_code/makenotwork
38 - cargo build --release 2>&1
39 - artifacts:
40 - - makenotwork/server_code/makenotwork/target/release/makenotwork
A CLAUDE.md +126
@@ -0,0 +1,126 @@
1 + # Makenotwork
2 +
3 + Fair creator platform. Rust/Axum backend, HTMX frontend, PostgreSQL, Stripe Connect.
4 +
5 + ## Repository Layout
6 +
7 + ```
8 + server_code/makenotwork/ # Main Rust application
9 + src/ # Application source
10 + migrations/ # SQLx migrations (numbered, auto-applied on boot)
11 + templates/ # Askama HTML templates
12 + static/ # CSS, JS, fonts, images
13 + tests/ # Integration tests (workflows/, load/, harness/)
14 + deploy/ # Deployment scripts and config files
15 + deploy.sh # Cross-compile + upload + restart
16 + makenotwork.service # systemd unit file
17 + Caddyfile # Reverse proxy config
18 + backup-db.sh # DB backup script
19 + error-pages/ # Custom 404/500/502 pages
20 + docs/ # Documentation (public/ and unpublished/)
21 + ```
22 +
23 + ## Production Server (5.78.144.244)
24 +
25 + Hetzner VPS, x86_64 Linux. SSH as `root@5.78.144.244`.
26 +
27 + ### Filesystem
28 +
29 + ```
30 + /opt/makenotwork/
31 + makenotwork # Application binary
32 + .env # Environment variables (secrets)
33 + static/ # CSS, JS, fonts, images
34 + error-pages/ # Custom error pages served by Caddy
35 + backup-db.sh # Database backup script
36 + src/ # Deployed source (for sqlx migrations)
37 + docs/ # Deployed docs (for /docs pages)
38 +
39 + /opt/git/ # Bare git repos for source browser
40 + makenotwork.git/
41 + synckit-client.git/
42 + ...
43 +
44 + /etc/caddy/Caddyfile # Caddy reverse proxy config
45 + /etc/systemd/system/makenotwork.service # systemd unit
46 + ```
47 +
48 + ### Services
49 +
50 + - `makenotwork` — the application (systemd, runs as `makenotwork` user)
51 + - `caddy` — reverse proxy + TLS
52 + - `postgresql` — database (`makenotwork` db, `makenotwork` user)
53 +
54 + ### Deployment
55 +
56 + From `server_code/makenotwork/`:
57 + ```sh
58 + ./deploy/deploy.sh # Full: build + config + binary + restart
59 + ./deploy/deploy.sh --quick # Build + binary + restart (no config upload)
60 + ./deploy/deploy.sh --config # Config files only (Caddyfile, systemd, error pages, static)
61 + ```
62 +
63 + Cross-compiles with `cargo zigbuild` (requires `zig`, `cargo-zigbuild`, `x86_64-unknown-linux-gnu` target).
64 +
65 + ## Astra (dev/test server)
66 +
67 + General-purpose Linux box on Tailscale. SSH as `max@100.106.221.39`.
68 +
69 + - **OS:** Pop!_OS 24.04 LTS, aarch64 (96 cores, 125GB RAM, 929GB NVMe)
70 + - **Tailscale hostname:** `astra` (IP: `100.106.221.39`)
71 + - **PostgreSQL 16** — tuned: `max_connections=200`, `shared_buffers=4GB`, `work_mem=64MB`, `maintenance_work_mem=512MB`
72 + - **PG roles:** `postgres` (super), `max` (local, createdb), `mnw_staging` (createdb)
73 + - **Rust 1.94** — installed via rustup (`~/.cargo/bin/cargo`)
74 + - **Databases:** `postgres`, `devdb`, `makenotwork_staging`
75 + - **Staging copy:** `/home/max/staging/makenotwork/` (not a git repo, deployed copy)
76 + - **Environment:** `RUST_TEST_THREADS=8` (in `.bashrc` and `.profile`), `ulimit -n 65536` (in `/etc/security/limits.conf`)
77 +
78 + ### Running integration tests on astra
79 +
80 + ```sh
81 + # Using the test runner script (handles env vars and orphan cleanup):
82 + ssh 100.106.221.39
83 + /home/max/staging/run-tests.sh # Run all
84 + /home/max/staging/run-tests.sh auth:: # Run filtered
85 +
86 + # Or manually:
87 + cd /home/max/staging/makenotwork
88 + TEST_DATABASE_URL="postgres:///postgres" cargo test --test integration -- --test-threads=8
89 + ```
90 +
91 + **Important:** Use `--test-threads=8` (or similar). The default parallelism (96 on astra) overwhelms PostgreSQL with too many simultaneous `CREATE DATABASE` calls. The `RUST_TEST_THREADS=8` env var handles this automatically.
92 +
93 + ### Cleaning up orphaned test databases
94 +
95 + Test databases (`mnw_test_*`) are orphaned if the test process is killed. The `run-tests.sh` script cleans these up automatically. Manual cleanup:
96 + ```sh
97 + psql -t -c "SELECT datname FROM pg_database WHERE datname LIKE 'mnw_test_%';" postgres \
98 + | xargs -I{} psql -c "DROP DATABASE IF EXISTS \"{}\";" postgres
99 + ```
100 +
101 + ## External Services
102 +
103 + - **Stripe Connect** — payments (live mode)
104 + - **Hetzner Object Storage** — S3-compatible file storage (fsn1 region)
105 + - **Postmark** — transactional email (password reset, verification, purchase receipts, notifications). Currently in trial mode: can only send to @makenot.work addresses until domain approval completes.
106 + - **Cloudflare** — DNS, CDN, DDoS protection
107 + - **Sentry** — error tracking
108 + - **Fastmail** — business email (support@, legal@, max@)
109 +
110 + ## Key Patterns
111 +
112 + - `impl_str_enum!` macro for enum ↔ string (Display, FromStr, sqlx Type/Encode/Decode)
113 + - `define_pg_uuid_id!` macro for newtype UUID ID wrappers
114 + - `EnvironmentFile=/opt/makenotwork/.env` for all secrets
115 + - SQLx compile-time checked queries; migrations auto-run on boot
116 + - HTMX responses return HTML fragments; JSON fallback for non-HTMX requests
117 + - Tests: each integration test creates/drops its own PostgreSQL database
118 +
119 + ## Testing
120 +
121 + ```sh
122 + cargo test # Unit tests (no DB needed)
123 +
124 + # Integration tests (need a running Postgres):
125 + TEST_DATABASE_URL="postgres://user:pass@host:5432/postgres" cargo test --test integration
126 + ```
@@ -23,8 +23,7 @@ Everything listed here is live and working.
23 23 - **Pay-what-you-want**: Buyer chooses the amount, optional minimum price
24 24 - **Subscriptions**: Monthly recurring tiers per project with Stripe billing (multiple tiers, active/inactive toggle)
25 25 - **License keys**: Auto-generated on purchase, configurable activation limits, machine tracking, public validation endpoint for software phone-home
26 - - **Discount codes**: Percentage or fixed-amount, item-scoped or seller-wide, usage limits, expiration dates, auto-apply via URL parameter
27 - - **Download codes**: Single-use codes for free access, optional max uses and expiration
26 + - **Promo codes**: Unified code system supporting percentage/fixed discounts, free access grants, and free trial periods for subscriptions. Item-scoped or project-wide, usage limits, expiration dates, auto-apply via URL parameter
28 27
29 28 ### Discovery & Organization
30 29
@@ -39,14 +38,17 @@ Everything listed here is live and working.
39 38 - **Library**: All purchased content in one place, download files, view license keys
40 39 - **Audio streaming**: In-browser player with chapter navigation
41 40 - **Text reader**: Clean typography, reading time estimate
42 - - **Contact sharing**: Opt-in email sharing with creators at purchase time
41 + - **Contact sharing**: Opt-in email sharing with creators at purchase time, revocable by the buyer
43 42 - **Free accounts**: Fans never pay for the platform, only for content they choose to buy
43 + - **Email notifications**: Sale alerts, follower alerts, new release announcements, new device login warnings (each individually toggleable)
44 44
45 45 ### Creator Dashboard
46 46
47 47 - **Project management**: Overview with revenue and sales stats, content tab, blog tab, settings, subscriptions
48 - - **Item management**: Settings, content editor, versions, tags, license keys, download codes, chapters
48 + - **Item management**: Settings, content editor, versions, tags, license keys, promo codes, chapters
49 49 - **Transactions**: Full purchase and sales history, filterable
50 + - **Contacts**: View fans who shared their email at purchase, with purchase count and total spent
51 + - **Broadcasts**: Send plain-text email updates to all your followers (rate-limited to one per 24 hours)
50 52 - **Data export**: All projects, items, blog posts, sales (CSV), and purchases (CSV) downloadable anytime
51 53 - **Custom links**: Add external links to your profile
52 54
@@ -74,10 +76,12 @@ Everything listed here is live and working.
74 76 - **Creator waitlist**: Invite-only launch with lottery waves and hand-picked approvals
75 77 - **Admin CLI** (`mnw-admin`): Command-line tool for waitlist management, creator approval, spam flagging, wave execution, stats, user suspension/unsuspension, appeal processing, revenue reports, transaction history, CSV data export, and S3 storage audits -- connects directly to the database, no web UI needed
76 78 - **Documentation**: Server-rendered from markdown, auto-linked cross-references
79 + - **Transactional email**: Password reset, email verification, purchase receipts, subscription lifecycle, sale and follower notifications via Postmark with bounce/complaint suppression
80 + - **Git source browser**: Browse server-hosted bare repositories with syntax highlighting
77 81 - **Health monitoring**: Real uptime tracking, database status, service connectivity checks
78 - - **Malware scanning**: ClamAV + VirusTotal hash lookup on file uploads
82 + - **Malware scanning**: ClamAV + YARA rules + MalwareBazaar hash lookup on file uploads
79 83 - **Creator guide**: 12-page documentation covering the full UX surface area
80 - - **619 automated tests**: Unit, integration, workflow, and health tests
84 + - **621 automated tests**: Unit, integration, workflow, and health tests
81 85
82 86 ### Developer Infrastructure (SyncKit)
83 87
@@ -95,12 +99,9 @@ Cloud sync and OTA update infrastructure for indie app developers, hosted on Mak
95 99
96 100 Near-term work. No timelines because we ship when it's ready.
97 101
98 - - **Deploy to production** (server setup complete, final manual backup test remaining)
99 - - **Postmark email setup** (SPF/DKIM, bounce handling for transactional email)
100 - - **Free trial support** for subscription tiers
101 - - **Sale and follower notifications** (email alerts for creators)
102 - - **Contacts dashboard** (view fans who shared their email at purchase)
103 - - **Admin CLI expansion**: Broadcast sending (deferred until Postmark integration)
102 + - **Beta launch**: Final testing pass, onboard first creators
103 + - **Notification preferences UI**: Dashboard settings page for managing email notification toggles
104 + - **Admin CLI expansion**: Broadcast sending to all users for platform announcements
104 105
105 106 ---
106 107
@@ -130,7 +131,7 @@ JSON-LD (Product, MusicRecording, Article). OG and Twitter Card meta tags are al
130 131
131 132 ### Open source creator tools
132 133
133 - Integration with Sourcehut and GitHub for software creators. Sponsor tiers, license display, release hosting, build status badges.
134 + Built-in git hosting with source browser, plus GitHub integration for software creators. Sponsor tiers, license display, release hosting, build status badges.
134 135
135 136 ### Analytics
136 137
@@ -13,7 +13,7 @@ Public code means public scrutiny. Our practices are auditable.
13 13 ## Repository
14 14
15 15 ```
16 - sr.ht/~maxmj/
16 + makenot.work/git/maxmj/
17 17 ```
18 18
19 19 Available for review:
@@ -170,7 +170,7 @@ All code is publicly available for review. You can:
170 170 - Check for vulnerabilities
171 171 - Report issues responsibly
172 172
173 - Repository: [sr.ht/~maxmj/](https://sr.ht/~maxmj/) (Sourcehut)
173 + Repository: [makenot.work/git/maxmj/](https://makenot.work/git/maxmj/)
174 174
175 175 ---
176 176
@@ -3453,7 +3453,7 @@ dependencies = [
3453 3453
3454 3454 [[package]]
3455 3455 name = "makenotwork"
3456 - version = "0.1.5"
3456 + version = "0.1.6"
3457 3457 dependencies = [
3458 3458 "ammonia",
3459 3459 "anyhow",
@@ -4601,9 +4601,9 @@ dependencies = [
4601 4601
4602 4602 [[package]]
4603 4603 name = "quinn-proto"
4604 - version = "0.11.13"
4604 + version = "0.11.14"
4605 4605 source = "registry+https://github.com/rust-lang/crates.io-index"
4606 - checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
4606 + checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
4607 4607 dependencies = [
4608 4608 "bytes",
4609 4609 "getrandom 0.3.4",
@@ -0,0 +1,121 @@
1 + #!/bin/bash
2 + # CI script for makenotwork — replaces .build.yml (Sourcehut).
3 + # Run on astra: /home/max/staging/run-ci.sh [filter]
4 + #
5 + # Runs: cargo check, cargo test --lib, cargo test --test integration,
6 + # cargo clippy, cargo audit (if installed).
7 + #
8 + # Usage:
9 + # ./run-ci.sh # full CI
10 + # ./run-ci.sh auth # only tests matching "auth"
11 +
12 + set -euo pipefail
13 +
14 + # Ensure ~/.cargo/bin is in PATH (SSH non-login shells may not source .profile)
15 + export PATH="$HOME/.cargo/bin:$PATH"
16 +
17 + SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
18 + PROJECT_DIR="${SCRIPT_DIR}"
19 +
20 + # Detect project directory layout
21 + if [ -d "$SCRIPT_DIR/server_code/makenotwork" ]; then
22 + # Full repo checkout (e.g., ~/staging/makenotwork/server_code/makenotwork)
23 + PROJECT_DIR="$SCRIPT_DIR/server_code/makenotwork"
24 + elif [ -d "$SCRIPT_DIR/makenotwork/src" ]; then
25 + # Rsync'd staging layout (e.g., ~/staging/makenotwork/)
26 + PROJECT_DIR="$SCRIPT_DIR/makenotwork"
27 + fi
28 +
29 + cd "$PROJECT_DIR"
30 +
31 + export SQLX_OFFLINE=true
32 + export TEST_DATABASE_URL="${TEST_DATABASE_URL:-postgres:///postgres}"
33 + export RUST_TEST_THREADS="${RUST_TEST_THREADS:-8}"
34 + export CARGO_INCREMENTAL=0
35 + export RUST_BACKTRACE=1
36 +
37 + FILTER="${1:-}"
38 + FAILURES=()
39 + PASS=()
40 +
41 + run_step() {
42 + local name="$1"
43 + shift
44 + echo ""
45 + echo "========================================"
46 + echo " $name"
47 + echo "========================================"
48 + echo ""
49 + if "$@"; then
50 + PASS+=("$name")
51 + else
52 + FAILURES+=("$name")
53 + echo "FAILED: $name"
54 + fi
55 + }
56 +
57 + cleanup_test_dbs() {
58 + echo "Cleaning up orphaned test databases..."
59 + sudo -u postgres psql -Atc "SELECT datname FROM pg_database WHERE datname LIKE 'test_%';" 2>/dev/null | while read -r db; do
60 + sudo -u postgres psql -c "DROP DATABASE IF EXISTS \"$db\";" 2>/dev/null || true
61 + done
62 + }
63 +
64 + # Pre-cleanup
65 + cleanup_test_dbs
66 +
67 + # Step 1: Compilation check
68 + run_step "cargo check" cargo check
69 +
70 + # Step 2: Unit tests
71 + if [ -n "$FILTER" ]; then
72 + run_step "cargo test --lib ($FILTER)" cargo test --lib "$FILTER"
73 + else
74 + run_step "cargo test --lib" cargo test --lib
75 + fi
76 +
77 + # Step 3: Integration tests
78 + if [ -n "$FILTER" ]; then
79 + run_step "cargo test --test integration ($FILTER)" cargo test --test integration "$FILTER" -- --test-threads=8
80 + else
81 + run_step "cargo test --test integration" cargo test --test integration -- --test-threads=8
82 + fi
83 +
84 + # Step 4: Clippy
85 + run_step "cargo clippy" cargo clippy --all-targets -- -D warnings
86 +
87 + # Step 5: Security audit (optional)
88 + if command -v cargo-audit &>/dev/null; then
89 + # Ignore known unfixable advisories:
90 + # RUSTSEC-2023-0071: rsa via sqlx-mysql (MNW uses Postgres, not affected)
91 + run_step "cargo audit" cargo audit --ignore RUSTSEC-2023-0071
92 + else
93 + echo ""
94 + echo "[skip] cargo-audit not installed (cargo install cargo-audit)"
95 + fi
96 +
97 + # Post-cleanup
98 + cleanup_test_dbs
99 +
100 + # Summary
101 + echo ""
102 + echo "========================================"
103 + echo " CI Summary"
104 + echo "========================================"
105 + echo ""
106 +
107 + for step in "${PASS[@]}"; do
108 + echo " PASS $step"
109 + done
110 + for step in "${FAILURES[@]+"${FAILURES[@]}"}"; do
111 + echo " FAIL $step"
112 + done
113 +
114 + echo ""
115 + if [ ${#FAILURES[@]} -eq 0 ]; then
116 + echo "All steps passed."
117 + exit 0
118 + else
119 + echo "${#FAILURES[@]} step(s) failed."
120 + exit 1
121 + fi
@@ -0,0 +1,68 @@
1 + #!/bin/bash
2 + # Setup SSH git push access on the production VPS.
3 + #
4 + # Creates a `git` system user with git-shell, home directory at /opt/git/
5 + # so that `git@makenot.work:maxmj/makenotwork.git` resolves to
6 + # /opt/git/maxmj/makenotwork.git.
7 + #
8 + # Run as root on the production VPS.
9 +
10 + set -euo pipefail
11 +
12 + GIT_HOME="/opt/git"
13 +
14 + # 1. Create git system user with git-shell (no interactive login)
15 + if id git &>/dev/null; then
16 + echo "git user already exists"
17 + else
18 + useradd --system --shell "$(which git-shell)" --home-dir "$GIT_HOME" --no-create-home git
19 + echo "Created git user"
20 + fi
21 +
22 + # Ensure home dir is set correctly (in case user existed with different home)
23 + usermod --home "$GIT_HOME" --shell "$(which git-shell)" git
24 +
25 + # 2. Set up SSH authorized_keys
26 + mkdir -p "$GIT_HOME/.ssh"
27 + touch "$GIT_HOME/.ssh/authorized_keys"
28 + chmod 700 "$GIT_HOME/.ssh"
29 + chmod 600 "$GIT_HOME/.ssh/authorized_keys"
30 +
31 + # Add your SSH public key (replace with your actual key)
32 + if [ -f /root/.ssh/authorized_keys ]; then
33 + # Copy root's authorized keys as a starting point
34 + grep -v '^#' /root/.ssh/authorized_keys >> "$GIT_HOME/.ssh/authorized_keys" 2>/dev/null || true
35 + # Deduplicate
36 + sort -u "$GIT_HOME/.ssh/authorized_keys" -o "$GIT_HOME/.ssh/authorized_keys"
37 + echo "Copied SSH keys from root"
38 + fi
39 +
40 + # 3. Ensure /opt/git/ repos are owned by git user
41 + chown -R git:git "$GIT_HOME"
42 +
43 + # The makenotwork service needs read access to /opt/git/ for the web browser.
44 + # The service runs as makenotwork user. Add makenotwork to git group for read access.
45 + usermod -aG git makenotwork 2>/dev/null || true
46 +
47 + # Ensure group read on repo dirs
48 + chmod -R g+rX "$GIT_HOME"
49 +
50 + # 4. Create git-shell-commands directory (required for git-shell to work)
51 + mkdir -p "$GIT_HOME/git-shell-commands"
52 + # Add a no-interactive-login script
53 + cat > "$GIT_HOME/git-shell-commands/no-interactive-login" << 'SCRIPT'
54 + #!/bin/sh
55 + echo "Interactive login disabled. Use git push/pull/clone."
56 + exit 128
57 + SCRIPT
58 + chmod +x "$GIT_HOME/git-shell-commands/no-interactive-login"
59 + chown -R git:git "$GIT_HOME/git-shell-commands"
60 +
61 + echo ""
62 + echo "=== Git SSH setup complete ==="
63 + echo ""
64 + echo "Test with:"
65 + echo " git clone git@makenot.work:maxmj/makenotwork.git"
66 + echo " cd makenotwork && git push"
67 + echo ""
68 + echo "To add more SSH keys, edit: $GIT_HOME/.ssh/authorized_keys"
@@ -0,0 +1,18 @@
1 + CREATE TABLE git_repos (
2 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
3 + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
4 + name VARCHAR(64) NOT NULL,
5 + project_id UUID REFERENCES projects(id) ON DELETE SET NULL,
6 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
7 + UNIQUE (user_id, name)
8 + );
9 +
10 + -- Migrate existing project->repo links
11 + INSERT INTO git_repos (user_id, name, project_id)
12 + SELECT p.user_id, p.git_repo_name, p.id
13 + FROM projects p
14 + WHERE p.git_repo_name IS NOT NULL;
15 +
16 + -- Drop old column + index
17 + DROP INDEX IF EXISTS idx_projects_git_repo_unique;
18 + ALTER TABLE projects DROP COLUMN git_repo_name;
@@ -0,0 +1,2 @@
1 + ALTER TABLE users ADD COLUMN upload_trusted BOOLEAN NOT NULL DEFAULT false;
2 + UPDATE users SET upload_trusted = true WHERE can_create_projects = true;
@@ -133,7 +133,7 @@ async fn cmd_waitlist(pool: &PgPool) -> anyhow::Result<()> {
133 133 return Ok(());
134 134 }
135 135
136 - println!("{:<20} {:<30} {:<12} {}", "Username", "Email", "Date", "Pitch");
136 + println!("{:<20} {:<30} {:<12} Pitch", "Username", "Email", "Date");
137 137 println!("{}", "-".repeat(90));
138 138
139 139 for entry in &entries {
@@ -352,8 +352,8 @@ async fn cmd_appeals(pool: &PgPool) -> anyhow::Result<()> {
352 352 }
353 353
354 354 println!(
355 - "{:<20} {:<30} {:<12} {:<12} {}",
356 - "Username", "Email", "Suspended", "Appeal Date", "Appeal Text"
355 + "{:<20} {:<30} {:<12} {:<12} Appeal Text",
356 + "Username", "Email", "Suspended", "Appeal Date"
357 357 );
358 358 println!("{}", "-".repeat(110));
359 359
@@ -536,8 +536,8 @@ async fn cmd_storage(pool: &PgPool, username_str: &str) -> anyhow::Result<()> {
536 536 }
537 537
538 538 println!(
539 - "{:<10} {:<20} {:<25} {}",
540 - "Type", "Project", "Item", "S3 Key"
539 + "{:<10} {:<20} {:<25} S3 Key",
540 + "Type", "Project", "Item"
541 541 );
542 542 println!("{}", "-".repeat(100));
543 543
@@ -104,6 +104,7 @@ pub async fn get_published_blog_posts_by_project(
104 104 /// `publish_at` uses a double-Option: `None` = no change, `Some(None)` = clear schedule,
105 105 /// `Some(Some(dt))` = set schedule. When a schedule is set, `published_at` stays NULL
106 106 /// (the scheduler will set it when the time comes).
107 + #[allow(clippy::too_many_arguments)]
107 108 pub async fn update_blog_post(
108 109 pool: &PgPool,
109 110 id: BlogPostId,
@@ -10,6 +10,7 @@ use crate::error::Result;
10 10 // ── Insertion library ──
11 11
12 12 /// Create a new reusable insertion clip for a creator.
13 + #[allow(clippy::too_many_arguments)]
13 14 pub async fn create_insertion(
14 15 pool: &PgPool,
15 16 user_id: UserId,
@@ -4,6 +4,18 @@ use sqlx::PgPool;
4 4
5 5 use crate::error::Result;
6 6
7 + /// Check if an email address is on the suppression list.
8 + pub async fn is_suppressed(pool: &PgPool, email: &str) -> Result<bool> {
9 + let row = sqlx::query_scalar::<_, bool>(
10 + "SELECT EXISTS(SELECT 1 FROM email_suppressions WHERE email = LOWER($1))",
11 + )
12 + .bind(email)
13 + .fetch_one(pool)
14 + .await?;
15 +
16 + Ok(row)
17 + }
18 +
7 19 /// Record a suppressed email address. Idempotent — does nothing if already suppressed.
8 20 pub async fn add_suppression(pool: &PgPool, email: &str, reason: &str) -> Result<()> {
9 21 sqlx::query(
@@ -220,11 +220,12 @@ impl_str_enum!(SyncPlatform {
220 220 // ── File scanning ──
221 221
222 222 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
223 - #[serde(rename_all = "lowercase")]
223 + #[serde(rename_all = "snake_case")]
224 224 pub enum FileScanStatus {
225 225 Pending,
226 226 Clean,
227 227 Quarantined,
228 + HeldForReview,
228 229 Error,
229 230 }
230 231
@@ -232,6 +233,7 @@ impl_str_enum!(FileScanStatus {
232 233 Pending => "pending",
233 234 Clean => "clean",
234 235 Quarantined => "quarantined",
236 + HeldForReview => "held_for_review",
235 237 Error => "error",
236 238 });
237 239
@@ -424,6 +426,15 @@ mod tests {
424 426 }
425 427
426 428 #[test]
429 + fn file_scan_status_round_trip() {
430 + assert_eq!(FileScanStatus::Clean.to_string(), "clean");
431 + assert_eq!("held_for_review".parse::<FileScanStatus>().unwrap(), FileScanStatus::HeldForReview);
432 + assert_eq!(FileScanStatus::HeldForReview.to_string(), "held_for_review");
433 + assert_eq!("quarantined".parse::<FileScanStatus>().unwrap(), FileScanStatus::Quarantined);
434 + assert!("bogus".parse::<FileScanStatus>().is_err());
435 + }
436 +
437 + #[test]
427 438 fn code_purpose_round_trip() {
428 439 assert_eq!(CodePurpose::Discount.to_string(), "discount");
429 440 assert_eq!("free_access".parse::<CodePurpose>().unwrap(), CodePurpose::FreeAccess);
@@ -0,0 +1,93 @@
1 + //! Git repository CRUD and lookup queries.
2 +
3 + use sqlx::PgPool;
4 +
5 + use super::models::DbGitRepo;
6 + use super::{GitRepoId, ProjectId, UserId};
7 + use crate::error::Result;
8 +
9 + /// Register a new git repository for a user.
10 + pub async fn create_repo(pool: &PgPool, user_id: UserId, name: &str) -> Result<DbGitRepo> {
11 + let repo = sqlx::query_as::<_, DbGitRepo>(
12 + r#"
13 + INSERT INTO git_repos (user_id, name)
14 + VALUES ($1, $2)
15 + RETURNING *
16 + "#,
17 + )
18 + .bind(user_id)
19 + .bind(name)
20 + .fetch_one(pool)
21 + .await?;
22 +
23 + Ok(repo)
24 + }
25 +
26 + /// Look up a repo by its owning user and bare name. Returns `None` if not found.
27 + pub async fn get_repo_by_user_and_name(
28 + pool: &PgPool,
29 + user_id: UserId,
30 + name: &str,
31 + ) -> Result<Option<DbGitRepo>> {
32 + let repo = sqlx::query_as::<_, DbGitRepo>(
33 + "SELECT * FROM git_repos WHERE user_id = $1 AND name = $2",
34 + )
35 + .bind(user_id)
36 + .bind(name)
37 + .fetch_optional(pool)
38 + .await?;
39 +
40 + Ok(repo)
41 + }
42 +
43 + /// List all repos owned by a user, newest first.
44 + pub async fn get_repos_by_user(pool: &PgPool, user_id: UserId) -> Result<Vec<DbGitRepo>> {
45 + let repos = sqlx::query_as::<_, DbGitRepo>(
46 + "SELECT * FROM git_repos WHERE user_id = $1 ORDER BY created_at DESC LIMIT 500",
47 + )
48 + .bind(user_id)
49 + .fetch_all(pool)
50 + .await?;
51 +
52 + Ok(repos)
53 + }
54 +
55 + /// List all repos linked to a specific project.
56 + pub async fn get_repos_by_project(
57 + pool: &PgPool,
58 + project_id: ProjectId,
59 + ) -> Result<Vec<DbGitRepo>> {
60 + let repos = sqlx::query_as::<_, DbGitRepo>(
61 + "SELECT * FROM git_repos WHERE project_id = $1 ORDER BY name ASC",
62 + )
63 + .bind(project_id)
64 + .fetch_all(pool)
65 + .await?;
66 +
67 + Ok(repos)
68 + }
69 +
70 + /// Link a repo to a project (sets `project_id`).
71 + pub async fn link_repo_to_project(
72 + pool: &PgPool,
73 + repo_id: GitRepoId,
74 + project_id: ProjectId,
75 + ) -> Result<()> {
76 + sqlx::query("UPDATE git_repos SET project_id = $2 WHERE id = $1")
77 + .bind(repo_id)
78 + .bind(project_id)
79 + .execute(pool)
80 + .await?;
81 +
82 + Ok(())
83 + }
84 +
85 + /// Unlink a repo from its project (sets `project_id = NULL`).
86 + pub async fn unlink_repo_from_project(pool: &PgPool, repo_id: GitRepoId) -> Result<()> {
87 + sqlx::query("UPDATE git_repos SET project_id = NULL WHERE id = $1")
88 + .bind(repo_id)
89 + .execute(pool)
90 + .await?;
91 +
92 + Ok(())
93 + }
@@ -161,6 +161,7 @@ define_pg_uuid_id!(
161 161 ContentInsertionId,
162 162 ContentInsertionPlacementId,
163 163 InviteCodeId,
164 + GitRepoId,
164 165 );
165 166
166 167 #[cfg(test)]
@@ -37,6 +37,7 @@ pub(crate) mod content_insertions;
37 37 pub(crate) mod invites;
38 38 pub(crate) mod analytics;
39 39 pub(crate) mod email_suppressions;
40 + pub(crate) mod git_repos;
40 41
41 42 pub use id_types::*;
42 43 pub use validated_types::*;
@@ -100,6 +100,8 @@ pub struct DbUser {
100 100 // Creator access
101 101 /// Whether this user is allowed to create projects.
102 102 pub can_create_projects: bool,
103 + /// Whether this user's uploads skip the review queue (trusted = auto-publish).
104 + pub upload_trusted: bool,
103 105 // Notification preferences
104 106 /// Whether to email the user on new device logins.
105 107 pub login_notification_enabled: bool,
@@ -179,8 +181,21 @@ pub struct DbProject {
179 181 pub created_at: DateTime<Utc>,
180 182 /// When the project was last modified.
181 183 pub updated_at: DateTime<Utc>,
182 - /// Optional linked git repository name (bare name, not full path).
183 - pub git_repo_name: Option<String>,
184 + }
185 +
186 + /// A git repository tracked on disk, optionally linked to a project.
187 + #[derive(Debug, Clone, FromRow, Serialize)]
188 + pub struct DbGitRepo {
189 + /// Database primary key.
190 + pub id: GitRepoId,
191 + /// Owning user's ID.
192 + pub user_id: UserId,
193 + /// Bare repository name (no path separators).
194 + pub name: String,
195 + /// Linked project (many repos can link to one project).
196 + pub project_id: Option<ProjectId>,
197 + /// When the repo was registered.
198 + pub created_at: DateTime<Utc>,
184 199 }
185 200
186 201 /// A purchasable or free item within a project.
@@ -1235,6 +1250,7 @@ mod tests {
1235 1250 locked_until: None,
1236 1251 last_failed_login_at: None,
1237 1252 can_create_projects: false,
1253 + upload_trusted: false,
1238 1254 login_notification_enabled: true,
1239 1255 totp_secret: None,
1240 1256 totp_enabled: false,
@@ -164,38 +164,6 @@ pub async fn get_public_projects_with_item_counts(
164 164 Ok(projects)
165 165 }
166 166
167 - /// Set or clear a project's linked git repository name.
168 - pub async fn set_project_git_repo(
169 - pool: &PgPool,
170 - id: ProjectId,
171 - repo_name: Option<&str>,
172 - ) -> Result<()> {
173 - sqlx::query("UPDATE projects SET git_repo_name = $2 WHERE id = $1")
174 - .bind(id)
175 - .bind(repo_name)
176 - .execute(pool)
177 - .await?;
178 -
179 - Ok(())
180 - }
181 -
182 - /// Look up a project by its linked git repo name and owning user.
183 - pub async fn get_project_by_git_repo(
184 - pool: &PgPool,
185 - user_id: UserId,
186 - repo_name: &str,
187 - ) -> Result<Option<DbProject>> {
188 - let project = sqlx::query_as::<_, DbProject>(
189 - "SELECT * FROM projects WHERE user_id = $1 AND git_repo_name = $2 LIMIT 1",
190 - )
191 - .bind(user_id)
192 - .bind(repo_name)
193 - .fetch_optional(pool)
194 - .await?;
195 -
196 - Ok(project)
197 - }
198 -
199 167 /// Fetch a public project by its URL slug. Returns `None` if not found or not public.
200 168 pub async fn get_public_project_by_slug(
201 169 pool: &PgPool,