diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5dfbdc6 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +.git +.gitmodules +node_modules +javascript +*.s9pk +readstr/node_modules +readstr/.next diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..40c292d --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,20 @@ +name: Build + +on: + workflow_dispatch: + pull_request: + paths-ignore: ['*.md'] + branches: ['main'] + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + cancel-in-progress: true + +jobs: + build: + if: github.event.pull_request.draft == false + uses: start9labs/shared-workflows/.github/workflows/build.yml@master + with: + FREE_DISK_SPACE: true + secrets: + DEV_KEY: ${{ secrets.DEV_KEY }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..75c6036 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,19 @@ +name: Release + +on: + push: + tags: + - 'v*.*' + +jobs: + release: + uses: start9labs/shared-workflows/.github/workflows/release.yml@master + with: + RELEASE_REGISTRY: ${{ vars.RELEASE_REGISTRY }} + S3_S9PKS_BASE_URL: ${{ vars.S3_S9PKS_BASE_URL }} + secrets: + DEV_KEY: ${{ secrets.DEV_KEY }} + S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }} + S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }} + permissions: + contents: write diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7faf44f --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +javascript/ +*.s9pk +.DS_Store diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..c39ada0 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "readstr"] + path = readstr + url = https://github.com/privkeyio/readstr.git + branch = main diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b90bfda --- /dev/null +++ b/Dockerfile @@ -0,0 +1,41 @@ +FROM node:22-alpine AS deps +RUN apk add --no-cache libc6-compat openssl +WORKDIR /app +COPY readstr/package.json readstr/package-lock.json* ./ +COPY readstr/prisma ./prisma +RUN npm ci + +FROM node:22-alpine AS builder +WORKDIR /app +RUN apk add --no-cache openssl +COPY --from=deps /app/node_modules ./node_modules +COPY readstr/ . +RUN npx prisma generate +ENV NEXT_TELEMETRY_DISABLED=1 +RUN npm run build + +FROM node:22-alpine AS runner +WORKDIR /app +RUN apk add --no-cache openssl postgresql16 postgresql16-contrib su-exec + +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 + +COPY --from=builder /app/public ./public +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static +COPY --from=builder /app/prisma ./prisma +COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma + +# Pin the Prisma CLI into the image so `prisma migrate deploy` resolves locally +# at boot with no network access. +RUN npm install --no-save prisma@5.22.0 + +COPY docker_entrypoint.sh /usr/local/bin/docker_entrypoint.sh +RUN chmod +x /usr/local/bin/docker_entrypoint.sh + +EXPOSE 3000 +ENV PORT=3000 +ENV HOSTNAME=0.0.0.0 + +ENTRYPOINT ["/usr/local/bin/docker_entrypoint.sh"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0bd9656 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 PrivKey LLC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..10d4360 --- /dev/null +++ b/Makefile @@ -0,0 +1,3 @@ +ARCHES := x86 arm +# overrides to s9pk.mk must precede the include statement +include s9pk.mk diff --git a/README.md b/README.md index cdd0a55..3caa240 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,221 @@ -# readstr-startos -Feed aggregator combining RSS feeds with Nostr long-form content (NIP-23) and videos for StartOS +

+ Readstr Logo +

+ +# Readstr on StartOS + +> **Upstream docs:** +> +> Everything not listed in this document should behave the same as upstream +> Readstr. If a feature, setting, or behavior is not mentioned here, the +> upstream documentation is accurate and fully applicable. + +[Readstr](https://github.com/privkeyio/readstr) is a self-hosted, Google Reader-style feed aggregator for RSS/Atom, Nostr, and video. This package runs its Next.js standalone server alongside a bundled PostgreSQL database, so your subscriptions and reading state live entirely on your own server. You sign in with your own Nostr key (NIP-07 or NIP-46), organize subscriptions with tags or categories, and sync them across devices over Nostr. + +--- + +## Table of Contents + +- [Image and Container Runtime](#image-and-container-runtime) +- [Volume and Data Layout](#volume-and-data-layout) +- [Installation and First-Run Flow](#installation-and-first-run-flow) +- [Configuration Management](#configuration-management) +- [Network Access and Interfaces](#network-access-and-interfaces) +- [Actions (StartOS UI)](#actions-startos-ui) +- [Dependencies](#dependencies) +- [Backups and Restore](#backups-and-restore) +- [Health Checks](#health-checks) +- [Limitations and Differences](#limitations-and-differences) +- [What Is Unchanged from Upstream](#what-is-unchanged-from-upstream) +- [Contributing](#contributing) +- [Quick Reference for AI Consumers](#quick-reference-for-ai-consumers) + +--- + +## Image and Container Runtime + +| Property | Value | +|----------|-------| +| Image | `readstr`, built from source via the root `Dockerfile` | +| Source | `readstr` git submodule (upstream Next.js app + Prisma + tRPC) | +| Build | Next.js standalone build (Node 22 Alpine) bundled with PostgreSQL 16 and `su-exec` on an Alpine runtime | +| Architectures | x86_64, aarch64 | +| Entrypoint | `/usr/local/bin/docker_entrypoint.sh`, which boots PostgreSQL on localhost, runs Prisma migrations, then starts `node server.js` | + +--- + +## Volume and Data Layout + +| Volume | Mount Point | Purpose | +|--------|-------------|---------| +| `main` | `/data` | Bundled PostgreSQL database and StartOS settings | + +**Key paths on the `main` volume:** + +- `/data/postgres`: the bundled PostgreSQL data directory (`PGDATA`) holding all feeds, subscriptions, and reading state +- `/data/start9/store.json`: StartOS persistent settings, namely the database password, default relays, and allowed hosts + +--- + +## Installation and First-Run Flow + +| Step | Upstream | StartOS | +|------|----------|---------| +| Database | Manual (docker-compose PostgreSQL) | Auto-bundled, initialized at `/data/postgres` on first start | +| DB password | User-supplied (`.env`) | Auto-generated internal secret (never shown) | +| Migrations | Run manually | Run automatically by the entrypoint (`prisma migrate deploy`) | +| Sign-in | Nostr key (NIP-07 / NIP-46) | Same | + +**First-run steps:** + +1. Open the **Web UI** from this service's page in StartOS. +2. Click **Connect with Nostr** and authorize with a browser extension (NIP-07, e.g. nos2x or Alby) on desktop, or pair a remote signer (NIP-46, e.g. Amber) on mobile. Your npub is your identity, so there is no separate account. +3. Use **Add Feed** to subscribe to RSS feeds, Nostr authors (npub or NIP-05), or YouTube/Rumble channels. +4. If you reach this server at a non-default address, set **Allowed Hosts** in the **Configure** action so NIP-98 login is accepted. + +See [instructions.md](instructions.md) for the user-facing walkthrough. + +--- + +## Configuration Management + +| StartOS-Managed | Upstream-Managed (in-app) | +|-----------------|---------------------------| +| Database password (auto-generated internal secret) | Feed subscriptions and tags/categories | +| Default relays (Configure action) | Nostr identity (your key) | +| Allowed hosts for NIP-98 login (Configure action) | Reading state | + +**Environment variables set by StartOS** (`startos/main.ts`): + +| Variable | Value | Purpose | +|----------|-------|---------| +| `NODE_ENV` | `production` | Next.js runtime mode | +| `PORT` | `3000` | Web UI bind port | +| `HOSTNAME` | `0.0.0.0` | Web UI bind address | +| `PGDATA` | `/data/postgres` | Bundled PostgreSQL data dir (on the backed-up `main` volume) | +| `DB_PASSWORD` | (auto-generated) | Password for the bundled PostgreSQL role | +| `DEFAULT_RELAYS` | (Configure) | Nostr relays for long-form content and profiles, comma-separated | +| `NIP98_ALLOWED_HOSTS` | (auto + Configure) | Hostnames allowed in NIP-98 auth: the interface's StartOS-assigned addresses (Tor, `.local`, LAN IP), plus any custom hosts from Configure | + +Default relays are `wss://relay.damus.io`, `wss://nos.lol`, and `wss://relay.nostr.band`. The Configure action accepts any number of `wss://` relays; saving restarts the service to apply. + +--- + +## Network Access and Interfaces + +| Interface | Port | Protocol | Purpose | +|-----------|------|----------|---------| +| Web UI (`ui`) | 3000 | HTTP | Three-panel reader and NIP-98-authenticated app | + +The Web UI is unmasked and carries no StartOS-level auth, because Readstr authenticates you with your own Nostr key (NIP-07 / NIP-46) and NIP-98 verifies the request host (see Allowed Hosts). + +**Access methods:** + +- LAN IP with unique port +- `.local` with unique port +- Tor `.onion` address (if added) +- Custom domains (if configured) + +--- + +## Actions (StartOS UI) + +### Configure + +Set the Nostr relays and allowed hosts Readstr uses, then restart to apply. + +| Property | Value | +|----------|-------| +| Availability | Any status | +| Visibility | Always visible | +| Inputs | Default Relays, Allowed Hosts (optional) | +| Outputs | Confirmation; restarts the service | + +--- + +## Dependencies + +None. + +--- + +## Backups and Restore + +**Included in backup:** + +- `main` volume, covering the bundled PostgreSQL data (`/data/postgres`) and `store.json` + +**Restore behavior:** + +- The database and its password are restored together, so your feeds, subscriptions, reading state, relays, and allowed hosts come back as-is, with no re-import or reconfiguration. Init regenerates the database password only on a fresh install, never on restore. + +--- + +## Health Checks + +| Check | Display Name | Method | Messages | +|-------|--------------|--------|----------| +| `primary` | Web UI | Port-listening check on 3000 | "The Readstr web UI is ready" / "The Readstr web UI is not responding" | + +--- + +## Limitations and Differences + +1. **Custom domains require Allowed Hosts.** Readstr verifies the host in the NIP-98 auth token. The package auto-allows every address StartOS assigns the interface (Tor `.onion`, `.local`, LAN IP), so Tor and LAN work out of the box; for a custom domain you added yourself, set it in **Allowed Hosts** in Configure or login at that address may be rejected. +2. **Bundled single-node PostgreSQL.** The database runs inside the service container, not as a separate StartOS service, and is initialized and migrated automatically on first start. +3. **Nostr sign-in required.** There is no separate account system; your npub is your identity. + +--- + +## What Is Unchanged from Upstream + +- RSS/Atom feed aggregation in a three-panel reader +- Nostr long-form content (NIP-23) alongside RSS +- YouTube and Rumble video subscriptions +- Tag- and category-based organization with cross-device sync over Nostr +- Nostr sign-in via NIP-07 and NIP-46 remote signers +- The Next.js standalone server, tRPC API, and Prisma schema + +--- + +## Contributing + +This package targets **StartOS 0.4.x** and uses the StartOS TypeScript SDK. + +```sh +git clone --recurse-submodules https://github.com/privkeyio/readstr-startos +cd readstr-startos +make # produces readstr_x86_64.s9pk and readstr_aarch64.s9pk +make install # installs to the host in ~/.startos/config.yaml +``` + +The `readstr` git submodule pins the upstream source built into the image. CI builds the `.s9pk` on PRs to `main` (`.github/workflows/build.yml`, requires the `DEV_KEY` secret from `start-cli init-key`) and publishes on `v*` tags (`.github/workflows/release.yml`, plus the registry/S3 vars and secrets). + +--- + +## Quick Reference for AI Consumers + +```yaml +package_id: readstr +architectures: [x86_64, aarch64] +image: readstr (built from source; Next.js standalone + bundled PostgreSQL) +volumes: + main: /data +ports: + ui: 3000 +dependencies: none +startos_managed_env_vars: + - NODE_ENV + - PORT + - HOSTNAME + - PGDATA + - DB_PASSWORD + - DEFAULT_RELAYS + - NIP98_ALLOWED_HOSTS +actions: + - configure +health_checks: + - primary: port_check 3000 +backup_volumes: + - main +``` diff --git a/assets/README.md b/assets/README.md new file mode 100644 index 0000000..8220df2 --- /dev/null +++ b/assets/README.md @@ -0,0 +1,3 @@ +# assets + +Static assets bundled into the s9pk (screenshots, etc.). diff --git a/docker_entrypoint.sh b/docker_entrypoint.sh new file mode 100644 index 0000000..a6dcf0b --- /dev/null +++ b/docker_entrypoint.sh @@ -0,0 +1,86 @@ +#!/bin/sh +set -e + +# The StartOS daemon launch does not inherit the image WORKDIR, so anchor the +# cwd here: `npx prisma` needs ./prisma and `node server.js` lives in /app. +cd /app + +PGDATA="${PGDATA:-/data/postgres}" +export PGDATA +PGHOST=127.0.0.1 +PGPORT=5432 +DB_NAME=readstr +DB_USER=readstr +DB_PASSWORD="${DB_PASSWORD:?DB_PASSWORD is required}" + +stop_postgres() { + su-exec postgres pg_ctl -D "$PGDATA" -m fast -w stop || true +} +cleanup() { + if [ -n "$APP_PID" ]; then + kill "$APP_PID" 2>/dev/null || true + wait "$APP_PID" 2>/dev/null || true + fi + stop_postgres +} +# Always stop postgres when the script exits, including on error. pg_ctl +# daemonizes postgres detached from this script, so without this an early exit +# would leave it running and the next launch would fail on the postmaster.pid +# lock, looping forever. +trap cleanup EXIT +trap 'exit 143' TERM INT + +mkdir -p "$PGDATA" +# Always re-assert ownership: on restore-from-backup the data dir already has a +# PG_VERSION but may land owned by root, which would make pg_ctl start fail. +chown -R postgres:postgres "$PGDATA" +if [ ! -s "$PGDATA/PG_VERSION" ]; then + # Local socket uses peer auth (OS user postgres -> role postgres), so the + # admin commands below need no password; the app connects over TCP with scram. + su-exec postgres initdb -D "$PGDATA" -U postgres --auth-local=peer --auth-host=scram-sha-256 --encoding=UTF8 +fi + +su-exec postgres pg_ctl -D "$PGDATA" -w \ + -o "-c listen_addresses='$PGHOST' -p $PGPORT -c unix_socket_directories='/tmp'" start + +psql() { + su-exec postgres psql -h /tmp -p "$PGPORT" -U postgres -v ON_ERROR_STOP=1 "$@" +} + +# Pass the password as a psql variable via stdin: :'pw' is interpolated and +# safely quoted by psql in stdin/-f mode (it is NOT processed in -c strings). +# DB_USER/DB_NAME are fixed constants, quoted as identifiers. +if ! psql -tAc "SELECT 1 FROM pg_roles WHERE rolname='$DB_USER'" | grep -q 1; then + psql -v pw="$DB_PASSWORD" <&2 + exit 1 + fi + echo "migrate deploy failed, retrying ($attempts/$max_attempts)..." >&2 + sleep 2 +done + +node server.js & +APP_PID=$! +set +e +wait "$APP_PID" +APP_EXIT=$? +set -e +exit "$APP_EXIT" diff --git a/icon.png b/icon.png new file mode 100644 index 0000000..6d30789 Binary files /dev/null and b/icon.png differ diff --git a/instructions.md b/instructions.md new file mode 100644 index 0000000..842c6a1 --- /dev/null +++ b/instructions.md @@ -0,0 +1,26 @@ +# Readstr + +Readstr is a self-hosted, Google Reader-style feed aggregator. It brings RSS/Atom feeds together with Nostr long-form content (NIP-23) and video feeds from YouTube and Rumble in a clean, three-panel reading interface. Your subscriptions and reading state live on your own server in a bundled PostgreSQL database. + +## First-time setup + +1. **Open the Web UI.** Launch the interface from this service's page in StartOS. +2. **Sign in with Nostr.** Click "Connect with Nostr" and authorize with a browser extension (NIP-07, e.g. nos2x or Alby) on desktop, or pair a remote signer (NIP-46, e.g. Amber) with a `bunker://` connection string on mobile. Your npub is your identity, so there is no separate account. +3. **Add feeds.** Use "Add Feed" to subscribe to RSS feeds, Nostr authors (npub or NIP-05), or YouTube/Rumble channels. + +## Login on a custom address + +Readstr verifies the host in your NIP-98 auth token. Every address StartOS assigns this service (its `.onion`, `.local`, and LAN IP) is allowed automatically, so login works over Tor and on your LAN with no setup. If you reach the server at a custom domain you added yourself, set it in **Allowed Hosts** in the **Configure** action (comma-separated, hostname only); otherwise login at that address may be rejected. + +## Configuration + +Use the **Configure** action to set: + +- **Default Relays:** the Nostr relays used to fetch long-form content and profiles. Defaults to `wss://relay.damus.io`, `wss://nos.lol`, and `wss://relay.nostr.band`. +- **Allowed Hosts:** extra hostnames to accept for NIP-98 login, for custom domains you added yourself. The service's StartOS addresses (Tor, `.local`, LAN IP) are already allowed automatically. + +Saving the configuration restarts the service. + +## Data and backups + +All state, the PostgreSQL database and this package's settings, lives in the service's data volume and is captured by StartOS backups. Restoring a backup brings back your feeds, subscriptions, and reading state. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..700f9b7 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,374 @@ +{ + "name": "readstr-startos", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "readstr-startos", + "dependencies": { + "@start9labs/start-sdk": "1.5.3" + }, + "devDependencies": { + "@types/node": "^22.19.0", + "@vercel/ncc": "^0.38.4", + "prettier": "^3.6.2", + "typescript": "^5.9.3" + } + }, + "node_modules/@iarna/toml": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@iarna/toml/-/toml-3.0.0.tgz", + "integrity": "sha512-td6ZUkz2oS3VeleBcN+m//Q6HlCFCPrnI0FZhrt/h4XqLEdOyYp2u21nd8MdsR+WJy5r9PTDaHTDDfhf4H4l6Q==", + "license": "ISC" + }, + "node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@nodable/entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, + "node_modules/@start9labs/start-sdk": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@start9labs/start-sdk/-/start-sdk-1.5.3.tgz", + "integrity": "sha512-OyHe9J6hMvyA5ZavcLkxdVQvZcuTH9J9kagV6NDI83eAG/YpJFIq62gP/n/2PPNdHWwNSXVQmSwnsvsV8Gyg+A==", + "license": "MIT", + "dependencies": { + "@iarna/toml": "^3.0.0", + "@noble/curves": "^1.9.7", + "@noble/hashes": "^1.8.0", + "@types/ini": "^4.1.1", + "deep-equality-data-structures": "^2.0.0", + "fast-xml-parser": "~5.7.0", + "ini": "^5.0.0", + "isomorphic-fetch": "^3.0.0", + "mime": "^4.1.0", + "yaml": "^2.8.3", + "zod": "4.3.6", + "zod-deep-partial": "^1.2.0" + } + }, + "node_modules/@types/ini": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@types/ini/-/ini-4.1.1.tgz", + "integrity": "sha512-MIyNUZipBTbyUNnhvuXJTY7B6qNI78meck9Jbv3wk0OgNwRyOOVEKDutAkOs1snB/tx0FafyR6/SN4Ps0hZPeg==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz", + "integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@vercel/ncc": { + "version": "0.38.4", + "resolved": "https://registry.npmjs.org/@vercel/ncc/-/ncc-0.38.4.tgz", + "integrity": "sha512-8LwjnlP39s08C08J5NstzriPvW1SP8Zfpp1BvC2sI35kPeZnHfxVkCwu4/+Wodgnd60UtT1n8K8zw+Mp7J9JmQ==", + "dev": true, + "license": "MIT", + "bin": { + "ncc": "dist/ncc/cli.js" + } + }, + "node_modules/anynum": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz", + "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/deep-equality-data-structures": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/deep-equality-data-structures/-/deep-equality-data-structures-2.0.0.tgz", + "integrity": "sha512-qgrUr7MKXq7VRN+WUpQ48QlXVGL0KdibAoTX8KRg18lgOgqbEKMAW1WZsVCtakY4+XX42pbAJzTz/DlXEFM2Fg==", + "license": "MIT", + "dependencies": { + "object-hash": "^3.0.0" + } + }, + "node_modules/fast-xml-builder": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", + "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.5.0", + "xml-naming": "^0.1.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz", + "integrity": "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.1.7", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.2.3" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/ini": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-5.0.0.tgz", + "integrity": "sha512-+N0ngpO3e7cRUWOJAS7qw0IZIVc6XPrW4MlFBdD066F2L4k1L6ker3hLqSq7iXxU5tgS4WGkIUElWn5vogAEnw==", + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/isomorphic-fetch": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-3.0.0.tgz", + "integrity": "sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.6.1", + "whatwg-fetch": "^3.4.1" + } + }, + "node_modules/mime": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-4.1.0.tgz", + "integrity": "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==", + "funding": [ + "https://github.com/sponsors/broofa" + ], + "license": "MIT", + "bin": { + "mime": "bin/cli.js" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-expression-matcher": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.0.tgz", + "integrity": "sha512-e5y7RCLHKjemsgQ4eqGJtPyr10ILz25HO7flzxhTV8bgvd5yHx98DGtCAtbVW9f2TqnYI/gEVZd+vz7snrdPTw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/prettier": { + "version": "3.8.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.4.tgz", + "integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/strnum": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.1.tgz", + "integrity": "sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "anynum": "^1.0.1" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-fetch": { + "version": "3.6.20", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", + "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==", + "license": "MIT" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/xml-naming": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", + "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-deep-partial": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/zod-deep-partial/-/zod-deep-partial-1.4.4.tgz", + "integrity": "sha512-aWkPl7hVStgE01WzbbSxCgX4O+sSpgt8JOjvFUtMTF75VgL6MhWQbiZi+AWGN85SfSTtI9gsOtL1vInoqfDVaA==", + "license": "MIT", + "peerDependencies": { + "zod": "^4.1.13" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..c0674ac --- /dev/null +++ b/package.json @@ -0,0 +1,23 @@ +{ + "name": "readstr-startos", + "scripts": { + "build": "rm -rf ./javascript && ncc build startos/index.ts -o ./javascript", + "prettier": "prettier --write startos", + "check": "tsc --noEmit" + }, + "dependencies": { + "@start9labs/start-sdk": "1.5.3" + }, + "devDependencies": { + "@types/node": "^22.19.0", + "@vercel/ncc": "^0.38.4", + "prettier": "^3.6.2", + "typescript": "^5.9.3" + }, + "prettier": { + "trailingComma": "all", + "tabWidth": 2, + "semi": false, + "singleQuote": true + } +} diff --git a/readstr b/readstr new file mode 160000 index 0000000..7299c5a --- /dev/null +++ b/readstr @@ -0,0 +1 @@ +Subproject commit 7299c5ae2011ced0156d0587f0160424f6f5d591 diff --git a/s9pk.mk b/s9pk.mk new file mode 100644 index 0000000..25538af --- /dev/null +++ b/s9pk.mk @@ -0,0 +1,141 @@ +# ** Plumbing. DO NOT EDIT **. +# This file is imported by ./Makefile. Make edits there + +PACKAGE_ID := $(shell awk -F"'" '/id:/ {print $$2}' startos/manifest/index.ts) +INGREDIENTS := $(shell start-cli s9pk list-ingredients 2>/dev/null) +# Resolve the actual git dir so this works inside git worktrees, where .git +# is a file pointing at
/.git/worktrees/ rather than a directory. +GIT_DIR := $(shell git rev-parse --git-dir 2>/dev/null) +GIT_DEPS := $(if $(GIT_DIR),$(GIT_DIR)/HEAD $(GIT_DIR)/index) +ARCHES ?= x86 arm riscv +# TARGETS is the list of leaf make-targets the build matrix fans out over. +# Defaults to the arches; variant packages override (e.g. immich, ollama, vllm +# set this to a list of variant or variant-arch leaf targets). +TARGETS ?= $(ARCHES) +ifdef VARIANT +BASE_NAME := $(PACKAGE_ID)_$(VARIANT) +else +BASE_NAME := $(PACKAGE_ID) +endif + +.PHONY: all arches aarch64 x86_64 riscv64 arm arm64 x86 riscv arch/* clean install check-deps check-init package ingredients +.DELETE_ON_ERROR: +.SECONDARY: + +define SUMMARY + @manifest=$$(start-cli s9pk inspect $(1) manifest); \ + size=$$(du -h $(1) | awk '{print $$1}'); \ + title=$$(printf '%s' "$$manifest" | jq -r .title); \ + version=$$(printf '%s' "$$manifest" | jq -r .version); \ + arches=$$(printf '%s' "$$manifest" | jq -r '[.images[].arch // []] | flatten | unique | join(", ")'); \ + sdkv=$$(printf '%s' "$$manifest" | jq -r .sdkVersion); \ + gitHash=$$(printf '%s' "$$manifest" | jq -r .gitHash | sed -E 's/(.*-modified)$$/\x1b[0;31m\1\x1b[0m/'); \ + printf "\n"; \ + printf "\033[1;32m✅ Build Complete!\033[0m\n"; \ + printf "\n"; \ + printf "\033[1;37m📦 $$title\033[0m \033[36mv$$version\033[0m\n"; \ + printf "───────────────────────────────\n"; \ + printf " \033[1;36mFilename:\033[0m %s\n" "$(1)"; \ + printf " \033[1;36mSize:\033[0m %s\n" "$$size"; \ + printf " \033[1;36mArch:\033[0m %s\n" "$$arches"; \ + printf " \033[1;36mSDK:\033[0m %s\n" "$$sdkv"; \ + printf " \033[1;36mGit:\033[0m %s\n" "$$gitHash"; \ + echo "" +endef + +all: $(TARGETS) + +arches: $(ARCHES) + +# Generic make-variable introspection. Used by the release workflow to +# read $(TARGETS) and fan out one matrix runner per target. `make -s +# print-TARGETS` echoes the list with no other output. +print-%: + @echo '$($*)' + +universal: $(BASE_NAME).s9pk + $(call SUMMARY,$<) + +arch/%: $(BASE_NAME)_%.s9pk + $(call SUMMARY,$<) + +x86 x86_64: arch/x86_64 +arm arm64 aarch64: arch/aarch64 +riscv riscv64: arch/riscv64 + +$(BASE_NAME).s9pk: $(INGREDIENTS) $(GIT_DEPS) + @$(MAKE) --no-print-directory ingredients + @echo " Packing '$@'..." + start-cli s9pk pack -o $@ + +$(BASE_NAME)_%.s9pk: $(INGREDIENTS) $(GIT_DEPS) + @$(MAKE) --no-print-directory ingredients + @echo " Packing '$@'..." + start-cli s9pk pack --arch=$* -o $@ + +ingredients: $(INGREDIENTS) + @echo " Re-evaluating ingredients..." + +install: | check-deps check-init + @HOST=$$(awk -F'/' '/^host:/ {print $$3}' ~/.startos/config.yaml); \ + if [ -z "$$HOST" ]; then \ + echo "Error: You must define \"host: http://server-name.local\" in ~/.startos/config.yaml"; \ + exit 1; \ + fi; \ + S9PK=$$(ls -t *.s9pk 2>/dev/null | head -1); \ + if [ -z "$$S9PK" ]; then \ + echo "Error: No .s9pk file found. Run 'make' first."; \ + exit 1; \ + fi; \ + printf "\n🚀 Installing %s to %s ...\n" "$$S9PK" "$$HOST"; \ + start-cli package install -s "$$S9PK" + +publish: | all + @REGISTRY=$$(awk -F'/' '/^registry:/ {print $$3}' ~/.startos/config.yaml); \ + if [ -z "$$REGISTRY" ]; then \ + echo "Error: You must define \"registry: https://my-registry.tld\" in ~/.startos/config.yaml"; \ + exit 1; \ + fi; \ + S3BASE=$$(awk -F'/' '/^s9pk-s3base:/ {print $$3}' ~/.startos/config.yaml); \ + if [ -z "$$S3BASE" ]; then \ + echo "Error: You must define \"s3base: https://s9pks.my-s3-bucket.tld\" in ~/.startos/config.yaml"; \ + exit 1; \ + fi; \ + command -v s3cmd >/dev/null || \ + (echo "Error: s3cmd not found. It must be installed to publish using s3." && exit 1); \ + printf "\n🚀 Publishing to %s; indexing on %s ...\n" "$$S3BASE" "$$REGISTRY"; \ + for s9pk in *.s9pk; do \ + age=$$(( $$(date +%s) - $$(stat -c %Y "$$s9pk") )); \ + if [ "$$age" -gt 3600 ]; then \ + printf "\033[1;33m⚠️ %s is %d minutes old. Publish anyway? [y/N] \033[0m" "$$s9pk" "$$((age / 60))"; \ + read -r ans; \ + case "$$ans" in [yY]*) ;; *) echo "Skipping $$s9pk"; continue ;; esac; \ + fi; \ + start-cli s9pk publish "$$s9pk"; \ + done + +check-deps: + @command -v start-cli >/dev/null || \ + (echo "Error: start-cli not found. Please see https://docs.start9.com/latest/developer-guide/sdk/installing-the-sdk" && exit 1) + @command -v npm >/dev/null || \ + (echo "Error: npm not found. Please install Node.js and npm." && exit 1) + +check-init: + @if [ ! -f ~/.startos/developer.key.pem ]; then \ + echo "Initializing StartOS developer environment..."; \ + start-cli init-key; \ + fi + +javascript/index.js: $(shell find startos -type f) tsconfig.json node_modules + npm run check + npm run build + +node_modules: package-lock.json + npm ci + +package-lock.json: package.json + npm i + +clean: + @echo "Cleaning up build artifacts..." + @rm -rf $(PACKAGE_ID).s9pk $(PACKAGE_ID)_x86_64.s9pk $(PACKAGE_ID)_aarch64.s9pk $(PACKAGE_ID)_riscv64.s9pk javascript node_modules diff --git a/startos/actions/configure.ts b/startos/actions/configure.ts new file mode 100644 index 0000000..80a0aa6 --- /dev/null +++ b/startos/actions/configure.ts @@ -0,0 +1,67 @@ +import { sdk } from '../sdk' +import { i18n } from '../i18n' +import { storeJson } from '../fileModels/store.json' +import { defaultRelays } from '../utils' + +const { InputSpec, Value, List } = sdk + +const relayPattern = { + regex: '^wss://.+', + description: 'Relay URLs must start with wss://', +} + +const inputSpec = InputSpec.of({ + defaultRelays: Value.list( + List.text( + { + name: i18n('Default Relays'), + description: i18n( + 'Nostr relays used to fetch long-form content and profiles', + ), + default: defaultRelays, + }, + { patterns: [relayPattern], placeholder: 'wss://nos.lol' }, + ), + ), + nip98AllowedHosts: Value.text({ + name: i18n('Allowed Hosts'), + description: i18n( + 'Comma-separated hostnames clients use to reach this server (required for login when not using the default host). Leave blank to use the app default.', + ), + required: false, + default: null, + }), +}) + +export const configure = sdk.Action.withInput( + 'configure', + async ({ effects }) => ({ + name: i18n('Configure'), + description: i18n('Configure the Nostr relays and allowed hosts'), + warning: null, + allowedStatuses: 'any', + group: null, + visibility: 'enabled', + }), + inputSpec, + async ({ effects }) => { + const s = await storeJson.read().once() + return { + defaultRelays: s?.defaultRelays ?? defaultRelays, + nip98AllowedHosts: s?.nip98AllowedHosts || null, + } + }, + async ({ effects, input }) => { + await storeJson.merge(effects, { + defaultRelays: input.defaultRelays, + nip98AllowedHosts: input.nip98AllowedHosts ?? '', + }) + await effects.restart() + return { + version: '1' as const, + title: i18n('Configuration saved'), + message: i18n('The service is restarting with the new settings.'), + result: null, + } + }, +) diff --git a/startos/actions/index.ts b/startos/actions/index.ts new file mode 100644 index 0000000..99e118d --- /dev/null +++ b/startos/actions/index.ts @@ -0,0 +1,4 @@ +import { sdk } from '../sdk' +import { configure } from './configure' + +export const actions = sdk.Actions.of().addAction(configure) diff --git a/startos/backups.ts b/startos/backups.ts new file mode 100644 index 0000000..0a90b1e --- /dev/null +++ b/startos/backups.ts @@ -0,0 +1,5 @@ +import { sdk } from './sdk' + +export const { createBackup, restoreInit } = sdk.setupBackups( + async ({ effects }) => sdk.Backups.ofVolumes('main'), +) diff --git a/startos/dependencies.ts b/startos/dependencies.ts new file mode 100644 index 0000000..7221c4b --- /dev/null +++ b/startos/dependencies.ts @@ -0,0 +1,5 @@ +import { sdk } from './sdk' + +export const setDependencies = sdk.setupDependencies( + async ({ effects }) => ({}), +) diff --git a/startos/fileModels/store.json.ts b/startos/fileModels/store.json.ts new file mode 100644 index 0000000..8518643 --- /dev/null +++ b/startos/fileModels/store.json.ts @@ -0,0 +1,22 @@ +import { z, FileHelper } from '@start9labs/start-sdk' +import { sdk } from '../sdk' +import { defaultRelays } from '../utils' + +// Package-internal state. Written only by our init + actions, so .const() +// gives automatic restart-on-change. +const storeConfigSchema = z.object({ + // Generated once at install; password for the bundled PostgreSQL role. + dbPassword: z.string().catch(''), + // Default Nostr relays the app reads long-form content from. + defaultRelays: z.array(z.string()).catch(defaultRelays), + // Comma-separated hostnames allowed in NIP-98 auth tokens. Set this to the + // address you use to reach this server so login works. Empty = app default. + nip98AllowedHosts: z.string().catch(''), +}) + +export type StoreConfig = z.infer + +export const storeJson = FileHelper.json( + { base: sdk.volumes.main, subpath: 'start9/store.json' }, + storeConfigSchema, +) diff --git a/startos/i18n/dictionaries/default.ts b/startos/i18n/dictionaries/default.ts new file mode 100644 index 0000000..8b8f045 --- /dev/null +++ b/startos/i18n/dictionaries/default.ts @@ -0,0 +1,29 @@ +export const DEFAULT_LANG = 'en_US' + +const dict = { + // main.ts + 'Starting Readstr': 0, + 'Web UI': 1, + 'The Readstr web UI is ready': 2, + 'The Readstr web UI is not responding': 3, + + // interfaces.ts + 'Browse your RSS feeds, Nostr long-form content, and video subscriptions': 4, + + // actions/configure.ts + Configure: 5, + 'Configure the Nostr relays and allowed hosts': 6, + 'Default Relays': 7, + 'Nostr relays used to fetch long-form content and profiles': 8, + 'Allowed Hosts': 9, + 'Comma-separated hostnames clients use to reach this server (required for login when not using the default host). Leave blank to use the app default.': 10, + 'Configuration saved': 11, + 'The service is restarting with the new settings.': 12, +} as const + +/** + * Plumbing. DO NOT EDIT. + */ +export type I18nKey = keyof typeof dict +export type LangDict = Record<(typeof dict)[I18nKey], string> +export default dict diff --git a/startos/i18n/dictionaries/translations.ts b/startos/i18n/dictionaries/translations.ts new file mode 100644 index 0000000..98f9348 --- /dev/null +++ b/startos/i18n/dictionaries/translations.ts @@ -0,0 +1,3 @@ +import { LangDict } from './default' + +export default {} satisfies Record diff --git a/startos/i18n/index.ts b/startos/i18n/index.ts new file mode 100644 index 0000000..04cea20 --- /dev/null +++ b/startos/i18n/index.ts @@ -0,0 +1,8 @@ +/** + * Plumbing. DO NOT EDIT this file. + */ +import { setupI18n } from '@start9labs/start-sdk' +import defaultDict, { DEFAULT_LANG } from './dictionaries/default' +import translations from './dictionaries/translations' + +export const i18n = setupI18n(defaultDict, translations, DEFAULT_LANG) diff --git a/startos/index.ts b/startos/index.ts new file mode 100644 index 0000000..7af589b --- /dev/null +++ b/startos/index.ts @@ -0,0 +1,11 @@ +/** + * Plumbing. DO NOT EDIT. + */ +export { createBackup } from './backups' +export { main } from './main' +export { init, uninit } from './init' +export { actions } from './actions' +import { buildManifest } from '@start9labs/start-sdk' +import { manifest as sdkManifest } from './manifest' +import { versionGraph } from './versions' +export const manifest = buildManifest(versionGraph, sdkManifest) diff --git a/startos/init/index.ts b/startos/init/index.ts new file mode 100644 index 0000000..a07ab38 --- /dev/null +++ b/startos/init/index.ts @@ -0,0 +1,18 @@ +import { sdk } from '../sdk' +import { setDependencies } from '../dependencies' +import { setInterfaces } from '../interfaces' +import { versionGraph } from '../versions' +import { actions } from '../actions' +import { restoreInit } from '../backups' +import { initializeService } from './initializeService' + +export const init = sdk.setupInit( + restoreInit, + versionGraph, + setInterfaces, + setDependencies, + actions, + initializeService, +) + +export const uninit = sdk.setupUninit(versionGraph) diff --git a/startos/init/initializeService.ts b/startos/init/initializeService.ts new file mode 100644 index 0000000..afcad90 --- /dev/null +++ b/startos/init/initializeService.ts @@ -0,0 +1,19 @@ +import { utils } from '@start9labs/start-sdk' +import { storeJson } from '../fileModels/store.json' +import { sdk } from '../sdk' + +export const initializeService = sdk.setupOnInit(async (effects, kind) => { + // Seed defaults on every init so new schema defaults apply on upgrade. + await storeJson.merge(effects, {}) + + if (kind !== 'install') return + + // Generate the PostgreSQL password once at install. It lives in the + // (backed-up) main volume's store.json and is URL-safe for DATABASE_URL. + await storeJson.merge(effects, { + dbPassword: utils.getDefaultString({ + charset: 'a-z,A-Z,0-9', + len: 32, + }), + }) +}) diff --git a/startos/interfaces.ts b/startos/interfaces.ts new file mode 100644 index 0000000..3cacd48 --- /dev/null +++ b/startos/interfaces.ts @@ -0,0 +1,27 @@ +import { sdk } from './sdk' +import { i18n } from './i18n' +import { uiPort } from './utils' + +export const setInterfaces = sdk.setupInterfaces(async ({ effects }) => { + const uiMulti = sdk.MultiHost.of(effects, 'ui-multi') + const uiMultiOrigin = await uiMulti.bindPort(uiPort, { + protocol: 'http', + }) + const ui = sdk.createInterface(effects, { + name: i18n('Web UI'), + id: 'ui', + description: i18n( + 'Browse your RSS feeds, Nostr long-form content, and video subscriptions', + ), + type: 'ui', + masked: false, + schemeOverride: null, + username: null, + path: '', + query: {}, + }) + + const uiReceipt = await uiMultiOrigin.export([ui]) + + return [uiReceipt] +}) diff --git a/startos/main.ts b/startos/main.ts new file mode 100644 index 0000000..66296a9 --- /dev/null +++ b/startos/main.ts @@ -0,0 +1,71 @@ +import { storeJson } from './fileModels/store.json' +import { i18n } from './i18n' +import { sdk } from './sdk' +import { uiPort } from './utils' + +export const main = sdk.setupMain(async ({ effects }) => { + console.info(i18n('Starting Readstr')) + + const store = await storeJson.read().const(effects) + if (!store) throw new Error('no store.json') + + // The image bundles PostgreSQL; the entrypoint boots it on localhost, runs + // prisma migrations, then starts the Next.js server. Both the database and + // the app data live under the mounted volume so backups capture everything. + // Readstr's NIP-98 auth binds each signed request to the host the browser + // used and rejects hosts not on its allow-list. Allow every address StartOS + // assigns this interface (.onion, .local, LAN IP) so login works over Tor and + // LAN out of the box, plus any custom hosts set via Configure. .const() reruns + // this (restarting the daemon) if the assigned addresses change later, e.g. + // when a Tor address is added. + const ui = await sdk.serviceInterface.getOwn(effects, 'ui').const() + const assignedHosts = (ui?.addressInfo?.hostnames ?? []) + .map((h) => h.hostname) + .filter( + (h) => + !!h && + !['localhost', '127.0.0.1', '::1'].includes(h) && + !h.startsWith('fe80'), + ) + const userHosts = (store.nip98AllowedHosts ?? '') + .split(',') + .map((h) => h.trim()) + .filter(Boolean) + const allowedHosts = [...new Set([...assignedHosts, ...userHosts])] + + const env: Record = { + NODE_ENV: 'production', + PORT: `${uiPort}`, + HOSTNAME: '0.0.0.0', + PGDATA: '/data/postgres', + DB_PASSWORD: store.dbPassword, + DEFAULT_RELAYS: store.defaultRelays.join(','), + } + if (allowedHosts.length) env.NIP98_ALLOWED_HOSTS = allowedHosts.join(',') + + const subcontainer = await sdk.SubContainer.of( + effects, + { imageId: 'readstr' }, + sdk.Mounts.of().mountVolume({ + volumeId: 'main', + subpath: null, + mountpoint: '/data', + readonly: false, + }), + 'readstr', + ) + + return sdk.Daemons.of(effects).addDaemon('primary', { + subcontainer, + exec: { command: ['/usr/local/bin/docker_entrypoint.sh'], env }, + ready: { + display: i18n('Web UI'), + fn: () => + sdk.healthCheck.checkPortListening(effects, uiPort, { + successMessage: i18n('The Readstr web UI is ready'), + errorMessage: i18n('The Readstr web UI is not responding'), + }), + }, + requires: [], + }) +}) diff --git a/startos/manifest/i18n.ts b/startos/manifest/i18n.ts new file mode 100644 index 0000000..6ee67b6 --- /dev/null +++ b/startos/manifest/i18n.ts @@ -0,0 +1,8 @@ +export const short = { + en_US: 'Google Reader-style feed aggregator for RSS and Nostr', +} + +export const long = { + en_US: + 'Readstr is a self-hosted feed aggregator that brings RSS/Atom feeds together with Nostr long-form content (NIP-23) and video feeds from YouTube and Rumble in a clean, three-panel reading interface. You sign in with your own Nostr key over NIP-07 or a NIP-46 remote signer, organize subscriptions with tags or categories, and sync them across devices over Nostr. Running it on your StartOS server gives you a private, always-on reader with its own bundled database, so your feeds and reading state stay on hardware you control.', +} diff --git a/startos/manifest/index.ts b/startos/manifest/index.ts new file mode 100644 index 0000000..ea4bd28 --- /dev/null +++ b/startos/manifest/index.ts @@ -0,0 +1,34 @@ +import { setupManifest } from '@start9labs/start-sdk' +import { long, short } from './i18n' + +export const manifest = setupManifest({ + id: 'readstr', + title: 'Readstr', + license: 'MIT', + packageRepo: 'https://github.com/privkeyio/readstr-startos', + upstreamRepo: 'https://github.com/privkeyio/readstr', + marketingUrl: 'https://github.com/privkeyio/readstr', + donationUrl: null, + description: { + short, + long, + }, + volumes: ['main'], + images: { + readstr: { + source: { + dockerBuild: {}, + }, + arch: ['x86_64', 'aarch64'], + }, + }, + alerts: { + install: null, + update: null, + uninstall: null, + restore: null, + start: null, + stop: null, + }, + dependencies: {}, +}) diff --git a/startos/sdk.ts b/startos/sdk.ts new file mode 100644 index 0000000..04ae4b1 --- /dev/null +++ b/startos/sdk.ts @@ -0,0 +1,9 @@ +import { StartSdk } from '@start9labs/start-sdk' +import { manifest } from './manifest' + +/** + * Plumbing. DO NOT EDIT. + * + * The exported "sdk" const is used throughout this package codebase. + */ +export const sdk = StartSdk.of().withManifest(manifest).build(true) diff --git a/startos/utils.ts b/startos/utils.ts new file mode 100644 index 0000000..97424b6 --- /dev/null +++ b/startos/utils.ts @@ -0,0 +1,7 @@ +export const uiPort = 3000 + +export const defaultRelays = [ + 'wss://relay.damus.io', + 'wss://nos.lol', + 'wss://relay.nostr.band', +] diff --git a/startos/versions/index.ts b/startos/versions/index.ts new file mode 100644 index 0000000..509ffcf --- /dev/null +++ b/startos/versions/index.ts @@ -0,0 +1,7 @@ +import { VersionGraph } from '@start9labs/start-sdk' +import { v0_1_0_0 } from './v0.1.0_0' + +export const versionGraph = VersionGraph.of({ + current: v0_1_0_0, + other: [], +}) diff --git a/startos/versions/v0.1.0_0.ts b/startos/versions/v0.1.0_0.ts new file mode 100644 index 0000000..0219326 --- /dev/null +++ b/startos/versions/v0.1.0_0.ts @@ -0,0 +1,12 @@ +import { IMPOSSIBLE, VersionInfo } from '@start9labs/start-sdk' + +export const v0_1_0_0 = VersionInfo.of({ + version: '0.1.0:0', + releaseNotes: { + en_US: 'Initial release of Readstr for StartOS.', + }, + migrations: { + up: async ({ effects }) => {}, + down: IMPOSSIBLE, + }, +}) diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..a2945a5 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,11 @@ +{ + "include": ["startos/**/*.ts", "node_modules/**/startos"], + "compilerOptions": { + "target": "ES2018", + "module": "CommonJS", + "moduleResolution": "node", + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true + } +}