Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e7adfb38d | ||
|
|
033ba63762 | ||
|
|
e6eba7a2b7 | ||
|
|
723522b90d | ||
|
|
e7094014b7 | ||
|
|
82798d5c5a | ||
|
|
ef9c73501c | ||
|
|
872066e5be | ||
|
|
a41a944742 | ||
|
|
1ec5d88fa2 | ||
|
|
699855849d | ||
|
|
94f5da4013 | ||
|
|
4fd6ac890c | ||
|
|
115c81cba3 | ||
|
|
a54d998ba9 | ||
|
|
860f02b2c1 | ||
|
|
293378293d | ||
|
|
c5abb6fd64 | ||
|
|
1fb61377b9 | ||
|
|
1a1067bbb6 | ||
|
|
71d2109b2f | ||
|
|
7f08384372 | ||
|
|
7d003021d1 | ||
|
|
50a8f12c77 | ||
|
|
4ceb5cd66a | ||
|
|
c4158ee245 | ||
|
|
61c68761df | ||
|
|
c04b8b47eb | ||
|
|
ce6bba0510 | ||
|
|
9ea399ded5 | ||
|
|
9f7723d104 | ||
|
|
5b84843217 | ||
|
|
bd8decb4b3 | ||
|
|
52f43d4d61 | ||
|
|
7eaba5e3f3 | ||
|
|
124db82a0c | ||
|
|
b4d001eb26 | ||
|
|
60c51a1635 | ||
|
|
d208b0c277 | ||
|
|
2f836cd366 | ||
|
|
f7f2cf75ab | ||
|
|
3deed92dd9 | ||
|
|
d525bbb8c5 | ||
|
|
310e9b86be | ||
|
|
e291b5e411 | ||
|
|
c899253e52 | ||
|
|
ff55d9d486 | ||
|
|
e58021408c | ||
|
|
2afe3453f0 | ||
|
|
e89966435a | ||
|
|
f5451e1b14 | ||
|
|
ef5545465d | ||
|
|
1830051732 | ||
|
|
bbb190cbda | ||
|
|
fb662bfe12 | ||
|
|
0fc6abeb6e | ||
|
|
75316b0882 | ||
|
|
c022f2795f | ||
|
|
688a1335cb | ||
|
|
89e13441c8 | ||
|
|
63afd2fc89 | ||
|
|
d80de49768 | ||
|
|
b92a0d837e | ||
|
|
d7da0ff203 | ||
|
|
bfe62d7078 | ||
|
|
37407a5ecd | ||
|
|
cad3867027 | ||
|
|
20526e1bc8 | ||
|
|
c70b0095a9 | ||
|
|
08db5374cb | ||
|
|
6c7cbee808 | ||
|
|
8456b8f976 | ||
|
|
747f1b9c5c | ||
|
|
89467d6f93 | ||
|
|
8e13aaa057 | ||
|
|
aa5052ba8a | ||
|
|
97989fbfb4 | ||
|
|
bf836e208b | ||
|
|
53a36cd11a | ||
|
|
d2d6ce9756 | ||
|
|
7d1895308d | ||
|
|
0b3aa42a09 | ||
|
|
6d81b63d86 | ||
|
|
c4929a8b1b | ||
|
|
0ec7ddba81 | ||
|
|
3100965c30 | ||
|
|
9d2eeb0963 | ||
|
|
fd8659a964 | ||
|
|
7457146846 | ||
|
|
321428ba1f | ||
|
|
cf4172e81a | ||
|
|
e2d09c0d52 | ||
|
|
cb524675cc | ||
|
|
4758ca46fa | ||
|
|
21d783e257 | ||
|
|
3f75ff6e36 | ||
|
|
74ae929ab4 | ||
|
|
807afba45a | ||
|
|
1e6c7c6042 | ||
|
|
43cab41e60 | ||
|
|
d76e06a023 | ||
|
|
c79bf91398 | ||
|
|
94fe73522d | ||
|
|
180228f2d1 | ||
|
|
c7d88481ad | ||
|
|
9b6d1b18eb | ||
|
|
628e4dec3e | ||
|
|
0727d5a3b3 | ||
|
|
3513a3dde9 | ||
|
|
95b545d8b2 | ||
|
|
85e1975fa5 | ||
|
|
26bb0be7af | ||
|
|
e27e52f267 | ||
|
|
531b496618 | ||
|
|
e7f41b5609 | ||
|
|
24accc3647 | ||
|
|
028005d487 | ||
|
|
9d3f72be75 |
@@ -4,19 +4,54 @@
|
|||||||
# Your server's public domain name (required)
|
# Your server's public domain name (required)
|
||||||
DOMAIN=example.com
|
DOMAIN=example.com
|
||||||
|
|
||||||
|
# ─── Deployment mode ───────────────────────────────────────
|
||||||
|
# How this instance is exposed to the internet. ./install.sh sets these for you;
|
||||||
|
# only touch them for a fully manual setup.
|
||||||
|
#
|
||||||
|
# allinone (default) — the bundled Caddy owns ports 80/443 and does automatic
|
||||||
|
# HTTPS for DOMAIN. Requires 80/443 free on this host.
|
||||||
|
# proxy — you run your OWN reverse proxy (nginx, Traefik, Caddy,
|
||||||
|
# Nginx Proxy Manager, SWAG …). No bundled Caddy; the app
|
||||||
|
# is published on 127.0.0.1:APP_PORT for your proxy to
|
||||||
|
# forward to. Use with:
|
||||||
|
# docker compose -f docker-compose.yml \
|
||||||
|
# -f docker-compose.proxy.yml up -d
|
||||||
|
# tunnel — same as proxy, but fronted by a tunnel (Cloudflare
|
||||||
|
# Tunnel, Tailscale …). Point the tunnel's ingress at
|
||||||
|
# http://127.0.0.1:APP_PORT. NOTE: voice/WebRTC does NOT
|
||||||
|
# traverse a tunnel, and Cloudflare caps request bodies
|
||||||
|
# at 100 MB — set MAX_UPLOAD_SIZE below that (see below).
|
||||||
|
DEPLOY_MODE=allinone
|
||||||
|
|
||||||
|
# Host loopback port the app is published on in `proxy`/`tunnel` mode (ignored in
|
||||||
|
# `allinone` mode). Your reverse proxy / tunnel forwards to 127.0.0.1:APP_PORT.
|
||||||
|
# APP_PORT=8080
|
||||||
|
|
||||||
# ─── Server ─────────────────────────────────────────────────
|
# ─── Server ─────────────────────────────────────────────────
|
||||||
# Production/Docker listen port (Caddy reverse-proxies to it). Local development
|
# Production/Docker listen port (Caddy reverse-proxies to it). Local development
|
||||||
# ignores this and uses 3005 — the Vite dev proxy target — set by `pnpm dev`.
|
# ignores this and uses 3005 — the Vite dev proxy target — set by `pnpm dev`.
|
||||||
PORT=3000
|
PORT=3000
|
||||||
HOST=0.0.0.0
|
HOST=0.0.0.0
|
||||||
|
|
||||||
# Authentication — generate with: openssl rand -hex 32
|
# ─── Authentication (REQUIRED) ──────────────────────────────
|
||||||
|
# Secret that signs login tokens. This MUST be set to a strong random value.
|
||||||
|
# Leaving it empty is intentional — `docker compose up` then fails immediately
|
||||||
|
# with a clear message instead of the server boot-looping. Generate one with:
|
||||||
|
#
|
||||||
|
# openssl rand -hex 32
|
||||||
|
#
|
||||||
|
# (./install.sh fills this in for you automatically — you only touch this when
|
||||||
|
# configuring by hand.) Must be at least 32 characters.
|
||||||
JWT_SECRET=
|
JWT_SECRET=
|
||||||
|
|
||||||
# Registration — set to false to close signups after initial setup
|
# Registration — set to false to close signups after initial setup
|
||||||
REGISTRATION_OPEN=true
|
REGISTRATION_OPEN=true
|
||||||
|
|
||||||
# Max file upload size in bytes (default: 100MB)
|
# Max file upload size in bytes (default: 100MB)
|
||||||
|
# TUNNEL USERS: Cloudflare (free/pro) hard-caps request bodies at 100 MB, so the
|
||||||
|
# 100 MB default lets uploads fail at the edge. Set this below the cap, e.g.
|
||||||
|
# 94371840 (90 MB), leaving headroom for multipart overhead. install.sh does
|
||||||
|
# this automatically when you pick `tunnel` mode.
|
||||||
MAX_UPLOAD_SIZE=104857600
|
MAX_UPLOAD_SIZE=104857600
|
||||||
|
|
||||||
# ─── AGPL-3.0 § 13 Source Offer ────────────────────────────
|
# ─── AGPL-3.0 § 13 Source Offer ────────────────────────────
|
||||||
@@ -31,6 +66,13 @@ MAX_UPLOAD_SIZE=104857600
|
|||||||
# dev — the app reports commit=null. Set manually only for custom build pipelines.
|
# dev — the app reports commit=null. Set manually only for custom build pipelines.
|
||||||
# BACKSPACE_COMMIT=
|
# BACKSPACE_COMMIT=
|
||||||
|
|
||||||
|
# ─── Prebuilt image (GHCR) ─────────────────────────────────
|
||||||
|
# By default the stack pulls the prebuilt multi-arch image
|
||||||
|
# ghcr.io/thezwiss/backspace:latest (published from tagged releases). Pin a
|
||||||
|
# specific version for reproducibility, or point at your own fork's registry.
|
||||||
|
# BACKSPACE_IMAGE=ghcr.io/thezwiss/backspace
|
||||||
|
# BACKSPACE_IMAGE_TAG=latest
|
||||||
|
|
||||||
# ─── LiveKit Voice/Video ───────────────────────────────────
|
# ─── LiveKit Voice/Video ───────────────────────────────────
|
||||||
# To enable voice/video, fill in all three values below and add:
|
# To enable voice/video, fill in all three values below and add:
|
||||||
# COMPOSE_PROFILES=voice
|
# COMPOSE_PROFILES=voice
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
name: Bug report
|
||||||
|
description: Report something that is broken or behaving incorrectly.
|
||||||
|
labels: ["bug"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Thanks for taking the time to file a bug. Please search existing issues first to avoid duplicates.
|
||||||
|
For security vulnerabilities, do not open a public issue. See SECURITY.md and report privately.
|
||||||
|
- type: textarea
|
||||||
|
id: what-happened
|
||||||
|
attributes:
|
||||||
|
label: What happened
|
||||||
|
description: A clear description of the bug and what you expected instead.
|
||||||
|
placeholder: When I do X, Y happens. I expected Z.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: repro
|
||||||
|
attributes:
|
||||||
|
label: Steps to reproduce
|
||||||
|
description: The exact steps that trigger the problem.
|
||||||
|
placeholder: |
|
||||||
|
1. Go to ...
|
||||||
|
2. Click ...
|
||||||
|
3. See ...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
id: deploy-mode
|
||||||
|
attributes:
|
||||||
|
label: Deployment mode
|
||||||
|
description: How is this instance deployed?
|
||||||
|
options:
|
||||||
|
- All-in-One (bundled Caddy)
|
||||||
|
- Behind my own reverse proxy
|
||||||
|
- Behind a tunnel (Cloudflare, Tailscale)
|
||||||
|
- Local development (pnpm dev)
|
||||||
|
- Not sure
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
id: surface
|
||||||
|
attributes:
|
||||||
|
label: Where did it happen
|
||||||
|
description: Which client were you using?
|
||||||
|
options:
|
||||||
|
- Web browser
|
||||||
|
- Desktop app (Electron)
|
||||||
|
- Installed PWA on mobile
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
id: version
|
||||||
|
attributes:
|
||||||
|
label: Backspace version
|
||||||
|
description: The version shown in the app, or the image tag / commit you deployed.
|
||||||
|
placeholder: "1.0.0"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
id: subsystems
|
||||||
|
attributes:
|
||||||
|
label: Does it involve voice or federation
|
||||||
|
description: These subsystems have their own moving parts, so it helps to know up front.
|
||||||
|
multiple: true
|
||||||
|
options:
|
||||||
|
- Voice or video (LiveKit)
|
||||||
|
- Screen sharing
|
||||||
|
- Federation between instances
|
||||||
|
- None of these
|
||||||
|
- type: textarea
|
||||||
|
id: logs
|
||||||
|
attributes:
|
||||||
|
label: Relevant logs
|
||||||
|
description: Server logs (docker compose logs backspace) or browser console output, if any. This is automatically formatted as code.
|
||||||
|
render: shell
|
||||||
|
- type: input
|
||||||
|
id: environment
|
||||||
|
attributes:
|
||||||
|
label: Browser and OS
|
||||||
|
placeholder: "Firefox 128 on Ubuntu 24.04, or Chrome 126 on Windows 11"
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
|
contact_links:
|
||||||
|
- name: Security vulnerability
|
||||||
|
url: https://github.com/TheZwiss/backspace/security/advisories/new
|
||||||
|
about: Do not open a public issue for security problems. Report privately here.
|
||||||
|
- name: Question or setup help
|
||||||
|
url: https://github.com/TheZwiss/backspace/discussions
|
||||||
|
about: For usage questions, deployment help, and general discussion.
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
name: Feature request
|
||||||
|
description: Suggest a new capability or an improvement to an existing one.
|
||||||
|
labels: ["enhancement"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Open an issue before starting work on anything non-trivial, so we can agree on direction first.
|
||||||
|
Please check the README feature list and existing issues before filing.
|
||||||
|
- type: textarea
|
||||||
|
id: problem
|
||||||
|
attributes:
|
||||||
|
label: The problem
|
||||||
|
description: What are you trying to do that Backspace does not support today?
|
||||||
|
placeholder: As a space admin, I want to ... so that ...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: proposal
|
||||||
|
attributes:
|
||||||
|
label: Proposed solution
|
||||||
|
description: How you imagine it working. Rough ideas are fine.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
id: area
|
||||||
|
attributes:
|
||||||
|
label: Area
|
||||||
|
description: Which part of the project does this touch?
|
||||||
|
options:
|
||||||
|
- Text chat and messaging
|
||||||
|
- Voice, video, or screen sharing
|
||||||
|
- Spaces, roles, and permissions
|
||||||
|
- Direct messages and friends
|
||||||
|
- Federation between instances
|
||||||
|
- Admin and moderation
|
||||||
|
- Desktop app
|
||||||
|
- Mobile and PWA
|
||||||
|
- Deployment and self-hosting
|
||||||
|
- Other
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: alternatives
|
||||||
|
attributes:
|
||||||
|
label: Alternatives considered
|
||||||
|
description: Workarounds you have tried, or how other tools handle this.
|
||||||
|
- type: dropdown
|
||||||
|
id: federation-impact
|
||||||
|
attributes:
|
||||||
|
label: Federation compatibility
|
||||||
|
description: Would this need to work across peered instances? If unsure, leave the default.
|
||||||
|
options:
|
||||||
|
- Not sure
|
||||||
|
- Yes, it should work across federated instances
|
||||||
|
- No, it is local to a single instance
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<!-- Thanks for contributing. Keep one logical change per pull request. -->
|
||||||
|
|
||||||
|
## What this changes
|
||||||
|
|
||||||
|
<!-- A short summary of the change and the problem it solves. Link the issue it addresses. -->
|
||||||
|
|
||||||
|
Closes #
|
||||||
|
|
||||||
|
## Type of change
|
||||||
|
|
||||||
|
- [ ] Bug fix
|
||||||
|
- [ ] New feature
|
||||||
|
- [ ] Refactor or cleanup
|
||||||
|
- [ ] Documentation
|
||||||
|
- [ ] Other
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ] `pnpm build` succeeds (shared types, server, and web all build)
|
||||||
|
- [ ] `pnpm dev` starts the server and web client without errors
|
||||||
|
- [ ] Tests pass where applicable (`pnpm test`)
|
||||||
|
- [ ] I updated the relevant `docs/systems/` spec if this changed schema, API routes, WebSocket events, the federation protocol, permissions, or the design system
|
||||||
|
- [ ] This change resolves the correct federated identity where it compares IDs, checks permissions, or talks to remote servers (no assumption of a single global user ID)
|
||||||
|
- [ ] I have read and agree to the [CLA](../CLA.md) — ticking this box is not the
|
||||||
|
signature. After opening this PR, post a separate comment containing exactly:
|
||||||
|
`I have read the CLA Document and I hereby sign the CLA`
|
||||||
|
|
||||||
|
## Notes for reviewers
|
||||||
|
|
||||||
|
<!-- Anything that helps review: screenshots for UI, migration notes, edge cases, follow-ups. -->
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Dependabot keeps dependencies and CI actions patched. Three ecosystems:
|
||||||
|
# - npm → the pnpm workspace (Dependabot reads pnpm-lock.yaml v9)
|
||||||
|
# - github-actions → action version bumps (feeds the SHA-pin comments)
|
||||||
|
# - docker → the Dockerfile base image (FROM node:20-slim)
|
||||||
|
#
|
||||||
|
# NOTE (intentional): there is NO docker entry for docker-compose.yml. It sits
|
||||||
|
# at the same "/" directory (a second docker entry would collide on
|
||||||
|
# ecosystem+directory), and Dependabot's docker ecosystem parses Dockerfiles,
|
||||||
|
# not `image:` refs in compose. The pinned caddy / livekit-server compose images
|
||||||
|
# are updated MANUALLY — see the maintainer checklist in
|
||||||
|
# docs/systems/security-scanning.md.
|
||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: npm
|
||||||
|
directory: /
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
|
open-pull-requests-limit: 10
|
||||||
|
groups:
|
||||||
|
# One grouped PR for routine minor/patch bumps to cut PR noise.
|
||||||
|
npm-minor-patch:
|
||||||
|
update-types:
|
||||||
|
- minor
|
||||||
|
- patch
|
||||||
|
ignore:
|
||||||
|
# uiohook-napi is pinned by an exact-version pnpm patch
|
||||||
|
# (patches/uiohook-napi@1.5.5.patch). A bump makes the patch path stop
|
||||||
|
# matching, breaking `pnpm install --frozen-lockfile` in CI and both
|
||||||
|
# Docker stages until the patch is regenerated. Bump it by hand.
|
||||||
|
- dependency-name: uiohook-napi
|
||||||
|
|
||||||
|
- package-ecosystem: github-actions
|
||||||
|
directory: /
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
|
groups:
|
||||||
|
github-actions:
|
||||||
|
patterns:
|
||||||
|
- "*"
|
||||||
|
|
||||||
|
- package-ecosystem: docker
|
||||||
|
directory: /
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# Caminho rápido: só Windows x64, disparado à mão.
|
||||||
|
#
|
||||||
|
# O `release.yml` continua sendo o release de verdade (todas as plataformas,
|
||||||
|
# publica release e alimenta o electron-updater). Este aqui existe para quando
|
||||||
|
# você só quer um .exe para testar, sem marcar versão.
|
||||||
|
#
|
||||||
|
# Arquivo separado de propósito: o release.yml veio do upstream e recebe merges;
|
||||||
|
# mexer nele criaria conflito a cada atualização.
|
||||||
|
name: Build Windows (rápido)
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
arch:
|
||||||
|
description: Arquitetura
|
||||||
|
required: false
|
||||||
|
default: x64
|
||||||
|
type: choice
|
||||||
|
options: [x64, arm64]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
# windows-2022, não windows-latest: a imagem latest traz o Visual Studio 18,
|
||||||
|
# que o node-gyp embutido no electron-rebuild não detecta ao compilar os
|
||||||
|
# módulos nativos. Mesma razão documentada no release.yml.
|
||||||
|
runs-on: windows-2022
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||||
|
with:
|
||||||
|
version: 10.34.3
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
|
# ~100MB de binários do Electron e das ferramentas do NSIS, baixados a
|
||||||
|
# cada execução sem isto. É o maior ganho depois de cortar o arm64.
|
||||||
|
- name: Cache Electron binaries
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~\AppData\Local\electron\Cache
|
||||||
|
~\AppData\Local\electron-builder\Cache
|
||||||
|
key: electron-cache-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||||
|
restore-keys: electron-cache-${{ runner.os }}-
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build shared package
|
||||||
|
run: pnpm --filter @backspace/shared build
|
||||||
|
|
||||||
|
# O postinstall termina em `|| console.warn` para que quem não tem
|
||||||
|
# ferramentas de build consiga instalar. No CI isso transforma falha em
|
||||||
|
# sucesso silencioso: o instalador sai sem o módulo nativo e o app
|
||||||
|
# compartilha tela sem som, sem erro nenhum. Então verifica-se o
|
||||||
|
# resultado em vez de confiar no código de saída.
|
||||||
|
- name: Verify native audio module compiled
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
found=$(find node_modules/.pnpm -path '*electron-native-screenshare*' -name '*.node' 2>/dev/null | head -5)
|
||||||
|
if [ -z "$found" ]; then
|
||||||
|
echo "::error::electron-native-screenshare has no compiled .node — the installer would ship without system-audio capture"
|
||||||
|
find node_modules/.pnpm -maxdepth 1 -name 'electron-native-screenshare*' -printf '%p\n' 2>/dev/null || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "OK:"; echo "$found"
|
||||||
|
|
||||||
|
- name: Compile desktop TypeScript
|
||||||
|
working-directory: packages/desktop
|
||||||
|
run: pnpm exec tsc
|
||||||
|
|
||||||
|
# --publish never: sem release, sem precisar de tag nem bump de versão.
|
||||||
|
# O instalador sai como artefato desta execução.
|
||||||
|
- name: Build installer
|
||||||
|
working-directory: packages/desktop
|
||||||
|
run: pnpm exec electron-builder --win --${{ inputs.arch || 'x64' }} --publish never
|
||||||
|
env:
|
||||||
|
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||||
|
|
||||||
|
- name: Upload installer
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: backspace-windows-${{ inputs.arch || 'x64' }}
|
||||||
|
path: packages/desktop/dist-electron/*.exe
|
||||||
|
retention-days: 14
|
||||||
|
if-no-files-found: error
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
# Runs the workspace's typecheck, production build, and full test suite on every
|
||||||
|
# pull request and on pushes to main. Purpose: catch compile errors and test
|
||||||
|
# regressions before merge instead of relying on each contributor running tests
|
||||||
|
# locally. Once this check is green on a PR, enable branch protection on `main`
|
||||||
|
# ("Require status checks to pass" → select "Build & test") to make it blocking.
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
# A newer commit on the same branch/PR supersedes in-flight runs — cancel the
|
||||||
|
# stale one so a rapid push sequence doesn't queue redundant CI.
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-test:
|
||||||
|
name: Build & test (Node ${{ matrix.node-version }})
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
node-version: [20, 24]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Harden the runner
|
||||||
|
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||||
|
with:
|
||||||
|
egress-policy: audit
|
||||||
|
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||||
|
with:
|
||||||
|
version: 10.34.3
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||||
|
with:
|
||||||
|
node-version: ${{ matrix.node-version }}
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
|
# The desktop postinstall tries to rebuild the native uiohook-napi module.
|
||||||
|
# It fails gracefully (|| warn) without X11 dev headers, and nothing in CI
|
||||||
|
# needs the native binary — the desktop tests and TS compile are pure JS —
|
||||||
|
# so we intentionally skip installing those headers to keep CI fast.
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# Compiles shared → server → web (each runs tsc; web also runs the Vite
|
||||||
|
# production build). This is the typecheck + build gate for those three.
|
||||||
|
- name: Build shared, server & web
|
||||||
|
run: pnpm build
|
||||||
|
|
||||||
|
# Desktop is not part of `pnpm build` (that produces an Electron installer,
|
||||||
|
# which release.yml owns). Type-check its source here so desktop TS errors
|
||||||
|
# surface on PRs rather than only at release-tag time.
|
||||||
|
- name: Typecheck desktop
|
||||||
|
run: pnpm --filter @backspace/desktop build:ts
|
||||||
|
|
||||||
|
# Runs every package's `test` script (server, web, desktop) via vitest.
|
||||||
|
- name: Test
|
||||||
|
run: pnpm -r test
|
||||||
|
|
||||||
|
# Aggregate gate reporting a single, matrix-independent "Build & test" status.
|
||||||
|
# Branch protection on main requires the "Build & test" context, but the matrix
|
||||||
|
# job above reports per-version contexts ("Build & test (Node 20/24)"). This job
|
||||||
|
# keeps the stable required context alive and fails unless every matrix leg
|
||||||
|
# succeeded (if: always() so a matrix failure still reports a definitive result
|
||||||
|
# instead of leaving the required check pending forever).
|
||||||
|
build-and-test-required:
|
||||||
|
name: Build & test
|
||||||
|
if: always()
|
||||||
|
needs: build-and-test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Verify matrix result
|
||||||
|
run: |
|
||||||
|
if [ "${{ needs.build-and-test.result }}" != "success" ]; then
|
||||||
|
echo "Matrix build-and-test did not succeed: ${{ needs.build-and-test.result }}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "All matrix legs passed."
|
||||||
@@ -2,8 +2,16 @@ name: CLA Assistant
|
|||||||
|
|
||||||
# Requires every contributor to sign the project Contributor License Agreement
|
# Requires every contributor to sign the project Contributor License Agreement
|
||||||
# (CLA.md) before their pull request can be merged. Signatures are stored in
|
# (CLA.md) before their pull request can be merged. Signatures are stored in
|
||||||
# this repository at signatures/cla.json — no external service or database is
|
# this repository at signatures/cla.json on the `cla-signatures` branch — no
|
||||||
# used. A contributor signs by commenting the exact sentence configured below.
|
# external service or database is used. A contributor signs by commenting the
|
||||||
|
# exact sentence configured below.
|
||||||
|
#
|
||||||
|
# Why not `main`: the action appends each signature as a direct commit, which
|
||||||
|
# the "Require CI on main" ruleset rejects ("Repository rule violations found"),
|
||||||
|
# leaving the signature unrecorded and the check permanently red. Keeping the
|
||||||
|
# store on its own branch lets the bot append without granting any actor a
|
||||||
|
# bypass on main. That branch has its own ruleset blocking deletion and
|
||||||
|
# force-pushes, so the record cannot be rewritten.
|
||||||
|
|
||||||
on:
|
on:
|
||||||
issue_comment:
|
issue_comment:
|
||||||
@@ -29,7 +37,7 @@ jobs:
|
|||||||
(github.event.comment.body == 'recheck' ||
|
(github.event.comment.body == 'recheck' ||
|
||||||
github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') ||
|
github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') ||
|
||||||
github.event_name == 'pull_request_target'
|
github.event_name == 'pull_request_target'
|
||||||
uses: contributor-assistant/github-action@v2.6.1
|
uses: contributor-assistant/github-action@ca4a40a7d1004f18d9960b404b97e5f30a505a08 # v2.6.1
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
with:
|
with:
|
||||||
@@ -37,7 +45,7 @@ jobs:
|
|||||||
path-to-signatures: 'signatures/cla.json'
|
path-to-signatures: 'signatures/cla.json'
|
||||||
# The CLA document contributors are agreeing to.
|
# The CLA document contributors are agreeing to.
|
||||||
path-to-document: 'https://github.com/TheZwiss/backspace/blob/main/CLA.md'
|
path-to-document: 'https://github.com/TheZwiss/backspace/blob/main/CLA.md'
|
||||||
branch: 'main'
|
branch: 'cla-signatures'
|
||||||
# Accounts that never need to sign (maintainer + automation).
|
# Accounts that never need to sign (maintainer + automation).
|
||||||
allowlist: 'TheZwiss,dependabot[bot],github-actions[bot]'
|
allowlist: 'TheZwiss,dependabot[bot],github-actions[bot]'
|
||||||
# The exact phrase a contributor comments to sign.
|
# The exact phrase a contributor comments to sign.
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
name: CodeQL
|
||||||
|
|
||||||
|
# Static application security testing for all TS/JS. Uses build-mode: none — no
|
||||||
|
# compile needed, which sidesteps the monorepo/native-module build entirely.
|
||||||
|
# Default (code-scanning) query suite; security-extended is deferred (triage tax).
|
||||||
|
# CodeQL uploads alerts to the Security tab but does NOT fail the PR by itself —
|
||||||
|
# blocking is a repo setting (code-scanning merge protection), documented in the
|
||||||
|
# maintainer checklist in docs/systems/security-scanning.md.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
schedule:
|
||||||
|
- cron: '27 3 * * 1' # weekly Monday 03:27 UTC
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: codeql-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
analyze:
|
||||||
|
name: Analyze (javascript-typescript)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
security-events: write # upload SARIF to code scanning
|
||||||
|
actions: read
|
||||||
|
steps:
|
||||||
|
- name: Harden the runner
|
||||||
|
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||||
|
with:
|
||||||
|
egress-policy: audit
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
|
- name: Initialize CodeQL
|
||||||
|
uses: github/codeql-action/init@02c5e83432fe5497fd85b873b6c9f16a8578e1d9 # v3.37.0
|
||||||
|
with:
|
||||||
|
languages: javascript-typescript
|
||||||
|
build-mode: none
|
||||||
|
- name: Perform CodeQL analysis
|
||||||
|
uses: github/codeql-action/analyze@02c5e83432fe5497fd85b873b6c9f16a8578e1d9 # v3.37.0
|
||||||
|
with:
|
||||||
|
category: "/language:javascript-typescript"
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
name: Deploy landing page
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'site/**'
|
||||||
|
- '.github/workflows/deploy-pages.yml'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pages: write
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: pages
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
environment:
|
||||||
|
name: github-pages
|
||||||
|
url: ${{ steps.deployment.outputs.page_url }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
|
- uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
|
||||||
|
- uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
|
||||||
|
with:
|
||||||
|
path: ./site
|
||||||
|
- id: deployment
|
||||||
|
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
name: Publish Container Image
|
||||||
|
|
||||||
|
# Builds and publishes the Backspace application image to the GitHub Container
|
||||||
|
# Registry (GHCR) as a multi-architecture (linux/amd64 + linux/arm64) image, so
|
||||||
|
# self-hosters — including weak/ARM boxes like a Raspberry Pi — can `docker pull`
|
||||||
|
# a prebuilt image instead of building the ~1.6 GB image locally (the Vite build
|
||||||
|
# OOMs small ARM hosts). install.sh and docker-compose.yml default to pulling
|
||||||
|
# this image, with a from-source build as the fallback.
|
||||||
|
#
|
||||||
|
# This is intentionally SEPARATE from the desktop-installer workflow
|
||||||
|
# (release.yml) — they share the `v*` tag trigger but build entirely different
|
||||||
|
# artifacts and must not be entangled.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
# Allow a manual rebuild/publish (e.g. to (re)publish `latest` or a moving tag
|
||||||
|
# without cutting a new release).
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: 'Extra tag to publish (optional, e.g. "edge")'
|
||||||
|
required: false
|
||||||
|
default: ''
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
security-events: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
|
|
||||||
|
# The runtime image bakes the git commit for the AGPL-3.0 § 13 source
|
||||||
|
# offer (config.commit → GET /api/instance/info). The .git dir is not in
|
||||||
|
# the build context (.dockerignore), so resolve the short SHA here and feed
|
||||||
|
# it to the build as a --build-arg, matching install.sh / deploy.sh.
|
||||||
|
- name: Resolve build metadata
|
||||||
|
id: meta_commit
|
||||||
|
run: echo "commit=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||||
|
|
||||||
|
- name: Log in to GitHub Container Registry
|
||||||
|
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Derive image tags and labels
|
||||||
|
id: docker_meta
|
||||||
|
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||||
|
with:
|
||||||
|
# github.repository is "TheZwiss/backspace"; metadata-action lowercases
|
||||||
|
# it → ghcr.io/thezwiss/backspace (GHCR requires lowercase).
|
||||||
|
images: ghcr.io/${{ github.repository }}
|
||||||
|
tags: |
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||||
|
type=sha
|
||||||
|
type=raw,value=${{ github.event.inputs.tag }},enable=${{ github.event.inputs.tag != '' }}
|
||||||
|
labels: |
|
||||||
|
org.opencontainers.image.title=Backspace
|
||||||
|
org.opencontainers.image.description=Self-hosted Discord alternative — text, voice, video, and federation.
|
||||||
|
org.opencontainers.image.source=https://github.com/TheZwiss/backspace
|
||||||
|
org.opencontainers.image.licenses=AGPL-3.0-only
|
||||||
|
org.opencontainers.image.revision=${{ github.sha }}
|
||||||
|
|
||||||
|
# Build a single-arch amd64 image and LOAD it into the runner's docker
|
||||||
|
# daemon so Trivy can scan the exact artifact before anything is published.
|
||||||
|
# A multi-arch manifest cannot be --load'ed, so scanning must happen on a
|
||||||
|
# single-arch build first; the multi-arch push below reuses these layers
|
||||||
|
# from the buildx cache, so this is cheap.
|
||||||
|
- name: Build amd64 image for scanning
|
||||||
|
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: linux/amd64
|
||||||
|
load: true
|
||||||
|
push: false
|
||||||
|
tags: backspace:scan
|
||||||
|
build-args: |
|
||||||
|
BACKSPACE_COMMIT=${{ steps.meta_commit.outputs.commit }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
|
- name: Trivy image scan (report-only)
|
||||||
|
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
||||||
|
continue-on-error: true # report-only; enforcement flipped on in Plan E
|
||||||
|
with:
|
||||||
|
scan-type: image
|
||||||
|
image-ref: backspace:scan
|
||||||
|
ignore-unfixed: true
|
||||||
|
format: sarif
|
||||||
|
output: trivy-image.sarif
|
||||||
|
severity: HIGH,CRITICAL
|
||||||
|
|
||||||
|
- name: Upload Trivy image SARIF
|
||||||
|
if: always()
|
||||||
|
continue-on-error: true # a scanner/SARIF-emit flake must never skip the publish below
|
||||||
|
uses: github/codeql-action/upload-sarif@02c5e83432fe5497fd85b873b6c9f16a8578e1d9 # v3.37.0
|
||||||
|
with:
|
||||||
|
sarif_file: trivy-image.sarif
|
||||||
|
category: trivy-image
|
||||||
|
|
||||||
|
# Publish the multi-arch image. Reuses the amd64 layers built above via the
|
||||||
|
# gha cache. Attaches an SBOM and SLSA provenance attestation to the image.
|
||||||
|
- name: Build and push (linux/amd64, linux/arm64)
|
||||||
|
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.docker_meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.docker_meta.outputs.labels }}
|
||||||
|
build-args: |
|
||||||
|
BACKSPACE_COMMIT=${{ steps.meta_commit.outputs.commit }}
|
||||||
|
sbom: true
|
||||||
|
provenance: true
|
||||||
|
# Cache multi-arch layers across runs via the GitHub Actions cache to
|
||||||
|
# keep the ~1.6 GB build from re-running cold every release.
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
# Compila no GitHub, publica no Gitea.
|
||||||
|
#
|
||||||
|
# O GitHub entra só como máquina de build — é dele que vêm os runners Windows
|
||||||
|
# de que o módulo nativo de áudio precisa. A distribuição fica no Gitea, que
|
||||||
|
# serve os arquivos a qualquer um: assim o electron-updater não precisa de
|
||||||
|
# credencial embutida no app, o que aconteceria com um repositório privado no
|
||||||
|
# GitHub.
|
||||||
|
name: Publicar no Gitea
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ['v*']
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: Tag a publicar (ex. v1.1.0)
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- os: windows-2022
|
||||||
|
args: --win --x64
|
||||||
|
- os: ubuntu-latest
|
||||||
|
args: --linux --x64
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
|
|
||||||
|
- name: Install Linux build dependencies
|
||||||
|
if: runner.os == 'Linux'
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y \
|
||||||
|
libx11-dev libxtst-dev libxt-dev \
|
||||||
|
libxkbcommon-dev libxkbcommon-x11-dev libxkbfile-dev \
|
||||||
|
libxrandr-dev libxinerama-dev libx11-xcb-dev \
|
||||||
|
libpipewire-0.3-dev libpulse-dev
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||||
|
with:
|
||||||
|
version: 10.34.3
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
|
- name: Cache Electron binaries
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cache/electron
|
||||||
|
~/.cache/electron-builder
|
||||||
|
~\AppData\Local\electron\Cache
|
||||||
|
~\AppData\Local\electron-builder\Cache
|
||||||
|
key: electron-cache-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||||
|
restore-keys: electron-cache-${{ runner.os }}-
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build shared package
|
||||||
|
run: pnpm --filter @backspace/shared build
|
||||||
|
|
||||||
|
# O postinstall termina em `|| console.warn` para quem não tem ferramentas
|
||||||
|
# de build. No CI isso esconde falha: o instalador sairia sem captura de
|
||||||
|
# áudio do sistema e ninguém saberia. Verifica-se o resultado.
|
||||||
|
- name: Verify native audio module compiled
|
||||||
|
if: runner.os == 'Windows'
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
found=$(find node_modules/.pnpm -path '*electron-native-screenshare*' -name '*.node' | head -5)
|
||||||
|
[ -n "$found" ] || { echo "::error::sem .node compilado — instalador sairia sem áudio do sistema"; exit 1; }
|
||||||
|
echo "$found"
|
||||||
|
|
||||||
|
- name: Compile desktop TypeScript
|
||||||
|
working-directory: packages/desktop
|
||||||
|
run: pnpm exec tsc
|
||||||
|
|
||||||
|
# --publish never: o electron-builder não sabe enviar para o Gitea. Ele
|
||||||
|
# gera os instaladores e o latest.yml (o índice que o app consulta), e o
|
||||||
|
# passo seguinte faz o upload.
|
||||||
|
- name: Build installers
|
||||||
|
working-directory: packages/desktop
|
||||||
|
run: pnpm exec electron-builder ${{ matrix.args }} --publish never
|
||||||
|
env:
|
||||||
|
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||||
|
|
||||||
|
- name: Upload to Gitea release
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
GITEA_API: https://git.resenha.website/api/v1/repos/devsyncwrld/backspace
|
||||||
|
TAG: latest
|
||||||
|
run: |
|
||||||
|
set -uo pipefail
|
||||||
|
if [ -z "${GITEA_TOKEN:-}" ]; then
|
||||||
|
echo "::error::segredo GITEA_TOKEN não configurado"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Sem -f e imprimindo o corpo: a versão anterior usava `curl -sf`, que
|
||||||
|
# engole a resposta de erro, então uma falha aqui só aparecia como um
|
||||||
|
# JSONDecodeError sem dizer o motivo.
|
||||||
|
api() {
|
||||||
|
local method=$1 path=$2; shift 2
|
||||||
|
curl -s -w '\n%{http_code}' -X "$method" \
|
||||||
|
-H "Authorization: token $GITEA_TOKEN" "$GITEA_API$path" "$@"
|
||||||
|
}
|
||||||
|
body() { sed '$d' <<<"$1"; }
|
||||||
|
code() { tail -n1 <<<"$1"; }
|
||||||
|
# jq em vez de python embutido: um heredoc multilinha dentro de um
|
||||||
|
# bloco literal de YAML encerra o bloco na primeira linha sem recuo.
|
||||||
|
json_id() { jq -r '.id // empty'; }
|
||||||
|
|
||||||
|
find_release() {
|
||||||
|
local r; r=$(api GET "/releases/tags/$TAG")
|
||||||
|
[ "$(code "$r")" = "200" ] && body "$r" | json_id || echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# O corpo do POST fica em ASCII de proposito: o shell do runner
|
||||||
|
# Windows corrompe UTF-8 na requisicao e o Gitea recusa com
|
||||||
|
# "invalid UTF-8 within /name" (HTTP 422).
|
||||||
|
ID=$(find_release)
|
||||||
|
if [ -z "$ID" ]; then
|
||||||
|
R=$(api POST "/releases" -H 'Content-Type: application/json' \
|
||||||
|
-d "{\"tag_name\":\"$TAG\",\"name\":\"Latest build\",\"target_commitish\":\"main\",\"body\":\"Installers published automatically by CI.\"}")
|
||||||
|
if [ "$(code "$R")" = "201" ]; then
|
||||||
|
ID=$(body "$R" | json_id)
|
||||||
|
echo "release criada: $ID"
|
||||||
|
else
|
||||||
|
echo "criação retornou HTTP $(code "$R"): $(body "$R")"
|
||||||
|
# O outro job da matriz pode tê-la criado no mesmo instante.
|
||||||
|
ID=$(find_release)
|
||||||
|
[ -n "$ID" ] || { echo "::error::não foi possível obter nem criar a release"; exit 1; }
|
||||||
|
echo "release encontrada após corrida: $ID"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "release existente: $ID"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Remove só os anexos que esta plataforma vai repor, para os dois jobs
|
||||||
|
# não apagarem o trabalho um do outro.
|
||||||
|
#
|
||||||
|
# Padrão montado em bash puro: a versão anterior interpolava uma
|
||||||
|
# expressão do Actions dentro de um `case`, e quando ela não casou o
|
||||||
|
# laço passou em silêncio — a release ficou com dois latest.yml e o
|
||||||
|
# updater serviu o antigo, dizendo que a versão nova não existia.
|
||||||
|
R=$(api GET "/releases/$ID/assets")
|
||||||
|
if [ "$(code "$R")" != "200" ]; then
|
||||||
|
echo "::error::não foi possível listar os anexos — HTTP $(code "$R"): $(body "$R")"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Filtro dentro do jq, comparando o nome direto. A versão anterior
|
||||||
|
# montava um regex e o casava contra "<id> <nome>", onde o `^` de
|
||||||
|
# `^latest\.yml$` ancorava no início da linha — depois do id — e nunca
|
||||||
|
# podia casar. O laço então só removia os .exe e deixava um latest.yml
|
||||||
|
# duplicado, que é o arquivo que decide se há atualização.
|
||||||
|
OLD=$(body "$R" | jq -r --arg os "${RUNNER_OS:-}" '
|
||||||
|
.[]
|
||||||
|
| select(
|
||||||
|
if $os == "Windows"
|
||||||
|
then (.name | endswith(".exe")) or .name == "latest.yml"
|
||||||
|
else (.name | endswith(".AppImage")) or (.name | endswith(".deb")) or .name == "latest-linux.yml"
|
||||||
|
end
|
||||||
|
)
|
||||||
|
| "\(.id) \(.name)"')
|
||||||
|
echo "anexos desta plataforma já na release: $(printf '%s' "$OLD" | grep -c . || true)"
|
||||||
|
if [ -n "$OLD" ]; then
|
||||||
|
printf '%s\n' "$OLD" | while read -r aid aname; do
|
||||||
|
[ -n "$aid" ] || continue
|
||||||
|
echo "removendo anexo antigo: $aname"
|
||||||
|
D=$(api DELETE "/releases/$ID/assets/$aid")
|
||||||
|
[ "$(code "$D")" = "204" ] || echo "::warning::falha ao remover $aname — HTTP $(code "$D")"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
shopt -s nullglob
|
||||||
|
sent=0
|
||||||
|
for f in packages/desktop/dist-electron/*.exe \
|
||||||
|
packages/desktop/dist-electron/*.AppImage \
|
||||||
|
packages/desktop/dist-electron/*.deb \
|
||||||
|
packages/desktop/dist-electron/latest*.yml; do
|
||||||
|
name=$(basename "$f")
|
||||||
|
R=$(api POST "/releases/$ID/assets?name=$name" -F "attachment=@$f")
|
||||||
|
if [ "$(code "$R")" != "201" ]; then
|
||||||
|
echo "::error::falha ao enviar $name — HTTP $(code "$R"): $(body "$R")"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "enviado: $name"
|
||||||
|
sent=$((sent+1))
|
||||||
|
done
|
||||||
|
[ "$sent" -gt 0 ] || { echo "::error::o build não produziu instaladores"; exit 1; }
|
||||||
|
echo "$sent arquivo(s) publicados"
|
||||||
|
|
||||||
|
# O updater busca latest.yml pelo nome. Duas cópias com o mesmo nome
|
||||||
|
# fazem o Gitea servir a mais antiga, e a atualização deixa de ser
|
||||||
|
# oferecida — sem erro em lugar nenhum. Falha aqui em vez de publicar
|
||||||
|
# uma release que parece boa e não atualiza.
|
||||||
|
if [ "${RUNNER_OS:-}" = "Windows" ]; then
|
||||||
|
R=$(api GET "/releases/$ID/assets")
|
||||||
|
DUP=$(body "$R" | jq -r '[.[] | select(.name == "latest.yml")] | length')
|
||||||
|
if [ "$DUP" != "1" ]; then
|
||||||
|
echo "::error::a release tem $DUP cópias de latest.yml — o updater serviria a errada"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "latest.yml: 1 cópia, como esperado"
|
||||||
|
fi
|
||||||
@@ -14,28 +14,38 @@ jobs:
|
|||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
# macOS — universal build (arm64 + x64) on Apple Silicon runner
|
# Fork: só as plataformas que o grupo usa. Windows arm64 saiu porque
|
||||||
- os: macos-latest
|
# dobrava o job mais lento (duas distribuições do Electron, módulos
|
||||||
args: --mac --arm64 --x64
|
# nativos compilados duas vezes) e ninguém roda Windows em ARM.
|
||||||
|
# Linux arm64 saiu pelo mesmo motivo. Com o arm64 fora, o passo que
|
||||||
|
# instalava o fpm nativo perdeu a razão de existir: ele só era
|
||||||
|
# necessário porque o fpm embutido no electron-builder é x86_64 e
|
||||||
|
# falhava no runner arm64.
|
||||||
|
# macOS removido neste fork: ninguém do grupo usa, e o runner macOS
|
||||||
|
# é cobrado a 10x num repositório privado — era a plataforma mais cara
|
||||||
|
# da matriz, compilada em toda tag para zero usuários.
|
||||||
# Windows — x64 + arm64 on x64 runner.
|
# Windows — x64 + arm64 on x64 runner.
|
||||||
# Pinned to windows-2022 (VS 2022 / v17): the windows-latest image
|
# Pinned to windows-2022 (VS 2022 / v17): the windows-latest image
|
||||||
# ships VS "18", which the node-gyp bundled with electron-rebuild
|
# ships VS "18", which the node-gyp bundled with electron-rebuild
|
||||||
# cannot detect ("unknown version undefined") when compiling
|
# cannot detect ("unknown version undefined") when compiling
|
||||||
# uiohook-napi in postinstall.
|
# uiohook-napi in postinstall.
|
||||||
- os: windows-2022
|
- os: windows-2022
|
||||||
args: --win --x64 --arm64
|
args: --win --x64
|
||||||
# Linux — x64 on x64 runner
|
# Linux — x64 on x64 runner
|
||||||
- os: ubuntu-latest
|
- os: ubuntu-latest
|
||||||
args: --linux --x64
|
args: --linux --x64
|
||||||
# Linux — arm64 on arm64 runner
|
|
||||||
- os: ubuntu-24.04-arm
|
|
||||||
args: --linux --arm64
|
|
||||||
|
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
- name: Harden the runner
|
||||||
|
if: runner.os == 'Linux'
|
||||||
|
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||||
|
with:
|
||||||
|
egress-policy: audit
|
||||||
|
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
|
|
||||||
- name: Install Linux build dependencies
|
- name: Install Linux build dependencies
|
||||||
if: runner.os == 'Linux'
|
if: runner.os == 'Linux'
|
||||||
@@ -53,24 +63,13 @@ jobs:
|
|||||||
libxkbcommon-dev libxkbcommon-x11-dev libxkbfile-dev \
|
libxkbcommon-dev libxkbcommon-x11-dev libxkbfile-dev \
|
||||||
libxrandr-dev libxinerama-dev libx11-xcb-dev
|
libxrandr-dev libxinerama-dev libx11-xcb-dev
|
||||||
|
|
||||||
- name: Install fpm for .deb packaging
|
|
||||||
if: runner.os == 'Linux'
|
|
||||||
# electron-builder bundles an x86_64-only fpm; on the arm64 runner it
|
|
||||||
# aborts the .deb target with "cannot execute binary file: Exec format
|
|
||||||
# error". Install fpm natively and set USE_SYSTEM_FPM (below) so both
|
|
||||||
# arches package their .deb with a host-native fpm. ruby-dev + make are
|
|
||||||
# needed for fpm's native gem dependencies.
|
|
||||||
run: |
|
|
||||||
sudo apt-get install -y ruby ruby-dev build-essential
|
|
||||||
sudo gem install --no-document fpm
|
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||||
with:
|
with:
|
||||||
version: 10
|
version: 10.34.3
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
@@ -91,7 +90,3 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||||
# Use the host-native fpm installed above instead of electron-builder's
|
|
||||||
# bundled x86_64 fpm (which can't run on the arm64 runner). No-op on
|
|
||||||
# macOS/Windows, which don't build .deb.
|
|
||||||
USE_SYSTEM_FPM: "true"
|
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
name: OpenSSF Scorecard
|
||||||
|
|
||||||
|
# Scores the repo's security posture (branch protection, pinned deps, token
|
||||||
|
# permissions, etc.) and publishes to the OpenSSF public API so a badge can be
|
||||||
|
# shown (badge is added in Plan E). REQUIRES the canonical repo to be PUBLIC —
|
||||||
|
# see the maintainer checklist in docs/systems/security-scanning.md.
|
||||||
|
|
||||||
|
on:
|
||||||
|
branch_protection_rule:
|
||||||
|
schedule:
|
||||||
|
- cron: '18 4 * * 2' # weekly Tuesday 04:18 UTC
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
permissions: read-all
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
analysis:
|
||||||
|
name: Scorecard analysis
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
security-events: write # upload SARIF
|
||||||
|
id-token: write # publish_results OIDC attestation
|
||||||
|
steps:
|
||||||
|
- name: Harden the runner
|
||||||
|
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||||
|
with:
|
||||||
|
egress-policy: audit
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Run Scorecard
|
||||||
|
uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3
|
||||||
|
with:
|
||||||
|
results_file: results.sarif
|
||||||
|
results_format: sarif
|
||||||
|
publish_results: true
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||||
|
with:
|
||||||
|
name: SARIF file
|
||||||
|
path: results.sarif
|
||||||
|
retention-days: 5
|
||||||
|
- name: Upload SARIF to code scanning
|
||||||
|
uses: github/codeql-action/upload-sarif@02c5e83432fe5497fd85b873b6c9f16a8578e1d9 # v3.37.0
|
||||||
|
with:
|
||||||
|
sarif_file: results.sarif
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
name: Security
|
||||||
|
|
||||||
|
# Report-only in this plan: every scanner is non-blocking and uploads SARIF to
|
||||||
|
# the Security tab. Enforcement (fail on fixable HIGH/CRITICAL, block on secrets)
|
||||||
|
# is flipped on in Plan E after the remediation pass.
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
schedule:
|
||||||
|
- cron: '32 5 * * 1' # weekly Monday 05:32 UTC
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: security-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
gitleaks:
|
||||||
|
name: Secret scan (gitleaks)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Harden the runner
|
||||||
|
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||||
|
with:
|
||||||
|
egress-policy: audit
|
||||||
|
- name: Checkout (full history)
|
||||||
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
|
with:
|
||||||
|
fetch-depth: 0 # gitleaks scans the whole git history, not just the diff
|
||||||
|
- name: Run gitleaks
|
||||||
|
uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 # v2.3.9
|
||||||
|
continue-on-error: true # report-only; enforcement flipped on in Plan E
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
osv-scanner:
|
||||||
|
name: Dependency scan (OSV-Scanner)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
security-events: write # upload SARIF to code scanning
|
||||||
|
steps:
|
||||||
|
- name: Harden the runner
|
||||||
|
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||||
|
with:
|
||||||
|
egress-policy: audit
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
|
- name: Run OSV-Scanner
|
||||||
|
uses: google/osv-scanner-action/osv-scanner-action@9a498708959aeaef5ef730655706c5a1df1edbc2 # v2.3.8
|
||||||
|
continue-on-error: true # report-only; enforcement flipped on in Plan E
|
||||||
|
with:
|
||||||
|
scan-args: |-
|
||||||
|
--lockfile=./pnpm-lock.yaml
|
||||||
|
--format=sarif
|
||||||
|
--output=osv-results.sarif
|
||||||
|
- name: Upload OSV SARIF
|
||||||
|
if: always()
|
||||||
|
uses: github/codeql-action/upload-sarif@02c5e83432fe5497fd85b873b6c9f16a8578e1d9 # v3.37.0
|
||||||
|
with:
|
||||||
|
sarif_file: osv-results.sarif
|
||||||
|
category: osv-scanner
|
||||||
|
|
||||||
|
trivy-config:
|
||||||
|
name: IaC/config scan (Trivy)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
security-events: write
|
||||||
|
steps:
|
||||||
|
- name: Harden the runner
|
||||||
|
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||||
|
with:
|
||||||
|
egress-policy: audit
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
|
- name: Trivy config scan (Dockerfile + docker-compose)
|
||||||
|
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
||||||
|
continue-on-error: true # report-only; enforcement flipped on in Plan E
|
||||||
|
with:
|
||||||
|
scan-type: config
|
||||||
|
scan-ref: .
|
||||||
|
format: sarif
|
||||||
|
output: trivy-config.sarif
|
||||||
|
- name: Upload Trivy config SARIF
|
||||||
|
if: always()
|
||||||
|
uses: github/codeql-action/upload-sarif@02c5e83432fe5497fd85b873b6c9f16a8578e1d9 # v3.37.0
|
||||||
|
with:
|
||||||
|
sarif_file: trivy-config.sarif
|
||||||
|
category: trivy-config
|
||||||
|
|
||||||
|
trivy-license:
|
||||||
|
name: License compliance scan (Trivy)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
security-events: write
|
||||||
|
steps:
|
||||||
|
- name: Harden the runner
|
||||||
|
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||||
|
with:
|
||||||
|
egress-policy: audit
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
|
- name: Trivy license scan
|
||||||
|
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
||||||
|
continue-on-error: true # report-only; enforcement flipped on in Plan E
|
||||||
|
with:
|
||||||
|
scan-type: fs
|
||||||
|
scan-ref: .
|
||||||
|
scanners: license
|
||||||
|
format: sarif
|
||||||
|
output: trivy-license.sarif
|
||||||
|
- name: Upload Trivy license SARIF
|
||||||
|
if: always()
|
||||||
|
uses: github/codeql-action/upload-sarif@02c5e83432fe5497fd85b873b6c9f16a8578e1d9 # v3.37.0
|
||||||
|
with:
|
||||||
|
sarif_file: trivy-license.sarif
|
||||||
|
category: trivy-license
|
||||||
@@ -34,6 +34,12 @@ id_ed25519*
|
|||||||
# Generated deployment config (contains secrets)
|
# Generated deployment config (contains secrets)
|
||||||
livekit.yaml
|
livekit.yaml
|
||||||
|
|
||||||
|
# Local deploy targets & private ops notes — REAL hosts/IPs/users for the
|
||||||
|
# maintainer's own infra. NEVER commit: this is a public repo. deploy.sh reads
|
||||||
|
# .deploy.local at runtime if present; the committed defaults stay placeholders.
|
||||||
|
/.deploy.local
|
||||||
|
/.deploy-local/
|
||||||
|
|
||||||
# Raw screenshot originals — optimized WebP copies live in docs/screenshots/
|
# Raw screenshot originals — optimized WebP copies live in docs/screenshots/
|
||||||
demo-pictures/
|
demo-pictures/
|
||||||
|
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ submit.
|
|||||||
|
|
||||||
**"Contribution"** means any original work of authorship, including any
|
**"Contribution"** means any original work of authorship, including any
|
||||||
modification of or addition to an existing work, that You intentionally submit
|
modification of or addition to an existing work, that You intentionally submit
|
||||||
to the Project in any form — including source code, object code, documentation,
|
to the Project in any form (including source code, object code, documentation,
|
||||||
configuration, designs, or assets — through any means, including pull requests,
|
configuration, designs, or assets) through any means, including pull requests,
|
||||||
patches, issues with attached code, or electronic communication, but excluding
|
patches, issues with attached code, or electronic communication, but excluding
|
||||||
any communication You conspicuously mark in writing as "Not a Contribution".
|
any communication You conspicuously mark in writing as "Not a Contribution".
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ fully sublicensable, and transferable license to use, reproduce, modify, prepare
|
|||||||
derivative works of, publicly display, publicly perform, distribute, relicense
|
derivative works of, publicly display, publicly perform, distribute, relicense
|
||||||
(under any terms, including open-source and commercial licenses), sell, and
|
(under any terms, including open-source and commercial licenses), sell, and
|
||||||
otherwise exploit your Contributions, by all means and in all media now known or
|
otherwise exploit your Contributions, by all means and in all media now known or
|
||||||
later developed — so that the Maintainer may offer the Project under both the
|
later developed, so that the Maintainer may offer the Project under both the
|
||||||
GNU AGPL-3.0 and one or more commercial licenses.
|
GNU AGPL-3.0 and one or more commercial licenses.
|
||||||
|
|
||||||
This grant covers all Contributions You have already submitted and all
|
This grant covers all Contributions You have already submitted and all
|
||||||
@@ -46,17 +46,17 @@ Contributions You submit in the future, effective at the moment each is created.
|
|||||||
|
|
||||||
To the extent that the exclusive license in Section 2 is, in any jurisdiction,
|
To the extent that the exclusive license in Section 2 is, in any jurisdiction,
|
||||||
narrower than stated or otherwise limited by law, You grant the Maintainer the
|
narrower than stated or otherwise limited by law, You grant the Maintainer the
|
||||||
broadest license permissible there, and — only to the extent necessary to give
|
broadest license permissible there, and, only to the extent necessary to give
|
||||||
the Maintainer equivalent rights — assign such rights to the Maintainer, so that
|
the Maintainer equivalent rights, assign such rights to the Maintainer, so that
|
||||||
the Maintainer obtains, as nearly as possible, the same rights as the exclusive
|
the Maintainer obtains, as nearly as possible, the same rights as the exclusive
|
||||||
license in Section 2.
|
license in Section 2.
|
||||||
|
|
||||||
## 4. Patent License
|
## 4. Patent License
|
||||||
|
|
||||||
You grant the Maintainer a perpetual, worldwide, non-exclusive, royalty-free,
|
You grant the Maintainer a perpetual, worldwide, non-exclusive, royalty-free,
|
||||||
irrevocable, sublicensable patent license — under any patent claims You can
|
irrevocable, sublicensable patent license (under any patent claims You can
|
||||||
license that are necessarily infringed by your Contribution alone or by
|
license that are necessarily infringed by your Contribution alone or by
|
||||||
combination of your Contribution with the Project — to make, have made, use,
|
combination of your Contribution with the Project) to make, have made, use,
|
||||||
offer to sell, sell, import, and otherwise transfer your Contributions and the
|
offer to sell, sell, import, and otherwise transfer your Contributions and the
|
||||||
Project.
|
Project.
|
||||||
|
|
||||||
|
|||||||
@@ -160,6 +160,7 @@ Before modifying any subsystem, read its spec from `docs/systems/`. After making
|
|||||||
| [message-list.md](docs/systems/message-list.md) | Auto-scroll model, position memory (session-only), embed renderer dimension contract, known limitations | Touching MessageList.tsx, scroll behavior, embed renderers, position restore |
|
| [message-list.md](docs/systems/message-list.md) | Auto-scroll model, position memory (session-only), embed renderer dimension contract, known limitations | Touching MessageList.tsx, scroll behavior, embed renderers, position restore |
|
||||||
| [deployment.md](docs/systems/deployment.md) | Hosting pipeline: Docker/Caddy build, admin bootstrap, DB backup/restore, image pinning, env vars | Any deploy, backup/restore, or hosting change |
|
| [deployment.md](docs/systems/deployment.md) | Hosting pipeline: Docker/Caddy build, admin bootstrap, DB backup/restore, image pinning, env vars | Any deploy, backup/restore, or hosting change |
|
||||||
| [activity-presence.md](docs/systems/activity-presence.md) | Presence states, rich activities, activity types/priorities, broadcast pipeline, visibility control, ActivityCard/Panel | Presence, rich activities, activity display, status management |
|
| [activity-presence.md](docs/systems/activity-presence.md) | Presence states, rich activities, activity types/priorities, broadcast pipeline, visibility control, ActivityCard/Panel | Presence, rich activities, activity display, status management |
|
||||||
|
| [security-scanning.md](docs/systems/security-scanning.md) | CI security pipeline: Dependabot, CodeQL SAST, gitleaks, OSV-Scanner, Trivy (config/license; image scan in a later plan), OpenSSF Scorecard, SHA-pinning, harden-runner, tiered enforcement policy, maintainer settings checklist | Any CI security work, adding/changing scanners, enabling enforcement, supply-chain hardening |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Thanks for considering a contribution! Backspace is free and open source software
|
Thanks for considering a contribution! Backspace is free and open source software
|
||||||
(GNU AGPL-3.0, with a commercial dual-license option), and contributions of all
|
(GNU AGPL-3.0, with a commercial dual-license option), and contributions of all
|
||||||
sizes are welcome — bug reports, fixes, features, documentation, and design.
|
sizes are welcome: bug reports, fixes, features, documentation, and design.
|
||||||
|
|
||||||
## Before you start
|
## Before you start
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ Before your first contribution can be merged, you must sign the project's
|
|||||||
|
|
||||||
Backspace is a single-owner project. Under the CLA **you keep the copyright to
|
Backspace is a single-owner project. Under the CLA **you keep the copyright to
|
||||||
your contribution** and grant the maintainer (Jannis Braun) an exclusive,
|
your contribution** and grant the maintainer (Jannis Braun) an exclusive,
|
||||||
sublicensable license to it — which is what lets the project be offered under both
|
sublicensable license to it, which is what lets the project be offered under both
|
||||||
the AGPL and a commercial license. In return, you receive a perpetual license to
|
the AGPL and a commercial license. In return, you receive a perpetual license to
|
||||||
reuse the specific code you authored in your own other projects (see CLA §5). You
|
reuse the specific code you authored in your own other projects (see CLA §5). You
|
||||||
also confirm that you have the right to contribute the code in the first place.
|
also confirm that you have the right to contribute the code in the first place.
|
||||||
@@ -36,11 +36,13 @@ Signing is automatic and takes one comment:
|
|||||||
> I have read the CLA Document and I hereby sign the CLA
|
> I have read the CLA Document and I hereby sign the CLA
|
||||||
|
|
||||||
4. The bot records your signature against your GitHub username. You only sign
|
4. The bot records your signature against your GitHub username. You only sign
|
||||||
once — it covers all of your future contributions.
|
once, and it covers all of your future contributions.
|
||||||
|
|
||||||
## Development setup
|
## Development setup
|
||||||
|
|
||||||
Requirements: **Node.js 20+** and **pnpm 8+**.
|
Requirements: **Node.js 20 (LTS)** and **pnpm 10**. Run `nvm use` (reads
|
||||||
|
`.nvmrc`); Corepack activates the pinned pnpm from the `packageManager` field
|
||||||
|
automatically, so don't install pnpm globally.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm install # install all workspace dependencies
|
pnpm install # install all workspace dependencies
|
||||||
@@ -50,6 +52,11 @@ pnpm dev # API server on :3005, Vite dev server on :5173
|
|||||||
|
|
||||||
You can run the two halves separately with `pnpm dev:server` and `pnpm dev:web`.
|
You can run the two halves separately with `pnpm dev:server` and `pnpm dev:web`.
|
||||||
|
|
||||||
|
Working on the **desktop** app additionally needs a C++ toolchain (`make`, `g++`,
|
||||||
|
`python3`) to build the native `uiohook-napi` module. On Debian/Ubuntu:
|
||||||
|
`sudo apt install build-essential python3`. Without it `pnpm install` just warns
|
||||||
|
and skips that one rebuild; the server and web client are unaffected.
|
||||||
|
|
||||||
Voice and video are optional and require a LiveKit server; see the README for
|
Voice and video are optional and require a LiveKit server; see the README for
|
||||||
configuration. Text, federation, uploads, and everything else run fully without
|
configuration. Text, federation, uploads, and everything else run fully without
|
||||||
it.
|
it.
|
||||||
@@ -57,7 +64,7 @@ it.
|
|||||||
## Coding standards
|
## Coding standards
|
||||||
|
|
||||||
- **TypeScript strict mode**, no `any`. The codebase compiles cleanly under
|
- **TypeScript strict mode**, no `any`. The codebase compiles cleanly under
|
||||||
strict settings — keep it that way.
|
strict settings, so keep it that way.
|
||||||
- **Match the surrounding code.** Follow existing patterns, naming, and module
|
- **Match the surrounding code.** Follow existing patterns, naming, and module
|
||||||
boundaries rather than introducing new ones.
|
boundaries rather than introducing new ones.
|
||||||
- **Federation-aware.** Never assume a single global user ID. Resolve the
|
- **Federation-aware.** Never assume a single global user ID. Resolve the
|
||||||
@@ -89,10 +96,10 @@ it.
|
|||||||
Use GitHub Issues. For bugs, include reproduction steps, expected vs. actual
|
Use GitHub Issues. For bugs, include reproduction steps, expected vs. actual
|
||||||
behavior, and your environment (deployment method, browser/desktop, and whether
|
behavior, and your environment (deployment method, browser/desktop, and whether
|
||||||
federation or voice is involved). For security issues, please do **not** open a
|
federation or voice is involved). For security issues, please do **not** open a
|
||||||
public issue — see [`SECURITY.md`](SECURITY.md).
|
public issue. See [`SECURITY.md`](SECURITY.md).
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
By contributing, you agree that your contributions are licensed under the
|
By contributing, you agree that your contributions are licensed under the
|
||||||
[GNU AGPL-3.0](LICENSE) and are subject to the [CLA](CLA.md) — an exclusive-license
|
[GNU AGPL-3.0](LICENSE) and are subject to the [CLA](CLA.md), an exclusive-license
|
||||||
grant that also enables the project's commercial dual-license.
|
grant that also enables the project's commercial dual-license.
|
||||||
|
|||||||
@@ -10,3 +10,9 @@
|
|||||||
# Backspace API, WebSocket, and frontend — Docker DNS resolves "backspace"
|
# Backspace API, WebSocket, and frontend — Docker DNS resolves "backspace"
|
||||||
reverse_proxy backspace:3000
|
reverse_proxy backspace:3000
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Gitea — servidor git próprio (stack em /opt/gitea). Alcançado pelo nome do
|
||||||
|
# container na rede interna; o Gitea não publica porta nenhuma no host.
|
||||||
|
{$GIT_DOMAIN:git.resenha.website} {
|
||||||
|
reverse_proxy gitea:3000
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
# Stage 1: Install dependencies and build frontend
|
# Stage 1: Install dependencies and build frontend
|
||||||
FROM node:20-slim AS builder
|
FROM node:20-slim AS builder
|
||||||
|
|
||||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
RUN corepack enable && corepack prepare pnpm@10.34.3 --activate
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -21,6 +21,15 @@ COPY packages/web/package.json packages/web/
|
|||||||
# Copy patches (referenced by pnpm-lock.yaml)
|
# Copy patches (referenced by pnpm-lock.yaml)
|
||||||
COPY patches/ patches/
|
COPY patches/ patches/
|
||||||
|
|
||||||
|
# better-sqlite3 publishes no prebuilt binary for Node 20 (ABI 115) — its
|
||||||
|
# releases cover ABI 127/137/141/147 only — so prebuild-install falls back to
|
||||||
|
# compiling with node-gyp, which needs python3/make/g++. node:20-slim ships
|
||||||
|
# none of them. Builder stage only: the runtime stage copies the compiled
|
||||||
|
# .node and stays slim.
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
python3 make g++ \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
@@ -36,11 +45,14 @@ RUN pnpm --filter @backspace/web build
|
|||||||
# Stage 2: Production runtime
|
# Stage 2: Production runtime
|
||||||
FROM node:20-slim AS runtime
|
FROM node:20-slim AS runtime
|
||||||
|
|
||||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
RUN corepack enable && corepack prepare pnpm@10.34.3 --activate
|
||||||
|
|
||||||
# Install build dependencies for better-sqlite3 native module
|
# Runtime deps only: ffmpeg (media processing) + gosu (drop to non-root in the
|
||||||
|
# entrypoint). sharp is N-API (ABI-independent) and loads a prebuilt binary;
|
||||||
|
# better-sqlite3 no longer ships one for Node 20, so it is compiled below with
|
||||||
|
# a toolchain that is purged in the same layer.
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
apt-get install -y --no-install-recommends python3 make g++ ffmpeg && \
|
apt-get install -y --no-install-recommends ffmpeg gosu && \
|
||||||
rm -rf /var/lib/apt/lists/*
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -57,8 +69,16 @@ COPY packages/web/package.json packages/web/
|
|||||||
# Copy patches (referenced by pnpm-lock.yaml)
|
# Copy patches (referenced by pnpm-lock.yaml)
|
||||||
COPY patches/ patches/
|
COPY patches/ patches/
|
||||||
|
|
||||||
# Install production dependencies only (tsx is in server dependencies)
|
# Install production dependencies only (tsx is in server dependencies).
|
||||||
RUN pnpm install --prod --frozen-lockfile
|
# better-sqlite3 compiles from source here (no Node 20 prebuilt), so the C
|
||||||
|
# toolchain is installed, used and purged inside this single layer — the final
|
||||||
|
# image ships no compiler.
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y --no-install-recommends python3 make g++ && \
|
||||||
|
pnpm install --prod --frozen-lockfile && \
|
||||||
|
apt-get purge -y python3 make g++ && \
|
||||||
|
apt-get autoremove -y && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Copy shared source (needed at runtime since server imports types directly)
|
# Copy shared source (needed at runtime since server imports types directly)
|
||||||
COPY packages/shared/ packages/shared/
|
COPY packages/shared/ packages/shared/
|
||||||
@@ -72,6 +92,11 @@ COPY --from=builder /app/packages/web/dist packages/web/dist
|
|||||||
# Create data directories
|
# Create data directories
|
||||||
RUN mkdir -p /app/data/uploads
|
RUN mkdir -p /app/data/uploads
|
||||||
|
|
||||||
|
# Non-root hardening: copy the privilege-dropping entrypoint. It chowns the
|
||||||
|
# data volume as root, then execs the CMD as the unprivileged `node` user.
|
||||||
|
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||||
|
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||||
|
|
||||||
# Set environment defaults
|
# Set environment defaults
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
@@ -94,4 +119,5 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=5 \
|
|||||||
|
|
||||||
# Run the server using tsx from the server package directory
|
# Run the server using tsx from the server package directory
|
||||||
WORKDIR /app/packages/server
|
WORKDIR /app/packages/server
|
||||||
|
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||||
CMD ["node", "--import", "tsx/esm", "src/index.ts"]
|
CMD ["node", "--import", "tsx/esm", "src/index.ts"]
|
||||||
|
|||||||
@@ -4,52 +4,52 @@
|
|||||||
|
|
||||||
# Backspace
|
# Backspace
|
||||||
|
|
||||||
**A self-hosted communication platform — text, voice, video, and federation — that you own.**
|
**A self-hosted communication platform you own. Text, voice, video, and federation.**
|
||||||
|
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://www.typescriptlang.org/)
|
[](https://www.typescriptlang.org/)
|
||||||
[](https://nodejs.org/)
|
[](https://nodejs.org/)
|
||||||
[](#project-status)
|
[](#project-status)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Backspace is a Discord-style chat platform you run on your own hardware. Spaces,
|
Backspace is a self-hosted, open-source Discord alternative: a Discord-style chat
|
||||||
channels, roles, voice and video, screen sharing, direct messages, friends, file
|
platform you run on your own hardware. Spaces, channels, roles, voice and video,
|
||||||
sharing, and message search — plus **server-to-server federation**, so
|
screen sharing, direct messages, friends, file sharing, and message search. On top
|
||||||
independent Backspace instances can talk to each other while each stays under
|
of that, **server-to-server federation** lets independent Backspace instances talk
|
||||||
its own control.
|
to each other while each stays under its own control.
|
||||||
|
|
||||||
It is **free and open source** under the **GNU AGPL-3.0**, and dual-licensed: a
|
It is **free and open source** under the **GNU AGPL-3.0**, and dual-licensed: a
|
||||||
commercial license is available if the AGPL doesn't fit your use. See
|
commercial license is available if the AGPL doesn't fit your use. See
|
||||||
[License](#license) for the details.
|
[License](#license) for the details.
|
||||||
|
|
||||||
> **Project status** <a name="project-status"></a>
|
> **Project status** <a name="project-status"></a>
|
||||||
> Backspace 1.0 — stable, self-hostable, and actively developed.
|
> Backspace 1.0. Stable, self-hostable, and actively developed.
|
||||||
|
|
||||||
## What makes Backspace different
|
## What makes Backspace different
|
||||||
|
|
||||||
Self-hosted chat usually forces a trade-off: gaming-grade voice and video, *or* a
|
Self-hosted chat usually forces a trade-off: gaming-grade voice and video, *or* a
|
||||||
polished Discord-style experience, *or* federation between independent servers —
|
polished Discord-style experience, *or* federation between independent servers.
|
||||||
rarely all three, and rarely with the fine-grained media controls people expect.
|
Rarely all three, and rarely with the fine-grained media controls people expect.
|
||||||
|
|
||||||
Backspace does all three at once:
|
Backspace does all three at once:
|
||||||
|
|
||||||
- **Voice & video with a real control surface.** Not just "it has screen share":
|
- **Voice and video with a real control surface.** This goes past a screen-share
|
||||||
choose resolution, frame rate, codec (VP9 or hardware H.264), and bitrate; set
|
button. Choose resolution, frame rate, codec (VP9 or hardware H.264), and
|
||||||
independent 0–200% volume for every person *and* every screen-share; RNNoise
|
bitrate; set independent 0-200% volume for every person and every screen-share;
|
||||||
noise suppression; a live connection inspector (bitrate, codec, ping, packet
|
RNNoise noise suppression; a live connection inspector (bitrate, codec, ping,
|
||||||
loss, jitter); and a per-tile badge showing each stream's measured
|
packet loss, jitter); and a per-tile badge showing each stream's measured
|
||||||
resolution/frame-rate. Screen sharing goes up to 4K/120fps within admin-set
|
resolution and frame-rate. Screen sharing goes up to 4K/120fps within admin-set
|
||||||
bounds.
|
bounds.
|
||||||
- **Federation, not a walled garden.** Run your own instance and peer it with
|
- **Federation, not a walled garden.** Run your own instance and peer it with
|
||||||
others: cross-instance friends, DMs, calls, and presence — each server
|
others for cross-instance friends, DMs, calls, and presence. Each server stays
|
||||||
independently owned, requests HMAC-authenticated.
|
independently owned, and requests are HMAC-authenticated.
|
||||||
- **A complete, polished platform — not a demo.** Role-based permissions with
|
- **A complete platform, not a demo.** Role-based permissions with per-category
|
||||||
per-category and per-channel overrides, friends and group DMs, inline playable
|
and per-channel overrides, friends and group DMs, inline playable media,
|
||||||
media, moderation with audit trails, search, a desktop app, and an installable
|
moderation with audit trails, search, a desktop app, and an installable mobile
|
||||||
mobile PWA — all in the warm, calm "Aether Drift" interface.
|
PWA, all in the warm, calm "Aether Drift" interface.
|
||||||
|
|
||||||
You own the server, the data, and the network it federates into.
|
You own the server, the data, and the network it federates into.
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@ You own the server, the data, and the network it federates into.
|
|||||||
|
|
||||||
<img src="docs/screenshots/voice-video-grid.webp" alt="A voice channel with a grid of camera and screen-share tiles" width="900" />
|
<img src="docs/screenshots/voice-video-grid.webp" alt="A voice channel with a grid of camera and screen-share tiles" width="900" />
|
||||||
|
|
||||||
<sub><em>A voice channel in full swing — camera tiles alongside live screen-shares, each with its own resolution / frame-rate label.</em></sub>
|
<sub><em>A voice channel in full swing. Camera tiles alongside live screen-shares, each with its own resolution and frame-rate label.</em></sub>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -67,31 +67,31 @@ You own the server, the data, and the network it federates into.
|
|||||||
<tr>
|
<tr>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<img src="docs/screenshots/chat.webp" alt="A text channel with messages and a typing indicator" /><br/>
|
<img src="docs/screenshots/chat.webp" alt="A text channel with messages and a typing indicator" /><br/>
|
||||||
<sub><b>Text channels</b> — Markdown, replies, reactions, and live typing indicators.</sub>
|
<sub><b>Text channels.</b> Markdown, replies, reactions, and live typing indicators.</sub>
|
||||||
</td>
|
</td>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<img src="docs/screenshots/screen-share-settings.webp" alt="The screen-share settings popover" /><br/>
|
<img src="docs/screenshots/screen-share-settings.webp" alt="The screen-share settings popover" /><br/>
|
||||||
<sub><b>Screen-share controls</b> — resolution, frame rate, codec, and bitrate, within admin-set bounds.</sub>
|
<sub><b>Screen-share controls.</b> Resolution, frame rate, codec, and bitrate, within admin-set bounds.</sub>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<img src="docs/screenshots/space-discovery.webp" alt="The space discovery / Explore view" /><br/>
|
<img src="docs/screenshots/space-discovery.webp" alt="The space discovery / Explore view" /><br/>
|
||||||
<sub><b>Spaces & discovery</b> — browse public, request-to-join, and joined spaces.</sub>
|
<sub><b>Spaces and discovery.</b> Browse public, request-to-join, and joined spaces.</sub>
|
||||||
</td>
|
</td>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<img src="docs/screenshots/group-dm.webp" alt="A federated group direct message" /><br/>
|
<img src="docs/screenshots/group-dm.webp" alt="A federated group direct message" /><br/>
|
||||||
<sub><b>Direct messages</b> — 1-on-1 and group DMs, including members on peer instances.</sub>
|
<sub><b>Direct messages.</b> 1-on-1 and group DMs, including members on peer instances.</sub>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<img src="docs/screenshots/user-discovery.webp" alt="The find-people / user discovery view" /><br/>
|
<img src="docs/screenshots/user-discovery.webp" alt="The find-people / user discovery view" /><br/>
|
||||||
<sub><b>Friends & social</b> — find people across instances with mutual friends and spaces.</sub>
|
<sub><b>Friends and social.</b> Find people across instances with mutual friends and spaces.</sub>
|
||||||
</td>
|
</td>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<img src="docs/screenshots/admin-federation.webp" alt="The federation admin panel showing peered instances" /><br/>
|
<img src="docs/screenshots/admin-federation.webp" alt="The federation admin panel showing peered instances" /><br/>
|
||||||
<sub><b>Federation admin</b> — manage peered instances, relay, and secret rotation.</sub>
|
<sub><b>Federation admin.</b> Manage peered instances, relay, and secret rotation.</sub>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -106,22 +106,22 @@ You own the server, the data, and the network it federates into.
|
|||||||
- Message reactions, replies, editing, deletion, and per-message mark-as-unread
|
- Message reactions, replies, editing, deletion, and per-message mark-as-unread
|
||||||
- Rich link embeds (YouTube, Vimeo, Spotify, and generic OpenGraph) with SSRF-protected scraping, plus GIF search (Klipy)
|
- Rich link embeds (YouTube, Vimeo, Spotify, and generic OpenGraph) with SSRF-protected scraping, plus GIF search (Klipy)
|
||||||
- Typing indicators, unread badges, and presence
|
- Typing indicators, unread badges, and presence
|
||||||
- Direct messages — 1-on-1 and group DMs (up to 10 people), with voice/video calls (ring / accept / reject)
|
- Direct messages: 1-on-1 and group DMs (up to 10 people), with voice/video calls (ring, accept, reject)
|
||||||
|
|
||||||
**Voice, video & screen sharing** (via [LiveKit](https://livekit.io/)):
|
**Voice, video, and screen sharing** (via [LiveKit](https://livekit.io/)):
|
||||||
- Screen sharing up to 4K / 120fps — VP9 by default, an optional hardware-accelerated H.264 mode, and a VP8 simulcast fallback
|
- Screen sharing up to 4K/120fps: VP9 by default, an optional hardware-accelerated H.264 mode, and a VP8 simulcast fallback
|
||||||
- Per-stream quality controls — resolution, frame rate, codec, and bitrate, within admin-set bounds
|
- Per-stream quality controls: resolution, frame rate, codec, and bitrate, within admin-set bounds
|
||||||
- Independent 0–200% volume for every participant *and* every screen-share
|
- Independent 0-200% volume for every participant and every screen-share
|
||||||
- RNNoise noise suppression (on by default), plus echo-cancellation and auto-gain toggles and mic/speaker device selection
|
- RNNoise noise suppression (on by default), plus echo-cancellation and auto-gain toggles and mic/speaker device selection
|
||||||
- Live connection inspector — per-participant bitrate, codec, ping, packet loss, and jitter — plus a per-tile badge showing each stream's measured resolution/frame-rate
|
- Live connection inspector for per-participant bitrate, codec, ping, packet loss, and jitter, plus a per-tile badge showing each stream's measured resolution and frame-rate
|
||||||
- Screen-share viewer detection ("who's watching") and auto-ducking that lowers stream audio when someone speaks
|
- Screen-share viewer detection ("who's watching") and auto-ducking that lowers stream audio when someone speaks
|
||||||
- Selective subscription — mute or stop watching any camera/stream to save bandwidth
|
- Selective subscription: mute or stop watching any camera or stream to save bandwidth
|
||||||
- Push-to-talk and fully customizable keybinds (including mouse buttons), in the browser and the desktop app
|
- Push-to-talk and fully customizable keybinds (including mouse buttons), in the browser and the desktop app
|
||||||
- Picture-in-Picture for voice and video
|
- Picture-in-Picture for voice and video
|
||||||
|
|
||||||
### Organization
|
### Organization
|
||||||
- Spaces with channel categories
|
- Spaces with channel categories
|
||||||
- Role-based permissions — bitwise RBAC with category- and channel-level overrides
|
- Role-based permissions: bitwise RBAC with category- and channel-level overrides
|
||||||
- Customizable user sidebar layout, with personal color-coded folders that group whole spaces
|
- Customizable user sidebar layout, with personal color-coded folders that group whole spaces
|
||||||
- Space discovery (public, request-to-join, and private)
|
- Space discovery (public, request-to-join, and private)
|
||||||
- Shareable invite codes
|
- Shareable invite codes
|
||||||
@@ -132,8 +132,8 @@ You own the server, the data, and the network it federates into.
|
|||||||
- Mutual friends and mutual spaces
|
- Mutual friends and mutual spaces
|
||||||
- User profiles with banner, bio, and accent color
|
- User profiles with banner, bio, and accent color
|
||||||
- Presence and rich activities (playing, listening, watching, streaming, custom)
|
- Presence and rich activities (playing, listening, watching, streaming, custom)
|
||||||
- Manual status — Online, Idle, or Do Not Disturb — with a custom status message
|
- Manual status (Online, Idle, or Do Not Disturb) with a custom status message
|
||||||
- Privacy controls — toggle discoverability and activity-status sharing
|
- Privacy controls: toggle discoverability and activity-status sharing
|
||||||
|
|
||||||
### Moderation
|
### Moderation
|
||||||
- Bans with reason and moderator attribution (who, why, and when)
|
- Bans with reason and moderator attribution (who, why, and when)
|
||||||
@@ -144,7 +144,7 @@ You own the server, the data, and the network it federates into.
|
|||||||
### Federation
|
### Federation
|
||||||
- Multi-instance peering with HMAC-signed server-to-server requests
|
- Multi-instance peering with HMAC-signed server-to-server requests
|
||||||
- Federated identity resolution (`username@instance`)
|
- Federated identity resolution (`username@instance`)
|
||||||
- Cross-instance DMs — messages, reactions, and membership relay
|
- Cross-instance DMs: messages, reactions, and membership relay
|
||||||
- Cross-instance friends and presence
|
- Cross-instance friends and presence
|
||||||
- File replication with size validation
|
- File replication with size validation
|
||||||
- Background workers for outbox delivery, file download, peer health, and cleanup
|
- Background workers for outbox delivery, file download, peer health, and cleanup
|
||||||
@@ -152,26 +152,38 @@ You own the server, the data, and the network it federates into.
|
|||||||
### Platform
|
### Platform
|
||||||
- File uploads with image thumbnails (via `sharp`), drag-and-drop and paste-to-upload, and in-app avatar/banner cropping
|
- File uploads with image thumbnails (via `sharp`), drag-and-drop and paste-to-upload, and in-app avatar/banner cropping
|
||||||
- Message search with `from:`, `has:`, `before:`, and `after:` filters, plus jump-to-message
|
- Message search with `from:`, `has:`, `before:`, and `after:` filters, plus jump-to-message
|
||||||
- Admin panel — instance settings, user management, registration controls, storage management, and federation/peering, plus granular streaming controls (a per-resolution × per-frame-rate bitrate matrix, min/max caps, quality-slider step, and an optional user-set-bitrate mode)
|
- Admin panel: instance settings, user management, registration controls, storage management, and federation/peering, plus granular streaming controls (a per-resolution by per-frame-rate bitrate matrix, min/max caps, quality-slider step, and an optional user-set-bitrate mode)
|
||||||
- Automatic SQLite backups (pre-migration, scheduled, and manual) with restore tooling
|
- Automatic SQLite backups (pre-migration, scheduled, and manual) with restore tooling
|
||||||
- Electron desktop app (Windows, macOS, Linux) with global keybinds (push-to-talk, mute, deafen) and activity detection
|
- Electron desktop app (Windows, macOS, Linux) with global keybinds (push-to-talk, mute, deafen) and activity detection
|
||||||
- Native desktop notifications and unread badge counts
|
- Native desktop notifications and unread badge counts
|
||||||
- Mobile-responsive web UI with a dedicated touch layout (bottom navigation, swipe gestures, full-screen views)
|
- Mobile-responsive web UI with a dedicated touch layout (bottom navigation, swipe gestures, full-screen views)
|
||||||
- Installable PWA — add it to your phone's home screen to run it as a standalone app, with service-worker caching and an offline message queue (messages send once you reconnect)
|
- Installable PWA: add it to your phone's home screen to run it as a standalone app, with service-worker caching and an offline message queue (messages send once you reconnect)
|
||||||
- Account management — password change and account deletion with safeguards
|
- Account management: password change and account deletion with safeguards
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
The intended way to deploy Backspace is the **interactive installer** — it
|
The intended way to deploy Backspace is the **interactive installer**. It
|
||||||
configures everything (`.env`, secrets, HTTPS, optional voice) and brings the
|
configures everything (`.env`, secrets, HTTPS, optional voice) and brings the
|
||||||
stack up for you. Everything you need for a working instance is below.
|
stack up for you. It **auto-detects your environment** and picks one of three
|
||||||
|
deployment modes. The default "All-in-One" (below) needs nothing but a host and
|
||||||
|
a domain, but if ports 80/443 are already taken (an existing reverse proxy, a
|
||||||
|
tunnel, another app) the installer steers you to the right mode instead of
|
||||||
|
dead-ending. See [Deployment modes](#deployment-modes) for the full picture.
|
||||||
|
|
||||||
|
By default the installer **pulls a prebuilt multi-architecture image** from the
|
||||||
|
GitHub Container Registry (`linux/amd64` + `linux/arm64`), so weak or ARM boxes
|
||||||
|
(a Raspberry Pi) skip the heavy local build. It falls back to building from
|
||||||
|
source automatically if the image can't be pulled.
|
||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
|
|
||||||
- A **Linux host** (VPS, VM, or home server) with **Docker** and **Docker Compose**.
|
- A **Linux host** (VPS, VM, or home server) with **Docker** and **Docker Compose**.
|
||||||
- A **domain name** pointed at the host's public IP — Caddy uses it to obtain
|
- A **domain name** for your instance. In the default All-in-One mode it must
|
||||||
HTTPS certificates automatically.
|
point at the host's public IP (Caddy obtains HTTPS certificates for it
|
||||||
- The ability to open the firewall ports in step 2.
|
automatically); behind your own reverse proxy or a tunnel it points at that
|
||||||
|
edge instead. See [Deployment modes](#deployment-modes).
|
||||||
|
- The ability to open the firewall ports in step 2 (All-in-One), or a reverse
|
||||||
|
proxy / tunnel already terminating HTTPS for you.
|
||||||
|
|
||||||
### 1. Run the installer
|
### 1. Run the installer
|
||||||
|
|
||||||
@@ -191,34 +203,34 @@ The installer walks you through everything interactively:
|
|||||||
|
|
||||||
### 2. Open the firewall ports
|
### 2. Open the firewall ports
|
||||||
|
|
||||||
Open these on the host — and, if it's behind a router, port-forward them to the host:
|
Open these on the host (and, if it's behind a router, port-forward them to the host):
|
||||||
|
|
||||||
| Port | Proto | When | Purpose |
|
| Port | Proto | When | Purpose |
|
||||||
|------|-------|------|---------|
|
|------|-------|------|---------|
|
||||||
| `80` | TCP | **Always** | HTTP — Caddy's automatic-HTTPS (ACME) challenge + redirect to HTTPS |
|
| `80` | TCP | **Always** | HTTP. Caddy's automatic-HTTPS (ACME) challenge + redirect to HTTPS |
|
||||||
| `443` | TCP | **Always** | HTTPS — web app, REST API, WebSocket, and LiveKit signaling (proxied) |
|
| `443` | TCP | **Always** | HTTPS. Web app, REST API, WebSocket, and LiveKit signaling (proxied) |
|
||||||
| `3478` | UDP | If voice enabled | TURN — NAT traversal for WebRTC |
|
| `3478` | UDP | If voice enabled | TURN. NAT traversal for WebRTC |
|
||||||
| `7881` | TCP | If voice enabled | WebRTC TCP fallback (clients that can't use UDP) |
|
| `7881` | TCP | If voice enabled | WebRTC TCP fallback (clients that can't use UDP) |
|
||||||
| `50000–60000` | UDP | If voice enabled | WebRTC media (voice / video / screen-share streams) |
|
| `50000–60000` | UDP | If voice enabled | WebRTC media (voice / video / screen-share streams) |
|
||||||
|
|
||||||
Without voice, you only need `80` and `443`. The voice ports are required only
|
Without voice, you only need `80` and `443`. The voice ports are required only
|
||||||
when you enable LiveKit. LiveKit's own signaling port (`7880`) stays internal —
|
when you enable LiveKit. LiveKit's own signaling port (`7880`) stays internal.
|
||||||
it's reverse-proxied through Caddy on `443`, so you do **not** forward it.
|
It's reverse-proxied through Caddy on `443`, so you do **not** forward it.
|
||||||
|
|
||||||
> **Do this together with DNS, ideally before (or right after) running the
|
> **Do this together with DNS, ideally before (or right after) running the
|
||||||
> installer.** Caddy gets your HTTPS certificate from Let's Encrypt the first
|
> installer.** Caddy gets your HTTPS certificate from Let's Encrypt the first
|
||||||
> time the stack starts, which requires your domain to resolve to this host
|
> time the stack starts, which requires your domain to resolve to this host
|
||||||
> **and** ports `80`/`443` reachable from the internet. If they aren't ready
|
> **and** ports `80`/`443` reachable from the internet. If they aren't ready
|
||||||
> yet, that's fine — Caddy keeps retrying, and HTTPS comes up automatically once
|
> yet, that's fine. Caddy keeps retrying, and HTTPS comes up automatically once
|
||||||
> DNS and the ports are in place.
|
> DNS and the ports are in place.
|
||||||
|
|
||||||
### 3. Create your admin account
|
### 3. Create your admin account
|
||||||
|
|
||||||
Open `https://your-domain` and register. **The first account created becomes the
|
Open `https://your-domain` and register. **The first account created becomes the
|
||||||
instance admin** — there is no default username or password.
|
instance admin**. There is no default username or password.
|
||||||
|
|
||||||
If the page doesn't load over HTTPS, it's almost always DNS or ports `80`/`443`
|
If the page doesn't load over HTTPS, it's almost always DNS or ports `80`/`443`
|
||||||
not being reachable from outside — check `docker compose logs caddy` for
|
not being reachable from outside. Check `docker compose logs caddy` for
|
||||||
certificate errors. (The installer's health check confirms the app is up
|
certificate errors. (The installer's health check confirms the app is up
|
||||||
internally, not that the certificate was issued.)
|
internally, not that the certificate was issued.)
|
||||||
|
|
||||||
@@ -232,7 +244,7 @@ backup/restore and image-pinning guide.
|
|||||||
### Manual setup (advanced, optional)
|
### Manual setup (advanced, optional)
|
||||||
|
|
||||||
The installer above is the supported path. If you'd rather configure everything
|
The installer above is the supported path. If you'd rather configure everything
|
||||||
by hand, you can skip it and drive Docker Compose directly — but then DNS,
|
by hand, you can skip it and drive Docker Compose directly, but then DNS,
|
||||||
`.env`, secrets, voice config, and the same firewall ports from step 2 are your
|
`.env`, secrets, voice config, and the same firewall ports from step 2 are your
|
||||||
responsibility:
|
responsibility:
|
||||||
|
|
||||||
@@ -253,11 +265,210 @@ The stack runs three services via Docker Compose:
|
|||||||
|-------------|---------------------------------------------------|
|
|-------------|---------------------------------------------------|
|
||||||
| `backspace` | The app (API + WebSocket + built web client) on internal port `3000` |
|
| `backspace` | The app (API + WebSocket + built web client) on internal port `3000` |
|
||||||
| `caddy` | Reverse proxy with automatic HTTPS for your `DOMAIN` (ports `80`/`443`) |
|
| `caddy` | Reverse proxy with automatic HTTPS for your `DOMAIN` (ports `80`/`443`) |
|
||||||
| `livekit` | Voice/video server — optional, enabled with `COMPOSE_PROFILES=voice` |
|
| `livekit` | Voice/video server; optional, enabled with `COMPOSE_PROFILES=voice` |
|
||||||
|
|
||||||
|
## Deployment modes
|
||||||
|
|
||||||
|
Homelabs differ. Backspace supports three deployment modes from **one installer**,
|
||||||
|
which auto-detects which one fits and, in non-obvious cases, asks. The mode is
|
||||||
|
recorded as `DEPLOY_MODE` in `.env`; you can also set it up front for a
|
||||||
|
non-interactive install (`DEPLOY_MODE=proxy ./install.sh`).
|
||||||
|
|
||||||
|
| Mode | When | HTTPS handled by | Voice |
|
||||||
|
|------|------|------------------|-------|
|
||||||
|
| **`allinone`** (default) | Ports 80/443 are free and you have a domain | The bundled **Caddy** (automatic Let's Encrypt) | Yes, with UDP media ports open |
|
||||||
|
| **`proxy`** | You already run a reverse proxy (nginx, Traefik, Caddy, Nginx Proxy Manager, SWAG…) | **Your** reverse proxy | Yes, if you also proxy `/livekit` and open the media ports |
|
||||||
|
| **`tunnel`** | You expose the box through a tunnel (Cloudflare Tunnel, Tailscale…) | The **tunnel** provider | No, WebRTC/UDP can't traverse a tunnel |
|
||||||
|
|
||||||
|
In `proxy` and `tunnel` mode the bundled Caddy is **not** started; instead the app
|
||||||
|
is published on **`127.0.0.1:APP_PORT`** (loopback only, never exposed directly)
|
||||||
|
for your proxy or tunnel to forward to. This is driven by a small overlay,
|
||||||
|
`docker-compose.proxy.yml`, which the installer wires in for you by setting
|
||||||
|
`COMPOSE_FILE=docker-compose.yml:docker-compose.proxy.yml` in `.env`, so every
|
||||||
|
later `docker compose …` command in the directory keeps working with no `-f`
|
||||||
|
flags. The installer auto-picks a free `APP_PORT` (3000/8080 are often taken);
|
||||||
|
override it with `APP_PORT=…`.
|
||||||
|
|
||||||
|
The installer prints ready-to-paste config for your mode at the end. The
|
||||||
|
canonical snippets are below.
|
||||||
|
|
||||||
|
### Mode 2: behind your own reverse proxy
|
||||||
|
|
||||||
|
The app answers plain HTTP on `127.0.0.1:APP_PORT`; your proxy terminates TLS and
|
||||||
|
forwards to it. Every snippet already includes the three things people get wrong:
|
||||||
|
**WebSocket upgrade** (chat and live events won't work without it),
|
||||||
|
**`X-Forwarded-*`** (the server runs with `trustProxy` and needs the real client
|
||||||
|
scheme/IP), and a **body-size limit** matching `MAX_UPLOAD_SIZE` (default 100 MB).
|
||||||
|
|
||||||
|
Replace `chat.example.com` and `8080` with your domain and `APP_PORT`.
|
||||||
|
|
||||||
|
**nginx.** The `map` goes in `http { }` once; the `server` block per site:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
map $http_upgrade $connection_upgrade { default upgrade; '' close; }
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name chat.example.com;
|
||||||
|
|
||||||
|
# ssl_certificate /etc/letsencrypt/live/chat.example.com/fullchain.pem;
|
||||||
|
# ssl_certificate_key /etc/letsencrypt/live/chat.example.com/privkey.pem;
|
||||||
|
|
||||||
|
client_max_body_size 100m; # match MAX_UPLOAD_SIZE
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8080;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
|
proxy_set_header Upgrade $http_upgrade; # WebSocket
|
||||||
|
proxy_set_header Connection $connection_upgrade; # WebSocket
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Voice only: forward LiveKit signaling (strips the /livekit prefix):
|
||||||
|
# location /livekit/ {
|
||||||
|
# proxy_pass http://127.0.0.1:7880/;
|
||||||
|
# proxy_http_version 1.1;
|
||||||
|
# proxy_set_header Host $host;
|
||||||
|
# proxy_set_header Upgrade $http_upgrade;
|
||||||
|
# proxy_set_header Connection $connection_upgrade;
|
||||||
|
# }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Caddy** (if you run your own; it handles WebSocket and `X-Forwarded-*` itself):
|
||||||
|
|
||||||
|
```caddy
|
||||||
|
chat.example.com {
|
||||||
|
reverse_proxy 127.0.0.1:8080
|
||||||
|
request_body { max_size 100MB }
|
||||||
|
|
||||||
|
# Voice only:
|
||||||
|
# handle_path /livekit/* { reverse_proxy 127.0.0.1:7880 }
|
||||||
|
# handle { reverse_proxy 127.0.0.1:8080 }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Traefik** (file provider; Traefik handles WebSocket automatically):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
http:
|
||||||
|
routers:
|
||||||
|
backspace:
|
||||||
|
rule: "Host(`chat.example.com`)"
|
||||||
|
entryPoints: [websecure]
|
||||||
|
service: backspace
|
||||||
|
tls: { certResolver: letsencrypt }
|
||||||
|
services:
|
||||||
|
backspace:
|
||||||
|
loadBalancer:
|
||||||
|
servers:
|
||||||
|
- url: "http://127.0.0.1:8080"
|
||||||
|
# Voice only: add a higher-priority router + stripPrefix middleware for
|
||||||
|
# PathPrefix(`/livekit`) → http://127.0.0.1:7880.
|
||||||
|
```
|
||||||
|
|
||||||
|
#### GUI proxies (Nginx Proxy Manager, SWAG, etc.)
|
||||||
|
|
||||||
|
You can't paste a config file into a point-and-click proxy, so set these fields
|
||||||
|
by hand. In **Nginx Proxy Manager**, add a **Proxy Host**:
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|-------|-------|
|
||||||
|
| **Domain Names** | `chat.example.com` |
|
||||||
|
| **Scheme** | `http` |
|
||||||
|
| **Forward Hostname / IP** | `127.0.0.1`, but **if NPM runs in Docker**, `127.0.0.1` is NPM's *own* container. Use the host's LAN IP, or `host.docker.internal` with `extra_hosts: ["host.docker.internal:host-gateway"]` on the NPM container. |
|
||||||
|
| **Forward Port** | your `APP_PORT` (e.g. `8080`) |
|
||||||
|
| **Websockets Support** | **ON** (required; chat/live events break without it) |
|
||||||
|
| **Block Common Exploits** | fine to leave on |
|
||||||
|
| **SSL tab** | request a Let's Encrypt cert and enable **Force SSL** |
|
||||||
|
| **Advanced tab** | add `client_max_body_size 100m;` (match `MAX_UPLOAD_SIZE`) |
|
||||||
|
|
||||||
|
The same three ideas apply to any GUI proxy: forward to the app's host+port,
|
||||||
|
enable WebSocket support, and raise the request-body limit.
|
||||||
|
|
||||||
|
### Mode 3: behind a tunnel (Cloudflare, Tailscale)
|
||||||
|
|
||||||
|
Same loopback publish as Mode 2, but the tunnel daemon on the host reaches
|
||||||
|
`127.0.0.1:APP_PORT` and no inbound ports are opened at all. For **Cloudflare
|
||||||
|
Tunnel** (`cloudflared`):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# ~/.cloudflared/config.yml
|
||||||
|
tunnel: <YOUR-TUNNEL-ID>
|
||||||
|
credentials-file: /root/.cloudflared/<YOUR-TUNNEL-ID>.json
|
||||||
|
|
||||||
|
ingress:
|
||||||
|
- hostname: chat.example.com
|
||||||
|
service: http://127.0.0.1:8080
|
||||||
|
- service: http_status:404
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cloudflared tunnel route dns <YOUR-TUNNEL-ID> chat.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
Two tunnel-specific caveats, both handled by the installer:
|
||||||
|
|
||||||
|
- **Upload cap.** Cloudflare (free/pro) hard-caps request bodies at **100 MB**, so
|
||||||
|
the 100 MB default would let large uploads fail *at the edge*. In `tunnel` mode
|
||||||
|
the installer sets `MAX_UPLOAD_SIZE=94371840` (90 MB) with headroom. Don't raise
|
||||||
|
it back above ~100 MB behind Cloudflare.
|
||||||
|
- **No voice.** Voice/video is **WebRTC over UDP**, which a tunnel can't carry, so
|
||||||
|
it's disabled in `tunnel` mode. If you need voice, use Mode 2 (reverse proxy)
|
||||||
|
with the media ports opened, or All-in-One.
|
||||||
|
|
||||||
|
### Voice per mode
|
||||||
|
|
||||||
|
Voice/video (LiveKit) needs its **UDP media ports** reachable from clients.
|
||||||
|
These carry the actual audio/video and never pass through your HTTP proxy or
|
||||||
|
tunnel:
|
||||||
|
|
||||||
|
| Port | Proto | Purpose |
|
||||||
|
|------|-------|---------|
|
||||||
|
| `3478` | UDP | TURN. WebRTC NAT traversal |
|
||||||
|
| `7881` | TCP | WebRTC TCP fallback |
|
||||||
|
| `50000–60000` | UDP | WebRTC media (voice / video / screen-share) |
|
||||||
|
|
||||||
|
- **All-in-One.** Voice works once those ports are open/forwarded. LiveKit
|
||||||
|
*signaling* is proxied through Caddy on 443 (`/livekit`); port `7880` stays
|
||||||
|
internal, never forwarded.
|
||||||
|
- **Reverse proxy.** You must **also** route `/livekit` to `127.0.0.1:7880` (see
|
||||||
|
the commented lines in the snippets) **and** open the media ports above.
|
||||||
|
- **Tunnel.** Voice does **not** work (UDP can't traverse the tunnel). This is a
|
||||||
|
known, unavoidable limitation, not a misconfiguration.
|
||||||
|
|
||||||
|
### Updating a running instance
|
||||||
|
|
||||||
|
Back up first. The app auto-snapshots the SQLite DB, and you can take one on
|
||||||
|
demand with `./backup.sh` (see [`docs/systems/deployment.md`](docs/systems/deployment.md)).
|
||||||
|
Then, from the install directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git pull # refresh compose files / install.sh / docs
|
||||||
|
|
||||||
|
# Prebuilt-image installs (the default):
|
||||||
|
docker compose pull && docker compose up -d
|
||||||
|
|
||||||
|
# From-source installs (a fork, or BACKSPACE_BUILD=true):
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Because `COMPOSE_FILE` lives in `.env`, these commands automatically use the
|
||||||
|
right compose files in every mode, with no `-f` flags to remember. A redeploy
|
||||||
|
briefly restarts the `backspace` container (clients reconnect automatically).
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
Requirements: **Node.js 20+** and **pnpm 8+**.
|
Requirements: **Node.js 20 or newer** and **pnpm 10**. The `.nvmrc` file keeps
|
||||||
|
Node 20 as the default development and production baseline; CI additionally
|
||||||
|
exercises Node 24, and newer majors generally work but are not part of the test
|
||||||
|
matrix. The Docker image continues to build on Node 20 regardless of your host.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm install
|
pnpm install
|
||||||
@@ -265,6 +476,29 @@ cp .env.example .env # set JWT_SECRET (openssl rand -hex 32)
|
|||||||
pnpm dev # API server on :3005, Vite dev server on :5173
|
pnpm dev # API server on :3005, Vite dev server on :5173
|
||||||
```
|
```
|
||||||
|
|
||||||
|
On Windows PowerShell, confirm Node 20 or newer and use the native copy command:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
node --version
|
||||||
|
pnpm install
|
||||||
|
Copy-Item .env.example .env
|
||||||
|
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||||
|
```
|
||||||
|
|
||||||
|
Paste the generated value after `JWT_SECRET=` in `.env`, then start both
|
||||||
|
development servers with `pnpm dev`. Use `node --version` to confirm the active
|
||||||
|
version if pnpm reports an engine warning. This covers the server and web dev
|
||||||
|
servers; building the Electron desktop app still expects a POSIX shell (macOS or
|
||||||
|
Linux).
|
||||||
|
|
||||||
|
> **Server/web only?** `pnpm install` also builds the desktop app's native
|
||||||
|
> keyboard-hook module (`uiohook-napi`), which needs a C++ toolchain
|
||||||
|
> (`make`, `g++`, `python3`). If those are missing it now **warns and continues**,
|
||||||
|
> and the server and web client don't need it. Install a build toolchain
|
||||||
|
> (Debian/Ubuntu: `sudo apt install build-essential python3`) only if you're
|
||||||
|
> building the **desktop** app. And to *self-host*, use the Docker installer
|
||||||
|
> above; it never touches the desktop package.
|
||||||
|
|
||||||
Run the halves separately if you prefer:
|
Run the halves separately if you prefer:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -287,19 +521,22 @@ The most important:
|
|||||||
|
|
||||||
| Variable | Required | Default | Description |
|
| Variable | Required | Default | Description |
|
||||||
|----------------------|----------|-------------|-------------|
|
|----------------------|----------|-------------|-------------|
|
||||||
| `DOMAIN` | yes | — | Public domain name of your instance |
|
| `DOMAIN` | yes | none | Public domain name of your instance |
|
||||||
| `JWT_SECRET` | yes | — | Auth signing secret, **min 32 chars** (`openssl rand -hex 32`) |
|
| `JWT_SECRET` | yes | none | Auth signing secret, **min 32 chars** (`openssl rand -hex 32`) |
|
||||||
|
| `DEPLOY_MODE` | no | `allinone` | `allinone` \| `proxy` \| `tunnel`, see [Deployment modes](#deployment-modes) |
|
||||||
|
| `APP_PORT` | no | auto | `proxy`/`tunnel` only: host loopback port the app is published on |
|
||||||
| `PORT` | no | `3000` | App listen port (behind Caddy in Docker) |
|
| `PORT` | no | `3000` | App listen port (behind Caddy in Docker) |
|
||||||
| `HOST` | no | `0.0.0.0` | Bind address |
|
| `HOST` | no | `0.0.0.0` | Bind address |
|
||||||
| `REGISTRATION_OPEN` | no | `true` | Set `false` to close signups after setup |
|
| `REGISTRATION_OPEN` | no | `true` | Set `false` to close signups after setup |
|
||||||
| `MAX_UPLOAD_SIZE` | no | `104857600` | Max upload size in bytes (100 MB) |
|
| `MAX_UPLOAD_SIZE` | no | `104857600` | Max upload size in bytes (100 MB; 90 MB in `tunnel` mode) |
|
||||||
| `LIVEKIT_URL` / `LIVEKIT_API_KEY` / `LIVEKIT_API_SECRET` | no | — | Enable voice/video |
|
| `BACKSPACE_IMAGE` / `BACKSPACE_IMAGE_TAG` | no | `ghcr.io/thezwiss/backspace` / `latest` | Prebuilt image to pull; pin a tag or point at your fork's registry |
|
||||||
| `COMPOSE_PROFILES` | no | — | Set to `voice` to start the bundled LiveKit service |
|
| `LIVEKIT_URL` / `LIVEKIT_API_KEY` / `LIVEKIT_API_SECRET` | no | none | Enable voice/video |
|
||||||
|
| `COMPOSE_PROFILES` | no | none | Set to `voice` to start the bundled LiveKit service |
|
||||||
|
|
||||||
## Voice & Video
|
## Voice & Video
|
||||||
|
|
||||||
Voice, video, and screen sharing require a [LiveKit](https://livekit.io/) server.
|
Voice, video, and screen sharing require a [LiveKit](https://livekit.io/) server.
|
||||||
The Docker Compose file bundles one — enable it by setting these in `.env`:
|
The Docker Compose file bundles one. Enable it by setting these in `.env`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
COMPOSE_PROFILES=voice
|
COMPOSE_PROFILES=voice
|
||||||
@@ -309,10 +546,10 @@ LIVEKIT_API_SECRET=your-api-secret
|
|||||||
```
|
```
|
||||||
|
|
||||||
Enabling voice also requires opening the WebRTC ports (`3478/UDP`, `7881/TCP`,
|
Enabling voice also requires opening the WebRTC ports (`3478/UDP`, `7881/TCP`,
|
||||||
`50000–60000/UDP`) — see [Open the firewall ports](#2-open-the-firewall-ports).
|
`50000–60000/UDP`). See [Open the firewall ports](#2-open-the-firewall-ports).
|
||||||
|
|
||||||
Without LiveKit configured, everything else — text, federation, DMs, uploads,
|
Without LiveKit configured, everything else works fully (text, federation, DMs,
|
||||||
search — works fully; only voice/video channels won't connect.
|
uploads, search); only voice/video channels won't connect.
|
||||||
|
|
||||||
## Federation
|
## Federation
|
||||||
|
|
||||||
@@ -336,11 +573,11 @@ Grab the installer for your platform from the
|
|||||||
|
|
||||||
| Platform | File | Notes |
|
| Platform | File | Notes |
|
||||||
|----------|------|-------|
|
|----------|------|-------|
|
||||||
| Windows | `Backspace-<version>.exe` | Universal installer (x64 + arm64). SmartScreen may warn on first run — choose "More info" → "Run anyway". Auto-updates. |
|
| Windows | `Backspace-<version>.exe` | Universal installer (x64 + arm64). SmartScreen may warn on first run; choose "More info" → "Run anyway". Auto-updates. |
|
||||||
| macOS | `Backspace-<version>-arm64.dmg` (Apple Silicon) / `Backspace-<version>-x64.dmg` (Intel) | Builds are currently **unsigned**: on first launch, right-click the app → **Open** → **Open**. Auto-update is not available on macOS yet — check the releases page for new versions. |
|
| macOS | `Backspace-<version>-arm64.dmg` (Apple Silicon) / `Backspace-<version>-x64.dmg` (Intel) | Builds are currently **unsigned**: on first launch, right-click the app → **Open** → **Open**. Auto-update is not available on macOS yet, so check the releases page for new versions. |
|
||||||
| Linux | `Backspace-<version>-x86_64.AppImage` / `-arm64.AppImage`, or `.deb` (`amd64` / `arm64`) | AppImage auto-updates; `.deb` installs update via new releases. |
|
| Linux | `Backspace-<version>-x86_64.AppImage` / `-arm64.AppImage`, or `.deb` (`amd64` / `arm64`) | AppImage auto-updates; `.deb` installs update via new releases. |
|
||||||
|
|
||||||
On first launch the app asks for your instance URL — enter the address of the
|
On first launch the app asks for your instance URL. Enter the address of the
|
||||||
Backspace server you use (e.g. `https://chat.example.com`).
|
Backspace server you use (e.g. `https://chat.example.com`).
|
||||||
|
|
||||||
### Building from source
|
### Building from source
|
||||||
@@ -357,14 +594,14 @@ Cross-platform builds are produced for Windows, macOS, and Linux. See
|
|||||||
|
|
||||||
## Mobile
|
## Mobile
|
||||||
|
|
||||||
Backspace works on mobile today — just open your instance in a phone browser.
|
Backspace works on mobile today. Just open your instance in a phone browser.
|
||||||
The UI has a dedicated touch layout (bottom navigation, swipe gestures, and
|
The UI has a dedicated touch layout (bottom navigation, swipe gestures, and
|
||||||
full-screen views), and because it ships as an installable **PWA** you can use
|
full-screen views), and because it ships as an installable **PWA** you can use
|
||||||
your browser's **Add to Home Screen** to install it as a standalone app: its own
|
your browser's **Add to Home Screen** to install it as a standalone app: its own
|
||||||
icon, no browser chrome, and an offline message queue that flushes when you
|
icon, no browser chrome, and an offline message queue that flushes when you
|
||||||
reconnect.
|
reconnect.
|
||||||
|
|
||||||
Native **iOS and Android app-store apps are planned** — once the project gains
|
Native **iOS and Android app-store apps are planned**, once the project gains
|
||||||
traction and the funding for the developer-program licenses is secured. Until
|
traction and the funding for the developer-program licenses is secured. Until
|
||||||
then, the installable PWA is the supported way to run Backspace on a phone.
|
then, the installable PWA is the supported way to run Backspace on a phone.
|
||||||
|
|
||||||
@@ -374,10 +611,10 @@ Backspace is a TypeScript monorepo managed with pnpm workspaces.
|
|||||||
|
|
||||||
```
|
```
|
||||||
packages/
|
packages/
|
||||||
shared/ — Shared types, permission bits, constants
|
shared/ - Shared types, permission bits, constants
|
||||||
server/ — Fastify API + WebSocket server, Drizzle/SQLite, federation
|
server/ - Fastify API + WebSocket server, Drizzle/SQLite, federation
|
||||||
web/ — React 18 SPA (Vite, Tailwind, Zustand)
|
web/ - React 18 SPA (Vite, Tailwind, Zustand)
|
||||||
desktop/ — Electron wrapper
|
desktop/ - Electron wrapper
|
||||||
```
|
```
|
||||||
|
|
||||||
| Layer | Technology |
|
| Layer | Technology |
|
||||||
@@ -392,26 +629,61 @@ packages/
|
|||||||
| Deployment | Docker Compose + Caddy (auto-HTTPS) |
|
| Deployment | Docker Compose + Caddy (auto-HTTPS) |
|
||||||
|
|
||||||
Every subsystem has a dedicated specification under
|
Every subsystem has a dedicated specification under
|
||||||
[`docs/systems/`](docs/systems/) — database schema, REST API, WebSocket
|
[`docs/systems/`](docs/systems/): database schema, REST API, WebSocket
|
||||||
protocol, federation, permissions, voice, the design system, and more. **These
|
protocol, federation, permissions, voice, the design system, and more. **These
|
||||||
are the reference for how Backspace works**; start there if you want to
|
are the reference for how Backspace works**; start there if you want to
|
||||||
understand or extend a subsystem.
|
understand or extend a subsystem.
|
||||||
|
|
||||||
|
## FAQ
|
||||||
|
|
||||||
|
**Is Backspace a self-hosted Discord alternative?**
|
||||||
|
Yes. It gives you a Discord-style experience (spaces, channels, roles, voice,
|
||||||
|
video, screen sharing, DMs, friends) that you run entirely on your own server, so
|
||||||
|
you own the data and set the rules.
|
||||||
|
|
||||||
|
**How is it different from Revolt, Spacebar, Matrix, or Mumble?**
|
||||||
|
See the full [comparison](docs/comparison.md), including where each of those is the
|
||||||
|
better choice. In short: Backspace pairs a Discord-style client with a serious
|
||||||
|
voice and screen-share control surface and optional server-to-server federation.
|
||||||
|
|
||||||
|
**Does it have screen sharing and high-quality video?**
|
||||||
|
Yes. Screen sharing goes up to 4K/120fps within admin-set bounds, with per-stream
|
||||||
|
codec, bitrate, and resolution controls, RNNoise noise suppression, and a live
|
||||||
|
connection inspector. Voice and video use [LiveKit](https://livekit.io/) and are
|
||||||
|
optional; text, federation, DMs, and everything else run fully without them.
|
||||||
|
|
||||||
|
**Can I self-host it on a Raspberry Pi?**
|
||||||
|
Yes. The installer pulls a prebuilt multi-architecture image (amd64 and arm64), so
|
||||||
|
low-power and ARM boxes skip the heavy local build.
|
||||||
|
|
||||||
|
**Is it really open source?**
|
||||||
|
Yes, under the GNU AGPL-3.0. A separate commercial license is available for cases
|
||||||
|
the AGPL does not fit. Every released version stays available under the AGPL.
|
||||||
|
|
||||||
|
**Does it work on mobile?**
|
||||||
|
Yes, as an installable PWA with a dedicated touch layout. Native iOS and Android
|
||||||
|
apps are planned.
|
||||||
|
|
||||||
|
**What does "federation" mean here?**
|
||||||
|
Independent Backspace instances can peer with each other so users on different
|
||||||
|
servers can be friends, DM, and call across instances, while each server stays
|
||||||
|
independently owned. Requests between servers are HMAC-authenticated.
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
Contributions are welcome. Please read [`CONTRIBUTING.md`](CONTRIBUTING.md)
|
Contributions are welcome. Please read [`CONTRIBUTING.md`](CONTRIBUTING.md)
|
||||||
first. Backspace is a single-owner project, so all contributors sign a
|
first. Backspace is a single-owner project, so all contributors sign a
|
||||||
[Contributor License Agreement](CLA.md) — a one-time comment on your pull
|
[Contributor License Agreement](CLA.md), a one-time comment on your pull
|
||||||
request, handled automatically by a bot. **You keep the copyright to your
|
request, handled automatically by a bot. **You keep the copyright to your
|
||||||
contribution** and grant the maintainer (Jannis Braun) an exclusive license to
|
contribution** and grant the maintainer (Jannis Braun) an exclusive license to
|
||||||
it — which is what lets Backspace be offered under both the AGPL and a commercial
|
it, which is what lets Backspace be offered under both the AGPL and a commercial
|
||||||
license. You also receive a perpetual license to reuse the specific code you
|
license. You also receive a perpetual license to reuse the specific code you
|
||||||
wrote in your own other projects.
|
wrote in your own other projects.
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
If you discover a security vulnerability, please **do not** open a public issue.
|
If you discover a security vulnerability, please **do not** open a public issue.
|
||||||
Report it privately via a GitHub security advisory on this repository — see
|
Report it privately via a GitHub security advisory on this repository. See
|
||||||
[`SECURITY.md`](SECURITY.md). We'll work with you on a fix and coordinated
|
[`SECURITY.md`](SECURITY.md). We'll work with you on a fix and coordinated
|
||||||
disclosure.
|
disclosure.
|
||||||
|
|
||||||
@@ -422,23 +694,23 @@ Backspace is **free and open source software**, licensed under the
|
|||||||
|
|
||||||
In plain terms:
|
In plain terms:
|
||||||
|
|
||||||
- ✅ Self-host, run, study, and modify it — including commercially and inside a business.
|
- Yes: self-host, run, study, and modify it, including commercially and inside a business.
|
||||||
- ✅ Redistribute it and your changes under the same AGPL-3.0 license.
|
- Yes: redistribute it and your changes under the same AGPL-3.0 license.
|
||||||
- ⚠️ If you run a **modified** version as a network service, you must offer your
|
- Note: if you run a **modified** version as a network service, you must offer your
|
||||||
users its complete corresponding source (AGPL § 13). Backspace makes this easy —
|
users its complete corresponding source (AGPL § 13). Backspace makes this easy:
|
||||||
set `BACKSPACE_SOURCE_URL` to your fork so the in-app "Source code" link points
|
set `BACKSPACE_SOURCE_URL` to your fork so the in-app "Source code" link points
|
||||||
at what you actually run.
|
at what you actually run.
|
||||||
- ⚠️ Preserve the copyright and license notices.
|
- Note: preserve the copyright and license notices.
|
||||||
|
|
||||||
**Commercial license.** If the AGPL doesn't fit — embedding Backspace in a
|
**Commercial license.** If the AGPL doesn't fit (embedding Backspace in a
|
||||||
closed-source product, offering it as a managed service without publishing your
|
closed-source product, offering it as a managed service without publishing your
|
||||||
modifications, or an organization that can't use AGPL software — a separate
|
modifications, or an organization that can't use AGPL software), a separate
|
||||||
commercial license is available on request. See
|
commercial license is available on request. See
|
||||||
[`LICENSE-COMMERCIAL.md`](LICENSE-COMMERCIAL.md).
|
[`LICENSE-COMMERCIAL.md`](LICENSE-COMMERCIAL.md).
|
||||||
|
|
||||||
> **Our open-source commitment.** Every released version of Backspace is, and
|
> **Our open-source commitment.** Every released version of Backspace is, and
|
||||||
> will remain, available under the AGPL-3.0. The Contributor License Agreement
|
> will remain, available under the AGPL-3.0. The Contributor License Agreement
|
||||||
> exists to enable a commercial license and optional enterprise add-ons — **not**
|
> exists to enable a commercial license and optional enterprise add-ons, **not**
|
||||||
> to take the open-source edition private. If this project is ever abandoned, or
|
> to take the open-source edition private. If this project is ever abandoned, or
|
||||||
> the open-source edition is relicensed under non-free terms, the community stays
|
> the open-source edition is relicensed under non-free terms, the community stays
|
||||||
> free to fork the last AGPL release.
|
> free to fork the last AGPL release.
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 200 KiB After Width: | Height: | Size: 25 KiB |
@@ -1,33 +1,40 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<svg id="Ebene_2" data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 147.46 146.59">
|
<!--
|
||||||
|
Resenhacord app icon: the mark on the app's own night-blue ground (#0b0b10,
|
||||||
|
the theme colour), inside the 22 %-radius rounded square the icon pipeline
|
||||||
|
masks to. A soft violet glow behind the mark keeps the badge from reading as
|
||||||
|
a flat black square at large sizes.
|
||||||
|
-->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
||||||
<defs>
|
<defs>
|
||||||
<style>
|
<radialGradient id="glow" cx="512" cy="480" r="420" gradientUnits="userSpaceOnUse">
|
||||||
.cls-1 {
|
<stop offset="0" stop-color="#7c6cf6" stop-opacity="0.28"/>
|
||||||
fill: #1d1d1b;
|
<stop offset="1" stop-color="#7c6cf6" stop-opacity="0"/>
|
||||||
}
|
</radialGradient>
|
||||||
|
<linearGradient id="back" x1="10" y1="12" x2="74" y2="74" gradientUnits="userSpaceOnUse">
|
||||||
.cls-2 {
|
<stop offset="0" stop-color="#c4b5fd"/>
|
||||||
fill: url(#Unbenannter_Verlauf_515);
|
<stop offset="1" stop-color="#7c6cf6"/>
|
||||||
}
|
|
||||||
|
|
||||||
.cls-3 {
|
|
||||||
fill: url(#Unbenannter_Verlauf_547);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<linearGradient id="Unbenannter_Verlauf_515" data-name="Unbenannter Verlauf 515" x1="62.13" y1="77.74" x2="118.16" y2="77.74" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop offset="0" stop-color="#fff"/>
|
|
||||||
<stop offset=".95" stop-color="#9a86b8"/>
|
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
<linearGradient id="Unbenannter_Verlauf_547" data-name="Unbenannter Verlauf 547" x1="9.21" y1="71.45" x2="135.41" y2="71.45" gradientUnits="userSpaceOnUse">
|
<linearGradient id="front" x1="42" y1="42" x2="94" y2="94" gradientUnits="userSpaceOnUse">
|
||||||
<stop offset="0" stop-color="#fff"/>
|
<stop offset="0" stop-color="#fda4af"/>
|
||||||
<stop offset=".95" stop-color="#9a86b8"/>
|
<stop offset="1" stop-color="#f472b6"/>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
|
<path id="frontBubble" d="M56 42 H80 A14 14 0 0 1 94 56 V68 A14 14 0 0 1 80 82 H76 L88 94 L68 82 H56 A14 14 0 0 1 42 68 V56 A14 14 0 0 1 56 42 Z"/>
|
||||||
|
<mask id="gap" maskUnits="userSpaceOnUse" x="0" y="0" width="100" height="100">
|
||||||
|
<rect width="100" height="100" fill="#fff"/>
|
||||||
|
<use href="#frontBubble" fill="#000" stroke="#000" stroke-width="7" stroke-linejoin="round"/>
|
||||||
|
</mask>
|
||||||
</defs>
|
</defs>
|
||||||
<g id="Ebene_1-2" data-name="Ebene 1">
|
<rect width="1024" height="1024" rx="225" fill="#0b0b10"/>
|
||||||
<rect class="cls-1" x="0" y="0" width="147.46" height="146.59" rx="32.42" ry="32.42"/>
|
<rect width="1024" height="1024" rx="225" fill="url(#glow)"/>
|
||||||
<g>
|
<g transform="translate(192 192) scale(6.4)">
|
||||||
<path class="cls-2" d="M100.85,75.32c9.67-3.15,14.27-9.44,14.27-20.12,0-13.48-9.33-21.23-25.52-21.23h-16.13c-7.98,0-11.35,3.48-11.35,12.14v53.82h0c5.77,0,10.46-4.68,10.46-10.46v-7.44c-.06-.33-.1-.68-.09-1.05,0-3.35.07-6.69.12-10.04h-.03v-24.05c0-2.24,1.12-3.6,3.25-3.6h12.99c10.23,0,15.51,4.5,15.51,13.26s-5.62,14.38-15.29,14.38h-6.04c-.04,3.08-.1,6.14-.11,9.22h6.5c11.46,0,17.64,5.51,17.64,16.08s-8.01,16.22-19.36,16.22l-4.36.38h0l-10.57.41c-6.01,0-6.3,8.01-.3,8.25.34.01.68.02,1.03.02h17.37c17.32,0,27.31-8.99,27.31-24.28,0-12.14-5.84-19.56-17.3-21.92Z"/>
|
<path mask="url(#gap)" fill="url(#back)"
|
||||||
<path class="cls-3" d="M102.43,119.43c-.64.25-1.29.48-1.93.7l-1.67.39c-.57.14-1.04.17-1.57.26-.51.09-1.02.16-1.51.21-.98.11-1.95.2-2.9.25-.96.07-1.88.12-2.82.16-3.72.14-7.36.14-10.98.07-1.81-.03-3.61-.1-5.41-.16l-2.67-.11c-.83-.03-1.76-.08-2.69-.13l-5.55-.32-.63-.04-.81-.09c-.54-.08-1.08-.16-1.62-.27-1.07-.22-2.13-.5-3.17-.88-2.07-.74-4.05-1.78-5.83-3.11-.9-.66-1.74-1.39-2.53-2.18l-1.91-1.98-7.37-7.7-7.37-7.7c-2.49-2.54-4.81-5.23-7.56-7.52-2.75-2.29-5.15-4.91-7.6-7.49-1.23-1.29-2.4-2.62-3.61-3.93l-.45-.49-.22-.25-.34-.38c-.59-.67-1.07-1.43-1.47-2.23-.77-1.61-1.13-3.41-.99-5.18.11-1.76.72-3.49,1.7-4.96.26-.35.51-.72.81-1.04l.45-.48.25-.25.96-.93,3.83-3.71,7.66-7.42,15.32-14.84,7.66-7.42.99-.95c.4-.38.8-.74,1.23-1.09.85-.69,1.73-1.33,2.67-1.87,1.87-1.11,3.91-1.92,6.02-2.42,1.06-.24,2.13-.4,3.2-.49.54-.04,1.08-.06,1.62-.06h.73s.67.02.67.02l5.33.11,10.66.21,21.32.42,10.72.23c4.1.16,8.16,1.58,11.38,4.07.83.6,1.56,1.31,2.29,2.01.67.76,1.36,1.51,1.93,2.35,1.16,1.65,2.12,3.46,2.7,5.4.59,1.92.97,3.93.94,5.93,0,1.87-.06,3.58-.09,5.38l-.85,42.64-.22,10.68c-.12,3.6-.47,7.31-1.88,10.77-.69,1.72-1.64,3.4-2.98,4.74-1.32,1.36-2.95,2.38-4.68,3.05-1.75.64-3.58.92-5.39,1.06-.9.02-1.82.04-2.71.02-.44-.01-.89,0-1.33-.06-.22-.02-.44-.06-.67-.1l-.33-.07c-.11-.03-.22.01-.33-.26.11-.27.22-.22.33-.26l.33-.07c.22-.04.44-.08.67-.1.44-.05.89-.04,1.33-.06.89-.02,1.75-.07,2.62-.12,1.72-.2,3.42-.54,5-1.18,1.55-.67,2.95-1.64,4.06-2.88,1.13-1.22,1.92-2.74,2.48-4.33,1.16-3.19,1.37-6.71,1.34-10.18l-.2-10.64-.85-42.65c-.04-1.76-.05-3.61-.12-5.28-.04-1.56-.4-3.08-.9-4.52-.5-1.46-1.29-2.79-2.21-4.01-.45-.62-1-1.16-1.52-1.71-.58-.5-1.14-1.02-1.78-1.43-2.48-1.76-5.49-2.67-8.48-2.67l-10.6.19-21.32.42-10.66.21-5.33.11h-.67c-.23.02-.45.02-.6.03-.35,0-.7.04-1.04.08-.69.09-1.38.22-2.05.4-1.34.38-2.61.95-3.77,1.7-.58.37-1.12.8-1.64,1.25-.26.23-.5.47-.74.71l-.9.94-7.36,7.72-14.72,15.43-7.36,7.72-3.68,3.86-.92.96-.22.23s0,.01,0,.01c-.01,0,.01.02,0,.01.02.02.02.02.05.04,0,.01.05,0,.04,0,.02-.02,0-.01.02-.03l.81.83c1.24,1.28,2.5,2.53,3.71,3.82,2.43,2.59,4.91,5.14,7.04,8.02,2.13,2.88,4.69,5.35,7.08,7.98l7.27,7.8,7.27,7.8,1.76,1.89c.46.48.95.92,1.48,1.32,1.06.8,2.27,1.43,3.54,1.88.64.23,1.3.4,1.97.54.33.07.67.12,1.01.17l.51.06c.33.03.46.03.7.04l5.09.27c.84.05,1.69.1,2.63.12l2.65.1c1.76.06,3.51.12,5.25.15,3.48.06,6.96.07,10.34-.17,1.68-.1,3.36-.28,4.9-.57.4-.06.77-.14,1.12-.23.35-.09.76-.16,1.04-.27l.9-.31c.18-.11.36-.22.54-.3l.65-.29c2.43-1.07,5.26.03,6.33,2.46,1.07,2.43-.03,5.26-2.46,6.33-.05.02-.12.05-.17.07Z"/>
|
d="M26 12 H58 A16 16 0 0 1 74 28 V44 A16 16 0 0 1 58 60 H34 L16 74 L20 60 H26 A16 16 0 0 1 10 44 V28 A16 16 0 0 1 26 12 Z"/>
|
||||||
|
<use href="#frontBubble" fill="url(#front)"/>
|
||||||
|
<g fill="#fff">
|
||||||
|
<circle cx="58" cy="62" r="3.6"/>
|
||||||
|
<circle cx="68" cy="62" r="3.6"/>
|
||||||
|
<circle cx="78" cy="62" r="3.6"/>
|
||||||
</g>
|
</g>
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 2.0 KiB |
@@ -1,14 +1,20 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<svg id="Ebene_2" data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 123.08 100.14">
|
<!-- Monochrome silhouette of mark.svg for macOS menu-bar templates (alpha only). -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||||
<defs>
|
<defs>
|
||||||
<style>
|
<path id="frontBubble" d="M56 42 H80 A14 14 0 0 1 94 56 V68 A14 14 0 0 1 80 82 H76 L88 94 L68 82 H56 A14 14 0 0 1 42 68 V56 A14 14 0 0 1 56 42 Z"/>
|
||||||
.cls-1 {
|
<mask id="gap">
|
||||||
fill: #1d1d1b;
|
<rect width="100" height="100" fill="#fff"/>
|
||||||
}
|
<use href="#frontBubble" fill="#000" stroke="#000" stroke-width="7" stroke-linejoin="round"/>
|
||||||
</style>
|
</mask>
|
||||||
|
<mask id="dots">
|
||||||
|
<rect width="100" height="100" fill="#fff"/>
|
||||||
|
<circle cx="58" cy="62" r="3.6" fill="#000"/>
|
||||||
|
<circle cx="68" cy="62" r="3.6" fill="#000"/>
|
||||||
|
<circle cx="78" cy="62" r="3.6" fill="#000"/>
|
||||||
|
</mask>
|
||||||
</defs>
|
</defs>
|
||||||
<g id="Ebene_1-2" data-name="Ebene 1">
|
<path mask="url(#gap)" fill="#1d1d1b"
|
||||||
<path class="cls-1" d="M91.64,53.93c9.67-3.15,14.27-9.44,14.27-20.12,0-13.48-9.33-21.23-25.52-21.23h-16.13c-7.98,0-11.35,3.48-11.35,12.14v53.82h0c5.77,0,10.46-4.68,10.46-10.46v-7.44c-.06-.33-.1-.68-.09-1.05,0-3.35.07-6.69.12-10.04h-.03v-24.05c0-2.24,1.12-3.6,3.25-3.6h12.99c10.23,0,15.51,4.5,15.51,13.26s-5.62,14.38-15.29,14.38h-6.04c-.04,3.08-.1,6.14-.11,9.22h6.5c11.46,0,17.64,5.51,17.64,16.08s-8.01,16.22-19.36,16.22l-4.36.38h0l-10.57.41c-6.01,0-6.3,8.01-.3,8.25.34.01.68.02,1.03.02h17.37c17.32,0,27.31-8.99,27.31-24.28,0-12.14-5.84-19.56-17.3-21.92Z"/>
|
d="M26 12 H58 A16 16 0 0 1 74 28 V44 A16 16 0 0 1 58 60 H34 L16 74 L20 60 H26 A16 16 0 0 1 10 44 V28 A16 16 0 0 1 26 12 Z"/>
|
||||||
<path class="cls-1" d="M93.21,98.04c-1.58.67-3.48,1.14-5.16,1.36-7.86,1.04-15.77.76-23.61.52-3.06-.04-7.8-.42-10.92-.56-2.12-.13-4.23-.56-6.23-1.28-4.01-1.34-7.48-4.13-10.26-7.27-3.66-3.85-11.08-11.56-14.75-15.4-2.49-2.54-4.81-5.23-7.56-7.52-4.08-3.48-7.59-7.49-11.21-11.42-3.78-3.58-4.71-9.08-1.77-13.49.6-.98,1.66-1.88,2.47-2.69,6.89-6.67,19.79-19.17,26.81-25.96,0,0,7.66-7.42,7.66-7.42,1.47-1.45,3.08-2.88,4.89-3.91C46.83,1.04,50.61,0,54.4,0c.89-.03,5.73.13,6.73.12,0,0,10.66.21,10.66.21,7.02.15,25.23.49,32.04.65,8.24.22,15.88,5.97,18.3,13.83,1.26,3.64.92,7.57.85,11.31,0,0-.85,42.64-.85,42.64-.31,13.92,1.41,31-17.86,30.33-.78,0-1.57-.04-2.33-.23-.11-.03-.22.01-.33-.26.11-.27.22-.22.33-.26,3.35-.48,6.76-.1,9.95-1.53,6.81-2.78,8.04-10.78,7.88-17.39-.14-6.81-.88-45.63-1.05-53.3-.12-3.34.17-6.68-1.03-9.81-1.03-2.88-3-5.41-5.51-7.15-2.48-1.76-5.49-2.67-8.48-2.67,0,0-10.6.19-10.6.19-9.89.2-27.61.55-37.32.74-3.9-.11-7.81,1.33-10.51,4.18-.22.33-7.8,8.1-8.26,8.65-4.86,5.11-17.37,18.21-22.08,23.15-.73.81-4.11,4.27-4.82,5.05,0,0,0,.01,0,.01-.01,0,.01.02,0,.01.02.02.08.07.09.03.02-.02,0-.01.02-.03,0,0,.81.83.81.83,3.71,3.84,7.52,7.57,10.75,11.84,3.79,4.86,10.17,11.13,14.34,15.78,0,0,7.27,7.8,7.27,7.8,1.85,2.11,4.03,4.24,6.79,5.09,2.44.99,6.67.88,9.28,1.08,3.39.17,7.13.31,10.53.37,5.86.03,11.79.22,17.4-1.24,0,0,.9-.31.9-.31.35-.2.82-.44,1.2-.58,6.01-2.4,9.65,6.26,3.7,8.87h0Z"/>
|
<use href="#frontBubble" fill="#1d1d1b" mask="url(#dots)"/>
|
||||||
</g>
|
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 1.0 KiB |
@@ -1,28 +1,33 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<svg id="Ebene_2" data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 123.07 100.13">
|
<!--
|
||||||
|
Resenhacord mark: two speech bubbles in conversation, the front one talking.
|
||||||
|
Square viewBox so every raster target (tray, favicon, PWA) scales without
|
||||||
|
letterboxing. The gap between the bubbles is cut with a mask rather than
|
||||||
|
painted, so the mark stays legible on any background and the macOS template
|
||||||
|
(alpha-only) variant keeps the same silhouette.
|
||||||
|
-->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||||
<defs>
|
<defs>
|
||||||
<style>
|
<linearGradient id="back" x1="10" y1="12" x2="74" y2="74" gradientUnits="userSpaceOnUse">
|
||||||
.cls-1 {
|
<stop offset="0" stop-color="#c4b5fd"/>
|
||||||
fill: url(#Unbenannter_Verlauf_515);
|
<stop offset="1" stop-color="#7c6cf6"/>
|
||||||
}
|
|
||||||
|
|
||||||
.cls-2 {
|
|
||||||
fill: url(#Unbenannter_Verlauf_547);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<linearGradient id="Unbenannter_Verlauf_515" data-name="Unbenannter Verlauf 515" x1="52.91" y1="56.35" x2="108.94" y2="56.35" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop offset="0" stop-color="#fff"/>
|
|
||||||
<stop offset=".95" stop-color="#9a86b8"/>
|
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
<linearGradient id="Unbenannter_Verlauf_547" data-name="Unbenannter Verlauf 547" x1="0" y1="50.06" x2="126.19" y2="50.06" gradientUnits="userSpaceOnUse">
|
<linearGradient id="front" x1="42" y1="42" x2="94" y2="94" gradientUnits="userSpaceOnUse">
|
||||||
<stop offset="0" stop-color="#fff"/>
|
<stop offset="0" stop-color="#fda4af"/>
|
||||||
<stop offset=".95" stop-color="#9a86b8"/>
|
<stop offset="1" stop-color="#f472b6"/>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
|
<path id="frontBubble" d="M56 42 H80 A14 14 0 0 1 94 56 V68 A14 14 0 0 1 80 82 H76 L88 94 L68 82 H56 A14 14 0 0 1 42 68 V56 A14 14 0 0 1 56 42 Z"/>
|
||||||
|
<mask id="gap">
|
||||||
|
<rect width="100" height="100" fill="#fff"/>
|
||||||
|
<use href="#frontBubble" fill="#000" stroke="#000" stroke-width="7" stroke-linejoin="round"/>
|
||||||
|
</mask>
|
||||||
</defs>
|
</defs>
|
||||||
<g id="Ebene_1-2" data-name="Ebene 1">
|
<path mask="url(#gap)" fill="url(#back)"
|
||||||
<g>
|
d="M26 12 H58 A16 16 0 0 1 74 28 V44 A16 16 0 0 1 58 60 H34 L16 74 L20 60 H26 A16 16 0 0 1 10 44 V28 A16 16 0 0 1 26 12 Z"/>
|
||||||
<path class="cls-1" d="M91.64,53.93c9.67-3.15,14.27-9.44,14.27-20.12,0-13.48-9.33-21.23-25.52-21.23h-16.13c-7.98,0-11.35,3.48-11.35,12.14v53.82h0c5.77,0,10.46-4.68,10.46-10.46v-7.44c-.06-.33-.1-.68-.09-1.05,0-3.35.07-6.69.12-10.04h-.03v-24.05c0-2.24,1.12-3.6,3.25-3.6h12.99c10.23,0,15.51,4.5,15.51,13.26s-5.62,14.38-15.29,14.38h-6.04c-.04,3.08-.1,6.14-.11,9.22h6.5c11.46,0,17.64,5.51,17.64,16.08s-8.01,16.22-19.36,16.22l-4.36.38h0l-10.57.41c-6.01,0-6.3,8.01-.3,8.25.34.01.68.02,1.03.02h17.37c17.32,0,27.31-8.99,27.31-24.28,0-12.14-5.84-19.56-17.3-21.92Z"/>
|
<use href="#frontBubble" fill="url(#front)"/>
|
||||||
<path class="cls-2" d="M93.21,98.04c-.64.25-1.29.48-1.93.7l-1.67.39c-.57.14-1.04.17-1.57.26-.51.09-1.02.16-1.51.21-.98.11-1.95.2-2.9.25-.96.07-1.88.12-2.82.16-3.72.14-7.36.14-10.98.07-1.81-.03-3.61-.1-5.41-.16l-2.67-.11c-.83-.03-1.76-.08-2.69-.13l-5.55-.32-.63-.04-.81-.09c-.54-.08-1.08-.16-1.62-.27-1.07-.22-2.13-.5-3.17-.88-2.07-.74-4.05-1.78-5.83-3.11-.9-.66-1.74-1.39-2.53-2.18l-1.91-1.98-7.37-7.7-7.37-7.7c-2.49-2.54-4.81-5.23-7.56-7.52-2.75-2.29-5.15-4.91-7.6-7.49-1.23-1.29-2.4-2.62-3.61-3.93l-.45-.49-.22-.25-.34-.38c-.59-.67-1.07-1.43-1.47-2.23-.77-1.61-1.13-3.41-.99-5.18.11-1.76.72-3.49,1.7-4.96.26-.35.51-.72.81-1.04l.45-.48.25-.25.96-.93,3.83-3.71,7.66-7.42,15.32-14.84,7.66-7.42.99-.95c.4-.38.8-.74,1.23-1.09.85-.69,1.73-1.33,2.67-1.87,1.87-1.11,3.91-1.92,6.02-2.42,1.06-.24,2.13-.4,3.2-.49.54-.04,1.08-.06,1.62-.06h.73s.67.02.67.02l5.33.11,10.66.21,21.32.42,10.72.23c4.1.16,8.16,1.58,11.38,4.07.83.6,1.56,1.31,2.29,2.01.67.76,1.36,1.51,1.93,2.35,1.16,1.65,2.12,3.46,2.7,5.4.59,1.92.97,3.93.94,5.93,0,1.87-.06,3.58-.09,5.38l-.85,42.64-.22,10.68c-.12,3.6-.47,7.31-1.88,10.77-.69,1.72-1.64,3.4-2.98,4.74-1.32,1.36-2.95,2.38-4.68,3.05-1.75.64-3.58.92-5.39,1.06-.9.02-1.82.04-2.71.02-.44-.01-.89,0-1.33-.06-.22-.02-.44-.06-.67-.1l-.33-.07c-.11-.03-.22.01-.33-.26.11-.27.22-.22.33-.26l.33-.07c.22-.04.44-.08.67-.1.44-.05.89-.04,1.33-.06.89-.02,1.75-.07,2.62-.12,1.72-.2,3.42-.54,5-1.18,1.55-.67,2.95-1.64,4.06-2.88,1.13-1.22,1.92-2.74,2.48-4.33,1.16-3.19,1.37-6.71,1.34-10.18l-.2-10.64-.85-42.65c-.04-1.76-.05-3.61-.12-5.28-.04-1.56-.4-3.08-.9-4.52-.5-1.46-1.29-2.79-2.21-4.01-.45-.62-1-1.16-1.52-1.71-.58-.5-1.14-1.02-1.78-1.43-2.48-1.76-5.49-2.67-8.48-2.67l-10.6.19-21.32.42-10.66.21-5.33.11h-.67c-.23.02-.45.02-.6.03-.35,0-.7.04-1.04.08-.69.09-1.38.22-2.05.4-1.34.38-2.61.95-3.77,1.7-.58.37-1.12.8-1.64,1.25-.26.23-.5.47-.74.71l-.9.94-7.36,7.72-14.72,15.43-7.36,7.72-3.68,3.86-.92.96-.22.23s0,.01,0,.01c-.01,0,.01.02,0,.01.02.02.02.02.05.04,0,.01.05,0,.04,0,.02-.02,0-.01.02-.03l.81.83c1.24,1.28,2.5,2.53,3.71,3.82,2.43,2.59,4.91,5.14,7.04,8.02,2.13,2.88,4.69,5.35,7.08,7.98l7.27,7.8,7.27,7.8,1.76,1.89c.46.48.95.92,1.48,1.32,1.06.8,2.27,1.43,3.54,1.88.64.23,1.3.4,1.97.54.33.07.67.12,1.01.17l.51.06c.33.03.46.03.7.04l5.09.27c.84.05,1.69.1,2.63.12l2.65.1c1.76.06,3.51.12,5.25.15,3.48.06,6.96.07,10.34-.17,1.68-.1,3.36-.28,4.9-.57.4-.06.77-.14,1.12-.23.35-.09.76-.16,1.04-.27l.9-.31c.18-.11.36-.22.54-.3l.65-.29c2.43-1.07,5.26.03,6.33,2.46,1.07,2.43-.03,5.26-2.46,6.33-.05.02-.12.05-.17.07Z"/>
|
<g fill="#fff">
|
||||||
</g>
|
<circle cx="58" cy="62" r="3.6"/>
|
||||||
|
<circle cx="68" cy="62" r="3.6"/>
|
||||||
|
<circle cx="78" cy="62" r="3.6"/>
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 222 KiB |
@@ -32,6 +32,16 @@ PI_PATH="~/backspace"
|
|||||||
BETA_HOST="orbit.ddns.net"
|
BETA_HOST="orbit.ddns.net"
|
||||||
BETA_PATH="~/backspace"
|
BETA_PATH="~/backspace"
|
||||||
|
|
||||||
|
# ── Local target override (gitignored) ──────────────────────
|
||||||
|
# The values above are public placeholders. A maintainer can point this script
|
||||||
|
# at real infrastructure by creating ./.deploy.local (git-ignored) that reassigns
|
||||||
|
# PI_USER / PI_LOCAL / PI_REMOTE / PI_PATH / BETA_HOST / BETA_PATH. This keeps
|
||||||
|
# real hostnames, IPs, and usernames out of the public repo. Never commit it.
|
||||||
|
if [[ -f ./.deploy.local ]]; then
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
source ./.deploy.local
|
||||||
|
fi
|
||||||
|
|
||||||
# ── Rsync excludes ──────────────────────────────────────────
|
# ── Rsync excludes ──────────────────────────────────────────
|
||||||
|
|
||||||
EXCLUDES=(
|
EXCLUDES=(
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# ============================================================
|
||||||
|
# Backspace — Reverse-proxy / Tunnel override
|
||||||
|
# ============================================================
|
||||||
|
# Overlay for Mode 2 (behind your own reverse proxy) and Mode 3 (tunnel, e.g.
|
||||||
|
# Cloudflare Tunnel / Tailscale). Layer it on top of the base compose file:
|
||||||
|
#
|
||||||
|
# docker compose -f docker-compose.yml -f docker-compose.proxy.yml up -d
|
||||||
|
#
|
||||||
|
# What it changes versus the all-in-one base:
|
||||||
|
# 1. Publishes the app on 127.0.0.1:${APP_PORT} (loopback only) so your own
|
||||||
|
# reverse proxy or tunnel daemon on the same host can reach it. Nothing is
|
||||||
|
# exposed on a public interface by this stack — TLS/termination is the
|
||||||
|
# proxy's job.
|
||||||
|
# 2. Moves the bundled Caddy into a profile that is never activated here, so it
|
||||||
|
# does NOT start (your proxy owns 80/443). The base file leaves Caddy in the
|
||||||
|
# default profile, so Mode 1 (`-f docker-compose.yml` alone) is unchanged.
|
||||||
|
#
|
||||||
|
# ./install.sh selects the right `-f` combination automatically per mode; this
|
||||||
|
# file is also usable by hand for a fully manual setup.
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
services:
|
||||||
|
backspace:
|
||||||
|
# Bind to loopback only. The reverse proxy / tunnel connects over 127.0.0.1;
|
||||||
|
# the app is never reachable directly from the network. Container listens on
|
||||||
|
# PORT (default 3000); APP_PORT is the host-side port your proxy forwards to.
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:${APP_PORT:-8080}:${PORT:-3000}"
|
||||||
|
|
||||||
|
caddy:
|
||||||
|
# Park Caddy in a profile that install.sh / the documented commands never
|
||||||
|
# enable, so the merged config drops it in proxy/tunnel mode. (Compose
|
||||||
|
# replaces the `profiles` list on merge; the base service has none, so this
|
||||||
|
# is the effective value only when this override is layered on.)
|
||||||
|
profiles:
|
||||||
|
- _proxy_mode_no_caddy
|
||||||
@@ -10,6 +10,14 @@
|
|||||||
services:
|
services:
|
||||||
# ── Backspace application server ──────────────────────────
|
# ── Backspace application server ──────────────────────────
|
||||||
backspace:
|
backspace:
|
||||||
|
# Prebuilt multi-arch image on GHCR (published by .github/workflows/
|
||||||
|
# docker-publish.yml). `docker compose pull` / install.sh's default path
|
||||||
|
# fetches this so weak/ARM hosts skip the heavy local build. Both `image:`
|
||||||
|
# and `build:` are declared: if the image isn't present locally and can't be
|
||||||
|
# pulled, `docker compose up --build` (install.sh's fallback, and deploy.sh)
|
||||||
|
# builds from source instead and tags the result under this same ref.
|
||||||
|
# Override the tag with BACKSPACE_IMAGE_TAG in .env (defaults to `latest`).
|
||||||
|
image: ${BACKSPACE_IMAGE:-ghcr.io/thezwiss/backspace}:${BACKSPACE_IMAGE_TAG:-latest}
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
args:
|
args:
|
||||||
@@ -27,6 +35,12 @@ services:
|
|||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- DB_PATH=/app/data/backspace.db
|
- DB_PATH=/app/data/backspace.db
|
||||||
- UPLOAD_DIR=/app/data/uploads
|
- UPLOAD_DIR=/app/data/uploads
|
||||||
|
# Fail fast with a clear message if the secret is missing/empty, instead of
|
||||||
|
# letting the container boot-crash on every `restart: unless-stopped` cycle
|
||||||
|
# (a silent loop that only shows up in `docker compose logs backspace`).
|
||||||
|
# install.sh generates this before bringing the stack up; this guard only
|
||||||
|
# bites the manual `cp .env.example .env && docker compose up` path.
|
||||||
|
- "JWT_SECRET=${JWT_SECRET:?Set JWT_SECRET in .env - generate one with: openssl rand -hex 32}"
|
||||||
networks:
|
networks:
|
||||||
- internal
|
- internal
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Runs as root: make the (bind-mounted, host-owned) data dir writable by the
|
||||||
|
# non-root `node` user, then drop privileges via gosu and exec the CMD. This
|
||||||
|
# lets the container run as uid 1000 while still owning ./data on hosts where
|
||||||
|
# the bind mount was created by a different uid.
|
||||||
|
#
|
||||||
|
# - Idempotent AND cheap: only chown entries not already node-owned, so after
|
||||||
|
# the first boot this is near-instant. A plain `chown -R` over a large
|
||||||
|
# uploads/ tree on slow Pi/SD storage would delay startup on EVERY restart.
|
||||||
|
# - Non-fatal: on a bind mount that rejects chown (some CIFS/NFS backings),
|
||||||
|
# warn and continue rather than crash-looping under `restart: unless-stopped`
|
||||||
|
# (the old root container booted fine on such mounts).
|
||||||
|
set -e
|
||||||
|
mkdir -p /app/data/uploads
|
||||||
|
chown node:node /app/data /app/data/uploads 2>/dev/null || true
|
||||||
|
find /app/data ! -user node -exec chown node:node {} + 2>/dev/null || \
|
||||||
|
echo "docker-entrypoint: warning: could not chown /app/data; continuing (ensure it is writable by uid 1000)"
|
||||||
|
exec gosu node "$@"
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# Gerar o instalador do app desktop
|
||||||
|
|
||||||
|
O workflow **`.github/workflows/release.yml`** (herdado do upstream) já compila
|
||||||
|
para macOS, Windows e Linux e publica os instaladores como release. Ele dispara
|
||||||
|
ao empurrar uma tag `v*`.
|
||||||
|
|
||||||
|
Não foi preciso escrever workflow novo — foi preciso **adaptá-lo ao fork**.
|
||||||
|
|
||||||
|
## Por que GitHub e não Gitea
|
||||||
|
|
||||||
|
O Gitea desta instância não tem Actions habilitado, e mesmo habilitado ele
|
||||||
|
**não oferece máquinas hospedadas**: seria preciso registrar um PC Windows
|
||||||
|
como runner e mantê-lo ligado. O GitHub fornece runners Windows prontos, que é
|
||||||
|
exatamente o que falta — o módulo nativo do compartilhamento de áudio usa
|
||||||
|
WASAPI e só compila no Windows, com o compilador da Microsoft.
|
||||||
|
|
||||||
|
O Gitea continua sendo o repositório principal. O GitHub entra apenas como
|
||||||
|
espelho para compilar.
|
||||||
|
|
||||||
|
## Passos (uma vez)
|
||||||
|
|
||||||
|
1. Criar um repositório no GitHub — o `electron-builder.yml` está apontado para
|
||||||
|
`syncwrld/resenhacord`. **Se o seu for outro nome, ajuste lá.**
|
||||||
|
2. Adicionar o espelho e empurrar:
|
||||||
|
```
|
||||||
|
cd /opt/backspace
|
||||||
|
git remote add github git@github.com:syncwrld/resenhacord.git
|
||||||
|
git push github main
|
||||||
|
```
|
||||||
|
3. Marcar uma versão e empurrar a tag — é ela que dispara a compilação:
|
||||||
|
```
|
||||||
|
git tag v1.0.1 && git push github v1.0.1
|
||||||
|
```
|
||||||
|
4. Os instaladores aparecem na aba Releases do GitHub em ~15 minutos.
|
||||||
|
|
||||||
|
## O que isso resolve de quebra
|
||||||
|
|
||||||
|
O `electron-updater` já estava instalado no projeto mas **sem feed** — o app
|
||||||
|
não se atualizava sozinho. Como o `publish` agora aponta para as releases do
|
||||||
|
fork, o app passa a encontrar versões novas por conta própria. Ninguém do grupo
|
||||||
|
precisa reinstalar na mão de novo.
|
||||||
|
|
||||||
|
## Adaptações feitas para o fork
|
||||||
|
|
||||||
|
| O quê | Por quê |
|
||||||
|
|---|---|
|
||||||
|
| `publish.owner` → `syncwrld` | Apontava para o repositório do upstream |
|
||||||
|
| `postinstall` reconstrói também `electron-native-screenshare` | Rodava `electron-rebuild` só no `uiohook-napi`. O módulo novo seria compilado para a ABI do Node em vez da do Electron e falharia ao carregar — e como o carregamento degrada em silêncio, o sintoma seria "compartilha sem som", sem erro visível |
|
||||||
|
| Nota sobre `asarUnpack` | O `uiohook-napi` tem `build/` excluído do asar porque distribui binários prontos. O módulo novo **não** distribui: `build/Release/*.node` é a única cópia e não pode ser excluída |
|
||||||
|
|
||||||
|
## Detalhe herdado que vale preservar
|
||||||
|
|
||||||
|
O runner do Windows está fixado em `windows-2022`, não `windows-latest`. O
|
||||||
|
comentário no workflow explica: a imagem `latest` traz o Visual Studio 18, que
|
||||||
|
o node-gyp embutido no `electron-rebuild` não detecta ("unknown version
|
||||||
|
undefined"). Não mude isso sem testar.
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# Backspace compared to other chat platforms
|
||||||
|
|
||||||
|
This page is an honest look at how Backspace fits next to the tools people usually
|
||||||
|
weigh against it: Discord, Revolt, Spacebar, Matrix/Element, and Mumble. It
|
||||||
|
includes the places where those tools are the better choice. Feature sets change,
|
||||||
|
so verify current details on each project before you decide, and open an issue if
|
||||||
|
anything here is out of date.
|
||||||
|
|
||||||
|
Short version: Backspace is for people who want a Discord-style experience they
|
||||||
|
fully self-host, with serious voice and screen-share controls, and the option to
|
||||||
|
federate independently owned servers. If you need the largest ecosystem, a mature
|
||||||
|
open federation standard, or native mobile apps today, one of the others may suit
|
||||||
|
you better.
|
||||||
|
|
||||||
|
## Feature matrix
|
||||||
|
|
||||||
|
| | Backspace | Discord | Revolt | Spacebar | Matrix / Element | Mumble |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| Self-hostable | Yes | No | Yes | Yes | Yes | Yes |
|
||||||
|
| Open source | Yes (AGPL-3.0) | No | Yes | Yes | Yes | Yes |
|
||||||
|
| Discord-style UX | Yes | Yes | Yes | Yes (Discord client) | Different model | No |
|
||||||
|
| Text chat, roles, reactions | Yes | Yes | Yes | Yes | Yes | Limited |
|
||||||
|
| Voice channels | Yes | Yes | Yes | Partial | Yes | Yes (focus) |
|
||||||
|
| Video and screen share | Yes, up to 4K/120fps within admin bounds | Yes | Limited | Partial | Yes | No |
|
||||||
|
| Per-stream media controls (codec, bitrate, resolution) | Yes | No | No | No | Partial | Some audio |
|
||||||
|
| Federation between servers | Yes, between Backspace instances | No | No | No | Yes, open standard | No |
|
||||||
|
| Native mobile apps | Installable PWA | Yes | Yes | Desktop client only | Yes | Yes |
|
||||||
|
| Runs on a Raspberry Pi | Yes (prebuilt arm64 image) | No | Yes | Yes | Yes | Yes |
|
||||||
|
|
||||||
|
"Partial" and "Limited" mean the capability exists but is less complete or less
|
||||||
|
polished than the leaders in that row at the time of writing. Check the current
|
||||||
|
state of each project.
|
||||||
|
|
||||||
|
## Backspace vs Discord
|
||||||
|
|
||||||
|
Discord is the reference experience and has the ecosystem, the bots, and the user
|
||||||
|
base. It is also proprietary, you cannot host it, and you do not own the data or
|
||||||
|
the moderation policy. Backspace exists for the people who want the Discord shape
|
||||||
|
without giving up ownership. You trade the ecosystem and the network effect for
|
||||||
|
control of the server, the data, and the rules. If you want the biggest community
|
||||||
|
and the deepest bot ecosystem, use Discord. If you want to own your instance, use
|
||||||
|
Backspace.
|
||||||
|
|
||||||
|
## Backspace vs Revolt
|
||||||
|
|
||||||
|
Revolt is the closest peer: an open-source, self-hostable, Discord-style chat with
|
||||||
|
an active community and a Rust backend. The main differences are the media stack
|
||||||
|
and federation. Backspace is built around a full voice and video control surface
|
||||||
|
(per-stream codec, bitrate, and resolution, RNNoise, a live connection inspector,
|
||||||
|
screen share up to 4K/120fps within admin limits) and supports peering independent
|
||||||
|
instances. If high-quality voice and screen sharing or cross-instance federation
|
||||||
|
are central to you, Backspace is aimed squarely at that. If you want a larger
|
||||||
|
existing community and a longer track record, look at Revolt.
|
||||||
|
|
||||||
|
## Backspace vs Spacebar
|
||||||
|
|
||||||
|
Spacebar reimplements the Discord backend so the actual Discord client can talk to
|
||||||
|
a server you host. That is a clever path to instant client familiarity, and it is
|
||||||
|
the right pick if using the real Discord app against your own backend is the goal.
|
||||||
|
The trade-off is that it inherits Discord's client and its constraints, and its
|
||||||
|
voice stack is still maturing. Backspace ships its own client and its own voice
|
||||||
|
and video stack, and it is federation-first rather than Discord-protocol-first.
|
||||||
|
|
||||||
|
## Backspace vs Matrix and Element
|
||||||
|
|
||||||
|
Matrix is the mature, standardized answer to open federation, and Element is its
|
||||||
|
best-known client. If interoperable, standards-based federation across many
|
||||||
|
different server and client implementations is your priority, Matrix is the
|
||||||
|
stronger choice and Backspace does not try to replace it. Backspace federation is
|
||||||
|
newer, simpler, and currently peers Backspace instances with each other rather
|
||||||
|
than speaking an open cross-ecosystem protocol. Where Backspace differs is the
|
||||||
|
experience: a tightly integrated Discord-style client with a purpose-built media
|
||||||
|
control surface, rather than a protocol with many clients of varying polish.
|
||||||
|
|
||||||
|
## Backspace vs Mumble
|
||||||
|
|
||||||
|
Mumble is outstanding at exactly one thing: low-latency voice for groups, self
|
||||||
|
hosted, lightweight. It has no rich text platform, no video, and no federation.
|
||||||
|
If all you need is the best self-hosted push-to-talk voice, Mumble is a great,
|
||||||
|
proven choice. Backspace is a full communication platform (text, voice, video,
|
||||||
|
files, social, federation) rather than a dedicated voice server, so pick it when
|
||||||
|
you want more than voice.
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
# Roadmap — fork Resenha
|
||||||
|
|
||||||
|
Plano de features próprias desta instância. Arquivo exclusivo do fork
|
||||||
|
(nome com sufixo para não colidir com arquivos do upstream em merges).
|
||||||
|
|
||||||
|
Escrito em português por ser documento de planejamento do dono do fork; o
|
||||||
|
código e os commits seguem em inglês, como o resto do repositório.
|
||||||
|
|
||||||
|
## Entregue
|
||||||
|
|
||||||
|
| Feature | Commit | Nota |
|
||||||
|
|---|---|---|
|
||||||
|
| Ir para a call clicando no nome do canal | `c70b0095` | Exigiu o `voiceStore` passar a guardar o espaço da call — antes ele não sabia onde a call estava assim que o usuário navegava para outro servidor |
|
||||||
|
| Botão de GIF redesenhado | `20526e1b` | Contorno vazado com letras cheias, no lugar do bloco sólido |
|
||||||
|
| Explorador de GIF no banner | `20526e1b` | Sem upload: banner já aceita URL absoluta no cliente e no servidor |
|
||||||
|
| Teste de microfone com retorno | `bfe62d70` | `AudioManager.startMicTest/stopMicTest`; devolve o microfone ao parar, com duas travas independentes |
|
||||||
|
| Bloco de atividade no perfil | `d7da0ff2` | `ProfileActivity`; inclui correção de validação de assets no servidor |
|
||||||
|
| Favoritar GIFs + categorias (#6) | `fb662bfe` | Favoritos no servidor, por conta; guarda o resultado inteiro porque o provedor não busca por id |
|
||||||
|
| Registro de auditoria (#9) | `bbb190cb` | Tabela append-only genérica; 8 ganchos; sair ≠ ser expulso |
|
||||||
|
| Estatísticas do grupo | `18300517` | Tabela própria de sessões de voz; ganchos no ponto único de entrada/saída |
|
||||||
|
| Spotify por OAuth (atividade com capa e progresso) | `75316b08` | Token só no servidor; `state` assinado; atividades por fonte para não brigar com o detector do Electron |
|
||||||
|
| Menu do próprio nome | `f5451e1b` | Perfil, status e copiar ID. Sem Clips/trocar conta — não existem aqui |
|
||||||
|
| Cronômetro da call | `f5451e1b` | `startedAt` do servidor; zera sozinho porque sala vazia é destruída |
|
||||||
|
| Correções do Spotify (sincronia, sumiço, barra) | `c899253e` | Pausa virou estado; horário do servidor no `ready` corrige relógio |
|
||||||
|
| Soundboard no app desktop | `c899253e` | `window.prompt` não existe no Electron; campo inline no lugar |
|
||||||
|
| Áudio do sistema sem eco | `d525bbb8` `5b848432` | Módulo nativo com isolamento por processo + ganho e preset de música |
|
||||||
|
| Instaladores publicados pelo Gitea | `ce6bba05` | GitHub compila, Gitea distribui; atualização automática ligada |
|
||||||
|
| Fixar mensagens no canal | `4ceb5cd6` | Estado na própria mensagem; painel no cabeçalho; limite de 50 |
|
||||||
|
| Busca: filtros embutidos e tradução | `50a8f12c` | Os filtros já existiam; faltava `de:fulano` e descoberta |
|
||||||
|
| Emojis e figurinhas do servidor | `7d003021` | `:nome:` no markdown sem plugin; figurinha como mensagem inteira |
|
||||||
|
| Fundação de i18n (en + pt-BR) | `688a1335` | `src/i18n/`; pt-BR é parcial e cai para inglês. Aba Idioma nas configurações |
|
||||||
|
| Rebrand: **Resenhacord** + logo nova | (ver git log) | `productName` mudou, `appId` **não** — assim o NSIS troca a instalação antiga no lugar. `userData` migra `Backspace` → `Resenhacord` no primeiro boot (senão todo mundo cairia da sessão). Logo: duas bolhas de conversa, roxo → rosa; fonte em `assets/brand/*.svg`, PNGs e ícones gerados por `pnpm gen-icons` |
|
||||||
|
| Aviso "X fixou uma mensagem" | (ver git log) | Tabela `messages` ganhou `type` (migração 0018); a rota de pin posta uma mensagem de sistema. Dois links: pular pra mensagem e abrir o painel de fixadas |
|
||||||
|
| Menção `@everyone` | (ver git log) | Reusa o token `<@id>`; permissão `MENTION_EVERYONE` nova, concedida ao `@everyone` por padrão e retroativa via backfill no boot. Servidor neutraliza o token de quem não tem a permissão, no envio **e** na edição |
|
||||||
|
| Renomear canal (nome e tópico) | (ver git log) | Rota `PATCH /api/channels/:id` já existia; faltava interface. Normalização em `shared/constants` para servidor e web usarem a mesma regra |
|
||||||
|
| Volume da tela em 50% por padrão | (ver git log) | `DEFAULT_STREAM_VOLUME`; o publicador reforça o áudio do sistema, então o padrão anterior chegava alto demais |
|
||||||
|
| Sons 50% mais baixos | (ver git log) | Só `SFX_BASE_VOLUME` (0.8 → 0.4); os `.ogg` não foram regerados |
|
||||||
|
| Atualização do desktop verificada com mais frequência | (ver git log) | Era só na abertura e de 4 em 4 horas; agora a cada 30 min e ao focar a janela, com trava de 5 min |
|
||||||
|
| Preview de perfil nos participantes da call | (ver git log) | O popout já existia e era aberto de 11 lugares; **nenhum era de voz**. Ligado nas linhas da lista de voz e no nome dos tiles da grade |
|
||||||
|
|
||||||
|
## Já existia no código (verificado, não construir de novo)
|
||||||
|
|
||||||
|
- **Busca com filtros** — servidor (`q`, `from`, `has`, `before`, `after`),
|
||||||
|
client de API e painel no `SearchPopover` já existiam completos. Só faltava
|
||||||
|
tradução, sintaxe embutida (`de:fulano`) e descoberta.
|
||||||
|
|
||||||
|
- **Animação de digitação** — `TypingIndicator.tsx`, três pontos `animate-bounce`
|
||||||
|
escalonados em 0/150/300ms.
|
||||||
|
- **Sons de call/stream** — `SoundController.tsx`, montado no `AppLayout`:
|
||||||
|
stream started/ended, alguém entra/sai da tela, entra/sai da call, câmera,
|
||||||
|
mute, ringing. Os `.ogg` estão em `web/public/sounds/`.
|
||||||
|
- **Preview de perfil ancorado** — `uiStore.openUserProfile(user, anchor, placement)`
|
||||||
|
guarda `userProfilePopout`; popout posicionado no desktop, tela cheia no
|
||||||
|
mobile. Já era aberto por mensagens, menções, avatares, lista de membros,
|
||||||
|
DMs e painel de atividade. O que faltava era só a voz — agora ligado.
|
||||||
|
Continua faltando o bloco de atividade do print do Discord, que é a #8.
|
||||||
|
|
||||||
|
Se qualquer um dos dois não se manifestar em uso, o trabalho é **depuração**,
|
||||||
|
não implementação.
|
||||||
|
|
||||||
|
## Pendente — pedido original
|
||||||
|
|
||||||
|
Ordem sugerida: as pequenas primeiro, as grandes uma de cada vez.
|
||||||
|
|
||||||
|
| # | Feature | Tamanho | Observação técnica |
|
||||||
|
|---|---|---|---|
|
||||||
|
|
||||||
|
## Identidade do perfil (entregue 2026-09-02, pedido: "completo e FODA")
|
||||||
|
|
||||||
|
Uma coluna JSON (`users.profile_style`, migração 0019) normalizada no servidor
|
||||||
|
por `shared/profile.ts`. Aba **Perfil** nas configurações com prévia ao vivo
|
||||||
|
(`ProfilePanel` renderiza o mesmo `ProfileCard` que o popout).
|
||||||
|
|
||||||
|
| Peça | Onde aparece | Detalhe |
|
||||||
|
|---|---|---|
|
||||||
|
| Estilo do nome — 10 fontes × 9 efeitos × 2 cores | popout, perfil completo | Fontes OFL em `public/fonts`; efeitos são CSS (`.name-fx-*`) |
|
||||||
|
| Decoração do avatar — 10 | popout, perfil, editor | SVG inline em `DecoratedAvatar`, sem imagens |
|
||||||
|
| Efeito do perfil — 9 | banner do popout e do perfil | Partículas CSS com posições semeadas (`ProfileEffectLayer`) |
|
||||||
|
| Nameplate — 10 + personalizado | lista de membros | `.nameplate-*` |
|
||||||
|
| Tema (2 cores) | fundo do cartão e banner sem imagem | |
|
||||||
|
| Pronomes, tag (emoji + 2–4 letras) | ao lado do nome; tag também na lista de membros | |
|
||||||
|
| Links (até 5) | popout e aba Sobre | Ícone detectado pelo host; só https |
|
||||||
|
| Quadro: jogo favorito, rotação (5), quero jogar (10), citação | aba Quadro do perfil; tira "Coleção" no popout | Busca na Steam via `/api/games/search` (proxy, cache 10 min); capas só de CDN da Steam |
|
||||||
|
| Emblemas derivados | popout e perfil | admin, dono, fundador (1º mês), veterano (1 ano), OG (6 meses), estiloso — nada é armazenado |
|
||||||
|
|
||||||
|
Fora do escopo desta entrega, candidatos a seguir: perfil por servidor
|
||||||
|
(apelido/avatar por servidor), emblemas concedidos pelo admin, molduras
|
||||||
|
animadas por upload, status com expiração.
|
||||||
|
|
||||||
|
## Pendente — ideias aprovadas
|
||||||
|
|
||||||
|
| Feature | Tamanho | Observação técnica |
|
||||||
|
|---|---|---|
|
||||||
|
| Watch party | Grande | O screen share do LiveKit já existe; falta sincronizar posição de reprodução entre participantes |
|
||||||
|
|
||||||
|
## Regra de idioma (a partir de 2026-08-31)
|
||||||
|
|
||||||
|
Funcionalidade nova sai com interface em **pt-BR**, e a cada update um sistema
|
||||||
|
existente é traduzido. Os dois idiomas **coexistem**. Código, comentários e
|
||||||
|
commits seguem em inglês.
|
||||||
|
|
||||||
|
A fundação está pronta (`src/i18n/`). Para traduzir um sistema: adicione as
|
||||||
|
chaves em `locales/en.ts`, traduza em `locales/pt-BR.ts` e troque as strings
|
||||||
|
fixas por `t('chave')`. O que faltar cai para o inglês sozinho.
|
||||||
|
|
||||||
|
### Sistemas já traduzidos
|
||||||
|
|
||||||
|
- Configurações → navegação e aba Idioma
|
||||||
|
- Configurações → Voz (dispositivo de entrada, volume, teste de microfone)
|
||||||
|
- Cartão de perfil (sobre mim, membro desde, enviar mensagem, atividade)
|
||||||
|
- Configurações → Privacidade
|
||||||
|
- Configurações → Conexões (nasceu bilíngue)
|
||||||
|
- Seletor de GIF
|
||||||
|
- Chat: composer e mensagens (placeholder, anexos, responder, reações)
|
||||||
|
- Barra lateral: servidores, canais e menus de contexto
|
||||||
|
- Lista de membros (grupos por cargo, carregamento)
|
||||||
|
- Busca (rótulos, filtros e sintaxe embutida)
|
||||||
|
- Registro de auditoria e Estatísticas (nasceram bilíngues)
|
||||||
|
- Configurações do canal (visão geral, privacidade, exclusão e rótulos de permissão)
|
||||||
|
- Amigos: abas, listas, pedidos, aba de adicionar e cartões de descoberta
|
||||||
|
- Modal de convite (incluindo os motivos de falha por convite)
|
||||||
|
- Lista de membros da DM em grupo (remover e transferir propriedade)
|
||||||
|
- Configurações → Área de trabalho (início automático, atualizações, instância)
|
||||||
|
- Configurações → Voz: dispositivo de saída (completa a aba, junto com a entrada)
|
||||||
|
- Configurações → Atalhos (incluindo os nomes das ações e o aviso de conflito)
|
||||||
|
- Configurações → Conta (perfil, senha, conta desligada da origem, zona de perigo)
|
||||||
|
- Explorar servidores (cartões, entrar, pedir para entrar)
|
||||||
|
- Configurações → Voz: vídeo (prévia, permissões, escolha de câmera) — fecha a aba
|
||||||
|
- Criar canal, categoria e servidor
|
||||||
|
- DMs em grupo (nova DM, adicionar amigos, configurações do grupo, sair)
|
||||||
|
- Entrar num servidor (descoberta, código de convite, conectar e login federado)
|
||||||
|
- Excluir conta, transferir propriedade e a tela de erro geral
|
||||||
|
- Configurações do servidor → Visão geral (identidade, ícone, banner, zona de perigo)
|
||||||
|
- Configurações do servidor → Membros e banimentos
|
||||||
|
- Configurações do servidor → Cargos (incluindo a tabela de permissões)
|
||||||
|
- Configurações da categoria (renomear, privacidade, exceções, excluir)
|
||||||
|
- Configurações da instância → Geral (nome, descoberta, busca de GIFs)
|
||||||
|
- Mensagens de sistema do chat e a prévia delas na barra lateral
|
||||||
|
- Login, registro e a página de convite
|
||||||
|
- Voz: controles, informações de conexão, folha de entrada no celular, aba Voz
|
||||||
|
- Telas do celular (`Mobile*.tsx`)
|
||||||
|
- Navegação das configurações (servidor, usuário e instância) + Descoberta e
|
||||||
|
pedidos de entrada
|
||||||
|
|
||||||
|
Os rótulos de permissão viraram um conjunto único (`perm.*`), usado pelo painel
|
||||||
|
de cargos e pelos editores de exceção de canal e de categoria — antes havia
|
||||||
|
duas cópias da mesma lista.
|
||||||
|
|
||||||
|
### Fila sugerida de tradução
|
||||||
|
|
||||||
|
Configurações → Instância: registro, federação, transmissão, armazenamento e
|
||||||
|
usuários (os cinco painéis grandes, só o dono vê) · `ConnectedInstances` ·
|
||||||
|
`UserProfileModal` · `MainContent` e `ChannelSidebar` · mensagens de erro
|
||||||
|
vindas de stores e hooks (`instanceStore`, `useLiveKit`, `transferStore`)
|
||||||
|
|
||||||
|
## Correções pendentes
|
||||||
|
|
||||||
|
| Problema | Causa provável | Correção |
|
||||||
|
|---|---|---|
|
||||||
|
|
||||||
|
|
||||||
|
## App desktop — já existia (verificado 2026-08-31)
|
||||||
|
|
||||||
|
As três pedidas já estão implementadas e ligadas de ponta a ponta. **Não
|
||||||
|
construir de novo.**
|
||||||
|
|
||||||
|
- **Bandeja** — `createTray()` em `desktop/src/main.ts`, chamada na inicialização;
|
||||||
|
fechar a janela esconde em vez de sair (`mainWindow.on('close')`).
|
||||||
|
- **Notificações nativas** — `showNotification()` no processo principal, canal
|
||||||
|
IPC `show-notification`, e o web já chama por `platform/notifications.ts`.
|
||||||
|
- **Áudio do sistema no compartilhamento** — `setDisplayMediaRequestHandler`
|
||||||
|
devolve `audio: 'loopback'`; existe caixa de seleção no `ScreenSharePicker`
|
||||||
|
ligada a `screenShareConfig.shareAudio`, que atravessa o IPC.
|
||||||
|
|
||||||
|
Se algum não se manifestar em uso, o trabalho é **depuração**, não construção.
|
||||||
|
|
||||||
|
|
||||||
|
## Aprovadas, a fazer depois (2026-08-31)
|
||||||
|
|
||||||
|
Escolhidas pelo dono, inspiradas no Discord. **Nenhuma iniciada.**
|
||||||
|
|
||||||
|
| Feature | Tamanho | Observação técnica |
|
||||||
|
|---|---|---|
|
||||||
|
| **Resposta rápida de voz (soundboard por atalho)** | Pequena | O soundboard já está pronto; falta só marcar favoritos e ligar a atalhos de teclado, sem abrir painel. Cuidado: o limite de repetição do servidor continua valendo e é o que impede virar metralhadora |
|
||||||
|
| **Cargos com ícone e exibição separada** | Média | Cargos e cores já existem; falta ícone, o campo "exibir separadamente" e o agrupamento na lista de membros |
|
||||||
|
|
||||||
|
|
||||||
|
## Ideias novas — a avaliar
|
||||||
|
|
||||||
|
Ordenadas por relação valor/custo para um servidor de grupo fechado.
|
||||||
|
|
||||||
|
| Sistema | Tamanho | Por que faz sentido aqui |
|
||||||
|
|---|---|---|
|
||||||
|
| **Backup fora da VPS** | Pequena | Hoje app, banco, uploads, backups e as três cópias do repositório morrem no mesmo evento. Um envio periódico para fora resolve |
|
||||||
|
| **Aniversários e lembretes** | Pequena | Alto retorno afetivo, custo baixo: campo de data + verificação diária + mensagem no canal |
|
||||||
|
| **Perfis por servidor** | Média | Apelido e avatar diferentes por espaço, como no Discord. O modelo já tem membro por espaço |
|
||||||
|
| **Eventos agendados com presença** | Média | "Sexta 21h" com confirmação. Encaixa nas notificações e no PWA já instalado |
|
||||||
|
| **Notificações push de verdade** | Média | O `vite-plugin-pwa` e o service worker já estão lá; falta Web Push (VAPID) e o registro no servidor |
|
||||||
|
| **Níveis e conquistas** | Média | Gamificação por tempo em call e mensagens. **Mesmo registro de eventos da auditoria e das estatísticas** — três features, um mecanismo |
|
||||||
|
| **Clipes de call** | Grande | "Salvar os últimos 30 segundos" depois de alguém falar besteira. O LiveKit tem egress; exige buffer contínuo e armazenamento |
|
||||||
|
| **Fila de música compartilhada** | Grande | Um participante-robô publicando faixa de áudio na sala LiveKit. É o que mais muda o uso de um servidor de amigos, e o mais caro |
|
||||||
|
| **Autenticação em duas etapas** | Média | Só faz sentido depois de decidir o modelo de cadastro |
|
||||||
|
|
||||||
|
## Dependência que vale respeitar
|
||||||
|
|
||||||
|
**Auditoria (#9) e Estatísticas compartilham o mesmo mecanismo**: uma tabela de
|
||||||
|
eventos append-only no servidor. Construir a auditoria primeiro e as
|
||||||
|
estatísticas como leitura agregada dessa mesma tabela evita escrever dois
|
||||||
|
sistemas de registro paralelos que divergem com o tempo.
|
||||||
@@ -0,0 +1,645 @@
|
|||||||
|
# Plan A — Scanning Pipeline & Supply-Chain Hardening (report-only) Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Stand up the full automated security-scanning pipeline (Dependabot, CodeQL SAST, secret scanning, dependency CVEs, IaC/license scanning, OpenSSF Scorecard) plus supply-chain hardening (SHA-pinned actions, harden-runner, least-privilege permissions) on GitHub Actions — all **report-only/advisory**, so the PR that adds it stays green and mergeable.
|
||||||
|
|
||||||
|
**Architecture:** Four new files under `.github/` (one Dependabot config + three workflows) each with a single scan responsibility, plus a hardening sweep across the four existing workflows. Every scanner uploads SARIF to the GitHub Security tab and is non-blocking in this plan; enforcement (fail-the-build) is flipped on in a later plan (Plan E) after the remediation pass. This is the foundation the rest of the initiative builds on and, on its own, answers the "no security scanning" objection with visibly-running scanners.
|
||||||
|
|
||||||
|
**Tech Stack:** GitHub Actions (YAML), GitHub Dependabot, CodeQL (`javascript-typescript`, build-mode `none`), gitleaks, OSV-Scanner (reads `pnpm-lock.yaml` v9), Aqua Trivy (config + license), OpenSSF Scorecard, StepSecurity harden-runner. Local validators: `actionlint`, `pinact` (SHA-pinning).
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- **Report-only in this plan.** Every scanner must be non-blocking (`continue-on-error: true` at step level, or advisory SARIF upload). Enforcement is flipped on in Plan E — do NOT make any scanner fail the build here. Each non-blocking step carries a comment: `# report-only; enforcement flipped on in Plan E`.
|
||||||
|
- **SHA-pin every action.** All `uses:` refs across ALL workflows (new and existing) pin to a full 40-char commit SHA with a trailing `# vX.Y.Z` version comment. No `@v5`/`@main` tag refs may remain after Task 5.
|
||||||
|
- **harden-runner is Linux-only.** `step-security/harden-runner` runs only on Ubuntu runners. In any matrix that includes macOS/Windows (i.e. `release.yml`), guard it with `if: runner.os == 'Linux'`.
|
||||||
|
- **`egress-policy: audit`** for every harden-runner step (never `block` in this plan — multi-arch buildx/QEMU/gha-cache make many egress calls).
|
||||||
|
- **Commit identity:** the repo's local git config already uses `Jannis Braun <151788261+TheZwiss@users.noreply.github.com>` — use a plain `git commit`. NEVER override author/committer email with `-c user.email=...`, and never commit as `alxtrading94@gmail.com`.
|
||||||
|
- **No new runtime dependencies.** This plan touches only `.github/` and docs; it must not modify `package.json` dependency lists or any application/runtime code.
|
||||||
|
- **Node 20 / pnpm 10.34.3** are the project's pinned toolchain — any workflow that installs deps mirrors `ci.yml` (`pnpm/action-setup` @ 10.34.3, `actions/setup-node` node 20).
|
||||||
|
- **Branch:** all work lands on `security-scanning-hardening` (already checked out).
|
||||||
|
- **Action versions:** the YAML below uses each action's current major tag. If an action's latest major differs at implementation time, check its README and adjust the tag — then Task 5 pins whatever tag you used to its SHA. A wrong tag surfaces as an `actionlint` error or a red PR check; fix and re-run.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Dependabot config + local validators
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `.github/dependabot.yml`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: nothing (first task).
|
||||||
|
- Produces: `.github/dependabot.yml` — the Dependabot v2 config later documented by Task 6. No code symbols.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Install the local validators**
|
||||||
|
|
||||||
|
`actionlint` validates workflow YAML; `pinact` will SHA-pin actions in Task 5. On the macOS dev host:
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
brew install actionlint pinact
|
||||||
|
actionlint --version && pinact --version
|
||||||
|
```
|
||||||
|
Expected: both print a version. (Fallbacks if Homebrew lacks them: `go install github.com/rhysd/actionlint/cmd/actionlint@latest` and `go install github.com/suzuki-shunsuke/pinact/cmd/pinact@latest`, or run actionlint via Docker `docker run --rm -v "$(pwd):/repo" --workdir /repo rhysd/actionlint:latest -color`.)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write `.github/dependabot.yml`**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Dependabot keeps dependencies and CI actions patched. Three ecosystems:
|
||||||
|
# - npm → the pnpm workspace (Dependabot reads pnpm-lock.yaml v9)
|
||||||
|
# - github-actions → action version bumps (feeds the SHA-pin comments)
|
||||||
|
# - docker → the Dockerfile base image (FROM node:20-slim)
|
||||||
|
#
|
||||||
|
# NOTE (intentional): there is NO docker entry for docker-compose.yml. It sits
|
||||||
|
# at the same "/" directory (a second docker entry would collide on
|
||||||
|
# ecosystem+directory), and Dependabot's docker ecosystem parses Dockerfiles,
|
||||||
|
# not `image:` refs in compose. The pinned caddy / livekit-server compose images
|
||||||
|
# are updated MANUALLY — see the maintainer checklist in
|
||||||
|
# docs/systems/security-scanning.md.
|
||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: npm
|
||||||
|
directory: /
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
|
open-pull-requests-limit: 10
|
||||||
|
groups:
|
||||||
|
# One grouped PR for routine minor/patch bumps to cut PR noise.
|
||||||
|
npm-minor-patch:
|
||||||
|
update-types:
|
||||||
|
- minor
|
||||||
|
- patch
|
||||||
|
ignore:
|
||||||
|
# uiohook-napi is pinned by an exact-version pnpm patch
|
||||||
|
# (patches/uiohook-napi@1.5.5.patch). A bump makes the patch path stop
|
||||||
|
# matching, breaking `pnpm install --frozen-lockfile` in CI and both
|
||||||
|
# Docker stages until the patch is regenerated. Bump it by hand.
|
||||||
|
- dependency-name: uiohook-napi
|
||||||
|
|
||||||
|
- package-ecosystem: github-actions
|
||||||
|
directory: /
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
|
groups:
|
||||||
|
github-actions:
|
||||||
|
patterns:
|
||||||
|
- "*"
|
||||||
|
|
||||||
|
- package-ecosystem: docker
|
||||||
|
directory: /
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Validate YAML syntax**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
python3 -c "import yaml,sys; yaml.safe_load(open('.github/dependabot.yml')); print('dependabot.yml: valid YAML')"
|
||||||
|
```
|
||||||
|
Expected: `dependabot.yml: valid YAML` (no traceback). (The full schema is validated by GitHub after push — Task 7 confirms it in the repo's Insights → Dependency graph → Dependabot.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add .github/dependabot.yml
|
||||||
|
git commit -m "ci(security): add Dependabot config (npm + actions + docker)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: `security.yml` — secret, dependency, IaC & license scanning (report-only)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `.github/workflows/security.yml`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `pnpm-lock.yaml` (OSV lockfile scan), repo tree (gitleaks history, Trivy config/license).
|
||||||
|
- Produces: workflow `Security` with jobs `gitleaks`, `osv-scanner`, `trivy-config`, `trivy-license`; each uploads a SARIF category (`gitleaks`, `osv-scanner`, `trivy-config`, `trivy-license`). Task 6 documents these; Task 5 pins their actions.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write `.github/workflows/security.yml`**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: Security
|
||||||
|
|
||||||
|
# Report-only in this plan: every scanner is non-blocking and uploads SARIF to
|
||||||
|
# the Security tab. Enforcement (fail on fixable HIGH/CRITICAL, block on secrets)
|
||||||
|
# is flipped on in Plan E after the remediation pass.
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
schedule:
|
||||||
|
- cron: '32 5 * * 1' # weekly Monday 05:32 UTC
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: security-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
gitleaks:
|
||||||
|
name: Secret scan (gitleaks)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Harden the runner
|
||||||
|
uses: step-security/harden-runner@v2
|
||||||
|
with:
|
||||||
|
egress-policy: audit
|
||||||
|
- name: Checkout (full history)
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
fetch-depth: 0 # gitleaks scans the whole git history, not just the diff
|
||||||
|
- name: Run gitleaks
|
||||||
|
uses: gitleaks/gitleaks-action@v2
|
||||||
|
continue-on-error: true # report-only; enforcement flipped on in Plan E
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
osv-scanner:
|
||||||
|
name: Dependency scan (OSV-Scanner)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
security-events: write # upload SARIF to code scanning
|
||||||
|
steps:
|
||||||
|
- name: Harden the runner
|
||||||
|
uses: step-security/harden-runner@v2
|
||||||
|
with:
|
||||||
|
egress-policy: audit
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
- name: Run OSV-Scanner
|
||||||
|
uses: google/osv-scanner-action@v2
|
||||||
|
continue-on-error: true # report-only; enforcement flipped on in Plan E
|
||||||
|
with:
|
||||||
|
scan-args: |-
|
||||||
|
--lockfile=./pnpm-lock.yaml
|
||||||
|
--format=sarif
|
||||||
|
--output=osv-results.sarif
|
||||||
|
- name: Upload OSV SARIF
|
||||||
|
if: always()
|
||||||
|
uses: github/codeql-action/upload-sarif@v3
|
||||||
|
with:
|
||||||
|
sarif_file: osv-results.sarif
|
||||||
|
category: osv-scanner
|
||||||
|
|
||||||
|
trivy-config:
|
||||||
|
name: IaC/config scan (Trivy)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
security-events: write
|
||||||
|
steps:
|
||||||
|
- name: Harden the runner
|
||||||
|
uses: step-security/harden-runner@v2
|
||||||
|
with:
|
||||||
|
egress-policy: audit
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
- name: Trivy config scan (Dockerfile + docker-compose)
|
||||||
|
uses: aquasecurity/trivy-action@0.28.0
|
||||||
|
continue-on-error: true # report-only; enforcement flipped on in Plan E
|
||||||
|
with:
|
||||||
|
scan-type: config
|
||||||
|
scan-ref: .
|
||||||
|
format: sarif
|
||||||
|
output: trivy-config.sarif
|
||||||
|
- name: Upload Trivy config SARIF
|
||||||
|
if: always()
|
||||||
|
uses: github/codeql-action/upload-sarif@v3
|
||||||
|
with:
|
||||||
|
sarif_file: trivy-config.sarif
|
||||||
|
category: trivy-config
|
||||||
|
|
||||||
|
trivy-license:
|
||||||
|
name: License compliance scan (Trivy)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
security-events: write
|
||||||
|
steps:
|
||||||
|
- name: Harden the runner
|
||||||
|
uses: step-security/harden-runner@v2
|
||||||
|
with:
|
||||||
|
egress-policy: audit
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
- name: Trivy license scan
|
||||||
|
uses: aquasecurity/trivy-action@0.28.0
|
||||||
|
continue-on-error: true # report-only; enforcement flipped on in Plan E
|
||||||
|
with:
|
||||||
|
scan-type: fs
|
||||||
|
scan-ref: .
|
||||||
|
scanners: license
|
||||||
|
format: sarif
|
||||||
|
output: trivy-license.sarif
|
||||||
|
- name: Upload Trivy license SARIF
|
||||||
|
if: always()
|
||||||
|
uses: github/codeql-action/upload-sarif@v3
|
||||||
|
with:
|
||||||
|
sarif_file: trivy-license.sarif
|
||||||
|
category: trivy-license
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Validate with actionlint**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
actionlint .github/workflows/security.yml
|
||||||
|
```
|
||||||
|
Expected: no output (exit 0). If actionlint flags an unknown input for an action, check that action's README and correct it. (Note: actionlint does not fetch action inputs, so most such errors are shellcheck/expression issues — fix those.)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add .github/workflows/security.yml
|
||||||
|
git commit -m "ci(security): add report-only security scan workflow (gitleaks, OSV, Trivy)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: `codeql.yml` — CodeQL SAST (report-only)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `.github/workflows/codeql.yml`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: repo TypeScript/JavaScript source (analyzed with `build-mode: none`).
|
||||||
|
- Produces: workflow `CodeQL` with job `analyze`, category `/language:javascript-typescript`. Findings land in the Security tab. Task 6 documents it; Task 5 pins its actions.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write `.github/workflows/codeql.yml`**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: CodeQL
|
||||||
|
|
||||||
|
# Static application security testing for all TS/JS. Uses build-mode: none — no
|
||||||
|
# compile needed, which sidesteps the monorepo/native-module build entirely.
|
||||||
|
# Default (code-scanning) query suite; security-extended is deferred (triage tax).
|
||||||
|
# CodeQL uploads alerts to the Security tab but does NOT fail the PR by itself —
|
||||||
|
# blocking is a repo setting (code-scanning merge protection), documented in the
|
||||||
|
# maintainer checklist in docs/systems/security-scanning.md.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
schedule:
|
||||||
|
- cron: '27 3 * * 1' # weekly Monday 03:27 UTC
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: codeql-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
analyze:
|
||||||
|
name: Analyze (javascript-typescript)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
security-events: write # upload SARIF to code scanning
|
||||||
|
actions: read
|
||||||
|
steps:
|
||||||
|
- name: Harden the runner
|
||||||
|
uses: step-security/harden-runner@v2
|
||||||
|
with:
|
||||||
|
egress-policy: audit
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
- name: Initialize CodeQL
|
||||||
|
uses: github/codeql-action/init@v3
|
||||||
|
with:
|
||||||
|
languages: javascript-typescript
|
||||||
|
build-mode: none
|
||||||
|
- name: Perform CodeQL analysis
|
||||||
|
uses: github/codeql-action/analyze@v3
|
||||||
|
with:
|
||||||
|
category: "/language:javascript-typescript"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Validate with actionlint**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
actionlint .github/workflows/codeql.yml
|
||||||
|
```
|
||||||
|
Expected: no output (exit 0).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add .github/workflows/codeql.yml
|
||||||
|
git commit -m "ci(security): add CodeQL SAST workflow (javascript-typescript)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: `scorecard.yml` — OpenSSF Scorecard (report-only)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `.github/workflows/scorecard.yml`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: the whole repo + workflow metadata (Scorecard evaluates repo posture).
|
||||||
|
- Produces: workflow `OpenSSF Scorecard` with job `analysis`; publishes results (feeds the public badge added in Plan E) and uploads SARIF. Task 5 pins its actions.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write `.github/workflows/scorecard.yml`**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: OpenSSF Scorecard
|
||||||
|
|
||||||
|
# Scores the repo's security posture (branch protection, pinned deps, token
|
||||||
|
# permissions, etc.) and publishes to the OpenSSF public API so a badge can be
|
||||||
|
# shown (badge is added in Plan E). REQUIRES the canonical repo to be PUBLIC —
|
||||||
|
# see the maintainer checklist in docs/systems/security-scanning.md.
|
||||||
|
|
||||||
|
on:
|
||||||
|
branch_protection_rule:
|
||||||
|
schedule:
|
||||||
|
- cron: '18 4 * * 2' # weekly Tuesday 04:18 UTC
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
permissions: read-all
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
analysis:
|
||||||
|
name: Scorecard analysis
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
security-events: write # upload SARIF
|
||||||
|
id-token: write # publish_results OIDC attestation
|
||||||
|
steps:
|
||||||
|
- name: Harden the runner
|
||||||
|
uses: step-security/harden-runner@v2
|
||||||
|
with:
|
||||||
|
egress-policy: audit
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Run Scorecard
|
||||||
|
uses: ossf/scorecard-action@v2
|
||||||
|
with:
|
||||||
|
results_file: results.sarif
|
||||||
|
results_format: sarif
|
||||||
|
publish_results: true
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: SARIF file
|
||||||
|
path: results.sarif
|
||||||
|
retention-days: 5
|
||||||
|
- name: Upload SARIF to code scanning
|
||||||
|
uses: github/codeql-action/upload-sarif@v3
|
||||||
|
with:
|
||||||
|
sarif_file: results.sarif
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Validate with actionlint**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
actionlint .github/workflows/scorecard.yml
|
||||||
|
```
|
||||||
|
Expected: no output (exit 0).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add .github/workflows/scorecard.yml
|
||||||
|
git commit -m "ci(security): add OpenSSF Scorecard workflow"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Harden existing workflows + SHA-pin every action
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `.github/workflows/ci.yml` (add harden-runner step)
|
||||||
|
- Modify: `.github/workflows/release.yml` (add Linux-guarded harden-runner step)
|
||||||
|
- Modify: `.github/workflows/security.yml`, `codeql.yml`, `scorecard.yml`, `ci.yml`, `release.yml`, `cla.yml`, `deploy-pages.yml`, `docker-publish.yml` (SHA-pin all `uses:`)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: all workflow files from Tasks 2-4 plus the four pre-existing ones.
|
||||||
|
- Produces: every `uses:` pinned to `@<40-char-sha> # vX.Y.Z`; harden-runner (audit) on the two build workflows. No code symbols.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add harden-runner to `ci.yml`**
|
||||||
|
|
||||||
|
In `.github/workflows/ci.yml`, insert as the FIRST step of the `build-and-test` job (before `Checkout`):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: Harden the runner
|
||||||
|
uses: step-security/harden-runner@v2
|
||||||
|
with:
|
||||||
|
egress-policy: audit
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add Linux-guarded harden-runner to `release.yml`**
|
||||||
|
|
||||||
|
In `.github/workflows/release.yml`, insert as the FIRST step of the `build` matrix job (before `Checkout`). It MUST be guarded — the matrix includes macOS and Windows, where harden-runner does not run:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: Harden the runner
|
||||||
|
if: runner.os == 'Linux'
|
||||||
|
uses: step-security/harden-runner@v2
|
||||||
|
with:
|
||||||
|
egress-policy: audit
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: SHA-pin every action across all workflows**
|
||||||
|
|
||||||
|
Run `pinact` from the repo root — it rewrites each `uses: owner/repo@vX` to `uses: owner/repo@<sha> # vX` in place:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pinact run
|
||||||
|
```
|
||||||
|
|
||||||
|
Manual fallback (if `pinact` is unavailable) — resolve each tag to its commit SHA with `gh` and edit by hand. `repos/{repo}/commits/{ref}` dereferences both lightweight and annotated tags to the commit:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Example for one action; repeat for every distinct uses: ref.
|
||||||
|
gh api repos/actions/checkout/commits/v5 --jq '.sha'
|
||||||
|
# → paste as: uses: actions/checkout@<sha> # v5
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify no unpinned action refs remain**
|
||||||
|
|
||||||
|
Run (flags any `uses:` ref NOT pinned to a 40-hex-char SHA — catches both `@v5` and non-`v` semver tags like Trivy's `@0.28.0`):
|
||||||
|
```bash
|
||||||
|
grep -rnE 'uses: +[^ ]+@' .github/workflows/ | grep -vE '@[0-9a-f]{40}' && echo "UNPINNED REFS FOUND (fix above)" || echo "All actions pinned to SHA"
|
||||||
|
```
|
||||||
|
Expected: `All actions pinned to SHA` (the second grep exits non-zero when nothing is unpinned, so the `||` branch prints). A properly pinned line contains `@<40-hex> # vX.Y.Z` and is filtered out; any surviving line is an unpinned ref to fix.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Re-validate all workflows**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
actionlint
|
||||||
|
```
|
||||||
|
Expected: no output (exit 0) — actionlint scans every file in `.github/workflows/`.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add .github/workflows/
|
||||||
|
git commit -m "ci(security): SHA-pin all actions and add harden-runner (audit)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Document the pipeline
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `docs/systems/security-scanning.md`
|
||||||
|
- Modify: `CLAUDE.md` (add a subsystem-table row)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: the workflows/config from Tasks 1-5 (documents them).
|
||||||
|
- Produces: the `security-scanning.md` spec + maintainer checklist referenced by every workflow comment; a CLAUDE.md table row. No code symbols.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write `docs/systems/security-scanning.md`**
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Security Scanning & Supply-Chain Assurance
|
||||||
|
|
||||||
|
Automated, continuous scanning wired into GitHub Actions. This document is the
|
||||||
|
reference for what runs, where results go, and the one-time settings a maintainer
|
||||||
|
must enable. **Current state: report-only** — scanners surface findings in the
|
||||||
|
Security tab but do not block merges yet. Enforcement (blocking) is turned on in a
|
||||||
|
later change once the remediation pass has cleared the backlog.
|
||||||
|
|
||||||
|
## Workflows
|
||||||
|
|
||||||
|
| File | Purpose | Trigger | Result |
|
||||||
|
|------|---------|---------|--------|
|
||||||
|
| `.github/dependabot.yml` | Dependency + action + base-image update PRs | weekly | PRs |
|
||||||
|
| `.github/workflows/codeql.yml` | CodeQL SAST (`javascript-typescript`, build-mode none) | PR + push main + weekly | Security tab |
|
||||||
|
| `.github/workflows/security.yml` | gitleaks (secrets, full history), OSV-Scanner (deps), Trivy config (IaC), Trivy license | PR + push main + weekly | Security tab |
|
||||||
|
| `.github/workflows/scorecard.yml` | OpenSSF Scorecard (repo posture) | push main + weekly | Security tab + public badge |
|
||||||
|
|
||||||
|
## Tiered policy (target, enforced in a later change)
|
||||||
|
|
||||||
|
- **Always block:** gitleaks secret hit; OSV/Trivy fixable HIGH/CRITICAL; Trivy
|
||||||
|
disallowed license.
|
||||||
|
- **Advisory (SARIF → Security tab):** CodeQL alerts; OSV/Trivy unfixable or
|
||||||
|
medium/low; Scorecard.
|
||||||
|
|
||||||
|
Code-level gates (OSV, Trivy, gitleaks) block via workflow exit codes. CodeQL
|
||||||
|
merge-blocking, Dependabot alerts, and native secret-scanning are GitHub *settings*
|
||||||
|
— see the checklist below.
|
||||||
|
|
||||||
|
## Supply-chain hardening
|
||||||
|
|
||||||
|
- Every action is pinned to a full commit SHA (`# vX.Y.Z` comment) — resists
|
||||||
|
tag-move attacks and satisfies Scorecard's Pinned-Dependencies check.
|
||||||
|
- `step-security/harden-runner` (egress-policy `audit`) on Linux jobs.
|
||||||
|
- Least-privilege `permissions:` per workflow/job.
|
||||||
|
- SBOM + SLSA provenance are attached to the published container image (added with
|
||||||
|
the image-scan work).
|
||||||
|
|
||||||
|
## Maintainer checklist (one-time GitHub settings — NOT code)
|
||||||
|
|
||||||
|
- [ ] Repository must be **public** (required for the Scorecard badge/publish and
|
||||||
|
the CodeQL free tier).
|
||||||
|
- [ ] Settings → Code security: enable **Dependabot alerts** and **Dependabot
|
||||||
|
security updates**.
|
||||||
|
- [ ] Settings → Code security: enable **Secret scanning** + **Push protection**.
|
||||||
|
- [ ] Settings → Code security: enable **CodeQL / code-scanning merge protection**
|
||||||
|
so high-severity alerts block PRs (the code-level gates do the rest).
|
||||||
|
- [ ] Branch protection on `main`: require the CI + security status checks to pass.
|
||||||
|
- [ ] **Manual image bumps:** Dependabot does not track `docker-compose.yml`
|
||||||
|
`image:` pins — update `caddy` and `livekit/livekit-server` by hand when new
|
||||||
|
releases ship. (Renovate, which parses compose, is an optional future
|
||||||
|
alternative.)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add the CLAUDE.md subsystem-table row**
|
||||||
|
|
||||||
|
In `CLAUDE.md`, inside the "Subsystem Documentation" table (the block of `| File | Contents | Read when... |` rows), add:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
| [security-scanning.md](docs/systems/security-scanning.md) | CI security pipeline: Dependabot, CodeQL SAST, gitleaks, OSV-Scanner, Trivy (config/image/license), OpenSSF Scorecard, SHA-pinning, harden-runner, tiered enforcement policy, maintainer settings checklist | Any CI security work, adding/changing scanners, enabling enforcement, supply-chain hardening |
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify the doc links resolve**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
test -f docs/systems/security-scanning.md && grep -q 'security-scanning.md' CLAUDE.md && echo "doc + CLAUDE.md row present"
|
||||||
|
```
|
||||||
|
Expected: `doc + CLAUDE.md row present`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docs/systems/security-scanning.md CLAUDE.md
|
||||||
|
git commit -m "docs(security): document the scanning pipeline + maintainer checklist"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Open the PR and verify the pipeline runs
|
||||||
|
|
||||||
|
**Files:** none (verification only).
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: everything from Tasks 1-6, pushed to GitHub.
|
||||||
|
- Produces: a PR with all scanners running green/advisory — the acceptance gate for Plan A.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Push the branch**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
git push -u origin security-scanning-hardening
|
||||||
|
```
|
||||||
|
Expected: branch pushed; GitHub prints a PR-create URL.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Open a PR**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
gh pr create --fill --base main --head security-scanning-hardening \
|
||||||
|
--title "Security scanning pipeline (report-only)" \
|
||||||
|
--body "Adds Dependabot, CodeQL, gitleaks, OSV-Scanner, Trivy (config/license), OpenSSF Scorecard, SHA-pinned actions, and harden-runner. All scanners are report-only; enforcement is flipped on in a later change. See docs/systems/security-scanning.md."
|
||||||
|
```
|
||||||
|
Expected: prints the PR URL.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Watch the checks**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
gh pr checks --watch
|
||||||
|
```
|
||||||
|
Expected: `CI / Build & test` passes; `CodeQL`, `Security` (gitleaks/osv/trivy jobs), and `OpenSSF Scorecard` all complete. Because every scanner is `continue-on-error`/advisory, **no scanner may report a failing (red) required check** — a scanner surfacing findings is fine, but the job itself should not fail the PR. If a job fails for a non-finding reason (bad action input, missing permission), fix the workflow and push.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Confirm SARIF + Dependabot registration**
|
||||||
|
|
||||||
|
Verify in the GitHub UI (or note as maintainer follow-up if Actions/security features aren't enabled yet):
|
||||||
|
- Security → Code scanning: alerts appear under categories `codeql`, `osv-scanner`, `trivy-config`, `trivy-license`, and Scorecard.
|
||||||
|
- Insights → Dependency graph → Dependabot: the three ecosystems (npm, github-actions, docker) are listed as configured.
|
||||||
|
|
||||||
|
Run (CLI cross-check of code-scanning analyses, if the repo is public with Actions enabled):
|
||||||
|
```bash
|
||||||
|
gh api repos/:owner/:repo/code-scanning/analyses --jq '[.[].category] | unique' 2>/dev/null || echo "code-scanning API not available yet (enable in Settings)"
|
||||||
|
```
|
||||||
|
Expected: a list including the scanner categories, or the fallback message (then it's a maintainer-settings follow-up, not a plan defect).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Record verification outcome**
|
||||||
|
|
||||||
|
No commit. Note in the PR description (or a comment) which checks passed and any settings follow-ups (from the Task 6 maintainer checklist) still pending. Plan A is complete when the PR is green with all four scanners running advisory.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review Notes
|
||||||
|
|
||||||
|
- **Spec coverage (WS1 + supply-chain):** Dependabot (Task 1) ✓; CodeQL (Task 3) ✓; gitleaks + OSV + Trivy config + Trivy license (Task 2) ✓; Scorecard (Task 4) ✓; SHA-pinning + harden-runner (audit) + least-priv permissions across all workflows (Task 5) ✓; docs + maintainer checklist + CLAUDE.md row (Task 6) ✓; report-only sequencing honored throughout (Global Constraints + per-step comments) ✓.
|
||||||
|
- **Deferred by design (other plans, not gaps):** container image scan + SBOM/provenance + docker-publish restructure → Plan B; helmet/CSP/CORS + DAST → Plan C; Electron hardening → Plan D; remediation of findings + enforcement flip + README badges → Plan E. Task 5's SHA-pin sweep does include `docker-publish.yml` (harmless; Plan B re-pins as it restructures).
|
||||||
|
- **Enforcement stays OFF here** — every scanner is `continue-on-error`/advisory; no `fail-on`/severity gate is set in this plan. The maintainer-settings toggles (CodeQL merge protection, push protection, branch protection) are documented, not enabled in code.
|
||||||
@@ -0,0 +1,360 @@
|
|||||||
|
# Plan B — Container Hardening & Real Image Scanning Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Harden the published container image — run it as a non-root user, slim its runtime attack surface, and scan the actual amd64 image for OS/library CVEs before publishing — without breaking existing self-hosters or the multi-arch (amd64+arm64) GHCR publish.
|
||||||
|
|
||||||
|
**Architecture:** Two edits to the runtime layer (`Dockerfile` + a new `docker-entrypoint.sh`) plus a restructure of `docker-publish.yml` so a single-arch amd64 image is built and Trivy-scanned before the multi-arch push. The image scan is **report-only** here (matching Plan A's sequencing); Plan E flips it to blocking. SBOM + SLSA provenance are attached at push time.
|
||||||
|
|
||||||
|
**Tech Stack:** Docker multi-stage build (`node:20-slim`), Docker Buildx + QEMU, GitHub Actions, Aqua Trivy (image + SARIF), `gosu` for privilege drop, better-sqlite3 (prebuilt binary), tsx (runtime TS loader).
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- **This plan builds on Plan A's branch** (`security/scanning-pipeline`); the workflows here are already SHA-pinned. Work branch: `security/container-hardening`.
|
||||||
|
- **Do not break existing self-hosters.** The `./data:/app/data` bind mount (`docker-compose.yml:31`) is host-owned; the container must still read/write it after `docker pull` + restart. The non-root switch is handled by an entrypoint that chowns `/app/data` **as root** then drops to the `node` user via `gosu` — so there is **no static `USER` line** (a static `USER` would run the entrypoint unprivileged and make the chown impossible).
|
||||||
|
- **Keep `ffmpeg`** (real runtime dependency) and **keep `tsx`** (the `CMD` runs TS via `tsx/esm`). Only `python3 make g++` may leave the runtime stage.
|
||||||
|
- **better-sqlite3 must still load.** It is expected to install via its prebuilt binary on `node:20-slim` (glibc) for both amd64 and arm64. If a task's build shows it compiling (needs the toolchain), use the documented fallback (keep the toolchain, OR copy the built module from the builder stage) and report it — do not ship a broken image.
|
||||||
|
- **Image scan is report-only in this plan** (`exit-code: '0'` + `continue-on-error: true`, comment `# report-only; enforcement flipped on in Plan E`). Do NOT make it fail the publish here.
|
||||||
|
- **trivy-action pinned to `ed142fd0673e97e23eac54620cfb913e5ce36c25` (# v0.36.0)** — v0.28.0's nested `setup-trivy@v0.2.1` ref is broken (see memory `ci-security-action-gotchas`). SHA-pin any other new action with a `# vX.Y.Z` comment.
|
||||||
|
- **Both build paths must keep working:** the GHCR prebuilt-image pull (`docker-compose.yml` `image:`) AND the from-source `docker compose up --build` fallback.
|
||||||
|
- **Commit identity:** plain `git commit` (local config = `Jannis Braun <151788261+TheZwiss@users.noreply.github.com>`). NEVER `-c user.email`; never the alxtrading94 email.
|
||||||
|
- **Node 20 / pnpm 10.34.3** are the pinned toolchain.
|
||||||
|
- **Docker daemon must be running** for Tasks 1 and 2 verification (`docker build` / `buildx --load`). If it is not up, STOP and report — do not mark a Dockerfile task done without a real build+boot.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Harden the runtime image (non-root via gosu, drop build toolchain)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `docker-entrypoint.sh`
|
||||||
|
- Modify: `Dockerfile` (runtime stage, lines 37-97)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: the existing builder stage output (`/app/packages/web/dist`).
|
||||||
|
- Produces: an image that runs `node --import tsx/esm src/index.ts` as the non-root `node` user (uid 1000) with a writable `/app/data`. No code symbols.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the entrypoint script**
|
||||||
|
|
||||||
|
Create `docker-entrypoint.sh` at the repo root:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
#!/bin/sh
|
||||||
|
# Runs as root: make the (bind-mounted, host-owned) data dir writable by the
|
||||||
|
# non-root `node` user, then drop privileges via gosu and exec the CMD. This
|
||||||
|
# lets the container run as uid 1000 while still owning ./data on hosts where
|
||||||
|
# the bind mount was created by a different uid.
|
||||||
|
#
|
||||||
|
# - Idempotent AND cheap: only chown entries not already node-owned, so after
|
||||||
|
# the first boot this is near-instant. A plain `chown -R` over a large
|
||||||
|
# uploads/ tree on slow Pi/SD storage would delay startup on EVERY restart.
|
||||||
|
# - Non-fatal: on a bind mount that rejects chown (some CIFS/NFS backings),
|
||||||
|
# warn and continue rather than crash-looping under `restart: unless-stopped`
|
||||||
|
# (the old root container booted fine on such mounts).
|
||||||
|
set -e
|
||||||
|
mkdir -p /app/data/uploads
|
||||||
|
chown node:node /app/data /app/data/uploads 2>/dev/null || true
|
||||||
|
find /app/data ! -user node -exec chown node:node {} + 2>/dev/null || \
|
||||||
|
echo "docker-entrypoint: warning: could not chown /app/data; continuing (ensure it is writable by uid 1000)"
|
||||||
|
exec gosu node "$@"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Modify the runtime stage's apt-get line**
|
||||||
|
|
||||||
|
In `Dockerfile`, replace the runtime-stage package install (currently line 42-44):
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y --no-install-recommends python3 make g++ ffmpeg && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
```
|
||||||
|
|
||||||
|
with (drop the C toolchain; keep ffmpeg; add gosu for the privilege drop):
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y --no-install-recommends ffmpeg gosu && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Wire the entrypoint + keep the CMD**
|
||||||
|
|
||||||
|
In `Dockerfile`, immediately AFTER the `RUN mkdir -p /app/data/uploads` line (currently line 73) add the entrypoint copy:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
# Non-root hardening: copy the privilege-dropping entrypoint. It chowns the
|
||||||
|
# data volume as root, then execs the CMD as the unprivileged `node` user.
|
||||||
|
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||||
|
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, at the END of the file, replace the final two lines (currently line 96-97):
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
WORKDIR /app/packages/server
|
||||||
|
CMD ["node", "--import", "tsx/esm", "src/index.ts"]
|
||||||
|
```
|
||||||
|
|
||||||
|
with (add the ENTRYPOINT between WORKDIR and CMD; do NOT add a `USER` line):
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
WORKDIR /app/packages/server
|
||||||
|
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||||
|
CMD ["node", "--import", "tsx/esm", "src/index.ts"]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Build the native (arm64) image + confirm neither arch needs the toolchain**
|
||||||
|
|
||||||
|
The runtime stage runs its own `pnpm install --prod` (Dockerfile:61), so better-sqlite3/sharp install **per arch**. This host is Apple Silicon (arm64), which is ALSO the Raspberry Pi's arch — the main self-host target — so build the real image **natively for arm64** (fast; a `--platform linux/amd64` build here would emulate the whole Vite build via Rosetta and likely time out). This build both produces the image for the Step 5 boot test AND exercises the arm64 runtime install:
|
||||||
|
```bash
|
||||||
|
docker buildx build --platform linux/arm64 --load -t backspace:hardening-test --build-arg BACKSPACE_COMMIT=test .
|
||||||
|
```
|
||||||
|
Watch the better-sqlite3 output: it must use a prebuilt binary (`prebuild-install`), NOT `node-gyp`/compilation. This build may take a few minutes (pnpm install + Vite) — give it an ample timeout or run it in the background so it isn't killed mid-build.
|
||||||
|
|
||||||
|
Then verify the OTHER arch (amd64) toolchain drop with a lightweight, emulated native-module check (no Vite build, so it's quick even under emulation) on `node:20-slim` amd64, which — like the hardened runtime stage — has no `python3/make/g++`:
|
||||||
|
```bash
|
||||||
|
BSQL=$(grep -A1 'better-sqlite3@' pnpm-lock.yaml | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1); echo "testing better-sqlite3@$BSQL"
|
||||||
|
docker run --rm --platform linux/amd64 node:20-slim sh -c "
|
||||||
|
cd /tmp && npm init -y >/dev/null 2>&1 &&
|
||||||
|
npm install --no-audit --no-fund better-sqlite3@$BSQL sharp@0.33.5 2>&1 | grep -iE 'prebuild-install|prebuilt|node-gyp|gyp ERR|rebuild' | head -20;
|
||||||
|
node -e \"require('better-sqlite3')(':memory:').close(); require('sharp'); console.log('amd64 native modules OK (no toolchain)')\"
|
||||||
|
"
|
||||||
|
```
|
||||||
|
Expected: the native arm64 image builds (better-sqlite3 prebuilt), AND the amd64 test prints `amd64 native modules OK (no toolchain)`. If EITHER arch tries to compile, or the amd64 test errors, STOP — apply the fallback (re-add the toolchain to the runtime apt-get line, OR build the module in the builder stage and `COPY --from=builder`) and report which arch failed, which fallback you used, and why. Do not proceed on a one-arch pass.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Boot the container and verify non-root + data volume + DB**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
mkdir -p /tmp/bkspace-data
|
||||||
|
docker run -d --name bkspace-htest -e JWT_SECRET=testsecret_at_least_32_chars_long_xx -p 3999:3000 -v /tmp/bkspace-data:/app/data backspace:hardening-test
|
||||||
|
sleep 12
|
||||||
|
echo "--- health ---"; curl -fsS http://localhost:3999/api/health && echo " OK"
|
||||||
|
# IMPORTANT: check PID 1 (the actual server), NOT `docker exec ... id`. `docker exec`
|
||||||
|
# spawns a NEW process as the image's configured USER (root, since there is no USER
|
||||||
|
# line), so `exec ... id` prints uid=0 even when the gosu drop worked. /proc/1/status
|
||||||
|
# is the real server process's identity.
|
||||||
|
echo "--- server (PID 1) runs as node/uid 1000, not root ---"; docker exec bkspace-htest sh -c "grep '^Uid:' /proc/1/status"
|
||||||
|
echo "--- data dir written + owned by node ---"; docker exec bkspace-htest sh -c 'ls -ld /app/data /app/data/uploads'
|
||||||
|
echo "--- better-sqlite3 loaded (DB file exists) ---"; docker exec bkspace-htest sh -c 'ls -la /app/data/*.db 2>/dev/null || echo NO_DB'
|
||||||
|
echo "--- sharp (native, toolchain-sensitive) loads ---"; docker exec bkspace-htest node -e "require('sharp'); console.log('sharp OK')"
|
||||||
|
echo "--- boot logs clean (no EACCES / permission errors from running non-root) ---"; docker logs bkspace-htest 2>&1 | grep -iE 'EACCES|permission denied|EPERM' && echo "PERMISSION ERRORS FOUND" || echo "logs clean"
|
||||||
|
```
|
||||||
|
Expected: `/api/health` returns ok; `Uid:` line shows `1000 1000 1000 1000` (server runs non-root); `/app/data` + `/app/data/uploads` exist and are `node`-owned; a `.db` file was created (better-sqlite3 loaded and wrote); `sharp OK` prints (the OTHER native module survived the toolchain drop); logs show no permission errors. If any fails, fix before proceeding.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Tear down the test container**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
docker rm -f bkspace-htest; rm -rf /tmp/bkspace-data
|
||||||
|
docker rmi backspace:hardening-test 2>/dev/null || true
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docker-entrypoint.sh Dockerfile
|
||||||
|
git commit -m "fix(docker): run container as non-root (gosu) and drop build toolchain from runtime"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Scan the image before publishing (restructure docker-publish.yml)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `.github/workflows/docker-publish.yml`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: the hardened `Dockerfile` from Task 1.
|
||||||
|
- Produces: a publish workflow that builds amd64 → Trivy-scans it (report-only) → pushes multi-arch with SBOM + provenance. No code symbols.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Restructure the build/scan/push steps**
|
||||||
|
|
||||||
|
In `.github/workflows/docker-publish.yml`, add `security-events: write` to the top-level `permissions` block (it currently has `contents: read` + `packages: write`):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
security-events: write
|
||||||
|
```
|
||||||
|
|
||||||
|
Then replace the single `Build and push (linux/amd64, linux/arm64)` step (currently lines 79-92) with the build → scan → push sequence:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Build a single-arch amd64 image and LOAD it into the runner's docker
|
||||||
|
# daemon so Trivy can scan the exact artifact before anything is published.
|
||||||
|
# A multi-arch manifest cannot be --load'ed, so scanning must happen on a
|
||||||
|
# single-arch build first; the multi-arch push below reuses these layers
|
||||||
|
# from the buildx cache, so this is cheap.
|
||||||
|
- name: Build amd64 image for scanning
|
||||||
|
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: linux/amd64
|
||||||
|
load: true
|
||||||
|
push: false
|
||||||
|
tags: backspace:scan
|
||||||
|
build-args: |
|
||||||
|
BACKSPACE_COMMIT=${{ steps.meta_commit.outputs.commit }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
|
- name: Trivy image scan (report-only)
|
||||||
|
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
||||||
|
continue-on-error: true # report-only; enforcement flipped on in Plan E
|
||||||
|
with:
|
||||||
|
scan-type: image
|
||||||
|
image-ref: backspace:scan
|
||||||
|
ignore-unfixed: true
|
||||||
|
format: sarif
|
||||||
|
output: trivy-image.sarif
|
||||||
|
severity: HIGH,CRITICAL
|
||||||
|
|
||||||
|
- name: Upload Trivy image SARIF
|
||||||
|
if: always()
|
||||||
|
uses: github/codeql-action/upload-sarif@02c5e83432fe5497fd85b873b6c9f16a8578e1d9 # v3.37.0
|
||||||
|
with:
|
||||||
|
sarif_file: trivy-image.sarif
|
||||||
|
category: trivy-image
|
||||||
|
|
||||||
|
# Publish the multi-arch image. Reuses the amd64 layers built above via the
|
||||||
|
# gha cache. Attaches an SBOM and SLSA provenance attestation to the image.
|
||||||
|
- name: Build and push (linux/amd64, linux/arm64)
|
||||||
|
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.docker_meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.docker_meta.outputs.labels }}
|
||||||
|
build-args: |
|
||||||
|
BACKSPACE_COMMIT=${{ steps.meta_commit.outputs.commit }}
|
||||||
|
sbom: true
|
||||||
|
provenance: true
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Validate the workflow**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
actionlint .github/workflows/docker-publish.yml
|
||||||
|
```
|
||||||
|
Expected: exit 0, no output.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Confirm all actions still SHA-pinned**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
grep -rnE 'uses: +[^ ]+@' .github/workflows/docker-publish.yml | grep -vE '@[0-9a-f]{40}' && echo "UNPINNED FOUND" || echo "All actions pinned to SHA"
|
||||||
|
```
|
||||||
|
Expected: `All actions pinned to SHA`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Locally reproduce the build→load→scan path**
|
||||||
|
|
||||||
|
This proves the new build→load→scan logic works without publishing anything (requires Docker daemon + local Trivy: `brew install trivy` if absent). Build native (arm64) here to avoid emulation — the scan mechanism is arch-independent; CI scans the amd64 image natively on GitHub's runners:
|
||||||
|
```bash
|
||||||
|
docker buildx build --platform linux/arm64 --load -t backspace:scan --build-arg BACKSPACE_COMMIT=test .
|
||||||
|
trivy image --severity HIGH,CRITICAL --ignore-unfixed backspace:scan | tail -25
|
||||||
|
docker rmi backspace:scan
|
||||||
|
```
|
||||||
|
Expected: the image builds + loads, and Trivy scans it and prints a summary (findings are fine — the scan is report-only; we just need it to RUN). Note accurately in the report: only the **amd64** image is Trivy-scanned; the published **arm64** image ships unscanned (acceptable for this plan). The multi-arch push + SBOM/provenance path cannot be exercised without publishing — it is verified by review + a maintainer `workflow_dispatch` run; the amd64 layers are gha-cache reused on the push, but the **arm64 layers build cold** there (so the push is not "free").
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add .github/workflows/docker-publish.yml
|
||||||
|
git commit -m "ci(docker): scan the amd64 image before publish; attach SBOM + provenance"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Document the container hardening
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `docs/systems/deployment.md`
|
||||||
|
- Modify: `docs/systems/security-scanning.md`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: the changes from Tasks 1-2.
|
||||||
|
- Produces: an upgrade/migration note + an updated pipeline reference. No code symbols.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add a container-hardening + migration note to deployment.md AND correct now-false ownership statements**
|
||||||
|
|
||||||
|
Read `docs/systems/deployment.md` first to match its structure. Then:
|
||||||
|
|
||||||
|
(a) Add a subsection (place it near the Docker/image content) with this content:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
### Container hardening (non-root)
|
||||||
|
|
||||||
|
The runtime image runs as the unprivileged `node` user (uid 1000), not root. On
|
||||||
|
container start, `docker-entrypoint.sh` runs as root only long enough to `chown`
|
||||||
|
the `./data` bind mount to `node` (only entries not already node-owned, so it is
|
||||||
|
near-instant after the first boot), then drops privileges via `gosu` and execs the
|
||||||
|
server. The build toolchain (`python3`/`make`/`g++`) is not installed in the
|
||||||
|
runtime stage — `better-sqlite3` and `sharp` load from prebuilt binaries — which
|
||||||
|
shrinks the runtime attack surface. `ffmpeg` remains (a real runtime dependency).
|
||||||
|
|
||||||
|
The published image carries an SBOM and SLSA provenance attestation, and the
|
||||||
|
amd64 image is scanned by Trivy before publish (report-only). Note: only the
|
||||||
|
amd64 image is scanned; the arm64 image is published unscanned.
|
||||||
|
|
||||||
|
**Minimum Docker version:** the attestation-bearing multi-arch image requires a
|
||||||
|
reasonably modern Docker to `pull` cleanly (Docker Engine 24+ recommended).
|
||||||
|
Very old daemons (≤ 20.10) may mishandle the `unknown/unknown` attestation
|
||||||
|
manifests. New installs via `install.sh` (get.docker.com) are fine.
|
||||||
|
|
||||||
|
**Upgrade note for existing self-hosters:** on the first start of the hardened
|
||||||
|
image, the contents of your host `./data` directory are chowned to uid 1000. This
|
||||||
|
is expected and idempotent. If you previously accessed `./data` on the host as a
|
||||||
|
different user, adjust host-side access accordingly. `./restore.sh` continues to
|
||||||
|
work — it swaps files inside a throwaway root container, and root can rewrite the
|
||||||
|
now uid-1000-owned files.
|
||||||
|
```
|
||||||
|
|
||||||
|
(b) Correct the two statements that this change makes false (the data dir is no
|
||||||
|
longer root-owned):
|
||||||
|
- The seed-admin line (around `deployment.md:170`): change
|
||||||
|
`writes it to `data/seed-admin-rotated.txt` (mode `0600`, root-owned via the bind-mount)`
|
||||||
|
→ `... (mode `0600`, owned by the container's runtime user uid 1000 via the bind-mount)`.
|
||||||
|
- The Restore intro (around `deployment.md:252`): change
|
||||||
|
`Because `data/` (including `backspace.db` and `data/backups/`) is **container-owned (root)** via the bind-mount`
|
||||||
|
→ `... is **container-owned (uid 1000)** via the bind-mount`. (The throwaway
|
||||||
|
root `alpine` container still performs the swap — root can rewrite uid-1000
|
||||||
|
files — so the mechanism description after it stays correct.)
|
||||||
|
|
||||||
|
**Maintainer release-gate (record it, do not action it here):** before the first
|
||||||
|
`v*` tag that ships this image, do a real `docker compose pull && docker compose
|
||||||
|
up -d` on both an amd64 host and the arm64 Pi to confirm the attestation-bearing
|
||||||
|
image pulls on the actual deployment Docker versions.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Reflect the image scan in security-scanning.md**
|
||||||
|
|
||||||
|
In `docs/systems/security-scanning.md`, update the supply-chain line about SBOM/provenance (currently "**will be** attached ... not yet live") to reflect that image scanning + SBOM + provenance now exist in `docker-publish.yml` (report-only image scan; SBOM + provenance attached at push). Add `docker-publish.yml` to the workflow table with trigger "tag push / manual" and result "image scan (report-only) + SBOM + provenance".
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify the docs reference reality**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
grep -q 'non-root' docs/systems/deployment.md && grep -q 'provenance' docs/systems/security-scanning.md && echo "docs updated"
|
||||||
|
```
|
||||||
|
Expected: `docs updated`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docs/systems/deployment.md docs/systems/security-scanning.md
|
||||||
|
git commit -m "docs(docker): document non-root runtime, data-volume migration, and image scan"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review Notes
|
||||||
|
|
||||||
|
- **Spec coverage (WS2):** non-root USER via gosu (Task 1) ✓; slim runtime / drop toolchain with prebuilt-binary verification + fallback (Task 1) ✓; keep ffmpeg + tsx (Task 1 / constraints) ✓; bind-mount chown migration (Task 1 entrypoint + Task 3 doc) ✓; restructure to single-arch load → scan → multi-arch push (Task 2) ✓; SBOM + provenance (Task 2) ✓; image scan report-only, flips in Plan E (Task 2 + constraints) ✓.
|
||||||
|
- **Deferred by design:** flipping the image scan to blocking → Plan E. Desktop, web/CSP/CORS → Plans C/D.
|
||||||
|
- **Risk-managed:** the toolchain removal is verified by a real build that must show better-sqlite3 using a prebuilt binary; a fallback is defined if it compiles. The non-root switch is verified by asserting `uid=1000` at runtime and a successful `./data` write + DB creation. Multi-arch push + SBOM/provenance is review-plus-workflow_dispatch verified (cannot be exercised without publishing).
|
||||||
|
- **Enforcement stays OFF** — image scan is `continue-on-error` + `severity`-limited to HIGH/CRITICAL for the report; no build-failing gate added here.
|
||||||
|
- **Adversarial pre-execution review folded in:** the non-root verification now checks `/proc/1/status` (not `docker exec … id`, which spawns a new root process and would false-fail); BOTH arches are built to verify the toolchain drop (arm64 is the release-hard-fail path); the entrypoint chown is idempotent-cheap + non-fatal (Pi/CIFS safety); a `sharp` native-load smoke check + EACCES log scan were added; now-false `deployment.md` ownership statements are corrected; and an SBOM/provenance min-Docker floor + a maintainer pull-test release-gate are documented (attestation-bearing images can trip very old Docker on the `pull` path).
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
# Design: Split `routes/federation.ts` into cohesive modules
|
||||||
|
|
||||||
|
**Date:** 2026-07-10
|
||||||
|
**Status:** Approved (strategy) — pending implementation plan
|
||||||
|
**Author:** Lead Developer
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
`packages/server/src/routes/federation.ts` is **7,626 lines** — the largest file in the
|
||||||
|
codebase by nearly 3×. It bundles at least five unrelated responsibilities:
|
||||||
|
|
||||||
|
1. HTTP route registration (`federationRoutes()` alone is ~2,800 lines / 30 endpoints)
|
||||||
|
2. Federated identity resolution (`resolveLocalUser`, `resolveOrCreateReplicatedUser`, …)
|
||||||
|
3. ~30 inbound relay event processors (`process*Event`)
|
||||||
|
4. DM channel / federated-id reconciliation helpers
|
||||||
|
5. Rate-limiting, nonce, and peer-approval-queue internals
|
||||||
|
|
||||||
|
A file this size is unreviewable (the originating complaint) and unsafe to change: any
|
||||||
|
diff touching it is hard to reason about, and the file is too large to hold in working
|
||||||
|
memory (human or model) at once.
|
||||||
|
|
||||||
|
This is a **structural** problem, not a behavioral one. The fix is to split the file
|
||||||
|
along its existing responsibility seams — **not** to change any behavior.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Every module has **one clear purpose**, is independently readable (~150–850 lines),
|
||||||
|
and communicates through explicit imports/exports.
|
||||||
|
- **Zero behavior change.** The ~21,700 lines of existing federation tests are the proof
|
||||||
|
and must pass unchanged.
|
||||||
|
- **Zero import-path churn** anywhere else in the codebase or tests.
|
||||||
|
|
||||||
|
## Non-Goals (explicitly deferred to a separate follow-up — "Phase C")
|
||||||
|
|
||||||
|
- Refactoring or de-duplicating logic *inside* the moved functions.
|
||||||
|
- Removing intra-function dead branches.
|
||||||
|
- Renaming symbols or changing signatures.
|
||||||
|
|
||||||
|
These are real but must not be mixed into the move: an edit-while-moving diff destroys
|
||||||
|
`git`'s move detection and re-creates the unreviewability problem. Cleanup, if warranted,
|
||||||
|
lands afterward as small edits scoped to the now-isolated modules.
|
||||||
|
|
||||||
|
## Key findings from analysis
|
||||||
|
|
||||||
|
- **No dead top-level code.** All 78 top-level symbols are reachable (self-referenced,
|
||||||
|
imported by another source module, or exercised by tests). A "prune dead functions"
|
||||||
|
phase has **no targets** — this is a pure move. (Verified by a per-symbol reference
|
||||||
|
count across all `packages/**/*.ts`.)
|
||||||
|
- **Small public surface.** Only **5 symbols** are imported by other *source* files:
|
||||||
|
`validateOrigin`, `extractDomain`, `backfillReplicatedProfileAssets`,
|
||||||
|
`sweepDeadIncarnationArtifacts`, `reconcileDriftedDmFederatedIds`, plus `federationRoutes`
|
||||||
|
(from `index.ts`). Tests import the 22 currently-`export`ed symbols. Everything else is
|
||||||
|
file-internal.
|
||||||
|
- **`federationRoutes()` has no function-local shared state.** Its body is a flat list of
|
||||||
|
`app.<verb>()` registrations; each handler closes only over module-level imports and the
|
||||||
|
5 module-level rate-limit maps + nonce store. Splitting it into independent registrar
|
||||||
|
functions requires no hoisting.
|
||||||
|
- **Build mechanics:** `moduleResolution: "bundler"`, `isolatedModules: true`,
|
||||||
|
`noUnusedLocals: false`. Importers use explicit `./routes/federation.js` specifiers.
|
||||||
|
|
||||||
|
## Strategy: barrel + submodule directory
|
||||||
|
|
||||||
|
`routes/federation.ts` **stays as a file** and becomes a thin **barrel**:
|
||||||
|
|
||||||
|
- It re-exports the 22 public symbols from their new homes, so every existing
|
||||||
|
`from '.../routes/federation.js'` import (source **and** test) resolves unchanged.
|
||||||
|
The one interface (`DmReconcileResult`) is re-exported via `export type { … }`
|
||||||
|
(required by `isolatedModules`).
|
||||||
|
- It defines `federationRoutes(app)` as a thin function that calls the six route
|
||||||
|
registrars in order.
|
||||||
|
|
||||||
|
Because importers use the explicit `.js` specifier, the barrel **must** remain a file at
|
||||||
|
`routes/federation.ts`; the extracted modules live beside it in a new `routes/federation/`
|
||||||
|
directory (a file and a same-named directory coexist fine on disk and under bundler
|
||||||
|
resolution).
|
||||||
|
|
||||||
|
## Target module layout
|
||||||
|
|
||||||
|
```
|
||||||
|
packages/server/src/routes/
|
||||||
|
federation.ts ← BARREL: re-exports public API + federationRoutes()
|
||||||
|
federation/
|
||||||
|
rateLimits.ts ← rate-limit consts+maps+fns, nonce store, _resetLookupRateBuckets
|
||||||
|
origin.ts ← validateOrigin, resolveLocalOrigin, sanitizePeer, SanitizedPeer
|
||||||
|
identity.ts ← extractDomain, getOurIdentityDomain, verifyAttribution,
|
||||||
|
resolveLocalUser, findFederatedUser,
|
||||||
|
resolveOrCreateReplicatedUser, backfillHomeUserId
|
||||||
|
dmChannels.ts ← buildDmChannelPayload, findOrCreateDmChannel,
|
||||||
|
buildDmMessagePayload, isUrlFromPeer, resolveLocalDmMessage
|
||||||
|
profile.ts ← hydrateReplicatedUserProfile, downloadProfileAsset,
|
||||||
|
processProfileUpdateEvent, backfillReplicatedProfileAssets
|
||||||
|
reconciliation.ts ← DmReconcileResult, reconcileDmChannelFederatedId,
|
||||||
|
reconcileDriftedDmFederatedIds, sweepDeadIncarnationArtifacts
|
||||||
|
events/
|
||||||
|
dmMessages.ts ← processCreate/Update/Delete/ReactionAdd/ReactionRemove Event
|
||||||
|
membership.ts ← processMemberAdd/MemberRemove/OwnershipTransfer/GroupMetadataUpdate Event
|
||||||
|
friends.ts ← processFriendRequestCreate/Update/Cancel, FriendAdd/Remove Event
|
||||||
|
calls.ts ← processDmCallStart/Accept/Reject/End, TypingStart/Stop,
|
||||||
|
fanOutCallEvent, emitHostFanoutUndeliverable
|
||||||
|
dmState.ts ← processDmClose/DmReopen/ReadStateUpdate/PresenceUpdate/FileRejected Event
|
||||||
|
dispatch.ts ← processRelayEvents (imports every processor above)
|
||||||
|
handlers/
|
||||||
|
peerHandshake.ts ← POST peer/initiate, peer/accept, peer/ensure, peer/rotate, peer/denied
|
||||||
|
peerAdmin.ts ← GET peers, reset-events(+ack), peers/:id GET/DELETE/permanent,
|
||||||
|
peers/:id reset/recheck/rotate
|
||||||
|
approvals.ts ← approval-requests(+approve/deny), peering-subscriptions,
|
||||||
|
peering-notifications + queueApprovalRequest,
|
||||||
|
handleInbound/OutboundApprove, handleInbound/OutboundDeny
|
||||||
|
relay.ts ← POST identity, relay, epoch, sync
|
||||||
|
lookup.ts ← POST users/lookup, users/by-home-id
|
||||||
|
files.ts ← POST verify-attach-proof
|
||||||
|
```
|
||||||
|
|
||||||
|
~19 modules, ~150–850 lines each (avg ~400). Route-registration order across registrars
|
||||||
|
is preserved by calling them in path order; Fastify does not depend on cross-path
|
||||||
|
registration order, so intra-group reordering (grouping interleaved endpoints) is safe.
|
||||||
|
|
||||||
|
### Dependency layering (acyclic)
|
||||||
|
|
||||||
|
```
|
||||||
|
L0 leaves: rateLimits · origin · identity · dmChannels
|
||||||
|
L1: profile · reconciliation · events/* (use L0)
|
||||||
|
L2: events/dispatch (uses all events/*)
|
||||||
|
L3: handlers/* (use L0–L2)
|
||||||
|
L4 barrel: federation.ts (re-exports + calls handlers/*)
|
||||||
|
```
|
||||||
|
|
||||||
|
No leaf imports upward, so no import cycles.
|
||||||
|
|
||||||
|
## Correctness / verification strategy
|
||||||
|
|
||||||
|
- **Test net:** the full server suite (`pnpm --filter @backspace/server test`, ~30
|
||||||
|
federation test files / ~21.7k lines) runs after **each** module group is extracted.
|
||||||
|
Green throughout = behavior preserved.
|
||||||
|
- **Typecheck + build** (`pnpm -w typecheck && pnpm --filter @backspace/server build`)
|
||||||
|
after each group catches import/type regressions immediately.
|
||||||
|
- **Move discipline:** functions are moved **verbatim**. The only permitted edits are
|
||||||
|
(a) adding `import`/`export` statements, and (b) the barrel re-exports. No logic edits.
|
||||||
|
- **`git diff -M`** on the final result should render as moves + a small barrel — the
|
||||||
|
reviewability property we are buying.
|
||||||
|
|
||||||
|
## Rollout (phased so each commit is independently verifiable)
|
||||||
|
|
||||||
|
- **Phase A — Prune.** *Empty by analysis* (no dead top-level code). Skipped; documented
|
||||||
|
here so the absence is deliberate, not overlooked.
|
||||||
|
- **Phase B — Extract (this design).** One commit per module group, tests green at each:
|
||||||
|
1. Leaf helpers: `rateLimits`, `origin`, `identity`, `dmChannels`
|
||||||
|
2. `profile`, `reconciliation`
|
||||||
|
3. `events/*` + `events/dispatch`
|
||||||
|
4. `handlers/*` + convert `federation.ts` to the barrel
|
||||||
|
5. Update `docs/systems/federation.md` source-file map
|
||||||
|
- **Phase C — Cleanup (separate, later, optional).** Intra-module logic simplification,
|
||||||
|
now reviewable because each concern is isolated in a small file.
|
||||||
|
|
||||||
|
## Risks & mitigations
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|------|-----------|
|
||||||
|
| Import cycle between extracted modules | Enforced L0–L4 layering; typecheck catches any cycle immediately |
|
||||||
|
| A test imports a symbol the barrel forgot to re-export | Barrel re-exports the exact set of 22 currently-`export`ed symbols; verified against the export grep |
|
||||||
|
| `isolatedModules` breaks type re-export | `DmReconcileResult` re-exported via `export type { … }` |
|
||||||
|
| Route path resolution changes | Barrel stays a file at `routes/federation.ts`; no importer specifier changes |
|
||||||
|
| Hidden shared local state in `federationRoutes` | Verified none exists (flat `app.<verb>()` body) |
|
||||||
|
```
|
||||||
@@ -0,0 +1,428 @@
|
|||||||
|
# Security Scanning & Hardening Initiative — Design
|
||||||
|
|
||||||
|
**Date:** 2026-07-10
|
||||||
|
**Status:** Approved (design); pending implementation plan
|
||||||
|
**Author:** Lead Developer (Backspace)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Motivation
|
||||||
|
|
||||||
|
A prospective self-hoster declined to run Backspace with the objection:
|
||||||
|
|
||||||
|
> "Security testing: You've made a web app. I am not installing a new webapp that
|
||||||
|
> is expected to touch the internet without some level of security scanning."
|
||||||
|
|
||||||
|
The objection is valid. Investigation of the current state shows Backspace has
|
||||||
|
solid security **engineering** but no security **assurance infrastructure**:
|
||||||
|
|
||||||
|
**Already present (good):**
|
||||||
|
- `SECURITY.md` with a private vulnerability-disclosure policy.
|
||||||
|
- Real defensive code: SSRF protection (`packages/server/src/utils/ssrf.ts` — DNS
|
||||||
|
resolution, private-IP blocking, per-redirect-hop re-validation), HMAC-signed
|
||||||
|
federation with replay-nonce prevention, sliding-window rate limiters, JWT +
|
||||||
|
bcrypt, input validation.
|
||||||
|
- Good secrets hygiene: `.deploy.local` untracked; thorough `.gitignore`
|
||||||
|
(`.env*`, `*.pem`, `*.key`, `data/`, `*.db`).
|
||||||
|
- CI (`ci.yml`) running typecheck + build + full test suite.
|
||||||
|
|
||||||
|
**Absent (the gap):**
|
||||||
|
- No SAST, no dependency/CVE scanning, no secret scanning, no container image
|
||||||
|
scanning, no supply-chain hardening, no Dependabot.
|
||||||
|
- No visible, verifiable evidence a stranger can audit before trusting the app.
|
||||||
|
- **No browser-facing hardening** the objection actually cares about: no security
|
||||||
|
response headers (`@fastify/helmet` absent; bare `Caddyfile`), CORS reflects any
|
||||||
|
origin with credentials, unsigned desktop autoupdate, no Electron fuses/asar
|
||||||
|
integrity, no license compliance gate for a dual-licensed (AGPL + commercial)
|
||||||
|
project.
|
||||||
|
|
||||||
|
**Root cause (per the No-Band-Aids principle):** the fix is not a one-off scan. It
|
||||||
|
is a permanent, automated, and *visible* scanning pipeline wired into CI/CD, plus
|
||||||
|
remediation of the browser/desktop hardening gaps that continuous scanning would
|
||||||
|
be embarrassing to leave open.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Goals & Non-Goals
|
||||||
|
|
||||||
|
### Goals
|
||||||
|
1. Continuous, automated scanning on every change: SAST, dependency CVEs, secrets,
|
||||||
|
container image, license compliance, supply-chain posture.
|
||||||
|
2. Tiered enforcement: high-confidence, fixable issues **block merge**; the rest are
|
||||||
|
advisory in the GitHub Security tab. Never wall off merges on unfixable upstream
|
||||||
|
CVEs.
|
||||||
|
3. Close the browser-facing and desktop-facing hardening gaps (headers, CORS,
|
||||||
|
Electron integrity).
|
||||||
|
4. Publish verifiable evidence: badges, an OpenSSF Scorecard, SBOM/provenance, and
|
||||||
|
documentation a stranger can read without repo access.
|
||||||
|
5. Land the whole thing without leaving CI spuriously red: scanners report-only →
|
||||||
|
remediate → flip enforcement.
|
||||||
|
|
||||||
|
### Non-Goals (explicitly out of scope for this initiative)
|
||||||
|
- **npm provenance / package signing** — every workspace is `"private": true`;
|
||||||
|
nothing is published to npm. N/A.
|
||||||
|
- **Purchasing desktop code-signing certificates** — a procurement action (Apple
|
||||||
|
Developer ~$99/yr, a Windows code-signing cert) that cannot be done in code. We
|
||||||
|
implement the *code-level* Electron hardening and *document* the signing steps
|
||||||
|
and certs to buy; we do not fake signing.
|
||||||
|
- **Full fuzzing harness for federation input** — valuable but multi-week; deferred.
|
||||||
|
A handful of targeted negative/property tests on `validateExternalUrl` and S2S
|
||||||
|
JSON parsing is in scope; a standing fuzz harness is not.
|
||||||
|
- **TLS/cipher configuration** — Caddy already auto-provisions HTTPS with modern
|
||||||
|
defaults; we add security *headers*, not a TLS overhaul.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Tool Selection & Rationale
|
||||||
|
|
||||||
|
Where a "GitHub-native" option and a "committed-workflow" option overlap, we prefer
|
||||||
|
**committed workflow files** — a self-hoster auditing the repo can read a `.yml`
|
||||||
|
file; they cannot read repo settings. Settings-only toggles are documented as
|
||||||
|
required manual steps, never claimed as code.
|
||||||
|
|
||||||
|
| Scan class | Choice | Rationale |
|
||||||
|
|---|---|---|
|
||||||
|
| SAST | **CodeQL** (committed advanced workflow, default `security` suite to start) | Free for public repos, best TS/JS coverage, `none` build mode sidesteps monorepo/native-module build complexity. `security-extended` deferred to avoid a day-one triage tax. |
|
||||||
|
| Dependency CVEs | **OSV-Scanner** (blocking CI gate) **+ Dependabot** (auto-upgrade PRs) | OSV-Scanner parses `pnpm-lock.yaml` v9 directly and can fail the build; Dependabot alerts are advisory-only. Two distinct roles, no overlap. **Trivy is NOT used for dependency CVEs** (avoids double-noise). |
|
||||||
|
| Secrets | **gitleaks** (committed, full history + PR diff) **+** documented native push-protection | gitleaks is the verifiable, blocking, history-aware gate; native push-protection is the complementary pre-commit net for the future. |
|
||||||
|
| Container image | **Trivy** (image scan, blocking) | SARIF output, `ignore-unfixed: true` for tiered policy, scans the exact GHCR image users pull. |
|
||||||
|
| IaC/config | **Trivy config** (Dockerfile, docker-compose) | Note: Trivy does **not** lint the `Caddyfile`; the reverse-proxy hardening is done by hand (§6.3). |
|
||||||
|
| License compliance | **Trivy `--scanners license`** with an allowlist | Dual-licensed AGPL + commercial → a copyleft-incompatible transitive dep is a legal defect. Reuses the Trivy we already run. |
|
||||||
|
| Supply chain | **SHA-pinned actions + harden-runner (audit) + SBOM + SLSA provenance + OpenSSF Scorecard** | Answers "can I trust the build?" and produces a public Scorecard badge. |
|
||||||
|
| Dynamic (DAST) | **ZAP baseline** against an ephemeral `docker compose up` (advisory) | Catches missing headers + CORS reflection continuously; the one dynamic check for a "webapp exposed to the internet." |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Architecture — Component Layout
|
||||||
|
|
||||||
|
Each workflow file has one clear purpose (mirrors the codebase's module-boundary
|
||||||
|
principle).
|
||||||
|
|
||||||
|
```
|
||||||
|
.github/
|
||||||
|
dependabot.yml NEW — pnpm(npm) + github-actions + docker(Dockerfile only)
|
||||||
|
workflows/
|
||||||
|
codeql.yml NEW — CodeQL SAST (PR + push main + weekly)
|
||||||
|
security.yml NEW — gitleaks + OSV-Scanner + Trivy config + Trivy license
|
||||||
|
scorecard.yml NEW — OpenSSF Scorecard (push main + weekly) → Security tab + badge
|
||||||
|
dast.yml NEW — ZAP baseline vs ephemeral compose stack (advisory)
|
||||||
|
docker-publish.yml EDIT — restructure for real image scanning + SBOM + provenance
|
||||||
|
ci.yml EDIT — harden-runner (audit), tighten permissions
|
||||||
|
release.yml EDIT — harden-runner (audit), tighten permissions
|
||||||
|
cla.yml EDIT
|
||||||
|
deploy-pages.yml EDIT
|
||||||
|
```
|
||||||
|
|
||||||
|
**SHA-pinning applies to EVERY workflow** — the four edited above, `docker-publish.yml`,
|
||||||
|
and all four new ones (`codeql`/`security`/`scorecard`/`dast`). Pin every `uses:` to a
|
||||||
|
full commit SHA with a trailing `# vX.Y.Z` comment. (OpenSSF Scorecard's
|
||||||
|
Pinned-Dependencies check and tag-move attack resistance both require this repo-wide.)
|
||||||
|
|
||||||
|
```
|
||||||
|
Dockerfile EDIT — non-root USER, slim runtime, copy pruned node_modules
|
||||||
|
Caddyfile EDIT — security response headers
|
||||||
|
packages/server/src/index.ts EDIT — @fastify/helmet + CSP; tighten CORS
|
||||||
|
packages/server/package.json EDIT — add @fastify/helmet
|
||||||
|
packages/web/index.html EDIT — CSP meta (defense in depth for static shell)
|
||||||
|
packages/desktop/src/main.ts EDIT — will-navigate deny handler
|
||||||
|
packages/desktop/electron-builder.yml EDIT — @electron/fuses / asar integrity
|
||||||
|
packages/desktop/package.json EDIT — add @electron/fuses
|
||||||
|
README.md EDIT — badges + "Security & supply chain" section
|
||||||
|
SECURITY.md EDIT — "Security testing & assurance" section
|
||||||
|
docs/systems/security-scanning.md NEW — full pipeline spec
|
||||||
|
docs/systems/desktop-security.md NEW — Electron hardening + signing procurement
|
||||||
|
CLAUDE.md EDIT — add subsystem-table rows
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Policy Engine (tiered enforcement)
|
||||||
|
|
||||||
|
| Finding | Action |
|
||||||
|
|---|---|
|
||||||
|
| gitleaks secret hit | **Block** (always) |
|
||||||
|
| OSV-Scanner — fixable HIGH/CRITICAL | **Block** |
|
||||||
|
| Trivy image — fixable HIGH/CRITICAL (`ignore-unfixed: true`) | **Block** |
|
||||||
|
| Trivy license — disallowed license | **Block** |
|
||||||
|
| CodeQL — any alert | Advisory (SARIF → Security tab) |
|
||||||
|
| OSV/Trivy — unfixable, or medium/low | Advisory (SARIF → Security tab) |
|
||||||
|
| ZAP baseline (DAST) | Advisory (report artifact) |
|
||||||
|
| Scorecard | Advisory (score badge + Security tab) |
|
||||||
|
|
||||||
|
**Enforcement honesty — two mechanisms, kept separate:**
|
||||||
|
- **Code-enforced (auditable in the `.yml`):** OSV-Scanner, Trivy, and gitleaks
|
||||||
|
block via workflow exit codes.
|
||||||
|
- **Settings-enforced (documented one-time toggles, NOT claimed as code):** CodeQL
|
||||||
|
merge-blocking (code-scanning merge protection), Dependabot alerts, native
|
||||||
|
secret-scanning + push protection, and branch protection "require status checks."
|
||||||
|
These live in `docs/systems/security-scanning.md` as a maintainer checklist.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Workstreams (bounded, independently reviewable)
|
||||||
|
|
||||||
|
Sequencing rule: **WS1 scanners land report-only → WS5 remediation → flip WS1/WS2
|
||||||
|
enforcement to blocking.** WS3/WS4 are otherwise independent and can land in
|
||||||
|
parallel. Two cross-workstream dependencies to respect: **(a)** WS3 (CSP/CORS
|
||||||
|
validation) and WS6's DAST job share the same **two-instance + LiveKit ephemeral
|
||||||
|
test rig** — build it once, reuse it; **(b)** WS6's badges + maintainer checklist
|
||||||
|
document state produced by WS1/WS2/WS5, so its final copy is written *last* (the
|
||||||
|
workflow files can be scaffolded earlier).
|
||||||
|
|
||||||
|
### WS1 — Scanning & supply-chain pipeline (report-only first)
|
||||||
|
- `.github/dependabot.yml`:
|
||||||
|
- `package-ecosystem: npm` at `/` (Dependabot handles pnpm workspaces), weekly,
|
||||||
|
grouped minor/patch.
|
||||||
|
- **`ignore` `uiohook-napi`** — it is pinned by an exact-version patch
|
||||||
|
(`patches/uiohook-napi@1.5.5.patch`); an unmatched bump breaks
|
||||||
|
`pnpm install --frozen-lockfile` in CI and both Docker stages. Also treat
|
||||||
|
`onlyBuiltDependencies` (`better-sqlite3`, `esbuild`, `electron`, `sharp`)
|
||||||
|
bumps with care (grouped, expect native-rebuild churn).
|
||||||
|
- `package-ecosystem: github-actions` at `/`.
|
||||||
|
- `package-ecosystem: docker` at `/` — tracks the **Dockerfile `FROM`** only.
|
||||||
|
**No compose entry:** `docker-compose.yml` sits at the same `/` directory (a
|
||||||
|
second docker entry there would collide on ecosystem+directory), and Dependabot's
|
||||||
|
docker ecosystem parses Dockerfiles, **not** `image:` refs in compose. The pinned
|
||||||
|
`caddy:2.11.1-alpine` / `livekit/livekit-server:v1.9.11` compose images are
|
||||||
|
therefore updated **manually** — added as a line item to the maintainer checklist
|
||||||
|
in `docs/systems/security-scanning.md`. (Renovate, which does parse compose, is
|
||||||
|
noted there as an optional future alternative.)
|
||||||
|
- `codeql.yml`: languages `javascript-typescript`, default `security` queries,
|
||||||
|
triggers PR + push `main` + weekly cron. SARIF uploaded.
|
||||||
|
- `security.yml`:
|
||||||
|
- **gitleaks** — full history + PR diff, SARIF, **block** on hit.
|
||||||
|
- **OSV-Scanner** — reads `pnpm-lock.yaml`; report-only initially, then block on
|
||||||
|
fixable HIGH/CRITICAL after WS5.
|
||||||
|
- **Trivy config** — Dockerfile + docker-compose misconfig, SARIF, advisory.
|
||||||
|
- **Trivy license** — `--scanners license` against the dependency tree with an
|
||||||
|
allowlist (permissive + AGPL-compatible); block on disallowed.
|
||||||
|
- `scorecard.yml`: `ossf/scorecard-action`, push `main` + weekly, publish results +
|
||||||
|
badge.
|
||||||
|
- Harden **all** workflows (new and existing, incl. `docker-publish.yml`): pin every
|
||||||
|
`uses:` to a full commit SHA (retain a `# vX.Y.Z` comment); add
|
||||||
|
`step-security/harden-runner` in **`egress-policy: audit`** (not block — multi-arch
|
||||||
|
buildx + QEMU + gha cache make many egress calls); tighten job-level `permissions`
|
||||||
|
to least privilege.
|
||||||
|
|
||||||
|
### WS2 — Container hardening & real image scanning
|
||||||
|
- **Restructure `docker-publish.yml`** (the current single multi-arch `build-push`
|
||||||
|
cannot be scanned before publish):
|
||||||
|
1. Build **single-arch `linux/amd64`** with `load: true`.
|
||||||
|
2. **Trivy image scan** (`ignore-unfixed: true`, block on fixable HIGH/CRITICAL),
|
||||||
|
SARIF uploaded.
|
||||||
|
3. On pass, the multi-arch (`amd64,arm64`) `build-push` with `push: true`,
|
||||||
|
`sbom: true`, `provenance: true`. (Buildx cache makes the second build cheap.)
|
||||||
|
- Trivy authenticates to GHCR with the same `GITHUB_TOKEN` used for login (the
|
||||||
|
package may be private until manually flipped public).
|
||||||
|
- **Dockerfile hardening:**
|
||||||
|
- **Prune mechanics (precise):** the builder runs a *full* `pnpm install
|
||||||
|
--frozen-lockfile` (Dockerfile:25) whose `node_modules` is a symlinked `.pnpm`
|
||||||
|
virtual store — a plain `COPY --from=builder node_modules` is **not**
|
||||||
|
self-contained. Use `pnpm --filter @backspace/server deploy --prod
|
||||||
|
/app/deploy` in the builder to produce a dereferenced/hoisted prod tree, then
|
||||||
|
`COPY --from=builder /app/deploy` into the runtime stage. This replaces the
|
||||||
|
runtime stage's own `pnpm install --prod`, letting `python3 make g++` be dropped
|
||||||
|
from runtime. **Keep `ffmpeg`** (real runtime dep) and **keep `tsx`** as a prod
|
||||||
|
dependency (the CMD runs TS via `tsx/esm`). Verify `better-sqlite3`'s prebuilt
|
||||||
|
binary and `tsx` are present in the copied tree for **both** target arches.
|
||||||
|
- **Non-root + bind-mount chown (reconciled — the two are mutually exclusive if
|
||||||
|
done naively):** `docker-compose.yml:31` bind-mounts host-owned `./data:/app/data`.
|
||||||
|
Chowning it requires **root**, so we do **not** hard-set a `USER` line (that would
|
||||||
|
run the entrypoint as non-root and make the chown impossible). Instead: install
|
||||||
|
`gosu` (or `su-exec`), add an `ENTRYPOINT` that (a) idempotently `chown`s
|
||||||
|
`/app/data` to a fixed non-root UID, then (b) `exec gosu <uid> "$@"` to drop
|
||||||
|
privileges — so the process runs non-root while the volume stays writable. The
|
||||||
|
`ENTRYPOINT` must `exec "$@"` to preserve the existing `WORKDIR
|
||||||
|
/app/packages/server` + `CMD ["node","--import","tsx/esm","src/index.ts"]`
|
||||||
|
(Dockerfile:96-97). Ship a documented upgrade note; must not break existing
|
||||||
|
self-hosters on `docker pull` + restart.
|
||||||
|
|
||||||
|
### WS3 — Web/server hardening
|
||||||
|
|
||||||
|
**Reality check (from review):** this app renders *arbitrary user-supplied content*
|
||||||
|
and is *federated*, so a restrictive `img-src`/`media-src`/`connect-src` is
|
||||||
|
infeasible. A CSP here realistically constrains `script-src` / `object-src` /
|
||||||
|
`base-uri` / `frame-ancestors` / `form-action` (the XSS/clickjacking-relevant
|
||||||
|
directives) and stays permissive on content origins. Concretely:
|
||||||
|
- **`img-src` / `media-src` must be broad** (`https: data: blob:`): link-embed OG
|
||||||
|
images (`VideoEmbed.tsx`, `RichEmbed.tsx`) come from *any* linked site, and GIF
|
||||||
|
previews load directly from Klipy's CDN (`routes/gif.ts` returns `file.url`
|
||||||
|
unproxied — the CDN host differs from `api.klipy.com`).
|
||||||
|
- **`connect-src` must include the LiveKit `wss://` origin, which is operator
|
||||||
|
config** (`routes/livekit.ts` returns `config.livekit.url` = `LIVEKIT_URL`
|
||||||
|
verbatim) — so the **CSP must be generated at runtime from config**, not a static
|
||||||
|
string. Federation (`getApiForOrigin` in `exploreStore`/`socialStore`/`spaceStore`)
|
||||||
|
fetches/opens WS to peers discovered at runtime → `connect-src` must also allow
|
||||||
|
`https: wss:` (peers aren't enumerable at build time).
|
||||||
|
- **`frame-src` needs an explicit provider allowlist** — YouTube, Vimeo, Spotify
|
||||||
|
embed origins — or the embed iframes break (default `frame-src 'self'` blocks them).
|
||||||
|
|
||||||
|
Steps:
|
||||||
|
- Add `@fastify/helmet`. Build the CSP **dynamically** from `config.livekit.url` +
|
||||||
|
the embed-provider list; ship it **report-only first**, validate against real flows
|
||||||
|
(chat, **cross-instance federation**, embed render, upload, and a **real voice
|
||||||
|
join**) with zero violations, then flip to enforcing.
|
||||||
|
- `packages/web/index.html`: CSP `<meta>` (defense-in-depth) — script/object/base
|
||||||
|
directives only; do not duplicate the dynamic connect/img rules there.
|
||||||
|
- `Caddyfile`: `Strict-Transport-Security`, `X-Content-Type-Options: nosniff`,
|
||||||
|
`Referrer-Policy`, and clickjacking protection via CSP `frame-ancestors` (prefer
|
||||||
|
over `X-Frame-Options`). **Ownership split (documented to avoid conflicts):** Caddy
|
||||||
|
owns HSTS + nosniff + Referrer-Policy; the app (helmet) owns the CSP. Don't set CSP
|
||||||
|
in two places.
|
||||||
|
- **CORS (`packages/server/src/index.ts:46-48`)** — replace `origin: true` with a
|
||||||
|
**dynamic `origin` callback backed by the live federation-peer registry**, NOT a
|
||||||
|
static `DOMAIN`-derived list. Two breakages a static list would cause, both must be
|
||||||
|
handled:
|
||||||
|
- **Federated browser uploads:** browsers make cross-origin tus POST/HEAD/PATCH/
|
||||||
|
DELETE to peer `/api/files/*` (see the existing CORS-block comment at
|
||||||
|
`index.ts:50-64`); peers are DB-backed and added after boot → the callback must
|
||||||
|
consult the live registry, not a boot-time snapshot.
|
||||||
|
- **Desktop instance picker:** `packages/desktop/resources/instance-picker.html`
|
||||||
|
does a renderer `fetch('<url>/api/instance/info')` from a `file://` document
|
||||||
|
(Origin `null`). Keep `/api/instance/info` **CORS-open** (or move that probe to a
|
||||||
|
main-process fetch) so the picker doesn't report instances as unreachable.
|
||||||
|
- **Federation note:** S2S endpoints authenticate by HMAC and receive no browser
|
||||||
|
`Origin`; verify they are unaffected by the two-instance federation integration
|
||||||
|
suite.
|
||||||
|
- **Rollout (phased, like the scanners):** CSP report-only → observe → enforce; CORS
|
||||||
|
gets a **"log-and-allow" observation phase** (log rejected origins without blocking)
|
||||||
|
before switching to reject. **Test-rig dependency:** validating CSP + CORS here
|
||||||
|
needs the **two-instance + LiveKit** harness (shared with the DAST env, §WS6/G2),
|
||||||
|
which is heavier than a single-instance boot — call this out when scheduling.
|
||||||
|
|
||||||
|
### WS4 — Desktop/Electron hardening
|
||||||
|
- **Fuses without breaking the existing hook:** `electron-builder.yml:20` already
|
||||||
|
declares `afterPack: ./scripts/afterPack.js` (it strips host-compiled
|
||||||
|
`uiohook-napi` artifacts + cross-platform prebuilds), and electron-builder allows
|
||||||
|
**only one** `afterPack`. So do **not** add a second hook. Prefer electron-builder's
|
||||||
|
top-level **`electronFuses:`** config key (cleanest, no collision); if a fuse isn't
|
||||||
|
expressible there, call `@electron/fuses` `flipFuses()` **inside** the existing
|
||||||
|
`scripts/afterPack.js`. Fuses: disable `RunAsNode` + `EnableNodeCliInspectArguments`,
|
||||||
|
enable `OnlyLoadAppFromAsar`. **Asar-integrity caveat:** it interacts with the
|
||||||
|
existing `asarUnpack: **/*.node` (lines 17-18) and the afterPack that mutates
|
||||||
|
`app.asar.unpacked` — integrity hashes must be computed *after* those mutations, and
|
||||||
|
because builds are unsigned (`release.yml:93`) macOS integrity **enforcement** is
|
||||||
|
limited; document this in `desktop-security.md` rather than over-claiming.
|
||||||
|
- **`will-navigate` deny handler** in `main.ts`: block foreign top-level navigations
|
||||||
|
while allowing the initial `https://` instance load and the `file://` picker.
|
||||||
|
Clarification (mechanism): the app is client-routed (history API →
|
||||||
|
`did-navigate-in-page`), cross-instance switching uses main-process `loadURL`, and
|
||||||
|
`/join/*` deep-links are handled by `setWindowOpenHandler` (`main.ts:454`) — none of
|
||||||
|
these are `will-navigate`, so the deny handler is safe and `setWindowOpenHandler`
|
||||||
|
stays untouched.
|
||||||
|
- `docs/systems/desktop-security.md`: document the current webPreferences posture
|
||||||
|
(contextIsolation on, nodeIntegration off, sandbox on — `main.ts:356-360`), the
|
||||||
|
fuses/asar posture and its unsigned-macOS limits, and — because `release.yml:93`
|
||||||
|
sets `CSC_IDENTITY_AUTO_DISCOVERY: false` (unsigned) — the exact signing +
|
||||||
|
notarization steps and certificates to procure. Flag unsigned autoupdate as a known
|
||||||
|
gap until signing is wired up.
|
||||||
|
|
||||||
|
### WS5 — Remediation (after WS1 lands report-only)
|
||||||
|
- Run OSV-Scanner + Trivy + CodeQL; triage. Fix real HIGH/CRITICAL: direct upgrades,
|
||||||
|
`pnpm.overrides` for transitive pins where no direct upgrade exists, code fixes for
|
||||||
|
true-positive SAST findings. Dismiss false positives **with written justification**
|
||||||
|
(`.trivyignore` / inline).
|
||||||
|
- **SSRF hardening (fix, then test — not just test):** the string-prefix
|
||||||
|
`isPrivateIp` (`utils/ssrf.ts:3-16`) is genuinely bypassable — `::ffff:127.0.0.1`
|
||||||
|
matches no branch and returns `false` (SSRF to loopback via an attacker AAAA
|
||||||
|
record), and there is no `100.64.0.0/10` (CGNAT) or `::` handling. **Harden
|
||||||
|
`isPrivateIp`**: normalize IPv4-mapped IPv6, reject CGNAT and `::`/unspecified, and
|
||||||
|
normalize decimal/octal/hex hostname encodings — *then* add the negative/property
|
||||||
|
tests for `validateExternalUrl` covering those vectors. The residual DNS-rebind
|
||||||
|
TOCTOU is already documented (`ssrf.ts:58-61`) and stays out of scope (noted, not
|
||||||
|
fixed).
|
||||||
|
- **Then flip WS1/WS2 enforcement to blocking.**
|
||||||
|
|
||||||
|
### WS6 — Visible evidence, DAST & docs
|
||||||
|
- **`dast.yml` (ZAP baseline):** stands up an ephemeral instance and runs ZAP
|
||||||
|
baseline (advisory). **CI env override required** — the production compose won't
|
||||||
|
come up unmodified: Caddy uses `{$DOMAIN}` + ACME auto-HTTPS (hangs in CI without
|
||||||
|
public DNS), `backspace` requires `JWT_SECRET`, livekit is profile-gated. Use a CI
|
||||||
|
compose override that sets a test `JWT_SECRET`/`DOMAIN` and **points ZAP directly at
|
||||||
|
the `backspace` container `:3000`, bypassing Caddy** (or Caddy `internal`/local
|
||||||
|
TLS). This is the same two-instance-capable rig WS3 needs for CSP/CORS validation.
|
||||||
|
- README: CodeQL, OpenSSF Scorecard, and security-policy badges; a "Security &
|
||||||
|
supply chain" section describing what runs on every change and where results are
|
||||||
|
published.
|
||||||
|
- SECURITY.md: add a "Security testing & assurance" section enumerating the pipeline.
|
||||||
|
- `docs/systems/security-scanning.md`: full spec of every workflow, the tiered
|
||||||
|
policy, and the maintainer settings checklist (§5) — including the **repo-must-be-
|
||||||
|
public precondition** (Scorecard `publish_results` + badge and CodeQL free tier both
|
||||||
|
require a public canonical repo) and the manual `caddy`/`livekit` compose-image
|
||||||
|
update reminder (from WS1/F1).
|
||||||
|
- CLAUDE.md: add subsystem-table rows for `security-scanning.md` and
|
||||||
|
`desktop-security.md` (required by the Documentation Rule — this is structural CI
|
||||||
|
and architecture).
|
||||||
|
- **Finalize WS6 last:** badges + the maintainer checklist document state that only
|
||||||
|
exists once WS1/WS2/WS5 land, so write the final copy after those are green (the
|
||||||
|
workflow *files* can be scaffolded earlier).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Testing Strategy
|
||||||
|
|
||||||
|
- **`actionlint`** on every new/edited workflow.
|
||||||
|
- **Real PR-branch run** watching each check go green (or advisory) as intended.
|
||||||
|
- **Canary proof of blocking:** on a throwaway branch, introduce a fake secret and a
|
||||||
|
known-vulnerable dependency; confirm gitleaks and OSV-Scanner actually **fail** the
|
||||||
|
build; revert.
|
||||||
|
- **WS2:** `docker build` locally for amd64 + container boots + `/api/health`
|
||||||
|
responds, before and after the Dockerfile changes; confirm the process runs
|
||||||
|
**non-root** (via gosu step-down) yet still writes the host-owned `./data` bind
|
||||||
|
mount; confirm `tsx` + `better-sqlite3` prebuilt are present in the pruned tree and
|
||||||
|
the arm64 image still builds; confirm existing self-hosters survive `pull` + restart.
|
||||||
|
- **WS3 (needs the two-instance + LiveKit rig):** security headers present
|
||||||
|
(curl/DevTools); **zero CSP violations** across chat, **cross-instance federation**,
|
||||||
|
embed render (YouTube/Vimeo/Spotify + generic OG image), GIF, upload, and a **real
|
||||||
|
voice join**; CORS callback permits the app origin **and dynamically-registered
|
||||||
|
peers** (federated upload), keeps `/api/instance/info` open to the `file://` picker
|
||||||
|
(Origin `null`), and rejects an unknown origin; two-instance federation S2S suite
|
||||||
|
still green.
|
||||||
|
- **WS4:** desktop app boots with fuses/asar-integrity applied and the existing
|
||||||
|
`afterPack` native-module cleanup intact; `will-navigate` blocks a foreign top-level
|
||||||
|
URL while the initial instance load, the `file://` picker, and `/join/*` deep-links
|
||||||
|
(via `setWindowOpenHandler`) still work.
|
||||||
|
- **DAST:** ZAP baseline runs against the CI compose override (bypassing Caddy) and
|
||||||
|
produces a report artifact.
|
||||||
|
- **Full suite** (`pnpm -r test`) green throughout; existing federation/voice suites
|
||||||
|
unaffected.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Risks & Mitigations
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|---|---|
|
||||||
|
| Enforcement day-one paints CI permanently red (May-2024 lockfile has fixable highs) | Report-only → remediate (WS5) → flip blocking. |
|
||||||
|
| Multi-arch image "scan" is theater / arm64 unscanned | WS2 restructure: single-arch load+scan → then multi-arch push. |
|
||||||
|
| Non-root USER breaks `./data` bind-mount for existing self-hosters | Run entrypoint as root → chown → `exec gosu <uid>` step-down (no static `USER`); documented upgrade note; tested before/after. |
|
||||||
|
| Dependabot breaks CI via `uiohook-napi` patch / native rebuilds | `ignore` the patched dep; group `onlyBuiltDependencies`. |
|
||||||
|
| CSP too strict for a federated, arbitrary-content app | CSP built **dynamically** from `config.livekit.url` + peer registry; `img/media/connect` permissive; constrain only script/object/base/frame-ancestors; report-only → enforce. |
|
||||||
|
| CORS allowlist breaks federated uploads + desktop `file://` picker | Dynamic `origin` callback backed by the **live peer registry**; keep `/api/instance/info` CORS-open; "log-and-allow" phase before rejecting. |
|
||||||
|
| Electron fuses overwrite the existing `afterPack` (native-module cleanup) | Use top-level `electronFuses:` key or call `flipFuses()` inside the existing `scripts/afterPack.js`; compute asar-integrity hashes after afterPack mutations. |
|
||||||
|
| pnpm symlinked `.pnpm` store makes a plain `node_modules` copy non-self-contained | Use `pnpm --filter @backspace/server deploy --prod`; verify `tsx` + `better-sqlite3` prebuilt land per-arch; keep `ffmpeg`; boot test. |
|
||||||
|
| DAST/compose won't come up in CI (ACME/DOMAIN/JWT_SECRET) | CI compose override with test env; point ZAP at `backspace:3000`, bypass Caddy. |
|
||||||
|
| harden-runner block mode false-positives the Docker build | Start in `audit`; graduate to block only on lightweight jobs. |
|
||||||
|
| Scorecard badge / CodeQL free tier assume a public repo | Documented as an explicit precondition in the maintainer checklist. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Definition of Done
|
||||||
|
|
||||||
|
- All new workflows present, `actionlint`-clean, and green on a real PR.
|
||||||
|
- Blocking gates proven by canary (secret + vuln), then reverted.
|
||||||
|
- Security tab populated (CodeQL, Scorecard, advisory Trivy/OSV) with no open
|
||||||
|
fixable HIGH/CRITICAL after WS5.
|
||||||
|
- helmet + CSP + Caddyfile headers live with no CSP violations in normal use; CORS
|
||||||
|
allowlisted; federation suite green.
|
||||||
|
- Electron fuses + `will-navigate` live; desktop boots and deep-links work; existing
|
||||||
|
`afterPack` native-module cleanup intact.
|
||||||
|
- `isPrivateIp` hardened (IPv4-mapped IPv6 / CGNAT / `::` / alt-encodings) with
|
||||||
|
passing negative tests.
|
||||||
|
- Container image scanned before publish; SBOM + provenance attached; Dockerfile
|
||||||
|
runs non-root (gosu step-down) with a working data volume.
|
||||||
|
- README badges + Security section; SECURITY.md expanded;
|
||||||
|
`docs/systems/security-scanning.md` + `docs/systems/desktop-security.md` written;
|
||||||
|
CLAUDE.md subsystem table updated.
|
||||||
|
- Maintainer settings checklist documented (CodeQL merge protection, Dependabot
|
||||||
|
alerts, push protection, branch protection).
|
||||||
@@ -4,9 +4,11 @@ Operator- and contributor-facing reference for hosting Backspace: the Docker bui
|
|||||||
|
|
||||||
Source files:
|
Source files:
|
||||||
- `Dockerfile` -- multi-stage build (builder → runtime)
|
- `Dockerfile` -- multi-stage build (builder → runtime)
|
||||||
- `docker-compose.yml` -- `backspace` + `caddy` (+ optional `livekit`) services, healthcheck
|
- `docker-compose.yml` -- base stack: `backspace` + `caddy` (+ optional `livekit`) services, healthcheck
|
||||||
- `Caddyfile` -- reverse proxy / auto-HTTPS config
|
- `docker-compose.proxy.yml` -- proxy/tunnel overlay: publishes the app on `127.0.0.1:APP_PORT` and drops Caddy
|
||||||
- `install.sh` -- interactive first-time setup
|
- `.github/workflows/docker-publish.yml` -- multi-arch (amd64+arm64) GHCR image publish
|
||||||
|
- `Caddyfile` -- reverse proxy / auto-HTTPS config (All-in-One mode only)
|
||||||
|
- `install.sh` -- interactive first-time setup, mode-aware (allinone / proxy / tunnel)
|
||||||
- `deploy.sh` -- rsync + rebuild to Heidi's two boxes
|
- `deploy.sh` -- rsync + rebuild to Heidi's two boxes
|
||||||
- `backup.sh` / `restore.sh` -- manual snapshot + restore tooling (host side)
|
- `backup.sh` / `restore.sh` -- manual snapshot + restore tooling (host side)
|
||||||
- `packages/server/src/config.ts` -- `config.backup.*` env parsing
|
- `packages/server/src/config.ts` -- `config.backup.*` env parsing
|
||||||
@@ -25,14 +27,42 @@ Source files:
|
|||||||
|
|
||||||
## 1. Pipeline Overview
|
## 1. Pipeline Overview
|
||||||
|
|
||||||
Backspace ships as a single application container fronted by Caddy. Everything is built and run via Docker Compose; there is no separate CI artifact — **the image is built on each target host** from source.
|
Backspace ships as a single application container. In the default **All-in-One** deployment it is fronted by the bundled Caddy (automatic HTTPS); behind an operator's own reverse proxy or a tunnel, Caddy is dropped and the container is published on a host loopback port instead (see [Deployment modes](#deployment-modes) below). The application image is a **prebuilt multi-architecture image published to GHCR** — `docker compose pull` (install.sh's default path) fetches `ghcr.io/thezwiss/backspace` for `linux/amd64` or `linux/arm64`, so weak/ARM hosts skip the heavy local build; a from-source build is the fallback when the image can't be pulled.
|
||||||
|
|
||||||
|
### Prebuilt image (GHCR)
|
||||||
|
|
||||||
|
`.github/workflows/docker-publish.yml` builds and pushes the application image to `ghcr.io/thezwiss/backspace` on every `v*` tag (and on manual `workflow_dispatch`). It is deliberately **separate from** the desktop-installer workflow (`release.yml`): the two share the `v*` tag trigger but build entirely different artifacts and must not be entangled.
|
||||||
|
|
||||||
|
- **Multi-arch.** `docker/setup-qemu-action` + `buildx` build `linux/amd64,linux/arm64` in one push, so a Raspberry Pi pulls a native image instead of cross-building (the Vite build OOMs small ARM boxes).
|
||||||
|
- **Tags.** `docker/metadata-action` derives `{version}`, `{major}.{minor}`, `latest` (on `v*` tags), and `sha-<short>`. A `workflow_dispatch` with an extra `tag` input publishes that tag too (e.g. `latest` without cutting a release).
|
||||||
|
- **AGPL § 13 commit stamping is preserved.** The workflow resolves `git rev-parse --short HEAD` and passes it as `--build-arg BACKSPACE_COMMIT=…`, exactly like `install.sh`/`deploy.sh`, plus OCI labels (`source`, `licenses=AGPL-3.0-only`, `revision`). The pulled image therefore advertises its exact source version via `GET /api/instance/info`.
|
||||||
|
- **Auth.** The push authenticates with the built-in `GITHUB_TOKEN` (`permissions: packages: write`). The GHCR package must be set **public** once for unauthenticated `docker pull` to work.
|
||||||
|
- **Compose wiring.** `docker-compose.yml` declares **both** `image: ${BACKSPACE_IMAGE:-ghcr.io/thezwiss/backspace}:${BACKSPACE_IMAGE_TAG:-latest}` **and** `build: .`. `pull`/`up` uses the image; `up --build` (deploy.sh, or install.sh's fallback) builds from source and tags the result under the same ref. Operators pin a version or point at a fork's registry via `BACKSPACE_IMAGE` / `BACKSPACE_IMAGE_TAG`.
|
||||||
|
|
||||||
|
### Deployment modes
|
||||||
|
|
||||||
|
One installer, three modes, recorded as `DEPLOY_MODE` in `.env`. `install.sh` auto-detects (and, when ambiguous, prompts); a non-interactive run honors an explicit `DEPLOY_MODE`.
|
||||||
|
|
||||||
|
| Mode | Ports 80/443 | Topology | TLS | Voice |
|
||||||
|
|------|--------------|----------|-----|-------|
|
||||||
|
| `allinone` (default) | must be free | base `docker-compose.yml`: `backspace` + `caddy` (+ `livekit`) | bundled Caddy (Let's Encrypt) | ✅ with UDP media ports open |
|
||||||
|
| `proxy` | already taken | base **+** `docker-compose.proxy.yml`: `backspace` on `127.0.0.1:APP_PORT`, no Caddy | operator's reverse proxy | ✅ if operator proxies `/livekit` and opens media ports |
|
||||||
|
| `tunnel` | already taken | same overlay as `proxy` | tunnel provider (Cloudflare, Tailscale…) | ❌ WebRTC/UDP can't traverse a tunnel |
|
||||||
|
|
||||||
|
**The overlay (`docker-compose.proxy.yml`).** Layered on top of the base file it (1) publishes `backspace` on `127.0.0.1:${APP_PORT:-8080}:${PORT:-3000}` — loopback only, so nothing is exposed on a public interface — and (2) parks `caddy` in an inert profile (`_proxy_mode_no_caddy`) that is never activated, so Caddy does not start. The base file is unchanged, so All-in-One (`docker-compose.yml` alone) behaves exactly as before.
|
||||||
|
|
||||||
|
**`COMPOSE_FILE` wiring.** In proxy/tunnel mode install.sh writes `COMPOSE_FILE=docker-compose.yml:docker-compose.proxy.yml` into `.env`. Docker Compose reads `COMPOSE_FILE` from `.env`, so **every** later `docker compose …` command in the directory transparently uses both files — the operator (and the update commands) never need `-f` flags. All-in-One leaves `COMPOSE_FILE` unset (defaults to `docker-compose.yml`).
|
||||||
|
|
||||||
|
**Server proxy-awareness.** The server sets Fastify `trustProxy: true`, so it trusts `X-Forwarded-*` from the fronting proxy/tunnel. `getOurOrigin()` returns `https://${DOMAIN}` (federation/public identity) whenever `DOMAIN` is set and `PUBLIC_ORIGIN` is unset — correct in all three modes, since the public URL is `https://DOMAIN` regardless of which layer terminates TLS. No `PUBLIC_ORIGIN` is needed for a normal proxy/tunnel deployment.
|
||||||
|
|
||||||
|
**Voice per mode.** LiveKit media is WebRTC over UDP and never flows through the HTTP proxy/tunnel — the media ports (`3478/udp` TURN, `7881/tcp` fallback, `50000-60000/udp` media) must be reachable from clients directly. All-in-One proxies LiveKit *signaling* through Caddy (`/livekit` → `host.docker.internal:7880`); a reverse-proxy operator must replicate that route (`/livekit` → `127.0.0.1:7880`, prefix stripped) **and** open the media ports. Over a tunnel, voice is unavailable and install.sh force-disables it. See `docs/systems/voice.md` for LiveKit tuning.
|
||||||
|
|
||||||
### Build: multi-stage Dockerfile
|
### Build: multi-stage Dockerfile
|
||||||
|
|
||||||
`Dockerfile` has two stages:
|
`Dockerfile` has two stages:
|
||||||
|
|
||||||
1. **`builder`** (`node:20-slim`) — enables pnpm via corepack, installs the full workspace with `pnpm install --frozen-lockfile`, copies `shared`/`server`/`web` source, and runs `pnpm --filter @backspace/web build` to produce the static frontend (`packages/web/dist`).
|
1. **`builder`** (`node:20-slim`) — enables pnpm via corepack, installs the full workspace with `pnpm install --frozen-lockfile`, copies `shared`/`server`/`web` source, and runs `pnpm --filter @backspace/web build` to produce the static frontend (`packages/web/dist`).
|
||||||
2. **`runtime`** (`node:20-slim`) — installs the native toolchain for `better-sqlite3` plus `ffmpeg` (`python3 make g++ ffmpeg`), installs production-only deps with `pnpm install --prod --frozen-lockfile` (`tsx` is a server runtime dependency), copies `shared` + `server` source and the prebuilt `web/dist`, creates `/app/data/uploads`, and starts the server with `node --import tsx/esm src/index.ts` from `/app/packages/server`.
|
2. **`runtime`** (`node:20-slim`) — installs `ffmpeg` (media) + `gosu` (privilege drop) only — **no C toolchain**, since `better-sqlite3`/`sharp` load prebuilt binaries — installs production-only deps with `pnpm install --prod --frozen-lockfile` (`tsx` is a server runtime dependency), copies `shared` + `server` source and the prebuilt `web/dist`, creates `/app/data/uploads`, and runs the server **as the non-root `node` user** via `docker-entrypoint.sh` (which chowns `/app/data` as root, then `exec gosu node`) with `node --import tsx/esm src/index.ts` from `/app/packages/server`.
|
||||||
|
|
||||||
The server is run through `tsx` (no separate transpile step); TypeScript is executed directly at runtime.
|
The server is run through `tsx` (no separate transpile step); TypeScript is executed directly at runtime.
|
||||||
|
|
||||||
@@ -43,6 +73,42 @@ The server is run through `tsx` (no separate transpile step); TypeScript is exec
|
|||||||
|
|
||||||
Empty/unset → `config.commit` is `null` (local dev, tarball install, or git unavailable). The source URL itself is `config.sourceCodeUrl` (env `BACKSPACE_SOURCE_URL`, default upstream) — operators running a modified build MUST set it to their fork.
|
Empty/unset → `config.commit` is `null` (local dev, tarball install, or git unavailable). The source URL itself is `config.sourceCodeUrl` (env `BACKSPACE_SOURCE_URL`, default upstream) — operators running a modified build MUST set it to their fork.
|
||||||
|
|
||||||
|
### Container hardening (non-root)
|
||||||
|
|
||||||
|
The runtime image runs as the unprivileged `node` user (uid 1000), not root. On
|
||||||
|
container start, `docker-entrypoint.sh` runs as root only long enough to `chown`
|
||||||
|
the `./data` bind mount to `node` (only entries not already node-owned, so it is
|
||||||
|
near-instant after the first boot), then drops privileges via `gosu` and execs the
|
||||||
|
server. The build toolchain (`python3`/`make`/`g++`) is not installed in the
|
||||||
|
runtime stage — `better-sqlite3` and `sharp` load from prebuilt binaries — which
|
||||||
|
shrinks the runtime attack surface. `ffmpeg` remains (a real runtime dependency).
|
||||||
|
|
||||||
|
The published image carries an SBOM and SLSA provenance attestation, and the
|
||||||
|
amd64 image is scanned by Trivy before publish (report-only). Note: only the
|
||||||
|
amd64 image is scanned; the arm64 image is published unscanned.
|
||||||
|
|
||||||
|
**Minimum Docker version:** the attestation-bearing multi-arch image requires a
|
||||||
|
reasonably modern Docker to `pull` cleanly (Docker Engine 24+ recommended).
|
||||||
|
Very old daemons (≤ 20.10) may mishandle the `unknown/unknown` attestation
|
||||||
|
manifests. New installs via `install.sh` (get.docker.com) are fine.
|
||||||
|
|
||||||
|
**Upgrade note for existing self-hosters:** on the first start of the hardened
|
||||||
|
image, the contents of your host `./data` directory are chowned to uid 1000. This
|
||||||
|
is expected and idempotent. On an instance with a large `uploads/` tree on slow
|
||||||
|
storage (e.g. a Pi on SD), the **first** restart after upgrade may take noticeably
|
||||||
|
longer as this one-time chown runs before the server starts; subsequent boots only
|
||||||
|
touch not-yet-node-owned entries and are near-instant. If you previously accessed
|
||||||
|
`./data` on the host as a different user, adjust host-side access accordingly. `./restore.sh` continues to
|
||||||
|
work — it swaps files inside a throwaway root container, and root can rewrite the
|
||||||
|
now uid-1000-owned files.
|
||||||
|
|
||||||
|
**Release-gate (maintainer):** before the first `v*` tag that ships this image,
|
||||||
|
do a real `docker compose pull && docker compose up -d` on both an amd64 host and
|
||||||
|
the arm64 Pi to confirm the attestation-bearing image pulls cleanly on the actual
|
||||||
|
deployment Docker versions, and that the container boots non-root with a writable
|
||||||
|
`./data` on real Linux (the macOS Docker Desktop bind-mount ownership display is
|
||||||
|
not representative of Linux behaviour).
|
||||||
|
|
||||||
### Run: `docker compose up -d --build`
|
### Run: `docker compose up -d --build`
|
||||||
|
|
||||||
`docker-compose.yml` defines:
|
`docker-compose.yml` defines:
|
||||||
@@ -65,7 +131,13 @@ Caddy provisions and renews TLS certificates automatically for `DOMAIN`; the per
|
|||||||
|
|
||||||
### First-time setup: `install.sh`
|
### First-time setup: `install.sh`
|
||||||
|
|
||||||
`./install.sh` is the interactive installer for a fresh Linux host. It prompts for the domain, whether to enable voice, and an instance name (each skippable via the `DOMAIN` / `ENABLE_VOICE` / `INSTANCE_NAME` env vars for a non-interactive run), generates a `JWT_SECRET`, writes `.env`, optionally configures LiveKit (`livekit.yaml` + `COMPOSE_PROFILES=voice`), and brings the stack up (`docker compose build` then `up -d`).
|
`./install.sh` is the interactive installer for a fresh Linux host. It prompts for the domain, whether to enable voice, and an instance name (each skippable via the `DOMAIN` / `ENABLE_VOICE` / `INSTANCE_NAME` env vars for a non-interactive run), generates a `JWT_SECRET`, writes `.env`, optionally configures LiveKit (`livekit.yaml` + `COMPOSE_PROFILES=voice`), and brings the stack up.
|
||||||
|
|
||||||
|
**Mode selection.** Before configuring, it determines the deployment mode (precedence: explicit `DEPLOY_MODE` env → existing `.env` → auto-detect + prompt). Auto-detection checks whether ports 80/443 are free — and does so **Docker-aware**: it consults both `ss` *and* `docker ps` published ports, because a host running Docker with the userland proxy disabled DNATs 80/443 via iptables with **no listening socket for `ss` to see** (a box whose Caddy already owns those ports would otherwise be misread as "ports free"). When 80/443 are free it offers All-in-One (default); when taken it never dead-ends — it explains what holds them and steers to `proxy`/`tunnel`. In proxy/tunnel mode it auto-picks a free loopback `APP_PORT` (scanning past commonly-taken 3000/8080), force-lowers `MAX_UPLOAD_SIZE` to 90 MB for `tunnel` (Cloudflare's 100 MB body cap), and force-disables voice for `tunnel`.
|
||||||
|
|
||||||
|
**Image acquisition.** By default it pulls the prebuilt image (`docker compose pull backspace`). If the pull fails but a usable image is already present on the host (a prior run, an air-gapped `docker load`, or a previous from-source build tagged under the ref) it uses that copy rather than forcing a needless rebuild; only if neither pull nor a local image is available does it fall back to `docker compose build` (or when `BACKSPACE_BUILD=true` forces a source build, e.g. a fork). The commit is captured from the checkout and passed as `--build-arg BACKSPACE_COMMIT=<sha>` on the build path.
|
||||||
|
|
||||||
|
**Reverse-proxy / tunnel output.** In proxy/tunnel mode the post-deploy check verifies the app answers on `127.0.0.1:APP_PORT` (TLS is the operator's edge's job, not ours to test), and the summary prints paste-ready nginx / Caddy / Traefik snippets (proxy) or a `cloudflared` ingress rule (tunnel), each with WebSocket upgrade, `X-Forwarded-*`, and a body-size cap matching `MAX_UPLOAD_SIZE` already correct — plus the `/livekit` route and media ports when voice is on.
|
||||||
|
|
||||||
**Post-install HTTPS reachability check.** The container healthcheck only proves the app is up *inside* Docker — not that `https://DOMAIN` actually works, which additionally requires Caddy to have obtained a publicly-trusted certificate (DNS pointing here **and** ports 80/443 reachable from the internet). After the stack is healthy, the installer verifies this and reports it honestly instead of always printing success:
|
**Post-install HTTPS reachability check.** The container healthcheck only proves the app is up *inside* Docker — not that `https://DOMAIN` actually works, which additionally requires Caddy to have obtained a publicly-trusted certificate (DNS pointing here **and** ports 80/443 reachable from the internet). After the stack is healthy, the installer verifies this and reports it honestly instead of always printing success:
|
||||||
|
|
||||||
@@ -131,7 +203,7 @@ Behavior (`packages/server/src/scripts/remediate-seed-admin.ts`):
|
|||||||
- **Targets only the local seed admin** — `username = 'admin'` with `home_instance IS NULL` and `is_admin = 1`. Replicated/federated users are never touched.
|
- **Targets only the local seed admin** — `username = 'admin'` with `home_instance IS NULL` and `is_admin = 1`. Replicated/federated users are never touched.
|
||||||
- **Rotates only `admin123`.** It verifies the current hash against `admin123`; if the password has already been changed, it is a **no-op** ("nothing to do"). It is fully idempotent — safe to run repeatedly.
|
- **Rotates only `admin123`.** It verifies the current hash against `admin123`; if the password has already been changed, it is a **no-op** ("nothing to do"). It is fully idempotent — safe to run repeatedly.
|
||||||
- **Never deletes** the account (the default-space ownership constraint above).
|
- **Never deletes** the account (the default-space ownership constraint above).
|
||||||
- On rotation it generates a 24-character random password, updates the hash, prints the new password to stdout, **and** writes it to `data/seed-admin-rotated.txt` (mode `0600`, root-owned via the bind-mount). **Store the password somewhere safe, then delete `data/seed-admin-rotated.txt`.**
|
- On rotation it generates a 24-character random password, updates the hash, prints the new password to stdout, **and** writes it to `data/seed-admin-rotated.txt` (mode `0600`, **root-owned** — the script runs via `docker exec`, which bypasses the entrypoint's gosu drop and runs as root, so this file is uid 0 until the next container restart re-chowns it). **Store the password somewhere safe, then delete `data/seed-admin-rotated.txt`** (a non-root host user may need `sudo`).
|
||||||
|
|
||||||
> **Note — sessions are not invalidated.** Rotation changes the stored password hash only; it does **not** revoke existing JWTs. An already-logged-in admin session survives until the token expires (`JWT_EXPIRES_IN`, default 30 days). Rotation closes off *future* logins with the old password; it does not eject a currently active session. If you must terminate live sessions immediately, rotate `JWT_SECRET` (which invalidates **all** tokens instance-wide) and restart.
|
> **Note — sessions are not invalidated.** Rotation changes the stored password hash only; it does **not** revoke existing JWTs. An already-logged-in admin session survives until the token expires (`JWT_EXPIRES_IN`, default 30 days). Rotation closes off *future* logins with the old password; it does not eject a currently active session. If you must terminate live sessions immediately, rotate `JWT_SECRET` (which invalidates **all** tokens instance-wide) and restart.
|
||||||
|
|
||||||
@@ -214,7 +286,7 @@ They do **not** protect against **hardware loss** (disk failure, the box being d
|
|||||||
|
|
||||||
## 4. Restore
|
## 4. Restore
|
||||||
|
|
||||||
Restores are driven by `./restore.sh` from the host. Because `data/` (including `backspace.db` and `data/backups/`) is **container-owned (root)** via the bind-mount, the host user cannot rewrite those files directly — so the actual swap runs inside a throwaway root `alpine` container that mounts `data/`.
|
Restores are driven by `./restore.sh` from the host. Because `data/` (including `backspace.db` and `data/backups/`) is **container-owned (uid 1000)** via the bind-mount, the host user cannot rewrite those files directly — so the actual swap runs inside a throwaway root `alpine` container that mounts `data/`.
|
||||||
|
|
||||||
### List snapshots
|
### List snapshots
|
||||||
|
|
||||||
@@ -247,14 +319,16 @@ The pre-restore copy means a mistaken restore is itself undoable: the previous D
|
|||||||
|
|
||||||
## 5. Image Pinning & Upgrades
|
## 5. Image Pinning & Upgrades
|
||||||
|
|
||||||
The two pulled images are **pinned to explicit tags** in `docker-compose.yml`, never `latest`:
|
The third-party images are **pinned to explicit tags** in `docker-compose.yml`, never `latest`:
|
||||||
|
|
||||||
| Service | Pinned image |
|
| Service | Pinned image |
|
||||||
|---------|--------------|
|
|---------|--------------|
|
||||||
| `caddy` | `caddy:2.11.1-alpine` |
|
| `caddy` | `caddy:2.11.1-alpine` |
|
||||||
| `livekit` | `livekit/livekit-server:v1.9.11` |
|
| `livekit` | `livekit/livekit-server:v1.9.11` |
|
||||||
|
|
||||||
Pinning makes deploys reproducible — a rebuild pulls the exact same proxy/SFU version every time, so an upstream release can't silently change behavior under you. (The `backspace` image is built from source via the `Dockerfile`, which itself pins the `node:20-slim` base.)
|
Pinning makes deploys reproducible — a rebuild pulls the exact same proxy/SFU version every time, so an upstream release can't silently change behavior under you.
|
||||||
|
|
||||||
|
The `backspace` image itself defaults to `ghcr.io/thezwiss/backspace:latest` (`BACKSPACE_IMAGE` / `BACKSPACE_IMAGE_TAG`). `latest` is chosen for a frictionless first install, but it is a **moving** tag: operators who want reproducible upgrades should pin `BACKSPACE_IMAGE_TAG` to a released version (e.g. `1.0.0`) in `.env` and bump it deliberately. On the source-build paths (`deploy.sh`, `install.sh`'s fallback) the image is built from the `Dockerfile`, which pins the `node:20-slim` base.
|
||||||
|
|
||||||
**Upgrade procedure:** bump the tag in `docker-compose.yml` → test the new version (locally or on one box) → redeploy. Concretely:
|
**Upgrade procedure:** bump the tag in `docker-compose.yml` → test the new version (locally or on one box) → redeploy. Concretely:
|
||||||
|
|
||||||
@@ -270,7 +344,7 @@ Never pin to a floating tag like `latest` or a bare major — it defeats reprodu
|
|||||||
|
|
||||||
These are accepted constraints of the current deploy model, documented so operators aren't surprised:
|
These are accepted constraints of the current deploy model, documented so operators aren't surprised:
|
||||||
|
|
||||||
- **The image is built on each target host, including the ARM Raspberry Pi.** There is no cross-built/registry-pushed artifact. The Pi build is slower and consumes build resources on the box (`deploy.sh` caps the build cache and prunes old images to compensate). A native-module or toolchain regression can surface on ARM but not x86, or vice-versa.
|
- **`deploy.sh` still builds on each target host.** The public `install.sh` path now defaults to the prebuilt GHCR image (multi-arch, so a Pi pulls a native image), but `deploy.sh` — Heidi's rsync-then-`up -d --build` helper for `nova`/`orbit` — deliberately builds from the rsynced working tree on the box (it caps the build cache and prunes old images to compensate). A native-module or toolchain regression can still surface on ARM but not x86, or vice-versa, on that path; the CI multi-arch build catches most such regressions before release.
|
||||||
- **A deploy causes brief downtime + WebSocket reconnect.** `docker compose up -d --build` rebuilds and recreates the `backspace` container; while it restarts, the server is briefly unavailable and every connected client's WebSocket drops and must reconnect. There is no rolling/zero-downtime deploy. Clients reconnect automatically, but in-flight requests during the swap can fail.
|
- **A deploy causes brief downtime + WebSocket reconnect.** `docker compose up -d --build` rebuilds and recreates the `backspace` container; while it restarts, the server is briefly unavailable and every connected client's WebSocket drops and must reconnect. There is no rolling/zero-downtime deploy. Clients reconnect automatically, but in-flight requests during the swap can fail.
|
||||||
- **`deploy.sh all` can mask one host failing.** The `all` target runs both deploys in parallel (`deploy … & deploy … & wait`). The visible "Deployment complete." is printed regardless of whether one host's build failed mid-stream; the failure scrolls by in the interleaved output. After an `all` deploy, **confirm `/api/health` on both boxes** rather than trusting the final line. For a high-stakes change, deploy to one box at a time.
|
- **`deploy.sh all` can mask one host failing.** The `all` target runs both deploys in parallel (`deploy … & deploy … & wait`). The visible "Deployment complete." is printed regardless of whether one host's build failed mid-stream; the failure scrolls by in the interleaved output. After an `all` deploy, **confirm `/api/health` on both boxes** rather than trusting the final line. For a high-stakes change, deploy to one box at a time.
|
||||||
|
|
||||||
@@ -280,10 +354,12 @@ These are accepted constraints of the current deploy model, documented so operat
|
|||||||
|
|
||||||
| Task | Command |
|
| Task | Command |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| First-time install | `./install.sh` (or `DOMAIN=chat.example.com ./install.sh`) |
|
| First-time install | `./install.sh` (or `DOMAIN=chat.example.com DEPLOY_MODE=proxy ./install.sh`) |
|
||||||
|
| Update (prebuilt image — default) | `git pull && docker compose pull && docker compose up -d` |
|
||||||
|
| Update (from source / fork) | `git pull && docker compose up -d --build` |
|
||||||
| Redeploy both boxes | `./deploy.sh all` |
|
| Redeploy both boxes | `./deploy.sh all` |
|
||||||
| Redeploy one box | `./deploy.sh pi` / `./deploy.sh vm` |
|
| Redeploy one box | `./deploy.sh pi` / `./deploy.sh vm` |
|
||||||
| Bring stack up manually | `docker compose up -d --build` |
|
| Bring stack up manually | `docker compose up -d` (proxy/tunnel: `.env`'s `COMPOSE_FILE` auto-adds the overlay) |
|
||||||
| Check health | `curl -fsS https://<domain>/api/health` |
|
| Check health | `curl -fsS https://<domain>/api/health` |
|
||||||
| Take a manual snapshot | `./backup.sh` |
|
| Take a manual snapshot | `./backup.sh` |
|
||||||
| List snapshots | `./restore.sh` |
|
| List snapshots | `./restore.sh` |
|
||||||
|
|||||||
@@ -199,6 +199,39 @@ interface AvatarStackProps {
|
|||||||
|
|
||||||
**Hooks-in-loop safety:** each rendered slot is its own `<AvatarTile>` component so `useCanonicalUserView` is called exactly once per slot, never inside a variable-length `.map()`.
|
**Hooks-in-loop safety:** each rendered slot is its own `<AvatarTile>` component so `useCanonicalUserView` is called exactly once per slot, never inside a variable-length `.map()`.
|
||||||
|
|
||||||
|
### Avatar vs ProfileAvatar
|
||||||
|
|
||||||
|
Two components, one deliberate split:
|
||||||
|
|
||||||
|
| Component | Role |
|
||||||
|
|---|---|
|
||||||
|
| `Avatar` (`ui/Avatar.tsx`) | Purely presentational. Takes `user` for the gradient, avatar colour, `homeUserId` and status dot. Clicking it does nothing unless the caller passes `onClick`. |
|
||||||
|
| `ProfileAvatar` (`ui/ProfileAvatar.tsx`) | `Avatar` plus the profile card. Opens `UserProfilePopout` anchored to its own box, stops propagation so it wins over an enclosing row handler, and stays inert while `user` is undefined. |
|
||||||
|
|
||||||
|
**Rule:** an avatar is only a profile trigger when it is a `ProfileAvatar`. Never re-add an implicit "open the profile if a `user` prop is present" branch to `Avatar` — passing `user` is how *every* avatar gets its colour, so that branch silently turns the picture inside the profile card, the settings preview, the avatar-upload button and every row in a modal into a trigger. It also made the card re-anchor to its own picture and walk across the screen on repeated clicks (issue #37).
|
||||||
|
|
||||||
|
Use `ProfileAvatar` when the avatar is the primary way to reach that person's profile and nothing else owns the click. Use `Avatar` when an enclosing row, button or list item already handles clicks, or when the avatar depicts the surface it already sits on.
|
||||||
|
|
||||||
|
**Escalation chain.** Clicking a face always moves one step deeper, never sideways and never nowhere:
|
||||||
|
|
||||||
|
| Surface | Picture click |
|
||||||
|
|---|---|
|
||||||
|
| Member tile / row / message author | Opens the preview card (`UserProfilePopout`) |
|
||||||
|
| Preview card | Opens the full profile modal (`UserProfileModal`) and closes the card |
|
||||||
|
| Full profile modal | Nothing — this is the terminus |
|
||||||
|
|
||||||
|
The middle step matters: an inert picture on the preview card is a dead end that forces the user down to the *View Full Profile* link. What it must never do is reopen the card itself — that is the drift bug from issue #37.
|
||||||
|
|
||||||
|
### Floating placement
|
||||||
|
|
||||||
|
Every floating surface places itself with `computeFloatingPosition` (`hooks/useFloatingPosition.ts`): preferred side → flip when it would overflow → clamp into the viewport, with an 8px viewport padding.
|
||||||
|
|
||||||
|
- Components with a live anchor element use the `useFloatingPosition` hook (tooltips, mention/search popovers, voice popovers).
|
||||||
|
- Components opened from a store keep the anchor's **rect** instead of an element — `uiStore.openUserProfile(user, anchor, placement)` stores `AnchorRect` + `Placement`, and `UserProfilePopout` measures itself and places off that. `pointAnchor(x, y)` builds a zero-size rect for the rare caller with no anchor element.
|
||||||
|
- `align: 'start'` lines the surface's leading edge up with the anchor; the default centres it on the anchor.
|
||||||
|
|
||||||
|
**Callers never compute coordinates.** A surface that is handed a finished `{ top, left }` cannot account for its own measured size, and any caller-side constant (an assumed card height, a hardcoded sidebar width) drifts the moment the content or the layout changes.
|
||||||
|
|
||||||
**Tile geometry contract.** Each `AvatarTile` renders at `size × size` with a 2px border (`box-sizing: border-box` from Tailwind preflight), so its content area is `(size − 4) × (size − 4)`. The inner `Avatar` is sized to that content area (`size − 2 · TILE_BORDER_WIDTH`) and centered geometrically on the tile via `flex items-center justify-center`, **not** by inline-flow placement. Both corrections are required: sizing the Avatar to the outer dimensions overflows the padding box and gets clipped off-center (visible disc remains centered, but the avatar's contents — image crop, initials gradient + letter — anchor at the padding-edge top-left and visibly drift toward the lower-right of the visible disc); relying on `Avatar`'s `inline-flex` placement makes the Avatar drift vertically by whatever the inherited `line-height` adds, independent of border. `TILE_BORDER_WIDTH` is exported from `AvatarStack.tsx` as the single source of truth for the `border-2` width and must be updated in lockstep with any future change to that class.
|
**Tile geometry contract.** Each `AvatarTile` renders at `size × size` with a 2px border (`box-sizing: border-box` from Tailwind preflight), so its content area is `(size − 4) × (size − 4)`. The inner `Avatar` is sized to that content area (`size − 2 · TILE_BORDER_WIDTH`) and centered geometrically on the tile via `flex items-center justify-center`, **not** by inline-flow placement. Both corrections are required: sizing the Avatar to the outer dimensions overflows the padding box and gets clipped off-center (visible disc remains centered, but the avatar's contents — image crop, initials gradient + letter — anchor at the padding-edge top-left and visibly drift toward the lower-right of the visible disc); relying on `Avatar`'s `inline-flex` placement makes the Avatar drift vertically by whatever the inherited `line-height` adds, independent of border. `TILE_BORDER_WIDTH` is exported from `AvatarStack.tsx` as the single source of truth for the `border-2` width and must be updated in lockstep with any future change to that class.
|
||||||
|
|
||||||
**Border tiers:** the surface tier the stack sits on determines the tile border color (so the tiles cleanly separate from the panel they overlap). `channel` → `border-surface-channel` (sidebar); `chat` → `border-surface-chat` (chat area / welcome header / chat header); `modal` → `border-surface-elevated` (modal hero, mobile info-screen hero — there is no `surface-modal` token in `tailwind.config.js`).
|
**Border tiers:** the surface tier the stack sits on determines the tile border color (so the tiles cleanly separate from the panel they overlap). `channel` → `border-surface-channel` (sidebar); `chat` → `border-surface-chat` (chat area / welcome header / chat header); `modal` → `border-surface-elevated` (modal hero, mobile info-screen hero — there is no `surface-modal` token in `tailwind.config.js`).
|
||||||
|
|||||||
@@ -3,7 +3,16 @@
|
|||||||
> **Companion spec:** This document covers **S2S (server-to-server)** federation — the relay protocol, HMAC auth, identity resolution, and background workers. For the **client-side** multi-instance architecture (how the web/desktop app connects to multiple instances, federated account creation, origin-aware routing), see [`client-federation.md`](client-federation.md). Both systems work together.
|
> **Companion spec:** This document covers **S2S (server-to-server)** federation — the relay protocol, HMAC auth, identity resolution, and background workers. For the **client-side** multi-instance architecture (how the web/desktop app connects to multiple instances, federated account creation, origin-aware routing), see [`client-federation.md`](client-federation.md). Both systems work together.
|
||||||
|
|
||||||
Source files:
|
Source files:
|
||||||
- `packages/server/src/routes/federation.ts` -- API endpoints (peer handshake, relay, sync) + all inbound event processors + identity resolution functions
|
- `packages/server/src/routes/federation.ts` -- **Barrel** for the federation route subsystem. Re-exports the public API (identity resolution, event processors, reconciliation, `validateOrigin`) so `from '.../routes/federation.js'` imports resolve unchanged, and composes the HTTP registrars into `federationRoutes()`. The implementation lives in `routes/federation/` (split out of the former single 7.6k-line file; see `docs/superpowers/specs/2026-07-10-federation-ts-split-design.md`):
|
||||||
|
- `routes/federation/rateLimits.ts` -- In-memory sliding-window rate limiters (accept/relay/lookup/ensure) + replay-nonce store + eviction timers
|
||||||
|
- `routes/federation/origin.ts` -- `validateOrigin`, `resolveLocalOrigin`, `sanitizePeer` (+ `SanitizedPeer` shape)
|
||||||
|
- `routes/federation/identity.ts` -- Federated identity resolution: `extractDomain`, `getOurIdentityDomain`, `verifyAttribution`, `resolveLocalUser`, `findFederatedUser`, `resolveOrCreateReplicatedUser`, `backfillHomeUserId`
|
||||||
|
- `routes/federation/dmChannels.ts` -- DM channel/message payload builders, `findOrCreateDmChannel`, `resolveLocalDmMessage`, `isUrlFromPeer`
|
||||||
|
- `routes/federation/profile.ts` -- Replicated-profile hydration + asset download, `processProfileUpdateEvent`, `backfillReplicatedProfileAssets`
|
||||||
|
- `routes/federation/reconciliation.ts` -- DM federated-id reconciliation + dead-incarnation artifact sweeps (worker-facing maintenance)
|
||||||
|
- `routes/federation/events/*.ts` -- Inbound relay event processors, grouped by domain: `dmMessages`, `membership`, `friends`, `calls`, `dmState` (presence/read-state/close/reopen/file-rejected), and `dispatch` (`processRelayEvents`, the fan-out entry point shared by the HTTP relay handler and the initial-sync worker)
|
||||||
|
- `routes/federation/handlers/*.ts` -- Fastify route registrars, grouped by endpoint concern: `peerHandshake` (initiate/accept/ensure/rotate/denied), `peerAdmin` (peer list/CRUD/reset/recheck/rotate), `approvals` (approval queue + peering subscriptions/notifications + approve/deny helpers), `relay` (identity delete, relay, epoch, sync), `lookup` (user lookups), `attach` (verify-attach-proof, `/api/users/@me/reattach`)
|
||||||
|
- `routes/federation/handlers/s2sAuth.ts` -- `authenticateS2SPeer(request, reply, opts?)`: the shared inbound S2S-HMAC auth preamble (parse headers → resolve active peer → optional per-peer rate limit **before** signature → verify HMAC signature → nonce replay). Adopted by the six endpoints whose preamble is byte-identical: `DELETE /identity`, `POST /relay`, `POST /sync` (`relay.ts`), `POST /users/lookup`, `POST /users/by-home-id` (`lookup.ts`), and `POST /verify-attach-proof` (`attach.ts`). Returns `{ ok: true, peer, nonce }` or, having already sent the rejection reply, `{ ok: false }` (caller must `return`). **Intentional non-adopters** (each keeps a load-bearing gate the helper would flatten, documented in its own docstring/comment): `POST /epoch` (revoked-only gate for peer recovery, 400 on missing headers, no nonce check), `POST /peer/rotate` (active-only, no nonce check), `POST /peer/denied` (`awaiting_approval` gate, synthetic no-grace secret verify).
|
||||||
- `packages/server/src/utils/federationAuth.ts` -- HMAC signing, verification, header parsing, `getOurOrigin()`
|
- `packages/server/src/utils/federationAuth.ts` -- HMAC signing, verification, header parsing, `getOurOrigin()`
|
||||||
- `packages/server/src/utils/federationOutbox.ts` -- Event queuing, coalescing, relay payload construction, mutation log, participant/target resolution
|
- `packages/server/src/utils/federationOutbox.ts` -- Event queuing, coalescing, relay payload construction, mutation log, participant/target resolution
|
||||||
- `packages/server/src/utils/federationLookup.ts` -- HMAC-signed remote-user lookups: `lookupRemoteUser` (by username) and `lookupRemoteUserByHomeId` (reverse lookup, used by stub backfill)
|
- `packages/server/src/utils/federationLookup.ts` -- HMAC-signed remote-user lookups: `lookupRemoteUser` (by username) and `lookupRemoteUserByHomeId` (reverse lookup, used by stub backfill)
|
||||||
|
|||||||
@@ -45,6 +45,15 @@ Storage: Bigint decimal strings in SQLite TEXT columns (bigint not JSON-safe).
|
|||||||
### Step 1: Owner/Admin Check
|
### Step 1: Owner/Admin Check
|
||||||
- Space owner OR instance admin (`isAdmin === 1`) → return ALL_PERMISSIONS
|
- Space owner OR instance admin (`isAdmin === 1`) → return ALL_PERMISSIONS
|
||||||
|
|
||||||
|
### Step 1b: Membership Gate
|
||||||
|
- If the user is **not** a member of the space (`getMember` returns nothing) → return `0n`
|
||||||
|
- A non-member has no permissions in a space they have not joined. Without this,
|
||||||
|
the @everyone role in Step 2 would leak default member rights (VIEW_CHANNEL,
|
||||||
|
READ_MESSAGE_HISTORY, CREATE_INVITE, …) to any authenticated non-member —
|
||||||
|
allowing them to read channels and mint invite codes for spaces they never
|
||||||
|
joined. Owner and instance admin are already resolved in Step 1, so they are
|
||||||
|
unaffected.
|
||||||
|
|
||||||
### Step 2: Compute Base (space-level)
|
### Step 2: Compute Base (space-level)
|
||||||
- Start with @everyone role permissions (role where `id === spaceId`)
|
- Start with @everyone role permissions (role where `id === spaceId`)
|
||||||
- OR together all permissions from user's assigned roles
|
- OR together all permissions from user's assigned roles
|
||||||
@@ -89,7 +98,6 @@ if channelOverride: base = (base & ~deny) | allow
|
|||||||
| `permissionsToString(perms)` | Bigint → decimal string for JSON |
|
| `permissionsToString(perms)` | Bigint → decimal string for JSON |
|
||||||
| `stringToPermissions(str)` | Decimal string → bigint (supports legacy JSON array format) |
|
| `stringToPermissions(str)` | Decimal string → bigint (supports legacy JSON array format) |
|
||||||
| `computePermissions(userId, spaceId, channelId?)` | Full resolution algorithm |
|
| `computePermissions(userId, spaceId, channelId?)` | Full resolution algorithm |
|
||||||
| `computeCategoryPermissions(userId, spaceId, categoryId)` | Stops at category level (no channel overrides) |
|
|
||||||
| `hasPermission(userId, spaceId, permission, channelId?)` | Boolean wrapper |
|
| `hasPermission(userId, spaceId, permission, channelId?)` | Boolean wrapper |
|
||||||
| `getMember/isMember/isSpaceOwner` | Membership checks |
|
| `getMember/isMember/isSpaceOwner` | Membership checks |
|
||||||
| `isDmMember/isBanned` | DM/ban checks |
|
| `isDmMember/isBanned` | DM/ban checks |
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# Security Scanning & Supply-Chain Assurance
|
||||||
|
|
||||||
|
Automated, continuous scanning wired into GitHub Actions. This document is the
|
||||||
|
reference for what runs, where results go, and the one-time settings a maintainer
|
||||||
|
must enable. **Current state: report-only** — scanners surface findings in the
|
||||||
|
Security tab but do not block merges yet. Enforcement (blocking) is turned on in a
|
||||||
|
later change once the remediation pass has cleared the backlog.
|
||||||
|
|
||||||
|
## Workflows
|
||||||
|
|
||||||
|
| File | Purpose | Trigger | Result |
|
||||||
|
|------|---------|---------|--------|
|
||||||
|
| `.github/dependabot.yml` | Dependency + action + base-image update PRs | weekly | PRs |
|
||||||
|
| `.github/workflows/codeql.yml` | CodeQL SAST (`javascript-typescript`, build-mode none) | PR + push main + weekly | Security tab |
|
||||||
|
| `.github/workflows/security.yml` | gitleaks (secrets, full history), OSV-Scanner (deps), Trivy config (IaC), Trivy license | PR + push main + weekly | Security tab |
|
||||||
|
| `.github/workflows/scorecard.yml` | OpenSSF Scorecard (repo posture) | push main + weekly + on branch-protection change | Security tab + public badge |
|
||||||
|
| `.github/workflows/docker-publish.yml` | Image scan (Trivy) + SBOM + provenance for the published container | tag push / manual | image scan (report-only) + SBOM + provenance |
|
||||||
|
|
||||||
|
> **gitleaks findings** surface in the workflow's job log and PR summary — the
|
||||||
|
> `gitleaks` job does not upload SARIF, so secret hits do **not** appear under
|
||||||
|
> Security → Code scanning (unlike the OSV / Trivy / CodeQL / Scorecard jobs).
|
||||||
|
|
||||||
|
## Tiered policy (target, enforced in a later change)
|
||||||
|
|
||||||
|
- **Always block:** gitleaks secret hit; OSV/Trivy fixable HIGH/CRITICAL; Trivy
|
||||||
|
disallowed license.
|
||||||
|
- **Advisory (SARIF → Security tab):** CodeQL alerts; OSV/Trivy unfixable or
|
||||||
|
medium/low; Scorecard.
|
||||||
|
|
||||||
|
Code-level gates (OSV, Trivy, gitleaks) block via workflow exit codes. CodeQL
|
||||||
|
merge-blocking, Dependabot alerts, and native secret-scanning are GitHub *settings*
|
||||||
|
— see the checklist below.
|
||||||
|
|
||||||
|
## Supply-chain hardening
|
||||||
|
|
||||||
|
- Every action is pinned to a full commit SHA (`# vX.Y.Z` comment) — resists
|
||||||
|
tag-move attacks and satisfies Scorecard's Pinned-Dependencies check.
|
||||||
|
- `step-security/harden-runner` (egress-policy `audit`) on Linux jobs.
|
||||||
|
- Least-privilege `permissions:` per workflow/job.
|
||||||
|
- SBOM + SLSA provenance are attached to the published container image at push
|
||||||
|
(`.github/workflows/docker-publish.yml`), alongside a report-only Trivy scan
|
||||||
|
of the amd64 image (the arm64 image ships unscanned; enforcement is turned
|
||||||
|
on in a later plan).
|
||||||
|
|
||||||
|
## Maintainer checklist (one-time GitHub settings — NOT code)
|
||||||
|
|
||||||
|
- [ ] Repository must be **public** (required for the Scorecard badge/publish and
|
||||||
|
the CodeQL free tier).
|
||||||
|
- [ ] Settings → Code security: enable **Dependabot alerts** and **Dependabot
|
||||||
|
security updates**.
|
||||||
|
- [ ] Settings → Code security: enable **Secret scanning** + **Push protection**.
|
||||||
|
- [ ] Settings → Code security: enable **CodeQL / code-scanning merge protection**
|
||||||
|
so high-severity alerts block PRs (the code-level gates do the rest).
|
||||||
|
- [ ] Branch protection on `main`: require the CI + security status checks to pass.
|
||||||
|
- [ ] **Manual image bumps:** Dependabot does not track `docker-compose.yml`
|
||||||
|
`image:` pins — update `caddy` and `livekit/livekit-server` by hand when new
|
||||||
|
releases ship. (Renovate, which parses compose, is an optional future
|
||||||
|
alternative.)
|
||||||
@@ -113,6 +113,8 @@ Cross-references: [database.md](database.md) (table schemas), [permissions.md](p
|
|||||||
|
|
||||||
**Behavior:** Returns existing `inviteCode` if one exists. Only generates a new one (`crypto.randomBytes(4).toString('hex')`) if the space has no invite code. Invite codes are permanent (no expiration).
|
**Behavior:** Returns existing `inviteCode` if one exists. Only generates a new one (`crypto.randomBytes(4).toString('hex')`) if the space has no invite code. Invite codes are permanent (no expiration).
|
||||||
|
|
||||||
|
**Visibility gate:** returns `403` for `request`-visibility spaces — they are approval-gated and have no usable invite link (the join endpoints reject invite-code joins for them), so the endpoint refuses to hand one out. The client (`InviteModal`) shows an "invite by join request" notice instead of the invite UI for such spaces, and `POST /api/dm/space-invite` likewise rejects a `request`-visibility **local** space with `403 space_requires_approval` (remote request spaces are enforced by their home instance at join time).
|
||||||
|
|
||||||
**Response:** `{ inviteCode: string }`
|
**Response:** `{ inviteCode: string }`
|
||||||
|
|
||||||
### Invite URL Format
|
### Invite URL Format
|
||||||
@@ -168,7 +170,9 @@ Two endpoints serve the same purpose:
|
|||||||
| `POST /api/spaces/:id/join` | Join when spaceId is known (body: `{ inviteCode }`) |
|
| `POST /api/spaces/:id/join` | Join when spaceId is known (body: `{ inviteCode }`) |
|
||||||
| `POST /api/spaces/join` | Join by code only, spaceId looked up from `inviteCode` |
|
| `POST /api/spaces/join` | Join by code only, spaceId looked up from `inviteCode` |
|
||||||
|
|
||||||
**Validations:** invite code match, not banned, not already a member.
|
**Validations:** invite code match, not banned, not already a member, and **space visibility is not `request`**.
|
||||||
|
|
||||||
|
**Visibility gate:** invite-code joins are rejected (`403`) for `request`-visibility spaces — entry to a request-only space must go through `POST /api/spaces/:id/request-join` + manager approval, never a bearer invite code. `private` spaces remain invite-joinable (an invite is their only entry path); `public` spaces are joinable by code or via `POST /api/spaces/:id/public-join`. Combined with the permission membership gate (a non-member cannot obtain `CREATE_INVITE`, see [permissions.md](permissions.md)), this closes the invite-bypass path where a non-member could mint a code for a request-only space and self-join without approval.
|
||||||
|
|
||||||
**Side effects:**
|
**Side effects:**
|
||||||
1. Insert `space_members` row
|
1. Insert `space_members` row
|
||||||
|
|||||||
@@ -2,13 +2,31 @@
|
|||||||
# ============================================================
|
# ============================================================
|
||||||
# Backspace — Production Installer
|
# Backspace — Production Installer
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Sets up Backspace with Caddy (auto-HTTPS) and optional
|
# Sets up Backspace on a Linux server in one of three deployment modes,
|
||||||
# LiveKit (voice/video) on a Linux server.
|
# auto-detecting which one fits your environment:
|
||||||
|
#
|
||||||
|
# allinone The bundled Caddy owns ports 80/443 and does automatic HTTPS
|
||||||
|
# for your domain. The simplest setup — pick this if 80/443 are free.
|
||||||
|
# proxy You already run a reverse proxy (nginx, Traefik, Caddy, Nginx Proxy
|
||||||
|
# Manager, SWAG…). Backspace is published on 127.0.0.1:APP_PORT and
|
||||||
|
# the installer prints ready-to-paste proxy config. No bundled Caddy.
|
||||||
|
# tunnel You expose the box through a tunnel (Cloudflare Tunnel, Tailscale…).
|
||||||
|
# Same as proxy, plus tunnel-specific guidance. (Voice does not work
|
||||||
|
# over a tunnel — WebRTC/UDP can't traverse it.)
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# ./install.sh Interactive setup
|
# ./install.sh Interactive setup (auto-detects the mode)
|
||||||
|
#
|
||||||
# Non-interactive — set any/all of these to skip the matching prompt:
|
# Non-interactive — set any/all of these to skip the matching prompt:
|
||||||
# DOMAIN=chat.example.com ENABLE_VOICE=true INSTANCE_NAME="My Chat" ./install.sh
|
# DOMAIN=chat.example.com \
|
||||||
|
# DEPLOY_MODE=allinone|proxy|tunnel \
|
||||||
|
# APP_PORT=8080 \ # proxy/tunnel only; auto-picked if unset
|
||||||
|
# ENABLE_VOICE=true \
|
||||||
|
# INSTANCE_NAME="My Chat" \
|
||||||
|
# ./install.sh
|
||||||
|
#
|
||||||
|
# BACKSPACE_BUILD=true ./install.sh Force a local from-source build instead of
|
||||||
|
# pulling the prebuilt image (fork operators).
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -97,56 +115,84 @@ if ! $COMPOSE version &>/dev/null 2>&1; then
|
|||||||
fi
|
fi
|
||||||
success "Docker Compose $($COMPOSE version --short 2>/dev/null || echo 'installed')"
|
success "Docker Compose $($COMPOSE version --short 2>/dev/null || echo 'installed')"
|
||||||
|
|
||||||
# Check if ports 80/443 are available
|
# ── Port helpers ────────────────────────────────────────────
|
||||||
check_port() {
|
# A host port can be held in two ways this installer must detect:
|
||||||
local port=$1
|
# 1. A normal listening socket (host process) → visible via `ss -tln`.
|
||||||
if ss -tlnp 2>/dev/null | grep -qE ":${port}\b"; then
|
# 2. A Docker-published port. With the userland proxy *disabled*
|
||||||
local proc
|
# (`userland-proxy: false`, common on tuned hosts), Docker DNATs the port
|
||||||
proc=$(ss -tlnp 2>/dev/null | grep -E ":${port}\b" | grep -oP 'users:\(\("\K[^"]+' | head -1 || echo "unknown")
|
# with iptables and there is NO listening socket for `ss` to see. So we must
|
||||||
error "Port $port is already in use by: $proc"
|
# also consult `docker ps` — otherwise a box whose Caddy already owns 80/443
|
||||||
error "Free port $port before running this script."
|
# via iptables would be misreported as "ports free".
|
||||||
return 1
|
|
||||||
fi
|
# Does any running container publish this host port? (matches "…:<port>->" in the
|
||||||
return 0
|
# Ports column; the ":" before the port and "->" after pin it to the HOST port,
|
||||||
|
# so :80 doesn't match :8080 and never matches the container-side port.)
|
||||||
|
docker_publishes_port() {
|
||||||
|
$DOCKER ps --format '{{.Names}} {{.Ports}}' 2>/dev/null | grep -qE ":${1}->"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Only check ports if we're NOT already running (upgrade scenario)
|
# Same, but ignore Backspace's own container (used when re-running on a host that
|
||||||
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q '^caddy$'; then
|
# already runs this instance — its published port must not count as a conflict).
|
||||||
port_ok=true
|
docker_publishes_port_other() {
|
||||||
check_port 80 || port_ok=false
|
$DOCKER ps --format '{{.Names}} {{.Ports}}' 2>/dev/null \
|
||||||
check_port 443 || port_ok=false
|
| grep -vE '^backspace ' | grep -qE ":${1}->"
|
||||||
if [[ "$port_ok" == false ]]; then
|
}
|
||||||
exit 1
|
|
||||||
|
# Is the port in use by anything (host socket OR any container)?
|
||||||
|
port_in_use() {
|
||||||
|
local port=$1
|
||||||
|
if ss -tln 2>/dev/null | grep -qE ":${port}[[:space:]]"; then
|
||||||
|
return 0
|
||||||
fi
|
fi
|
||||||
success "Ports 80 and 443 are available"
|
docker_publishes_port "$port"
|
||||||
else
|
}
|
||||||
success "Caddy is already running (upgrade mode)"
|
|
||||||
|
# In use by something OTHER than our own Backspace container?
|
||||||
|
port_in_use_by_other() {
|
||||||
|
local port=$1
|
||||||
|
if ss -tln 2>/dev/null | grep -qE ":${port}[[:space:]]"; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
docker_publishes_port_other "$port"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Best-effort human description of what holds a port (for helpful warnings).
|
||||||
|
port_holder() {
|
||||||
|
local port=$1 holder=""
|
||||||
|
holder=$($DOCKER ps --format '{{.Names}} {{.Ports}}' 2>/dev/null \
|
||||||
|
| grep -E ":${port}->" | awk '{print $1}' | head -1 || true)
|
||||||
|
if [[ -n "$holder" ]]; then
|
||||||
|
echo "docker container '$holder'"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
holder=$(ss -tlnp 2>/dev/null | grep -E ":${port}[[:space:]]" \
|
||||||
|
| grep -oP 'users:\(\("\K[^"]+' | head -1 || true)
|
||||||
|
echo "${holder:-another process}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── openssl (needed for secret generation) ──────────────────
|
||||||
|
if ! command -v openssl &>/dev/null; then
|
||||||
|
error "openssl is required for generating secrets."
|
||||||
|
error "Install: sudo apt-get install openssl"
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Disk space check
|
# Disk space check
|
||||||
available_kb=$(df -k . 2>/dev/null | tail -1 | awk '{print $4}' || true)
|
available_kb=$(df -k . 2>/dev/null | tail -1 | awk '{print $4}' || true)
|
||||||
if [[ -n "$available_kb" ]] && (( available_kb < 3000000 )); then
|
if [[ -n "$available_kb" ]] && (( available_kb < 3000000 )); then
|
||||||
warn "Low disk space: $((available_kb / 1024))MB available (recommend 3GB+)"
|
warn "Low disk space: $((available_kb / 1024))MB available (recommend 3GB+)"
|
||||||
|
warn "The prebuilt image (~1.6GB pulled) needs less than a from-source build."
|
||||||
else
|
else
|
||||||
success "Disk space OK"
|
success "Disk space OK"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Check for openssl (needed for secret generation)
|
|
||||||
if ! command -v openssl &>/dev/null; then
|
|
||||||
error "openssl is required for generating secrets."
|
|
||||||
error "Install: sudo apt-get install openssl"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── Phase 2: Configuration ─────────────────────────────────
|
# ── Phase 2: Configuration ─────────────────────────────────
|
||||||
|
|
||||||
step "Configuration"
|
step "Configuration"
|
||||||
|
|
||||||
# Detect existing installation
|
# Detect existing installation
|
||||||
EXISTING_ENV=false
|
|
||||||
if [[ -f .env ]]; then
|
if [[ -f .env ]]; then
|
||||||
EXISTING_ENV=true
|
info "Existing .env detected — secrets and settings will be preserved."
|
||||||
info "Existing .env detected — secrets will be preserved."
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Helper: read existing .env value (|| true prevents set -e from killing
|
# Helper: read existing .env value (|| true prevents set -e from killing
|
||||||
@@ -157,17 +203,131 @@ env_val() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ── Deployment mode ─────────────────────────────────────────
|
||||||
|
# Precedence: explicit DEPLOY_MODE env → existing .env → auto-detect + prompt.
|
||||||
|
# Auto-detect never dead-ends: if 80/443 are taken, All-in-One is simply off the
|
||||||
|
# menu and we steer the operator to proxy/tunnel instead.
|
||||||
|
|
||||||
|
existing_mode=$(env_val DEPLOY_MODE)
|
||||||
|
CADDY_RUNNING=false
|
||||||
|
if $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q '^caddy$'; then
|
||||||
|
CADDY_RUNNING=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
port80_free=true; port443_free=true
|
||||||
|
port_in_use 80 && port80_free=false
|
||||||
|
port_in_use 443 && port443_free=false
|
||||||
|
|
||||||
|
choose_mode_interactively() {
|
||||||
|
# Writes the chosen mode to the global DEPLOY_MODE.
|
||||||
|
local choice=""
|
||||||
|
if [[ "$port80_free" == true && "$port443_free" == true ]]; then
|
||||||
|
echo " Ports 80 and 443 are free — All-in-One (bundled Caddy + auto-HTTPS) is available."
|
||||||
|
echo ""
|
||||||
|
echo " How do you want to expose Backspace?"
|
||||||
|
echo -e " ${BOLD}1)${NC} All-in-One — bundled Caddy handles HTTPS for you ${GREEN}(recommended)${NC}"
|
||||||
|
echo -e " ${BOLD}2)${NC} Behind my own reverse proxy (nginx, Traefik, Caddy, Nginx Proxy Manager, SWAG…)"
|
||||||
|
echo -e " ${BOLD}3)${NC} Behind a tunnel (Cloudflare Tunnel, Tailscale…)"
|
||||||
|
echo ""
|
||||||
|
read -rp " Choice [1]: " choice || choice=""
|
||||||
|
case "${choice:-1}" in
|
||||||
|
1) DEPLOY_MODE=allinone ;;
|
||||||
|
2) DEPLOY_MODE=proxy ;;
|
||||||
|
3) DEPLOY_MODE=tunnel ;;
|
||||||
|
*) DEPLOY_MODE=allinone ;;
|
||||||
|
esac
|
||||||
|
else
|
||||||
|
warn "Ports 80/443 are already in use on this host:"
|
||||||
|
[[ "$port80_free" == false ]] && echo " 80 → held by $(port_holder 80)"
|
||||||
|
[[ "$port443_free" == false ]] && echo " 443 → held by $(port_holder 443)"
|
||||||
|
echo ""
|
||||||
|
info "All-in-One needs 80 and 443 free, so it's unavailable here."
|
||||||
|
info "That's fine — run Backspace behind what already owns those ports:"
|
||||||
|
echo ""
|
||||||
|
echo -e " ${BOLD}1)${NC} Behind my own reverse proxy (nginx, Traefik, Caddy, Nginx Proxy Manager, SWAG…) ${GREEN}(recommended)${NC}"
|
||||||
|
echo -e " ${BOLD}2)${NC} Behind a tunnel (Cloudflare Tunnel, Tailscale…)"
|
||||||
|
echo -e " ${BOLD}3)${NC} Nothing yet — I'll free 80/443 and use All-in-One (exit so I can do that)"
|
||||||
|
echo ""
|
||||||
|
read -rp " Choice [1]: " choice || choice=""
|
||||||
|
case "${choice:-1}" in
|
||||||
|
1) DEPLOY_MODE=proxy ;;
|
||||||
|
2) DEPLOY_MODE=tunnel ;;
|
||||||
|
3)
|
||||||
|
error "Free ports 80 and 443, then re-run ./install.sh for All-in-One mode."
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
*) DEPLOY_MODE=proxy ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ -n "${DEPLOY_MODE:-}" ]]; then
|
||||||
|
info "Deployment mode: ${DEPLOY_MODE} (from environment)"
|
||||||
|
elif [[ -n "$existing_mode" ]]; then
|
||||||
|
DEPLOY_MODE="$existing_mode"
|
||||||
|
info "Deployment mode: ${DEPLOY_MODE} (from existing .env)"
|
||||||
|
info "To switch modes, re-run with DEPLOY_MODE=allinone|proxy|tunnel."
|
||||||
|
elif [[ -t 0 ]]; then
|
||||||
|
echo ""
|
||||||
|
choose_mode_interactively
|
||||||
|
else
|
||||||
|
# Non-interactive with no DEPLOY_MODE: pick a safe, never-dead-end default.
|
||||||
|
if [[ "$port80_free" == true && "$port443_free" == true ]]; then
|
||||||
|
DEPLOY_MODE=allinone
|
||||||
|
else
|
||||||
|
DEPLOY_MODE=proxy
|
||||||
|
warn "Ports 80/443 are in use and no DEPLOY_MODE was given — defaulting to 'proxy'."
|
||||||
|
fi
|
||||||
|
info "Deployment mode: ${DEPLOY_MODE} (auto-detected)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$DEPLOY_MODE" in
|
||||||
|
allinone|proxy|tunnel) ;;
|
||||||
|
*)
|
||||||
|
error "Invalid DEPLOY_MODE='${DEPLOY_MODE}'. Use allinone, proxy, or tunnel."
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# All-in-One requires 80/443 — but a re-run over an existing All-in-One instance
|
||||||
|
# legitimately finds *its own* Caddy holding them (an upgrade), which is fine.
|
||||||
|
if [[ "$DEPLOY_MODE" == "allinone" ]]; then
|
||||||
|
if [[ "$CADDY_RUNNING" == true ]]; then
|
||||||
|
success "Caddy is already running (All-in-One upgrade)"
|
||||||
|
elif [[ "$port80_free" == false || "$port443_free" == false ]]; then
|
||||||
|
error "All-in-One mode needs ports 80 and 443 free, but:"
|
||||||
|
[[ "$port80_free" == false ]] && error " 80 is held by $(port_holder 80)"
|
||||||
|
[[ "$port443_free" == false ]] && error " 443 is held by $(port_holder 443)"
|
||||||
|
error "Free them, or re-run with DEPLOY_MODE=proxy (or =tunnel) to run behind them."
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
success "Ports 80 and 443 are available"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# In proxy/tunnel mode, layer the proxy override so Caddy is dropped and the app
|
||||||
|
# is published on a loopback port. Compose reads COMPOSE_FILE (from the shell and
|
||||||
|
# from .env) to pick up both files for every command — the operator never needs
|
||||||
|
# to remember `-f` flags afterwards.
|
||||||
|
if [[ "$DEPLOY_MODE" == "proxy" || "$DEPLOY_MODE" == "tunnel" ]]; then
|
||||||
|
export COMPOSE_FILE="docker-compose.yml:docker-compose.proxy.yml"
|
||||||
|
else
|
||||||
|
unset COMPOSE_FILE 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
# ── Domain ──────────────────────────────────────────────────
|
# ── Domain ──────────────────────────────────────────────────
|
||||||
|
# Required in every mode: it's the public hostname clients use and it drives the
|
||||||
|
# federation identity + LiveKit URL. In proxy/tunnel mode TLS is terminated at
|
||||||
|
# your edge, but the app still advertises https://DOMAIN.
|
||||||
|
|
||||||
existing_domain=$(env_val DOMAIN)
|
existing_domain=$(env_val DOMAIN)
|
||||||
if [[ -n "${DOMAIN:-}" ]]; then
|
if [[ -n "${DOMAIN:-}" ]]; then
|
||||||
# Non-interactive: DOMAIN set via environment
|
|
||||||
:
|
:
|
||||||
elif [[ -n "$existing_domain" ]]; then
|
elif [[ -n "$existing_domain" ]]; then
|
||||||
read -rp "Domain [$existing_domain]: " DOMAIN
|
read -rp "Domain [$existing_domain]: " DOMAIN || DOMAIN=""
|
||||||
DOMAIN="${DOMAIN:-$existing_domain}"
|
DOMAIN="${DOMAIN:-$existing_domain}"
|
||||||
else
|
else
|
||||||
read -rp "Domain (e.g., chat.example.com): " DOMAIN
|
read -rp "Domain (e.g., chat.example.com): " DOMAIN || DOMAIN=""
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -z "${DOMAIN:-}" ]]; then
|
if [[ -z "${DOMAIN:-}" ]]; then
|
||||||
@@ -175,22 +335,20 @@ if [[ -z "${DOMAIN:-}" ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# DNS verification
|
# DNS verification is meaningful for All-in-One (Caddy must reach this host to
|
||||||
info "Verifying DNS for ${DOMAIN}..."
|
# issue a certificate). In proxy/tunnel mode the DNS record points at your proxy
|
||||||
|
# or tunnel edge — often NOT this host's IP (that's the whole point) — so we only
|
||||||
|
# note what it resolves to, without warning about a mismatch.
|
||||||
|
info "Checking DNS for ${DOMAIN}..."
|
||||||
resolved_ip=""
|
resolved_ip=""
|
||||||
if command -v dig &>/dev/null; then
|
if command -v dig &>/dev/null; then
|
||||||
resolved_ip=$(dig +short "$DOMAIN" A 2>/dev/null | tail -1 || true)
|
resolved_ip=$(dig +short "$DOMAIN" A 2>/dev/null | tail -1 || true)
|
||||||
elif command -v getent &>/dev/null; then
|
elif command -v getent &>/dev/null; then
|
||||||
# A non-resolving domain makes `getent hosts` exit 2, and with `set -o
|
|
||||||
# pipefail` that would abort the installer here — before the graceful
|
|
||||||
# "Could not resolve" warning below. Swallow it: an empty result is the
|
|
||||||
# intended "not resolved yet" signal (installing before DNS is set up is
|
|
||||||
# explicitly supported).
|
|
||||||
resolved_ip=$(getent hosts "$DOMAIN" 2>/dev/null | awk '{print $1}' | head -1 || true)
|
resolved_ip=$(getent hosts "$DOMAIN" 2>/dev/null | awk '{print $1}' | head -1 || true)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [[ "$DEPLOY_MODE" == "allinone" ]]; then
|
||||||
my_ip=$(curl -s4 --connect-timeout 5 ifconfig.me 2>/dev/null || curl -s4 --connect-timeout 5 icanhazip.com 2>/dev/null || echo "")
|
my_ip=$(curl -s4 --connect-timeout 5 ifconfig.me 2>/dev/null || curl -s4 --connect-timeout 5 icanhazip.com 2>/dev/null || echo "")
|
||||||
|
|
||||||
if [[ -z "$resolved_ip" ]]; then
|
if [[ -z "$resolved_ip" ]]; then
|
||||||
warn "Could not resolve ${DOMAIN}. Ensure DNS is configured before Caddy can issue certificates."
|
warn "Could not resolve ${DOMAIN}. Ensure DNS is configured before Caddy can issue certificates."
|
||||||
elif [[ -n "$my_ip" && "$resolved_ip" != "$my_ip" ]]; then
|
elif [[ -n "$my_ip" && "$resolved_ip" != "$my_ip" ]]; then
|
||||||
@@ -199,18 +357,71 @@ elif [[ -n "$my_ip" && "$resolved_ip" != "$my_ip" ]]; then
|
|||||||
else
|
else
|
||||||
success "${DOMAIN} resolves to ${resolved_ip:-verified}"
|
success "${DOMAIN} resolves to ${resolved_ip:-verified}"
|
||||||
fi
|
fi
|
||||||
|
else
|
||||||
|
if [[ -n "$resolved_ip" ]]; then
|
||||||
|
info "${DOMAIN} currently resolves to ${resolved_ip} (should point at your proxy/tunnel edge)."
|
||||||
|
else
|
||||||
|
info "${DOMAIN} does not resolve yet — point it at your proxy/tunnel edge when ready."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── App port (proxy / tunnel only) ──────────────────────────
|
||||||
|
# The loopback host port your proxy/tunnel forwards to. Auto-picked to avoid
|
||||||
|
# common clashes (3000/8080 are frequently taken) unless APP_PORT is set.
|
||||||
|
|
||||||
|
pick_free_port() {
|
||||||
|
if [[ -n "${APP_PORT:-}" ]]; then
|
||||||
|
if port_in_use_by_other "$APP_PORT"; then
|
||||||
|
error "Requested APP_PORT=$APP_PORT is already in use by $(port_holder "$APP_PORT")."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "$APP_PORT"; return
|
||||||
|
fi
|
||||||
|
local existing; existing=$(env_val APP_PORT)
|
||||||
|
if [[ -n "$existing" ]]; then
|
||||||
|
# Re-run: keep the existing port so the proxy/tunnel config stays valid, even
|
||||||
|
# though our own container is currently publishing it.
|
||||||
|
echo "$existing"; return
|
||||||
|
fi
|
||||||
|
local p
|
||||||
|
for p in 8080 8081 8082 8090 8095 3001 3002 18080 28080; do
|
||||||
|
if ! port_in_use "$p"; then echo "$p"; return; fi
|
||||||
|
done
|
||||||
|
for p in $(seq 8100 8200); do
|
||||||
|
if ! port_in_use "$p"; then echo "$p"; return; fi
|
||||||
|
done
|
||||||
|
error "Could not find a free host port for the app. Set APP_PORT to a free port and re-run."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
APP_PORT_FINAL=""
|
||||||
|
if [[ "$DEPLOY_MODE" == "proxy" || "$DEPLOY_MODE" == "tunnel" ]]; then
|
||||||
|
APP_PORT_FINAL="$(pick_free_port)"
|
||||||
|
success "App will be published on 127.0.0.1:${APP_PORT_FINAL}"
|
||||||
|
fi
|
||||||
|
|
||||||
# ── Voice/Video ─────────────────────────────────────────────
|
# ── Voice/Video ─────────────────────────────────────────────
|
||||||
|
# Voice needs open UDP media ports and (in proxy mode) a /livekit route. Over a
|
||||||
|
# tunnel, WebRTC/UDP cannot traverse the edge at all — so voice is force-disabled
|
||||||
|
# in tunnel mode rather than silently configured and then failing at call time.
|
||||||
|
|
||||||
existing_profiles=$(env_val COMPOSE_PROFILES)
|
existing_profiles=$(env_val COMPOSE_PROFILES)
|
||||||
if [[ -n "${ENABLE_VOICE:-}" ]]; then
|
if [[ "$DEPLOY_MODE" == "tunnel" ]]; then
|
||||||
# Non-interactive
|
if [[ "${ENABLE_VOICE:-}" == "true" ]]; then
|
||||||
|
warn "Voice cannot work over a tunnel (WebRTC/UDP can't traverse it) — disabling it."
|
||||||
|
fi
|
||||||
|
ENABLE_VOICE=false
|
||||||
|
info "Voice/video is disabled in tunnel mode (a known, unavoidable limitation)."
|
||||||
|
elif [[ -n "${ENABLE_VOICE:-}" ]]; then
|
||||||
:
|
:
|
||||||
elif [[ "$existing_profiles" == *"voice"* ]]; then
|
elif [[ "$existing_profiles" == *"voice"* ]]; then
|
||||||
read -rp "Voice/video is currently enabled. Keep it? [Y/n] " yn
|
read -rp "Voice/video is currently enabled. Keep it? [Y/n] " yn || yn=""
|
||||||
ENABLE_VOICE=$([[ "${yn,,}" == "n" ]] && echo false || echo true)
|
ENABLE_VOICE=$([[ "${yn,,}" == "n" ]] && echo false || echo true)
|
||||||
else
|
else
|
||||||
read -rp "Enable voice/video? (requires open UDP ports) [Y/n] " yn
|
if [[ "$DEPLOY_MODE" == "proxy" ]]; then
|
||||||
|
info "Voice needs open UDP media ports AND a /livekit route in your proxy (snippet printed at the end)."
|
||||||
|
fi
|
||||||
|
read -rp "Enable voice/video? (requires open UDP ports) [Y/n] " yn || yn=""
|
||||||
ENABLE_VOICE=$([[ "${yn,,}" == "n" ]] && echo false || echo true)
|
ENABLE_VOICE=$([[ "${yn,,}" == "n" ]] && echo false || echo true)
|
||||||
fi
|
fi
|
||||||
ENABLE_VOICE="${ENABLE_VOICE:-true}"
|
ENABLE_VOICE="${ENABLE_VOICE:-true}"
|
||||||
@@ -219,10 +430,26 @@ ENABLE_VOICE="${ENABLE_VOICE:-true}"
|
|||||||
|
|
||||||
existing_name=$(env_val INSTANCE_NAME)
|
existing_name=$(env_val INSTANCE_NAME)
|
||||||
if [[ -z "${INSTANCE_NAME:-}" ]]; then
|
if [[ -z "${INSTANCE_NAME:-}" ]]; then
|
||||||
read -rp "Instance name [${existing_name:-Backspace}]: " INSTANCE_NAME
|
read -rp "Instance name [${existing_name:-Backspace}]: " INSTANCE_NAME || INSTANCE_NAME=""
|
||||||
INSTANCE_NAME="${INSTANCE_NAME:-${existing_name:-Backspace}}"
|
INSTANCE_NAME="${INSTANCE_NAME:-${existing_name:-Backspace}}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ── Max upload size ─────────────────────────────────────────
|
||||||
|
# Cloudflare (free/pro) hard-caps request bodies at 100MB, so a 100MB app limit
|
||||||
|
# lets uploads fail at the edge instead of in-app. In tunnel mode we default the
|
||||||
|
# cap below that (90MB) with headroom for multipart overhead. An explicit
|
||||||
|
# MAX_UPLOAD_SIZE (env or existing .env) always wins.
|
||||||
|
existing_max=$(env_val MAX_UPLOAD_SIZE)
|
||||||
|
if [[ -n "${MAX_UPLOAD_SIZE:-}" ]]; then
|
||||||
|
:
|
||||||
|
elif [[ -n "$existing_max" ]]; then
|
||||||
|
MAX_UPLOAD_SIZE="$existing_max"
|
||||||
|
elif [[ "$DEPLOY_MODE" == "tunnel" ]]; then
|
||||||
|
MAX_UPLOAD_SIZE=94371840 # 90 MB — under Cloudflare's 100MB body cap
|
||||||
|
else
|
||||||
|
MAX_UPLOAD_SIZE=104857600 # 100 MB
|
||||||
|
fi
|
||||||
|
|
||||||
# ── Phase 3: Generate Secrets ───────────────────────────────
|
# ── Phase 3: Generate Secrets ───────────────────────────────
|
||||||
|
|
||||||
step "Generating configuration"
|
step "Generating configuration"
|
||||||
@@ -259,12 +486,31 @@ step "Writing configuration files"
|
|||||||
|
|
||||||
# ── .env ────────────────────────────────────────────────────
|
# ── .env ────────────────────────────────────────────────────
|
||||||
|
|
||||||
cat > .env << EOF
|
{
|
||||||
|
cat << EOF
|
||||||
# Backspace Configuration
|
# Backspace Configuration
|
||||||
# Generated by install.sh on $(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
# Generated by install.sh on $(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
|
||||||
DOMAIN=${DOMAIN}
|
DOMAIN=${DOMAIN}
|
||||||
|
|
||||||
|
# Deployment mode: allinone | proxy | tunnel (see ./install.sh --help / README)
|
||||||
|
DEPLOY_MODE=${DEPLOY_MODE}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
if [[ "$DEPLOY_MODE" == "proxy" || "$DEPLOY_MODE" == "tunnel" ]]; then
|
||||||
|
cat << EOF
|
||||||
|
|
||||||
|
# Reverse-proxy / tunnel mode: layer the proxy override so the bundled Caddy is
|
||||||
|
# dropped and the app is published on 127.0.0.1:APP_PORT for your edge to reach.
|
||||||
|
# COMPOSE_FILE makes every 'docker compose' command in this directory use both
|
||||||
|
# files automatically — no -f flags needed.
|
||||||
|
COMPOSE_FILE=docker-compose.yml:docker-compose.proxy.yml
|
||||||
|
APP_PORT=${APP_PORT_FINAL}
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat << EOF
|
||||||
|
|
||||||
# Server
|
# Server
|
||||||
PORT=3000
|
PORT=3000
|
||||||
HOST=0.0.0.0
|
HOST=0.0.0.0
|
||||||
@@ -275,12 +521,12 @@ JWT_SECRET=${JWT_SECRET}
|
|||||||
# Registration
|
# Registration
|
||||||
REGISTRATION_OPEN=true
|
REGISTRATION_OPEN=true
|
||||||
|
|
||||||
# Max upload size in bytes (100MB)
|
# Max upload size in bytes
|
||||||
MAX_UPLOAD_SIZE=104857600
|
MAX_UPLOAD_SIZE=${MAX_UPLOAD_SIZE}
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
if [[ "$ENABLE_VOICE" == true ]]; then
|
if [[ "$ENABLE_VOICE" == true ]]; then
|
||||||
cat >> .env << EOF
|
cat << EOF
|
||||||
|
|
||||||
# LiveKit Voice/Video
|
# LiveKit Voice/Video
|
||||||
LIVEKIT_URL=wss://${DOMAIN}/livekit
|
LIVEKIT_URL=wss://${DOMAIN}/livekit
|
||||||
@@ -291,7 +537,7 @@ LIVEKIT_API_SECRET=${LIVEKIT_API_SECRET}
|
|||||||
COMPOSE_PROFILES=voice
|
COMPOSE_PROFILES=voice
|
||||||
EOF
|
EOF
|
||||||
else
|
else
|
||||||
cat >> .env << EOF
|
cat << EOF
|
||||||
|
|
||||||
# LiveKit Voice/Video (disabled)
|
# LiveKit Voice/Video (disabled)
|
||||||
# To enable: fill in credentials and add COMPOSE_PROFILES=voice
|
# To enable: fill in credentials and add COMPOSE_PROFILES=voice
|
||||||
@@ -300,6 +546,7 @@ else
|
|||||||
# LIVEKIT_API_SECRET=
|
# LIVEKIT_API_SECRET=
|
||||||
EOF
|
EOF
|
||||||
fi
|
fi
|
||||||
|
} > .env
|
||||||
|
|
||||||
success ".env"
|
success ".env"
|
||||||
|
|
||||||
@@ -382,14 +629,43 @@ step "Deploying Backspace"
|
|||||||
# so GET /api/instance/info advertises the exact source version. Passed straight
|
# so GET /api/instance/info advertises the exact source version. Passed straight
|
||||||
# to the build as --build-arg (survives the sudo/non-sudo $COMPOSE split, unlike
|
# to the build as --build-arg (survives the sudo/non-sudo $COMPOSE split, unlike
|
||||||
# an exported env var). Empty when this isn't a git checkout (e.g. tarball
|
# an exported env var). Empty when this isn't a git checkout (e.g. tarball
|
||||||
# install) or git is unavailable → the server treats the commit as null.
|
# install) or git is unavailable → the server treats the commit as null. A pulled
|
||||||
|
# prebuilt image already carries the commit baked at CI build time.
|
||||||
BUILD_COMMIT="$(git rev-parse --short HEAD 2>/dev/null || echo '')"
|
BUILD_COMMIT="$(git rev-parse --short HEAD 2>/dev/null || echo '')"
|
||||||
|
|
||||||
|
build_from_source() {
|
||||||
if [[ -n "$BUILD_COMMIT" ]]; then
|
if [[ -n "$BUILD_COMMIT" ]]; then
|
||||||
info "Building Backspace image from commit ${BUILD_COMMIT} (this may take a few minutes on first run)..."
|
info "Building Backspace image from commit ${BUILD_COMMIT} (first build takes a few minutes)..."
|
||||||
else
|
else
|
||||||
info "Building Backspace image (this may take a few minutes on first run)..."
|
info "Building Backspace image (first build takes a few minutes)..."
|
||||||
|
fi
|
||||||
|
$COMPOSE build --build-arg BACKSPACE_COMMIT="$BUILD_COMMIT"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Default path: pull the prebuilt multi-arch image (fast, and it spares weak/ARM
|
||||||
|
# hosts the ~1.6GB local build that OOMs small boxes). Fall back to a from-source
|
||||||
|
# build if the image can't be pulled (not published yet, private, or offline), or
|
||||||
|
# if the operator forces a build (BACKSPACE_BUILD=true — e.g. running a fork).
|
||||||
|
if [[ "${BACKSPACE_BUILD:-false}" == "true" ]]; then
|
||||||
|
info "BACKSPACE_BUILD=true — building from source (skipping the prebuilt image)."
|
||||||
|
build_from_source
|
||||||
|
else
|
||||||
|
image_ref="${BACKSPACE_IMAGE:-ghcr.io/thezwiss/backspace}:${BACKSPACE_IMAGE_TAG:-latest}"
|
||||||
|
info "Fetching prebuilt image ${image_ref} ..."
|
||||||
|
if $COMPOSE pull backspace; then
|
||||||
|
success "Pulled prebuilt image"
|
||||||
|
elif $DOCKER image inspect "$image_ref" >/dev/null 2>&1; then
|
||||||
|
# Pull failed (offline / registry hiccup / private) but a usable copy is
|
||||||
|
# already on this host (a prior run, an air-gapped `docker load`, or a
|
||||||
|
# previous from-source build tagged under this ref) — use it instead of
|
||||||
|
# forcing a needless multi-hundred-MB rebuild.
|
||||||
|
warn "Could not pull ${image_ref} — using the copy already present on this host."
|
||||||
|
else
|
||||||
|
warn "Prebuilt image unavailable (not published yet, private, or offline)."
|
||||||
|
warn "Falling back to a from-source build — slower, and heavy on low-RAM/ARM hosts."
|
||||||
|
build_from_source
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
$COMPOSE build --quiet --build-arg BACKSPACE_COMMIT="$BUILD_COMMIT"
|
|
||||||
|
|
||||||
info "Starting services..."
|
info "Starting services..."
|
||||||
$COMPOSE up -d
|
$COMPOSE up -d
|
||||||
@@ -427,26 +703,24 @@ if [[ "$healthy" == true && -n "$INSTANCE_NAME" && "$INSTANCE_NAME" != "Backspac
|
|||||||
' 2>/dev/null && success "Instance name set to: ${INSTANCE_NAME}" || warn "Could not set instance name (set it manually in admin settings)"
|
' 2>/dev/null && success "Instance name set to: ${INSTANCE_NAME}" || warn "Could not set instance name (set it manually in admin settings)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Phase 7.5: Verify HTTPS reachability ───────────────────
|
# ── Phase 7.5: Post-deploy reachability check ──────────────
|
||||||
# The internal healthcheck only proves the app is up *inside* Docker. What the
|
# What's verifiable differs by mode:
|
||||||
# operator actually cares about is whether https://DOMAIN works — which needs
|
# allinone → prove https://DOMAIN works end-to-end (Caddy has a valid,
|
||||||
# Caddy to have obtained a publicly-trusted TLS certificate, and that only
|
# publicly-trusted certificate AND the app answers over it).
|
||||||
# happens once DNS points here AND ports 80/443 are reachable from the internet.
|
# proxy/tunnel → prove the app answers on its loopback port (your edge then
|
||||||
#
|
# fronts it); TLS is your proxy/tunnel's job, not ours to test.
|
||||||
# We test this hairpin-safely with `curl --resolve DOMAIN:443:127.0.0.1`: it
|
|
||||||
# connects to the LOCAL Caddy but presents the real SNI/Host and performs full
|
|
||||||
# certificate verification. Success means Caddy is serving a valid, publicly-
|
|
||||||
# trusted certificate for DOMAIN *and* the app answers over it — which is only
|
|
||||||
# possible once issuance has succeeded. Crucially this avoids a false negative
|
|
||||||
# on self-hosted boxes that can't reach their own public address (router NAT
|
|
||||||
# hairpin), where a plain external request would time out even though the site
|
|
||||||
# is perfectly reachable for everyone else.
|
|
||||||
|
|
||||||
https_status="skipped"
|
https_status="skipped"
|
||||||
|
app_reachable="unknown"
|
||||||
|
|
||||||
|
if [[ "$DEPLOY_MODE" == "allinone" ]]; then
|
||||||
if [[ "$healthy" == true ]]; then
|
if [[ "$healthy" == true ]]; then
|
||||||
step "Verifying HTTPS"
|
step "Verifying HTTPS"
|
||||||
https_status="pending"
|
https_status="pending"
|
||||||
|
# `curl --resolve DOMAIN:443:127.0.0.1` connects to the LOCAL Caddy but
|
||||||
|
# presents the real SNI/Host and does full certificate verification. A pass
|
||||||
|
# proves a valid public cert is installed AND the app answers over it — and
|
||||||
|
# it's hairpin-safe (many self-hosted boxes can't reach their own public IP).
|
||||||
info "Checking for a valid TLS certificate on ${DOMAIN} (Caddy issues it on first start)..."
|
info "Checking for a valid TLS certificate on ${DOMAIN} (Caddy issues it on first start)..."
|
||||||
for i in $(seq 1 15); do
|
for i in $(seq 1 15); do
|
||||||
if curl -fsS --max-time 6 --resolve "${DOMAIN}:443:127.0.0.1" "https://${DOMAIN}/api/health" >/dev/null 2>&1; then
|
if curl -fsS --max-time 6 --resolve "${DOMAIN}:443:127.0.0.1" "https://${DOMAIN}/api/health" >/dev/null 2>&1; then
|
||||||
@@ -455,13 +729,192 @@ if [[ "$healthy" == true ]]; then
|
|||||||
fi
|
fi
|
||||||
sleep 2
|
sleep 2
|
||||||
done
|
done
|
||||||
|
|
||||||
if [[ "$https_status" == "live" ]]; then
|
if [[ "$https_status" == "live" ]]; then
|
||||||
success "HTTPS is live — a valid TLS certificate is installed and Backspace is serving over it."
|
success "HTTPS is live — a valid TLS certificate is installed and Backspace is serving over it."
|
||||||
else
|
else
|
||||||
warn "HTTPS is not live yet — Caddy hasn't obtained a publicly-trusted certificate."
|
warn "HTTPS is not live yet — Caddy hasn't obtained a publicly-trusted certificate."
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
else
|
||||||
|
if [[ "$healthy" == true ]]; then
|
||||||
|
step "Verifying the app"
|
||||||
|
app_reachable="no"
|
||||||
|
for i in $(seq 1 10); do
|
||||||
|
if curl -fsS --max-time 6 "http://127.0.0.1:${APP_PORT_FINAL}/api/health" >/dev/null 2>&1; then
|
||||||
|
app_reachable="yes"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
if [[ "$app_reachable" == "yes" ]]; then
|
||||||
|
success "Backspace is answering on http://127.0.0.1:${APP_PORT_FINAL} — point your $( [[ "$DEPLOY_MODE" == tunnel ]] && echo tunnel || echo 'reverse proxy') at it."
|
||||||
|
else
|
||||||
|
warn "Could not reach http://127.0.0.1:${APP_PORT_FINAL}/api/health yet — check: docker compose logs backspace"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Reverse-proxy / tunnel snippet generators ──────────────
|
||||||
|
# Paste-ready configs with WebSocket upgrade, X-Forwarded-*, and a body-size cap
|
||||||
|
# already correct. Printed for proxy/tunnel modes so the operator's edge routes
|
||||||
|
# to 127.0.0.1:APP_PORT (and, if voice is on, /livekit → the host LiveKit).
|
||||||
|
|
||||||
|
hr() { echo -e "${CYAN}────────────────────────────────────────────────────────────${NC}"; }
|
||||||
|
snip() { echo -e "${BOLD}$*${NC}"; }
|
||||||
|
|
||||||
|
# Body-size cap in the proxy should match the app's MAX_UPLOAD_SIZE. Express it in
|
||||||
|
# MB for the human-facing proxy directives (round up so the proxy never rejects a
|
||||||
|
# body the app would accept).
|
||||||
|
max_mb=$(( (MAX_UPLOAD_SIZE + 1048575) / 1048576 ))
|
||||||
|
|
||||||
|
print_nginx_snippet() {
|
||||||
|
snip "nginx — add the map once inside http { }, then a server block:"
|
||||||
|
hr
|
||||||
|
cat << EOF
|
||||||
|
# --- inside http { } (once) --------------------------------
|
||||||
|
map \$http_upgrade \$connection_upgrade {
|
||||||
|
default upgrade;
|
||||||
|
'' close;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name ${DOMAIN};
|
||||||
|
|
||||||
|
# Your TLS certs (certbot, your proxy manager, etc.):
|
||||||
|
# ssl_certificate /etc/letsencrypt/live/${DOMAIN}/fullchain.pem;
|
||||||
|
# ssl_certificate_key /etc/letsencrypt/live/${DOMAIN}/privkey.pem;
|
||||||
|
|
||||||
|
client_max_body_size ${max_mb}m; # match MAX_UPLOAD_SIZE (${MAX_UPLOAD_SIZE} bytes)
|
||||||
|
EOF
|
||||||
|
if [[ "$ENABLE_VOICE" == true ]]; then
|
||||||
|
cat << EOF
|
||||||
|
|
||||||
|
# Voice signaling → host-networked LiveKit (strips the /livekit prefix):
|
||||||
|
location /livekit/ {
|
||||||
|
proxy_pass http://127.0.0.1:7880/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host \$host;
|
||||||
|
proxy_set_header Upgrade \$http_upgrade;
|
||||||
|
proxy_set_header Connection \$connection_upgrade;
|
||||||
|
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
cat << EOF
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:${APP_PORT_FINAL};
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host \$host;
|
||||||
|
proxy_set_header X-Real-IP \$remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||||
|
proxy_set_header X-Forwarded-Host \$host;
|
||||||
|
# WebSocket upgrade (chat, live events, voice signaling):
|
||||||
|
proxy_set_header Upgrade \$http_upgrade;
|
||||||
|
proxy_set_header Connection \$connection_upgrade;
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
hr
|
||||||
|
}
|
||||||
|
|
||||||
|
print_caddy_snippet() {
|
||||||
|
snip "Caddy — if you run your OWN Caddy (it auto-handles WebSocket + HTTPS):"
|
||||||
|
hr
|
||||||
|
cat << EOF
|
||||||
|
${DOMAIN} {
|
||||||
|
EOF
|
||||||
|
if [[ "$ENABLE_VOICE" == true ]]; then
|
||||||
|
cat << EOF
|
||||||
|
handle_path /livekit/* {
|
||||||
|
reverse_proxy 127.0.0.1:7880
|
||||||
|
}
|
||||||
|
handle {
|
||||||
|
reverse_proxy 127.0.0.1:${APP_PORT_FINAL}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
else
|
||||||
|
cat << EOF
|
||||||
|
reverse_proxy 127.0.0.1:${APP_PORT_FINAL}
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
cat << EOF
|
||||||
|
request_body {
|
||||||
|
max_size ${max_mb}MB
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
hr
|
||||||
|
}
|
||||||
|
|
||||||
|
print_traefik_snippet() {
|
||||||
|
snip "Traefik — dynamic (file-provider) config; Traefik handles WebSocket itself:"
|
||||||
|
hr
|
||||||
|
cat << EOF
|
||||||
|
http:
|
||||||
|
routers:
|
||||||
|
backspace:
|
||||||
|
rule: "Host(\`${DOMAIN}\`)"
|
||||||
|
entryPoints: [websecure]
|
||||||
|
service: backspace
|
||||||
|
tls:
|
||||||
|
certResolver: letsencrypt
|
||||||
|
EOF
|
||||||
|
if [[ "$ENABLE_VOICE" == true ]]; then
|
||||||
|
cat << EOF
|
||||||
|
backspace-livekit:
|
||||||
|
rule: "Host(\`${DOMAIN}\`) && PathPrefix(\`/livekit\`)"
|
||||||
|
entryPoints: [websecure]
|
||||||
|
service: backspace-livekit
|
||||||
|
priority: 100
|
||||||
|
middlewares: [strip-livekit]
|
||||||
|
tls:
|
||||||
|
certResolver: letsencrypt
|
||||||
|
middlewares:
|
||||||
|
strip-livekit:
|
||||||
|
stripPrefix:
|
||||||
|
prefixes: ["/livekit"]
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
cat << EOF
|
||||||
|
services:
|
||||||
|
backspace:
|
||||||
|
loadBalancer:
|
||||||
|
servers:
|
||||||
|
- url: "http://127.0.0.1:${APP_PORT_FINAL}"
|
||||||
|
EOF
|
||||||
|
if [[ "$ENABLE_VOICE" == true ]]; then
|
||||||
|
cat << EOF
|
||||||
|
backspace-livekit:
|
||||||
|
loadBalancer:
|
||||||
|
servers:
|
||||||
|
- url: "http://127.0.0.1:7880"
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
hr
|
||||||
|
}
|
||||||
|
|
||||||
|
print_tunnel_snippet() {
|
||||||
|
snip "Cloudflare Tunnel — ingress rule (cloudflared config.yml):"
|
||||||
|
hr
|
||||||
|
cat << EOF
|
||||||
|
tunnel: <YOUR-TUNNEL-ID>
|
||||||
|
credentials-file: /root/.cloudflared/<YOUR-TUNNEL-ID>.json
|
||||||
|
|
||||||
|
ingress:
|
||||||
|
- hostname: ${DOMAIN}
|
||||||
|
service: http://127.0.0.1:${APP_PORT_FINAL}
|
||||||
|
- service: http_status:404
|
||||||
|
EOF
|
||||||
|
hr
|
||||||
|
echo " Then map the hostname to the tunnel (once):"
|
||||||
|
echo -e " ${BOLD}cloudflared tunnel route dns <YOUR-TUNNEL-ID> ${DOMAIN}${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
# ── Phase 8: Summary ───────────────────────────────────────
|
# ── Phase 8: Summary ───────────────────────────────────────
|
||||||
|
|
||||||
@@ -469,13 +922,15 @@ step "Backspace is running"
|
|||||||
|
|
||||||
echo -e " ${BOLD}URL:${NC} https://${DOMAIN}"
|
echo -e " ${BOLD}URL:${NC} https://${DOMAIN}"
|
||||||
echo -e " ${BOLD}Instance:${NC} ${INSTANCE_NAME}"
|
echo -e " ${BOLD}Instance:${NC} ${INSTANCE_NAME}"
|
||||||
|
echo -e " ${BOLD}Mode:${NC} ${DEPLOY_MODE}"
|
||||||
echo -e " ${BOLD}Voice:${NC} $(if [[ "$ENABLE_VOICE" == true ]]; then echo 'Enabled'; else echo 'Disabled'; fi)"
|
echo -e " ${BOLD}Voice:${NC} $(if [[ "$ENABLE_VOICE" == true ]]; then echo 'Enabled'; else echo 'Disabled'; fi)"
|
||||||
|
|
||||||
|
if [[ "$DEPLOY_MODE" == "allinone" ]]; then
|
||||||
case "$https_status" in
|
case "$https_status" in
|
||||||
live) echo -e " ${BOLD}HTTPS:${NC} ${GREEN}Live${NC}" ;;
|
live) echo -e " ${BOLD}HTTPS:${NC} ${GREEN}Live${NC}" ;;
|
||||||
pending) echo -e " ${BOLD}HTTPS:${NC} ${YELLOW}Not live yet${NC}" ;;
|
pending) echo -e " ${BOLD}HTTPS:${NC} ${YELLOW}Not live yet${NC}" ;;
|
||||||
esac
|
esac
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
if [[ "$https_status" == "pending" ]]; then
|
if [[ "$https_status" == "pending" ]]; then
|
||||||
echo -e " ${YELLOW}The app is up, but HTTPS isn't live yet — Caddy is still trying to get a certificate.${NC}"
|
echo -e " ${YELLOW}The app is up, but HTTPS isn't live yet — Caddy is still trying to get a certificate.${NC}"
|
||||||
echo -e " ${YELLOW}This is normal right after install; it comes up automatically once BOTH are true:${NC}"
|
echo -e " ${YELLOW}This is normal right after install; it comes up automatically once BOTH are true:${NC}"
|
||||||
@@ -487,12 +942,6 @@ if [[ "$https_status" == "pending" ]]; then
|
|||||||
else
|
else
|
||||||
echo -e " ${YELLOW}Open https://${DOMAIN} and create the first account — it becomes the instance admin.${NC}"
|
echo -e " ${YELLOW}Open https://${DOMAIN} and create the first account — it becomes the instance admin.${NC}"
|
||||||
fi
|
fi
|
||||||
echo ""
|
|
||||||
echo -e " ${BOLD}Commands:${NC}"
|
|
||||||
echo " docker compose logs -f # Watch logs"
|
|
||||||
echo " docker compose restart # Restart all services"
|
|
||||||
echo " docker compose down # Stop everything"
|
|
||||||
echo " docker compose up -d --build # Rebuild after code changes"
|
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
# Best-effort primary LAN IP (the address a router would port-forward to).
|
# Best-effort primary LAN IP (the address a router would port-forward to).
|
||||||
@@ -515,4 +964,51 @@ if [[ "$ENABLE_VOICE" == true ]]; then
|
|||||||
fi
|
fi
|
||||||
echo -e " LiveKit's own signaling port (7880) stays internal — do ${BOLD}not${NC} forward it."
|
echo -e " LiveKit's own signaling port (7880) stays internal — do ${BOLD}not${NC} forward it."
|
||||||
|
|
||||||
|
elif [[ "$DEPLOY_MODE" == "proxy" ]]; then
|
||||||
|
echo ""
|
||||||
|
echo -e " Backspace listens on ${BOLD}127.0.0.1:${APP_PORT_FINAL}${NC} (loopback only — never expose it directly)."
|
||||||
|
echo -e " Point your reverse proxy at it, then open ${BOLD}https://${DOMAIN}${NC} and register the first account (it becomes admin)."
|
||||||
|
echo ""
|
||||||
|
echo -e " ${BOLD}Paste one of these into your reverse proxy${NC} (WebSocket, X-Forwarded-*, and body cap already set):"
|
||||||
|
echo ""
|
||||||
|
print_nginx_snippet
|
||||||
|
echo ""
|
||||||
|
print_caddy_snippet
|
||||||
|
echo ""
|
||||||
|
print_traefik_snippet
|
||||||
|
echo ""
|
||||||
|
echo -e " ${BOLD}Nginx Proxy Manager / other GUI proxies:${NC} see the field-by-field guide"
|
||||||
|
echo -e " in the README → 'Deployment modes' (forward to 127.0.0.1:${APP_PORT_FINAL}, enable"
|
||||||
|
echo -e " 'Websockets Support', and set client_max_body_size ${max_mb}m in the Advanced tab)."
|
||||||
|
if [[ "$ENABLE_VOICE" == true ]]; then
|
||||||
|
echo ""
|
||||||
|
echo -e " ${YELLOW}Voice is enabled — in addition to the /livekit route above, open these UDP/TCP${NC}"
|
||||||
|
echo -e " ${YELLOW}media ports on the firewall and forward them to this host:${NC}"
|
||||||
|
echo " 3478/UDP TURN — WebRTC NAT traversal"
|
||||||
|
echo " 7881/TCP WebRTC TCP fallback"
|
||||||
|
echo " 50000-60000/UDP WebRTC media — voice / video / screen-share"
|
||||||
|
echo -e " ${YELLOW}Media flows host→client directly, NOT through your reverse proxy.${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
elif [[ "$DEPLOY_MODE" == "tunnel" ]]; then
|
||||||
|
echo ""
|
||||||
|
echo -e " Backspace listens on ${BOLD}127.0.0.1:${APP_PORT_FINAL}${NC} (loopback only). Your tunnel fronts it."
|
||||||
|
echo -e " Once the ingress rule is live, open ${BOLD}https://${DOMAIN}${NC} and register the first account (it becomes admin)."
|
||||||
|
echo ""
|
||||||
|
print_tunnel_snippet
|
||||||
|
echo ""
|
||||||
|
echo -e " ${YELLOW}Upload cap:${NC} MAX_UPLOAD_SIZE is set to ${max_mb}MB to stay under Cloudflare's 100MB"
|
||||||
|
echo -e " request-body limit. Raising it above ~100MB will make large uploads fail at the edge."
|
||||||
|
echo ""
|
||||||
|
echo -e " ${YELLOW}Voice/video is not available over a tunnel${NC} — WebRTC/UDP media can't traverse it."
|
||||||
|
echo -e " If you need voice, run Backspace behind a reverse proxy (proxy mode) with the"
|
||||||
|
echo -e " media ports opened, or use All-in-One with ports 80/443."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e " ${BOLD}Commands${NC} (run from this directory):"
|
||||||
|
echo " docker compose logs -f # Watch logs"
|
||||||
|
echo " docker compose restart # Restart all services"
|
||||||
|
echo " docker compose down # Stop everything"
|
||||||
|
echo " docker compose pull && docker compose up -d # Update to the latest prebuilt image"
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "backspace",
|
"name": "backspace",
|
||||||
"version": "1.0.0",
|
"version": "2.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Open, self-hosted communication platform — text, voice, video, and federation",
|
"description": "Open, self-hosted communication platform \u2014 text, voice, video, and federation",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"author": "Jannis Braun",
|
"author": "Jannis Braun",
|
||||||
"homepage": "https://github.com/TheZwiss/backspace",
|
"homepage": "https://github.com/TheZwiss/backspace",
|
||||||
@@ -13,11 +13,13 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev:server": "pnpm --filter @backspace/server dev",
|
"dev:server": "pnpm --filter @backspace/server dev",
|
||||||
"dev:web": "pnpm --filter @backspace/web dev",
|
"dev:web": "pnpm --filter @backspace/web dev",
|
||||||
"dev": "pnpm --filter @backspace/server dev & pnpm --filter @backspace/web dev",
|
"dev": "pnpm --parallel --filter @backspace/server --filter @backspace/web dev",
|
||||||
"build:shared": "pnpm --filter @backspace/shared build",
|
"build:shared": "pnpm --filter @backspace/shared build",
|
||||||
"build:server": "pnpm --filter @backspace/server build",
|
"build:server": "pnpm --filter @backspace/server build",
|
||||||
"build:web": "pnpm --filter @backspace/web build",
|
"build:web": "pnpm --filter @backspace/web build",
|
||||||
"build": "pnpm --filter @backspace/shared build && pnpm --filter @backspace/server build && pnpm --filter @backspace/web build",
|
"build": "pnpm --filter @backspace/shared build && pnpm --filter @backspace/server build && pnpm --filter @backspace/web build",
|
||||||
|
"typecheck": "pnpm --filter @backspace/shared build && pnpm -r typecheck",
|
||||||
|
"test": "pnpm -r test",
|
||||||
"dev:desktop": "pnpm --filter @backspace/desktop dev",
|
"dev:desktop": "pnpm --filter @backspace/desktop dev",
|
||||||
"build:desktop": "pnpm --filter @backspace/desktop build",
|
"build:desktop": "pnpm --filter @backspace/desktop build",
|
||||||
"gen-icons": "node scripts/gen-icons.mjs"
|
"gen-icons": "node scripts/gen-icons.mjs"
|
||||||
@@ -30,16 +32,18 @@
|
|||||||
"pnpm": {
|
"pnpm": {
|
||||||
"onlyBuiltDependencies": [
|
"onlyBuiltDependencies": [
|
||||||
"better-sqlite3",
|
"better-sqlite3",
|
||||||
"esbuild",
|
|
||||||
"electron",
|
"electron",
|
||||||
|
"electron-native-screenshare",
|
||||||
|
"esbuild",
|
||||||
"sharp"
|
"sharp"
|
||||||
],
|
],
|
||||||
"patchedDependencies": {
|
"patchedDependencies": {
|
||||||
"uiohook-napi@1.5.5": "patches/uiohook-napi@1.5.5.patch"
|
"uiohook-napi@1.5.5": "patches/uiohook-napi@1.5.5.patch"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"packageManager": "pnpm@10.34.3",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0",
|
"node": ">=20.0.0",
|
||||||
"pnpm": ">=8.0.0"
|
"pnpm": ">=10.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 364 KiB After Width: | Height: | Size: 364 KiB |
|
Before Width: | Height: | Size: 368 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 955 B After Width: | Height: | Size: 712 B |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 368 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 8.8 KiB After Width: | Height: | Size: 4.4 KiB |
@@ -1,5 +1,5 @@
|
|||||||
appId: com.backspace.desktop
|
appId: com.backspace.desktop
|
||||||
productName: Backspace
|
productName: Resenhacord
|
||||||
artifactName: "${productName}-${version}-${arch}.${ext}"
|
artifactName: "${productName}-${version}-${arch}.${ext}"
|
||||||
directories:
|
directories:
|
||||||
output: dist-electron
|
output: dist-electron
|
||||||
@@ -14,16 +14,29 @@ files:
|
|||||||
- "!**/node_modules/uiohook-napi/build/**"
|
- "!**/node_modules/uiohook-napi/build/**"
|
||||||
- "!**/node_modules/uiohook-napi/build.bak/**"
|
- "!**/node_modules/uiohook-napi/build.bak/**"
|
||||||
- "!**/node_modules/uiohook-napi/bin/**"
|
- "!**/node_modules/uiohook-napi/bin/**"
|
||||||
|
# electron-native-screenshare has no prebuilds — build/Release/*.node is the
|
||||||
|
# only copy, so it must NOT be excluded the way uiohook-napi's is. asarUnpack
|
||||||
|
# below takes it out of the archive so the loader can find it.
|
||||||
asarUnpack:
|
asarUnpack:
|
||||||
- "**/*.node"
|
- "**/*.node"
|
||||||
npmRebuild: false
|
npmRebuild: false
|
||||||
afterPack: ./scripts/afterPack.js
|
afterPack: ./scripts/afterPack.js
|
||||||
publish:
|
publish:
|
||||||
- provider: github
|
# Updates are served from this fork's own Gitea, not from GitHub.
|
||||||
owner: TheZwiss
|
#
|
||||||
repo: backspace
|
# GitHub is only the build machine — it has the Windows runners the native
|
||||||
|
# audio module needs. Its repository is private, and electron-updater against
|
||||||
|
# a private GitHub repo would need a token shipped inside the app, which is a
|
||||||
|
# leaked token. Gitea serves release assets to anyone, so no credential ends
|
||||||
|
# up in the installer.
|
||||||
|
#
|
||||||
|
# The tag is fixed at `latest` on purpose: electron-updater fetches
|
||||||
|
# latest.yml before it knows which version exists, so the URL cannot contain
|
||||||
|
# a version. CI replaces that release's assets on every publish.
|
||||||
|
- provider: generic
|
||||||
|
url: https://git.resenha.website/devsyncwrld/backspace/releases/download/latest/
|
||||||
protocols:
|
protocols:
|
||||||
- name: Backspace
|
- name: Resenhacord
|
||||||
schemes:
|
schemes:
|
||||||
- backspace
|
- backspace
|
||||||
mac:
|
mac:
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"name": "@backspace/desktop",
|
"name": "@backspace/desktop",
|
||||||
"version": "1.0.0",
|
"version": "2.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"description": "Backspace",
|
"description": "Resenhacord",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Jannis Braun",
|
"name": "Jannis Braun",
|
||||||
"email": "backspace@backspace.chat"
|
"email": "backspace@backspace.chat"
|
||||||
@@ -18,9 +18,10 @@
|
|||||||
"clean": "rm -rf dist dist-electron",
|
"clean": "rm -rf dist dist-electron",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"postinstall": "electron-rebuild -f -w uiohook-napi"
|
"postinstall": "electron-rebuild -f -w uiohook-napi,electron-native-screenshare || node -e \"console.warn('[desktop] uiohook-napi native rebuild skipped - needs build tools (make, g++, python3). Only required to RUN the desktop app; the server, web client, and Docker image are unaffected.')\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"electron-native-screenshare": "^1.2.0",
|
||||||
"electron-updater": "^6.3.0",
|
"electron-updater": "^6.3.0",
|
||||||
"uiohook-napi": "^1.5.5"
|
"uiohook-napi": "^1.5.5"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Backspace — Connect</title>
|
<title>Resenhacord — Conectar</title>
|
||||||
<style>
|
<style>
|
||||||
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&display=swap');
|
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&display=swap');
|
||||||
|
|
||||||
@@ -270,7 +270,7 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<h1>Welcome to Backspace</h1>
|
<h1>Bem-vindo ao Resenhacord</h1>
|
||||||
<p>Connect to your instance</p>
|
<p>Connect to your instance</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -360,7 +360,7 @@
|
|||||||
// Update header copy to reflect the "switch" intent rather than the
|
// Update header copy to reflect the "switch" intent rather than the
|
||||||
// first-run "welcome" framing.
|
// first-run "welcome" framing.
|
||||||
document.querySelector('.header h1').textContent = 'Switch instance';
|
document.querySelector('.header h1').textContent = 'Switch instance';
|
||||||
document.querySelector('.header p').textContent = 'Connect to a different Backspace instance, or cancel to stay.';
|
document.querySelector('.header p').textContent = 'Conecte-se a outra instância, ou cancele para ficar nesta.';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
init();
|
init();
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<title>Backspace — Recovery</title>
|
<title>Resenhacord — Recuperação</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<style>
|
<style>
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
@@ -128,7 +128,7 @@
|
|||||||
<div class="content">
|
<div class="content">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h1>Backspace couldn't load.</h1>
|
<h1>O Resenhacord não conseguiu abrir.</h1>
|
||||||
<p class="hint" id="hint">Loading…</p>
|
<p class="hint" id="hint">Loading…</p>
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
@@ -147,7 +147,7 @@
|
|||||||
<pre id="diagnostic">loading…</pre>
|
<pre id="diagnostic">loading…</pre>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<button class="action subtle" id="btn-quit">Quit Backspace</button>
|
<button class="action subtle" id="btn-quit">Fechar o Resenhacord</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -270,7 +270,7 @@
|
|||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
if (!window.backspace) {
|
if (!window.backspace) {
|
||||||
$('hint').textContent = 'Recovery bridge unavailable. Please quit and reinstall Backspace.';
|
$('hint').textContent = 'A ponte de recuperação não respondeu. Feche e reinstale o Resenhacord.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 983 B After Width: | Height: | Size: 746 B |
|
Before Width: | Height: | Size: 703 B After Width: | Height: | Size: 474 B |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 836 B |
@@ -40,26 +40,33 @@ import {
|
|||||||
import { migrateUserData } from './userDataMigration';
|
import { migrateUserData } from './userDataMigration';
|
||||||
|
|
||||||
// Override Electron's package.json-derived app name so userData lives at
|
// Override Electron's package.json-derived app name so userData lives at
|
||||||
// "<appData>/Backspace" instead of leaking the monorepo's "@backspace/desktop"
|
// "<appData>/Resenhacord" instead of leaking the monorepo's "@backspace/desktop"
|
||||||
// package name. Must run before any app.getPath('userData') consumer.
|
// package name. Must run before any app.getPath('userData') consumer.
|
||||||
app.setName('Backspace');
|
app.setName('Resenhacord');
|
||||||
|
|
||||||
// One-time migration from the historical scoped path. After the move the old
|
// One-time migrations, oldest first, so an install that skipped a release
|
||||||
// folder is gone, so subsequent launches hit the old-missing no-op branch.
|
// still walks the whole chain: "@backspace/desktop" → "Backspace" →
|
||||||
|
// "Resenhacord". Each step is a no-op once its source folder is gone, and a
|
||||||
|
// step whose target already has data leaves it alone (`new-populated`), so a
|
||||||
|
// user who already ran the renamed build is never overwritten by stale files.
|
||||||
|
// Without the second step the rename would have logged everyone out: the
|
||||||
|
// instance URL and session live under userData.
|
||||||
{
|
{
|
||||||
const appDataDir = app.getPath('appData');
|
const appDataDir = app.getPath('appData');
|
||||||
const oldParent = path.join(appDataDir, '@backspace');
|
const legacyParent = path.join(appDataDir, '@backspace');
|
||||||
const result = migrateUserData({
|
const steps: { oldDir: string; newDir: string; oldParent: string }[] = [
|
||||||
oldDir: path.join(oldParent, 'desktop'),
|
{ oldDir: path.join(legacyParent, 'desktop'), newDir: path.join(appDataDir, 'Backspace'), oldParent: legacyParent },
|
||||||
newDir: path.join(appDataDir, 'Backspace'),
|
{ oldDir: path.join(appDataDir, 'Backspace'), newDir: path.join(appDataDir, 'Resenhacord'), oldParent: appDataDir },
|
||||||
oldParent,
|
];
|
||||||
});
|
for (const step of steps) {
|
||||||
|
const result = migrateUserData(step);
|
||||||
if (result.kind === 'migrated') {
|
if (result.kind === 'migrated') {
|
||||||
console.log(`[userData] migrated ${result.from} → ${result.to}`);
|
console.log(`[userData] migrated ${result.from} → ${result.to}`);
|
||||||
} else if (result.kind === 'failed') {
|
} else if (result.kind === 'failed') {
|
||||||
console.error('[userData] migration failed:', result.error);
|
console.error('[userData] migration failed:', result.error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let mainWindow: BrowserWindow | null = null;
|
let mainWindow: BrowserWindow | null = null;
|
||||||
const keybindManager = new KeybindManager();
|
const keybindManager = new KeybindManager();
|
||||||
@@ -241,7 +248,7 @@ function applyLoginItemSettings(openAtLogin: boolean, startMinimized: boolean):
|
|||||||
enabled: openAtLogin,
|
enabled: openAtLogin,
|
||||||
path: process.execPath,
|
path: process.execPath,
|
||||||
args: startMinimized ? ['--hidden'] : [],
|
args: startMinimized ? ['--hidden'] : [],
|
||||||
name: 'Backspace',
|
name: 'Resenhacord',
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Linux: setLoginItemSettings creates ~/.config/autostart/<name>.desktop.
|
// Linux: setLoginItemSettings creates ~/.config/autostart/<name>.desktop.
|
||||||
@@ -265,6 +272,102 @@ function applyLoginItemSettings(openAtLogin: boolean, startMinimized: boolean):
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Native system-audio capture ────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Electron's own `audio: 'loopback'` captures the whole output mix, which
|
||||||
|
// includes this app playing everyone else's voices — so those voices went back
|
||||||
|
// out inside the screen share and every listener heard themselves. Not acoustic
|
||||||
|
// echo: it is a digital copy of the output, so headphones never helped.
|
||||||
|
//
|
||||||
|
// This module captures with process-level isolation instead: only the shared
|
||||||
|
// window (include mode), or everything except this app (exclude mode).
|
||||||
|
interface NativeAudioMeta {
|
||||||
|
sampleRate: number;
|
||||||
|
channels: number;
|
||||||
|
bitsPerSample: number;
|
||||||
|
isFloat: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NativeScreenShareAudio {
|
||||||
|
startCapture(processId?: number, isIncludeMode?: boolean, onData?: (data: Buffer, meta: NativeAudioMeta) => void): boolean;
|
||||||
|
stopCapture(): boolean;
|
||||||
|
getPidFromWindowHandle(windowHandle: number): number;
|
||||||
|
isAvailable(): boolean;
|
||||||
|
getLoadError(): string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nativeAudio: NativeScreenShareAudio | null = null;
|
||||||
|
try {
|
||||||
|
// Required lazily and defensively: a native module that fails to load must
|
||||||
|
// degrade to sharing without audio, never stop the app from starting.
|
||||||
|
nativeAudio = require('electron-native-screenshare') as NativeScreenShareAudio;
|
||||||
|
if (!nativeAudio.isAvailable()) {
|
||||||
|
console.warn('[Main:ScreenShare] Native audio unavailable:', nativeAudio.getLoadError());
|
||||||
|
nativeAudio = null;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[Main:ScreenShare] Native audio module missing:', err);
|
||||||
|
nativeAudio = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nativeAudioActive = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Windows and Linux hand desktopCapturer ids of the form `window:<handle>:<n>`.
|
||||||
|
* Recovering the handle lets us capture only that window's audio, which is
|
||||||
|
* better than excluding ourselves: a game's sound goes out, the rest of the
|
||||||
|
* desktop does not.
|
||||||
|
*/
|
||||||
|
function windowHandleFromSourceId(sourceId: string): number | null {
|
||||||
|
const match = /^window:(\d+)/.exec(sourceId);
|
||||||
|
if (!match) return null;
|
||||||
|
const handle = Number(match[1]);
|
||||||
|
return Number.isFinite(handle) && handle > 0 ? handle : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startNativeAudioCapture(sourceId: string): boolean {
|
||||||
|
if (!nativeAudio || nativeAudioActive) return false;
|
||||||
|
|
||||||
|
let targetPid = process.pid;
|
||||||
|
let includeMode = false;
|
||||||
|
|
||||||
|
const handle = windowHandleFromSourceId(sourceId);
|
||||||
|
if (handle !== null) {
|
||||||
|
const pid = nativeAudio.getPidFromWindowHandle(handle);
|
||||||
|
// pid 0 means the handle did not resolve; fall back to excluding ourselves
|
||||||
|
// rather than capturing nothing.
|
||||||
|
if (pid > 0) {
|
||||||
|
targetPid = pid;
|
||||||
|
includeMode = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const started = nativeAudio.startCapture(targetPid, includeMode, (data, meta) => {
|
||||||
|
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||||
|
mainWindow.webContents.send('native-audio-data', data, meta);
|
||||||
|
});
|
||||||
|
nativeAudioActive = started;
|
||||||
|
console.log('[Main:ScreenShare] Native audio', started ? 'started' : 'failed',
|
||||||
|
includeMode ? `(only pid ${targetPid})` : '(excluding self)');
|
||||||
|
return started;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Main:ScreenShare] Native audio start failed:', err);
|
||||||
|
nativeAudioActive = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopNativeAudioCapture(): void {
|
||||||
|
if (!nativeAudio || !nativeAudioActive) return;
|
||||||
|
try {
|
||||||
|
nativeAudio.stopCapture();
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[Main:ScreenShare] Native audio stop failed:', err);
|
||||||
|
}
|
||||||
|
nativeAudioActive = false;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Tray Icon ──────────────────────────────────────────────────────────────
|
// ─── Tray Icon ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function generateFallbackTrayIcon(): Electron.NativeImage {
|
function generateFallbackTrayIcon(): Electron.NativeImage {
|
||||||
@@ -341,7 +444,7 @@ function createWindow(): void {
|
|||||||
: {}),
|
: {}),
|
||||||
minWidth: 940,
|
minWidth: 940,
|
||||||
minHeight: 500,
|
minHeight: 500,
|
||||||
title: 'Backspace',
|
title: 'Resenhacord',
|
||||||
icon: path.join(__dirname, '..', 'build', 'icon.png'),
|
icon: path.join(__dirname, '..', 'build', 'icon.png'),
|
||||||
titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'hidden',
|
titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'hidden',
|
||||||
...(process.platform !== 'darwin' ? {
|
...(process.platform !== 'darwin' ? {
|
||||||
@@ -474,7 +577,7 @@ function createTray(): void {
|
|||||||
const icon = loadTrayIcon();
|
const icon = loadTrayIcon();
|
||||||
tray = new Tray(icon);
|
tray = new Tray(icon);
|
||||||
|
|
||||||
tray.setToolTip('Backspace');
|
tray.setToolTip('Resenhacord');
|
||||||
// Context menu is set by the recoveryStore subscriber in app.whenReady,
|
// Context menu is set by the recoveryStore subscriber in app.whenReady,
|
||||||
// which keeps the menu in sync with update/recovery state.
|
// which keeps the menu in sync with update/recovery state.
|
||||||
|
|
||||||
@@ -492,7 +595,9 @@ function createTray(): void {
|
|||||||
|
|
||||||
function showNotification(title: string, body: string, onClick?: () => void): void {
|
function showNotification(title: string, body: string, onClick?: () => void): void {
|
||||||
if (!Notification.isSupported()) return;
|
if (!Notification.isSupported()) return;
|
||||||
const notification = new Notification({ title, body, silent: false });
|
// silent: o som do sistema é o que mais incomoda numa notificação de chat, e
|
||||||
|
// o app toca o seu próprio efeito — que combina com os demais sons dele.
|
||||||
|
const notification = new Notification({ title, body, silent: true });
|
||||||
notification.on('click', onClick ?? (() => {
|
notification.on('click', onClick ?? (() => {
|
||||||
mainWindow?.show();
|
mainWindow?.show();
|
||||||
mainWindow?.focus();
|
mainWindow?.focus();
|
||||||
@@ -594,6 +699,10 @@ function registerIpcHandlers(): void {
|
|||||||
ipcMain.handle('get-app-version', () => app.getVersion());
|
ipcMain.handle('get-app-version', () => app.getVersion());
|
||||||
|
|
||||||
// Screen share picker coordination (used by setDisplayMediaRequestHandler)
|
// Screen share picker coordination (used by setDisplayMediaRequestHandler)
|
||||||
|
ipcMain.on('native-audio-stop', () => {
|
||||||
|
stopNativeAudioCapture();
|
||||||
|
});
|
||||||
|
|
||||||
ipcMain.on('screen-share-selected', (_event, _sourceId: string | null, _shareAudio?: boolean) => {
|
ipcMain.on('screen-share-selected', (_event, _sourceId: string | null, _shareAudio?: boolean) => {
|
||||||
// Handled via ipcMain.once in the display media handler — this is just
|
// Handled via ipcMain.once in the display media handler — this is just
|
||||||
// a safety net to prevent unhandled-message warnings
|
// a safety net to prevent unhandled-message warnings
|
||||||
@@ -608,7 +717,7 @@ function registerIpcHandlers(): void {
|
|||||||
// executableWillLaunchAtLogin to honour Task Manager's StartupApproved state.
|
// executableWillLaunchAtLogin to honour Task Manager's StartupApproved state.
|
||||||
const osState = app.getLoginItemSettings({ path: process.execPath });
|
const osState = app.getLoginItemSettings({ path: process.execPath });
|
||||||
const ownEntry = osState.launchItems?.find(
|
const ownEntry = osState.launchItems?.find(
|
||||||
(item) => item.name === 'Backspace' || item.path?.toLowerCase() === process.execPath.toLowerCase(),
|
(item) => item.name === 'Resenhacord' || item.name === 'Backspace' || item.path?.toLowerCase() === process.execPath.toLowerCase(),
|
||||||
);
|
);
|
||||||
// When the Run entry exists, its args are the source of truth for startMinimized.
|
// When the Run entry exists, its args are the source of truth for startMinimized.
|
||||||
// When the entry is absent (autostart is off), there is no OS state to read, so we
|
// When the entry is absent (autostart is off), there is no OS state to read, so we
|
||||||
@@ -643,7 +752,7 @@ function registerIpcHandlers(): void {
|
|||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') {
|
||||||
const osState = app.getLoginItemSettings({ path: process.execPath });
|
const osState = app.getLoginItemSettings({ path: process.execPath });
|
||||||
const ownEntry = osState.launchItems?.find(
|
const ownEntry = osState.launchItems?.find(
|
||||||
(item) => item.name === 'Backspace' || item.path?.toLowerCase() === process.execPath.toLowerCase(),
|
(item) => item.name === 'Resenhacord' || item.name === 'Backspace' || item.path?.toLowerCase() === process.execPath.toLowerCase(),
|
||||||
);
|
);
|
||||||
currentOpenAtLogin = osState.executableWillLaunchAtLogin ?? false;
|
currentOpenAtLogin = osState.executableWillLaunchAtLogin ?? false;
|
||||||
// See `get-auto-launch-settings`: when the entry is absent, fall back to disk
|
// See `get-auto-launch-settings`: when the entry is absent, fall back to disk
|
||||||
@@ -712,7 +821,14 @@ function initAutoUpdater(): void {
|
|||||||
|
|
||||||
let updateConfirmed = false;
|
let updateConfirmed = false;
|
||||||
|
|
||||||
|
// Cadence state. `lastCheckAt` is stamped from the event rather than from
|
||||||
|
// the callers, so a manual "check for updates" from the settings panel
|
||||||
|
// counts too and a focus arriving right after it doesn't check twice.
|
||||||
|
let lastCheckAt = 0;
|
||||||
|
let downloaded = false;
|
||||||
|
|
||||||
autoUpdater.on('checking-for-update', () => {
|
autoUpdater.on('checking-for-update', () => {
|
||||||
|
lastCheckAt = Date.now();
|
||||||
recoveryStore.update({ updateState: 'checking', lastCheckResult: null });
|
recoveryStore.update({ updateState: 'checking', lastCheckResult: null });
|
||||||
updateConfirmed = false;
|
updateConfirmed = false;
|
||||||
});
|
});
|
||||||
@@ -734,6 +850,7 @@ function initAutoUpdater(): void {
|
|||||||
});
|
});
|
||||||
|
|
||||||
autoUpdater.on('update-downloaded', (info: { version: string }) => {
|
autoUpdater.on('update-downloaded', (info: { version: string }) => {
|
||||||
|
downloaded = true;
|
||||||
const version = info.version.slice(0, 32);
|
const version = info.version.slice(0, 32);
|
||||||
recoveryStore.update({ updateState: 'downloaded', updateVersion: version });
|
recoveryStore.update({ updateState: 'downloaded', updateVersion: version });
|
||||||
mainWindow?.webContents.send('update-downloaded', { version });
|
mainWindow?.webContents.send('update-downloaded', { version });
|
||||||
@@ -743,7 +860,7 @@ function initAutoUpdater(): void {
|
|||||||
// (recovery mode) is visible — no need to also fire a native toast.
|
// (recovery mode) is visible — no need to also fire a native toast.
|
||||||
if (!mainWindow?.isFocused()) {
|
if (!mainWindow?.isFocused()) {
|
||||||
showNotification(
|
showNotification(
|
||||||
'Backspace update ready',
|
'Resenhacord update ready',
|
||||||
`Click to restart and install version ${version}.`,
|
`Click to restart and install version ${version}.`,
|
||||||
() => autoUpdater.quitAndInstall(),
|
() => autoUpdater.quitAndInstall(),
|
||||||
);
|
);
|
||||||
@@ -773,17 +890,28 @@ function initAutoUpdater(): void {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initial check with 10s delay (existing behavior)
|
// A release published while the app sits open used to go unnoticed until
|
||||||
setTimeout(() => {
|
// the next poll — the startup check had long since run, and four hours is
|
||||||
updateConfirmed = false;
|
// a long time to look at a stale version. Poll more often, and also check
|
||||||
autoUpdater.checkForUpdates().catch(() => {});
|
// when the user comes back to the window, which is exactly when they would
|
||||||
}, 10_000);
|
// notice an update anyway.
|
||||||
|
const POLL_INTERVAL_MS = 30 * 60 * 1000;
|
||||||
|
const MIN_GAP_MS = 5 * 60 * 1000; // keeps alt-tabbing off the feed
|
||||||
|
|
||||||
// Periodic check every 4 hours (existing behavior)
|
const maybeCheck = (): void => {
|
||||||
setInterval(() => {
|
if (downloaded) return; // already staged; nothing left to look for
|
||||||
|
if (Date.now() - lastCheckAt < MIN_GAP_MS) return;
|
||||||
updateConfirmed = false;
|
updateConfirmed = false;
|
||||||
autoUpdater.checkForUpdates().catch(() => {});
|
autoUpdater.checkForUpdates().catch(() => {});
|
||||||
}, 4 * 60 * 60 * 1000);
|
};
|
||||||
|
|
||||||
|
setTimeout(maybeCheck, 10_000);
|
||||||
|
setInterval(maybeCheck, POLL_INTERVAL_MS);
|
||||||
|
|
||||||
|
// App-level rather than mainWindow-level: the window is destroyed and
|
||||||
|
// recreated when closed to tray and reopened, and a listener bound to the
|
||||||
|
// old instance would go quiet after the first close.
|
||||||
|
app.on('browser-window-focus', maybeCheck);
|
||||||
} catch {
|
} catch {
|
||||||
// Auto-updater unavailable — recovery surface still functions, just
|
// Auto-updater unavailable — recovery surface still functions, just
|
||||||
// without update affordances. autoUpdaterRef stays null; recovery action
|
// without update affordances. autoUpdaterRef stays null; recovery action
|
||||||
@@ -953,7 +1081,19 @@ if (!gotTheLock) {
|
|||||||
// `PulseaudioLoopbackForScreenShare` feature flag we enable above.
|
// `PulseaudioLoopbackForScreenShare` feature flag we enable above.
|
||||||
// Fails on PipeWire-only systems without pulse compat — the
|
// Fails on PipeWire-only systems without pulse compat — the
|
||||||
// renderer catches that and toasts the user.
|
// renderer catches that and toasts the user.
|
||||||
callback({ video: selected, ...(shareAudio ? { audio: 'loopback' } : {}) });
|
// Audio no longer rides on the Electron stream: `loopback` would put
|
||||||
|
// this app's own output (everyone else's voices) back into the share.
|
||||||
|
// The native module captures it separately, isolated by process, and
|
||||||
|
// the renderer turns it into the track LiveKit publishes.
|
||||||
|
if (shareAudio) {
|
||||||
|
const started = startNativeAudioCapture(sourceId);
|
||||||
|
if (!started) {
|
||||||
|
// Tell the renderer so it can say the share is going out silently,
|
||||||
|
// instead of the user assuming sound is included.
|
||||||
|
mainWindow?.webContents.send('native-audio-unavailable');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
callback({ video: selected });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Main:ScreenShare] Handler error:', err);
|
console.error('[Main:ScreenShare] Handler error:', err);
|
||||||
// @ts-ignore — deny the request without crashing
|
// @ts-ignore — deny the request without crashing
|
||||||
@@ -971,7 +1111,7 @@ if (!gotTheLock) {
|
|||||||
|
|
||||||
// AGPL-3.0 § 13: native About panel advertises the version + source repo.
|
// AGPL-3.0 § 13: native About panel advertises the version + source repo.
|
||||||
app.setAboutPanelOptions({
|
app.setAboutPanelOptions({
|
||||||
applicationName: 'Backspace',
|
applicationName: 'Resenhacord',
|
||||||
applicationVersion: app.getVersion(),
|
applicationVersion: app.getVersion(),
|
||||||
copyright: `AGPL-3.0-only · Source: ${UPSTREAM_SOURCE_URL}`,
|
copyright: `AGPL-3.0-only · Source: ${UPSTREAM_SOURCE_URL}`,
|
||||||
website: UPSTREAM_SOURCE_URL,
|
website: UPSTREAM_SOURCE_URL,
|
||||||
|
|||||||
@@ -65,6 +65,24 @@ contextBridge.exposeInMainWorld('backspace', {
|
|||||||
onScreenShareSources: (callback: (sources: unknown[]) => void) => {
|
onScreenShareSources: (callback: (sources: unknown[]) => void) => {
|
||||||
ipcRenderer.on('screen-share-sources', (_event, sources) => callback(sources));
|
ipcRenderer.on('screen-share-sources', (_event, sources) => callback(sources));
|
||||||
},
|
},
|
||||||
|
// Raw PCM from the native capture. Arrives ~50x/second; the renderer turns it
|
||||||
|
// into a MediaStreamTrack for LiveKit.
|
||||||
|
onNativeAudioData: (callback: (data: ArrayBuffer, meta: { sampleRate: number; channels: number; bitsPerSample: number; isFloat: boolean }) => void) => {
|
||||||
|
const listener = (_event: unknown, data: Uint8Array, meta: { sampleRate: number; channels: number; bitsPerSample: number; isFloat: boolean }) => {
|
||||||
|
// Copied out of the transferred buffer: reusing it across IPC messages
|
||||||
|
// would let a later chunk overwrite one still being read.
|
||||||
|
callback(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer, meta);
|
||||||
|
};
|
||||||
|
ipcRenderer.on('native-audio-data', listener);
|
||||||
|
return () => ipcRenderer.off('native-audio-data', listener);
|
||||||
|
},
|
||||||
|
onNativeAudioUnavailable: (callback: () => void) => {
|
||||||
|
const listener = () => callback();
|
||||||
|
ipcRenderer.on('native-audio-unavailable', listener);
|
||||||
|
return () => ipcRenderer.off('native-audio-unavailable', listener);
|
||||||
|
},
|
||||||
|
stopNativeAudio: () => ipcRenderer.send('native-audio-stop'),
|
||||||
|
|
||||||
selectScreenSource: (sourceId: string | null, shareAudio?: boolean) => {
|
selectScreenSource: (sourceId: string | null, shareAudio?: boolean) => {
|
||||||
ipcRenderer.send('screen-share-selected', sourceId, shareAudio ?? true);
|
ipcRenderer.send('screen-share-selected', sourceId, shareAudio ?? true);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ describe('buildTrayMenuTemplate', () => {
|
|||||||
it('includes Show/Hide/Change Instance/Quit base items', () => {
|
it('includes Show/Hide/Change Instance/Quit base items', () => {
|
||||||
const items = buildTrayMenuTemplate(defaultState());
|
const items = buildTrayMenuTemplate(defaultState());
|
||||||
const labels = items.map((i) => i.label);
|
const labels = items.map((i) => i.label);
|
||||||
expect(labels).toContain('Show Backspace');
|
expect(labels).toContain('Mostrar o Resenhacord');
|
||||||
expect(labels).toContain('Hide');
|
expect(labels).toContain('Hide');
|
||||||
expect(labels).toContain('Change Instance');
|
expect(labels).toContain('Change Instance');
|
||||||
expect(labels).toContain('Quit');
|
expect(labels).toContain('Quit');
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ export function buildTrayMenuTemplate(
|
|||||||
actions?: Partial<MenuActions>,
|
actions?: Partial<MenuActions>,
|
||||||
): MenuItemConstructorOptions[] {
|
): MenuItemConstructorOptions[] {
|
||||||
const items: MenuItemConstructorOptions[] = [
|
const items: MenuItemConstructorOptions[] = [
|
||||||
{ label: 'Show Backspace', click: actions?.onShow },
|
{ label: 'Mostrar o Resenhacord', click: actions?.onShow },
|
||||||
{ label: 'Hide', click: actions?.onHide },
|
{ label: 'Hide', click: actions?.onHide },
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
checkForUpdatesItem(state, () => actions?.onCheckForUpdates?.()),
|
checkForUpdatesItem(state, () => actions?.onCheckForUpdates?.()),
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
CREATE TABLE `spotify_connections` (
|
||||||
|
`user_id` text PRIMARY KEY NOT NULL,
|
||||||
|
`access_token` text NOT NULL,
|
||||||
|
`refresh_token` text NOT NULL,
|
||||||
|
`expires_at` integer NOT NULL,
|
||||||
|
`spotify_user_id` text,
|
||||||
|
`created_at` integer NOT NULL,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
CREATE TABLE `gif_favorites` (
|
||||||
|
`user_id` text NOT NULL,
|
||||||
|
`gif_id` text NOT NULL,
|
||||||
|
`title` text NOT NULL,
|
||||||
|
`preview_url` text NOT NULL,
|
||||||
|
`url` text NOT NULL,
|
||||||
|
`width` integer NOT NULL,
|
||||||
|
`height` integer NOT NULL,
|
||||||
|
`created_at` integer NOT NULL,
|
||||||
|
PRIMARY KEY(`user_id`, `gif_id`),
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_gif_favorites_user_id` ON `gif_favorites` (`user_id`);
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
CREATE TABLE `audit_events` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`space_id` text NOT NULL,
|
||||||
|
`actor_id` text,
|
||||||
|
`action` text NOT NULL,
|
||||||
|
`target_type` text,
|
||||||
|
`target_id` text,
|
||||||
|
`metadata` text,
|
||||||
|
`created_at` integer NOT NULL,
|
||||||
|
FOREIGN KEY (`space_id`) REFERENCES `spaces`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`actor_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_audit_events_space_created` ON `audit_events` (`space_id`,`created_at`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_audit_events_actor` ON `audit_events` (`actor_id`);
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
CREATE TABLE `voice_sessions` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`space_id` text,
|
||||||
|
`channel_id` text NOT NULL,
|
||||||
|
`user_id` text NOT NULL,
|
||||||
|
`started_at` integer NOT NULL,
|
||||||
|
`ended_at` integer,
|
||||||
|
FOREIGN KEY (`space_id`) REFERENCES `spaces`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_voice_sessions_space_started` ON `voice_sessions` (`space_id`,`started_at`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_voice_sessions_user` ON `voice_sessions` (`user_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_voice_sessions_ended` ON `voice_sessions` (`ended_at`);
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
CREATE TABLE `soundboard_sounds` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`space_id` text NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`filename` text NOT NULL,
|
||||||
|
`uploader_id` text,
|
||||||
|
`created_at` integer NOT NULL,
|
||||||
|
FOREIGN KEY (`space_id`) REFERENCES `spaces`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`uploader_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_soundboard_space` ON `soundboard_sounds` (`space_id`);
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
ALTER TABLE `messages` ADD `pinned_at` integer;--> statement-breakpoint
|
||||||
|
ALTER TABLE `messages` ADD `pinned_by` text;--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_messages_channel_pinned` ON `messages` (`channel_id`,`pinned_at`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_messages_channel_user_created` ON `messages` (`channel_id`,`user_id`,`created_at`);
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
CREATE TABLE `space_emojis` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`space_id` text NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`filename` text NOT NULL,
|
||||||
|
`uploader_id` text,
|
||||||
|
`created_at` integer NOT NULL,
|
||||||
|
FOREIGN KEY (`space_id`) REFERENCES `spaces`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`uploader_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `space_stickers` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`space_id` text NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`filename` text NOT NULL,
|
||||||
|
`uploader_id` text,
|
||||||
|
`created_at` integer NOT NULL,
|
||||||
|
FOREIGN KEY (`space_id`) REFERENCES `spaces`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`uploader_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `messages` ADD `sticker_id` text;--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_space_emojis_space` ON `space_emojis` (`space_id`);--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `idx_space_emojis_space_name` ON `space_emojis` (`space_id`,`name`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_space_stickers_space` ON `space_stickers` (`space_id`);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `messages` ADD `type` text DEFAULT 'user' NOT NULL;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `users` ADD `profile_style` text;
|
||||||
@@ -78,6 +78,69 @@
|
|||||||
"when": 1783035334526,
|
"when": 1783035334526,
|
||||||
"tag": "0010_broken_blazing_skull",
|
"tag": "0010_broken_blazing_skull",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 11,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1788190305853,
|
||||||
|
"tag": "0011_lethal_bruce_banner",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 12,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1788192490711,
|
||||||
|
"tag": "0012_sour_pixie",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 13,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1788193162813,
|
||||||
|
"tag": "0013_fancy_betty_brant",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 14,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1788193659704,
|
||||||
|
"tag": "0014_mean_killer_shrike",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 15,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1788194631751,
|
||||||
|
"tag": "0015_young_human_fly",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 16,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1788295165743,
|
||||||
|
"tag": "0016_lyrical_freak",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 17,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1788295936825,
|
||||||
|
"tag": "0017_purple_wildside",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 18,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1788401450986,
|
||||||
|
"tag": "0018_eminent_kid_colt",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 19,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1788402724322,
|
||||||
|
"tag": "0019_glamorous_crusher_hogan",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
"author": "Jannis Braun",
|
"author": "Jannis Braun",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "PORT=3005 tsx watch src/index.ts",
|
"dev": "cross-env PORT=3005 tsx watch src/index.ts",
|
||||||
"start": "node --import tsx/esm src/index.ts",
|
"start": "node --import tsx/esm src/index.ts",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
"@tus/file-store": "^1.5.1",
|
"@tus/file-store": "^1.5.1",
|
||||||
"@tus/server": "^1.10.2",
|
"@tus/server": "^1.10.2",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"better-sqlite3": "^11.3.0",
|
"better-sqlite3": "^12.11.1",
|
||||||
"cheerio": "^1.0.0",
|
"cheerio": "^1.0.0",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"drizzle-orm": "^0.33.0",
|
"drizzle-orm": "^0.33.0",
|
||||||
@@ -41,6 +41,7 @@
|
|||||||
"@types/jsonwebtoken": "^9.0.6",
|
"@types/jsonwebtoken": "^9.0.6",
|
||||||
"@types/node": "^20.14.0",
|
"@types/node": "^20.14.0",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
|
"cross-env": "^7.0.3",
|
||||||
"drizzle-kit": "^0.24.0",
|
"drizzle-kit": "^0.24.0",
|
||||||
"typescript": "^5.4.0",
|
"typescript": "^5.4.0",
|
||||||
"vitest": "^4.0.18",
|
"vitest": "^4.0.18",
|
||||||
|
|||||||
@@ -76,6 +76,10 @@ export const config = {
|
|||||||
sourceCodeUrl,
|
sourceCodeUrl,
|
||||||
commit,
|
commit,
|
||||||
|
|
||||||
|
spotify: {
|
||||||
|
clientId: envOptional('SPOTIFY_CLIENT_ID'),
|
||||||
|
clientSecret: envOptional('SPOTIFY_CLIENT_SECRET'),
|
||||||
|
},
|
||||||
livekit: {
|
livekit: {
|
||||||
url: envOptional('LIVEKIT_URL'),
|
url: envOptional('LIVEKIT_URL'),
|
||||||
apiKey: envOptional('LIVEKIT_API_KEY'),
|
apiKey: envOptional('LIVEKIT_API_KEY'),
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { drizzle } from 'drizzle-orm/better-sqlite3';
|
|||||||
import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
|
import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
|
||||||
import { config } from '../config.js';
|
import { config } from '../config.js';
|
||||||
import * as schema from './schema.js';
|
import * as schema from './schema.js';
|
||||||
import { ensureDefaults, backfillOneOnOneDmMembership } from './migrate.js';
|
import { ensureDefaults, backfillOneOnOneDmMembership, grantMentionEveryoneToLegacyRoles } from './migrate.js';
|
||||||
import { setWorkerId } from '../utils/snowflake.js';
|
import { setWorkerId } from '../utils/snowflake.js';
|
||||||
import { createSnapshot } from '../utils/backup.js';
|
import { createSnapshot } from '../utils/backup.js';
|
||||||
import { hasPendingMigrations } from './pendingMigrations.js';
|
import { hasPendingMigrations } from './pendingMigrations.js';
|
||||||
@@ -53,6 +53,7 @@ export function initDatabase() {
|
|||||||
// Recover pre-fix broken 1-on-1 DM threads (deleted partner's membership row
|
// Recover pre-fix broken 1-on-1 DM threads (deleted partner's membership row
|
||||||
// lost before the tombstone fix). Idempotent — safe no-op on every later boot.
|
// lost before the tombstone fix). Idempotent — safe no-op on every later boot.
|
||||||
backfillOneOnOneDmMembership(sqlite);
|
backfillOneOnOneDmMembership(sqlite);
|
||||||
|
grantMentionEveryoneToLegacyRoles(sqlite);
|
||||||
|
|
||||||
// Initialize Snowflake worker ID from persisted value
|
// Initialize Snowflake worker ID from persisted value
|
||||||
const settings = sqlite.prepare('SELECT worker_id FROM instance_settings WHERE id = 1').get() as { worker_id: number } | undefined;
|
const settings = sqlite.prepare('SELECT worker_id FROM instance_settings WHERE id = 1').get() as { worker_id: number } | undefined;
|
||||||
|
|||||||
@@ -87,3 +87,44 @@ export function backfillOneOnOneDmMembership(db: Database.Database): void {
|
|||||||
run();
|
run();
|
||||||
if (restored > 0) console.log(`[backfill] restored ${restored} deleted-partner DM membership row(s)`);
|
if (restored > 0) console.log(`[backfill] restored ${restored} deleted-partner DM membership row(s)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grant MENTION_EVERYONE on @everyone roles that predate the bit.
|
||||||
|
*
|
||||||
|
* `DEFAULT_EVERYONE_PERMISSIONS` only applies at space creation, so spaces
|
||||||
|
* created before the bit existed carry a stored value without it — and would
|
||||||
|
* silently have @everyone mentions stripped for every member. This ORs the bit
|
||||||
|
* in once.
|
||||||
|
*
|
||||||
|
* Idempotent by construction: the OR is a no-op when the bit is already set,
|
||||||
|
* and the WHERE clause skips those rows anyway. Deliberately scoped to the
|
||||||
|
* @everyone role (`id = space_id`), so an owner who later denies the
|
||||||
|
* permission is not overruled on the next boot — their denial lives on a
|
||||||
|
* different row.
|
||||||
|
*/
|
||||||
|
export function grantMentionEveryoneToLegacyRoles(db: Database.Database): void {
|
||||||
|
const BIT = 1n << 15n;
|
||||||
|
|
||||||
|
const rows = db.prepare(
|
||||||
|
`SELECT id, permissions FROM roles WHERE id = space_id`
|
||||||
|
).all() as { id: string; permissions: string | null }[];
|
||||||
|
|
||||||
|
const update = db.prepare('UPDATE roles SET permissions = ? WHERE id = ?');
|
||||||
|
let changed = 0;
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
let current: bigint;
|
||||||
|
try {
|
||||||
|
current = BigInt(row.permissions ?? '0');
|
||||||
|
} catch {
|
||||||
|
continue; // unparseable value — leave it for a human, do not guess
|
||||||
|
}
|
||||||
|
if ((current & BIT) !== 0n) continue;
|
||||||
|
update.run((current | BIT).toString(), row.id);
|
||||||
|
changed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed > 0) {
|
||||||
|
console.log(`[defaults] Granted MENTION_EVERYONE to ${changed} @everyone role(s)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ export const users = sqliteTable('users', {
|
|||||||
profileUpdatedAt: integer('profile_updated_at'),
|
profileUpdatedAt: integer('profile_updated_at'),
|
||||||
passwordChangedAt: integer('password_changed_at'),
|
passwordChangedAt: integer('password_changed_at'),
|
||||||
showActivity: integer('show_activity').notNull().default(1),
|
showActivity: integer('show_activity').notNull().default(1),
|
||||||
|
// JSON ProfileStyle (shared/profile.ts), normalised on write. One column,
|
||||||
|
// not twelve: every field is optional, nothing is queried by value, and the
|
||||||
|
// shape keeps growing. Null means defaults.
|
||||||
|
profileStyle: text('profile_style'),
|
||||||
federationRegistryUpdatedAt: integer('federation_registry_updated_at').default(0),
|
federationRegistryUpdatedAt: integer('federation_registry_updated_at').default(0),
|
||||||
federationHealPending: integer('federation_heal_pending').default(0),
|
federationHealPending: integer('federation_heal_pending').default(0),
|
||||||
federationHomeOrphaned: integer('federation_home_orphaned').default(0),
|
federationHomeOrphaned: integer('federation_home_orphaned').default(0),
|
||||||
@@ -81,6 +85,18 @@ export const messages = sqliteTable('messages', {
|
|||||||
replyToId: text('reply_to_id'),
|
replyToId: text('reply_to_id'),
|
||||||
content: text('content'),
|
content: text('content'),
|
||||||
editedAt: integer('edited_at'),
|
editedAt: integer('edited_at'),
|
||||||
|
// Pinned state lives on the message rather than in a join table: a message is
|
||||||
|
// pinned in exactly one channel — its own — so a separate table would only
|
||||||
|
// add a join to every pin lookup.
|
||||||
|
pinnedAt: integer('pinned_at'),
|
||||||
|
pinnedBy: text('pinned_by'),
|
||||||
|
// Figurinha enviada como mensagem. `ON DELETE set null` de propósito: apagar
|
||||||
|
// a figurinha do servidor não pode apagar o histórico de quem a usou.
|
||||||
|
stickerId: text('sticker_id'),
|
||||||
|
// 'user' | 'system'. Space channels gained system messages with the pin
|
||||||
|
// notice; DM channels have carried them since group DMs. Same values, same
|
||||||
|
// renderer on the client.
|
||||||
|
type: text('type').notNull().default('user'),
|
||||||
createdAt: integer('created_at').notNull(),
|
createdAt: integer('created_at').notNull(),
|
||||||
}, (table) => ({
|
}, (table) => ({
|
||||||
replyToFk: foreignKey({
|
replyToFk: foreignKey({
|
||||||
@@ -89,6 +105,10 @@ export const messages = sqliteTable('messages', {
|
|||||||
}).onDelete('set null'),
|
}).onDelete('set null'),
|
||||||
channelIdx: index('idx_messages_channel_id').on(table.channelId),
|
channelIdx: index('idx_messages_channel_id').on(table.channelId),
|
||||||
userIdx: index('idx_messages_user_id').on(table.userId),
|
userIdx: index('idx_messages_user_id').on(table.userId),
|
||||||
|
// Listing a channel's pins, newest first, without scanning its history.
|
||||||
|
pinnedIdx: index('idx_messages_channel_pinned').on(table.channelId, table.pinnedAt),
|
||||||
|
// Filtered search: author within a channel, ordered by recency.
|
||||||
|
searchIdx: index('idx_messages_channel_user_created').on(table.channelId, table.userId, table.createdAt),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const attachments = sqliteTable('attachments', {
|
export const attachments = sqliteTable('attachments', {
|
||||||
@@ -549,3 +569,133 @@ export const inviteRedemptions = sqliteTable('invite_redemptions', {
|
|||||||
inviteIdx: index('idx_invite_redemptions_invite_id').on(table.inviteId),
|
inviteIdx: index('idx_invite_redemptions_invite_id').on(table.inviteId),
|
||||||
userIdx: index('idx_invite_redemptions_user_id').on(table.userId),
|
userIdx: index('idx_invite_redemptions_user_id').on(table.userId),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spotify tokens, one row per user.
|
||||||
|
*
|
||||||
|
* Kept server-side on purpose: refreshing requires the client secret, so the
|
||||||
|
* browser never holds a Spotify token at all — it asks this server what is
|
||||||
|
* playing and this server talks to Spotify.
|
||||||
|
*/
|
||||||
|
export const spotifyConnections = sqliteTable('spotify_connections', {
|
||||||
|
userId: text('user_id').primaryKey().references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
accessToken: text('access_token').notNull(),
|
||||||
|
refreshToken: text('refresh_token').notNull(),
|
||||||
|
// Epoch millis at which accessToken stops working.
|
||||||
|
expiresAt: integer('expires_at').notNull(),
|
||||||
|
spotifyUserId: text('spotify_user_id'),
|
||||||
|
createdAt: integer('created_at').notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Favourited GIFs, one row per user per GIF.
|
||||||
|
*
|
||||||
|
* Stores the whole result rather than an id: the provider offers no lookup by
|
||||||
|
* id, so a favourites tab that only kept ids could not render without
|
||||||
|
* re-searching for something the user may never find again.
|
||||||
|
*/
|
||||||
|
export const gifFavorites = sqliteTable('gif_favorites', {
|
||||||
|
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
gifId: text('gif_id').notNull(),
|
||||||
|
title: text('title').notNull(),
|
||||||
|
previewUrl: text('preview_url').notNull(),
|
||||||
|
url: text('url').notNull(),
|
||||||
|
width: integer('width').notNull(),
|
||||||
|
height: integer('height').notNull(),
|
||||||
|
createdAt: integer('created_at').notNull(),
|
||||||
|
}, (table) => ({
|
||||||
|
pk: primaryKey({ columns: [table.userId, table.gifId] }),
|
||||||
|
userIdx: index('idx_gif_favorites_user_id').on(table.userId),
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append-only record of who changed what in a space.
|
||||||
|
*
|
||||||
|
* Deliberately generic (action + target + JSON metadata) rather than a column
|
||||||
|
* per event type: new actions must not require a migration. Statistics read
|
||||||
|
* this same table — two features, one mechanism, instead of two logs that
|
||||||
|
* drift apart.
|
||||||
|
*/
|
||||||
|
export const auditEvents = sqliteTable('audit_events', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
spaceId: text('space_id').notNull().references(() => spaces.id, { onDelete: 'cascade' }),
|
||||||
|
actorId: text('actor_id').references(() => users.id, { onDelete: 'set null' }),
|
||||||
|
action: text('action').notNull(),
|
||||||
|
targetType: text('target_type'),
|
||||||
|
targetId: text('target_id'),
|
||||||
|
// JSON blob; shape depends on `action`. Never trusted for permissions.
|
||||||
|
metadata: text('metadata'),
|
||||||
|
createdAt: integer('created_at').notNull(),
|
||||||
|
}, (table) => ({
|
||||||
|
spaceIdx: index('idx_audit_events_space_created').on(table.spaceId, table.createdAt),
|
||||||
|
actorIdx: index('idx_audit_events_actor').on(table.actorId),
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row per stay in a voice room, closed when the user leaves.
|
||||||
|
*
|
||||||
|
* Separate from `auditEvents` on purpose: that table records points in time,
|
||||||
|
* while a call is an interval. Storing joins and leaves as separate point
|
||||||
|
* events would make every statistics query pair rows by hand and guess at
|
||||||
|
* joins whose leave never arrived (a crash, a restart).
|
||||||
|
*
|
||||||
|
* `endedAt` null means still connected. `spaceId` is null for DM calls.
|
||||||
|
*/
|
||||||
|
export const voiceSessions = sqliteTable('voice_sessions', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
spaceId: text('space_id').references(() => spaces.id, { onDelete: 'cascade' }),
|
||||||
|
channelId: text('channel_id').notNull(),
|
||||||
|
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
startedAt: integer('started_at').notNull(),
|
||||||
|
endedAt: integer('ended_at'),
|
||||||
|
}, (table) => ({
|
||||||
|
spaceIdx: index('idx_voice_sessions_space_started').on(table.spaceId, table.startedAt),
|
||||||
|
userIdx: index('idx_voice_sessions_user').on(table.userId),
|
||||||
|
openIdx: index('idx_voice_sessions_ended').on(table.endedAt),
|
||||||
|
}));
|
||||||
|
|
||||||
|
/** Soundboard clips, per space. The file lives in the normal upload dir. */
|
||||||
|
export const soundboardSounds = sqliteTable('soundboard_sounds', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
spaceId: text('space_id').notNull().references(() => spaces.id, { onDelete: 'cascade' }),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
filename: text('filename').notNull(),
|
||||||
|
uploaderId: text('uploader_id').references(() => users.id, { onDelete: 'set null' }),
|
||||||
|
createdAt: integer('created_at').notNull(),
|
||||||
|
}, (table) => ({
|
||||||
|
spaceIdx: index('idx_soundboard_space').on(table.spaceId),
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emojis próprios de um espaço, referenciados por `:nome:` nas mensagens.
|
||||||
|
*
|
||||||
|
* O nome é único por espaço: `:trollface:` tem de resolver para uma imagem só,
|
||||||
|
* senão o render vira loteria. Espaços diferentes podem repetir o nome.
|
||||||
|
*/
|
||||||
|
export const spaceEmojis = sqliteTable('space_emojis', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
spaceId: text('space_id').notNull().references(() => spaces.id, { onDelete: 'cascade' }),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
filename: text('filename').notNull(),
|
||||||
|
uploaderId: text('uploader_id').references(() => users.id, { onDelete: 'set null' }),
|
||||||
|
createdAt: integer('created_at').notNull(),
|
||||||
|
}, (table) => ({
|
||||||
|
spaceIdx: index('idx_space_emojis_space').on(table.spaceId),
|
||||||
|
nameUnique: uniqueIndex('idx_space_emojis_space_name').on(table.spaceId, table.name),
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Figurinhas do espaço. Tabela separada dos emojis de propósito: emoji entra
|
||||||
|
* no meio do texto e figurinha ocupa a mensagem inteira — tamanhos, limites e
|
||||||
|
* caminho de render são diferentes.
|
||||||
|
*/
|
||||||
|
export const spaceStickers = sqliteTable('space_stickers', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
spaceId: text('space_id').notNull().references(() => spaces.id, { onDelete: 'cascade' }),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
filename: text('filename').notNull(),
|
||||||
|
uploaderId: text('uploader_id').references(() => users.id, { onDelete: 'set null' }),
|
||||||
|
createdAt: integer('created_at').notNull(),
|
||||||
|
}, (table) => ({
|
||||||
|
spaceIdx: index('idx_space_stickers_space').on(table.spaceId),
|
||||||
|
}));
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ import { uploadRoutes } from './routes/uploads.js';
|
|||||||
import { filesRoutes } from './routes/files.js';
|
import { filesRoutes } from './routes/files.js';
|
||||||
import { dmRoutes } from './routes/dm.js';
|
import { dmRoutes } from './routes/dm.js';
|
||||||
import { livekitRoutes } from './routes/livekit.js';
|
import { livekitRoutes } from './routes/livekit.js';
|
||||||
|
import { spotifyRoutes } from './routes/spotify.js';
|
||||||
|
import { auditRoutes } from './routes/audit.js';
|
||||||
|
import { statsRoutes } from './routes/stats.js';
|
||||||
|
import { soundboardRoutes } from './routes/soundboard.js';
|
||||||
|
import { expressionRoutes } from './routes/expressions.js';
|
||||||
|
import { closeOrphanedVoiceSessions } from './utils/voiceSessions.js';
|
||||||
import { socialRoutes } from './routes/social.js';
|
import { socialRoutes } from './routes/social.js';
|
||||||
import { settingsRoutes } from './routes/settings.js';
|
import { settingsRoutes } from './routes/settings.js';
|
||||||
import { utilRoutes } from './routes/utils.js';
|
import { utilRoutes } from './routes/utils.js';
|
||||||
@@ -25,6 +31,7 @@ import { searchRoutes } from './routes/search.js';
|
|||||||
import { adminRoutes } from './routes/admin.js';
|
import { adminRoutes } from './routes/admin.js';
|
||||||
import { gifRoutes } from './routes/gif.js';
|
import { gifRoutes } from './routes/gif.js';
|
||||||
import { federationRoutes } from './routes/federation.js';
|
import { federationRoutes } from './routes/federation.js';
|
||||||
|
import { gameRoutes } from './routes/games.js';
|
||||||
import { startFederationWorkers, stopFederationWorkers } from './utils/federationWorker.js';
|
import { startFederationWorkers, stopFederationWorkers } from './utils/federationWorker.js';
|
||||||
import { startBackupWorker, stopBackupWorker } from './utils/backupWorker.js';
|
import { startBackupWorker, stopBackupWorker } from './utils/backupWorker.js';
|
||||||
import './utils/federationRollback.js'; // Side-effect: registers rollback callbacks for outbox terminal failures.
|
import './utils/federationRollback.js'; // Side-effect: registers rollback callbacks for outbox terminal failures.
|
||||||
@@ -109,6 +116,10 @@ async function main(): Promise<void> {
|
|||||||
// Initialize database
|
// Initialize database
|
||||||
getDb();
|
getDb();
|
||||||
|
|
||||||
|
// A restart leaves voice sessions open with no way to know when they really
|
||||||
|
// ended. Sweep them before anything can read the statistics.
|
||||||
|
closeOrphanedVoiceSessions();
|
||||||
|
|
||||||
// Reset orphaned `users.status` rows for locally-homed users. The previous
|
// Reset orphaned `users.status` rows for locally-homed users. The previous
|
||||||
// process's in-memory disconnect timers are gone, so any non-offline row
|
// process's in-memory disconnect timers are gone, so any non-offline row
|
||||||
// is stale by construction. Replicated (federated) rows are skipped — their
|
// is stale by construction. Replicated (federated) rows are skipped — their
|
||||||
@@ -126,6 +137,11 @@ async function main(): Promise<void> {
|
|||||||
await app.register(filesRoutes);
|
await app.register(filesRoutes);
|
||||||
await app.register(dmRoutes);
|
await app.register(dmRoutes);
|
||||||
await app.register(livekitRoutes);
|
await app.register(livekitRoutes);
|
||||||
|
await app.register(spotifyRoutes);
|
||||||
|
await app.register(auditRoutes);
|
||||||
|
await app.register(statsRoutes);
|
||||||
|
await app.register(soundboardRoutes);
|
||||||
|
await app.register(expressionRoutes);
|
||||||
await app.register(socialRoutes);
|
await app.register(socialRoutes);
|
||||||
await app.register(settingsRoutes);
|
await app.register(settingsRoutes);
|
||||||
await app.register(utilRoutes);
|
await app.register(utilRoutes);
|
||||||
@@ -135,6 +151,7 @@ async function main(): Promise<void> {
|
|||||||
await app.register(searchRoutes);
|
await app.register(searchRoutes);
|
||||||
await app.register(adminRoutes);
|
await app.register(adminRoutes);
|
||||||
await app.register(gifRoutes);
|
await app.register(gifRoutes);
|
||||||
|
await app.register(gameRoutes);
|
||||||
await app.register(federationRoutes);
|
await app.register(federationRoutes);
|
||||||
await app.register(registerWebSocket);
|
await app.register(registerWebSocket);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { and, desc, eq, lt } from 'drizzle-orm';
|
||||||
|
import { getDb, schema } from '../db/index.js';
|
||||||
|
import { authenticate } from '../utils/auth.js';
|
||||||
|
import { hasPermission } from '../utils/permissions.js';
|
||||||
|
import { PermissionBits } from '@backspace/shared/src/permissions.js';
|
||||||
|
import { AUDIT_PAGE_SIZE, type AuditAction, type AuditEvent } from '@backspace/shared/src/audit.js';
|
||||||
|
|
||||||
|
export async function auditRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
app.get<{ Params: { id: string }; Querystring: { before?: string; limit?: string } }>(
|
||||||
|
'/api/spaces/:id/audit-log',
|
||||||
|
{ preHandler: authenticate },
|
||||||
|
async (request, reply) => {
|
||||||
|
const { id } = request.params;
|
||||||
|
|
||||||
|
// Gated on MANAGE_SPACE rather than a new permission bit: the log names
|
||||||
|
// who did what to whom, which is administrator-shaped information, and a
|
||||||
|
// new bit would silently default to nobody until roles were re-edited.
|
||||||
|
if (!hasPermission(request.userId, id, PermissionBits.MANAGE_SPACE)) {
|
||||||
|
return reply.code(403).send({ error: 'Missing MANAGE_SPACE permission', statusCode: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const limit = Math.min(Math.max(Number(request.query.limit) || AUDIT_PAGE_SIZE, 1), AUDIT_PAGE_SIZE);
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
// Ids are snowflakes, so ordering by id is chronological and gives a
|
||||||
|
// stable cursor even when two events land in the same millisecond.
|
||||||
|
const where = request.query.before
|
||||||
|
? and(eq(schema.auditEvents.spaceId, id), lt(schema.auditEvents.id, request.query.before))
|
||||||
|
: eq(schema.auditEvents.spaceId, id);
|
||||||
|
|
||||||
|
const rows = db.select({
|
||||||
|
id: schema.auditEvents.id,
|
||||||
|
spaceId: schema.auditEvents.spaceId,
|
||||||
|
action: schema.auditEvents.action,
|
||||||
|
targetType: schema.auditEvents.targetType,
|
||||||
|
targetId: schema.auditEvents.targetId,
|
||||||
|
metadata: schema.auditEvents.metadata,
|
||||||
|
createdAt: schema.auditEvents.createdAt,
|
||||||
|
actorId: schema.users.id,
|
||||||
|
actorUsername: schema.users.username,
|
||||||
|
actorDisplayName: schema.users.displayName,
|
||||||
|
actorAvatar: schema.users.avatar,
|
||||||
|
})
|
||||||
|
.from(schema.auditEvents)
|
||||||
|
.leftJoin(schema.users, eq(schema.auditEvents.actorId, schema.users.id))
|
||||||
|
.where(where)
|
||||||
|
.orderBy(desc(schema.auditEvents.id))
|
||||||
|
.limit(limit)
|
||||||
|
.all();
|
||||||
|
|
||||||
|
const events: AuditEvent[] = rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
spaceId: r.spaceId,
|
||||||
|
action: r.action as AuditAction,
|
||||||
|
// Null when the account was deleted: the event stays, the actor does
|
||||||
|
// not — an audit log that vanished with its actor would be useless.
|
||||||
|
actor: r.actorId
|
||||||
|
? { id: r.actorId, username: r.actorUsername!, displayName: r.actorDisplayName, avatar: r.actorAvatar }
|
||||||
|
: null,
|
||||||
|
targetType: r.targetType,
|
||||||
|
targetId: r.targetId,
|
||||||
|
metadata: parseMetadata(r.metadata),
|
||||||
|
createdAt: r.createdAt,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return reply.code(200).send({ events, hasMore: events.length === limit });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Metadata is written by us, but a malformed row must not break the whole page. */
|
||||||
|
function parseMetadata(raw: string | null): Record<string, unknown> | null {
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as unknown;
|
||||||
|
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||||
|
? (parsed as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||