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 |
@@ -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,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,22 +14,26 @@ 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 }}
|
||||||
|
|
||||||
@@ -59,17 +63,6 @@ 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@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||||
with:
|
with:
|
||||||
@@ -97,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"
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -39,7 +48,9 @@ FROM node:20-slim AS runtime
|
|||||||
RUN corepack enable && corepack prepare pnpm@10.34.3 --activate
|
RUN corepack enable && corepack prepare pnpm@10.34.3 --activate
|
||||||
|
|
||||||
# Runtime deps only: ffmpeg (media processing) + gosu (drop to non-root in the
|
# Runtime deps only: ffmpeg (media processing) + gosu (drop to non-root in the
|
||||||
# entrypoint). No C toolchain — better-sqlite3 and sharp load prebuilt binaries.
|
# 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 ffmpeg gosu && \
|
apt-get install -y --no-install-recommends ffmpeg gosu && \
|
||||||
rm -rf /var/lib/apt/lists/*
|
rm -rf /var/lib/apt/lists/*
|
||||||
@@ -58,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/
|
||||||
|
|||||||
|
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 |
@@ -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,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.
|
||||||
@@ -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",
|
||||||
@@ -32,8 +32,9 @@
|
|||||||
"pnpm": {
|
"pnpm": {
|
||||||
"onlyBuiltDependencies": [
|
"onlyBuiltDependencies": [
|
||||||
"better-sqlite3",
|
"better-sqlite3",
|
||||||
"esbuild",
|
|
||||||
"electron",
|
"electron",
|
||||||
|
"electron-native-screenshare",
|
||||||
|
"esbuild",
|
||||||
"sharp"
|
"sharp"
|
||||||
],
|
],
|
||||||
"patchedDependencies": {
|
"patchedDependencies": {
|
||||||
|
|||||||
|
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 || 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.')\""
|
"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,24 +40,31 @@ 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) {
|
||||||
if (result.kind === 'migrated') {
|
const result = migrateUserData(step);
|
||||||
console.log(`[userData] migrated ${result.from} → ${result.to}`);
|
if (result.kind === 'migrated') {
|
||||||
} else if (result.kind === 'failed') {
|
console.log(`[userData] migrated ${result.from} → ${result.to}`);
|
||||||
console.error('[userData] migration failed:', result.error);
|
} else if (result.kind === 'failed') {
|
||||||
|
console.error('[userData] migration failed:', result.error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import type { FastifyInstance } from 'fastify';
|
|||||||
import { eq, and, inArray } from 'drizzle-orm';
|
import { eq, and, inArray } from 'drizzle-orm';
|
||||||
import { getDb, schema } from '../db/index.js';
|
import { getDb, schema } from '../db/index.js';
|
||||||
import { authenticate } from '../utils/auth.js';
|
import { authenticate } from '../utils/auth.js';
|
||||||
|
import { recordAuditEvent } from '../utils/auditLog.js';
|
||||||
import { generateSnowflake } from '../utils/snowflake.js';
|
import { generateSnowflake } from '../utils/snowflake.js';
|
||||||
import { isMember, hasPermission, getChannelSpaceId, PermissionBits, computePermissions } from '../utils/permissions.js';
|
import { isMember, hasPermission, getChannelSpaceId, PermissionBits, computePermissions } from '../utils/permissions.js';
|
||||||
import { permissionsToString } from '@backspace/shared/src/permissions.js';
|
import { permissionsToString } from '@backspace/shared/src/permissions.js';
|
||||||
@@ -14,6 +15,11 @@ import type {
|
|||||||
Channel,
|
Channel,
|
||||||
ChannelCategory,
|
ChannelCategory,
|
||||||
} from '@backspace/shared';
|
} from '@backspace/shared';
|
||||||
|
import {
|
||||||
|
normalizeChannelName,
|
||||||
|
CHANNEL_NAME_MIN_LENGTH,
|
||||||
|
CHANNEL_NAME_MAX_LENGTH,
|
||||||
|
} from '@backspace/shared/src/constants.js';
|
||||||
|
|
||||||
function rowToChannel(row: typeof schema.channels.$inferSelect): Channel {
|
function rowToChannel(row: typeof schema.channels.$inferSelect): Channel {
|
||||||
return {
|
return {
|
||||||
@@ -189,8 +195,8 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
return reply.code(400).send({ error: 'Channel name is required', statusCode: 400 });
|
return reply.code(400).send({ error: 'Channel name is required', statusCode: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const trimmedName = name.trim().toLowerCase().replace(/\s+/g, '-');
|
const trimmedName = normalizeChannelName(name);
|
||||||
if (trimmedName.length < 1 || trimmedName.length > 100) {
|
if (trimmedName.length < CHANNEL_NAME_MIN_LENGTH || trimmedName.length > CHANNEL_NAME_MAX_LENGTH) {
|
||||||
return reply.code(400).send({ error: 'Channel name must be between 1 and 100 characters', statusCode: 400 });
|
return reply.code(400).send({ error: 'Channel name must be between 1 and 100 characters', statusCode: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,6 +263,15 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
// Return the channel with the creator's computed permissions (same shape as
|
// Return the channel with the creator's computed permissions (same shape as
|
||||||
// the channel_created WS event) so the client can render it immediately
|
// the channel_created WS event) so the client can render it immediately
|
||||||
// without waiting for the broadcast to round-trip.
|
// without waiting for the broadcast to round-trip.
|
||||||
|
recordAuditEvent({
|
||||||
|
spaceId: id,
|
||||||
|
actorId: request.userId,
|
||||||
|
action: 'channel.create',
|
||||||
|
targetType: 'channel',
|
||||||
|
targetId: channelId,
|
||||||
|
metadata: { name: channelData.name, type: channelData.type },
|
||||||
|
});
|
||||||
|
|
||||||
const creatorPerms = computePermissions(request.userId, id, channelId);
|
const creatorPerms = computePermissions(request.userId, id, channelId);
|
||||||
return reply.code(201).send({
|
return reply.code(201).send({
|
||||||
...channelData,
|
...channelData,
|
||||||
@@ -286,8 +301,8 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
const updates: Partial<typeof schema.channels.$inferInsert> = {};
|
const updates: Partial<typeof schema.channels.$inferInsert> = {};
|
||||||
|
|
||||||
if (name !== undefined) {
|
if (name !== undefined) {
|
||||||
const trimmedName = name.trim().toLowerCase().replace(/\s+/g, '-');
|
const trimmedName = normalizeChannelName(name);
|
||||||
if (trimmedName.length < 1 || trimmedName.length > 100) {
|
if (trimmedName.length < CHANNEL_NAME_MIN_LENGTH || trimmedName.length > CHANNEL_NAME_MAX_LENGTH) {
|
||||||
return reply.code(400).send({ error: 'Channel name must be between 1 and 100 characters', statusCode: 400 });
|
return reply.code(400).send({ error: 'Channel name must be between 1 and 100 characters', statusCode: 400 });
|
||||||
}
|
}
|
||||||
updates.name = trimmedName;
|
updates.name = trimmedName;
|
||||||
@@ -346,6 +361,15 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
recordAuditEvent({
|
||||||
|
spaceId,
|
||||||
|
actorId: request.userId,
|
||||||
|
action: 'channel.update',
|
||||||
|
targetType: 'channel',
|
||||||
|
targetId: id,
|
||||||
|
metadata: { name: channelData.name },
|
||||||
|
});
|
||||||
|
|
||||||
return reply.code(200).send(channelData);
|
return reply.code(200).send(channelData);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -418,6 +442,15 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
connectionManager.sendToUser(uid, deleteEvent);
|
connectionManager.sendToUser(uid, deleteEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
recordAuditEvent({
|
||||||
|
spaceId,
|
||||||
|
actorId: request.userId,
|
||||||
|
action: 'channel.delete',
|
||||||
|
targetType: 'channel',
|
||||||
|
targetId: id,
|
||||||
|
metadata: { name: channel.name },
|
||||||
|
});
|
||||||
|
|
||||||
return reply.code(200).send({ success: true });
|
return reply.code(200).send({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { and, eq } from 'drizzle-orm';
|
||||||
|
import { getDb, schema } from '../db/index.js';
|
||||||
|
import { authenticate } from '../utils/auth.js';
|
||||||
|
import { hasPermission, isMember } from '../utils/permissions.js';
|
||||||
|
import { PermissionBits } from '@backspace/shared/src/permissions.js';
|
||||||
|
import { generateSnowflake } from '../utils/snowflake.js';
|
||||||
|
|
||||||
|
const MAX_EMOJIS_PER_SPACE = 100;
|
||||||
|
const MAX_STICKERS_PER_SPACE = 50;
|
||||||
|
const MAX_NAME_LENGTH = 32;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nomes viram `:nome:` no texto, então só letras, números e sublinhado —
|
||||||
|
* espaço ou dois-pontos dentro do nome tornariam a referência impossível de
|
||||||
|
* delimitar.
|
||||||
|
*/
|
||||||
|
const NAME_PATTERN = /^[a-z0-9_]{2,32}$/;
|
||||||
|
|
||||||
|
function normalizeName(raw: unknown): string | null {
|
||||||
|
if (typeof raw !== 'string') return null;
|
||||||
|
const name = raw.trim().toLowerCase().replace(/\s+/g, '_').slice(0, MAX_NAME_LENGTH);
|
||||||
|
return NAME_PATTERN.test(name) ? name : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** O nome do arquivo é chave no diretório de uploads, nunca um caminho. */
|
||||||
|
function validFilename(raw: unknown): raw is string {
|
||||||
|
return typeof raw === 'string' && raw.length > 0
|
||||||
|
&& !raw.includes('/') && !raw.includes('\\') && !raw.includes('..');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expressionRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
// ─── Emojis ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
app.get<{ Params: { id: string } }>('/api/spaces/:id/emojis', { preHandler: authenticate }, async (request, reply) => {
|
||||||
|
if (!isMember(request.params.id, request.userId)) {
|
||||||
|
return reply.code(403).send({ error: 'Not a member of this space', statusCode: 403 });
|
||||||
|
}
|
||||||
|
const rows = getDb().select().from(schema.spaceEmojis)
|
||||||
|
.where(eq(schema.spaceEmojis.spaceId, request.params.id)).all();
|
||||||
|
return reply.code(200).send({ emojis: rows });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post<{ Params: { id: string }; Body: { name?: string; filename?: string } }>(
|
||||||
|
'/api/spaces/:id/emojis', { preHandler: authenticate }, async (request, reply) => {
|
||||||
|
const { id } = request.params;
|
||||||
|
if (!hasPermission(request.userId, id, PermissionBits.MANAGE_SPACE)) {
|
||||||
|
return reply.code(403).send({ error: 'Missing MANAGE_SPACE permission', statusCode: 403 });
|
||||||
|
}
|
||||||
|
const name = normalizeName(request.body?.name);
|
||||||
|
if (!name) {
|
||||||
|
return reply.code(400).send({ error: 'Name must be 2-32 chars: letters, numbers, underscore', statusCode: 400 });
|
||||||
|
}
|
||||||
|
if (!validFilename(request.body?.filename)) {
|
||||||
|
return reply.code(400).send({ error: 'Invalid filename', statusCode: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const count = db.select().from(schema.spaceEmojis)
|
||||||
|
.where(eq(schema.spaceEmojis.spaceId, id)).all().length;
|
||||||
|
if (count >= MAX_EMOJIS_PER_SPACE) {
|
||||||
|
return reply.code(409).send({ error: `At most ${MAX_EMOJIS_PER_SPACE} emojis`, statusCode: 409 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = db.select().from(schema.spaceEmojis)
|
||||||
|
.where(and(eq(schema.spaceEmojis.spaceId, id), eq(schema.spaceEmojis.name, name))).get();
|
||||||
|
if (existing) {
|
||||||
|
return reply.code(409).send({ error: `:${name}: already exists in this space`, statusCode: 409 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = {
|
||||||
|
id: generateSnowflake(),
|
||||||
|
spaceId: id,
|
||||||
|
name,
|
||||||
|
filename: request.body!.filename!,
|
||||||
|
uploaderId: request.userId,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
};
|
||||||
|
db.insert(schema.spaceEmojis).values(row).run();
|
||||||
|
return reply.code(201).send(row);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.delete<{ Params: { id: string } }>('/api/emojis/:id', { preHandler: authenticate }, async (request, reply) => {
|
||||||
|
const db = getDb();
|
||||||
|
const emoji = db.select().from(schema.spaceEmojis)
|
||||||
|
.where(eq(schema.spaceEmojis.id, request.params.id)).get();
|
||||||
|
if (!emoji) return reply.code(404).send({ error: 'Emoji not found', statusCode: 404 });
|
||||||
|
if (!hasPermission(request.userId, emoji.spaceId, PermissionBits.MANAGE_SPACE)) {
|
||||||
|
return reply.code(403).send({ error: 'Missing MANAGE_SPACE permission', statusCode: 403 });
|
||||||
|
}
|
||||||
|
db.delete(schema.spaceEmojis).where(eq(schema.spaceEmojis.id, request.params.id)).run();
|
||||||
|
return reply.code(204).send();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Stickers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
app.get<{ Params: { id: string } }>('/api/spaces/:id/stickers', { preHandler: authenticate }, async (request, reply) => {
|
||||||
|
if (!isMember(request.params.id, request.userId)) {
|
||||||
|
return reply.code(403).send({ error: 'Not a member of this space', statusCode: 403 });
|
||||||
|
}
|
||||||
|
const rows = getDb().select().from(schema.spaceStickers)
|
||||||
|
.where(eq(schema.spaceStickers.spaceId, request.params.id)).all();
|
||||||
|
return reply.code(200).send({ stickers: rows });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post<{ Params: { id: string }; Body: { name?: string; filename?: string } }>(
|
||||||
|
'/api/spaces/:id/stickers', { preHandler: authenticate }, async (request, reply) => {
|
||||||
|
const { id } = request.params;
|
||||||
|
if (!hasPermission(request.userId, id, PermissionBits.MANAGE_SPACE)) {
|
||||||
|
return reply.code(403).send({ error: 'Missing MANAGE_SPACE permission', statusCode: 403 });
|
||||||
|
}
|
||||||
|
// Figurinha é escolhida numa grade, não digitada, então o nome é rótulo
|
||||||
|
// e aceita acento e espaço — ao contrário do emoji.
|
||||||
|
const name = typeof request.body?.name === 'string'
|
||||||
|
? request.body.name.trim().slice(0, MAX_NAME_LENGTH) : '';
|
||||||
|
if (!name) return reply.code(400).send({ error: 'Name is required', statusCode: 400 });
|
||||||
|
if (!validFilename(request.body?.filename)) {
|
||||||
|
return reply.code(400).send({ error: 'Invalid filename', statusCode: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const count = db.select().from(schema.spaceStickers)
|
||||||
|
.where(eq(schema.spaceStickers.spaceId, id)).all().length;
|
||||||
|
if (count >= MAX_STICKERS_PER_SPACE) {
|
||||||
|
return reply.code(409).send({ error: `At most ${MAX_STICKERS_PER_SPACE} stickers`, statusCode: 409 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = {
|
||||||
|
id: generateSnowflake(),
|
||||||
|
spaceId: id,
|
||||||
|
name,
|
||||||
|
filename: request.body!.filename!,
|
||||||
|
uploaderId: request.userId,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
};
|
||||||
|
db.insert(schema.spaceStickers).values(row).run();
|
||||||
|
return reply.code(201).send(row);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.delete<{ Params: { id: string } }>('/api/stickers/:id', { preHandler: authenticate }, async (request, reply) => {
|
||||||
|
const db = getDb();
|
||||||
|
const sticker = db.select().from(schema.spaceStickers)
|
||||||
|
.where(eq(schema.spaceStickers.id, request.params.id)).get();
|
||||||
|
if (!sticker) return reply.code(404).send({ error: 'Sticker not found', statusCode: 404 });
|
||||||
|
if (!hasPermission(request.userId, sticker.spaceId, PermissionBits.MANAGE_SPACE)) {
|
||||||
|
return reply.code(403).send({ error: 'Missing MANAGE_SPACE permission', statusCode: 403 });
|
||||||
|
}
|
||||||
|
db.delete(schema.spaceStickers).where(eq(schema.spaceStickers.id, request.params.id)).run();
|
||||||
|
return reply.code(204).send();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { authenticate } from '../utils/auth.js';
|
||||||
|
import { steamCoverUrl } from '@backspace/shared/src/profile.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Game lookup for profile widgets, backed by Steam's public store search.
|
||||||
|
*
|
||||||
|
* Proxied rather than called from the browser: the store endpoint has no CORS
|
||||||
|
* headers, and going through here means one place to cache, to bound the
|
||||||
|
* query, and to swap the source later without touching clients. No key is
|
||||||
|
* involved — it is the same unauthenticated endpoint the store website uses.
|
||||||
|
*/
|
||||||
|
interface SteamSearchItem { id: number; name: string; tiny_image?: string }
|
||||||
|
interface GameHit { appId: number; name: string; cover: string; header: string }
|
||||||
|
|
||||||
|
const CACHE_TTL_MS = 10 * 60 * 1000;
|
||||||
|
const cache = new Map<string, { at: number; hits: GameHit[] }>();
|
||||||
|
|
||||||
|
export async function gameRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
app.get<{ Querystring: { q?: string } }>('/api/games/search', { preHandler: authenticate }, async (request, reply) => {
|
||||||
|
const q = (request.query.q ?? '').trim().slice(0, 60);
|
||||||
|
if (q.length < 2) return reply.send({ hits: [] });
|
||||||
|
|
||||||
|
const key = q.toLowerCase();
|
||||||
|
const cached = cache.get(key);
|
||||||
|
if (cached && Date.now() - cached.at < CACHE_TTL_MS) return reply.send({ hits: cached.hits });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = `https://store.steampowered.com/api/storesearch/?term=${encodeURIComponent(q)}&l=brazilian&cc=BR`;
|
||||||
|
const res = await fetch(url, { signal: AbortSignal.timeout(6000), headers: { 'User-Agent': 'Resenhacord/1.0' } });
|
||||||
|
if (!res.ok) return reply.send({ hits: [] });
|
||||||
|
const data = (await res.json()) as { items?: SteamSearchItem[] };
|
||||||
|
const hits: GameHit[] = (data.items ?? []).slice(0, 8).map((it) => ({
|
||||||
|
appId: it.id,
|
||||||
|
name: it.name,
|
||||||
|
cover: steamCoverUrl(it.id),
|
||||||
|
header: `https://cdn.cloudflare.steamstatic.com/steam/apps/${it.id}/header.jpg`,
|
||||||
|
}));
|
||||||
|
cache.set(key, { at: Date.now(), hits });
|
||||||
|
if (cache.size > 500) cache.delete(cache.keys().next().value!);
|
||||||
|
return reply.send({ hits });
|
||||||
|
} catch {
|
||||||
|
// Upstream hiccup is not the user's problem: an empty list lets them
|
||||||
|
// type the title by hand instead.
|
||||||
|
return reply.send({ hits: [] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq, and, desc } from 'drizzle-orm';
|
||||||
import { getDb, schema } from '../db/index.js';
|
import { getDb, schema } from '../db/index.js';
|
||||||
import { authenticate } from '../utils/auth.js';
|
import { authenticate } from '../utils/auth.js';
|
||||||
import type { GifResult } from '@backspace/shared';
|
import type { GifResult } from '@backspace/shared';
|
||||||
@@ -178,4 +178,68 @@ export async function gifRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
return reply.code(200).send({ results: [], next: '' });
|
return reply.code(200).send({ results: [], next: '' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Favourites ──────────────────────────────────────────────────────────
|
||||||
|
// Kept per user and synced server-side so a favourite made on the phone is
|
||||||
|
// there on the desktop, which is the whole point of favouriting.
|
||||||
|
|
||||||
|
/** Cap per user: a favourites tab is a shortlist, not an archive. */
|
||||||
|
const MAX_FAVORITES = 200;
|
||||||
|
|
||||||
|
app.get('/api/gif/favorites', { preHandler: authenticate }, async (request, reply) => {
|
||||||
|
const db = getDb();
|
||||||
|
const rows = db.select().from(schema.gifFavorites)
|
||||||
|
.where(eq(schema.gifFavorites.userId, request.userId))
|
||||||
|
.orderBy(desc(schema.gifFavorites.createdAt))
|
||||||
|
.all();
|
||||||
|
const results: GifResult[] = rows.map((r) => ({
|
||||||
|
id: r.gifId, title: r.title, previewUrl: r.previewUrl,
|
||||||
|
url: r.url, width: r.width, height: r.height,
|
||||||
|
}));
|
||||||
|
return reply.code(200).send({ results });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post<{ Body: GifResult }>('/api/gif/favorites', { preHandler: authenticate }, async (request, reply) => {
|
||||||
|
const { id, title, previewUrl, url, width, height } = request.body ?? ({} as GifResult);
|
||||||
|
if (!id || typeof id !== 'string' || !previewUrl || !url) {
|
||||||
|
return reply.code(400).send({ error: 'id, previewUrl and url are required', statusCode: 400 });
|
||||||
|
}
|
||||||
|
// Only http(s): these become <img src> for everyone who opens the picker.
|
||||||
|
for (const candidate of [previewUrl, url]) {
|
||||||
|
if (!/^https?:\/\//.test(candidate)) {
|
||||||
|
return reply.code(400).send({ error: 'previewUrl and url must be http(s)', statusCode: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const count = db.select().from(schema.gifFavorites)
|
||||||
|
.where(eq(schema.gifFavorites.userId, request.userId)).all().length;
|
||||||
|
const existing = db.select().from(schema.gifFavorites)
|
||||||
|
.where(and(eq(schema.gifFavorites.userId, request.userId), eq(schema.gifFavorites.gifId, id))).get();
|
||||||
|
if (!existing && count >= MAX_FAVORITES) {
|
||||||
|
return reply.code(409).send({ error: `At most ${MAX_FAVORITES} favourites`, statusCode: 409 });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.insert(schema.gifFavorites).values({
|
||||||
|
userId: request.userId,
|
||||||
|
gifId: id,
|
||||||
|
title: typeof title === 'string' ? title.slice(0, 200) : '',
|
||||||
|
previewUrl,
|
||||||
|
url,
|
||||||
|
width: Number.isFinite(width) ? width : 0,
|
||||||
|
height: Number.isFinite(height) ? height : 0,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
}).onConflictDoNothing().run();
|
||||||
|
|
||||||
|
return reply.code(204).send();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete<{ Params: { id: string } }>('/api/gif/favorites/:id', { preHandler: authenticate }, async (request, reply) => {
|
||||||
|
const db = getDb();
|
||||||
|
db.delete(schema.gifFavorites)
|
||||||
|
.where(and(eq(schema.gifFavorites.userId, request.userId), eq(schema.gifFavorites.gifId, request.params.id)))
|
||||||
|
.run();
|
||||||
|
return reply.code(204).send();
|
||||||
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import { eq, and, desc, lt, inArray } from 'drizzle-orm';
|
import { eq, and, desc, lt, inArray, isNotNull } from 'drizzle-orm';
|
||||||
import { getDb, schema } from '../db/index.js';
|
import { getDb, schema } from '../db/index.js';
|
||||||
import { authenticate } from '../utils/auth.js';
|
import { authenticate } from '../utils/auth.js';
|
||||||
import { generateSnowflake } from '../utils/snowflake.js';
|
import { generateSnowflake } from '../utils/snowflake.js';
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
} from '@backspace/shared';
|
} from '@backspace/shared';
|
||||||
import { sanitizeUser } from '../utils/sanitize.js';
|
import { sanitizeUser } from '../utils/sanitize.js';
|
||||||
import { deleteAttachmentFiles } from '../utils/fileCleanup.js';
|
import { deleteAttachmentFiles } from '../utils/fileCleanup.js';
|
||||||
|
import { mentionsEveryone, stripEveryoneMention } from '@backspace/shared/src/mentions.js';
|
||||||
import { fetchEmbedsForMessages, resolveEmbeds, reResolveEmbeds, embedRowToEmbed } from '../utils/embedResolver.js';
|
import { fetchEmbedsForMessages, resolveEmbeds, reResolveEmbeds, embedRowToEmbed } from '../utils/embedResolver.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -145,6 +146,12 @@ export function buildMessageWithUser(
|
|||||||
content: message.content,
|
content: message.content,
|
||||||
editedAt: message.editedAt,
|
editedAt: message.editedAt,
|
||||||
createdAt: message.createdAt,
|
createdAt: message.createdAt,
|
||||||
|
// Carried on every message so the client can show the pin marker without a
|
||||||
|
// second request, and so the pins panel and the timeline agree.
|
||||||
|
pinnedAt: message.pinnedAt,
|
||||||
|
pinnedBy: message.pinnedBy,
|
||||||
|
type: (message.type === 'system' ? 'system' : 'user') as 'user' | 'system',
|
||||||
|
stickerId: message.stickerId,
|
||||||
user: sanitizeUser(user),
|
user: sanitizeUser(user),
|
||||||
attachments: attachmentRows.map(a => ({
|
attachments: attachmentRows.map(a => ({
|
||||||
id: a.id,
|
id: a.id,
|
||||||
@@ -271,7 +278,8 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
},
|
},
|
||||||
}, async (request, reply) => {
|
}, async (request, reply) => {
|
||||||
const { id } = request.params;
|
const { id } = request.params;
|
||||||
const { content, attachments: attachmentIds, replyToId } = request.body;
|
const { attachments: attachmentIds, replyToId, stickerId } = request.body;
|
||||||
|
let content = request.body.content;
|
||||||
|
|
||||||
const spaceId = getChannelSpaceId(id);
|
const spaceId = getChannelSpaceId(id);
|
||||||
if (!spaceId) {
|
if (!spaceId) {
|
||||||
@@ -287,18 +295,47 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
return reply.code(403).send({ error: 'Missing ATTACH_FILES permission', statusCode: 403 });
|
return reply.code(403).send({ error: 'Missing ATTACH_FILES permission', statusCode: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const db0 = getDb();
|
||||||
|
const db = db0;
|
||||||
|
|
||||||
|
// @everyone is a permission, not a formatting choice. Rather than reject
|
||||||
|
// the message — which loses what the author wrote over one token — the
|
||||||
|
// token is demoted to the plain text they typed, so it posts and reads the
|
||||||
|
// same but rings nobody.
|
||||||
|
if (
|
||||||
|
typeof content === 'string' &&
|
||||||
|
mentionsEveryone(content) &&
|
||||||
|
!hasPermission(request.userId, spaceId, PermissionBits.MENTION_EVERYONE, id)
|
||||||
|
) {
|
||||||
|
content = stripEveryoneMention(content);
|
||||||
|
}
|
||||||
|
|
||||||
const hasContent = content && typeof content === 'string' && content.trim().length > 0;
|
const hasContent = content && typeof content === 'string' && content.trim().length > 0;
|
||||||
const hasAttachments = attachmentIds && attachmentIds.length > 0;
|
const hasAttachments = attachmentIds && attachmentIds.length > 0;
|
||||||
|
|
||||||
if (!hasContent && !hasAttachments) {
|
// Figurinha vale como conteúdo: a mensagem é a figurinha.
|
||||||
return reply.code(400).send({ error: 'Message must have content or attachments', statusCode: 400 });
|
let sticker = null;
|
||||||
|
if (stickerId) {
|
||||||
|
sticker = db0.select().from(schema.spaceStickers)
|
||||||
|
.where(eq(schema.spaceStickers.id, stickerId)).get() ?? null;
|
||||||
|
if (!sticker) {
|
||||||
|
return reply.code(400).send({ error: 'Sticker not found', statusCode: 400 });
|
||||||
|
}
|
||||||
|
// Só figurinhas do próprio espaço: aceitar de outro vazaria imagem entre
|
||||||
|
// servidores que não têm relação nenhuma.
|
||||||
|
if (sticker.spaceId !== spaceId) {
|
||||||
|
return reply.code(400).send({ error: 'Sticker belongs to another space', statusCode: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasContent && !hasAttachments && !sticker) {
|
||||||
|
return reply.code(400).send({ error: 'Message must have content, attachments or a sticker', statusCode: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (content && content.length > MAX_MESSAGE_LENGTH) {
|
if (content && content.length > MAX_MESSAGE_LENGTH) {
|
||||||
return reply.code(400).send({ error: `Message content must be ${MAX_MESSAGE_LENGTH} characters or less`, statusCode: 400 });
|
return reply.code(400).send({ error: `Message content must be ${MAX_MESSAGE_LENGTH} characters or less`, statusCode: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const db = getDb();
|
|
||||||
const messageId = generateSnowflake();
|
const messageId = generateSnowflake();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
@@ -323,6 +360,7 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
channelId: id,
|
channelId: id,
|
||||||
userId: request.userId,
|
userId: request.userId,
|
||||||
replyToId: replyToId || null,
|
replyToId: replyToId || null,
|
||||||
|
stickerId: sticker ? sticker.id : null,
|
||||||
content: content?.trim() || null,
|
content: content?.trim() || null,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
}).run();
|
}).run();
|
||||||
@@ -400,9 +438,21 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
return reply.code(403).send({ error: 'You can only edit your own messages', statusCode: 403 });
|
return reply.code(403).send({ error: 'You can only edit your own messages', statusCode: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Same gate as on create — otherwise editing a message is the way around it.
|
||||||
|
let edited = content;
|
||||||
|
if (mentionsEveryone(edited)) {
|
||||||
|
const editSpaceId = getChannelSpaceId(message.channelId);
|
||||||
|
if (
|
||||||
|
!editSpaceId ||
|
||||||
|
!hasPermission(request.userId, editSpaceId, PermissionBits.MENTION_EVERYONE, message.channelId)
|
||||||
|
) {
|
||||||
|
edited = stripEveryoneMention(edited);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
db.update(schema.messages)
|
db.update(schema.messages)
|
||||||
.set({ content: content.trim(), editedAt: now })
|
.set({ content: edited.trim(), editedAt: now })
|
||||||
.where(eq(schema.messages.id, id))
|
.where(eq(schema.messages.id, id))
|
||||||
.run();
|
.run();
|
||||||
|
|
||||||
@@ -500,4 +550,146 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
|
|
||||||
return reply.code(200).send({ success: true });
|
return reply.code(200).send({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Pins ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assembles rows into the shape clients expect. Same batching the channel
|
||||||
|
* listing uses — kept in one place so pins and history cannot drift apart in
|
||||||
|
* what they include.
|
||||||
|
*/
|
||||||
|
function assembleMessages(rows: (typeof schema.messages.$inferSelect)[]): MessageWithUser[] {
|
||||||
|
if (rows.length === 0) return [];
|
||||||
|
const db = getDb();
|
||||||
|
const userIds = [...new Set(rows.map(m => m.userId))];
|
||||||
|
const userMap = new Map(
|
||||||
|
db.select().from(schema.users).where(inArray(schema.users.id, userIds)).all().map(u => [u.id, u]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const messageIds = rows.map(m => m.id);
|
||||||
|
const attachmentMap = new Map<string, (typeof schema.attachments.$inferSelect)[]>();
|
||||||
|
for (const att of db.select().from(schema.attachments)
|
||||||
|
.where(inArray(schema.attachments.messageId, messageIds)).all()) {
|
||||||
|
const mid = att.messageId ?? '';
|
||||||
|
if (!attachmentMap.has(mid)) attachmentMap.set(mid, []);
|
||||||
|
attachmentMap.get(mid)!.push(att);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reactionsMap = fetchReactionsForMessages(messageIds);
|
||||||
|
const embedMap = fetchEmbedsForMessages(messageIds);
|
||||||
|
const replyToMap = fetchReplyToMessages(rows);
|
||||||
|
|
||||||
|
return rows.map(m => {
|
||||||
|
const user = userMap.get(m.userId);
|
||||||
|
if (!user) return null;
|
||||||
|
const replyTo = m.replyToId ? (replyToMap.get(m.replyToId) ?? null) : null;
|
||||||
|
return buildMessageWithUser(m, user, attachmentMap.get(m.id) ?? [],
|
||||||
|
reactionsMap.get(m.id) ?? [], replyTo, embedMap.get(m.id) ?? []);
|
||||||
|
}).filter((m): m is MessageWithUser => m !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Discord caps a channel at 50; the same ceiling keeps the panel usable. */
|
||||||
|
const MAX_PINS_PER_CHANNEL = 50;
|
||||||
|
|
||||||
|
app.get<{ Params: { id: string } }>('/api/channels/:id/pins', { preHandler: authenticate }, async (request, reply) => {
|
||||||
|
const { id } = request.params;
|
||||||
|
const spaceId = getChannelSpaceId(id);
|
||||||
|
if (!spaceId) return reply.code(404).send({ error: 'Channel not found', statusCode: 404 });
|
||||||
|
if (!hasPermission(request.userId, spaceId, PermissionBits.VIEW_CHANNEL, id)) {
|
||||||
|
return reply.code(403).send({ error: 'Cannot view this channel', statusCode: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const rows = db.select()
|
||||||
|
.from(schema.messages)
|
||||||
|
.where(and(eq(schema.messages.channelId, id), isNotNull(schema.messages.pinnedAt)))
|
||||||
|
.orderBy(desc(schema.messages.pinnedAt))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
return reply.code(200).send({ messages: assembleMessages(rows) });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put<{ Params: { id: string } }>('/api/messages/:id/pin', { preHandler: authenticate }, async (request, reply) => {
|
||||||
|
const db = getDb();
|
||||||
|
const message = db.select().from(schema.messages).where(eq(schema.messages.id, request.params.id)).get();
|
||||||
|
if (!message) return reply.code(404).send({ error: 'Message not found', statusCode: 404 });
|
||||||
|
|
||||||
|
const spaceId = getChannelSpaceId(message.channelId);
|
||||||
|
if (!spaceId) return reply.code(404).send({ error: 'Channel not found', statusCode: 404 });
|
||||||
|
if (!hasPermission(request.userId, spaceId, PermissionBits.MANAGE_MESSAGES, message.channelId)) {
|
||||||
|
return reply.code(403).send({ error: 'Missing MANAGE_MESSAGES permission', statusCode: 403 });
|
||||||
|
}
|
||||||
|
if (message.pinnedAt) return reply.code(200).send({ success: true });
|
||||||
|
|
||||||
|
const count = db.select({ id: schema.messages.id })
|
||||||
|
.from(schema.messages)
|
||||||
|
.where(and(eq(schema.messages.channelId, message.channelId), isNotNull(schema.messages.pinnedAt)))
|
||||||
|
.all().length;
|
||||||
|
if (count >= MAX_PINS_PER_CHANNEL) {
|
||||||
|
return reply.code(409).send({ error: `At most ${MAX_PINS_PER_CHANNEL} pinned messages per channel`, statusCode: 409 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
db.update(schema.messages)
|
||||||
|
.set({ pinnedAt: now, pinnedBy: request.userId })
|
||||||
|
.where(eq(schema.messages.id, message.id)).run();
|
||||||
|
|
||||||
|
connectionManager.sendToChannel(spaceId, message.channelId, {
|
||||||
|
type: 'message_pinned',
|
||||||
|
channelId: message.channelId,
|
||||||
|
messageId: message.id,
|
||||||
|
pinned: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// A pin is a channel event, not just a flag on one row: post a system
|
||||||
|
// notice so everyone reading later knows something was pinned, and by whom.
|
||||||
|
// Same shape as the DM lifecycle notices — JSON payload, type 'system' —
|
||||||
|
// so the client renders it with the renderer it already has. Unpinning
|
||||||
|
// deliberately posts nothing; it is housekeeping, not news.
|
||||||
|
const actor = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
|
||||||
|
if (actor) {
|
||||||
|
const noticeId = generateSnowflake();
|
||||||
|
db.insert(schema.messages).values({
|
||||||
|
id: noticeId,
|
||||||
|
channelId: message.channelId,
|
||||||
|
userId: request.userId,
|
||||||
|
content: JSON.stringify({ event: 'message_pinned', messageId: message.id }),
|
||||||
|
type: 'system',
|
||||||
|
createdAt: now + 1, // strictly after the pin timestamp, so ordering is stable
|
||||||
|
}).run();
|
||||||
|
const noticeRow = db.select().from(schema.messages).where(eq(schema.messages.id, noticeId)).get();
|
||||||
|
if (noticeRow) {
|
||||||
|
connectionManager.sendToChannel(spaceId, message.channelId, {
|
||||||
|
type: 'message_created',
|
||||||
|
message: buildMessageWithUser(noticeRow, actor, [], [], null),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return reply.code(200).send({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete<{ Params: { id: string } }>('/api/messages/:id/pin', { preHandler: authenticate }, async (request, reply) => {
|
||||||
|
const db = getDb();
|
||||||
|
const message = db.select().from(schema.messages).where(eq(schema.messages.id, request.params.id)).get();
|
||||||
|
if (!message) return reply.code(404).send({ error: 'Message not found', statusCode: 404 });
|
||||||
|
|
||||||
|
const spaceId = getChannelSpaceId(message.channelId);
|
||||||
|
if (!spaceId) return reply.code(404).send({ error: 'Channel not found', statusCode: 404 });
|
||||||
|
if (!hasPermission(request.userId, spaceId, PermissionBits.MANAGE_MESSAGES, message.channelId)) {
|
||||||
|
return reply.code(403).send({ error: 'Missing MANAGE_MESSAGES permission', statusCode: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.update(schema.messages)
|
||||||
|
.set({ pinnedAt: null, pinnedBy: null })
|
||||||
|
.where(eq(schema.messages.id, message.id)).run();
|
||||||
|
|
||||||
|
connectionManager.sendToChannel(spaceId, message.channelId, {
|
||||||
|
type: 'message_pinned',
|
||||||
|
channelId: message.channelId,
|
||||||
|
messageId: message.id,
|
||||||
|
pinned: false,
|
||||||
|
});
|
||||||
|
return reply.code(200).send({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { getDb, schema } from '../db/index.js';
|
||||||
|
import { authenticate } from '../utils/auth.js';
|
||||||
|
import { hasPermission, isMember } from '../utils/permissions.js';
|
||||||
|
import { PermissionBits } from '@backspace/shared/src/permissions.js';
|
||||||
|
import { generateSnowflake } from '../utils/snowflake.js';
|
||||||
|
|
||||||
|
/** A soundboard is a shortlist of gags, not a media library. */
|
||||||
|
const MAX_SOUNDS_PER_SPACE = 48;
|
||||||
|
const MAX_NAME_LENGTH = 32;
|
||||||
|
|
||||||
|
export async function soundboardRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
app.get<{ Params: { id: string } }>(
|
||||||
|
'/api/spaces/:id/sounds',
|
||||||
|
{ preHandler: authenticate },
|
||||||
|
async (request, reply) => {
|
||||||
|
if (!isMember(request.params.id, request.userId)) {
|
||||||
|
return reply.code(403).send({ error: 'Not a member of this space', statusCode: 403 });
|
||||||
|
}
|
||||||
|
const rows = getDb().select().from(schema.soundboardSounds)
|
||||||
|
.where(eq(schema.soundboardSounds.spaceId, request.params.id)).all();
|
||||||
|
return reply.code(200).send({ sounds: rows });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post<{ Params: { id: string }; Body: { name?: string; filename?: string } }>(
|
||||||
|
'/api/spaces/:id/sounds',
|
||||||
|
{ preHandler: authenticate },
|
||||||
|
async (request, reply) => {
|
||||||
|
const { id } = request.params;
|
||||||
|
// Adding is gated but playing is not: anyone in the call may press a
|
||||||
|
// button, only the people who run the space decide what the buttons are.
|
||||||
|
if (!hasPermission(request.userId, id, PermissionBits.MANAGE_SPACE)) {
|
||||||
|
return reply.code(403).send({ error: 'Missing MANAGE_SPACE permission', statusCode: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = (request.body?.name ?? '').trim().slice(0, MAX_NAME_LENGTH);
|
||||||
|
const filename = (request.body?.filename ?? '').trim();
|
||||||
|
if (!name || !filename) {
|
||||||
|
return reply.code(400).send({ error: 'name and filename are required', statusCode: 400 });
|
||||||
|
}
|
||||||
|
// The filename is a key into the upload directory, never a path.
|
||||||
|
if (filename.includes('/') || filename.includes('\\') || filename.includes('..')) {
|
||||||
|
return reply.code(400).send({ error: 'Invalid filename', statusCode: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const count = db.select().from(schema.soundboardSounds)
|
||||||
|
.where(eq(schema.soundboardSounds.spaceId, id)).all().length;
|
||||||
|
if (count >= MAX_SOUNDS_PER_SPACE) {
|
||||||
|
return reply.code(409).send({ error: `At most ${MAX_SOUNDS_PER_SPACE} sounds`, statusCode: 409 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = {
|
||||||
|
id: generateSnowflake(),
|
||||||
|
spaceId: id,
|
||||||
|
name,
|
||||||
|
filename,
|
||||||
|
uploaderId: request.userId,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
};
|
||||||
|
db.insert(schema.soundboardSounds).values(row).run();
|
||||||
|
return reply.code(201).send(row);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.delete<{ Params: { id: string } }>(
|
||||||
|
'/api/sounds/:id',
|
||||||
|
{ preHandler: authenticate },
|
||||||
|
async (request, reply) => {
|
||||||
|
const db = getDb();
|
||||||
|
const sound = db.select().from(schema.soundboardSounds)
|
||||||
|
.where(eq(schema.soundboardSounds.id, request.params.id)).get();
|
||||||
|
if (!sound) return reply.code(404).send({ error: 'Sound not found', statusCode: 404 });
|
||||||
|
|
||||||
|
if (!hasPermission(request.userId, sound.spaceId, PermissionBits.MANAGE_SPACE)) {
|
||||||
|
return reply.code(403).send({ error: 'Missing MANAGE_SPACE permission', statusCode: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.delete(schema.soundboardSounds).where(eq(schema.soundboardSounds.id, request.params.id)).run();
|
||||||
|
return reply.code(204).send();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import type { FastifyInstance } from 'fastify';
|
|||||||
import { eq, and, inArray } from 'drizzle-orm';
|
import { eq, and, inArray } from 'drizzle-orm';
|
||||||
import { getDb, getRawDb, schema } from '../db/index.js';
|
import { getDb, getRawDb, schema } from '../db/index.js';
|
||||||
import { authenticate } from '../utils/auth.js';
|
import { authenticate } from '../utils/auth.js';
|
||||||
|
import { recordAuditEvent } from '../utils/auditLog.js';
|
||||||
import { generateSnowflake } from '../utils/snowflake.js';
|
import { generateSnowflake } from '../utils/snowflake.js';
|
||||||
import { isMember, isSpaceOwner, isBanned, hasPermission, computePermissions, PermissionBits } from '../utils/permissions.js';
|
import { isMember, isSpaceOwner, isBanned, hasPermission, computePermissions, PermissionBits } from '../utils/permissions.js';
|
||||||
import { DEFAULT_EVERYONE_PERMISSIONS, ALL_PERMISSIONS, permissionsToString } from '@backspace/shared/src/permissions.js';
|
import { DEFAULT_EVERYONE_PERMISSIONS, ALL_PERMISSIONS, permissionsToString } from '@backspace/shared/src/permissions.js';
|
||||||
@@ -489,6 +490,15 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
space: spaceData,
|
space: spaceData,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
recordAuditEvent({
|
||||||
|
spaceId: id,
|
||||||
|
actorId: request.userId,
|
||||||
|
action: 'space.update',
|
||||||
|
targetType: 'space',
|
||||||
|
targetId: id,
|
||||||
|
metadata: { fields: Object.keys(updates) },
|
||||||
|
});
|
||||||
|
|
||||||
return reply.code(200).send(spaceData);
|
return reply.code(200).send(spaceData);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1005,6 +1015,16 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
userId: uid,
|
userId: uid,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
recordAuditEvent({
|
||||||
|
spaceId: id,
|
||||||
|
actorId: request.userId,
|
||||||
|
// Leaving on your own is not the same event as being removed by someone
|
||||||
|
// else, and a log that conflates the two misleads exactly when it matters.
|
||||||
|
action: request.userId === uid ? 'member.leave' : 'member.kick',
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: uid,
|
||||||
|
});
|
||||||
|
|
||||||
return reply.code(200).send({ success: true });
|
return reply.code(200).send({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1068,6 +1088,17 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
}
|
}
|
||||||
checkVoicePermissions(id);
|
checkVoicePermissions(id);
|
||||||
|
|
||||||
|
recordAuditEvent({
|
||||||
|
spaceId: id,
|
||||||
|
actorId: request.userId,
|
||||||
|
action: 'role.create',
|
||||||
|
targetType: 'role',
|
||||||
|
// roleId is the value just inserted; `role` is a read-back the compiler
|
||||||
|
// cannot prove returned a row.
|
||||||
|
targetId: roleId,
|
||||||
|
metadata: { name: role?.name ?? null },
|
||||||
|
});
|
||||||
|
|
||||||
return reply.code(201).send(role);
|
return reply.code(201).send(role);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1125,6 +1156,15 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
}
|
}
|
||||||
checkVoicePermissions(id);
|
checkVoicePermissions(id);
|
||||||
|
|
||||||
|
recordAuditEvent({
|
||||||
|
spaceId: id,
|
||||||
|
actorId: request.userId,
|
||||||
|
action: 'role.update',
|
||||||
|
targetType: 'role',
|
||||||
|
targetId: roleId,
|
||||||
|
metadata: { name: updated?.name ?? null },
|
||||||
|
});
|
||||||
|
|
||||||
return reply.code(200).send(updated);
|
return reply.code(200).send(updated);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1247,6 +1287,14 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
space: spaceData,
|
space: spaceData,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
recordAuditEvent({
|
||||||
|
spaceId: id,
|
||||||
|
actorId: request.userId,
|
||||||
|
action: 'space.transfer_ownership',
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: newOwnerId,
|
||||||
|
});
|
||||||
|
|
||||||
return reply.code(200).send(spaceData);
|
return reply.code(200).send(spaceData);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { getDb, schema } from '../db/index.js';
|
||||||
|
import { authenticate } from '../utils/auth.js';
|
||||||
|
import { config } from '../config.js';
|
||||||
|
import { getOurOrigin } from '../utils/federationAuth.js';
|
||||||
|
import type { Activity } from '@backspace/shared';
|
||||||
|
|
||||||
|
const SPOTIFY_AUTH = 'https://accounts.spotify.com/authorize';
|
||||||
|
const SPOTIFY_TOKEN = 'https://accounts.spotify.com/api/token';
|
||||||
|
const SPOTIFY_NOW_PLAYING = 'https://api.spotify.com/v1/me/player/currently-playing';
|
||||||
|
|
||||||
|
// Read-only: enough to see the current track, nothing that can control playback
|
||||||
|
// or read the library.
|
||||||
|
const SCOPES = 'user-read-currently-playing user-read-playback-state';
|
||||||
|
|
||||||
|
/** Refresh this many ms before expiry, so a request never races the deadline. */
|
||||||
|
const REFRESH_MARGIN_MS = 60_000;
|
||||||
|
|
||||||
|
function redirectUri(): string {
|
||||||
|
return `${getOurOrigin()}/api/connections/spotify/callback`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isConfigured(): boolean {
|
||||||
|
return Boolean(config.spotify.clientId && config.spotify.clientSecret);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OAuth `state`, signed with the instance's JWT secret.
|
||||||
|
*
|
||||||
|
* The callback arrives as a browser redirect, which carries no Authorization
|
||||||
|
* header — so the state has to say who started the flow, and be tamper-proof
|
||||||
|
* or anyone could bind their Spotify account to someone else's user.
|
||||||
|
*/
|
||||||
|
function signState(userId: string): string {
|
||||||
|
const payload = Buffer.from(JSON.stringify({ userId, exp: Date.now() + 10 * 60_000 })).toString('base64url');
|
||||||
|
const sig = crypto.createHmac('sha256', config.jwtSecret).update(payload).digest('base64url');
|
||||||
|
return `${payload}.${sig}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyState(state: string): string | null {
|
||||||
|
const [payload, sig] = state.split('.');
|
||||||
|
if (!payload || !sig) return null;
|
||||||
|
const expected = crypto.createHmac('sha256', config.jwtSecret).update(payload).digest('base64url');
|
||||||
|
const a = Buffer.from(sig);
|
||||||
|
const b = Buffer.from(expected);
|
||||||
|
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null;
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(Buffer.from(payload, 'base64url').toString()) as { userId: string; exp: number };
|
||||||
|
if (!data.userId || typeof data.exp !== 'number' || data.exp < Date.now()) return null;
|
||||||
|
return data.userId;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function basicAuthHeader(): string {
|
||||||
|
return 'Basic ' + Buffer.from(`${config.spotify.clientId}:${config.spotify.clientSecret}`).toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a usable access token, refreshing it first when it is about to
|
||||||
|
* expire. Returns null when the connection is gone or Spotify rejected the
|
||||||
|
* refresh token — the caller then treats the user as disconnected.
|
||||||
|
*/
|
||||||
|
async function getAccessToken(userId: string): Promise<string | null> {
|
||||||
|
const db = getDb();
|
||||||
|
const row = db.select().from(schema.spotifyConnections)
|
||||||
|
.where(eq(schema.spotifyConnections.userId, userId)).get();
|
||||||
|
if (!row) return null;
|
||||||
|
|
||||||
|
if (row.expiresAt - REFRESH_MARGIN_MS > Date.now()) return row.accessToken;
|
||||||
|
|
||||||
|
const res = await fetch(SPOTIFY_TOKEN, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: basicAuthHeader(), 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: row.refreshToken }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
// A refresh token is rejected when the user revoked access on Spotify's
|
||||||
|
// side. Drop the row so the UI stops claiming a live connection.
|
||||||
|
if (res.status === 400 || res.status === 401) {
|
||||||
|
db.delete(schema.spotifyConnections).where(eq(schema.spotifyConnections.userId, userId)).run();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await res.json() as { access_token: string; expires_in: number; refresh_token?: string };
|
||||||
|
db.update(schema.spotifyConnections).set({
|
||||||
|
accessToken: body.access_token,
|
||||||
|
// Spotify only returns a new refresh token sometimes; keep the old one otherwise.
|
||||||
|
refreshToken: body.refresh_token ?? row.refreshToken,
|
||||||
|
expiresAt: Date.now() + body.expires_in * 1000,
|
||||||
|
}).where(eq(schema.spotifyConnections.userId, userId)).run();
|
||||||
|
|
||||||
|
return body.access_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SpotifyTrack {
|
||||||
|
is_playing: boolean;
|
||||||
|
progress_ms: number | null;
|
||||||
|
item: {
|
||||||
|
name: string;
|
||||||
|
duration_ms: number;
|
||||||
|
artists: { name: string }[];
|
||||||
|
album: { name: string; images: { url: string }[] };
|
||||||
|
external_urls?: { spotify?: string };
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps Spotify's payload onto the Activity shape the profile card renders.
|
||||||
|
*
|
||||||
|
* A paused track is still reported, marked `paused`. Returning null for it made
|
||||||
|
* the block disappear on every pause — and, together with the silent gap
|
||||||
|
* between two songs, produced the flicker of it vanishing and coming back.
|
||||||
|
*/
|
||||||
|
function toActivity(track: SpotifyTrack): Activity | null {
|
||||||
|
if (!track.item) return null;
|
||||||
|
const now = Date.now();
|
||||||
|
const progress = track.progress_ms ?? 0;
|
||||||
|
return {
|
||||||
|
type: 'listening',
|
||||||
|
name: 'Spotify',
|
||||||
|
paused: !track.is_playing,
|
||||||
|
details: track.item.name,
|
||||||
|
state: track.item.artists.map((a) => a.name).join(', '),
|
||||||
|
timestamps: { start: now - progress, end: now - progress + track.item.duration_ms },
|
||||||
|
assets: {
|
||||||
|
largeImage: track.item.album.images[0]?.url,
|
||||||
|
largeText: track.item.album.name,
|
||||||
|
},
|
||||||
|
url: track.item.external_urls?.spotify,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function spotifyRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
app.get('/api/connections/spotify/status', { preHandler: authenticate }, async (request, reply) => {
|
||||||
|
if (!isConfigured()) return reply.code(200).send({ configured: false, connected: false });
|
||||||
|
const db = getDb();
|
||||||
|
const row = db.select({ userId: schema.spotifyConnections.userId })
|
||||||
|
.from(schema.spotifyConnections)
|
||||||
|
.where(eq(schema.spotifyConnections.userId, request.userId)).get();
|
||||||
|
return reply.code(200).send({ configured: true, connected: Boolean(row) });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Returns the URL rather than redirecting: the caller is fetch(), which would
|
||||||
|
// follow a 302 to Spotify instead of navigating the window there.
|
||||||
|
app.get('/api/connections/spotify/authorize', { preHandler: authenticate }, async (request, reply) => {
|
||||||
|
if (!isConfigured()) {
|
||||||
|
return reply.code(503).send({ error: 'Spotify is not configured on this instance', statusCode: 503 });
|
||||||
|
}
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_id: config.spotify.clientId!,
|
||||||
|
response_type: 'code',
|
||||||
|
redirect_uri: redirectUri(),
|
||||||
|
scope: SCOPES,
|
||||||
|
state: signState(request.userId),
|
||||||
|
});
|
||||||
|
return reply.code(200).send({ url: `${SPOTIFY_AUTH}?${params}` });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get<{ Querystring: { code?: string; state?: string; error?: string } }>(
|
||||||
|
'/api/connections/spotify/callback',
|
||||||
|
async (request, reply) => {
|
||||||
|
const { code, state, error } = request.query;
|
||||||
|
const settingsUrl = `${getOurOrigin()}/channels/@me?settings=connections`;
|
||||||
|
|
||||||
|
if (error || !code || !state) return reply.redirect(`${settingsUrl}&spotify=denied`);
|
||||||
|
|
||||||
|
const userId = verifyState(state);
|
||||||
|
if (!userId) return reply.redirect(`${settingsUrl}&spotify=invalid_state`);
|
||||||
|
|
||||||
|
const res = await fetch(SPOTIFY_TOKEN, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: basicAuthHeader(), 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: redirectUri() }),
|
||||||
|
});
|
||||||
|
if (!res.ok) return reply.redirect(`${settingsUrl}&spotify=exchange_failed`);
|
||||||
|
|
||||||
|
const body = await res.json() as { access_token: string; refresh_token: string; expires_in: number };
|
||||||
|
const db = getDb();
|
||||||
|
const row = {
|
||||||
|
userId,
|
||||||
|
accessToken: body.access_token,
|
||||||
|
refreshToken: body.refresh_token,
|
||||||
|
expiresAt: Date.now() + body.expires_in * 1000,
|
||||||
|
spotifyUserId: null,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
};
|
||||||
|
db.insert(schema.spotifyConnections).values(row)
|
||||||
|
.onConflictDoUpdate({ target: schema.spotifyConnections.userId, set: row }).run();
|
||||||
|
|
||||||
|
return reply.redirect(`${settingsUrl}&spotify=connected`);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.get('/api/connections/spotify/now-playing', { preHandler: authenticate }, async (request, reply) => {
|
||||||
|
const token = await getAccessToken(request.userId);
|
||||||
|
if (!token) return reply.code(200).send({ activity: null, connected: false, serverTime: Date.now() });
|
||||||
|
|
||||||
|
const res = await fetch(SPOTIFY_NOW_PLAYING, { headers: { Authorization: `Bearer ${token}` } });
|
||||||
|
// 204 means "nothing playing"; anything else non-OK is a transient problem
|
||||||
|
// and must not be reported as a lost connection.
|
||||||
|
if (res.status === 204) return reply.code(200).send({ activity: null, connected: true, serverTime: Date.now() });
|
||||||
|
if (!res.ok) return reply.code(200).send({ activity: null, connected: res.status !== 401, serverTime: Date.now() });
|
||||||
|
|
||||||
|
const track = await res.json() as SpotifyTrack;
|
||||||
|
return reply.code(200).send({ activity: toActivity(track), connected: true, serverTime: Date.now() });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/connections/spotify', { preHandler: authenticate }, async (request, reply) => {
|
||||||
|
const db = getDb();
|
||||||
|
db.delete(schema.spotifyConnections).where(eq(schema.spotifyConnections.userId, request.userId)).run();
|
||||||
|
return reply.code(204).send();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { and, eq, gte, sql, isNotNull, inArray } from 'drizzle-orm';
|
||||||
|
import { getDb, schema } from '../db/index.js';
|
||||||
|
import { authenticate } from '../utils/auth.js';
|
||||||
|
import { isMember } from '../utils/permissions.js';
|
||||||
|
|
||||||
|
/** Windows the UI offers. Anything else is clamped into this range. */
|
||||||
|
const DEFAULT_DAYS = 30;
|
||||||
|
const MAX_DAYS = 365;
|
||||||
|
|
||||||
|
interface Leader {
|
||||||
|
userId: string;
|
||||||
|
username: string;
|
||||||
|
displayName: string | null;
|
||||||
|
avatar: string | null;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function statsRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
app.get<{ Params: { id: string }; Querystring: { days?: string } }>(
|
||||||
|
'/api/spaces/:id/stats',
|
||||||
|
{ preHandler: authenticate },
|
||||||
|
async (request, reply) => {
|
||||||
|
const { id } = request.params;
|
||||||
|
|
||||||
|
// Any member may look: these are the group's own numbers, not moderation
|
||||||
|
// data. The audit log, which names who did what, stays admin-only.
|
||||||
|
if (!isMember(id, request.userId)) {
|
||||||
|
return reply.code(403).send({ error: 'Not a member of this space', statusCode: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const days = Math.min(Math.max(Number(request.query.days) || DEFAULT_DAYS, 1), MAX_DAYS);
|
||||||
|
const since = Date.now() - days * 24 * 60 * 60 * 1000;
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
// Voice time. Only closed sessions count: an open one has no duration
|
||||||
|
// yet, and counting "now - startedAt" would make the numbers move every
|
||||||
|
// time the page is refreshed.
|
||||||
|
const voiceRows = db.select({
|
||||||
|
userId: schema.voiceSessions.userId,
|
||||||
|
username: schema.users.username,
|
||||||
|
displayName: schema.users.displayName,
|
||||||
|
avatar: schema.users.avatar,
|
||||||
|
value: sql<number>`sum(${schema.voiceSessions.endedAt} - ${schema.voiceSessions.startedAt})`,
|
||||||
|
})
|
||||||
|
.from(schema.voiceSessions)
|
||||||
|
.innerJoin(schema.users, eq(schema.voiceSessions.userId, schema.users.id))
|
||||||
|
.where(and(
|
||||||
|
eq(schema.voiceSessions.spaceId, id),
|
||||||
|
gte(schema.voiceSessions.startedAt, since),
|
||||||
|
isNotNull(schema.voiceSessions.endedAt),
|
||||||
|
))
|
||||||
|
.groupBy(schema.voiceSessions.userId)
|
||||||
|
.all() as Leader[];
|
||||||
|
|
||||||
|
// Messages. Scoped through the space's channels — the messages table has
|
||||||
|
// no space column.
|
||||||
|
const channelIds = db.select({ id: schema.channels.id })
|
||||||
|
.from(schema.channels)
|
||||||
|
.where(eq(schema.channels.spaceId, id))
|
||||||
|
.all()
|
||||||
|
.map((c) => c.id);
|
||||||
|
|
||||||
|
const messageRows = channelIds.length === 0 ? [] : db.select({
|
||||||
|
userId: schema.messages.userId,
|
||||||
|
username: schema.users.username,
|
||||||
|
displayName: schema.users.displayName,
|
||||||
|
avatar: schema.users.avatar,
|
||||||
|
value: sql<number>`count(*)`,
|
||||||
|
})
|
||||||
|
.from(schema.messages)
|
||||||
|
.innerJoin(schema.users, eq(schema.messages.userId, schema.users.id))
|
||||||
|
.where(and(
|
||||||
|
inArray(schema.messages.channelId, channelIds),
|
||||||
|
gte(schema.messages.createdAt, since),
|
||||||
|
))
|
||||||
|
.groupBy(schema.messages.userId)
|
||||||
|
.all() as Leader[];
|
||||||
|
|
||||||
|
const byValueDesc = (a: Leader, b: Leader) => b.value - a.value;
|
||||||
|
|
||||||
|
return reply.code(200).send({
|
||||||
|
days,
|
||||||
|
since,
|
||||||
|
voice: voiceRows.sort(byValueDesc),
|
||||||
|
messages: messageRows.sort(byValueDesc),
|
||||||
|
totals: {
|
||||||
|
voiceMs: voiceRows.reduce((sum, r) => sum + (r.value ?? 0), 0),
|
||||||
|
messages: messageRows.reduce((sum, r) => sum + (r.value ?? 0), 0),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import { config } from '../config.js';
|
|||||||
import { buildFederationHeaders, getOurOrigin } from '../utils/federationAuth.js';
|
import { buildFederationHeaders, getOurOrigin } from '../utils/federationAuth.js';
|
||||||
import { extractDomain } from './federation.js';
|
import { extractDomain } from './federation.js';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import { normalizeProfileStyle } from '@backspace/shared/src/profile.js';
|
||||||
|
|
||||||
/** Validates that a URL is a safe asset URL (relative upload path, bare filename, or http/https) */
|
/** Validates that a URL is a safe asset URL (relative upload path, bare filename, or http/https) */
|
||||||
export function isValidAssetUrl(url: string | null | undefined): boolean {
|
export function isValidAssetUrl(url: string | null | undefined): boolean {
|
||||||
@@ -187,7 +188,7 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.patch<{ Body: UpdateUserRequest }>('/api/users/@me', { preHandler: authenticate }, async (request, reply) => {
|
app.patch<{ Body: UpdateUserRequest }>('/api/users/@me', { preHandler: authenticate }, async (request, reply) => {
|
||||||
const { displayName, avatar, banner, accentColor, avatarColor, bio, customStatus, status, replicatedInstances, homeUserId, profileUpdatedAt, discoverable, showActivity } = request.body;
|
const { displayName, avatar, banner, accentColor, avatarColor, bio, customStatus, status, replicatedInstances, homeUserId, profileUpdatedAt, discoverable, showActivity, profileStyle } = request.body;
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|
||||||
const updateData: Record<string, string | null | undefined> = {};
|
const updateData: Record<string, string | null | undefined> = {};
|
||||||
@@ -426,12 +427,22 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
(updateData as Record<string, unknown>).showActivity = showActivity ? 1 : 0;
|
(updateData as Record<string, unknown>).showActivity = showActivity ? 1 : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Profile style is normalised, never rejected: the shared normaliser
|
||||||
|
// clamps lengths, drops bad URLs and falls back on unknown ids, so the
|
||||||
|
// stored value is always well-formed. Null resets to defaults. Kept out of
|
||||||
|
// DURABLE_PROFILE_FIELDS on purpose — peers on older builds do not know
|
||||||
|
// the field, and it is cosmetic, so it stays local to this instance.
|
||||||
|
if (profileStyle !== undefined) {
|
||||||
|
(updateData as Record<string, unknown>).profileStyle =
|
||||||
|
profileStyle === null ? null : JSON.stringify(normalizeProfileStyle(profileStyle));
|
||||||
|
}
|
||||||
|
|
||||||
if (Object.keys(updateData).length === 0) {
|
if (Object.keys(updateData).length === 0) {
|
||||||
return reply.code(400).send({ error: 'No fields to update', statusCode: 400 });
|
return reply.code(400).send({ error: 'No fields to update', statusCode: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect profile changes and stamp timestamp
|
// Detect profile changes and stamp timestamp
|
||||||
const hasProfileChange = ['displayName', 'avatar', 'banner', 'accentColor', 'avatarColor', 'bio', 'customStatus'].some(f => f in updateData);
|
const hasProfileChange = ['displayName', 'avatar', 'banner', 'accentColor', 'avatarColor', 'bio', 'customStatus', 'profileStyle'].some(f => f in updateData);
|
||||||
const hasDurableChange = DURABLE_PROFILE_FIELDS.some(f => f in updateData);
|
const hasDurableChange = DURABLE_PROFILE_FIELDS.some(f => f in updateData);
|
||||||
if (hasDurableChange) {
|
if (hasDurableChange) {
|
||||||
(updateData as Record<string, unknown>).profileUpdatedAt = Date.now();
|
(updateData as Record<string, unknown>).profileUpdatedAt = Date.now();
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { getDb, schema } from '../db/index.js';
|
||||||
|
import { generateSnowflake } from './snowflake.js';
|
||||||
|
import type { AuditAction } from '@backspace/shared/src/audit.js';
|
||||||
|
|
||||||
|
interface RecordAuditEventInput {
|
||||||
|
spaceId: string;
|
||||||
|
actorId: string | null;
|
||||||
|
action: AuditAction;
|
||||||
|
targetType?: string | null;
|
||||||
|
targetId?: string | null;
|
||||||
|
metadata?: Record<string, unknown> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Appends one entry to a space's audit log.
|
||||||
|
*
|
||||||
|
* Never throws: an audit write must not be able to fail the action it is
|
||||||
|
* describing. A moderator kicking someone must not see the kick fail because
|
||||||
|
* the log could not be written — the kick already happened.
|
||||||
|
*/
|
||||||
|
export function recordAuditEvent(input: RecordAuditEventInput): void {
|
||||||
|
try {
|
||||||
|
getDb().insert(schema.auditEvents).values({
|
||||||
|
id: generateSnowflake(),
|
||||||
|
spaceId: input.spaceId,
|
||||||
|
actorId: input.actorId,
|
||||||
|
action: input.action,
|
||||||
|
targetType: input.targetType ?? null,
|
||||||
|
targetId: input.targetId ?? null,
|
||||||
|
metadata: input.metadata ? JSON.stringify(input.metadata) : null,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
}).run();
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[audit] failed to record event', input.action, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
import type { User, ReplicatedInstance } from '@backspace/shared';
|
import type { User, ReplicatedInstance } from '@backspace/shared';
|
||||||
|
import { normalizeProfileStyle } from '@backspace/shared/src/profile.js';
|
||||||
import { schema } from '../db/index.js';
|
import { schema } from '../db/index.js';
|
||||||
|
|
||||||
|
function safeParse(json: string): unknown {
|
||||||
|
try { return JSON.parse(json); } catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
export function sanitizeUser(row: typeof schema.users.$inferSelect, isSelf = false): User {
|
export function sanitizeUser(row: typeof schema.users.$inferSelect, isSelf = false): User {
|
||||||
// Tombstoned (deleted) users — return anonymized profile
|
// Tombstoned (deleted) users — return anonymized profile
|
||||||
if (row.isDeleted === 1) {
|
if (row.isDeleted === 1) {
|
||||||
@@ -54,6 +59,9 @@ export function sanitizeUser(row: typeof schema.users.$inferSelect, isSelf = fal
|
|||||||
homeInstance: row.homeInstance ?? null,
|
homeInstance: row.homeInstance ?? null,
|
||||||
homeUserId: row.homeUserId ?? null,
|
homeUserId: row.homeUserId ?? null,
|
||||||
replicatedInstances,
|
replicatedInstances,
|
||||||
|
// Re-normalised on read as well: a row written by an older build (or a
|
||||||
|
// stricter future one) still comes out complete and bounded.
|
||||||
|
profileStyle: row.profileStyle ? normalizeProfileStyle(safeParse(row.profileStyle)) : null,
|
||||||
...(isSelf
|
...(isSelf
|
||||||
? {
|
? {
|
||||||
showActivity: row.showActivity !== 0,
|
showActivity: row.showActivity !== 0,
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { and, eq, isNull, sql } from 'drizzle-orm';
|
||||||
|
import { getDb, schema } from '../db/index.js';
|
||||||
|
import { generateSnowflake } from './snowflake.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens a session when someone joins a voice room.
|
||||||
|
*
|
||||||
|
* Never throws: statistics must not be able to break a call. Closes any
|
||||||
|
* dangling session for the same user first — the one-room-per-user invariant
|
||||||
|
* means a second open row would be a bookkeeping error, not two real calls.
|
||||||
|
*/
|
||||||
|
export function openVoiceSession(input: {
|
||||||
|
spaceId: string | null;
|
||||||
|
channelId: string;
|
||||||
|
userId: string;
|
||||||
|
}): void {
|
||||||
|
try {
|
||||||
|
const db = getDb();
|
||||||
|
closeVoiceSession(input.userId);
|
||||||
|
db.insert(schema.voiceSessions).values({
|
||||||
|
id: generateSnowflake(),
|
||||||
|
spaceId: input.spaceId,
|
||||||
|
channelId: input.channelId,
|
||||||
|
userId: input.userId,
|
||||||
|
startedAt: Date.now(),
|
||||||
|
endedAt: null,
|
||||||
|
}).run();
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[voice-sessions] failed to open session', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Closes the user's open session, if any. Never throws. */
|
||||||
|
export function closeVoiceSession(userId: string): void {
|
||||||
|
try {
|
||||||
|
getDb().update(schema.voiceSessions)
|
||||||
|
.set({ endedAt: Date.now() })
|
||||||
|
.where(and(eq(schema.voiceSessions.userId, userId), isNull(schema.voiceSessions.endedAt)))
|
||||||
|
.run();
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[voice-sessions] failed to close session', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closes sessions left open by a crash or restart.
|
||||||
|
*
|
||||||
|
* Their real end time is unknowable. Ending them at `startedAt` — a zero-length
|
||||||
|
* session — discards that time rather than inventing it: crediting the gap
|
||||||
|
* would silently hand someone hours they never spent, and the numbers are the
|
||||||
|
* entire point of keeping this table.
|
||||||
|
*/
|
||||||
|
export function closeOrphanedVoiceSessions(): void {
|
||||||
|
try {
|
||||||
|
const result = getDb().update(schema.voiceSessions)
|
||||||
|
.set({ endedAt: sql`${schema.voiceSessions.startedAt}` })
|
||||||
|
.where(isNull(schema.voiceSessions.endedAt))
|
||||||
|
.run();
|
||||||
|
if (result.changes > 0) {
|
||||||
|
console.log(`[voice-sessions] closed ${result.changes} session(s) orphaned by a restart`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[voice-sessions] failed to close orphans', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -168,6 +168,10 @@ export function handleClientEvent(
|
|||||||
case 'voice_join':
|
case 'voice_join':
|
||||||
handleVoiceJoin(event, userId, ws);
|
handleVoiceJoin(event, userId, ws);
|
||||||
break;
|
break;
|
||||||
|
case 'soundboard_play':
|
||||||
|
handleSoundboardPlay(event, userId);
|
||||||
|
break;
|
||||||
|
|
||||||
case 'voice_leave':
|
case 'voice_leave':
|
||||||
handleVoiceLeave(userId);
|
handleVoiceLeave(userId);
|
||||||
break;
|
break;
|
||||||
@@ -470,12 +474,23 @@ function validateActivities(raw: unknown): Activity[] | null {
|
|||||||
if (ts.start !== undefined || ts.end !== undefined) activity.timestamps = ts;
|
if (ts.start !== undefined || ts.end !== undefined) activity.timestamps = ts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Preserved through validation: without it the paused flag is stripped on
|
||||||
|
// its way to everyone else, and the block resumes ticking on their screens.
|
||||||
|
if (obj.paused === true) activity.paused = true;
|
||||||
|
|
||||||
if (obj.assets && typeof obj.assets === 'object') {
|
if (obj.assets && typeof obj.assets === 'object') {
|
||||||
const aObj = obj.assets as Record<string, unknown>;
|
const aObj = obj.assets as Record<string, unknown>;
|
||||||
const assets: ActivityAssets = {};
|
const assets: ActivityAssets = {};
|
||||||
if (typeof aObj.largeImage === 'string' && aObj.largeImage.length <= ACTIVITY_LIMITS.MAX_URL_LENGTH) assets.largeImage = aObj.largeImage;
|
// Image assets are rendered as <img src> by clients, so they get the same
|
||||||
|
// scheme check `url` above already has. Without it a client could point
|
||||||
|
// them at a host it controls and harvest the IP of everyone who opens
|
||||||
|
// that profile — and data: URIs would smuggle payloads through a field
|
||||||
|
// only length-checked.
|
||||||
|
if (typeof aObj.largeImage === 'string' && aObj.largeImage.length <= ACTIVITY_LIMITS.MAX_URL_LENGTH
|
||||||
|
&& isHttpUrl(aObj.largeImage)) assets.largeImage = aObj.largeImage;
|
||||||
if (typeof aObj.largeText === 'string' && aObj.largeText.length <= ACTIVITY_LIMITS.MAX_ASSET_TEXT_LENGTH) assets.largeText = aObj.largeText;
|
if (typeof aObj.largeText === 'string' && aObj.largeText.length <= ACTIVITY_LIMITS.MAX_ASSET_TEXT_LENGTH) assets.largeText = aObj.largeText;
|
||||||
if (typeof aObj.smallImage === 'string' && aObj.smallImage.length <= ACTIVITY_LIMITS.MAX_URL_LENGTH) assets.smallImage = aObj.smallImage;
|
if (typeof aObj.smallImage === 'string' && aObj.smallImage.length <= ACTIVITY_LIMITS.MAX_URL_LENGTH
|
||||||
|
&& isHttpUrl(aObj.smallImage)) assets.smallImage = aObj.smallImage;
|
||||||
if (typeof aObj.smallText === 'string' && aObj.smallText.length <= ACTIVITY_LIMITS.MAX_ASSET_TEXT_LENGTH) assets.smallText = aObj.smallText;
|
if (typeof aObj.smallText === 'string' && aObj.smallText.length <= ACTIVITY_LIMITS.MAX_ASSET_TEXT_LENGTH) assets.smallText = aObj.smallText;
|
||||||
if (Object.keys(assets).length > 0) activity.assets = assets;
|
if (Object.keys(assets).length > 0) activity.assets = assets;
|
||||||
}
|
}
|
||||||
@@ -485,6 +500,10 @@ function validateActivities(raw: unknown): Activity[] | null {
|
|||||||
return validated;
|
return validated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isHttpUrl(value: string): boolean {
|
||||||
|
return value.startsWith('https://') || value.startsWith('http://');
|
||||||
|
}
|
||||||
|
|
||||||
function handlePresenceUpdate(event: Record<string, unknown>, userId: string): void {
|
function handlePresenceUpdate(event: Record<string, unknown>, userId: string): void {
|
||||||
const status = event.status as string;
|
const status = event.status as string;
|
||||||
|
|
||||||
@@ -713,12 +732,14 @@ function handleVoiceJoin(event: Record<string, unknown>, userId: string, ws: Web
|
|||||||
// Join room
|
// Join room
|
||||||
connectionManager.joinRoom(channelId, userId);
|
connectionManager.joinRoom(channelId, userId);
|
||||||
|
|
||||||
// Broadcast join
|
// Broadcast join. Carries when this occupancy began so late joiners show the
|
||||||
|
// call's real elapsed time rather than counting from their own arrival.
|
||||||
connectionManager.sendToRoom(channelId, {
|
connectionManager.sendToRoom(channelId, {
|
||||||
type: 'voice_state_update',
|
type: 'voice_state_update',
|
||||||
channelId,
|
channelId,
|
||||||
userId,
|
userId,
|
||||||
action: 'join',
|
action: 'join',
|
||||||
|
startedAt: connectionManager.getRoomStartedAt(channelId) ?? undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Also broadcast current voice status if it exists (persisted during moves)
|
// Also broadcast current voice status if it exists (persisted during moves)
|
||||||
@@ -783,6 +804,48 @@ function handleVoiceJoin(event: Record<string, unknown>, userId: string, ws: Web
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimum gap between one person's soundboard triggers.
|
||||||
|
*
|
||||||
|
* Enforced on the server: a client-side cooldown only slows down people who
|
||||||
|
* are not trying to abuse it, and a soundboard is the easiest thing in a chat
|
||||||
|
* app to turn into a weapon.
|
||||||
|
*/
|
||||||
|
const SOUNDBOARD_COOLDOWN_MS = 2000;
|
||||||
|
const lastSoundboardPlay = new Map<string, number>();
|
||||||
|
|
||||||
|
function handleSoundboardPlay(event: Record<string, unknown>, userId: string): void {
|
||||||
|
const soundId = event.soundId;
|
||||||
|
if (typeof soundId !== 'string' || !soundId) return;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const last = lastSoundboardPlay.get(userId) ?? 0;
|
||||||
|
if (now - last < SOUNDBOARD_COOLDOWN_MS) return;
|
||||||
|
|
||||||
|
// Must be in a voice room: a soundboard is something you press while in a
|
||||||
|
// call, not a way to make noise in a call you are not part of.
|
||||||
|
const userRoom = connectionManager.getUserRoom(userId);
|
||||||
|
if (!userRoom || userRoom.room.roomType !== 'space') return;
|
||||||
|
|
||||||
|
const sound = getDb().select().from(schema.soundboardSounds)
|
||||||
|
.where(eq(schema.soundboardSounds.id, soundId)).get();
|
||||||
|
if (!sound) return;
|
||||||
|
|
||||||
|
// And the sound must belong to the space whose call they are in.
|
||||||
|
const meta = userRoom.room.metadata as SpaceRoomMeta;
|
||||||
|
if (sound.spaceId !== meta.spaceId) return;
|
||||||
|
|
||||||
|
lastSoundboardPlay.set(userId, now);
|
||||||
|
|
||||||
|
connectionManager.sendToRoomParticipants(userRoom.roomId, {
|
||||||
|
type: 'soundboard_played',
|
||||||
|
soundId: sound.id,
|
||||||
|
userId,
|
||||||
|
name: sound.name,
|
||||||
|
filename: sound.filename,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function handleVoiceLeave(userId: string): void {
|
function handleVoiceLeave(userId: string): void {
|
||||||
connectionManager.clearVoiceWs(userId);
|
connectionManager.clearVoiceWs(userId);
|
||||||
const left = connectionManager.leaveCurrentRoom(userId);
|
const left = connectionManager.leaveCurrentRoom(userId);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import type { WebSocket } from 'ws';
|
import type { WebSocket } from 'ws';
|
||||||
import { verifyJwt } from '../utils/auth.js';
|
import { verifyJwt } from '../utils/auth.js';
|
||||||
|
import { openVoiceSession, closeVoiceSession } from '../utils/voiceSessions.js';
|
||||||
import { getDb, schema } from '../db/index.js';
|
import { getDb, schema } from '../db/index.js';
|
||||||
import { eq, and, or, inArray, isNull, desc, sql } from 'drizzle-orm';
|
import { eq, and, or, inArray, isNull, desc, sql } from 'drizzle-orm';
|
||||||
import { handleClientEvent } from './events.js';
|
import { handleClientEvent } from './events.js';
|
||||||
@@ -414,6 +415,7 @@ class ConnectionManager {
|
|||||||
type: 'space_voice_state',
|
type: 'space_voice_state',
|
||||||
spaceId,
|
spaceId,
|
||||||
voiceStates: snapshot.voiceStates,
|
voiceStates: snapshot.voiceStates,
|
||||||
|
voiceRoomStarts: snapshot.voiceRoomStarts,
|
||||||
voiceUserStates: snapshot.voiceUserStates,
|
voiceUserStates: snapshot.voiceUserStates,
|
||||||
spaceVoiceStates: snapshot.spaceVoiceStates,
|
spaceVoiceStates: snapshot.spaceVoiceStates,
|
||||||
});
|
});
|
||||||
@@ -441,11 +443,13 @@ class ConnectionManager {
|
|||||||
*/
|
*/
|
||||||
buildSpaceVoiceState(spaceId: string, userId: string): {
|
buildSpaceVoiceState(spaceId: string, userId: string): {
|
||||||
voiceStates: Record<string, string[]>;
|
voiceStates: Record<string, string[]>;
|
||||||
|
voiceRoomStarts: Record<string, number>;
|
||||||
voiceUserStates: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>;
|
voiceUserStates: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>;
|
||||||
spaceVoiceStates: Record<string, { spaceMuted: boolean; spaceDeafened: boolean; permissionMuted: boolean }>;
|
spaceVoiceStates: Record<string, { spaceMuted: boolean; spaceDeafened: boolean; permissionMuted: boolean }>;
|
||||||
} {
|
} {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const voiceStates: Record<string, string[]> = {};
|
const voiceStates: Record<string, string[]> = {};
|
||||||
|
const voiceRoomStarts: Record<string, number> = {};
|
||||||
const voiceUserStates: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }> = {};
|
const voiceUserStates: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }> = {};
|
||||||
const spaceVoiceStates: Record<string, { spaceMuted: boolean; spaceDeafened: boolean; permissionMuted: boolean }> = {};
|
const spaceVoiceStates: Record<string, { spaceMuted: boolean; spaceDeafened: boolean; permissionMuted: boolean }> = {};
|
||||||
|
|
||||||
@@ -462,6 +466,8 @@ class ConnectionManager {
|
|||||||
if (participants.size > 0) {
|
if (participants.size > 0) {
|
||||||
const ids = Array.from(participants);
|
const ids = Array.from(participants);
|
||||||
voiceStates[ch.id] = ids;
|
voiceStates[ch.id] = ids;
|
||||||
|
const startedAt = this.getRoomStartedAt(ch.id);
|
||||||
|
if (startedAt !== null) voiceRoomStarts[ch.id] = startedAt;
|
||||||
for (const uid of ids) {
|
for (const uid of ids) {
|
||||||
const status = this.getVoiceUserStatus(uid);
|
const status = this.getVoiceUserStatus(uid);
|
||||||
if (status) voiceUserStates[uid] = status;
|
if (status) voiceUserStates[uid] = status;
|
||||||
@@ -499,7 +505,7 @@ class ConnectionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { voiceStates, voiceUserStates, spaceVoiceStates };
|
return { voiceStates, voiceRoomStarts, voiceUserStates, spaceVoiceStates };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Unified VoiceRoom API ─────────────────────────────────────────────────
|
// ─── Unified VoiceRoom API ─────────────────────────────────────────────────
|
||||||
@@ -714,6 +720,16 @@ class ConnectionManager {
|
|||||||
|
|
||||||
room.participants.add(userId);
|
room.participants.add(userId);
|
||||||
this.userToRoom.set(userId, roomId);
|
this.userToRoom.set(userId, roomId);
|
||||||
|
|
||||||
|
// Recorded here rather than at the seven call sites that lead into voice:
|
||||||
|
// every path — join, move, DM call, reconnect — funnels through this
|
||||||
|
// method, so hooking it cannot miss one.
|
||||||
|
openVoiceSession({
|
||||||
|
spaceId: room.roomType === 'space' ? (room.metadata as SpaceRoomMeta).spaceId : null,
|
||||||
|
channelId: roomId,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
|
||||||
return room;
|
return room;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -746,6 +762,8 @@ class ConnectionManager {
|
|||||||
const room = this.leaveRoom(roomId, userId);
|
const room = this.leaveRoom(roomId, userId);
|
||||||
if (!room) return null;
|
if (!room) return null;
|
||||||
|
|
||||||
|
closeVoiceSession(userId);
|
||||||
|
|
||||||
return { roomId, room };
|
return { roomId, room };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -757,6 +775,9 @@ class ConnectionManager {
|
|||||||
const displaced: string[] = [];
|
const displaced: string[] = [];
|
||||||
for (const userId of room.participants) {
|
for (const userId of room.participants) {
|
||||||
this.userToRoom.delete(userId);
|
this.userToRoom.delete(userId);
|
||||||
|
// Destroying a room bypasses leaveCurrentRoom, so these sessions would
|
||||||
|
// otherwise stay open until the next restart swept them away.
|
||||||
|
closeVoiceSession(userId);
|
||||||
displaced.push(userId);
|
displaced.push(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -960,6 +981,30 @@ class ConnectionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When the current occupancy of a room began. Null when nobody is in it —
|
||||||
|
* empty space rooms are destroyed, which is what makes the call timer reset
|
||||||
|
* once the last person leaves.
|
||||||
|
*/
|
||||||
|
getRoomStartedAt(roomId: string): number | null {
|
||||||
|
return this.voiceRooms.get(roomId)?.startedAt ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send only to the people actually inside a room.
|
||||||
|
*
|
||||||
|
* Distinct from `sendToRoom`, which fans a space room out to the whole
|
||||||
|
* space — right for presence updates the sidebar shows, wrong for anything
|
||||||
|
* audible: a soundboard clip must reach the call, not everyone online.
|
||||||
|
*/
|
||||||
|
sendToRoomParticipants(roomId: string, event: ServerEvent): void {
|
||||||
|
const room = this.voiceRooms.get(roomId);
|
||||||
|
if (!room) return;
|
||||||
|
for (const userId of room.participants) {
|
||||||
|
this.sendToUser(userId, event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Send to all connections of all online users. */
|
/** Send to all connections of all online users. */
|
||||||
sendToAll(event: ServerEvent, excludeUserId?: string): void {
|
sendToAll(event: ServerEvent, excludeUserId?: string): void {
|
||||||
const message = JSON.stringify(event);
|
const message = JSON.stringify(event);
|
||||||
@@ -1073,7 +1118,7 @@ class ConnectionManager {
|
|||||||
if (connections.size === 0) return;
|
if (connections.size === 0) return;
|
||||||
|
|
||||||
const readyData = buildReadyPayload(userId);
|
const readyData = buildReadyPayload(userId);
|
||||||
const message = JSON.stringify({ type: 'ready', ...readyData });
|
const message = JSON.stringify({ type: 'ready', serverTime: Date.now(), ...readyData });
|
||||||
for (const ws of connections) {
|
for (const ws of connections) {
|
||||||
if (ws.readyState === 1) {
|
if (ws.readyState === 1) {
|
||||||
ws.send(message);
|
ws.send(message);
|
||||||
@@ -1747,6 +1792,9 @@ export async function registerWebSocket(app: FastifyInstance): Promise<void> {
|
|||||||
const readyData = buildReadyPayload(userId);
|
const readyData = buildReadyPayload(userId);
|
||||||
ws.send(JSON.stringify({
|
ws.send(JSON.stringify({
|
||||||
type: 'ready',
|
type: 'ready',
|
||||||
|
// Lets each client measure its own offset from this server, so activity
|
||||||
|
// timestamps computed here render correctly on a machine whose clock drifts.
|
||||||
|
serverTime: Date.now(),
|
||||||
...readyData,
|
...readyData,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,13 @@
|
|||||||
"./src/activities": "./src/activities.ts",
|
"./src/activities": "./src/activities.ts",
|
||||||
"./src/activities.js": "./src/activities.ts",
|
"./src/activities.js": "./src/activities.ts",
|
||||||
"./src/constants": "./src/constants.ts",
|
"./src/constants": "./src/constants.ts",
|
||||||
"./src/constants.js": "./src/constants.ts"
|
"./src/constants.js": "./src/constants.ts",
|
||||||
|
"./src/audit": "./src/audit.ts",
|
||||||
|
"./src/audit.js": "./src/audit.ts",
|
||||||
|
"./src/mentions": "./src/mentions.ts",
|
||||||
|
"./src/mentions.js": "./src/mentions.ts",
|
||||||
|
"./src/profile": "./src/profile.ts",
|
||||||
|
"./src/profile.js": "./src/profile.ts"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* Audit action vocabulary, shared so the server writes and the client renders
|
||||||
|
* the same strings. Values are stored in the database, so renaming one
|
||||||
|
* rewrites history — add new actions instead.
|
||||||
|
*/
|
||||||
|
export const AUDIT_ACTIONS = [
|
||||||
|
'space.update',
|
||||||
|
'space.transfer_ownership',
|
||||||
|
'channel.create',
|
||||||
|
'channel.update',
|
||||||
|
'channel.delete',
|
||||||
|
'member.kick',
|
||||||
|
'member.leave',
|
||||||
|
'member.ban',
|
||||||
|
'member.unban',
|
||||||
|
'role.create',
|
||||||
|
'role.update',
|
||||||
|
'role.delete',
|
||||||
|
'invite.create',
|
||||||
|
'message.delete',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type AuditAction = (typeof AUDIT_ACTIONS)[number];
|
||||||
|
|
||||||
|
export interface AuditEventActor {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
displayName: string | null;
|
||||||
|
avatar: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditEvent {
|
||||||
|
id: string;
|
||||||
|
spaceId: string;
|
||||||
|
action: AuditAction;
|
||||||
|
actor: AuditEventActor | null;
|
||||||
|
targetType: string | null;
|
||||||
|
targetId: string | null;
|
||||||
|
/** Shape depends on `action`; used for display only. */
|
||||||
|
metadata: Record<string, unknown> | null;
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AUDIT_PAGE_SIZE = 50;
|
||||||
@@ -45,3 +45,20 @@ export const GROUP_DM_NAME_MAX_LENGTH = 50;
|
|||||||
export const GROUP_DM_NAME_MIN_LENGTH = 1;
|
export const GROUP_DM_NAME_MIN_LENGTH = 1;
|
||||||
export const GROUP_DM_ICON_MAX_BYTES = 8 * 1024 * 1024; // 8 MB
|
export const GROUP_DM_ICON_MAX_BYTES = 8 * 1024 * 1024; // 8 MB
|
||||||
export const GROUP_DM_ICON_MIME_PREFIX = 'image/';
|
export const GROUP_DM_ICON_MIME_PREFIX = 'image/';
|
||||||
|
|
||||||
|
// ─── Channel Names ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const CHANNEL_NAME_MIN_LENGTH = 1;
|
||||||
|
export const CHANNEL_NAME_MAX_LENGTH = 100;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical channel-name form: trimmed, lowercased, runs of whitespace folded
|
||||||
|
* into single hyphens.
|
||||||
|
*
|
||||||
|
* The server applies this before storing, so the client uses the same function
|
||||||
|
* to show what a name will actually become. Two copies of the rule would drift;
|
||||||
|
* one that lives here cannot.
|
||||||
|
*/
|
||||||
|
export function normalizeChannelName(name: string): string {
|
||||||
|
return name.trim().toLowerCase().replace(/\s+/g, '-');
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* The @everyone mention.
|
||||||
|
*
|
||||||
|
* It rides the existing `<@id>` token shape — `everyone` matches the same
|
||||||
|
* `[a-zA-Z0-9_-]+` the user-mention parser already accepts — so the markdown
|
||||||
|
* pre-processor, the inline renderer and the badge all pick it up without a
|
||||||
|
* second syntax. User ids are snowflakes (digits only), so the literal
|
||||||
|
* `everyone` can never collide with a real one.
|
||||||
|
*/
|
||||||
|
export const EVERYONE_MENTION_ID = 'everyone';
|
||||||
|
export const EVERYONE_MENTION_TOKEN = `<@${EVERYONE_MENTION_ID}>`;
|
||||||
|
|
||||||
|
/** Matches code spans and fences first, so their contents are never a ping. */
|
||||||
|
const CODE_OR_EVERYONE = /(```[\s\S]*?```|`[^`\n]*`)|<@everyone>/g;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a message pings everyone.
|
||||||
|
*
|
||||||
|
* A token inside a code span or fenced block does not count: people paste
|
||||||
|
* message payloads and snippets into chat, and a paste should not ring
|
||||||
|
* everybody's client.
|
||||||
|
*/
|
||||||
|
export function mentionsEveryone(content: string | null | undefined): boolean {
|
||||||
|
if (!content) return false;
|
||||||
|
CODE_OR_EVERYONE.lastIndex = 0;
|
||||||
|
for (let m = CODE_OR_EVERYONE.exec(content); m; m = CODE_OR_EVERYONE.exec(content)) {
|
||||||
|
if (!m[1]) return true; // matched the token itself, not a code span
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Neutralises the token into plain text, leaving code spans untouched.
|
||||||
|
*
|
||||||
|
* The server applies this when the author lacks MENTION_EVERYONE. Rejecting the
|
||||||
|
* message instead would lose what they wrote over a formatting detail; this way
|
||||||
|
* the message still posts, and reads as the literal text they typed.
|
||||||
|
*/
|
||||||
|
export function stripEveryoneMention(content: string): string {
|
||||||
|
return content.replace(CODE_OR_EVERYONE, (match, codeBlock: string | undefined) =>
|
||||||
|
codeBlock ? codeBlock : '@everyone',
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ export const PermissionBits = {
|
|||||||
ATTACH_FILES: 1n << 12n,
|
ATTACH_FILES: 1n << 12n,
|
||||||
READ_MESSAGE_HISTORY: 1n << 13n,
|
READ_MESSAGE_HISTORY: 1n << 13n,
|
||||||
ADD_REACTIONS: 1n << 14n,
|
ADD_REACTIONS: 1n << 14n,
|
||||||
|
MENTION_EVERYONE: 1n << 15n,
|
||||||
CONNECT: 1n << 20n,
|
CONNECT: 1n << 20n,
|
||||||
SPEAK: 1n << 21n,
|
SPEAK: 1n << 21n,
|
||||||
MUTE_MEMBERS: 1n << 22n,
|
MUTE_MEMBERS: 1n << 22n,
|
||||||
@@ -38,6 +39,10 @@ export const DEFAULT_EVERYONE_PERMISSIONS =
|
|||||||
PermissionBits.ATTACH_FILES |
|
PermissionBits.ATTACH_FILES |
|
||||||
PermissionBits.READ_MESSAGE_HISTORY |
|
PermissionBits.READ_MESSAGE_HISTORY |
|
||||||
PermissionBits.ADD_REACTIONS |
|
PermissionBits.ADD_REACTIONS |
|
||||||
|
// Granted by default: on a small instance the ping is a convenience, not a
|
||||||
|
// weapon. An owner who disagrees can deny it on @everyone and grant it to a
|
||||||
|
// role instead — which is why it is a bit rather than being ungated.
|
||||||
|
PermissionBits.MENTION_EVERYONE |
|
||||||
PermissionBits.STREAM;
|
PermissionBits.STREAM;
|
||||||
|
|
||||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -0,0 +1,299 @@
|
|||||||
|
/**
|
||||||
|
* Profile identity: everything a user can style about how they appear, beyond
|
||||||
|
* the avatar/banner/bio fields that predate it.
|
||||||
|
*
|
||||||
|
* Stored as one JSON column (`users.profile_style`) rather than a dozen
|
||||||
|
* columns: the shape will keep growing (new effects, new widgets), every field
|
||||||
|
* is optional, and nothing here is ever queried by value. The server
|
||||||
|
* normalises on write with `normalizeProfileStyle`, so what reaches clients is
|
||||||
|
* always a complete, bounded, well-formed object.
|
||||||
|
*
|
||||||
|
* Catalog ids are strings on purpose — a client that does not know an id
|
||||||
|
* (older build, other instance) falls back to `none`/`default` instead of
|
||||||
|
* crashing on an enum it has never seen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const NAME_FONTS = [
|
||||||
|
'default', 'display', 'comic', 'script', 'pixel', 'future', 'serif', 'mono', 'round', 'marker',
|
||||||
|
'techno', 'block', 'retro',
|
||||||
|
] as const;
|
||||||
|
export type NameFont = (typeof NAME_FONTS)[number];
|
||||||
|
|
||||||
|
export const NAME_EFFECTS = [
|
||||||
|
'solid', 'gradient', 'chrome', 'gold', 'holo', 'neon', 'toon', 'pop', 'gummy',
|
||||||
|
'prism', 'glitch', 'fire', 'ice', 'shadow', 'outline', 'rainbow',
|
||||||
|
] as const;
|
||||||
|
export type NameEffect = (typeof NAME_EFFECTS)[number];
|
||||||
|
|
||||||
|
export const AVATAR_DECORATIONS = [
|
||||||
|
'none', 'ring', 'neon', 'cd', 'chain', 'cyber', 'pixel', 'flames', 'lightning', 'spikes',
|
||||||
|
'frost', 'stars', 'hearts', 'butterfly', 'sakura', 'vines', 'bubbles', 'crown', 'halo', 'flower',
|
||||||
|
] as const;
|
||||||
|
export type AvatarDecoration = (typeof AVATAR_DECORATIONS)[number];
|
||||||
|
|
||||||
|
export const PROFILE_EFFECTS = [
|
||||||
|
'none', 'sparkles', 'rain', 'snow', 'matrix', 'confetti', 'aurora', 'embers', 'bubbles', 'hearts',
|
||||||
|
] as const;
|
||||||
|
export type ProfileEffect = (typeof PROFILE_EFFECTS)[number];
|
||||||
|
|
||||||
|
export const NAMEPLATES = [
|
||||||
|
'none', 'sunset', 'ocean', 'neon', 'forest', 'royal', 'brasil', 'cyber', 'blossom', 'midnight',
|
||||||
|
'holo', 'chrome', 'vapor', 'matrix', 'ember', 'custom',
|
||||||
|
] as const;
|
||||||
|
export type Nameplate = (typeof NAMEPLATES)[number];
|
||||||
|
|
||||||
|
export interface NameStyle {
|
||||||
|
font: NameFont;
|
||||||
|
effect: NameEffect;
|
||||||
|
/** Two hex colours. Solid uses the first; every other effect uses both. */
|
||||||
|
colors: [string, string];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the banner image sits inside its frame.
|
||||||
|
*
|
||||||
|
* A banner is cropped by the frame, and until now the crop was always dead
|
||||||
|
* centre — fine for a photo shot for it, wrong for anything else, and there was
|
||||||
|
* no way to fix it for a GIF, which cannot go through the still-image cropper.
|
||||||
|
* `x`/`y` are CSS object-position percentages; `zoom` scales the image inside
|
||||||
|
* the frame, so together they are a pan-and-zoom crop that costs nothing at
|
||||||
|
* render time and keeps the original file untouched.
|
||||||
|
*/
|
||||||
|
export interface BannerPosition {
|
||||||
|
/** 0–100. 50 is centred. */
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
/** 100–300 (%). 100 fills the frame. */
|
||||||
|
zoom: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfileTheme {
|
||||||
|
/** Two hex colours behind the card and the banner fallback. */
|
||||||
|
colors: [string, string];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfileTag {
|
||||||
|
/** 2–4 characters, shown uppercase in a chip next to the name. */
|
||||||
|
text: string;
|
||||||
|
/** Single emoji (grapheme) in front of the text; empty for none. */
|
||||||
|
emoji: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfileLink {
|
||||||
|
label: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameEntry {
|
||||||
|
/** Steam app id when picked from search; null for a free-typed title. */
|
||||||
|
appId: number | null;
|
||||||
|
name: string;
|
||||||
|
/** https URL of a cover image (Steam CDN when picked from search). */
|
||||||
|
cover: string | null;
|
||||||
|
note: string;
|
||||||
|
tags: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfileQuote {
|
||||||
|
text: string;
|
||||||
|
author: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfileWidgets {
|
||||||
|
favoriteGame: GameEntry | null;
|
||||||
|
rotation: GameEntry[];
|
||||||
|
wishlist: GameEntry[];
|
||||||
|
quote: ProfileQuote | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfileStyle {
|
||||||
|
name: NameStyle;
|
||||||
|
theme: ProfileTheme | null;
|
||||||
|
banner: BannerPosition;
|
||||||
|
decoration: AvatarDecoration;
|
||||||
|
effect: ProfileEffect;
|
||||||
|
nameplate: Nameplate;
|
||||||
|
/** Only read when nameplate === 'custom'. */
|
||||||
|
nameplateColors: [string, string];
|
||||||
|
pronouns: string;
|
||||||
|
tag: ProfileTag | null;
|
||||||
|
links: ProfileLink[];
|
||||||
|
widgets: ProfileWidgets;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PROFILE_LIMITS = {
|
||||||
|
pronouns: 40,
|
||||||
|
tagMin: 2,
|
||||||
|
tagMax: 4,
|
||||||
|
links: 5,
|
||||||
|
linkLabel: 24,
|
||||||
|
linkUrl: 200,
|
||||||
|
gameName: 80,
|
||||||
|
gameNote: 80,
|
||||||
|
gameTags: 3,
|
||||||
|
gameTag: 16,
|
||||||
|
rotation: 5,
|
||||||
|
wishlist: 10,
|
||||||
|
quoteText: 140,
|
||||||
|
quoteAuthor: 40,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const DEFAULT_BANNER_POSITION: BannerPosition = { x: 50, y: 50, zoom: 100 };
|
||||||
|
|
||||||
|
export const DEFAULT_PROFILE_STYLE: ProfileStyle = {
|
||||||
|
name: { font: 'default', effect: 'solid', colors: ['#efefef', '#7c6cf6'] },
|
||||||
|
theme: null,
|
||||||
|
banner: DEFAULT_BANNER_POSITION,
|
||||||
|
decoration: 'none',
|
||||||
|
effect: 'none',
|
||||||
|
nameplate: 'none',
|
||||||
|
nameplateColors: ['#7c6cf6', '#f472b6'],
|
||||||
|
pronouns: '',
|
||||||
|
tag: null,
|
||||||
|
links: [],
|
||||||
|
widgets: { favoriteGame: null, rotation: [], wishlist: [], quote: null },
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Normalisation ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const HEX = /^#[0-9a-fA-F]{6}$/;
|
||||||
|
/** Domains whose images a profile may embed. Anything else is dropped, not
|
||||||
|
* proxied — a profile is not a place to load arbitrary third-party pixels. */
|
||||||
|
const COVER_HOSTS = ['cdn.cloudflare.steamstatic.com', 'cdn.akamai.steamstatic.com', 'shared.cloudflare.steamstatic.com', 'shared.akamai.steamstatic.com', 'steamcdn-a.akamaihd.net'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This package compiles without the DOM or Node type libraries, so the `URL`
|
||||||
|
* global has no type here even though it exists at runtime everywhere this
|
||||||
|
* code runs (browsers, Node ≥ 10). Reach it through globalThis with a minimal
|
||||||
|
* structural type rather than pulling a whole lib into a types-only package.
|
||||||
|
*/
|
||||||
|
interface UrlLike { protocol: string; hostname: string; toString(): string }
|
||||||
|
const UrlCtor = (globalThis as unknown as { URL: new (s: string) => UrlLike }).URL;
|
||||||
|
function parseUrl(value: string): UrlLike | null {
|
||||||
|
try { return new UrlCtor(value); } catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function pick<T extends string>(value: unknown, allowed: readonly T[], fallback: T): T {
|
||||||
|
return typeof value === 'string' && (allowed as readonly string[]).includes(value) ? (value as T) : fallback;
|
||||||
|
}
|
||||||
|
function hex(value: unknown, fallback: string): string {
|
||||||
|
return typeof value === 'string' && HEX.test(value) ? value.toLowerCase() : fallback;
|
||||||
|
}
|
||||||
|
/** Clamps to a range, falling back when the value is not a finite number. */
|
||||||
|
function num(value: unknown, min: number, max: number, fallback: number): number {
|
||||||
|
if (typeof value !== 'number' || !Number.isFinite(value)) return fallback;
|
||||||
|
return Math.min(max, Math.max(min, Math.round(value * 10) / 10));
|
||||||
|
}
|
||||||
|
|
||||||
|
function str(value: unknown, max: number): string {
|
||||||
|
return typeof value === 'string' ? value.trim().slice(0, max) : '';
|
||||||
|
}
|
||||||
|
function httpsUrl(value: unknown, max: number): string | null {
|
||||||
|
if (typeof value !== 'string') return null;
|
||||||
|
const v = value.trim();
|
||||||
|
if (v.length === 0 || v.length > max) return null;
|
||||||
|
const u = parseUrl(v);
|
||||||
|
return u && u.protocol === 'https:' ? u.toString() : null;
|
||||||
|
}
|
||||||
|
function coverUrl(value: unknown): string | null {
|
||||||
|
const u = httpsUrl(value, 300);
|
||||||
|
if (!u) return null;
|
||||||
|
const parsed = parseUrl(u);
|
||||||
|
return parsed && COVER_HOSTS.includes(parsed.hostname) ? u : null;
|
||||||
|
}
|
||||||
|
/** First grapheme only — one emoji, not a sentence hidden in the tag. */
|
||||||
|
function oneGrapheme(value: unknown): string {
|
||||||
|
if (typeof value !== 'string') return '';
|
||||||
|
const s = value.trim();
|
||||||
|
if (!s) return '';
|
||||||
|
const seg = (Intl as unknown as { Segmenter?: new (l: string, o: { granularity: string }) => { segment: (s: string) => Iterable<{ segment: string }> } }).Segmenter;
|
||||||
|
if (seg) {
|
||||||
|
for (const g of new seg('und', { granularity: 'grapheme' }).segment(s)) return g.segment;
|
||||||
|
}
|
||||||
|
return Array.from(s)[0] ?? '';
|
||||||
|
}
|
||||||
|
function game(value: unknown): GameEntry | null {
|
||||||
|
if (!value || typeof value !== 'object') return null;
|
||||||
|
const v = value as Record<string, unknown>;
|
||||||
|
const name = str(v.name, PROFILE_LIMITS.gameName);
|
||||||
|
if (!name) return null;
|
||||||
|
const appId = typeof v.appId === 'number' && Number.isInteger(v.appId) && v.appId > 0 ? v.appId : null;
|
||||||
|
const tags = Array.isArray(v.tags)
|
||||||
|
? v.tags.map((t) => str(t, PROFILE_LIMITS.gameTag)).filter(Boolean).slice(0, PROFILE_LIMITS.gameTags)
|
||||||
|
: [];
|
||||||
|
return { appId, name, cover: coverUrl(v.cover), note: str(v.note, PROFILE_LIMITS.gameNote), tags };
|
||||||
|
}
|
||||||
|
function games(value: unknown, max: number): GameEntry[] {
|
||||||
|
return Array.isArray(value) ? value.map(game).filter((g): g is GameEntry => g !== null).slice(0, max) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coerces any input into a valid ProfileStyle. Never throws: unknown ids fall
|
||||||
|
* back to defaults, over-long strings are cut, bad colours are replaced, bad
|
||||||
|
* URLs are dropped. The server stores exactly this output.
|
||||||
|
*/
|
||||||
|
export function normalizeProfileStyle(input: unknown): ProfileStyle {
|
||||||
|
const d = DEFAULT_PROFILE_STYLE;
|
||||||
|
const v = (input && typeof input === 'object' ? input : {}) as Record<string, unknown>;
|
||||||
|
const name = (v.name && typeof v.name === 'object' ? v.name : {}) as Record<string, unknown>;
|
||||||
|
const nameColors = Array.isArray(name.colors) ? name.colors : [];
|
||||||
|
const theme = (v.theme && typeof v.theme === 'object' ? v.theme : null) as Record<string, unknown> | null;
|
||||||
|
const themeColors = theme && Array.isArray(theme.colors) ? theme.colors : null;
|
||||||
|
const npColors = Array.isArray(v.nameplateColors) ? v.nameplateColors : [];
|
||||||
|
const banner = (v.banner && typeof v.banner === 'object' ? v.banner : {}) as Record<string, unknown>;
|
||||||
|
const tag = (v.tag && typeof v.tag === 'object' ? v.tag : null) as Record<string, unknown> | null;
|
||||||
|
const tagText = tag ? str(tag.text, PROFILE_LIMITS.tagMax).toUpperCase().replace(/[^A-Z0-9]/g, '') : '';
|
||||||
|
const widgets = (v.widgets && typeof v.widgets === 'object' ? v.widgets : {}) as Record<string, unknown>;
|
||||||
|
const quote = (widgets.quote && typeof widgets.quote === 'object' ? widgets.quote : null) as Record<string, unknown> | null;
|
||||||
|
const quoteText = quote ? str(quote.text, PROFILE_LIMITS.quoteText) : '';
|
||||||
|
|
||||||
|
const links: ProfileLink[] = Array.isArray(v.links)
|
||||||
|
? v.links.map((l) => {
|
||||||
|
const o = (l && typeof l === 'object' ? l : {}) as Record<string, unknown>;
|
||||||
|
const url = httpsUrl(o.url, PROFILE_LIMITS.linkUrl);
|
||||||
|
if (!url) return null;
|
||||||
|
const label = str(o.label, PROFILE_LIMITS.linkLabel) || (parseUrl(url)?.hostname ?? url).replace(/^www\./, '');
|
||||||
|
return { label, url };
|
||||||
|
}).filter((l): l is ProfileLink => l !== null).slice(0, PROFILE_LIMITS.links)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: {
|
||||||
|
font: pick(name.font, NAME_FONTS, d.name.font),
|
||||||
|
effect: pick(name.effect, NAME_EFFECTS, d.name.effect),
|
||||||
|
colors: [hex(nameColors[0], d.name.colors[0]), hex(nameColors[1], d.name.colors[1])],
|
||||||
|
},
|
||||||
|
theme: themeColors ? { colors: [hex(themeColors[0], '#7c6cf6'), hex(themeColors[1], '#0b0b10')] } : null,
|
||||||
|
banner: {
|
||||||
|
x: num(banner.x, 0, 100, d.banner.x),
|
||||||
|
y: num(banner.y, 0, 100, d.banner.y),
|
||||||
|
zoom: num(banner.zoom, 100, 300, d.banner.zoom),
|
||||||
|
},
|
||||||
|
decoration: pick(v.decoration, AVATAR_DECORATIONS, d.decoration),
|
||||||
|
effect: pick(v.effect, PROFILE_EFFECTS, d.effect),
|
||||||
|
nameplate: pick(v.nameplate, NAMEPLATES, d.nameplate),
|
||||||
|
nameplateColors: [hex(npColors[0], d.nameplateColors[0]), hex(npColors[1], d.nameplateColors[1])],
|
||||||
|
pronouns: str(v.pronouns, PROFILE_LIMITS.pronouns),
|
||||||
|
tag: tagText.length >= PROFILE_LIMITS.tagMin
|
||||||
|
? { text: tagText, emoji: oneGrapheme(tag?.emoji), color: hex(tag?.color, '#7c6cf6') }
|
||||||
|
: null,
|
||||||
|
links,
|
||||||
|
widgets: {
|
||||||
|
favoriteGame: game(widgets.favoriteGame),
|
||||||
|
rotation: games(widgets.rotation, PROFILE_LIMITS.rotation),
|
||||||
|
wishlist: games(widgets.wishlist, PROFILE_LIMITS.wishlist),
|
||||||
|
quote: quoteText ? { text: quoteText, author: str(quote?.author, PROFILE_LIMITS.quoteAuthor) } : null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a style differs from the defaults — used to skip rendering work. */
|
||||||
|
export function isDefaultProfileStyle(s: ProfileStyle | null | undefined): boolean {
|
||||||
|
return !s || JSON.stringify(s) === JSON.stringify(DEFAULT_PROFILE_STYLE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Steam's poster-format cover for an app id. */
|
||||||
|
export function steamCoverUrl(appId: number): string {
|
||||||
|
return `https://cdn.cloudflare.steamstatic.com/steam/apps/${appId}/library_600x900.jpg`;
|
||||||
|
}
|
||||||
@@ -29,6 +29,8 @@ export interface User {
|
|||||||
showActivity?: boolean;
|
showActivity?: boolean;
|
||||||
/** Self-view only: this federated account's home instance was reset/lost — it now operates as a sovereign local account (detach spec). */
|
/** Self-view only: this federated account's home instance was reset/lost — it now operates as a sovereign local account (detach spec). */
|
||||||
federationHomeOrphaned?: boolean;
|
federationHomeOrphaned?: boolean;
|
||||||
|
/** Name style, decorations, effects, tag, links, widgets. See shared/profile.ts. Null = defaults. */
|
||||||
|
profileStyle?: import('./profile').ProfileStyle | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReplicatedInstance {
|
export interface ReplicatedInstance {
|
||||||
@@ -212,6 +214,11 @@ export interface Message {
|
|||||||
type?: 'user' | 'system';
|
type?: 'user' | 'system';
|
||||||
editedAt: number | null;
|
editedAt: number | null;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
|
/** Epoch millis when pinned; null when not pinned. */
|
||||||
|
pinnedAt?: number | null;
|
||||||
|
pinnedBy?: string | null;
|
||||||
|
/** Figurinha enviada como mensagem; o conteúdo fica vazio nesse caso. */
|
||||||
|
stickerId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MessageWithUser extends Message {
|
export interface MessageWithUser extends Message {
|
||||||
@@ -369,6 +376,12 @@ export interface ActivityAssets {
|
|||||||
export interface Activity {
|
export interface Activity {
|
||||||
type: ActivityType;
|
type: ActivityType;
|
||||||
name: string;
|
name: string;
|
||||||
|
/**
|
||||||
|
* Playback is paused. Kept as a state rather than dropping the activity:
|
||||||
|
* pausing a track used to remove the block entirely, so it vanished and
|
||||||
|
* reappeared on every pause and every gap between songs.
|
||||||
|
*/
|
||||||
|
paused?: boolean;
|
||||||
details?: string;
|
details?: string;
|
||||||
state?: string;
|
state?: string;
|
||||||
timestamps?: ActivityTimestamps;
|
timestamps?: ActivityTimestamps;
|
||||||
@@ -403,6 +416,7 @@ export type ClientEvent =
|
|||||||
| { type: 'typing_start'; channelId: string }
|
| { type: 'typing_start'; channelId: string }
|
||||||
| { type: 'presence_update'; status: 'online' | 'idle' | 'dnd' }
|
| { type: 'presence_update'; status: 'online' | 'idle' | 'dnd' }
|
||||||
| { type: 'voice_join'; channelId: string }
|
| { type: 'voice_join'; channelId: string }
|
||||||
|
| { type: 'soundboard_play'; soundId: string }
|
||||||
| { type: 'voice_leave' }
|
| { type: 'voice_leave' }
|
||||||
| { type: 'dm_message_create'; dmChannelId: string; content?: string; attachments?: string[]; replyToId?: string }
|
| { type: 'dm_message_create'; dmChannelId: string; content?: string; attachments?: string[]; replyToId?: string }
|
||||||
| { type: 'dm_typing_start'; dmChannelId: string }
|
| { type: 'dm_typing_start'; dmChannelId: string }
|
||||||
@@ -426,13 +440,15 @@ export type ClientEvent =
|
|||||||
|
|
||||||
// Server → Client Events
|
// Server → Client Events
|
||||||
export type ServerEvent =
|
export type ServerEvent =
|
||||||
| { type: 'ready'; user: User; spaces: SpaceWithChannelsAndMembers[]; dmChannels: DmChannel[]; folders?: SpaceFolder[]; spaceLayout?: SpaceLayoutItem[] | null; layoutUpdatedAt?: number; voiceStates?: Record<string, string[]>; voiceUserStates?: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>; readStates?: ReadState[]; activeCalls?: ActiveCallInfo[]; spaceVoiceStates?: Record<string, { spaceMuted: boolean; spaceDeafened: boolean }>; userActivities?: Record<string, Activity[]>; rejectedPeerOrigins?: string[]; awaitingApprovalPeerOrigins?: string[]; activePeerOrigins?: string[]; pendingApprovalCount?: number }
|
| { type: 'ready'; serverTime?: number; user: User; spaces: SpaceWithChannelsAndMembers[]; dmChannels: DmChannel[]; folders?: SpaceFolder[]; spaceLayout?: SpaceLayoutItem[] | null; layoutUpdatedAt?: number; voiceStates?: Record<string, string[]>; voiceRoomStarts?: Record<string, number>; voiceUserStates?: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>; readStates?: ReadState[]; activeCalls?: ActiveCallInfo[]; spaceVoiceStates?: Record<string, { spaceMuted: boolean; spaceDeafened: boolean }>; userActivities?: Record<string, Activity[]>; rejectedPeerOrigins?: string[]; awaitingApprovalPeerOrigins?: string[]; activePeerOrigins?: string[]; pendingApprovalCount?: number }
|
||||||
| { type: 'message_created'; message: MessageWithUser }
|
| { type: 'message_created'; message: MessageWithUser }
|
||||||
| { type: 'message_updated'; message: MessageWithUser }
|
| { type: 'message_updated'; message: MessageWithUser }
|
||||||
| { type: 'message_deleted'; messageId: string; channelId: string }
|
| { type: 'message_deleted'; messageId: string; channelId: string }
|
||||||
| { type: 'typing'; channelId: string; userId: string; username: string }
|
| { type: 'typing'; channelId: string; userId: string; username: string }
|
||||||
| { type: 'presence_update'; userId: string; status: string; activities?: Activity[] }
|
| { type: 'presence_update'; userId: string; status: string; activities?: Activity[] }
|
||||||
| { type: 'voice_state_update'; channelId: string; userId: string; action: 'join' | 'leave' }
|
| { type: 'voice_state_update'; channelId: string; userId: string; action: 'join' | 'leave'; startedAt?: number }
|
||||||
|
| { type: 'soundboard_played'; soundId: string; userId: string; name: string; filename: string }
|
||||||
|
| { type: 'message_pinned'; channelId: string; messageId: string; pinned: boolean }
|
||||||
| { type: 'member_joined'; spaceId: string; member: MemberWithUser }
|
| { type: 'member_joined'; spaceId: string; member: MemberWithUser }
|
||||||
| { type: 'member_left'; spaceId: string; userId: string }
|
| { type: 'member_left'; spaceId: string; userId: string }
|
||||||
| { type: 'dm_message_created'; message: DmMessageWithUser }
|
| { type: 'dm_message_created'; message: DmMessageWithUser }
|
||||||
@@ -451,7 +467,7 @@ export type ServerEvent =
|
|||||||
| { type: 'dm_call_ended'; dmChannelId: string }
|
| { type: 'dm_call_ended'; dmChannelId: string }
|
||||||
| { type: 'dm_call_undeliverable'; dmChannelId: string | null; federatedCallId: string; terminal: boolean; phase: DmCallPhase; failures: DmCallUndeliverableFailure[] }
|
| { type: 'dm_call_undeliverable'; dmChannelId: string | null; federatedCallId: string; terminal: boolean; phase: DmCallPhase; failures: DmCallUndeliverableFailure[] }
|
||||||
| { type: 'voice_status_update'; userId: string; channelId: string; isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }
|
| { type: 'voice_status_update'; userId: string; channelId: string; isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }
|
||||||
| { type: 'space_voice_state'; spaceId: string; voiceStates: Record<string, string[]>; voiceUserStates: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>; spaceVoiceStates: Record<string, { spaceMuted: boolean; spaceDeafened: boolean; permissionMuted: boolean }> }
|
| { type: 'space_voice_state'; spaceId: string; voiceStates: Record<string, string[]>; voiceRoomStarts?: Record<string, number>; voiceUserStates: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>; spaceVoiceStates: Record<string, { spaceMuted: boolean; spaceDeafened: boolean; permissionMuted: boolean }> }
|
||||||
| { type: 'dm_channel_created'; dmChannel: DmChannel }
|
| { type: 'dm_channel_created'; dmChannel: DmChannel }
|
||||||
| { type: 'dm_channel_closed'; dmChannelId: string }
|
| { type: 'dm_channel_closed'; dmChannelId: string }
|
||||||
| { type: 'dm_channel_updated'; dmChannelId: string; name: string | null; icon: string | null }
|
| { type: 'dm_channel_updated'; dmChannelId: string; name: string | null; icon: string | null }
|
||||||
@@ -577,6 +593,8 @@ export interface UpdateUserRequest {
|
|||||||
profileUpdatedAt?: number;
|
profileUpdatedAt?: number;
|
||||||
discoverable?: boolean;
|
discoverable?: boolean;
|
||||||
showActivity?: boolean;
|
showActivity?: boolean;
|
||||||
|
/** Normalised server-side; pass null to reset to defaults. */
|
||||||
|
profileStyle?: import('./profile').ProfileStyle | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateMemberRequest {
|
export interface UpdateMemberRequest {
|
||||||
@@ -587,6 +605,8 @@ export interface CreateMessageRequest {
|
|||||||
content: string;
|
content: string;
|
||||||
attachments?: string[];
|
attachments?: string[];
|
||||||
replyToId?: string;
|
replyToId?: string;
|
||||||
|
/** Envio de figurinha; nesse caso `content` fica vazio. */
|
||||||
|
stickerId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateMessageRequest {
|
export interface UpdateMessageRequest {
|
||||||
|
|||||||
@@ -4,15 +4,15 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="color-scheme" content="dark" />
|
<meta name="color-scheme" content="dark" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content" />
|
||||||
<meta name="description" content="The self-hosted Discord and TeamSpeak alternative. HD voice, video, and screen share — open source and free (AGPL-3.0)." />
|
<meta name="description" content="Resenhacord — o lugar da resenha. Voz, vídeo e tela em HD, num servidor só nosso. Fork do Backspace, código aberto (AGPL-3.0)." />
|
||||||
<meta name="theme-color" content="#0b0b10" />
|
<meta name="theme-color" content="#0b0b10" />
|
||||||
<meta name="mobile-web-app-capable" content="yes" />
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<meta name="apple-mobile-web-app-title" content="Backspace" />
|
<meta name="apple-mobile-web-app-title" content="Resenhacord" />
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32.png" />
|
<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32.png" />
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="/icons/favicon-16.png" />
|
<link rel="icon" type="image/png" sizes="16x16" href="/icons/favicon-16.png" />
|
||||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
|
||||||
<title>Backspace</title>
|
<title>Resenhacord</title>
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-surface-base text-txt-primary">
|
<body class="bg-surface-base text-txt-primary">
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -91,3 +91,9 @@ INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
|||||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
|
|
||||||
|
---
|
||||||
|
Display fonts for profile name styles (latin subsets, Google Fonts builds), all
|
||||||
|
licensed under the SIL Open Font License 1.1 above:
|
||||||
|
Bangers, Cinzel, Fredoka, Orbitron, Pacifico, Permanent Marker, Playfair Display,
|
||||||
|
Press Start 2P, Rubik Mono One, Space Mono.
|
||||||
|
|||||||