Compare commits
4 Commits
c7aa5a1c65
...
1721ee469d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1721ee469d | ||
|
|
e6002ad619 | ||
|
|
6d1d12953c | ||
|
|
50d7838de3 |
11
.dockerignore
Normal file
11
.dockerignore
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
.git
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
data
|
||||||
|
coverage
|
||||||
|
*.sqlite*
|
||||||
|
*.db*
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
# Loaded by bun start and bun run db:migrate:production.
|
# Loaded by bun start and bun run db:migrate:production.
|
||||||
# Set your public origin, e.g. https://habits.example.com
|
# Set your public origin, e.g. https://habits.example.com
|
||||||
APP_ORIGIN=
|
APP_ORIGIN=
|
||||||
|
# Enable only behind a private proxy that overwrites X-Forwarded-Proto.
|
||||||
|
TRUST_PROXY=false
|
||||||
|
# Docker sets HOST=0.0.0.0; local startup defaults to 127.0.0.1.
|
||||||
# Optional overrides: PORT, DATABASE_PATH, DISCORD_CLIENT_ID,
|
# Optional overrides: PORT, DATABASE_PATH, DISCORD_CLIENT_ID,
|
||||||
# DISCORD_CLIENT_SECRET, AUTH_COOKIE_SECRET.
|
# DISCORD_CLIENT_SECRET, AUTH_COOKIE_SECRET.
|
||||||
|
|||||||
4
Caddyfile
Normal file
4
Caddyfile
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
mina.teppelinlabs.com {
|
||||||
|
encode zstd gzip
|
||||||
|
reverse_proxy app:3000
|
||||||
|
}
|
||||||
26
Dockerfile
Normal file
26
Dockerfile
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
ARG BUN_IMAGE=oven/bun:1.3.14@sha256:e10577f0db68676a7024391c6e5cb4b879ebd17188ab750cf10024a6d700e5c4
|
||||||
|
FROM ${BUN_IMAGE} AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json bun.lock bunfig.toml ./
|
||||||
|
RUN bun install --frozen-lockfile
|
||||||
|
COPY . .
|
||||||
|
RUN bun run build
|
||||||
|
|
||||||
|
FROM ${BUN_IMAGE} AS dependencies
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json bun.lock bunfig.toml ./
|
||||||
|
RUN bun install --frozen-lockfile --production
|
||||||
|
|
||||||
|
FROM ${BUN_IMAGE} AS runtime
|
||||||
|
WORKDIR /app
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends fonts-dejavu-core ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY --from=dependencies /app/node_modules ./node_modules
|
||||||
|
COPY --from=build /app/dist ./dist
|
||||||
|
COPY --from=build /app/src ./src
|
||||||
|
COPY --from=build /app/scripts ./scripts
|
||||||
|
COPY --from=build /app/drizzle ./drizzle
|
||||||
|
COPY package.json bunfig.toml ./
|
||||||
|
ENV NODE_ENV=production HOST=0.0.0.0 PORT=3000 DATABASE_PATH=/data/minabot.sqlite
|
||||||
|
USER bun
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["sh", "-c", "bun scripts/migrate.ts && exec bun scripts/start.ts"]
|
||||||
4
build.ts
4
build.ts
@@ -27,4 +27,6 @@ for (const output of result.outputs) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await mkdir(path.join(outdir, "fonts"), { recursive: true });
|
await mkdir(path.join(outdir, "fonts"), { recursive: true });
|
||||||
await copyFile("src/assets/fonts/InstrumentSerif-Regular.ttf", path.join(outdir, "fonts/InstrumentSerif-Regular.ttf"));
|
for (const file of ["InstrumentSerif-Regular.ttf", "NotoSans-Regular.ttf", "NotoSans-Medium.ttf", "NotoSans-SemiBold.ttf"]) {
|
||||||
|
await copyFile(`src/assets/fonts/${file}`, path.join(outdir, "fonts", file));
|
||||||
|
}
|
||||||
|
|||||||
61
compose.yaml
Normal file
61
compose.yaml
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
name: minabot
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
image: ${APP_IMAGE:?Set APP_IMAGE to the tested release image}
|
||||||
|
env_file:
|
||||||
|
- path: .env.production
|
||||||
|
format: raw
|
||||||
|
volumes:
|
||||||
|
- /srv/minabot/data:/data
|
||||||
|
restart: unless-stopped
|
||||||
|
init: true
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
cap_drop: [ALL]
|
||||||
|
healthcheck:
|
||||||
|
test: [CMD, bun, -e, "fetch('http://127.0.0.1:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
start_period: 60s
|
||||||
|
retries: 3
|
||||||
|
logging:
|
||||||
|
driver: local
|
||||||
|
options:
|
||||||
|
max-size: 10m
|
||||||
|
max-file: "3"
|
||||||
|
caddy:
|
||||||
|
image: ${CADDY_IMAGE:?Set CADDY_IMAGE to a pinned image digest}
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
volumes:
|
||||||
|
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||||
|
- caddy_data:/data
|
||||||
|
- caddy_config:/config
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
app:
|
||||||
|
condition: service_healthy
|
||||||
|
logging:
|
||||||
|
driver: local
|
||||||
|
options:
|
||||||
|
max-size: 10m
|
||||||
|
max-file: "3"
|
||||||
|
monitor:
|
||||||
|
image: ${APP_IMAGE:?Set APP_IMAGE to the tested release image}
|
||||||
|
command: [bun, scripts/monitor.ts]
|
||||||
|
environment:
|
||||||
|
MONITOR_URL: https://mina.teppelinlabs.com/api/health
|
||||||
|
restart: unless-stopped
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
cap_drop: [ALL]
|
||||||
|
read_only: true
|
||||||
|
logging:
|
||||||
|
driver: local
|
||||||
|
options:
|
||||||
|
max-size: 10m
|
||||||
|
max-file: "3"
|
||||||
|
volumes:
|
||||||
|
caddy_data:
|
||||||
|
caddy_config:
|
||||||
2
deploy.env.example
Normal file
2
deploy.env.example
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
APP_IMAGE=minabot:RELEASE
|
||||||
|
CADDY_IMAGE=caddy@sha256:df7f1c2fb114453b951de51a98efc010db1655a92c2e86be6706714e2417a78d
|
||||||
109
docs/DEPLOYMENT-PLAN.md
Normal file
109
docs/DEPLOYMENT-PLAN.md
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
# Minabot deployment plan
|
||||||
|
|
||||||
|
Target: `https://mina.teppelinlabs.com` on `syntaxbullet@46.62.156.93`.
|
||||||
|
Prepared September 7, 2026. Executed the same day; see
|
||||||
|
[production operations](PRODUCTION.md) for the deployed configuration and results.
|
||||||
|
|
||||||
|
## Verified baseline
|
||||||
|
|
||||||
|
- Ubuntu 26.04.1, x86_64, 3.7 GiB RAM, approximately 34 GiB free disk.
|
||||||
|
- DNS A resolves to this server; no AAAA answer was returned.
|
||||||
|
- Docker and Caddy are not installed. Only SSH is publicly listening.
|
||||||
|
- SSH keys work, password authentication is disabled, UFW and Fail2ban are active.
|
||||||
|
- Passwordless sudo is installed in `/etc/sudoers.d/90-syntaxbullet` and verified
|
||||||
|
with `sudo -k` followed by `sudo -n id` in a fresh SSH connection.
|
||||||
|
- The checkout has uncommitted live Discord image changes and migrations 0009/0010.
|
||||||
|
Include and validate the intended complete release before tagging it.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Internet → Caddy container on TCP 80/443 → app container on port 3000 → SQLite.
|
||||||
|
|
||||||
|
Use Docker Compose with one app process and one Caddy container on a shared bridge
|
||||||
|
network. The app needs outbound access to Discord. Publish only Caddy's ports;
|
||||||
|
do not publish app port 3000 or mount the Docker socket into either container.
|
||||||
|
Keep reminders, backups, and live Discord updates in the existing app process.
|
||||||
|
Multiple app replicas are unsuitable for the current live-image worker.
|
||||||
|
|
||||||
|
Use `/opt/minabot` for deployment configuration and tagged releases,
|
||||||
|
`/srv/minabot/data` mounted as `/data` for SQLite and snapshots, and persistent
|
||||||
|
Docker volumes for Caddy `/data` and `/config`. Match data ownership to the app's
|
||||||
|
non-root container UID. Use `restart: unless-stopped`, bounded container logs,
|
||||||
|
a health check, and a sufficient startup grace period for migrations/backups.
|
||||||
|
An unhealthy health check alone does not restart a container; monitor it separately.
|
||||||
|
|
||||||
|
## Implementation sequence
|
||||||
|
|
||||||
|
1. Add `HOST` configuration to `src/index.ts`, retaining `127.0.0.1` locally and
|
||||||
|
using `HOST=0.0.0.0` inside Docker. The current hard-coded loopback binding
|
||||||
|
prevents Caddy from reaching the app across containers.
|
||||||
|
2. Add a multi-stage Dockerfile, `.dockerignore`, Compose file, Caddyfile, and
|
||||||
|
deployment environment example. Pin tested Bun/Caddy versions and image digests.
|
||||||
|
Build Linux amd64 dependencies in the image using the frozen lockfile; never
|
||||||
|
copy macOS `node_modules`. Exclude real dotenv files, databases, backups, and Git.
|
||||||
|
3. Preserve the existing startup contract: migrations must succeed before
|
||||||
|
`scripts/start.ts` loads `dist/index.js`. Include migrations and required script
|
||||||
|
imports, production dependencies, built assets, and the bundled Instrument Serif
|
||||||
|
font. Use a glibc-based Bun runtime with a sans-serif font installed, and verify
|
||||||
|
the native `@napi-rs/canvas` renderer inside that exact image.
|
||||||
|
4. Install Docker Engine and Compose from Docker's official Ubuntu repository.
|
||||||
|
Ubuntu 26.04 is supported. Keep Docker's firewall management enabled; published
|
||||||
|
ports can bypass UFW. Publish only 80/443 and verify exposure externally. Align
|
||||||
|
the existing Fail2ban nftables action with the chosen Docker firewall backend
|
||||||
|
(prefer an iptables-compatible SSH action for the standard Docker backend),
|
||||||
|
then recheck SSH protection. Open TCP 80/443 in UFW and any Hetzner firewall.
|
||||||
|
5. Put runtime secrets in `/opt/minabot/.env.production` with mode 0600 and inject
|
||||||
|
through Compose `env_file`. Set `NODE_ENV=production`, `HOST=0.0.0.0`, `PORT=3000`,
|
||||||
|
`APP_ORIGIN=https://mina.teppelinlabs.com`, and `DATABASE_PATH=/data/minabot.sqlite`.
|
||||||
|
Supply `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET`, a stable random
|
||||||
|
`AUTH_COOKIE_SECRET`, and the bot/channel settings. Do not bake secrets into
|
||||||
|
images or build arguments. Register this Discord OAuth redirect exactly:
|
||||||
|
`https://mina.teppelinlabs.com/api/auth/discord/callback`.
|
||||||
|
6. Default to a fresh production database. If existing local data should be moved,
|
||||||
|
use a verified SQLite snapshot, not a live database-file copy. Avoid running
|
||||||
|
two workers against copied live Discord message state. Resolve the data choice
|
||||||
|
before first production startup.
|
||||||
|
7. Build and tag the release, start it on the private Docker network, verify its
|
||||||
|
health, then start Caddy. Caddy obtains and renews TLS certificates and redirects
|
||||||
|
HTTP to HTTPS. Persist its certificate storage across container replacement.
|
||||||
|
|
||||||
|
Proposed Caddyfile:
|
||||||
|
|
||||||
|
```caddyfile
|
||||||
|
mina.teppelinlabs.com {
|
||||||
|
encode zstd gzip
|
||||||
|
reverse_proxy app:3000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification and operations
|
||||||
|
|
||||||
|
- Before release: typecheck, tests, build, and disposable production API smoke test.
|
||||||
|
Validate Compose and Caddy configuration; run migrations, health, static assets,
|
||||||
|
native PNG rendering, and a restart/persistence test inside the Linux image.
|
||||||
|
- After launch: verify public HTTPS, HTTP redirect, `/api/health`, Discord sign-in,
|
||||||
|
secure cookies, habit persistence, and port 3000 being unreachable externally.
|
||||||
|
Exercise bot workers with stubbed transports; a live Discord post or DM needs
|
||||||
|
an explicitly selected test destination and authorization to send it.
|
||||||
|
- Keep daily verified snapshots and seven retained copies using the existing app
|
||||||
|
backup system. Configure an off-server destination separately; none is provisioned
|
||||||
|
yet. Test restore to a new database with the matching image before relying on it.
|
||||||
|
- Configure an independent HTTPS monitor and alert destination. Existing monitor
|
||||||
|
scripts provide checks but do not provision an external monitoring service.
|
||||||
|
- Updates: build/test the next immutable image, snapshot the database, stop the
|
||||||
|
old app, and start the new release. Accept brief downtime; do not overlap workers.
|
||||||
|
Keep the previous image. If schema compatibility prevents image-only rollback,
|
||||||
|
stop the app and restore its matching pre-migration snapshot to a new file.
|
||||||
|
The restore tool revokes sessions and disables reminders; audit restored live
|
||||||
|
Discord state before starting workers. Never use `docker compose down -v`.
|
||||||
|
|
||||||
|
Remaining launch inputs: fresh database versus migration, Discord redirect setup
|
||||||
|
and production credentials, off-server backup destination, and monitoring alerts.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Docker Ubuntu installation](https://docs.docker.com/engine/install/ubuntu/)
|
||||||
|
- [Docker firewall behavior](https://docs.docker.com/engine/network/packet-filtering-firewalls/)
|
||||||
|
- [Bun Docker guide](https://bun.sh/guides/ecosystem/docker)
|
||||||
|
- [Caddy automatic HTTPS](https://caddyserver.com/docs/automatic-https)
|
||||||
|
- [Existing backup and recovery workflow](OPERATIONS.md)
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
# Deployment operations
|
# Deployment operations
|
||||||
|
|
||||||
|
See [production deployment](PRODUCTION.md) for the Docker/Caddy server, release,
|
||||||
|
data paths, verification results, and current backup/monitoring limitations.
|
||||||
|
|
||||||
## Backups and recovery
|
## Backups and recovery
|
||||||
|
|
||||||
Production creates a verified SQLite snapshot before migrations and automatically
|
Production creates a verified SQLite snapshot before migrations and automatically
|
||||||
|
|||||||
90
docs/PRODUCTION.md
Normal file
90
docs/PRODUCTION.md
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
# Minabot production
|
||||||
|
|
||||||
|
Live: https://mina.teppelinlabs.com
|
||||||
|
|
||||||
|
SSH: `ssh syntaxbullet@46.62.156.93` (key authentication; passwordless sudo).
|
||||||
|
Deployment root: `/opt/minabot`. Compose manages `app`, `caddy`, and `monitor`.
|
||||||
|
Docker starts at boot; containers use `restart: unless-stopped`.
|
||||||
|
|
||||||
|
## Release and configuration
|
||||||
|
|
||||||
|
Current image: `minabot:20260907-c7aa5a1-2`, built on the Linux amd64 server from
|
||||||
|
the local checkout based on `c7aa5a1` plus the Docker and trusted-proxy changes.
|
||||||
|
Its immutable image ID is recorded by `docker image inspect`; source is retained
|
||||||
|
under `/opt/minabot/releases/20260907-c7aa5a1-2`, with `source-v2.tar.gz` and build
|
||||||
|
logs in `/opt/minabot`. Bun 1.3.14 and Caddy images are pinned by digest.
|
||||||
|
|
||||||
|
`/opt/minabot/.env` selects release images. `/opt/minabot/.env.production` contains
|
||||||
|
runtime secrets (0600). Discord client and bot credentials were copied from the
|
||||||
|
effective development environment; production has a separate generated cookie
|
||||||
|
signing secret. Runtime uses `APP_ORIGIN=https://mina.teppelinlabs.com`,
|
||||||
|
`TRUST_PROXY=true`, `HOST=0.0.0.0`, and `DATABASE_PATH=/data/minabot.sqlite`.
|
||||||
|
|
||||||
|
`TRUST_PROXY` only accepts HTTPS from `X-Forwarded-Proto`, retains the actual
|
||||||
|
request Host, and must only be enabled behind the private Caddy proxy. Port 3000
|
||||||
|
is not published. Caddy exposes TCP 80/443 and persists certificate state in
|
||||||
|
`minabot_caddy_data` and `minabot_caddy_config`. SSH remains on 22. Fail2ban uses
|
||||||
|
the iptables-compatible SSH action alongside Docker/UFW.
|
||||||
|
|
||||||
|
The registered Discord callback is
|
||||||
|
`https://mina.teppelinlabs.com/api/auth/discord/callback`; localhost remains registered.
|
||||||
|
|
||||||
|
## Data and backups
|
||||||
|
|
||||||
|
`/srv/minabot/data` is bind-mounted at `/data`, owned by UID 1000. A verified
|
||||||
|
`VACUUM INTO` snapshot copied the development database, including its one account,
|
||||||
|
four habits, history, reminders, and live Discord state. The transfer hash and
|
||||||
|
counts are recorded in `/opt/minabot/migration-manifest.json`. Local development
|
||||||
|
was stopped before the snapshot so only production manages the existing live cards.
|
||||||
|
Development now blocks channel writes at both snapshot-send and live-card entry
|
||||||
|
points. It also skips copied live-card recovery and change scheduling. Preview and
|
||||||
|
download remain available. Stubbed tests inject their own transport/configuration;
|
||||||
|
real writes through the environment loader are enabled only in production.
|
||||||
|
|
||||||
|
Daily verified snapshots and pre-migration snapshots are stored under
|
||||||
|
`/srv/minabot/data/backups`, retaining seven snapshots. The restore rehearsal
|
||||||
|
passed using a separate file; production data was not replaced by the rehearsal.
|
||||||
|
Restore revokes sessions and disables reminder opt-ins; see [OPERATIONS.md](OPERATIONS.md).
|
||||||
|
|
||||||
|
Off-server backup replication and delivered uptime alerts are **not configured**:
|
||||||
|
the operator has no destinations set up yet. The monitor checks public HTTPS every
|
||||||
|
minute in a separate container on this server and writes failure/recovery events
|
||||||
|
to its logs. It cannot report a complete server outage to an external destination.
|
||||||
|
|
||||||
|
## Common commands
|
||||||
|
|
||||||
|
Run after SSH login:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd /opt/minabot
|
||||||
|
sudo docker compose ps
|
||||||
|
sudo docker compose logs --tail=100 app caddy monitor
|
||||||
|
sudo docker compose exec -T app bun scripts/backup.ts
|
||||||
|
sudo docker compose exec -T monitor bun scripts/monitor.ts --once
|
||||||
|
```
|
||||||
|
|
||||||
|
When sending several shell commands over SSH stdin, redirect noninteractive
|
||||||
|
`docker compose exec` calls from `/dev/null` so they do not consume later commands.
|
||||||
|
|
||||||
|
For updates, build a new release tag, run the container smoke test, take a backup,
|
||||||
|
update `APP_IMAGE` in `.env`, stop the old app, then run
|
||||||
|
`sudo docker compose up -d --wait --wait-timeout 120`. Never run overlapping app
|
||||||
|
workers or `docker compose down -v`. Keep the last known-good image and a matching
|
||||||
|
pre-migration snapshot. The initial `-1` image predates the proxy fix and is not
|
||||||
|
a suitable rollback target for working production sign-in.
|
||||||
|
|
||||||
|
## Verified September 7, 2026
|
||||||
|
|
||||||
|
- Typecheck, build, 195 tests, and production HTTP smoke tests passed.
|
||||||
|
- The Linux runtime passed migrations, native 1920×1080 PNG rendering, and
|
||||||
|
persistence across a process restart using a disposable database.
|
||||||
|
- Public HTTPS health returns `{"status":"ok"}`; HTTP redirects to HTTPS.
|
||||||
|
- Browser Discord sign-in completed and displayed the migrated account and habits.
|
||||||
|
- Read-only authenticated production API/PNG checks passed; unauthenticated access
|
||||||
|
and cross-origin writes were rejected. Temporary verification sessions were removed.
|
||||||
|
- Snapshot integrity, restore rehearsal, and transferred database hash passed.
|
||||||
|
- Application port 3000 is unpublished and an external HTTP probe timed out.
|
||||||
|
- Docker and Fail2ban are active; runtime secrets and the SQLite file are mode 0600.
|
||||||
|
|
||||||
|
Live Discord test posts/DMs were not sent. Existing configured workers continue
|
||||||
|
their normal production behavior.
|
||||||
93
src/assets/fonts/InstrumentSerif-OFL.txt
Normal file
93
src/assets/fonts/InstrumentSerif-OFL.txt
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2022 The Instrument Serif Project Authors (https://github.com/Instrument/instrument-serif)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
https://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
94
src/assets/fonts/NotoSans-LICENSE.txt
Normal file
94
src/assets/fonts/NotoSans-LICENSE.txt
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
Copyright 2018 The Noto Project Authors (github.com/googlei18n/noto-fonts)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License,
|
||||||
|
Version 1.1.
|
||||||
|
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font
|
||||||
|
creation efforts of academic and linguistic communities, and to
|
||||||
|
provide a free and open framework in which fonts may be shared and
|
||||||
|
improved in partnership with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply to
|
||||||
|
any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software
|
||||||
|
components as distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to,
|
||||||
|
deleting, or substituting -- in part or in whole -- any of the
|
||||||
|
components of the Original Version, by changing formats or by porting
|
||||||
|
the Font Software to a new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed,
|
||||||
|
modify, redistribute, and sell modified and unmodified copies of the
|
||||||
|
Font Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components, in
|
||||||
|
Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the
|
||||||
|
corresponding Copyright Holder. This restriction only applies to the
|
||||||
|
primary font name as presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created using
|
||||||
|
the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
BIN
src/assets/fonts/NotoSans-Medium.ttf
Normal file
BIN
src/assets/fonts/NotoSans-Medium.ttf
Normal file
Binary file not shown.
BIN
src/assets/fonts/NotoSans-Regular.ttf
Normal file
BIN
src/assets/fonts/NotoSans-Regular.ttf
Normal file
Binary file not shown.
BIN
src/assets/fonts/NotoSans-SemiBold.ttf
Normal file
BIN
src/assets/fonts/NotoSans-SemiBold.ttf
Normal file
Binary file not shown.
14
src/assets/fonts/README.md
Normal file
14
src/assets/fonts/README.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# Bundled share-card fonts
|
||||||
|
|
||||||
|
Instrument Serif Regular/Italic: downloaded from the
|
||||||
|
[Google Fonts source](https://github.com/google/fonts/tree/main/ofl/instrumentserif).
|
||||||
|
License: `InstrumentSerif-OFL.txt` (also retained in the original `OFL.txt`).
|
||||||
|
|
||||||
|
Noto Sans Regular (400), Medium (500), and SemiBold (600): downloaded from the
|
||||||
|
[Noto source](https://github.com/notofonts/noto-fonts/tree/main/hinted/ttf/NotoSans).
|
||||||
|
License: `NotoSans-LICENSE.txt`.
|
||||||
|
|
||||||
|
Downloaded September 7, 2026. The files are versioned in this repository and copied
|
||||||
|
into the production image. Browser and native canvas renderers use the alias
|
||||||
|
`Mina Sans`; no system sans-serif font is selected for card text. The native font
|
||||||
|
loader fails startup if a required font cannot be registered.
|
||||||
@@ -55,6 +55,32 @@ beforeEach(() => {
|
|||||||
afterEach(() => sqlite.close());
|
afterEach(() => sqlite.close());
|
||||||
|
|
||||||
describe("Discord sign-in and sessions", () => {
|
describe("Discord sign-in and sessions", () => {
|
||||||
|
test("trusted TLS proxy supports sign-in and secure callback cookies", async () => {
|
||||||
|
const publicOrigin = "https://habits.example";
|
||||||
|
const proxyApp = createApi(db, { ...config, origin: publicOrigin, trustProxy: true }, request, () => timestamp);
|
||||||
|
const response = await proxyApp.request("http://habits.example/api/auth/discord", { headers: { "X-Forwarded-Proto": "https" } });
|
||||||
|
const location = new URL(response.headers.get("Location")!);
|
||||||
|
expect(location.origin).toBe("https://discord.com");
|
||||||
|
expect(location.searchParams.get("redirect_uri")).toBe(`${publicOrigin}/api/auth/discord/callback`);
|
||||||
|
expect(response.headers.get("Set-Cookie")).toContain("Secure");
|
||||||
|
const callback = await proxyApp.request(`http://habits.example/api/auth/discord/callback?state=${location.searchParams.get("state")}&code=test-code`, {
|
||||||
|
headers: { "X-Forwarded-Proto": "https", Cookie: cookie(response, "__Host-minabot_oauth") },
|
||||||
|
});
|
||||||
|
expect(cookie(callback, "__Host-minabot_session")).not.toBe("");
|
||||||
|
expect(callback.headers.get("Set-Cookie")).toContain("Secure");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("forwarded protocol is opt-in and cannot override the actual hostname", async () => {
|
||||||
|
for (const [trustProxy, host] of [[false, "habits.example"], [true, "wrong.example"]] as const) {
|
||||||
|
const proxyApp = createApi(db, { ...config, origin: "https://habits.example", trustProxy }, request, () => timestamp);
|
||||||
|
const response = await proxyApp.request(`http://${host}/api/auth/discord`, {
|
||||||
|
headers: { "X-Forwarded-Proto": "https", "X-Forwarded-Host": "habits.example" },
|
||||||
|
});
|
||||||
|
expect(new URL(response.headers.get("Location")!).origin).toBe("https://habits.example");
|
||||||
|
expect(response.headers.get("Set-Cookie")).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("me requires a valid session and health stays public", async () => {
|
test("me requires a valid session and health stays public", async () => {
|
||||||
for (const value of ["", "minabot_session=malformed", `minabot_session=${"a".repeat(43)}`]) {
|
for (const value of ["", "minabot_session=malformed", `minabot_session=${"a".repeat(43)}`]) {
|
||||||
const response = await app.request(`${origin}/api/me`, { headers: { Cookie: value } });
|
const response = await app.request(`${origin}/api/me`, { headers: { Cookie: value } });
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export type AuthConfig = {
|
|||||||
clientId: string;
|
clientId: string;
|
||||||
clientSecret: string;
|
clientSecret: string;
|
||||||
cookieSecret: string;
|
cookieSecret: string;
|
||||||
|
trustProxy?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function readAuthConfig(env = process.env): AuthConfig {
|
export function readAuthConfig(env = process.env): AuthConfig {
|
||||||
@@ -28,6 +29,7 @@ export function readAuthConfig(env = process.env): AuthConfig {
|
|||||||
clientId: env.DISCORD_CLIENT_ID ?? "",
|
clientId: env.DISCORD_CLIENT_ID ?? "",
|
||||||
clientSecret: env.DISCORD_CLIENT_SECRET ?? "",
|
clientSecret: env.DISCORD_CLIENT_SECRET ?? "",
|
||||||
cookieSecret: env.AUTH_COOKIE_SECRET ?? "",
|
cookieSecret: env.AUTH_COOKIE_SECRET ?? "",
|
||||||
|
trustProxy: env.TRUST_PROXY === "true",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -75,7 +75,11 @@ export function createAuth(db: AppDatabase, config: AuthConfig, request: FetchDi
|
|||||||
routes.get("/discord", async c => {
|
routes.get("/discord", async c => {
|
||||||
if (!isAuthConfigured(config)) return c.redirect("/?auth_error=not_configured");
|
if (!isAuthConfigured(config)) return c.redirect("/?auth_error=not_configured");
|
||||||
// Use one configured origin for redirects and cookie ownership.
|
// Use one configured origin for redirects and cookie ownership.
|
||||||
if (new URL(c.req.url).origin !== config.origin) return c.redirect(`${config.origin}/api/auth/discord?${new URLSearchParams({ timezone: normalizeTimezone(c.req.query("timezone")) })}`);
|
const requestUrl = new URL(c.req.url);
|
||||||
|
// Enable only behind a private proxy that overwrites X-Forwarded-Proto.
|
||||||
|
// Keep the actual Host; never trust a client-supplied forwarded hostname.
|
||||||
|
if (config.trustProxy && c.req.header("X-Forwarded-Proto") === "https") requestUrl.protocol = "https:";
|
||||||
|
if (requestUrl.origin !== config.origin) return c.redirect(`${config.origin}/api/auth/discord?${new URLSearchParams({ timezone: normalizeTimezone(c.req.query("timezone")) })}`);
|
||||||
const state = randomToken();
|
const state = randomToken();
|
||||||
await setSignedCookie(c, stateName, JSON.stringify({
|
await setSignedCookie(c, stateName, JSON.stringify({
|
||||||
state,
|
state,
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const app = createApi(db, readAuthConfig(), undefined, undefined, undefined, rea
|
|||||||
if (import.meta.hot) import.meta.hot.dispose(() => app.stopLiveSharing());
|
if (import.meta.hot) import.meta.hot.dispose(() => app.stopLiveSharing());
|
||||||
|
|
||||||
const server = Bun.serve({
|
const server = Bun.serve({
|
||||||
hostname: "127.0.0.1",
|
hostname: process.env.HOST ?? "127.0.0.1",
|
||||||
port: Number(process.env.PORT ?? 3000),
|
port: Number(process.env.PORT ?? 3000),
|
||||||
routes: {
|
routes: {
|
||||||
"/api": req => app.fetch(req),
|
"/api": req => app.fetch(req),
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ async function loadAvatar(url: string): Promise<HTMLImageElement | null> {
|
|||||||
|
|
||||||
/** Compact summary: fixed typography stays readable when Discord scales the attachment. */
|
/** Compact summary: fixed typography stays readable when Discord scales the attachment. */
|
||||||
export async function renderProgressCard(data: ShareData, user: PublicUser, privacy: CardPrivacy, theme: Theme = "light"): Promise<{ blob: Blob; alt: string; avatarMissing: boolean }> {
|
export async function renderProgressCard(data: ShareData, user: PublicUser, privacy: CardPrivacy, theme: Theme = "light"): Promise<{ blob: Blob; alt: string; avatarMissing: boolean }> {
|
||||||
await document.fonts.load('64px "Instrument Serif"');
|
await Promise.all(['400 64px "Instrument Serif"', '400 32px "Mina Sans"', '500 32px "Mina Sans"', '600 32px "Mina Sans"'].map(font => document.fonts.load(font)));
|
||||||
const avatar = privacy.avatar && user.avatarUrl ? await loadAvatar(user.avatarUrl) : null;
|
const avatar = privacy.avatar && user.avatarUrl ? await loadAvatar(user.avatarUrl) : null;
|
||||||
const canvas = document.createElement("canvas");
|
const canvas = document.createElement("canvas");
|
||||||
const shortRange = dayNumber(data.to) - dayNumber(data.from) < 84;
|
const shortRange = dayNumber(data.to) - dayNumber(data.from) < 84;
|
||||||
@@ -52,7 +52,7 @@ export async function renderProgressCard(data: ShareData, user: PublicUser, priv
|
|||||||
ctx.scale(1.25, 1.25);
|
ctx.scale(1.25, 1.25);
|
||||||
const { ink, muted, paper, empty, rule, avatar: avatarBackground, dot } = themePalette[theme];
|
const { ink, muted, paper, empty, rule, avatar: avatarBackground, dot } = themePalette[theme];
|
||||||
const green = "#285d49";
|
const green = "#285d49";
|
||||||
const sans = '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
|
const sans = '"Mina Sans"';
|
||||||
const text = (value: string, x: number, y: number, size = 32, color: string = ink, weight = "400", maxWidth = 864, align: CanvasTextAlign = "left", serif = false) => {
|
const text = (value: string, x: number, y: number, size = 32, color: string = ink, weight = "400", maxWidth = 864, align: CanvasTextAlign = "left", serif = false) => {
|
||||||
ctx.font = `${weight} ${size}px ${serif ? '"Instrument Serif", Georgia, serif' : sans}`;
|
ctx.font = `${weight} ${size}px ${serif ? '"Instrument Serif", Georgia, serif' : sans}`;
|
||||||
ctx.fillStyle = color; ctx.textAlign = align;
|
ctx.fillStyle = color; ctx.textAlign = align;
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
export type DiscordSharingConfig = { token: string; channelId: string; allowedUserIds?: string[] };
|
export type DiscordSharingConfig = { token: string; channelId: string; allowedUserIds?: string[]; writesEnabled?: boolean };
|
||||||
|
|
||||||
/** Read only from the server entrypoint; never include credentials in public config. */
|
/** Read only from the server entrypoint; never include credentials in public config. */
|
||||||
export function readDiscordSharingConfig(env = process.env): DiscordSharingConfig {
|
export function readDiscordSharingConfig(env = process.env): DiscordSharingConfig {
|
||||||
const allowedUserIds = (env.DISCORD_SHARING_ALLOWED_USER_IDS ?? "").split(",").map(id => id.trim()).filter(Boolean);
|
const allowedUserIds = (env.DISCORD_SHARING_ALLOWED_USER_IDS ?? "").split(",").map(id => id.trim()).filter(Boolean);
|
||||||
if (allowedUserIds.some(id => !/^\d{17,20}$/.test(id))) throw new Error("DISCORD_SHARING_ALLOWED_USER_IDS must contain comma-separated Discord user IDs");
|
if (allowedUserIds.some(id => !/^\d{17,20}$/.test(id))) throw new Error("DISCORD_SHARING_ALLOWED_USER_IDS must contain comma-separated Discord user IDs");
|
||||||
return {
|
return {
|
||||||
|
// Real channel writes are production-only. Test fixtures may inject a stub config.
|
||||||
|
writesEnabled: env.NODE_ENV === "production",
|
||||||
allowedUserIds,
|
allowedUserIds,
|
||||||
token: (env.DISCORD_BOT_TOKEN ?? "").trim().replace(/^Bot\s+/i, ""),
|
token: (env.DISCORD_BOT_TOKEN ?? "").trim().replace(/^Bot\s+/i, ""),
|
||||||
channelId: (env.DISCORD_SHARING_CHANNEL_ID ?? "").trim(),
|
channelId: (env.DISCORD_SHARING_CHANNEL_ID ?? "").trim(),
|
||||||
|
|||||||
36
src/sharing/development-writes.test.ts
Normal file
36
src/sharing/development-writes.test.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { expect, test } from 'bun:test';
|
||||||
|
import { fixture } from '../habits/test-fixture';
|
||||||
|
import { createApi } from '../api';
|
||||||
|
import { liveDiscordCards } from '../db/schema';
|
||||||
|
import { readDiscordSharingConfig } from './config';
|
||||||
|
|
||||||
|
test('real Discord channel writes are enabled only in production', () => {
|
||||||
|
for (const NODE_ENV of ['development', 'test', undefined]) {
|
||||||
|
expect(readDiscordSharingConfig({ NODE_ENV, DISCORD_BOT_TOKEN: 'token', DISCORD_SHARING_CHANNEL_ID: '223456789012345678' }).writesEnabled).toBe(false);
|
||||||
|
}
|
||||||
|
expect(readDiscordSharingConfig({ NODE_ENV: 'production' }).writesEnabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('development preserves copied live state, allows PNG previews, and blocks all channel sends', async () => {
|
||||||
|
const f = fixture();
|
||||||
|
f.db.insert(liveDiscordCards).values({ userId: 'alice', channelId: '223456789012345678', messageId: '423456789012345678', nonce: 'copied-nonce', theme: 'light', status: 'active', dirty: true, updatedAt: 1 }).run();
|
||||||
|
const before = f.db.select().from(liveDiscordCards).all();
|
||||||
|
const requests: string[] = [];
|
||||||
|
const app = createApi(f.db, { origin: f.origin, clientId: '', clientSecret: '', cookieSecret: 'test-only-signing-secret-at-least-32-characters' }, undefined, () => Date.parse('2026-09-04T12:00:00Z'), async url => {
|
||||||
|
requests.push(url); throw new Error('Development contacted Discord');
|
||||||
|
}, readDiscordSharingConfig({ NODE_ENV: 'development', DISCORD_BOT_TOKEN: 'real-looking-token', DISCORD_SHARING_CHANNEL_ID: '223456789012345678' }));
|
||||||
|
const headers = { Cookie: `minabot_session=${'a'.repeat(43)}`, Origin: f.origin, 'Content-Type': 'application/json' };
|
||||||
|
try {
|
||||||
|
for (const path of ['/sharing/live', '/sharing/discord/send']) {
|
||||||
|
const response = await app.request(`${f.origin}/api${path}`, { method: 'POST', headers, body: JSON.stringify({ theme: 'light' }) });
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
}
|
||||||
|
await app.flushLiveSharing('alice');
|
||||||
|
const preview = await app.request(`${f.origin}/api/sharing/live/preview`, { headers });
|
||||||
|
expect(preview.status).toBe(200);
|
||||||
|
expect(preview.headers.get('Content-Type')).toBe('image/png');
|
||||||
|
await Bun.sleep(2100);
|
||||||
|
expect(requests).toEqual([]);
|
||||||
|
expect(f.db.select().from(liveDiscordCards).all()).toEqual(before);
|
||||||
|
} finally { app.stopLiveSharing(); f.close(); }
|
||||||
|
});
|
||||||
@@ -10,9 +10,18 @@ import { cardDate } from "../lib/progress-card";
|
|||||||
import { calendarTimeline } from "../components/design-system/calendar-model";
|
import { calendarTimeline } from "../components/design-system/calendar-model";
|
||||||
import { themedHabitShade } from "../lib/habit-palette";
|
import { themedHabitShade } from "../lib/habit-palette";
|
||||||
|
|
||||||
const sourceFont = new URL('../assets/fonts/InstrumentSerif-Regular.ttf', import.meta.url);
|
for (const [file, family] of [
|
||||||
const builtFont = new URL('./fonts/InstrumentSerif-Regular.ttf', import.meta.url);
|
['InstrumentSerif-Regular.ttf', 'Instrument Serif'],
|
||||||
GlobalFonts.registerFromPath((existsSync(sourceFont) ? sourceFont : builtFont).pathname, 'Instrument Serif');
|
['NotoSans-Regular.ttf', 'Mina Sans'],
|
||||||
|
['NotoSans-Medium.ttf', 'Mina Sans'],
|
||||||
|
['NotoSans-SemiBold.ttf', 'Mina Sans'],
|
||||||
|
]) {
|
||||||
|
const sourceFont = new URL(`../assets/fonts/${file}`, import.meta.url);
|
||||||
|
const builtFont = new URL(`./fonts/${file}`, import.meta.url);
|
||||||
|
if (!GlobalFonts.registerFromPath((existsSync(sourceFont) ? sourceFont : builtFont).pathname, family)) {
|
||||||
|
throw new Error(`Could not load bundled font: ${file}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function liveCardData(db: AppDatabase, userId: string, now: number) {
|
export function liveCardData(db: AppDatabase, userId: string, now: number) {
|
||||||
const user = db.select().from(users).where(eq(users.id, userId)).get();
|
const user = db.select().from(users).where(eq(users.id, userId)).get();
|
||||||
@@ -54,7 +63,7 @@ export async function renderLiveCard(data: LiveImageData, theme: Theme) {
|
|||||||
ctx.fillStyle = color; ctx.beginPath(); ctx.roundRect(x, y, Math.max(0, w), Math.max(0, h), radius); ctx.fill();
|
ctx.fillStyle = color; ctx.beginPath(); ctx.roundRect(x, y, Math.max(0, w), Math.max(0, h), radius); ctx.fill();
|
||||||
};
|
};
|
||||||
const text = (value: string, x: number, y: number, size: number, width: number, color: string = colors.ink, serif = false, weight = '400') => {
|
const text = (value: string, x: number, y: number, size: number, width: number, color: string = colors.ink, serif = false, weight = '400') => {
|
||||||
ctx.font = `${weight} ${size}px ${serif ? '"Instrument Serif"' : 'sans-serif'}`; ctx.fillStyle = color;
|
ctx.font = `${weight} ${size}px ${serif ? '"Instrument Serif"' : '"Mina Sans"'}`; ctx.fillStyle = color;
|
||||||
const chars = Array.from(value); let fitted = value;
|
const chars = Array.from(value); let fitted = value;
|
||||||
while (chars.length && ctx.measureText(fitted).width > width) { chars.pop(); fitted = chars.join('') + '…'; }
|
while (chars.length && ctx.measureText(fitted).width > width) { chars.pop(); fitted = chars.join('') + '…'; }
|
||||||
ctx.fillText(fitted, x, y);
|
ctx.fillText(fitted, x, y);
|
||||||
|
|||||||
@@ -33,12 +33,13 @@ export function createLiveDiscord(db: AppDatabase, auth: ReturnType<typeof creat
|
|||||||
return current ? { status: current.status, messageUrl: current.messageUrl, error: current.error, theme: current.theme } : { status: 'off', messageUrl: null, error: null };
|
return current ? { status: current.status, messageUrl: current.messageUrl, error: current.error, theme: current.theme } : { status: 'off', messageUrl: null, error: null };
|
||||||
}
|
}
|
||||||
function schedule(id: string, delay: number) {
|
function schedule(id: string, delay: number) {
|
||||||
if (stopped) return;
|
if (stopped || config.writesEnabled === false) return;
|
||||||
clearTimeout(timers.get(id));
|
clearTimeout(timers.get(id));
|
||||||
const timer = setTimeout(() => { timers.delete(id); void flush(id); }, Math.max(0, delay));
|
const timer = setTimeout(() => { timers.delete(id); void flush(id); }, Math.max(0, delay));
|
||||||
timer.unref(); timers.set(id, timer);
|
timer.unref(); timers.set(id, timer);
|
||||||
}
|
}
|
||||||
function changed(id: string) {
|
function changed(id: string) {
|
||||||
|
if (config.writesEnabled === false) return;
|
||||||
const current = row(id);
|
const current = row(id);
|
||||||
if (!current || !['active', 'sending'].includes(current.status)) return;
|
if (!current || !['active', 'sending'].includes(current.status)) return;
|
||||||
update(id, { dirty: true });
|
update(id, { dirty: true });
|
||||||
@@ -52,7 +53,7 @@ export function createLiveDiscord(db: AppDatabase, auth: ReturnType<typeof creat
|
|||||||
schedule(id, endOfDay(localDate(timestamp, owner.timezone), owner.timezone) - timestamp + 1);
|
schedule(id, endOfDay(localDate(timestamp, owner.timezone), owner.timezone) - timestamp + 1);
|
||||||
}
|
}
|
||||||
async function deliver(id: string) {
|
async function deliver(id: string) {
|
||||||
if (stopped) return;
|
if (stopped || config.writesEnabled === false) return;
|
||||||
const current = row(id);
|
const current = row(id);
|
||||||
if (!current || !['active', 'sending'].includes(current.status)) return;
|
if (!current || !['active', 'sending'].includes(current.status)) return;
|
||||||
const owner = db.select().from(users).where(eq(users.id, id)).get();
|
const owner = db.select().from(users).where(eq(users.id, id)).get();
|
||||||
@@ -64,7 +65,7 @@ export function createLiveDiscord(db: AppDatabase, auth: ReturnType<typeof creat
|
|||||||
update(id, { dirty: false, retryAt: null });
|
update(id, { dirty: false, retryAt: null });
|
||||||
const data = liveCardData(db, id, now());
|
const data = liveCardData(db, id, now());
|
||||||
// Hash the rendered content, excluding timestamps and private task details.
|
// Hash the rendered content, excluding timestamps and private task details.
|
||||||
const hash = createHash('sha256').update(JSON.stringify({ data, theme: current.theme, renderer: 2, content: liveMessageText })).digest('hex');
|
const hash = createHash('sha256').update(JSON.stringify({ data, theme: current.theme, renderer: 3, content: liveMessageText })).digest('hex');
|
||||||
if (current.messageId && current.imageHash === hash) {
|
if (current.messageId && current.imageHash === hash) {
|
||||||
update(id, { error: null }); rollover(id); return;
|
update(id, { error: null }); rollover(id); return;
|
||||||
}
|
}
|
||||||
@@ -155,6 +156,7 @@ export function createLiveDiscord(db: AppDatabase, auth: ReturnType<typeof creat
|
|||||||
return c.body(new Uint8Array(image));
|
return c.body(new Uint8Array(image));
|
||||||
});
|
});
|
||||||
app.post('/', async c => {
|
app.post('/', async c => {
|
||||||
|
if (config.writesEnabled === false) return c.json({ error: 'Discord channel writes are disabled in development. Preview and download are still available.' }, 403);
|
||||||
if (!configured) throw new ApiError(422, 'Discord sharing has not been configured on this server.');
|
if (!configured) throw new ApiError(422, 'Discord sharing has not been configured on this server.');
|
||||||
const user = c.get('user');
|
const user = c.get('user');
|
||||||
if (!allowed(user.discordId)) return c.json({ error: 'Discord sharing is limited to approved community members.' }, 403);
|
if (!allowed(user.discordId)) return c.json({ error: 'Discord sharing is limited to approved community members.' }, 403);
|
||||||
@@ -183,6 +185,7 @@ export function createLiveDiscord(db: AppDatabase, auth: ReturnType<typeof creat
|
|||||||
});
|
});
|
||||||
// Recover only known message edits. Never risk duplicating an interrupted first post.
|
// Recover only known message edits. Never risk duplicating an interrupted first post.
|
||||||
for (const current of db.select().from(liveDiscordCards).all()) {
|
for (const current of db.select().from(liveDiscordCards).all()) {
|
||||||
|
if (config.writesEnabled === false) break;
|
||||||
if (current.status === 'sending') update(current.userId, { status: 'uncertain', error: 'Server restarted during initial delivery. Check Discord before posting again.' });
|
if (current.status === 'sending') update(current.userId, { status: 'uncertain', error: 'Server restarted during initial delivery. Check Discord before posting again.' });
|
||||||
else if (current.status === 'active') schedule(current.userId, Math.max(2000, (current.retryAt ?? 0) - now()));
|
else if (current.status === 'active') schedule(current.userId, Math.max(2000, (current.retryAt ?? 0) - now()));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ export function createSharingRoutes(db: AppDatabase, auth: ReturnType<typeof cre
|
|||||||
return c.json({ connected: true, name: destination.name, channelUrl: destination.channelUrl });
|
return c.json({ connected: true, name: destination.name, channelUrl: destination.channelUrl });
|
||||||
});
|
});
|
||||||
app.post("/discord/send", async c => {
|
app.post("/discord/send", async c => {
|
||||||
|
if (config.writesEnabled === false) return c.json({ error: "Discord channel writes are disabled in development. Preview and download are still available." }, 403);
|
||||||
const userId = c.get("user").id;
|
const userId = c.get("user").id;
|
||||||
if (config.allowedUserIds?.length && !config.allowedUserIds.includes(c.get("user").discordId)) return c.json({ error: "Discord sharing is limited to approved community members." }, 403);
|
if (config.allowedUserIds?.length && !config.allowedUserIds.includes(c.get("user").discordId)) return c.json({ error: "Discord sharing is limited to approved community members." }, 403);
|
||||||
if (!configured) throw new ApiError(422, "Discord sharing has not been configured on this server.");
|
if (!configured) throw new ApiError(422, "Discord sharing has not been configured on this server.");
|
||||||
|
|||||||
@@ -1,3 +1,24 @@
|
|||||||
|
@font-face {
|
||||||
|
font-family: "Mina Sans";
|
||||||
|
src: url("../src/assets/fonts/NotoSans-Regular.ttf") format("truetype");
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "Mina Sans";
|
||||||
|
src: url("../src/assets/fonts/NotoSans-Medium.ttf") format("truetype");
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "Mina Sans";
|
||||||
|
src: url("../src/assets/fonts/NotoSans-SemiBold.ttf") format("truetype");
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "Instrument Serif";
|
font-family: "Instrument Serif";
|
||||||
src: url("../src/assets/fonts/InstrumentSerif-Regular.ttf") format("truetype");
|
src: url("../src/assets/fonts/InstrumentSerif-Regular.ttf") format("truetype");
|
||||||
|
|||||||
Reference in New Issue
Block a user