From b4be6bee5416934572a4ce7db7e03e378bfb07d5 Mon Sep 17 00:00:00 2001 From: robbie <66927283+robbiesrude@users.noreply.github.com> Date: Sat, 23 Aug 2025 12:40:46 -0700 Subject: [PATCH 01/10] Create python-app.yml Signed-off-by: robbie <66927283+robbiesrude@users.noreply.github.com> --- .github/workflows/python-app.yml | 39 ++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 .github/workflows/python-app.yml diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml new file mode 100644 index 0000000000..200872cdc9 --- /dev/null +++ b/.github/workflows/python-app.yml @@ -0,0 +1,39 @@ +# This workflow will install Python dependencies, run tests and lint with a single version of Python +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python + +name: Python application + +on: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + +permissions: + contents: read + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Set up Python 3.10 + uses: actions/setup-python@v3 + with: + python-version: "3.10" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install flake8 pytest + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + - name: Lint with flake8 + run: | + # stop the build if there are Python syntax errors or undefined names + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + - name: Test with pytest + run: | + pytest From 1abee12ede709afd97298fd91875238a2e648846 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 23:10:50 +0000 Subject: [PATCH 02/10] Add Oracle Cloud deployment setup Add deploy/oracle with an Oracle-ready docker-compose that builds this fork for the VM's native (ARM64) architecture, runs the logviewer, and reuses the existing MongoDB via CONNECTION_URI. Includes a host setup script (Docker install + firewall) and a step-by-step migration guide. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013vqQJQAud7rmfykwWKJACY --- deploy/oracle/.env.example | 20 ++++++ deploy/oracle/README.md | 107 +++++++++++++++++++++++++++++++ deploy/oracle/docker-compose.yml | 32 +++++++++ deploy/oracle/setup.sh | 48 ++++++++++++++ 4 files changed, 207 insertions(+) create mode 100644 deploy/oracle/.env.example create mode 100644 deploy/oracle/README.md create mode 100644 deploy/oracle/docker-compose.yml create mode 100755 deploy/oracle/setup.sh diff --git a/deploy/oracle/.env.example b/deploy/oracle/.env.example new file mode 100644 index 0000000000..84efd10a7d --- /dev/null +++ b/deploy/oracle/.env.example @@ -0,0 +1,20 @@ +# Copy this file to `.env` in the same directory and fill in your values. +# cp .env.example .env && nano .env + +# Your Discord bot token. +TOKEN=MyBotToken + +# The ID of the Discord server (guild) this bot serves. +GUILD_ID=1234567890 + +# Comma-separated user IDs allowed to run owner-only commands. +OWNERS=Owner1ID,Owner2ID + +# Your EXISTING MongoDB connection URI (kept from your previous host). +# The logviewer reuses this same URI via MONGO_URI in docker-compose.yml. +CONNECTION_URI=mongodb+srv://user:pass@cluster.mongodb.net/modmail + +# Public URL where the logviewer is reachable, used in generated log links. +# Set this to your VM's public IP (or domain) on port 8000, e.g.: +# http://123.45.67.89:8000 +LOG_URL=http://YOUR_VM_PUBLIC_IP:8000 diff --git a/deploy/oracle/README.md b/deploy/oracle/README.md new file mode 100644 index 0000000000..44fa86730e --- /dev/null +++ b/deploy/oracle/README.md @@ -0,0 +1,107 @@ +# Deploying Modmail to Oracle Cloud + +This guide moves your Modmail bot (this fork) and the Logviewer onto an Oracle +Cloud **Always Free** VM, while continuing to use your **existing MongoDB**. + +What you get: +- `bot` — built from this repo's `Dockerfile`, so your fork's changes are + included and the image is native to the VM's ARM64 architecture. +- `logviewer` — the web UI for closed-thread log links, served on port `8000`. +- No database container — the bot and logviewer both point at your current + `CONNECTION_URI`. + +--- + +## 1. Create the VM + +In the [OCI Console](https://cloud.oracle.com/) → **Compute → Instances → Create**: + +- **Image:** Canonical Ubuntu 22.04 (or Oracle Linux 9). +- **Shape:** `VM.Standard.A1.Flex` (Ampere ARM, **Always Free** — e.g. 1–2 OCPUs, + 6–12 GB RAM is plenty). +- **SSH keys:** upload/download a key pair so you can log in. +- Note the instance's **public IP** after it boots. + +## 2. Open the firewall (cloud side) + +The instance firewall is handled by `setup.sh`, but the cloud network is separate. + +In the OCI Console → **Networking → Virtual Cloud Networks → your VCN → your +subnet → Security List → Add Ingress Rules**: + +| Source CIDR | Protocol | Dest. Port | Purpose | +|-------------|----------|------------|---------------| +| `0.0.0.0/0` | TCP | `8000` | Logviewer | + +(Port `22` for SSH is usually open by default.) + +## 3. Install Docker on the VM + +SSH in, then: + +```bash +ssh ubuntu@YOUR_VM_PUBLIC_IP # or opc@... on Oracle Linux + +# Get this fork's deploy files: +git clone https://github.com/PloverRoblox/modmail.git +cd modmail +git checkout claude/gifted-curie-z2wprx +cd deploy/oracle + +chmod +x setup.sh && ./setup.sh +``` + +Then **log out and back in** so your user picks up the `docker` group. + +## 4. Configure your environment + +```bash +cp .env.example .env +nano .env +``` + +Fill in: +- `TOKEN`, `GUILD_ID`, `OWNERS` — same values as your old host. +- `CONNECTION_URI` — your **existing** MongoDB URI (nothing migrates; the bot + just reconnects to the same database). +- `LOG_URL` — `http://YOUR_VM_PUBLIC_IP:8000` + +## 5. Launch + +```bash +docker compose up -d --build +docker compose logs -f bot # watch it connect to Discord +``` + +You should see the bot log in. Closed-thread log links will resolve at +`http://YOUR_VM_PUBLIC_IP:8000`. + +## 6. Decommission the old host + +Once the new instance is confirmed working, stop the bot on your previous host +so two instances don't run against the same database at once. + +--- + +## Updating later + +```bash +cd ~/modmail && git pull +cd deploy/oracle && docker compose up -d --build +``` + +## Troubleshooting + +- **Bot won't start / DB errors:** double-check `CONNECTION_URI` and that your + MongoDB/Atlas network access list allows the VM's public IP. +- **Logviewer container exits with "exec format error" on the ARM VM:** the + upstream `logviewer` image may not publish an `arm64` variant. Two fixes: + 1. Add emulation, then retry: + `sudo apt install -y qemu-user-static binfmt-support` and add + `platform: linux/amd64` under the `logviewer` service in + `docker-compose.yml`; **or** + 2. Use an x86 shape (`VM.Standard.E2.1.Micro`, also Always Free) for the VM so + the upstream image runs natively. +- **Can't reach the logviewer in a browser:** confirm both the OCI Security List + rule (step 2) *and* the instance firewall (`setup.sh`) allow port `8000`. +``` diff --git a/deploy/oracle/docker-compose.yml b/deploy/oracle/docker-compose.yml new file mode 100644 index 0000000000..38377449de --- /dev/null +++ b/deploy/oracle/docker-compose.yml @@ -0,0 +1,32 @@ +# Oracle Cloud deployment for Modmail (this fork) + Logviewer. +# +# - `bot` is built from this repository's Dockerfile so your fork's changes are +# included, and so the image is native to the host architecture (the Always +# Free Ampere A1 VM is ARM64 / aarch64). +# - `logviewer` is pulled from the upstream registry. If it fails to start on an +# ARM VM (manifest/exec format error), see the note in README.md. +# - There is no `mongo` service here: this setup points at your EXISTING database +# via CONNECTION_URI in .env (your current MongoDB / Atlas URI). + +services: + bot: + build: + context: ../.. + dockerfile: Dockerfile + image: modmail-fork:latest + container_name: modmail-bot + restart: always + env_file: + - .env + + logviewer: + image: ghcr.io/modmail-dev/logviewer:master + container_name: modmail-logviewer + restart: always + env_file: + - .env + environment: + # Logviewer reads MONGO_URI; reuse the same database the bot writes to. + - MONGO_URI=${CONNECTION_URI} + ports: + - "8000:8000" diff --git a/deploy/oracle/setup.sh b/deploy/oracle/setup.sh new file mode 100755 index 0000000000..4dd9dd0979 --- /dev/null +++ b/deploy/oracle/setup.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# +# One-time host setup for running Modmail on an Oracle Cloud VM. +# Installs Docker + Compose and opens the logviewer port (8000) on the instance +# firewall. Works on Ubuntu (apt) and Oracle Linux (dnf) images. +# +# Usage (run on the VM): +# chmod +x setup.sh && ./setup.sh +# +# NOTE: This handles the *instance* firewall only. You must ALSO open port 8000 +# (and 22 for SSH) in the OCI Console -> VCN -> Security List. See README.md. + +set -euo pipefail + +LOGVIEWER_PORT=8000 + +echo "==> Installing Docker Engine + Compose plugin..." +if ! command -v docker >/dev/null 2>&1; then + curl -fsSL https://get.docker.com | sh +fi + +echo "==> Adding current user to the docker group (re-login to take effect)..." +sudo usermod -aG docker "$USER" || true + +echo "==> Enabling and starting Docker..." +sudo systemctl enable --now docker + +echo "==> Opening instance firewall on port ${LOGVIEWER_PORT}..." +if command -v firewall-cmd >/dev/null 2>&1; then + # Oracle Linux ships firewalld. + sudo firewall-cmd --permanent --add-port=${LOGVIEWER_PORT}/tcp + sudo firewall-cmd --reload +elif command -v iptables >/dev/null 2>&1; then + # Ubuntu Oracle images ship a restrictive iptables ruleset. + sudo iptables -I INPUT 6 -m state --state NEW -p tcp --dport ${LOGVIEWER_PORT} -j ACCEPT + if command -v netfilter-persistent >/dev/null 2>&1; then + sudo netfilter-persistent save + else + echo " (Install iptables-persistent to make this rule survive reboot.)" + fi +fi + +echo "" +echo "==> Host setup complete." +echo " 1. Log out and back in (so the docker group applies)." +echo " 2. cp .env.example .env && edit .env with your values." +echo " 3. docker compose up -d --build" +echo " 4. Make sure port ${LOGVIEWER_PORT} is also open in the OCI Security List." From 37edffbe201c4fc39baf59e8b30a543442398b46 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 23:38:36 +0000 Subject: [PATCH 03/10] Add swap setup and document x86 E2.1.Micro shape Create a swap file during host setup so image builds don't OOM on the 1 GB E2.1.Micro shape, and document both Always Free shape options. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013vqQJQAud7rmfykwWKJACY --- deploy/oracle/README.md | 7 +++++-- deploy/oracle/setup.sh | 12 ++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/deploy/oracle/README.md b/deploy/oracle/README.md index 44fa86730e..d19753b46d 100644 --- a/deploy/oracle/README.md +++ b/deploy/oracle/README.md @@ -17,8 +17,11 @@ What you get: In the [OCI Console](https://cloud.oracle.com/) → **Compute → Instances → Create**: - **Image:** Canonical Ubuntu 22.04 (or Oracle Linux 9). -- **Shape:** `VM.Standard.A1.Flex` (Ampere ARM, **Always Free** — e.g. 1–2 OCPUs, - 6–12 GB RAM is plenty). +- **Shape:** either Always Free option works: + - `VM.Standard.A1.Flex` (Ampere ARM) — more RAM, but see the logviewer ARM + note under Troubleshooting. + - `VM.Standard.E2.1.Micro` (x86, 1 OCPU / 1 GB RAM) — logviewer runs natively; + `setup.sh` adds a swap file to handle the 1 GB limit during image builds. - **SSH keys:** upload/download a key pair so you can log in. - Note the instance's **public IP** after it boots. diff --git a/deploy/oracle/setup.sh b/deploy/oracle/setup.sh index 4dd9dd0979..0d8ff1367e 100755 --- a/deploy/oracle/setup.sh +++ b/deploy/oracle/setup.sh @@ -13,6 +13,18 @@ set -euo pipefail LOGVIEWER_PORT=8000 +SWAP_SIZE=2G + +echo "==> Ensuring swap exists (important on 1 GB shapes like E2.1.Micro)..." +if ! sudo swapon --show | grep -q '/swapfile'; then + sudo fallocate -l "${SWAP_SIZE}" /swapfile || sudo dd if=/dev/zero of=/swapfile bs=1M count=2048 + sudo chmod 600 /swapfile + sudo mkswap /swapfile + sudo swapon /swapfile + grep -q '/swapfile' /etc/fstab || echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab +else + echo " Swap already present, skipping." +fi echo "==> Installing Docker Engine + Compose plugin..." if ! command -v docker >/dev/null 2>&1; then From 41847b693404d4136667fa1442ac16c87d56f345 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 00:11:02 +0000 Subject: [PATCH 04/10] Publish logviewer via Cloudflare Tunnel Add a cloudflared service to the Oracle deployment so the logviewer is served on a custom domain over HTTPS with no inbound ports. Drop the public logviewer port mapping, add TUNNEL_TOKEN config, and rewrite the guide for the Cloudflare Zero Trust tunnel flow. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013vqQJQAud7rmfykwWKJACY --- deploy/oracle/.env.example | 11 ++++-- deploy/oracle/README.md | 68 +++++++++++++++++++------------- deploy/oracle/docker-compose.yml | 20 +++++++++- 3 files changed, 66 insertions(+), 33 deletions(-) diff --git a/deploy/oracle/.env.example b/deploy/oracle/.env.example index 84efd10a7d..32e2cfe9b5 100644 --- a/deploy/oracle/.env.example +++ b/deploy/oracle/.env.example @@ -15,6 +15,11 @@ OWNERS=Owner1ID,Owner2ID CONNECTION_URI=mongodb+srv://user:pass@cluster.mongodb.net/modmail # Public URL where the logviewer is reachable, used in generated log links. -# Set this to your VM's public IP (or domain) on port 8000, e.g.: -# http://123.45.67.89:8000 -LOG_URL=http://YOUR_VM_PUBLIC_IP:8000 +# With Cloudflare Tunnel this is your domain over HTTPS, e.g.: +# https://logs.yourdomain.com +LOG_URL=https://logs.yourdomain.com + +# Cloudflare Tunnel token. Create a tunnel in the Cloudflare Zero Trust +# dashboard (Networks -> Tunnels), route your hostname to http://logviewer:8000, +# then copy the connector token here. See deploy/oracle/README.md. +TUNNEL_TOKEN=your-cloudflare-tunnel-token diff --git a/deploy/oracle/README.md b/deploy/oracle/README.md index d19753b46d..b8e3afe240 100644 --- a/deploy/oracle/README.md +++ b/deploy/oracle/README.md @@ -1,12 +1,16 @@ # Deploying Modmail to Oracle Cloud This guide moves your Modmail bot (this fork) and the Logviewer onto an Oracle -Cloud **Always Free** VM, while continuing to use your **existing MongoDB**. +Cloud **Always Free** VM, while continuing to use your **existing MongoDB**. The +logviewer is published on your own domain via a **Cloudflare Tunnel**, so no +inbound ports are exposed. What you get: - `bot` — built from this repo's `Dockerfile`, so your fork's changes are - included and the image is native to the VM's ARM64 architecture. -- `logviewer` — the web UI for closed-thread log links, served on port `8000`. + included and the image is native to the VM's architecture. +- `logviewer` — the web UI for closed-thread log links. +- `cloudflared` — a Cloudflare Tunnel that serves the logviewer at your domain + over HTTPS, with no open ports on the VM. - No database container — the bot and logviewer both point at your current `CONNECTION_URI`. @@ -23,20 +27,26 @@ In the [OCI Console](https://cloud.oracle.com/) → **Compute → Instances → - `VM.Standard.E2.1.Micro` (x86, 1 OCPU / 1 GB RAM) — logviewer runs natively; `setup.sh` adds a swap file to handle the 1 GB limit during image builds. - **SSH keys:** upload/download a key pair so you can log in. -- Note the instance's **public IP** after it boots. -## 2. Open the firewall (cloud side) +With a Cloudflare Tunnel you do **not** need to open any ingress ports in the OCI +Security List — the tunnel connects outbound. (Port `22` for SSH is open by +default.) -The instance firewall is handled by `setup.sh`, but the cloud network is separate. +## 2. Create the Cloudflare Tunnel -In the OCI Console → **Networking → Virtual Cloud Networks → your VCN → your -subnet → Security List → Add Ingress Rules**: +In the [Cloudflare Zero Trust dashboard](https://one.dash.cloudflare.com/) → +**Networks → Tunnels**: -| Source CIDR | Protocol | Dest. Port | Purpose | -|-------------|----------|------------|---------------| -| `0.0.0.0/0` | TCP | `8000` | Logviewer | - -(Port `22` for SSH is usually open by default.) +1. **Create a tunnel** → choose **Cloudflared** → give it a name (e.g. `modmail`). +2. On the install screen, **copy the tunnel token** — it's the long string in the + shown `cloudflared ... run ` command. You don't run that command; the + `cloudflared` container uses the token. Save it for step 4. +3. Add a **Public Hostname**: + - **Subdomain / Domain:** e.g. `logs` + your Cloudflare-managed domain. + - **Type:** `HTTP` + - **URL:** `logviewer:8000` (the container name + port — they share the + Docker network) +4. Save. Cloudflare auto-creates the DNS record and HTTPS cert for that hostname. ## 3. Install Docker on the VM @@ -67,17 +77,19 @@ Fill in: - `TOKEN`, `GUILD_ID`, `OWNERS` — same values as your old host. - `CONNECTION_URI` — your **existing** MongoDB URI (nothing migrates; the bot just reconnects to the same database). -- `LOG_URL` — `http://YOUR_VM_PUBLIC_IP:8000` +- `LOG_URL` — your tunnel hostname, e.g. `https://logs.yourdomain.com` +- `TUNNEL_TOKEN` — the token you copied in step 2. ## 5. Launch ```bash docker compose up -d --build -docker compose logs -f bot # watch it connect to Discord +docker compose logs -f bot # watch it connect to Discord +docker compose logs cloudflared # should show "Registered tunnel connection" ``` -You should see the bot log in. Closed-thread log links will resolve at -`http://YOUR_VM_PUBLIC_IP:8000`. +Open `https://logs.yourdomain.com` in a browser — the logviewer should load. +Closed-thread log links will now use that domain. ## 6. Decommission the old host @@ -96,15 +108,15 @@ cd deploy/oracle && docker compose up -d --build ## Troubleshooting - **Bot won't start / DB errors:** double-check `CONNECTION_URI` and that your - MongoDB/Atlas network access list allows the VM's public IP. + MongoDB/Atlas network access list (Atlas → Network Access) allows the VM's + public IP. +- **Logviewer domain shows Cloudflare error 502/1033:** the tunnel can't reach + the logviewer. Confirm the Public Hostname URL is exactly `logviewer:8000`, + and that `docker compose logs cloudflared` shows a registered connection. +- **`cloudflared` keeps restarting:** the `TUNNEL_TOKEN` is wrong or missing — + re-copy it from the tunnel's install screen. - **Logviewer container exits with "exec format error" on the ARM VM:** the - upstream `logviewer` image may not publish an `arm64` variant. Two fixes: - 1. Add emulation, then retry: - `sudo apt install -y qemu-user-static binfmt-support` and add - `platform: linux/amd64` under the `logviewer` service in - `docker-compose.yml`; **or** - 2. Use an x86 shape (`VM.Standard.E2.1.Micro`, also Always Free) for the VM so - the upstream image runs natively. -- **Can't reach the logviewer in a browser:** confirm both the OCI Security List - rule (step 2) *and* the instance firewall (`setup.sh`) allow port `8000`. -``` + upstream `logviewer` image may not publish an `arm64` variant. Either add + `platform: linux/amd64` under the `logviewer` service (with + `sudo apt install -y qemu-user-static binfmt-support`), or use the + `VM.Standard.E2.1.Micro` x86 shape so the image runs natively. diff --git a/deploy/oracle/docker-compose.yml b/deploy/oracle/docker-compose.yml index 38377449de..e48a6c7c8f 100644 --- a/deploy/oracle/docker-compose.yml +++ b/deploy/oracle/docker-compose.yml @@ -28,5 +28,21 @@ services: environment: # Logviewer reads MONGO_URI; reuse the same database the bot writes to. - MONGO_URI=${CONNECTION_URI} - ports: - - "8000:8000" + # No public port: Cloudflare Tunnel reaches the logviewer privately over the + # Docker network at http://logviewer:8000. To test locally without the + # tunnel, temporarily add: ports: ["8000:8000"] + + # Cloudflare Tunnel: makes an OUTBOUND connection to Cloudflare, so the + # logviewer is published at your domain with automatic HTTPS and no inbound + # ports opened. Create the tunnel in the Cloudflare Zero Trust dashboard, + # route your hostname to the service http://logviewer:8000, and paste the + # tunnel token into TUNNEL_TOKEN in .env. See README.md. + cloudflared: + image: cloudflare/cloudflared:latest + container_name: modmail-cloudflared + restart: always + command: tunnel --no-autoupdate run + environment: + - TUNNEL_TOKEN=${TUNNEL_TOKEN} + depends_on: + - logviewer From 3075ed5723f273e7a6857214d607cb4a4020fa47 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 01:31:24 +0000 Subject: [PATCH 05/10] Gate logviewer behind Discord role-based OAuth2 Add a Discord OAuth2 forward-auth proxy (authproxy) and a Caddy edge so only members of GUILD_ID holding REQUIRED_ROLE_ID can view logs. Caddy runs every request past the proxy's role check (via guilds.members.read) before serving the logviewer; the tunnel now points at caddy:8080. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013vqQJQAud7rmfykwWKJACY --- deploy/oracle/.env.example | 19 +++- deploy/oracle/Caddyfile | 25 ++++++ deploy/oracle/README.md | 43 +++++++-- deploy/oracle/auth/Dockerfile | 13 +++ deploy/oracle/auth/app.py | 131 ++++++++++++++++++++++++++++ deploy/oracle/auth/requirements.txt | 3 + deploy/oracle/docker-compose.yml | 38 ++++++-- 7 files changed, 258 insertions(+), 14 deletions(-) create mode 100644 deploy/oracle/Caddyfile create mode 100644 deploy/oracle/auth/Dockerfile create mode 100644 deploy/oracle/auth/app.py create mode 100644 deploy/oracle/auth/requirements.txt diff --git a/deploy/oracle/.env.example b/deploy/oracle/.env.example index 32e2cfe9b5..5fcbe41746 100644 --- a/deploy/oracle/.env.example +++ b/deploy/oracle/.env.example @@ -20,6 +20,23 @@ CONNECTION_URI=mongodb+srv://user:pass@cluster.mongodb.net/modmail LOG_URL=https://logs.yourdomain.com # Cloudflare Tunnel token. Create a tunnel in the Cloudflare Zero Trust -# dashboard (Networks -> Tunnels), route your hostname to http://logviewer:8000, +# dashboard (Networks -> Connectors), route your hostname to http://caddy:8080, # then copy the connector token here. See deploy/oracle/README.md. TUNNEL_TOKEN=your-cloudflare-tunnel-token + +# --- Discord OAuth2 login for the logviewer (role-gated access) --------------- +# Only members of GUILD_ID holding REQUIRED_ROLE_ID can view the logs. + +# Your Discord OAuth2 application's credentials (Developer Portal -> your app). +DISCORD_CLIENT_ID=729540679365296178 +DISCORD_CLIENT_SECRET=your-discord-client-secret + +# Must EXACTLY match a redirect added under OAuth2 -> Redirects in the portal. +DISCORD_REDIRECT_URI=https://pebble.getplover.com/auth/callback + +# The role a user must have (in GUILD_ID, set above) to view logs. +REQUIRED_ROLE_ID=766320574733221939 + +# Random secret used to sign login session cookies. Generate one with: +# openssl rand -hex 32 +SESSION_SECRET=change-me-to-a-long-random-string diff --git a/deploy/oracle/Caddyfile b/deploy/oracle/Caddyfile new file mode 100644 index 0000000000..686be2bbe0 --- /dev/null +++ b/deploy/oracle/Caddyfile @@ -0,0 +1,25 @@ +# Edge proxy for the logviewer, sitting between Cloudflare Tunnel and the app. +# TLS is terminated by Cloudflare, so Caddy just serves plain HTTP on :8080. +# +# Point your Cloudflare Tunnel public hostname at http://caddy:8080 (NOT the +# logviewer directly) so every request passes through the Discord auth check. + +{ + admin off + auto_https off +} + +:8080 { + # Auth endpoints are handled directly by the Discord OAuth proxy. + handle /auth/* { + reverse_proxy authproxy:5000 + } + + # Everything else must pass the role check before reaching the logviewer. + handle { + forward_auth authproxy:5000 { + uri /auth/verify + } + reverse_proxy logviewer:8000 + } +} diff --git a/deploy/oracle/README.md b/deploy/oracle/README.md index b8e3afe240..2c4abcb064 100644 --- a/deploy/oracle/README.md +++ b/deploy/oracle/README.md @@ -44,8 +44,8 @@ In the [Cloudflare Zero Trust dashboard](https://one.dash.cloudflare.com/) → 3. Add a **Public Hostname**: - **Subdomain / Domain:** e.g. `logs` + your Cloudflare-managed domain. - **Type:** `HTTP` - - **URL:** `logviewer:8000` (the container name + port — they share the - Docker network) + - **URL:** `caddy:8080` (the auth-proxy edge — **not** the logviewer + directly, so every request goes through the Discord role check) 4. Save. Cloudflare auto-creates the DNS record and HTTPS cert for that hostname. ## 3. Install Docker on the VM @@ -79,6 +79,30 @@ Fill in: just reconnects to the same database). - `LOG_URL` — your tunnel hostname, e.g. `https://logs.yourdomain.com` - `TUNNEL_TOKEN` — the token you copied in step 2. +- The `DISCORD_*`, `REQUIRED_ROLE_ID`, and `SESSION_SECRET` values — see the next + step. + +## 4b. Lock the logs behind Discord (role-gated) + +The logs are protected by a small Discord OAuth2 proxy (`authproxy` + `caddy`): +only members of `GUILD_ID` who hold `REQUIRED_ROLE_ID` can view them. + +1. In the [Discord Developer Portal](https://discord.com/developers/applications) + → your application → **OAuth2**: + - Copy the **Client ID** and **Client Secret** into `DISCORD_CLIENT_ID` / + `DISCORD_CLIENT_SECRET`. + - Under **Redirects**, add **exactly**: + `https:///auth/callback` + (e.g. `https://pebble.getplover.com/auth/callback`) and **Save Changes**. +2. In `.env`, set: + - `DISCORD_REDIRECT_URI` to that same `…/auth/callback` URL. + - `REQUIRED_ROLE_ID` to the role ID allowed to view logs. + - `GUILD_ID` is reused from the bot config above. + - `SESSION_SECRET` to a random string: `openssl rand -hex 32`. + +The proxy requests the `identify` and `guilds.members.read` scopes — the latter +is what lets it read the visitor's roles in your server. No bot invite or extra +permissions are needed. ## 5. Launch @@ -88,8 +112,9 @@ docker compose logs -f bot # watch it connect to Discord docker compose logs cloudflared # should show "Registered tunnel connection" ``` -Open `https://logs.yourdomain.com` in a browser — the logviewer should load. -Closed-thread log links will now use that domain. +Open `https://logs.yourdomain.com` in a browser — you'll be sent to Discord to +log in, and only granted through if you hold the required role. Closed-thread log +links will now use that domain. ## 6. Decommission the old host @@ -111,8 +136,14 @@ cd deploy/oracle && docker compose up -d --build MongoDB/Atlas network access list (Atlas → Network Access) allows the VM's public IP. - **Logviewer domain shows Cloudflare error 502/1033:** the tunnel can't reach - the logviewer. Confirm the Public Hostname URL is exactly `logviewer:8000`, - and that `docker compose logs cloudflared` shows a registered connection. + the edge. Confirm the Public Hostname URL is exactly `caddy:8080`, and that + `docker compose logs cloudflared` shows a registered connection. +- **Discord login loops or "Invalid OAuth2 redirect_uri":** the redirect in the + Developer Portal must match `DISCORD_REDIRECT_URI` exactly, including the + `https://` and the `/auth/callback` path. +- **"You do not have the required role":** the logged-in user lacks + `REQUIRED_ROLE_ID` in `GUILD_ID`, or those IDs are wrong. Check + `docker compose logs authproxy`. - **`cloudflared` keeps restarting:** the `TUNNEL_TOKEN` is wrong or missing — re-copy it from the tunnel's install screen. - **Logviewer container exits with "exec format error" on the ARM VM:** the diff --git a/deploy/oracle/auth/Dockerfile b/deploy/oracle/auth/Dockerfile new file mode 100644 index 0000000000..b8446af9d9 --- /dev/null +++ b/deploy/oracle/auth/Dockerfile @@ -0,0 +1,13 @@ +FROM python:3.11-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app.py . + +EXPOSE 5000 + +# Two workers is plenty for an auth gateway; keeps memory low on small VMs. +CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "2", "app:app"] diff --git a/deploy/oracle/auth/app.py b/deploy/oracle/auth/app.py new file mode 100644 index 0000000000..c97c60d178 --- /dev/null +++ b/deploy/oracle/auth/app.py @@ -0,0 +1,131 @@ +"""Discord OAuth2 forward-auth proxy for the Modmail logviewer. + +Sits behind Caddy's `forward_auth`. Visitors are sent through Discord's OAuth2 +flow; only members of GUILD_ID who hold REQUIRED_ROLE_ID are issued a signed +session cookie and allowed through to the logviewer. + +Endpoints (all under /auth, routed straight to this service by Caddy): + /auth/verify - called by Caddy for every request; 200 if authed, else 302 to login + /auth/login - starts the Discord OAuth2 flow + /auth/callback - Discord redirects here; verifies role, sets session cookie + /auth/logout - clears the session cookie +""" + +import os +import time +import secrets +import urllib.parse + +import requests +from flask import Flask, request, redirect, make_response +from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired + +CLIENT_ID = os.environ["DISCORD_CLIENT_ID"] +CLIENT_SECRET = os.environ["DISCORD_CLIENT_SECRET"] +REDIRECT_URI = os.environ["DISCORD_REDIRECT_URI"] # https:///auth/callback +GUILD_ID = os.environ["GUILD_ID"] +REQUIRED_ROLE_ID = os.environ["REQUIRED_ROLE_ID"] +SECRET_KEY = os.environ["SESSION_SECRET"] + +COOKIE_NAME = os.environ.get("SESSION_COOKIE_NAME", "modmail_logs_session") +SESSION_TTL = int(os.environ.get("SESSION_TTL", "86400")) # 24h + +API_BASE = "https://discord.com/api" +SCOPES = "identify guilds.members.read" + +app = Flask(__name__) +session_signer = URLSafeTimedSerializer(SECRET_KEY, salt="modmail-logs-session") +state_signer = URLSafeTimedSerializer(SECRET_KEY, salt="modmail-logs-state") + + +def _redirect_to_login(): + """Send the browser into Discord's OAuth2 flow, remembering where it wanted to go.""" + original = request.headers.get("X-Forwarded-Uri", "/") + state = state_signer.dumps({"nonce": secrets.token_urlsafe(8), "dest": original}) + params = urllib.parse.urlencode( + { + "client_id": CLIENT_ID, + "response_type": "code", + "redirect_uri": REDIRECT_URI, + "scope": SCOPES, + "state": state, + } + ) + return redirect(f"{API_BASE}/oauth2/authorize?{params}") + + +@app.route("/auth/verify") +def verify(): + token = request.cookies.get(COOKIE_NAME) + if token: + try: + session_signer.loads(token, max_age=SESSION_TTL) + return ("", 200) + except (BadSignature, SignatureExpired): + pass + return _redirect_to_login() + + +@app.route("/auth/login") +def login(): + return _redirect_to_login() + + +@app.route("/auth/callback") +def callback(): + code = request.args.get("code") + state = request.args.get("state") + if not code or not state: + return ("Missing code or state.", 400) + try: + state_data = state_signer.loads(state, max_age=600) + except (BadSignature, SignatureExpired): + return ("Invalid or expired login attempt. Please try again.", 400) + + # Exchange the authorization code for an access token. + token_resp = requests.post( + f"{API_BASE}/oauth2/token", + data={ + "client_id": CLIENT_ID, + "client_secret": CLIENT_SECRET, + "grant_type": "authorization_code", + "code": code, + "redirect_uri": REDIRECT_URI, + }, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + timeout=10, + ) + if token_resp.status_code != 200: + return ("Discord token exchange failed.", 403) + access_token = token_resp.json().get("access_token") + + # Read the caller's member object for the guild (includes their role IDs). + member_resp = requests.get( + f"{API_BASE}/users/@me/guilds/{GUILD_ID}/member", + headers={"Authorization": f"Bearer {access_token}"}, + timeout=10, + ) + if member_resp.status_code != 200: + return ("You are not a member of the required server.", 403) + member = member_resp.json() + if REQUIRED_ROLE_ID not in member.get("roles", []): + return ("You do not have the required role to view these logs.", 403) + + # Authorised: issue a signed session cookie and return to the original page. + user_id = member.get("user", {}).get("id") + value = session_signer.dumps({"id": user_id, "ts": int(time.time())}) + dest = state_data.get("dest", "/") + if not dest.startswith("/"): + dest = "/" + resp = make_response(redirect(dest)) + resp.set_cookie( + COOKIE_NAME, value, max_age=SESSION_TTL, httponly=True, secure=True, samesite="Lax" + ) + return resp + + +@app.route("/auth/logout") +def logout(): + resp = make_response(redirect("/auth/login")) + resp.delete_cookie(COOKIE_NAME) + return resp diff --git a/deploy/oracle/auth/requirements.txt b/deploy/oracle/auth/requirements.txt new file mode 100644 index 0000000000..974fd75af4 --- /dev/null +++ b/deploy/oracle/auth/requirements.txt @@ -0,0 +1,3 @@ +flask==3.0.3 +requests==2.32.3 +gunicorn==22.0.0 diff --git a/deploy/oracle/docker-compose.yml b/deploy/oracle/docker-compose.yml index e48a6c7c8f..1e3ab988f3 100644 --- a/deploy/oracle/docker-compose.yml +++ b/deploy/oracle/docker-compose.yml @@ -28,15 +28,39 @@ services: environment: # Logviewer reads MONGO_URI; reuse the same database the bot writes to. - MONGO_URI=${CONNECTION_URI} - # No public port: Cloudflare Tunnel reaches the logviewer privately over the - # Docker network at http://logviewer:8000. To test locally without the - # tunnel, temporarily add: ports: ["8000:8000"] + # No public port: only the Caddy auth proxy talks to the logviewer, privately + # over the Docker network at http://logviewer:8000. + + # Discord OAuth2 forward-auth proxy. Verifies the visitor is a member of + # GUILD_ID holding REQUIRED_ROLE_ID before Caddy lets them reach the logviewer. + authproxy: + build: + context: ./auth + image: modmail-authproxy:latest + container_name: modmail-authproxy + restart: always + env_file: + - .env + depends_on: + - logviewer + + # Caddy sits in front of the logviewer and runs every request past the auth + # proxy (Discord role check) before serving it. This is what the tunnel points + # at. TLS is handled by Cloudflare, so Caddy serves plain HTTP on :8080. + caddy: + image: caddy:2-alpine + container_name: modmail-caddy + restart: always + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + depends_on: + - authproxy + - logviewer # Cloudflare Tunnel: makes an OUTBOUND connection to Cloudflare, so the # logviewer is published at your domain with automatic HTTPS and no inbound - # ports opened. Create the tunnel in the Cloudflare Zero Trust dashboard, - # route your hostname to the service http://logviewer:8000, and paste the - # tunnel token into TUNNEL_TOKEN in .env. See README.md. + # ports opened. In the Cloudflare dashboard, route your public hostname to + # the service http://caddy:8080 (the auth proxy edge, NOT the logviewer). cloudflared: image: cloudflare/cloudflared:latest container_name: modmail-cloudflared @@ -45,4 +69,4 @@ services: environment: - TUNNEL_TOKEN=${TUNNEL_TOKEN} depends_on: - - logviewer + - caddy From cbe285e6532b51a48db2e2bf2be31a9a12e24cca Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 01:45:16 +0000 Subject: [PATCH 06/10] Add logviewer UI mockup for design preview Self-contained HTML mockup of all logviewer pages (home, log view, raw, not found) with fake data and a page switcher, for previewing/redesigning the logviewer UI. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013vqQJQAud7rmfykwWKJACY --- logviewer-mockup.html | 247 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 247 insertions(+) create mode 100644 logviewer-mockup.html diff --git a/logviewer-mockup.html b/logviewer-mockup.html new file mode 100644 index 0000000000..d0d0ce1135 --- /dev/null +++ b/logviewer-mockup.html @@ -0,0 +1,247 @@ + + + + + +Logviewer — Mockup (fake data) + + + + + + +
+
+
+

Logviewer

+

This site is used to display Modmail log entries.

+ Discord + Github + Docs + Patreon +
+
+
+ + +
+ +
+
+ +
+

Thread with starlight_fox Closed

+
User ID: 482910337554841600
+
+
CreatedJun 18, 2026 · 2:14 PM
+
Closed byMod • aurora
+
Messages7
+
Close reasonResolved ✓
+
+
+
+ +
+ +
+ +
+
starlight_fox created a Modmail thread · Jun 18, 2026 · 2:14 PM
+ +
+ +
+
starlight_foxToday at 2:14 PM
+
hey, i think i was muted by mistake? i didn't post anything in #general 😕
+
+
+ +
+ +
+
auroraInternal note2:15 PM
+
Checking audit log — looks like an automod false positive on the link filter.
+
+
+ +
+ +
+
auroraStaffToday at 2:16 PM
+
Hi! Thanks for reaching out — you're right, that was an automod mistake. I've lifted the mute. Sorry about that!
+
+
+ +
+ +
+
starlight_foxToday at 2:17 PM
+
oh amazing, thank you!! here's the screenshot i mentioned:
+
+
+
+ +
+ +
+
AnonymousAnon replyToday at 2:18 PM
+
No problem at all. I've also whitelisted that domain so it won't happen again.
+ +
+
+ +
+ +
+
auroraInternal note2:19 PM
+
Domain added to allowlist. Closing as resolved.
+
+
+ +
Thread closed by aurora · Jun 18, 2026 · 2:20 PM · “Resolved ✓”
+
+
+
+ + +
+
+
+

Not found

+

The page you are looking for is not here.

+
+
+
+ + +
+
+
Thread with starlight_fox (482910337554841600)
+Created: 2026-06-18 14:14 UTC   |   Closed by: aurora   |   Messages: 7
+
+[14:14] starlight_fox: hey, i think i was muted by mistake?
+[14:15] (NOTE) aurora: automod false positive on link filter
+[14:16] aurora (staff): Hi! That was an automod mistake. Mute lifted.
+[14:17] starlight_fox: thank you!! [attachment: screenshot.png]
+[14:18] Anonymous (staff): Whitelisted the domain. [attachment: automod_config.txt]
+[14:19] (NOTE) aurora: Domain added to allowlist. Closing.
+[14:20] *** Thread closed by aurora — "Resolved" ***
+
+
+ + +
+

Logviewer pages

+

Mockup with fake data — click to preview each page

+ + + + +
+
MOCKUP — NOT REAL DATA
+ + + + From 8b697080a0c86ef925431a95e3ea271010246999 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 01:52:41 +0000 Subject: [PATCH 07/10] Restyle logviewer mockup in Apple dark-mode aesthetic Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013vqQJQAud7rmfykwWKJACY --- logviewer-mockup.html | 286 +++++++++++++++++++++++------------------- 1 file changed, 157 insertions(+), 129 deletions(-) diff --git a/logviewer-mockup.html b/logviewer-mockup.html index d0d0ce1135..524cf35722 100644 --- a/logviewer-mockup.html +++ b/logviewer-mockup.html @@ -3,138 +3,169 @@ -Logviewer — Mockup (fake data) - +Logviewer — Apple Dark Mockup (fake data) - +
-
+

Logviewer

This site is used to display Modmail log entries.

- Discord - Github - Docs - Patreon +
- +
- +
- -
-

Thread with starlight_fox Closed

-
User ID: 482910337554841600
+ +
+
+

starlight_fox Closed

+
User ID · 482910337554841600
+
CreatedJun 18, 2026 · 2:14 PM
-
Closed byMod • aurora
+
Closed byaurora
Messages7
-
Close reasonResolved ✓
+
ReasonResolved
-
- -
+
-
starlight_fox created a Modmail thread · Jun 18, 2026 · 2:14 PM
+
starlight_fox opened a Modmail thread · Jun 18, 2026 · 2:14 PM
-
+
starlight_foxToday at 2:14 PM
hey, i think i was muted by mistake? i didn't post anything in #general 😕
@@ -142,7 +173,7 @@

Thread with starlight_fox Closed

-
+
auroraInternal note2:15 PM
Checking audit log — looks like an automod false positive on the link filter.
@@ -150,57 +181,58 @@

Thread with starlight_fox Closed

-
-
auroraStaffToday at 2:16 PM
+
+
auroraStaffToday at 2:16 PM
Hi! Thanks for reaching out — you're right, that was an automod mistake. I've lifted the mute. Sorry about that!
-
+
starlight_foxToday at 2:17 PM
oh amazing, thank you!! here's the screenshot i mentioned:
-
+
-
-
AnonymousAnon replyToday at 2:18 PM
+
+
AnonymousAnonToday at 2:18 PM
No problem at all. I've also whitelisted that domain so it won't happen again.
- +
-
+
auroraInternal note2:19 PM
Domain added to allowlist. Closing as resolved.
-
Thread closed by aurora · Jun 18, 2026 · 2:20 PM · “Resolved ✓”
+
Thread closed by aurora · Jun 18, 2026 · 2:20 PM · “Resolved”
- +
-
+

Not found

The page you are looking for is not here.

+
- +
-
Thread with starlight_fox (482910337554841600)
+
Thread with starlight_fox (482910337554841600)
 Created: 2026-06-18 14:14 UTC   |   Closed by: aurora   |   Messages: 7
 
 [14:14] starlight_fox: hey, i think i was muted by mistake?
@@ -213,34 +245,30 @@ 

Not found

- +

Logviewer pages

-

Mockup with fake data — click to preview each page

- - - - +

Apple dark-mode mockup · fake data

+ + + +
-
MOCKUP — NOT REAL DATA
+
MOCKUP · NOT REAL DATA
From 0f9f458db16628f29a411f8b2ecbfec2eb21de52 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 01:59:58 +0000 Subject: [PATCH 08/10] Apply Apple dark-mode skin to the logviewer Add a custom logviewer build (deploy/oracle/logviewer) that layers an Apple dark-mode stylesheet (SF Pro, layered greys, frosted nav, system accent colors) onto the upstream image. The Dockerfile injects applemode.css as the last stylesheet so the upstream message-rendering templates are untouched. Point the compose logviewer service at the build. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013vqQJQAud7rmfykwWKJACY --- deploy/oracle/README.md | 12 +- deploy/oracle/docker-compose.yml | 5 +- deploy/oracle/logviewer/Dockerfile | 14 ++ .../oracle/logviewer/static/css/applemode.css | 185 ++++++++++++++++++ 4 files changed, 214 insertions(+), 2 deletions(-) create mode 100644 deploy/oracle/logviewer/Dockerfile create mode 100644 deploy/oracle/logviewer/static/css/applemode.css diff --git a/deploy/oracle/README.md b/deploy/oracle/README.md index 2c4abcb064..81cce9ce6e 100644 --- a/deploy/oracle/README.md +++ b/deploy/oracle/README.md @@ -8,9 +8,11 @@ inbound ports are exposed. What you get: - `bot` — built from this repo's `Dockerfile`, so your fork's changes are included and the image is native to the VM's architecture. -- `logviewer` — the web UI for closed-thread log links. +- `logviewer` — the web UI for closed-thread log links, built from + `./logviewer` with an **Apple dark-mode skin** layered on the upstream image. - `cloudflared` — a Cloudflare Tunnel that serves the logviewer at your domain over HTTPS, with no open ports on the VM. +- `authproxy` + `caddy` — Discord OAuth2 role gate in front of the logviewer. - No database container — the bot and logviewer both point at your current `CONNECTION_URI`. @@ -123,6 +125,14 @@ so two instances don't run against the same database at once. --- +## Customising the logviewer look + +The logviewer UI is restyled by `logviewer/static/css/applemode.css` (an Apple +dark-mode skin using SF Pro). `logviewer/Dockerfile` builds on the upstream +image and injects that stylesheet as the last `` in the templates, so the +bot's message-rendering logic is untouched — only the appearance changes. Edit +the CSS and re-run `docker compose up -d --build` to iterate on the design. + ## Updating later ```bash diff --git a/deploy/oracle/docker-compose.yml b/deploy/oracle/docker-compose.yml index 1e3ab988f3..cd24cfe5fc 100644 --- a/deploy/oracle/docker-compose.yml +++ b/deploy/oracle/docker-compose.yml @@ -20,7 +20,10 @@ services: - .env logviewer: - image: ghcr.io/modmail-dev/logviewer:master + # Custom image: upstream logviewer + Apple dark-mode skin (see ./logviewer). + build: + context: ./logviewer + image: modmail-logviewer-apple:latest container_name: modmail-logviewer restart: always env_file: diff --git a/deploy/oracle/logviewer/Dockerfile b/deploy/oracle/logviewer/Dockerfile new file mode 100644 index 0000000000..d84e65e39b --- /dev/null +++ b/deploy/oracle/logviewer/Dockerfile @@ -0,0 +1,14 @@ +# Custom logviewer image: upstream logviewer + an Apple dark-mode skin. +# +# We keep the upstream app and all of its message-rendering templates untouched, +# and only overlay one extra stylesheet (applemode.css) that overrides the look. +# The stylesheet is injected as the LAST in each template so it wins the +# CSS cascade — no template logic is modified. +FROM ghcr.io/modmail-dev/logviewer:master + +COPY static/css/applemode.css /logviewer/static/css/applemode.css + +# Append the Apple stylesheet right before in both page templates. +RUN sed -i 's## \n#' \ + /logviewer/templates/base.html \ + /logviewer/templates/logbase.html diff --git a/deploy/oracle/logviewer/static/css/applemode.css b/deploy/oracle/logviewer/static/css/applemode.css new file mode 100644 index 0000000000..54350b2b95 --- /dev/null +++ b/deploy/oracle/logviewer/static/css/applemode.css @@ -0,0 +1,185 @@ +/* ============================================================================ + Apple dark-mode skin for the Modmail logviewer. + Loaded last, so it overrides Materialize + the logviewer's own stylesheets. + Targets the existing template classes; no markup/logic is changed. + ========================================================================== */ + +/* SF Pro (real SF Pro on Apple devices via -apple-system; webfont elsewhere). */ +@import url('https://fonts.cdnfonts.com/css/sf-pro-display'); + +:root { + --bg: #000000; + --elev1: #1c1c1e; + --elev2: #2c2c2e; + --elev3: #3a3a3c; + --label: #ffffff; + --label2: rgba(235,235,245,.60); + --label3: rgba(235,235,245,.30); + --sep: rgba(84,84,88,.65); + --blue: #0A84FF; + --green: #30D158; + --orange: #FF9F0A; + --red: #FF453A; + --purple: #BF5AF2; + --font: 'SF Pro Display','SF Pro Text',-apple-system,BlinkMacSystemFont, + 'Inter','Helvetica Neue',system-ui,sans-serif; +} + +html, body { + background: var(--bg) !important; + color: var(--label) !important; + font-family: var(--font) !important; + letter-spacing: -.01em; +} +* { -webkit-font-smoothing: antialiased; } +::selection { background: rgba(10,132,255,.35); } +a { color: var(--blue); } + +/* ---------- Navbar (frosted glass) ---------- */ +.navbar-fixed nav, nav, nav.lighten-1 { + background: rgba(28,28,30,.72) !important; + -webkit-backdrop-filter: saturate(180%) blur(20px); + backdrop-filter: saturate(180%) blur(20px); + box-shadow: none !important; + border-bottom: .5px solid var(--sep); +} +nav .brand-logo i, #dash { color: var(--blue) !important; } + +/* ---------- Splash card (index + not_found) ---------- */ +.container .card-panel, #main-card { + background: var(--elev1) !important; + border: .5px solid var(--sep); + border-radius: 22px !important; + box-shadow: 0 20px 60px rgba(0,0,0,.6) !important; + padding: 56px 44px !important; +} +#dashbots, h1#dashbots, .header#dashbots { + font-family: var(--font) !important; + font-weight: 700 !important; + font-size: 52px !important; + letter-spacing: -.03em !important; + background: linear-gradient(180deg,#fff,#c7c7cc); + -webkit-background-clip: text; background-clip: text; + color: transparent !important; +} +.grey-text { color: var(--label2) !important; } + +.my-button { + background: var(--elev2) !important; + color: var(--label) !important; + border: .5px solid var(--sep); + border-radius: 12px !important; + box-shadow: none !important; + text-transform: none !important; + font-weight: 500; + letter-spacing: 0; + margin: 6px; + transition: background .15s, transform .1s; +} +.my-button:hover { background: var(--elev3) !important; } +.my-button:active { transform: scale(.97); } +.row.center a.my-button:first-child { /* lead button -> system blue */ + background: var(--blue) !important; + border-color: transparent; + color: #fff !important; +} + +/* ---------- Log page: metadata header ---------- */ +.entry { max-width: 860px; margin: 0 auto; padding: 0 16px 90px; } +.info { + background: var(--elev1); + border: .5px solid var(--sep); + border-radius: 18px; + padding: 22px 24px; + display: flex; gap: 18px; align-items: center; + margin: 18px 0; + box-shadow: 0 10px 30px rgba(0,0,0,.4); +} +.info__metadata { flex: 1; min-width: 0; } +.info__guild-icon { + width: 64px; height: 64px; border-radius: 50%; + border: .5px solid var(--sep); +} +.info__guild-name { font-size: 21px; font-weight: 600; color: var(--label); } +.info__channel-topic { color: var(--label2); font-size: 14px; margin-top: 4px; } +.info__channel-topic b { color: var(--label); font-weight: 600; } +.info__channel-message-count { + color: var(--label3); font-size: 12px; margin-top: 12px; + text-transform: uppercase; letter-spacing: .04em; font-weight: 600; +} + +/* ---------- Log page: chat ---------- */ +.chatlog { + background: var(--elev1); + border: .5px solid var(--sep); + border-radius: 18px; + overflow: hidden; + max-width: 860px; margin: 0 auto; + box-shadow: 0 10px 30px rgba(0,0,0,.4); +} +.chatlog__message-group { + display: flex; gap: 14px; + padding: 14px 20px; + margin: 0; + border-top: .5px solid var(--sep); + transition: background .12s; +} +.chatlog__message-group:first-child { border-top: none; } +.chatlog__message-group:hover, .active_hover:hover { background: rgba(255,255,255,.03); } +.perma_hover { background: rgba(10,132,255,.12) !important; } + +.chatlog__author-avatar { + width: 40px; height: 40px; border-radius: 50%; + border: .5px solid var(--sep); +} +.chatlog__messages { display: block; flex: 1; min-width: 0; } +.chatlog__author-name { font-weight: 600; font-size: 15px; color: var(--label); } +.chatlog__timestamp { color: var(--label3); font-size: 12px; margin-left: 8px; } +.chatlog__content { font-size: 15px; line-height: 1.46; color: var(--label); margin-top: 3px; } +.chatlog__edited-timestamp { color: var(--label3); font-size: 11px; margin-left: 6px; } + +/* role / type tags */ +.mod-tag, .internal-tag, .system-tag { + font-size: 10px; font-weight: 700; + padding: 2px 7px; border-radius: 6px; + text-transform: uppercase; letter-spacing: .03em; + margin-left: 8px; vertical-align: middle; +} +.mod-tag { background: rgba(10,132,255,.18); color: var(--blue); } +.internal-tag { background: rgba(255,159,10,.18); color: var(--orange); } +.system-tag { background: rgba(142,142,147,.22); color: var(--label2); } +.anonymous .mod-tag { background: rgba(191,90,242,.18); color: var(--purple); } + +/* internal notes get a subtle amber wash + coloured name */ +.internal.chatlog__message-group { background: rgba(255,159,10,.07); } +.internal .chatlog__author-name { color: var(--orange); } + +/* attachments */ +.chatlog__attachment { margin-top: 10px; } +.chatlog__attachment-thumbnail { + max-width: 300px; border-radius: 12px; border: .5px solid var(--sep); +} +.chatlog__attachment a { color: var(--blue); } + +/* code blocks */ +pre, .pre--multiline { + background: #0d0d10 !important; + border: .5px solid var(--sep); + border-radius: 12px; +} +.pre--inline { background: var(--elev2); border-radius: 6px; padding: 1px 5px; } + +/* ---------- Internal-messages toggle (Materialize switch -> iOS toggle) ---- */ +.switch label .lever { + background: var(--elev3); + width: 42px; height: 26px; border-radius: 20px; margin: 0 8px; +} +.switch label .lever:after { + width: 22px; height: 22px; top: 2px; left: 2px; + background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.4); +} +.switch label input[type=checkbox]:checked + .lever { background: var(--green); } +.switch label input[type=checkbox]:checked + .lever:after { left: 18px; background: #fff; } + +/* tooltips */ +.tooltiptext { background: var(--elev3) !important; color: var(--label) !important; border-radius: 8px; } From 336ff5c195bde63a4572f8a8e0b5d9f7ca8cb834 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 02:12:59 +0000 Subject: [PATCH 09/10] Add Pebble link-preview (Open Graph) branding to logviewer Replace the upstream per-thread og:* tags with a fixed Pebble card (title, description, self-hosted og:image) so shared log links show consistent branding instead of leaking recipient details. Rebrand the page title and theme color, and ship a placeholder og.png to swap out. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013vqQJQAud7rmfykwWKJACY --- deploy/oracle/README.md | 17 ++++++++++ deploy/oracle/logviewer/Dockerfile | 37 +++++++++++++++++----- deploy/oracle/logviewer/static/img/og.png | Bin 0 -> 3160 bytes 3 files changed, 46 insertions(+), 8 deletions(-) create mode 100644 deploy/oracle/logviewer/static/img/og.png diff --git a/deploy/oracle/README.md b/deploy/oracle/README.md index 81cce9ce6e..76dd3b1580 100644 --- a/deploy/oracle/README.md +++ b/deploy/oracle/README.md @@ -133,6 +133,23 @@ image and injects that stylesheet as the last `` in the templates, so the bot's message-rendering logic is untouched — only the appearance changes. Edit the CSS and re-run `docker compose up -d --build` to iterate on the design. +### Link-preview (Open Graph) branding + +The Dockerfile also replaces the per-thread Open Graph tags with a fixed Pebble +card (title, description, and image) so shared log links show consistent +branding instead of leaking recipient details. The preview image is served from +your own domain at `/static/img/og.png`. + +`logviewer/static/img/og.png` is currently a **plain placeholder**. Replace it +with the real image (keep the same path/filename, ideally ~1200×630 PNG/JPG), +then rebuild: + +```bash +docker compose up -d --build +``` + +Discord caches previews, so use Discord's link or repost to refresh the embed. + ## Updating later ```bash diff --git a/deploy/oracle/logviewer/Dockerfile b/deploy/oracle/logviewer/Dockerfile index d84e65e39b..94c7259a58 100644 --- a/deploy/oracle/logviewer/Dockerfile +++ b/deploy/oracle/logviewer/Dockerfile @@ -1,14 +1,35 @@ -# Custom logviewer image: upstream logviewer + an Apple dark-mode skin. +# Custom logviewer image: upstream logviewer + an Apple dark-mode skin and +# Pebble link-preview (Open Graph) branding. # # We keep the upstream app and all of its message-rendering templates untouched, -# and only overlay one extra stylesheet (applemode.css) that overrides the look. -# The stylesheet is injected as the LAST in each template so it wins the -# CSS cascade — no template logic is modified. +# and only overlay one extra stylesheet (applemode.css) plus replace the page +# metadata. No template logic is modified. FROM ghcr.io/modmail-dev/logviewer:master +# Apple dark-mode skin + the link-preview image (served at /static/img/og.png). COPY static/css/applemode.css /logviewer/static/css/applemode.css +COPY static/img/og.png /logviewer/static/img/og.png -# Append the Apple stylesheet right before in both page templates. -RUN sed -i 's## \n#' \ - /logviewer/templates/base.html \ - /logviewer/templates/logbase.html +# Inject the Apple stylesheet, set the Pebble link-preview (Open Graph) tags, +# and rebrand the page title. The og:* tags shown when a log link is shared +# (e.g. in Discord) become a fixed Pebble card instead of leaking recipient +# details — and point at our own self-hosted image so they never expire. +RUN set -eux; \ + for f in base.html logbase.html; do \ + t="/logviewer/templates/$f"; \ + # drop the upstream per-thread og:* tags and any existing theme-color + sed -i '/property=.og:/d; /name=.theme-color./d' "$t"; \ + # rebrand the browser/tab title + sed -i 's#[^<]*#Pebble#' "$t"; \ + # inject our stylesheet + Open Graph card just before + sed -i 's@@\ + \ + \ + \ + \ + \ + \ + \ + \ +@' "$t"; \ + done diff --git a/deploy/oracle/logviewer/static/img/og.png b/deploy/oracle/logviewer/static/img/og.png new file mode 100644 index 0000000000000000000000000000000000000000..0b44e896724a75a481d9ef1756c0ca1da595671d GIT binary patch literal 3160 zcmeAS@N?(olHy`uVBq!ia0y~yVA;UHz*NS;1Qa=-^oE;(fk)oc#WAGf*4yibyg=UJ z4NNPK$v-oAGf!3d#Plu9KxNJoL5c-{S`s)I85j&&fLsYDAi<*uB-#Xkgfj;NLxUm< z6_wG^DzL*1TD0ilEwn(QWeB2K1&+4109KmB7B;)!Nd&JX4bl(2lPcJM^vM;7poE7m z4FV8ncBWBYNB9aY*Hgb(!D$sJCpIWf0p<>RCm%FF)4WI+E$L`hec?@AG{~s*PO4!4 tjh1vYD0@dsIvV7i(UOkhlI}qoWA!Sphnl+YWq?&9gQu&X%Q~loCIFyjSHu7S literal 0 HcmV?d00001 From 3d21428d4c6cda2fe148470264ea8fba376be33f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 02:40:04 +0000 Subject: [PATCH 10/10] Disable telemetry/auto-updates and rebrand runtime to Pebble - Default data_collection off (stop POSTing instance metadata to api.modmail.dev/metadata), and disable auto-updates / update notifications so a private instance doesn't pull from upstream. - Rebrand the about command to Pebble while keeping attribution to the upstream Modmail project and its AGPL-3.0 source link. - Stop the sponsors command from fetching upstream SPONSORS.json. - Add a commercial-license outreach draft. Note: LICENSE (AGPL-3.0) and upstream copyright are intentionally kept intact; these changes are configuration/branding only. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013vqQJQAud7rmfykwWKJACY --- cogs/utility.py | 66 +++++--------------- core/config.py | 8 +-- deploy/oracle/COMMERCIAL_LICENSE_OUTREACH.md | 47 ++++++++++++++ 3 files changed, 66 insertions(+), 55 deletions(-) create mode 100644 deploy/oracle/COMMERCIAL_LICENSE_OUTREACH.md diff --git a/cogs/utility.py b/cogs/utility.py index c420ee7979..0cce5a803e 100644 --- a/cogs/utility.py +++ b/cogs/utility.py @@ -334,56 +334,29 @@ async def about(self, ctx): """Shows information about this bot.""" embed = discord.Embed(color=self.bot.main_color, timestamp=discord.utils.utcnow()) embed.set_author( - name="Modmail - About", + name="Pebble — About", icon_url=self.bot.user.display_avatar.url if self.bot.user.display_avatar else None, - url="https://discord.gg/F34cRU8", ) embed.set_thumbnail(url=self.bot.user.display_avatar.url if self.bot.user.display_avatar else None) - desc = "This is an open source Discord bot that serves as a means for " - desc += "members to easily communicate with server administrators in " - desc += "an organised manner." - embed.description = desc + embed.description = ( + "Pebble is Plover's internal support assistant — an organised shared " + "inbox that lets contributors handle customer support inquiries." + ) embed.add_field(name="Uptime", value=self.bot.uptime) embed.add_field(name="Latency", value=f"{self.bot.latency * 1000:.2f} ms") embed.add_field(name="Version", value=f"`{self.bot.version}`") - embed.add_field(name="Authors", value="`kyb3r`, `Taki`, `fourjr`") embed.add_field(name="Hosting Method", value=self.bot.hosting_method.name) - changelog = await Changelog.from_url(self.bot) - latest = changelog.latest_version - - if self.bot.version.is_prerelease: - stable = next(filter(lambda v: not Version(v.version).is_prerelease, changelog.versions)) - footer = f"You are on the prerelease version • the latest version is v{stable.version}." - elif self.bot.version < Version(latest.version): - footer = f"A newer version is available v{latest.version}." - else: - footer = "You are up to date with the latest version." - - embed.add_field( - name="Want Modmail in Your Server?", - value="Follow the installation guide on [GitHub](https://github.com/modmail-dev/modmail/) " - "and join our [Discord server](https://discord.gg/cnUpwrnpYb)!", - inline=False, - ) - - embed.add_field( - name="Support the Developers", - value="This bot is completely free for everyone. We rely on kind individuals " - "like you to support us on [`Buy Me A Coffee`](https://buymeacoffee.com/modmaildev) (perks included for memberships) " - "to keep this bot free forever!", - inline=False, - ) - embed.add_field( - name="Project Sponsors", - value=f"Checkout the people who supported Modmail with command `{self.bot.prefix}sponsors`!", + name="Built On", + value="Pebble is built on the open-source [Modmail](https://github.com/modmail-dev/modmail) " + "project by `kyb3r`, `Taki`, and `fourjr`, licensed under AGPL-3.0.", inline=False, ) - embed.set_footer(text=footer) + embed.set_footer(text="Pebble • Plover") await ctx.send(embed=embed) @commands.command(aliases=["sponsor"]) @@ -392,21 +365,12 @@ async def about(self, ctx): async def sponsors(self, ctx): """Shows the sponsors of this project.""" - async with self.bot.session.get( - "https://raw.githubusercontent.com/modmail-dev/modmail/master/SPONSORS.json" - ) as resp: - data = loads(await resp.text()) - - embeds = [] - - for elem in data: - embed = discord.Embed.from_dict(elem["embed"]) - embeds.append(embed) - - random.shuffle(embeds) - - session = EmbedPaginatorSession(ctx, *embeds) - await session.run() + embed = discord.Embed( + color=self.bot.main_color, + title="Sponsors", + description="This is a private Pebble instance operated by Plover.", + ) + await ctx.send(embed=embed) @commands.group(invoke_without_command=True) @checks.has_permissions(PermissionLevel.OWNER) diff --git a/core/config.py b/core/config.py index 0e45b00175..d59197634c 100644 --- a/core/config.py +++ b/core/config.py @@ -45,7 +45,7 @@ class ConfigManager: "mention_channel_id": None, "update_channel_id": None, # updates - "update_notifications": True, + "update_notifications": False, # threads "sent_emoji": "\N{WHITE HEAVY CHECK MARK}", "blocked_emoji": "\N{NO ENTRY SIGN}", @@ -213,15 +213,15 @@ class ConfigManager: "enable_eval": True, # github access token for private repositories "github_token": None, - "disable_autoupdates": False, - "disable_updates": False, + "disable_autoupdates": True, + "disable_updates": True, # Logging "log_level": "INFO", "stream_log_format": "plain", "file_log_format": "plain", "discord_log_level": "INFO", # data collection - "data_collection": True, + "data_collection": False, } colors = { diff --git a/deploy/oracle/COMMERCIAL_LICENSE_OUTREACH.md b/deploy/oracle/COMMERCIAL_LICENSE_OUTREACH.md new file mode 100644 index 0000000000..3dd0f3947f --- /dev/null +++ b/deploy/oracle/COMMERCIAL_LICENSE_OUTREACH.md @@ -0,0 +1,47 @@ +# Commercial license outreach — draft + +The Modmail bot and the logviewer are licensed under **AGPL-3.0** / **GPL-3.0**. +To use a modified version as closed/proprietary software (e.g. offered to +external customers without publishing source), Plover needs a **commercial +license** from the copyright holders, or must build a clean-room replacement. + +This file is a starting point for requesting a commercial/dual license. Send it +to the maintainers (their Discord / Buy Me a Coffee / GitHub). + +--- + +**Subject:** Commercial license inquiry for Modmail + Logviewer + +Hi, + +I'm reaching out from **Plover** (getplover.com). We operate a self-hosted, +customised deployment of Modmail and the Logviewer to handle customer support +for our product, and we'd like to do this properly with respect to the AGPL-3.0 +/ GPL-3.0 licensing. + +We're interested in a **commercial / dual license** that would let us run a +modified version internally (and potentially as part of a customer-facing +support workflow) **without the AGPL's network source-disclosure obligation**. + +Could you let us know: + +1. Whether a commercial license for Modmail (and the Logviewer) is available. +2. Pricing / terms (one-time, annual, per-instance, etc.). +3. What it covers — modifications, the §13 network clause, rebranding, and + whether the Logviewer's premium Discord-OAuth feature can be included. +4. Who holds copyright and can sign such an agreement. + +Happy to jump on a call. We want to support the project and stay compliant. + +Thanks, + — Plover + +--- + +## Notes / fallbacks if a commercial license isn't available + +- **Internal-use + published fork (AGPL-compliant):** keep the deployment + AGPL, publish our fork's source, and offer it to network users. Lowest cost, + but our modifications stay public. +- **Clean-room rebuild:** build a Plover-owned support bot from scratch + (our own code/copyright). Most effort; full ownership and no AGPL.