max / makenotwork
103 files changed,
+7682 insertions,
-1114 deletions
| @@ -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 |
| @@ -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, |