diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..0ccc3c8 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,20 @@ +# Keep dev-only files out of the composer release (composer archive / +# Packagist tarball). `composer archive` and Packagist honour +# `export-ignore` natively, so operators installing via `composer require` +# receive only the runtime pieces (src/, migrations/, js/dist/, locale/, +# less/, views/, extend.php, composer.json) and never the test suite. + +/tests export-ignore +/phpunit.xml export-ignore +/.phpunit.cache export-ignore +/.github export-ignore +/.gitignore export-ignore +/.gitattributes export-ignore +/.vscode export-ignore +/.claude export-ignore +/.editorconfig export-ignore + +# Internal playbooks / notes — exclude every root .md from the tarball… +*.md export-ignore +# …but keep the README visible on Packagist and in installed copies. +README.md -export-ignore diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..eb3ba37 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,32 @@ +# 🟡 R1 — Dependabot para composer, npm, e github-actions. +# Os labels devem bater com `.github/pr-labeler.yml` para o changelog agrupar +# corretamente (release-drafter). + +version: 2 +updates: + - package-ecosystem: composer + directory: / + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 5 + labels: + - dependencias + + - package-ecosystem: npm + directory: /js + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 5 + labels: + - dependencias + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 5 + labels: + - dependencias diff --git a/.github/pr-labeler.yml b/.github/pr-labeler.yml new file mode 100644 index 0000000..cfafe77 --- /dev/null +++ b/.github/pr-labeler.yml @@ -0,0 +1,10 @@ +BC: bc/* +melhoria: melhoria/* +correcao: ['correcao/*', 'conserto/*', 'ajuste/*'] +dependencias: dependencias/* +documentacao: ['docs/*', 'documentacao/*'] +manutencao: manutencao/* +performance: performance/* +traducao: traducao/* +refatoracao: refatoracao/* +'pular changelog': release/* diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml new file mode 100644 index 0000000..70e2e77 --- /dev/null +++ b/.github/release-drafter.yml @@ -0,0 +1,63 @@ +name-template: 'v$RESOLVED_VERSION' +tag-template: 'v$RESOLVED_VERSION' +exclude-labels: + - 'pular changelog' +categories: + - title: '⚠️ Alterações Críticas (Breaking Changes)' + labels: + - 'BC' + - title: '🔨 Melhoria' + labels: + - 'melhoria' + - title: '🐞 Correções de Erros' + labels: + - 'correcao' + - title: '🚀 Performance' + labels: + - 'performance' + - title: '📖 Documentação' + labels: + - 'documentacao' + - title: '♻️ Refatoração' + labels: + - 'refatoracao' + - title: '📦 Dependências' + labels: + - 'dependencias' + - title: '🌍 Traduções' + labels: + - 'traducao' + - title: '🔧 Manutenção' + labels: + - 'manutencao' +# A versão dos releases é calculada no release-management.yml a partir da +# última tag git (label "BC" => minor; qualquer outra => patch) e passada +# explicitamente ao release-drafter via input `version`. Este resolver fica +# como fallback/documentação e espelha a mesma regra — sem "major" automático. +version-resolver: + minor: + labels: + - 'BC' + patch: + labels: + - 'melhoria' + - 'correcao' + - 'performance' + - 'documentacao' + - 'refatoracao' + - 'dependencias' + - 'traducao' + - 'manutencao' + default: patch +change-template: '- $TITLE (PR #$NUMBER) por @$AUTHOR' +template: | + ## O que mudou na extensão Backup & Migration 🌟 + $CHANGES + + ## 📦 Como atualizar + + ```bash + composer require ramon/backup:$RESOLVED_VERSION + php flarum cache:clear + php flarum assets:publish + ``` diff --git a/.github/semgrep/flarum-v2.yaml b/.github/semgrep/flarum-v2.yaml new file mode 100644 index 0000000..039e9cf --- /dev/null +++ b/.github/semgrep/flarum-v2.yaml @@ -0,0 +1,471 @@ +# ============================================================================= +# Regras Semgrep específicas para extensões Flarum v2 +# ============================================================================= +# Derivadas do playbook de segurança CLAUDE.md (§2–§37). Cada regra cita a +# seção correspondente em `metadata.flarum-playbook`. +# +# Uso local: +# semgrep scan --config .github/semgrep/flarum-v2.yaml +# +# As regras usam predominantemente `pattern-regex` (em block scalars `|-`) +# porque o objetivo é uma porta de segurança previsível e de baixo +# falso-positivo — não análise de fluxo de dados. Para taint analysis use os +# rulesets genéricos (p/php, p/security-audit) já ligados no security.yml. +# +# Severidades: ERROR = provável vuln / footgun crítico do playbook; +# WARNING = exige revisão manual / defesa em profundidade; +# INFO = nota de design. +# ============================================================================= +rules: + # --------------------------------------------------------------------------- + # §3 — Comparação frouxa de identidade do ator (IDOR / bypass de policy) + # --------------------------------------------------------------------------- + - id: flarum-v2-loose-actor-id-comparison + languages: [php] + severity: ERROR + message: >- + Comparação com `==` envolvendo `->id`. Em PHP `null == 0` é true, então + um guest (id null) é tratado como user 0. Use `===` após `(int)` nos dois + lados. Ver CLAUDE.md §3. + metadata: + category: security + cwe: "CWE-639: Authorization Bypass Through User-Controlled Key" + confidence: MEDIUM + flarum-playbook: "CLAUDE.md §3" + pattern-either: + - pattern-regex: |- + ->id\s*==[^=] + - pattern-regex: |- + [^=!]==\s*\(int\)\s*\$\w+->id + + # --------------------------------------------------------------------------- + # §7 — Mass assignment (Flarum v2 não tem $fillable/$guarded) + # --------------------------------------------------------------------------- + - id: flarum-v2-mass-assignment + languages: [php] + severity: WARNING + message: >- + Atribuição em massa potencial. Flarum v2 não tem $fillable/$guarded; a + defesa é a allowlist `writable()` do Schema. Nunca passe o corpo da + request para fill()/forceFill()/create(). Ver CLAUDE.md §7. + metadata: + category: security + cwe: "CWE-915: Improperly Controlled Modification of Object Attributes" + confidence: MEDIUM + flarum-playbook: "CLAUDE.md §7" + pattern-either: + - pattern-regex: |- + ->fill\s*\( + - pattern-regex: |- + ->forceFill\s*\( + - pattern-regex: |- + protected\s+\$guarded\s*=\s*\[\s*\] + - pattern-regex: |- + ::create\s*\(\s*\$(request|body|data|attributes|input|parsedBody)\b + + # --------------------------------------------------------------------------- + # §10 — SQL injection: SQL cru com interpolação de variável + # --------------------------------------------------------------------------- + - id: flarum-v2-raw-sql-concat + languages: [php] + severity: ERROR + message: >- + SQL cru com possível interpolação de variável. Use binding de parâmetros + (`?` / `:nome`) ou o Query Builder, que sempre faz binding. Ver CLAUDE.md §10. + metadata: + category: security + cwe: "CWE-89: SQL Injection" + confidence: MEDIUM + flarum-playbook: "CLAUDE.md §10" + pattern-either: + - pattern-regex: |- + (whereRaw|orderByRaw|selectRaw|havingRaw|fromRaw|groupByRaw)\s*\([^)]*\$ + - pattern-regex: |- + DB::raw\s*\([^)]*\$ + - pattern-regex: |- + ->raw\s*\([^)]*\.\s*\$ + + # --------------------------------------------------------------------------- + # §10 — Acesso direto a superglobais (deve usar a request PSR-7) + # --------------------------------------------------------------------------- + - id: flarum-v2-php-superglobals + languages: [php] + severity: ERROR + message: >- + Acesso direto a superglobal. Use a request PSR-7: + $request->getQueryParams() / getParsedBody() / getUploadedFiles(). + Ver CLAUDE.md §10. + metadata: + category: security + cwe: "CWE-20: Improper Input Validation" + confidence: HIGH + flarum-playbook: "CLAUDE.md §10" + pattern-regex: |- + \$_(GET|POST|REQUEST|COOKIE|FILES)\b + + # --------------------------------------------------------------------------- + # §10 — Capsule\Manager como entrypoint de query (smell de convenção) + # --------------------------------------------------------------------------- + - id: flarum-v2-capsule-manager + languages: [php] + severity: WARNING + message: >- + Uso do facade estático Capsule\Manager como entrypoint de query. Funciona + porque o Flarum boota o Capsule globalmente, mas é frágil sob testes e + queue workers. Injete Illuminate\Database\ConnectionInterface ou use um + model Eloquent. Ver CLAUDE.md §10. + metadata: + category: maintainability + confidence: HIGH + flarum-playbook: "CLAUDE.md §10" + pattern-regex: |- + Illuminate\\Database\\Capsule\\Manager + + # --------------------------------------------------------------------------- + # §16 — Bypass de CSRF / throttling + # --------------------------------------------------------------------------- + - id: flarum-v2-bypass-csrf-throttling + languages: [php] + severity: ERROR + message: >- + `bypassCsrfToken` / `bypassThrottling` detectado. Só é aceitável em rotas + autenticadas por token COM verificação própria do ator. Nunca combine + "state-change sensível" + "sem verificação in-request" assumindo o CSRF + como backstop. Ver CLAUDE.md §16. + metadata: + category: security + cwe: "CWE-352: Cross-Site Request Forgery" + confidence: MEDIUM + flarum-playbook: "CLAUDE.md §16" + pattern-regex: |- + bypassCsrfToken|bypassThrottling + + # --------------------------------------------------------------------------- + # §17 — Criação de ApiKey (risco de master key com user_id = NULL) + # --------------------------------------------------------------------------- + - id: flarum-v2-apikey-creation + languages: [php] + severity: ERROR + message: >- + Criação de ApiKey. Um ApiKey com `user_id = NULL` é uma MASTER KEY: permite + impersonar qualquer usuário (inclusive admin) via `;userId=N` no header + Authorization. Vincule a chave a um admin real, ou use HMAC para webhooks. + Documente inline o threat model. Ver CLAUDE.md §17. + metadata: + category: security + cwe: "CWE-798: Use of Hard-coded Credentials" + confidence: HIGH + flarum-playbook: "CLAUDE.md §17" + pattern-regex: |- + new\s+ApiKey\s*\( + + # --------------------------------------------------------------------------- + # §3 — forceAllow / forceDeny sem justificativa + # --------------------------------------------------------------------------- + - id: flarum-v2-force-allow-deny + languages: [php] + severity: WARNING + message: >- + `forceAllow()` / `forceDeny()` sobrepõe TODAS as outras policies + (FORCE_DENY > FORCE_ALLOW > DENY > ALLOW). Use apenas para caminhos sudo + reais (kill switch) e documente inline o motivo do override. Ver CLAUDE.md §3. + metadata: + category: security + cwe: "CWE-285: Improper Authorization" + confidence: HIGH + flarum-playbook: "CLAUDE.md §3" + pattern-regex: |- + ->(forceAllow|forceDeny)\s*\( + + # --------------------------------------------------------------------------- + # §4 — Permissão concedida ao grupo GUEST (a "armadilha do GUEST") + # --------------------------------------------------------------------------- + - id: flarum-v2-guest-permission-grant + languages: [php] + severity: ERROR + message: >- + Permissão aparentemente concedida ao grupo GUEST (id 2). Guest = a + internet inteira. Qualquer permissão além de viewForum/signUp deve ir para + MEMBER_ID (3) por padrão. Ver CLAUDE.md §4. + metadata: + category: security + cwe: "CWE-732: Incorrect Permission Assignment for Critical Resource" + confidence: MEDIUM + flarum-playbook: "CLAUDE.md §4" + pattern-either: + - pattern-regex: |- + Group::GUEST_ID + - pattern-regex: |- + ['"]group_id['"]\s*=>\s*2\b + + # --------------------------------------------------------------------------- + # §13 — Filtro ingênuo de path traversal + # --------------------------------------------------------------------------- + - id: flarum-v2-path-traversal-naive-filter + languages: [php] + severity: ERROR + message: >- + Filtro ingênuo de path traversal. `str_replace('..','')` é derrotado por + `....//`; `strpos($p,'..')` é derrotado por `%2e%2e`. Canonicalize com + `realpath()` e cheque o prefixo COM separador de diretório. + Ver CLAUDE.md §13. + metadata: + category: security + cwe: "CWE-22: Path Traversal" + confidence: MEDIUM + flarum-playbook: "CLAUDE.md §13" + pattern-either: + - pattern-regex: |- + str_replace\s*\(\s*['"]\.\.['"] + - pattern-regex: |- + str_ireplace\s*\(\s*['"]\.\.['"] + - pattern-regex: |- + (strpos|stripos|str_contains)\s*\([^,]+,\s*['"]\.\.['"] + + # --------------------------------------------------------------------------- + # §36 — Execução de comando do SO + # --------------------------------------------------------------------------- + - id: flarum-v2-shell-execution + languages: [php] + severity: ERROR + message: >- + Execução de comando do SO. O Flarum core nunca faz shell-out. Se for + indispensável: pin do binário em caminho absoluto, `proc_open` na forma de + ARRAY (sem shell — injeção de argumento fica estruturalmente impossível), + timeout de wall-clock e checagem de exit code + arquivo de saída. + Ver CLAUDE.md §36. + metadata: + category: security + cwe: "CWE-78: OS Command Injection" + confidence: MEDIUM + flarum-playbook: "CLAUDE.md §36" + pattern-either: + - pattern-regex: |- + \b(shell_exec|passthru|proc_open|popen|pcntl_exec)\s*\( + - pattern-regex: |- + (^|[^>$\w-])exec\s*\( + - pattern-regex: |- + (^|[^>$\w-])system\s*\( + + # --------------------------------------------------------------------------- + # §36 — escapeshellcmd em vez de escapeshellarg + # --------------------------------------------------------------------------- + - id: flarum-v2-escapeshellcmd + languages: [php] + severity: WARNING + message: >- + `escapeshellcmd()` escapa a string de comando inteira e AINDA permite + injeção de argumentos (`--output=/var/www/...`). Use `escapeshellarg()` em + cada argumento individualmente — ou, melhor, a forma de array do + `proc_open`. Ver CLAUDE.md §36. + metadata: + category: security + cwe: "CWE-78: OS Command Injection" + confidence: HIGH + flarum-playbook: "CLAUDE.md §36" + pattern-regex: |- + \bescapeshellcmd\s*\( + + # --------------------------------------------------------------------------- + # §15 — Open redirect a partir de input da request + # --------------------------------------------------------------------------- + - id: flarum-v2-open-redirect + languages: [php] + severity: ERROR + message: >- + RedirectResponse construída a partir de input da request sem validação de + host. Permita apenas paths relativos iniciados por `/` (mas não `//`) ou + URLs cujo host seja o do fórum. CVE-2024-21641. Ver CLAUDE.md §15. + metadata: + category: security + cwe: "CWE-601: Open Redirect" + confidence: MEDIUM + flarum-playbook: "CLAUDE.md §15" + pattern-either: + - pattern-regex: |- + new\s+RedirectResponse\s*\([^)]*getQueryParams + - pattern-regex: |- + new\s+RedirectResponse\s*\([^)]*getParsedBody + - pattern-regex: |- + new\s+RedirectResponse\s*\([^)]*\$_(GET|POST|REQUEST) + + # --------------------------------------------------------------------------- + # §11 / §12 — MIME / Content-Type controlado pelo cliente + # --------------------------------------------------------------------------- + - id: flarum-v2-client-controlled-mime + languages: [php] + severity: WARNING + message: >- + `getClientMediaType()` é controlado pelo cliente — re-detecte o MIME no + servidor com `finfo`. Idem para `Content-Type` montado a partir de + variável: derive-o no servidor a partir da extensão validada. + Ver CLAUDE.md §11 e §12. + metadata: + category: security + cwe: "CWE-434: Unrestricted Upload of File with Dangerous Type" + confidence: MEDIUM + flarum-playbook: "CLAUDE.md §11" + pattern-either: + - pattern-regex: |- + getClientMediaType\s*\( + - pattern-regex: |- + withHeader\s*\(\s*['"]Content-Type['"]\s*,\s*\$ + + # --------------------------------------------------------------------------- + # §21 — serializeToForum expondo segredo (sem filtro de visibilidade) + # --------------------------------------------------------------------------- + - id: flarum-v2-serialize-secret-to-forum + languages: [php] + severity: WARNING + message: >- + `serializeToForum()` expõe o valor a TODO request — inclusive guests — sem + filtro de visibilidade por ator. Nunca serialize segredos (token/key/ + secret/password/webhook). HTML controlado por admin precisa de um cast + sanitizador no terceiro argumento. Ver CLAUDE.md §21. + metadata: + category: security + cwe: "CWE-200: Exposure of Sensitive Information" + confidence: MEDIUM + flarum-playbook: "CLAUDE.md §21" + pattern-regex: |- + (?i)serializeToForum\s*\([^)]*(secret|token|api[_-]?key|password|passwd|webhook|client[_-]?secret|private[_-]?key|stripe) + + # --------------------------------------------------------------------------- + # §23 — Log de corpo/headers da request (vaza password, token, Authorization) + # --------------------------------------------------------------------------- + - id: flarum-v2-log-sensitive-request + languages: [php] + severity: WARNING + message: >- + Log do corpo ou dos headers da request pode vazar password, email, token e + o header Authorization (não há redação automática). Faça strip das chaves + sensíveis (`Arr::except`) antes de logar. Ver CLAUDE.md §23. + metadata: + category: security + cwe: "CWE-532: Insertion of Sensitive Information into Log File" + confidence: MEDIUM + flarum-playbook: "CLAUDE.md §23" + pattern-either: + - pattern-regex: |- + ->(info|debug|notice|warning|error|critical|alert|emergency)\s*\([^)]*getParsedBody + - pattern-regex: |- + ->(info|debug|notice|warning|error|critical|alert|emergency)\s*\([^)]*->getHeaders\s*\( + + # --------------------------------------------------------------------------- + # §14 — SSRF: fetch server-side com URL potencialmente controlada + # --------------------------------------------------------------------------- + - id: flarum-v2-server-side-fetch + languages: [php] + severity: WARNING + message: >- + Fetch server-side a partir de variável (possível SSRF). Se a URL puder vir + de input: valide o scheme E o host RESOLVIDO; rejeite RFC1918, + 169.254.169.254, 127.0.0.0/8, ::1, fe80::/10, fc00::/7; pine o IP resolvido + contra DNS rebinding. Ver CLAUDE.md §14. + metadata: + category: security + cwe: "CWE-918: Server-Side Request Forgery" + confidence: LOW + flarum-playbook: "CLAUDE.md §14" + pattern-either: + - pattern-regex: |- + file_get_contents\s*\(\s*\$ + - pattern-regex: |- + curl_init\s*\(\s*\$ + - pattern-regex: |- + (fopen|readfile)\s*\(\s*\$\w*url + + # --------------------------------------------------------------------------- + # §9.4 — Blade: `{!! !!}` renderiza HTML cru + # --------------------------------------------------------------------------- + - id: flarum-v2-blade-raw-output + languages: [generic] + severity: WARNING + message: >- + `{!! !!}` renderiza HTML cru em template Blade. Use `{{ }}` para QUALQUER + valor controlado por usuário; reserve `{!! !!}` para conteúdo já passado + pelo formatter do Flarum ou por um sanitizador. Ver CLAUDE.md §9.4. + metadata: + category: security + cwe: "CWE-79: Cross-site Scripting" + confidence: LOW + flarum-playbook: "CLAUDE.md §9.4" + paths: + include: + - '*.blade.php' + pattern-regex: |- + \{!!.*!!\} + + # --------------------------------------------------------------------------- + # §9.1 — m.trust() no frontend (HTML cru) + # --------------------------------------------------------------------------- + - id: flarum-v2-m-trust + languages: [javascript, typescript] + severity: WARNING + message: >- + `m.trust()` renderiza HTML cru. Rastreie a origem da string: se vier de + app.forum.attribute(), de uma resposta de API ou de um atributo do DOM, + precisa de sanitização espelhada backend+JS (allowlists idênticas). + Prefira construir a vnode tree do Mithril. Ver CLAUDE.md §9.1. + metadata: + category: security + cwe: "CWE-79: Cross-site Scripting" + confidence: MEDIUM + flarum-playbook: "CLAUDE.md §9.1" + pattern-regex: |- + m\.trust\s*\( + + # --------------------------------------------------------------------------- + # §22 — m.trust() sobre saída do translator (CVE-2021-32671) + # --------------------------------------------------------------------------- + - id: flarum-v2-m-trust-translator + languages: [javascript, typescript] + severity: ERROR + message: >- + `m.trust()` sobre a saída do translator com vars interpoladas é XSS + (padrão da CVE-2021-32671) — `extract:true` achata as vnodes em string e o + m.trust parseia como HTML. Nunca `m.trust(app.translator.trans(...))` com + vars de usuário; construa a vnode tree manualmente. Ver CLAUDE.md §22. + metadata: + category: security + cwe: "CWE-79: Cross-site Scripting" + confidence: HIGH + flarum-playbook: "CLAUDE.md §22" + pattern-regex: |- + m\.trust\s*\([^;]*\.trans\s*\( + + # --------------------------------------------------------------------------- + # §32 (Frontend) — atribuição a innerHTML + # --------------------------------------------------------------------------- + - id: flarum-v2-innerhtml-assignment + languages: [javascript, typescript] + severity: ERROR + message: >- + Atribuição a `innerHTML` com valor potencialmente controlado por usuário é + XSS. Use nós de texto ou vnodes do Mithril (que escapam por padrão). + Ver CLAUDE.md §32 (checklist Frontend). + metadata: + category: security + cwe: "CWE-79: Cross-site Scripting" + confidence: MEDIUM + flarum-playbook: "CLAUDE.md §32" + pattern-regex: |- + \.innerHTML\s*=[^=] + + # --------------------------------------------------------------------------- + # §9.3 — href/src a partir de input sem allowlist de protocolo + # --------------------------------------------------------------------------- + - id: flarum-v2-unsafe-url-attribute + languages: [javascript, typescript] + severity: WARNING + message: >- + Atributo `href`/`src`/`formaction` a partir de input sem allowlist de + protocolo permite `javascript:` URL. Valide com `/^https?:\/\//i` (ou + caminho relativo) antes de renderizar. Ver CLAUDE.md §9.3. + metadata: + category: security + cwe: "CWE-79: Cross-site Scripting" + confidence: LOW + flarum-playbook: "CLAUDE.md §9.3" + pattern-regex: |- + (href|src|formaction)\s*[:=]\s*\{?\s*\w+\.(profileLink|url|link|href|src)\s*\( diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f37f01b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,334 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + workflow_dispatch: + +# Avoid stacking redundant runs for the same PR or branch. +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +# Default-deny do GITHUB_TOKEN (§35.13 C1); cada job declara o que precisa. +permissions: {} + +jobs: + php: + name: PHP ${{ matrix.php }} + runs-on: ubuntu-latest + permissions: + contents: read + strategy: + fail-fast: false + matrix: + php: ['8.2', '8.3', '8.4'] + steps: + - name: Harden runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup PHP ${{ matrix.php }} + uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # v2 + with: + php-version: ${{ matrix.php }} + coverage: none + tools: composer:v2 + + - name: Validar composer.json + # O composer.json NÃO tem campo "version": a versão dos releases é + # derivada das tags git (ver release-management.yml). --no-check-publish + # evita falso-positivo de requisitos de publicação; demais warnings + # continuam fatais. + run: composer validate --strict --no-check-publish + + - name: Lint PHP (php -l) + run: | + set -euo pipefail + mapfile -d '' files < <(find src migrations extend.php -name '*.php' -print0 2>/dev/null) + [ "${#files[@]}" -gt 0 ] || { echo "Nenhum arquivo PHP encontrado"; exit 1; } + printf '%s\0' "${files[@]}" | xargs -0 -n1 -P4 php -l + + # --------------------------------------------------------------------------- + # Database transfer tests. + # + # The unit suite (Dialect detection, incl. the mariadb-driver regression) + # runs in every job since it needs no server. The integration suite drives + # the real dump/restore engine: for a given (source → target) pair to run, + # BOTH engines must be reachable, otherwise that direction is skipped. So: + # + # • the per-engine jobs below pin a SERVER version and run that engine's + # same-engine round-trip + every direction it shares with SQLite + # (which is always available) — this is the per-version coverage; + # • the `tests-all-directions` job brings up mysql + mariadb + postgres + # + sqlite together so the FULL source×target matrix executes — this is + # the "works in every direction" gate. + tests-mysql: + name: Tests · MySQL ${{ matrix.version }} + runs-on: ubuntu-latest + permissions: + contents: read + strategy: + fail-fast: false + matrix: + version: ['8.0', '8.4'] + services: + mysql: + image: mysql:${{ matrix.version }} + env: + MYSQL_ALLOW_EMPTY_PASSWORD: "yes" + MYSQL_DATABASE: backup_xfer_test + ports: + - 3306:3306 + options: >- + --health-cmd="mysqladmin ping -h 127.0.0.1 --silent" + --health-interval=10s --health-timeout=5s --health-retries=20 + env: + BACKUP_TEST_MYSQL: 'host=127.0.0.1;port=3306;username=root;password=;database=backup_xfer_test' + steps: + - name: Harden runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # v2 + with: + php-version: '8.3' + extensions: pdo_mysql, pdo_pgsql, pdo_sqlite, intl, gd, bcmath, sodium + coverage: none + tools: composer:v2 + - run: composer update --no-interaction --no-progress --prefer-dist --no-scripts + - run: vendor/bin/phpunit --testdox + + tests-mariadb: + name: Tests · MariaDB ${{ matrix.version }} + runs-on: ubuntu-latest + permissions: + contents: read + strategy: + fail-fast: false + matrix: + version: ['10.11', '11.4'] + services: + mariadb: + image: mariadb:${{ matrix.version }} + env: + MARIADB_ALLOW_EMPTY_ROOT_PASSWORD: "yes" + MARIADB_DATABASE: backup_xfer_test + ports: + - 3306:3306 + options: >- + --health-cmd="healthcheck.sh --connect --innodb_initialized" + --health-interval=10s --health-timeout=5s --health-retries=20 + env: + # Driver `mariadb` (illuminate v13's dedicated one) makes the + # connection report driverName=mariadb — the exact path that + # regressed. The harness picks it from the engine key, not env. + BACKUP_TEST_MARIADB: 'host=127.0.0.1;port=3306;username=root;password=;database=backup_xfer_test' + steps: + - name: Harden runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # v2 + with: + php-version: '8.3' + extensions: pdo_mysql, pdo_pgsql, pdo_sqlite, intl, gd, bcmath, sodium + coverage: none + tools: composer:v2 + - run: composer update --no-interaction --no-progress --prefer-dist --no-scripts + - run: vendor/bin/phpunit --testdox + + tests-postgres: + name: Tests · PostgreSQL ${{ matrix.version }} + runs-on: ubuntu-latest + permissions: + contents: read + strategy: + fail-fast: false + matrix: + version: ['14', '16', '17'] + services: + postgres: + image: postgres:${{ matrix.version }} + env: + POSTGRES_PASSWORD: postgres + POSTGRES_DB: backup_xfer_test + ports: + - 5432:5432 + options: >- + --health-cmd="pg_isready -U postgres" + --health-interval=10s --health-timeout=5s --health-retries=20 + env: + BACKUP_TEST_POSTGRES: 'host=127.0.0.1;port=5432;username=postgres;password=postgres;database=backup_xfer_test' + steps: + - name: Harden runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # v2 + with: + php-version: '8.3' + extensions: pdo_mysql, pdo_pgsql, pdo_sqlite, intl, gd, bcmath, sodium + coverage: none + tools: composer:v2 + - run: composer update --no-interaction --no-progress --prefer-dist --no-scripts + - run: vendor/bin/phpunit --testdox + + tests-all-directions: + name: Tests · all directions (mysql+mariadb+postgres+sqlite) + runs-on: ubuntu-latest + permissions: + contents: read + services: + mysql: + image: mysql:8.4 + env: + MYSQL_ALLOW_EMPTY_PASSWORD: "yes" + MYSQL_DATABASE: backup_xfer_test + ports: + - 3306:3306 + options: >- + --health-cmd="mysqladmin ping -h 127.0.0.1 --silent" + --health-interval=10s --health-timeout=5s --health-retries=20 + mariadb: + image: mariadb:11.4 + env: + MARIADB_ALLOW_EMPTY_ROOT_PASSWORD: "yes" + MARIADB_DATABASE: backup_xfer_test + ports: + # Host 3307 to avoid colliding with mysql's 3306 mapping. + - 3307:3306 + options: >- + --health-cmd="healthcheck.sh --connect --innodb_initialized" + --health-interval=10s --health-timeout=5s --health-retries=20 + postgres: + image: postgres:17 + env: + POSTGRES_PASSWORD: postgres + POSTGRES_DB: backup_xfer_test + ports: + - 5432:5432 + options: >- + --health-cmd="pg_isready -U postgres" + --health-interval=10s --health-timeout=5s --health-retries=20 + env: + BACKUP_TEST_MYSQL: 'host=127.0.0.1;port=3306;username=root;password=;database=backup_xfer_test' + BACKUP_TEST_MARIADB: 'host=127.0.0.1;port=3307;username=root;password=;database=backup_xfer_test' + BACKUP_TEST_POSTGRES: 'host=127.0.0.1;port=5432;username=postgres;password=postgres;database=backup_xfer_test' + steps: + - name: Harden runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # v2 + with: + php-version: '8.3' + extensions: pdo_mysql, pdo_pgsql, pdo_sqlite, intl, gd, bcmath, sodium + coverage: none + tools: composer:v2 + - run: composer update --no-interaction --no-progress --prefer-dist --no-scripts + - run: vendor/bin/phpunit --testdox + + js: + name: JS (typecheck, format, build) + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Harden runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + # Setup PHP + composer for the Flarum core typings. The js/tsconfig + # resolves `flarum/*` imports through `../vendor/flarum/core/js/dist-typings/*`, + # so without composer install the type-check fails with TS2307. + - name: Setup PHP + uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # v2 + with: + php-version: '8.3' + coverage: none + tools: composer:v2 + + - name: Cache do composer + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: ~/.cache/composer + key: composer-${{ runner.os }}-${{ hashFiles('composer.json') }} + restore-keys: composer-${{ runner.os }}- + + - name: Instalar dependências PHP (para os typings do core) + # `composer update` (em vez de install) porque a extensão é uma + # lib e mantém composer.lock no .gitignore. + run: composer update --no-interaction --no-progress --prefer-dist --no-scripts + + - name: Setup Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 20 + cache: 'npm' + cache-dependency-path: js/package-lock.json + + - name: Instalar dependências JS + working-directory: js + run: npm ci + + - name: Verificar formatação (Prettier) + working-directory: js + run: npm run format-check + + - name: Type-check (TypeScript) + working-directory: js + run: npm run check-typings + + - name: Build (webpack production) + working-directory: js + run: npm run build + + # §35.13 R3 — nível 6 com baseline versionado (phpstan.neon + + # phpstan-baseline.neon). BLOQUEANTE: o baseline mantém verde e novos + # achados reprovam o PR (aperte o nível/baseline com o tempo). + phpstan: + name: PHPStan (nível 6, baseline) + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Harden runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup PHP + uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # v2 + with: + php-version: '8.3' + extensions: pdo_mysql, pdo_pgsql, pdo_sqlite, intl, gd, bcmath, sodium + coverage: none + tools: composer:v2 + + - name: Instalar dependências + run: composer update --no-interaction --no-progress --prefer-dist --no-scripts + + - name: PHPStan analyse + run: vendor/bin/phpstan analyse --no-progress --memory-limit=1G --error-format=github diff --git a/.github/workflows/cleanup-releases.yml b/.github/workflows/cleanup-releases.yml new file mode 100644 index 0000000..d16cf49 --- /dev/null +++ b/.github/workflows/cleanup-releases.yml @@ -0,0 +1,61 @@ +name: Limpar Releases Antigas + +on: + workflow_dispatch: + +jobs: + cleanup: + name: Manter apenas as 5 últimas releases + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Harden runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + + - name: Apagar releases antigas + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const KEEP = 5; + + const releases = await github.paginate( + github.rest.repos.listReleases, + { owner: context.repo.owner, repo: context.repo.repo, per_page: 100 } + ); + + // Sort by published_at descending (newest first) + releases.sort((a, b) => new Date(b.published_at) - new Date(a.published_at)); + + const toDelete = releases.slice(KEEP); + + console.log(`Total de releases: ${releases.length}`); + console.log(`Mantendo as ${KEEP} mais recentes, apagando ${toDelete.length}`); + + for (const release of toDelete) { + console.log(`Apagando release: ${release.tag_name} (id: ${release.id})`); + + // Delete the release + await github.rest.repos.deleteRelease({ + owner: context.repo.owner, + repo: context.repo.repo, + release_id: release.id, + }); + + // Delete the associated tag + try { + await github.rest.git.deleteRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: `tags/${release.tag_name}`, + }); + console.log(` Tag ${release.tag_name} apagada`); + } catch (e) { + console.log(` Tag ${release.tag_name} não encontrada ou já apagada`); + } + } + + console.log('✅ Limpeza concluída!'); diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..5720f3e --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,63 @@ +name: CodeQL + +# §35.13 I2 — CodeQL (JS/TS). PHP não é suportado pelo CodeQL; a cobertura +# de PHP é feita por PHPStan (ci.yml) e pelas regras Semgrep (security.yml). +# +# NOTA: code scanning em repositório privado exige GitHub Advanced Security. +# Sem GHAS o passo `analyze` aborta com "Code scanning is not enabled". Os +# passos `init`/`analyze` levam `continue-on-error: true` para não derrubar a +# CI até o GHAS ser habilitado — quando for, o job volta a valer sozinho. +# Enquanto isso, a cobertura SAST de JS/TS fica por conta do Semgrep +# (security.yml: ruleset `p/javascript` + regras Flarum v2). +on: + push: + branches: + - main + pull_request: + branches: + - main + schedule: + - cron: '0 6 * * 1' # toda segunda, 06:00 UTC + +# §35.13 C1 — default-deny. +permissions: {} + +concurrency: + group: codeql-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + analyze: + name: Analisar (${{ matrix.language }}) + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + actions: read + strategy: + fail-fast: false + matrix: + language: ['javascript-typescript'] + steps: + - name: Harden Runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Inicializar CodeQL + # continue-on-error: requer GHAS em repo privado (ver nota no topo). + continue-on-error: true + uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + with: + languages: ${{ matrix.language }} + queries: security-extended,security-and-quality + + - name: Analisar + # continue-on-error: requer GHAS em repo privado (ver nota no topo). + continue-on-error: true + uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + with: + category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/dependabot-automerge.yml b/.github/workflows/dependabot-automerge.yml new file mode 100644 index 0000000..c64ccf1 --- /dev/null +++ b/.github/workflows/dependabot-automerge.yml @@ -0,0 +1,66 @@ +name: Dependabot auto-merge + +# Auto-merge dos PRs do Dependabot de patch/minor: aprova e liga o auto-merge, +# e o GitHub funde sozinho QUANDO os checks obrigatórios (CI) passam — se a CI +# acusar quebra, o PR NÃO é mesclado. A branch é apagada após o merge +# (--delete-branch). Major fica para revisão manual. +# +# Requer, nas Settings do repo (não dá pra configurar por workflow): +# • General → "Allow auto-merge" LIGADO (senão `gh pr merge --auto` falha); +# • Branch protection em `main` exigindo os checks do workflow CI verdes — +# é isso que faz o auto-merge ESPERAR a CI em vez de fundir na hora; +# • General → "Automatically delete head branches" (opcional; o +# --delete-branch abaixo já cobre o caso do Dependabot). +on: pull_request_target + +# §35.13 C1 — default-deny. +permissions: {} + +concurrency: + group: dependabot-automerge-${{ github.event.pull_request.number }} + cancel-in-progress: false + +jobs: + automerge: + name: Auto-merge (patch/minor) + runs-on: ubuntu-latest + # Só roda para PRs abertos pelo próprio Dependabot. NÃO faz checkout do + # código do PR (pull_request_target roda no contexto do base) — então não + # há execução de código não-confiável com o token de escrita. + if: github.actor == 'dependabot[bot]' + permissions: + contents: write + pull-requests: write + steps: + - name: Harden runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + allowed-endpoints: > + api.github.com:443 + github.com:443 + + - name: Metadados do update + id: meta + uses: dependabot/fetch-metadata@08eff52bf64351f401fb50d4972fa95b9f2c2d1b # v2.4.0 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + + # Aprova o PR para que o auto-merge consiga concluir mesmo quando o branch + # protection exige um review aprovado. Gated em patch/minor — major nunca + # é aprovado nem mesclado automaticamente. + - name: Aprovar (patch + minor) + if: steps.meta.outputs.update-type == 'version-update:semver-patch' || steps.meta.outputs.update-type == 'version-update:semver-minor' + run: gh pr review --approve "$PR_URL" + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # --auto: o GitHub funde sozinho quando os checks obrigatórios passarem + # (CI verde). --delete-branch: remove a branch do Dependabot após o merge. + - name: Habilitar auto-merge (patch + minor) + if: steps.meta.outputs.update-type == 'version-update:semver-patch' || steps.meta.outputs.update-type == 'version-update:semver-minor' + run: gh pr merge --auto --squash --delete-branch "$PR_URL" + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/gitleaks.yml b/.github/workflows/gitleaks.yml new file mode 100644 index 0000000..d11a716 --- /dev/null +++ b/.github/workflows/gitleaks.yml @@ -0,0 +1,52 @@ +name: Gitleaks + +# Varredura de segredos: chaves/tokens/credenciais hardcoded — inclusive no +# histórico git. Reprova o PR se encontrar segredo (gate bloqueante). +# Complementa o ruleset `p/secrets` do Semgrep (security.yml) com uma +# ferramenta dedicada que também varre commits antigos. +on: + push: + branches: + - main + pull_request: + schedule: + - cron: '0 8 * * 1' # toda segunda, 08:00 UTC + workflow_dispatch: + +# §35.13 C1 — default-deny. +permissions: {} + +concurrency: + group: gitleaks-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + gitleaks: + name: Gitleaks (secret scan) + runs-on: ubuntu-latest + permissions: + contents: read + # gitleaks-action, em pull_request, lê os commits do PR via API + # (GET /pulls/{n}/commits) — exige pull-requests: read, senão 403. + pull-requests: read + steps: + - name: Harden runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + allowed-endpoints: > + api.github.com:443 + github.com:443 + objects.githubusercontent.com:443 + release-assets.githubusercontent.com:443 + + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + # Histórico completo: varre segredos commitados no passado, não só o diff. + fetch-depth: 0 + + - name: Gitleaks + uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 # v2.3.9 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/pr-labeler.yml b/.github/workflows/pr-labeler.yml new file mode 100644 index 0000000..669dd04 --- /dev/null +++ b/.github/workflows/pr-labeler.yml @@ -0,0 +1,25 @@ +name: PR Labeler + +on: + pull_request: + types: [opened, reopened, synchronize] + +jobs: + label_pr: + name: Aplicar Labels no PR + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + steps: + - name: Harden runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + + - name: Aplicar label baseada na branch + uses: TimonVS/pr-labeler-action@f9c084306ce8b3f488a8f3ee1ccedc6da131d1af # v5.0.0 + with: + configuration-path: .github/pr-labeler.yml + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/psalm.yml b/.github/workflows/psalm.yml new file mode 100644 index 0000000..e5bc0ea --- /dev/null +++ b/.github/workflows/psalm.yml @@ -0,0 +1,90 @@ +name: Psalm Taint + +# Análise de taint (fluxo de dados origem→sink) para PHP — pega SQLi/XSS/ +# path-traversal que as regras de padrão do Semgrep não veem. O CodeQL não +# cobre PHP; o Psalm taint é o equivalente. +# +# BLOQUEANTE desde o início: o baseline foi pré-verificado limpo localmente +# (psalm 6.x, "No errors found"), então achado novo de taint reprova o PR. +on: + push: + branches: + - main + pull_request: + schedule: + - cron: '0 9 * * 1' # toda segunda, 09:00 UTC + workflow_dispatch: + +# §35.13 C1 — default-deny. +permissions: {} + +concurrency: + group: psalm-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + psalm: + name: Psalm taint analysis + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write # upload SARIF + actions: read + steps: + - name: Harden runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + allowed-endpoints: > + api.github.com:443 + github.com:443 + codeload.github.com:443 + objects.githubusercontent.com:443 + release-assets.githubusercontent.com:443 + raw.githubusercontent.com:443 + packagist.org:443 + repo.packagist.org:443 + getcomposer.org:443 + pecl.php.net:443 + + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup PHP + uses: shivammathur/setup-php@7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc # v2 + with: + php-version: '8.3' + extensions: dom, mbstring, fileinfo, bcmath, intl, simplexml, tokenizer, sodium, pdo_sqlite + coverage: none + tools: composer:v2 + + - name: Resolver dependências (composer update) + run: composer update --no-interaction --no-progress --prefer-stable --no-scripts + + # Bloqueante: o exit do psalm é propagado após garantir que o SARIF + # exista para upload. + - name: Psalm taint analysis + run: | + set +e + vendor/bin/psalm --taint-analysis --report=psalm-taint.sarif --no-progress + EXIT=$? + [ -f psalm-taint.sarif ] || echo '{"version":"2.1.0","runs":[]}' > psalm-taint.sarif + ls -l psalm-taint.sarif + exit $EXIT + + - name: Upload SARIF — Psalm taint + if: always() + continue-on-error: true + uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + with: + sarif_file: psalm-taint.sarif + category: psalm-taint + + - name: Upload SARIF como artefato + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: psalm-taint-sarif + path: psalm-taint.sarif + retention-days: 30 + if-no-files-found: warn diff --git a/.github/workflows/publish-to-flarum.yml b/.github/workflows/publish-to-flarum.yml new file mode 100644 index 0000000..e446f40 --- /dev/null +++ b/.github/workflows/publish-to-flarum.yml @@ -0,0 +1,98 @@ +name: Publicar Release no Flarum + +on: + release: + types: [published] + workflow_dispatch: + inputs: + release_tag: + description: 'Tag da release (ex: v2.1.19-beta)' + required: true + release_body: + description: 'Corpo/descrição da release' + required: false + default: '' + release_url: + description: 'URL da release no GitHub' + required: false + default: '' + +permissions: {} + +jobs: + publish_to_flarum: + name: Publicar no Fórum + runs-on: ubuntu-latest + steps: + # §35.13 I3 — este job toca em FLARUM_API_KEY; auditar o egress importa. + - name: Harden runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + + - name: Validar FLARUM_DISCUSSION_ID + env: + FLARUM_DISCUSSION_ID: ${{ vars.FLARUM_DISCUSSION_ID }} + run: | + if [ -z "$FLARUM_DISCUSSION_ID" ]; then + echo "❌ Erro: FLARUM_DISCUSSION_ID não está configurado nas variáveis do repositório." + echo "Configure em: Settings > Secrets and variables > Actions > Variables" + exit 1 + fi + echo "✅ FLARUM_DISCUSSION_ID configurado: $FLARUM_DISCUSSION_ID" + + - name: Postar comentário de release no Flarum + env: + FLARUM_API_KEY: ${{ secrets.FLARUM_API_KEY }} + FLARUM_DISCUSSION_ID: ${{ vars.FLARUM_DISCUSSION_ID }} + RELEASE_TAG: ${{ github.event.release.tag_name || inputs.release_tag }} + RELEASE_BODY: ${{ github.event.release.body || inputs.release_body }} + RELEASE_URL: ${{ github.event.release.html_url || inputs.release_url }} + run: | + # Extract version from tag (remove leading 'v' if present) + VERSION="${RELEASE_TAG#v}" + + # Build the JSON payload using jq to safely handle all special characters + PAYLOAD=$(jq -n \ + --arg tag "$RELEASE_TAG" \ + --arg body "$RELEASE_BODY" \ + --arg url "$RELEASE_URL" \ + --arg version "$VERSION" \ + --arg discussion_id "$FLARUM_DISCUSSION_ID" \ + '{ + "data": { + "type": "posts", + "attributes": { + "content": ("## 🚀 " + $tag + "\n\n" + $body + "\n\n**Instalação:**\n```\ncomposer require ramon/backup:" + $version + "\n```\n\n[Ver release no GitHub](" + $url + ")") + }, + "relationships": { + "discussion": { + "data": { + "type": "discussions", + "id": $discussion_id + } + } + } + } + }') + + echo "Payload preview:" + echo "$PAYLOAD" | jq '.' + + HTTP_STATUS=$(curl -s -o /tmp/flarum_response.json -w "%{http_code}" \ + -X POST "https://ramonguilherme.com.br/api/posts" \ + -H "Authorization: Token ${FLARUM_API_KEY}" \ + -H "Content-Type: application/json" \ + -d "$PAYLOAD") + + echo "HTTP Status: $HTTP_STATUS" + + if [ "$HTTP_STATUS" -ge 200 ] && [ "$HTTP_STATUS" -lt 300 ]; then + echo "✅ Comentário publicado com sucesso no Flarum!" + echo "Post ID: $(jq -r '.data.id // empty' /tmp/flarum_response.json)" + else + echo "❌ Falha ao publicar comentário no Flarum (HTTP $HTTP_STATUS)" + echo "Response body:" + jq '.' /tmp/flarum_response.json + exit 1 + fi diff --git a/.github/workflows/release-management.yml b/.github/workflows/release-management.yml new file mode 100644 index 0000000..859abd4 --- /dev/null +++ b/.github/workflows/release-management.yml @@ -0,0 +1,224 @@ +name: Release Workflow + +on: + # Dispara quando um PR é MESCLADO no main carregando a label "release". + # O tipo de incremento vem das labels do PR: + # - label "BC" => minor (ex.: 2.0.14 -> 2.1.0) + # - qualquer outra => patch (ex.: 2.0.14 -> 2.0.15) + # A versão é derivada da ÚLTIMA TAG git — o composer.json NÃO tem mais + # campo "version" (fonte única de verdade = tags do VCS). + pull_request: + types: [closed] + branches: + - main + # Escape hatch manual: permite publicar um release escolhendo o bump + # explicitamente (inclui "major", que o fluxo automático nunca faz). + workflow_dispatch: + inputs: + bump: + description: 'Tipo de incremento da versão' + required: true + type: choice + default: patch + options: + - patch + - minor + - major + +# §35.13 C1 — default-deny. Job declara o que precisa. +permissions: {} + +# §35.13 I4 — serializa releases; nunca cancela um em andamento (um release +# pela metade exigiria limpeza manual de tag). +concurrency: + group: release + cancel-in-progress: false + +jobs: + build_and_release: + name: Build e Publicação Automática + runs-on: ubuntu-latest + # Só roda em dispatch manual OU em PR mesclado que tenha a label "release". + if: >- + github.event_name == 'workflow_dispatch' || + (github.event.pull_request.merged == true && + contains(github.event.pull_request.labels.*.name, 'release')) + permissions: + contents: write # cria tag/release + pull-requests: read # release-drafter lê PRs mesclados + id-token: write # §35.13 R4 — attest-build-provenance + attestations: write # §35.13 R4 + steps: + # §35.13 I3 — auditar todo egress (em modo audit; promover a block depois). + - name: Harden Runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + + - name: Checkout do código + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + # fetch-depth: 0 traz todo o histórico E todas as tags — necessário + # para calcular a próxima versão a partir da última tag. + fetch-depth: 0 + # PR mesclado: aponta para o commit de merge real no main. + # Dispatch manual: HEAD da branch escolhida (default main). + ref: ${{ github.event.pull_request.merge_commit_sha || github.sha }} + + - name: Configurar Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 20 + cache: 'npm' + cache-dependency-path: js/package-lock.json + + - name: Instalar Dependências JS + run: | + cd js + npm ci + + - name: Build JS (Flarum) + run: | + cd js + npm run build + + - name: Calcular próxima versão + id: version + env: + EVENT_NAME: ${{ github.event_name }} + DISPATCH_BUMP: ${{ github.event.inputs.bump }} + PR_LABELS: ${{ github.event_name == 'pull_request' && join(github.event.pull_request.labels.*.name, ',') || '' }} + run: | + set -euo pipefail + + # 1. Determina o tipo de bump. + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + BUMP="$DISPATCH_BUMP" + else + # Label "BC" no PR => minor; qualquer outra label de tipo => patch. + case ",$PR_LABELS," in + *,BC,*) BUMP="minor" ;; + *) BUMP="patch" ;; + esac + fi + echo "Bump: $BUMP (labels do PR: ${PR_LABELS:-})" + + # 2. Última tag de versão (semver). O glob 'v[0-9]*' e o sort por + # versão pegam a maior tag; %%-* descarta qualquer sufixo legado + # de pré-release (ex.: -beta), pois agora publicamos estáveis. + LATEST="$(git tag --list 'v[0-9]*' --sort=-v:refname | head -n1 || true)" + LATEST="${LATEST:-v0.0.0}" + CORE="${LATEST#v}" + CORE="${CORE%%-*}" + IFS='.' read -r MAJOR MINOR PATCH <<< "$CORE" + MAJOR="${MAJOR:-0}"; MINOR="${MINOR:-0}"; PATCH="${PATCH:-0}" + echo "Última tag: $LATEST -> base ${MAJOR}.${MINOR}.${PATCH}" + + # 3. Incrementa conforme o bump. + case "$BUMP" in + major) MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 ;; + minor) MINOR=$((MINOR + 1)); PATCH=0 ;; + patch) PATCH=$((PATCH + 1)) ;; + *) echo "::error::Bump inválido: '$BUMP'"; exit 1 ;; + esac + + VERSION="${MAJOR}.${MINOR}.${PATCH}" + TAG="v${VERSION}" + + # 4. Garante idempotência — não republica uma tag existente. + if git rev-parse "refs/tags/${TAG}" >/dev/null 2>&1; then + echo "::error::A tag ${TAG} já existe — nada a publicar." + exit 1 + fi + + echo "Nova versão: ${VERSION} (tag ${TAG})" + { + echo "version=${VERSION}" + echo "tag=${TAG}" + echo "bump=${BUMP}" + } >> "$GITHUB_OUTPUT" + + - name: Publicar Release + id: create_release + uses: release-drafter/release-drafter@6a93d829887aa2e0748befe2e808c66c0ec6e4c7 # v6 + with: + publish: true + # Versão calculada acima — sobrepõe o version-resolver do + # .github/release-drafter.yml (que fica só como documentação). + version: ${{ steps.version.outputs.version }} + tag: ${{ steps.version.outputs.tag }} + name: ${{ steps.version.outputs.tag }} + # Aponta a tag/release para o commit de merge real (não para o + # merge simulado do evento pull_request). + commitish: ${{ github.event.pull_request.merge_commit_sha || github.sha }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # §35.13 R4 — provenance assinada (SLSA) para os artefatos do release. + - name: Atestar provenance dos artefatos + if: steps.create_release.outcome == 'success' + uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 + with: + subject-path: | + composer.json + js/dist/*.js + + - name: Publicar Release no Flarum + if: steps.create_release.outcome == 'success' && steps.create_release.outputs.html_url && vars.FLARUM_DISCUSSION_ID != '' + env: + FLARUM_API_KEY: ${{ secrets.FLARUM_API_KEY }} + FLARUM_DISCUSSION_ID: ${{ vars.FLARUM_DISCUSSION_ID }} + RELEASE_TAG: ${{ steps.create_release.outputs.tag_name }} + RELEASE_BODY: ${{ steps.create_release.outputs.body }} + RELEASE_URL: ${{ steps.create_release.outputs.html_url }} + run: | + VERSION="${RELEASE_TAG#v}" + + PAYLOAD=$(jq -n \ + --arg tag "$RELEASE_TAG" \ + --arg body "$RELEASE_BODY" \ + --arg url "$RELEASE_URL" \ + --arg version "$VERSION" \ + --arg discussion_id "$FLARUM_DISCUSSION_ID" \ + '{ + "data": { + "type": "posts", + "attributes": { + "content": ("## 🚀 " + $tag + "\n\n" + $body + "\n\n**Instalação:**\n```\ncomposer require ramon/backup:" + $version + "\n```\n\n[Ver release no GitHub](" + $url + ")") + }, + "relationships": { + "discussion": { + "data": { + "type": "discussions", + "id": $discussion_id + } + } + } + } + }') + + HTTP_STATUS=$(curl -s -o /tmp/flarum_response.json -w "%{http_code}" \ + -X POST "https://ramonguilherme.com.br/api/posts" \ + -H "Authorization: Token ${FLARUM_API_KEY}" \ + -H "Content-Type: application/json" \ + -d "$PAYLOAD") + + echo "HTTP Status: $HTTP_STATUS" + + if [ "$HTTP_STATUS" -ge 200 ] && [ "$HTTP_STATUS" -lt 300 ]; then + echo "✅ Comentário publicado com sucesso no Flarum!" + echo "Post ID: $(jq -r '.data.id // empty' /tmp/flarum_response.json)" + else + echo "❌ Falha ao publicar comentário no Flarum (HTTP $HTTP_STATUS)" + echo "Response body:" + jq '.' /tmp/flarum_response.json + exit 1 + fi + + - name: Log de Status + if: always() + run: | + echo "Bump: ${{ steps.version.outputs.bump }}" + echo "Nova versão: ${{ steps.version.outputs.version }}" + echo "Tag: ${{ steps.version.outputs.tag }}" + echo "Release URL: ${{ steps.create_release.outputs.html_url }}" diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..81a524e --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,133 @@ +name: Security Scan + +# Duas camadas de SAST: +# 1. Rulesets genéricos do Semgrep (PHP, OWASP, secrets, JS). +# 2. Regras específicas de Flarum v2 (.github/semgrep/flarum-v2.yaml) — +# derivadas do playbook de segurança CLAUDE.md (§2–§37). +# CodeQL (JS/TS) fica no codeql.yml. +on: + push: + branches: + - main + pull_request: + branches: + - main + schedule: + - cron: '0 7 * * 1' # toda segunda, 07:00 UTC + workflow_dispatch: + +# §35.13 C1 — default-deny. +permissions: {} + +concurrency: + group: security-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + semgrep: + name: Semgrep (SAST genérico + regras Flarum v2) + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write # upload SARIF + actions: read + container: + # 1.96.0 saía com exit ≠ 0 na camada 2 (ruleset custom) sem escrever o + # SARIF — a etapa de upload então falhava com "Path does not exist". + # 1.163.0 roda as 22 regras e gera o arquivo (verificado localmente: + # `semgrep --validate` => 0 erros; `semgrep scan` => SARIF de 19 achados). + image: semgrep/semgrep:1.163.0 + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + # Histórico completo: o scan diff-aware da camada 2 precisa do commit + # base disponível para comparar (--baseline-commit). + fetch-depth: 0 + + # ---- Camada 1: rulesets curados pelo time Semgrep ------------------ + - name: Semgrep — rulesets genéricos + run: | + set +e + semgrep scan \ + --config p/php \ + --config p/security-audit \ + --config p/owasp-top-ten \ + --config p/secrets \ + --config p/javascript \ + --exclude='js/dist' \ + --exclude='*.min.js' \ + --sarif --sarif-output=semgrep-generic.sarif \ + --metrics=off + echo "Semgrep (genérico) exit code: $?" + # Garante que o arquivo existe mesmo se o semgrep crashar antes de escrevê-lo. + [ -f semgrep-generic.sarif ] || echo '{"version":"2.1.0","runs":[]}' > semgrep-generic.sarif + ls -l semgrep-generic.sarif + exit 0 + + - name: Upload SARIF — genérico + if: always() + # Code scanning pode não estar habilitado (repo privado sem GitHub + # Advanced Security) — nesse caso o upload emite warning. O artefato + # ao final do job é o canal de resultados que sempre funciona. + continue-on-error: true + uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + with: + sarif_file: semgrep-generic.sarif + category: semgrep + + # ---- Camada 2: regras específicas de Flarum v2 -------------------- + # BLOQUEANTE em diff-aware: num PR, falha se houver achado NOVO vs. o + # commit base (--baseline-commit ... --error). Em push/schedule, varre + # tudo mas NÃO reprova — os achados legados ficam informativos no SARIF. + # Assim o gate aperta o código novo (inclui o gerado por IA) sem travar + # no legado, no mesmo espírito do baseline do PHPStan. + - name: Semgrep — regras Flarum v2 (CLAUDE.md) + env: + EVENT: ${{ github.event_name }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + set +e + BASELINE_ARGS="" + BLOCK=0 + if [ "$EVENT" = "pull_request" ] && [ -n "$BASE_SHA" ]; then + BASELINE_ARGS="--baseline-commit $BASE_SHA --error" + BLOCK=1 + fi + semgrep scan \ + --config .github/semgrep/flarum-v2.yaml \ + --exclude='js/dist' \ + --exclude='*.min.js' \ + $BASELINE_ARGS \ + --sarif --sarif-output=semgrep-flarum.sarif \ + --metrics=off + EXIT=$? + echo "Semgrep (Flarum v2) exit code: $EXIT (block=$BLOCK)" + # Garante que o arquivo existe mesmo se o semgrep crashar antes de escrevê-lo. + [ -f semgrep-flarum.sarif ] || echo '{"version":"2.1.0","runs":[]}' > semgrep-flarum.sarif + ls -l semgrep-flarum.sarif + # Em PR (BLOCK=1): propaga o exit do semgrep (≠0 = achado novo). + # Em push/schedule: informativo, nunca reprova. + [ "$BLOCK" = "1" ] && exit $EXIT + exit 0 + + - name: Upload SARIF — Flarum v2 + if: always() + continue-on-error: true + uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 + with: + sarif_file: semgrep-flarum.sarif + category: flarum-v2-security + + # Canal de resultados independente do code scanning estar ligado: + # baixável em Actions → run → Artifacts. Inclui as duas camadas. + - name: Upload SARIF como artefato + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: semgrep-sarif + path: | + semgrep-generic.sarif + semgrep-flarum.sarif + retention-days: 30 + if-no-files-found: warn diff --git a/.github/workflows/sync-branches.yml b/.github/workflows/sync-branches.yml new file mode 100644 index 0000000..27faa58 --- /dev/null +++ b/.github/workflows/sync-branches.yml @@ -0,0 +1,59 @@ +name: Sincronizar Branches com Main + +on: + push: + branches: + - main + workflow_dispatch: + +concurrency: + group: sync-branches + cancel-in-progress: false + +jobs: + sync: + name: Sincronizar branches com main + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Harden runner + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + + - name: Checkout do repositório + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Configurar Git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Sincronizar branches com main + run: | + git fetch --all + + BRANCHES=$(git branch -r \ + | grep -v 'HEAD' \ + | grep -v 'origin/main' \ + | grep -v 'origin/copilot/' \ + | sed 's|origin/||' \ + | tr -d ' ') + + for branch in $BRANCHES; do + echo "▶ Sincronizando branch: $branch" + + git checkout -B "$branch" "origin/$branch" + + if git merge origin/main --no-edit -m "chore: sync with main"; then + git push origin "$branch" + echo "✅ Branch '$branch' sincronizada com sucesso" + else + echo "⚠️ Conflito ao sincronizar '$branch' com main — pulando" + git merge --abort + fi + done diff --git a/.gitignore b/.gitignore index b475996..522540e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,22 +1,24 @@ -node_modules -vendor -js/dist/admin/ -composer.lock -.vscode/settings.json -.vscode -.claude -.claudeignore - -# Documentation files -DESIGN_SYSTEM.md -Avocado Design System -FLARUM_2.0_CORE_PATTERNS.md -FLARUM_DIRECTORY_STRUCTURE.md -FLARUM_IMPLEMENTATION_EXAMPLES.md -INDEX.md -QUICK_REFERENCE.md -FLARUM_CSS_FONTS_MANAGEMENT.md -DEAD_CODE_REFACTOR_PLAYBOOK.md -ERROR_HANDLING_AUDIT.md -SECURITY_AUDIT_PLAYBOOK.md +node_modules +vendor +js/dist/admin/ +composer.lock +.phpunit.cache +.vscode/settings.json +.vscode +.claude +.claudeignore + +# Documentation files +DESIGN_SYSTEM.md +Avocado Design System +FLARUM_2.0_CORE_PATTERNS.md +FLARUM_DIRECTORY_STRUCTURE.md +FLARUM_IMPLEMENTATION_EXAMPLES.md +INDEX.md +CLAUDE.md +QUICK_REFERENCE.md +FLARUM_CSS_FONTS_MANAGEMENT.md +DEAD_CODE_REFACTOR_PLAYBOOK.md +ERROR_HANDLING_AUDIT.md +SECURITY_AUDIT_PLAYBOOK.md AGENT.md \ No newline at end of file diff --git a/.gitleaksignore b/.gitleaksignore new file mode 100644 index 0000000..b870ba3 --- /dev/null +++ b/.gitleaksignore @@ -0,0 +1,5 @@ +# Falso-positivo histórico: chave X25519 de EXEMPLO num trecho de +# documentação do README (commit e03a801e, já removida do tree atual). +# Se essa chave algum dia foi usada num servidor real, ela deve ser +# ROTACIONADA — o ignore aqui só silencia o exemplo de docs no histórico. +e03a801e2d29581a5cf8055b4eec8371b1b2caaa:README.md:generic-api-key:80 diff --git a/.semgrepignore b/.semgrepignore new file mode 100644 index 0000000..a998c55 --- /dev/null +++ b/.semgrepignore @@ -0,0 +1,6 @@ +# Mantém o `semgrep scan` local consistente com o security.yml. +# (Semgrep já ignora node_modules/ e vendor/ por padrão.) +js/dist/ +js/node_modules/ +vendor/ +*.min.js diff --git a/README.md b/README.md index 3e10269..7bca83e 100644 --- a/README.md +++ b/README.md @@ -1,94 +1,161 @@ -# 📦 Backup & Migration — Portable Backups for Flarum - -![License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square) [![Latest Stable Version](https://img.shields.io/packagist/v/ramon/backup.svg?style=flat-square)](https://packagist.org/packages/ramon/backup) [![Total Downloads](https://img.shields.io/packagist/dt/ramon/backup.svg?style=flat-square)](https://packagist.org/packages/ramon/backup) [![GitHub Release](https://img.shields.io/github/v/release/ram0ng1/backup?style=flat-square&label=release&color=success)](https://github.com/ram0ng1/backup/releases/latest) [![Donate](https://img.shields.io/badge/donate-stripe-%236772E5?style=flat-square)](https://donate.stripe.com/fZe5o66nebkf39S28a) - -**A complete backup, export and import system for Flarum 2.x** - -### About the Project - -**Backup & Migration** is a full-featured backup and migration extension I've been -building for Flarum, inspired by *All-in-One WP Migration* but written from -scratch with a Flarum-native format. It bundles your forum into a single -portable `.flarum` file — database, uploads, storage, and any installed -extensions (workbench *or* vendor) — and restores it on the same install or a -different one with one click. - -It started from my own need to migrate forums between hosts without the manual -mysqldump-and-zip dance, and grew into a complete suite covering encryption, -cross-server transfer, per-extension picking, and automatic URL rewriting. - -> 🚧 **Active development** — first stable release coming soon! - ---- - -### ✨ Highlights - -- **Single portable `.flarum` file** — custom streaming format (not `.wpress`, - not zip), forward-only so multi-GB backups never need to fit in memory -- **Pick what to bundle** — database, `public/assets`, `storage`, and individual - extensions, with a tag on each row showing whether it lives in `workbench/` - or in `vendor/` (composer-managed) -- **`composer.json` + `composer.lock` travel along** — vendor extensions stay - reproducible on the destination -- **Resumable, chunked progress** on both export and import (~4 MB per HTTP - request), with live progress bars and an upload `%` indicator -- **Optional asymmetric encryption** — libsodium hybrid scheme: sealed-box - wraps a per-archive XChaCha20-Poly1305 stream key. Public key in the database, - private key only in `config.php` -- **Cross-server transfer** — encrypt to a foreign public key, paste the - matching private key at import time -- **Automatic URL rewriting** — the source URL is recorded in the archive - header and rewritten across `settings`, `posts.content` and - `posts.parsed_content` when restoring on a different host -- **Selectable restore** — per-section and per-extension checkboxes populated - from the archive's manifest -- **Foreign-key-safe restore** — disables FK checks per tick so DDL referencing - not-yet-created tables succeeds without ordering dance -- **Smart pruning** while scanning (`node_modules`, `.git`, `.idea`, nested - `vendor/`…) so workbench scans stay seconds-fast -- **Dedicated "you've been logged out" screen** when a DB restore replaces the - admin's session - ---- - -### 🛠️ Technologies - -- **PHP 8.1+** — resumable export / import jobs, libsodium crypto, MySQL dumper -- **TypeScript + Mithril** — admin panel UI -- **LESS** — styling (theme-aware via Flarum's CSS variables) -- **libsodium** — sealed-box + secretstream chunked encryption - ---- - -### Installation - -```sh -composer require ramon/backup -php flarum migrate -php flarum cache:clear -``` - -Then enable **Backup & Migration** under the *Extensions* page in the admin -panel. - ---- - -### Links - -- **GitHub:** [github.com/ram0ng1/backup](https://github.com/ram0ng1/backup) -- **Packagist:** [packagist.org/packages/ramon/backup](https://packagist.org/packages/ramon/backup) -- **Issues:** [github.com/ram0ng1/backup/issues](https://github.com/ram0ng1/backup/issues) -- **Donate:** [Stripe](https://donate.stripe.com/fZe5o66nebkf39S28a) - ---- - -### License - -[MIT](LICENSE) - ---- - -**Built with ❤️ by [Ramon Guilherme](https://ramonguilherme.com.br)** - -*A personal project focused on making it easier to back up, move and restore -Flarum communities — without leaving the admin panel.* +# 📦 Backup & Migration — Portable Backups for Flarum + +![License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square) [![Latest Stable Version](https://img.shields.io/packagist/v/ramon/backup.svg?style=flat-square)](https://packagist.org/packages/ramon/backup) [![Total Downloads](https://img.shields.io/packagist/dt/ramon/backup.svg?style=flat-square)](https://packagist.org/packages/ramon/backup) [![GitHub Release](https://img.shields.io/github/v/release/ram0ng1/backup?style=flat-square&label=release&color=success)](https://github.com/ram0ng1/backup/releases/latest) [![Donate](https://img.shields.io/badge/donate-stripe-%236772E5?style=flat-square)](https://donate.stripe.com/fZe5o66nebkf39S28a) + +**A complete backup, export and import system for Flarum 2.x** + +### About the Project + +**Backup & Migration** is a full-featured backup and migration extension I've been +building for Flarum, inspired by *All-in-One WP Migration* but written from +scratch with a Flarum-native format. It bundles your forum into a single +portable `.flarum` file — database, uploads, storage, and any installed +extensions (workbench *or* vendor) — and restores it on the same install or a +different one with one click. + +It started from my own need to migrate forums between hosts without the manual +mysqldump-and-zip dance, and grew into a complete suite covering encryption, +cross-server transfer, per-extension picking, and automatic URL rewriting. + +--- + +### ✨ Highlights + +- **Single portable `.flarum` file** — custom streaming format (not `.wpress`, + not zip), forward-only so multi-GB backups never need to fit in memory +- **Pick what to bundle** — database, `public/assets`, `storage`, and individual + extensions, with a tag on each row showing whether it lives in `workbench/` + or in `vendor/` (composer-managed) +- **`composer.json` + `composer.lock` travel along** — vendor extensions stay + reproducible on the destination +- **Resumable, chunked progress** on both export and import (~4 MB per HTTP + request), with live progress bars and an upload `%` indicator +- **Command-line export & import** — run a full backup or restore from + `php flarum backup:export` / `backup:import`, with no `max_execution_time` + or `memory_limit` worries and no browser tab to keep open; ideal for large + forums, cron jobs and scripted server-to-server transfer +- **Optional asymmetric encryption** — libsodium hybrid scheme: sealed-box + wraps a per-archive XChaCha20-Poly1305 stream key. Public key in the database, + private key only in `config.php` +- **Cross-server transfer** — encrypt to a foreign public key, paste the + matching private key at import time +- **Automatic URL rewriting** — the source URL is recorded in the archive + header and rewritten across `settings`, `posts.content` and + `posts.parsed_content` when restoring on a different host +- **Selectable restore** — per-section and per-extension checkboxes populated + from the archive's manifest +- **Foreign-key-safe restore** — disables FK checks per tick so DDL referencing + not-yet-created tables succeeds without ordering dance +- **Smart pruning** while scanning (`node_modules`, `.git`, `.idea`, nested + `vendor/`…) so workbench scans stay seconds-fast +- **Dedicated "you've been logged out" screen** when a DB restore replaces the + admin's session + +--- + +### 🛠️ Technologies + +- **PHP 8.1+** — resumable export / import jobs, libsodium crypto, MySQL dumper +- **TypeScript + Mithril** — admin panel UI +- **LESS** — styling (theme-aware via Flarum's CSS variables) +- **libsodium** — sealed-box + secretstream chunked encryption + +--- + +### Installation + +```sh +composer require ramon/backup +php flarum migrate +php flarum cache:clear +``` + +Then enable **Backup & Migration** under the *Extensions* page in the admin +panel. + +--- + +### 🖥️ Command-line interface (CLI) + +Export and import are also available as console commands. A CLI run has no HTTP +request timeout, no `memory_limit` pressure from a web worker, and doesn't +depend on keeping a browser tab open — so the CLI is the most reliable way to +back up or migrate **large** forums, and the natural fit for cron jobs and +scripted server-to-server transfer. Under the hood it drives the exact same +engine as the admin panel, simply looped to completion in a single process. + +#### Export — `backup:export` + +```sh +# Database only, same engine as the source +php flarum backup:export --db + +# Full backup: database + assets + storage + every extension +php flarum backup:export --all + +# Database, retargeted to a different engine (cross-engine migration) +php flarum backup:export --db --target=postgres + +# Pick specific extensions and also copy the finished archive elsewhere +php flarum backup:export --db --extensions=ramon/verified,fof/byobu -o /backups/forum.flarum + +# Encrypt to a public key (e.g. preparing a transfer to another server) +php flarum backup:export --all --encrypt --public-key="BASE64_PUBLIC_KEY" +``` + +Options: `--db/--no-db` (default on), `--assets`, `--storage`, +`--extensions[=LIST]` (omit the value for **all** installed extensions), +`--all`, `--target=mysql|mariadb|postgres|sqlite` (defaults to the source +engine), `--encrypt`, `--public-key=…`, `-o, --output=PATH`. + +#### Import — `backup:import` + +```sh +# Restore everything in an archive (replaces current data) +php flarum backup:import /backups/forum.flarum --yes + +# Restore only the database +php flarum backup:import /backups/forum.flarum --yes --db --no-assets --no-storage + +# Decrypt an encrypted archive with the matching private key +php flarum backup:import /backups/forum.flarum --yes --private-key="BASE64_PRIVATE_KEY" +``` + +> ⚠️ A restore **replaces** the destination database and files, so +> `backup:import` refuses to run without the explicit `--yes` flag. + +Options: `-y, --yes` (**required**), `--private-key=…`, `--db/--no-db`, +`--assets/--no-assets`, `--storage/--no-storage`, `--extensions[=LIST]`. With no +selection flags, the entire archive is restored. + +A typical server-to-server migration: + +```sh +# On the OLD server +php flarum backup:export --all --target=postgres -o /tmp/forum.flarum + +# copy /tmp/forum.flarum to the NEW server, then there: +php flarum backup:import /tmp/forum.flarum --yes +``` + +--- + +### Links + +- **GitHub:** [github.com/ram0ng1/backup](https://github.com/ram0ng1/backup) +- **Packagist:** [packagist.org/packages/ramon/backup](https://packagist.org/packages/ramon/backup) +- **Issues:** [github.com/ram0ng1/backup/issues](https://github.com/ram0ng1/backup/issues) +- **Donate:** [Stripe](https://donate.stripe.com/fZe5o66nebkf39S28a) + +--- + +### License + +[MIT](LICENSE) + +--- + +**Built with ❤️ by [Ramon Guilherme](https://ramonguilherme.com.br)** + +*A personal project focused on making it easier to back up, move and restore +Flarum communities — without leaving the admin panel.* diff --git a/composer.json b/composer.json index 0289dbb..3f9d2bb 100644 --- a/composer.json +++ b/composer.json @@ -21,11 +21,27 @@ "require": { "flarum/core": "^2.0.0" }, + "require-dev": { + "phpunit/phpunit": "^13.2", + "mockery/mockery": "^1.6", + "roave/security-advisories": "dev-latest", + "phpstan/phpstan": "^2.0", + "vimeo/psalm": "^6.0" + }, "autoload": { "psr-4": { "Ramon\\Backup\\": "src/" } }, + "autoload-dev": { + "psr-4": { + "Ramon\\Backup\\Tests\\": "tests/" + } + }, + "scripts": { + "test": "phpunit", + "analyse": "phpstan analyse" + }, "extra": { "flarum-extension": { "title": "Backup & Migration", diff --git a/extend.php b/extend.php index afb770b..c105783 100644 --- a/extend.php +++ b/extend.php @@ -5,10 +5,12 @@ use Flarum\Extend; use Ramon\Backup\Api\Controller\CancelExportController; use Ramon\Backup\Api\Controller\CancelImportController; +use Ramon\Backup\Api\Controller\ChunkImportController; use Ramon\Backup\Api\Controller\DeleteBackupController; use Ramon\Backup\Api\Controller\DownloadBackupController; use Ramon\Backup\Api\Controller\EncryptionStatusController; use Ramon\Backup\Api\Controller\GenerateKeypairController; +use Ramon\Backup\Api\Controller\InspectImportController; use Ramon\Backup\Api\Controller\ListBackupsController; use Ramon\Backup\Api\Controller\ListExtensionsController; use Ramon\Backup\Api\Controller\StartExportController; @@ -16,6 +18,9 @@ use Ramon\Backup\Api\Controller\TickExportController; use Ramon\Backup\Api\Controller\TickImportController; use Ramon\Backup\Api\Controller\UploadImportController; +use Ramon\Backup\Console\ExportCommand; +use Ramon\Backup\Console\ImportCommand; +use Ramon\Backup\Console\PruneStaleJobsCommand; return [ (new Extend\Frontend('admin')) @@ -24,6 +29,18 @@ new Extend\Locales(__DIR__.'/locale'), + (new Extend\Console()) + ->command(ExportCommand::class) + ->command(ImportCommand::class) + ->command(PruneStaleJobsCommand::class) + // Sweep abandoned/errored staging dirs daily so a closed tab or + // a failed job doesn't leave a plaintext dump.sql sitting under + // storage/backup-tmp forever. Requires the operator's cron to run + // `php flarum schedule:run`; the command is also runnable by hand. + ->schedule(PruneStaleJobsCommand::class, function (\Illuminate\Console\Scheduling\Event $event) { + $event->daily(); + }), + (new Extend\Settings()) // Public encryption key (base64). Empty string means encryption is off. // Same trust model as ramon/verified — the matching PRIVATE key is @@ -40,10 +57,14 @@ ->post('/backup/exports/{id:[a-f0-9]+}/tick', 'backup.export.tick', TickExportController::class) ->delete('/backup/exports/{id:[a-f0-9]+}', 'backup.export.cancel', CancelExportController::class) - ->post('/backup/imports', 'backup.import.upload', UploadImportController::class) - ->post('/backup/imports/{id:[a-f0-9]+}/start', 'backup.import.start', StartImportController::class) - ->post('/backup/imports/{id:[a-f0-9]+}/tick', 'backup.import.tick', TickImportController::class) - ->delete('/backup/imports/{id:[a-f0-9]+}', 'backup.import.cancel', CancelImportController::class) + // Chunked upload protocol — see UploadImportController docblock + // for why this replaced the old single multipart POST. + ->post('/backup/imports', 'backup.import.upload', UploadImportController::class) + ->post('/backup/imports/{id:[a-f0-9]+}/chunk', 'backup.import.chunk', ChunkImportController::class) + ->post('/backup/imports/{id:[a-f0-9]+}/inspect', 'backup.import.inspect', InspectImportController::class) + ->post('/backup/imports/{id:[a-f0-9]+}/start', 'backup.import.start', StartImportController::class) + ->post('/backup/imports/{id:[a-f0-9]+}/tick', 'backup.import.tick', TickImportController::class) + ->delete('/backup/imports/{id:[a-f0-9]+}', 'backup.import.cancel', CancelImportController::class) ->get('/backup/encryption/status', 'backup.encryption.status', EncryptionStatusController::class) ->post('/backup/encryption/generate-keypair','backup.encryption.generate', GenerateKeypairController::class) diff --git a/icon.svg b/icon.svg index 4ffe94f..28ac552 100644 --- a/icon.svg +++ b/icon.svg @@ -1,5 +1,20 @@ -

{trans('empty')}

; + return

{trans("empty")}

; } return ( - - - - + + + + @@ -65,7 +65,7 @@ export default class BackupList extends Component { @@ -103,14 +113,14 @@ export default class BackupList extends Component { className="Button Button--icon" href={`${apiUrl()}/backup/backups/${b.id}/download`} target="_blank" - title={String(trans('download_title'))} + title={String(trans("download_title"))} > - @@ -46,7 +56,7 @@ export default class BackupPanel extends Component {
-

{trans('panel.list_title')}

+

{trans("panel.list_title")}

{this.renderList()}
@@ -55,18 +65,22 @@ export default class BackupPanel extends Component { } renderList(): Mithril.Children { - if (this.listState === 'loading') return ; - if (this.listState === 'error') { + if (this.listState === "loading") return ; + if (this.listState === "error") { return (
-

{trans('list.load_failed')}

+

{trans("list.load_failed")}

{this.listError && (

{this.listError}

)} -
); @@ -81,20 +95,20 @@ export default class BackupPanel extends Component { } refresh(): Promise { - this.listState = 'loading'; + this.listState = "loading"; this.listError = null; return apiRequest<{ backups: BackupRow[] }>({ - method: 'GET', + method: "GET", url: `${apiUrl()}/backup/backups`, surface: false, }) .then((res) => { this.backups = res.backups || []; - this.listState = 'ok'; + this.listState = "ok"; }) .catch((e) => { this.backups = []; - this.listState = 'error'; + this.listState = "error"; this.listError = errorDetail(e); }) .then(() => { @@ -116,23 +130,26 @@ export default class BackupPanel extends Component { async delete(id: number) { const ok = await confirmAsync({ - title: trans('list.confirm_delete_title'), - body: trans('list.confirm_delete'), - confirmLabel: trans('list.delete_title'), + title: trans("list.confirm_delete_title"), + body: trans("list.confirm_delete"), + confirmLabel: trans("list.delete_title"), danger: true, }); if (!ok) return; try { await apiRequest({ - method: 'DELETE', + method: "DELETE", url: `${apiUrl()}/backup/backups/${id}`, surface: false, - fallbackMessage: String(trans('list.delete_failed')), + fallbackMessage: String(trans("list.delete_failed")), }); - app.alerts.show({ type: 'success' }, trans('list.deleted')); + app.alerts.show({ type: "success" }, trans("list.deleted")); this.refresh(); } catch (e) { - app.alerts.show({ type: 'error' }, errorDetail(e, String(trans('list.delete_failed')))); + app.alerts.show( + { type: "error" }, + errorDetail(e, String(trans("list.delete_failed"))) + ); } } } diff --git a/js/src/admin/components/ConfirmModal.tsx b/js/src/admin/components/ConfirmModal.tsx index e1561ac..8b17a1f 100644 --- a/js/src/admin/components/ConfirmModal.tsx +++ b/js/src/admin/components/ConfirmModal.tsx @@ -1,7 +1,7 @@ -import app from 'flarum/admin/app'; -import Modal, { IInternalModalAttrs } from 'flarum/common/components/Modal'; -import Button from 'flarum/common/components/Button'; -import type Mithril from 'mithril'; +import app from "flarum/admin/app"; +import Modal, { IInternalModalAttrs } from "flarum/common/components/Modal"; +import Button from "flarum/common/components/Button"; +import type Mithril from "mithril"; export interface ConfirmModalAttrs extends IInternalModalAttrs { title: Mithril.Children; @@ -26,7 +26,7 @@ export default class ConfirmModal extends Modal { protected resolved = false; className() { - return 'BackupConfirmModal Modal--small'; + return "BackupConfirmModal Modal--small"; } title() { @@ -35,16 +35,21 @@ export default class ConfirmModal extends Modal { content() { const confirmLabel = - this.attrs.confirmLabel ?? app.translator.trans('ramon-backup.admin.errors.confirm_default'); + this.attrs.confirmLabel ?? + app.translator.trans("ramon-backup.admin.errors.confirm_default"); const cancelLabel = - this.attrs.cancelLabel ?? app.translator.trans('ramon-backup.admin.errors.cancel_default'); + this.attrs.cancelLabel ?? + app.translator.trans("ramon-backup.admin.errors.cancel_default"); return (
{this.attrs.body}
-
@@ -70,7 +81,7 @@ class KeypairRevealModal extends Modal { copy(snippet: string) { if (!navigator.clipboard) { - app.alerts.show({ type: 'error' }, trans('clipboard_unavailable')); + app.alerts.show({ type: "error" }, trans("clipboard_unavailable")); return; } navigator.clipboard @@ -84,8 +95,8 @@ class KeypairRevealModal extends Modal { }, 2000); }) .catch((err) => { - console.error('[backup] clipboard writeText failed', err); - app.alerts.show({ type: 'error' }, trans('clipboard_failed')); + console.error("[backup] clipboard writeText failed", err); + app.alerts.show({ type: "error" }, trans("clipboard_failed")); }); } } @@ -99,18 +110,18 @@ class RegenerateConfirmModal extends Modal { protected submitting = false; className() { - return 'BackupRegenerateModal Modal--medium'; + return "BackupRegenerateModal Modal--medium"; } title() { - return trans('regenerate_modal.title'); + return trans("regenerate_modal.title"); } content() { return (
-

{trans('regenerate_modal.warning')}

+

{trans("regenerate_modal.warning")}

@@ -131,7 +142,7 @@ class RegenerateConfirmModal extends Modal { disabled={!this.acknowledged || this.submitting} onclick={() => this.submit()} > - {trans('regenerate_modal.submit')} + {trans("regenerate_modal.submit")}
@@ -155,7 +166,7 @@ class RegenerateConfirmModal extends Modal { export default class EncryptionCard extends Component { protected status: EncryptionStatus | null = null; - protected loadState: 'loading' | 'ok' | 'error' = 'loading'; + protected loadState: "loading" | "ok" | "error" = "loading"; protected loadError: string | null = null; protected publicCopied = false; @@ -168,76 +179,93 @@ export default class EncryptionCard extends Component { return (
-

{trans('section_title')}

-

{trans('section_help')}

+

{trans("section_title")}

+

{trans("section_help")}

- {this.loadState === 'loading' && } - {this.loadState === 'error' && ( + {this.loadState === "loading" && } + {this.loadState === "error" && (
-

{trans('status.load_failed')}

+

{trans("status.load_failed")}

{this.loadError && (

{this.loadError}

)} -
)} - {this.loadState === 'ok' && this.body()} + {this.loadState === "ok" && this.body()}
); } body() { - if (!this.status) return

{trans('status.unknown')}

; + if (!this.status) + return

{trans("status.unknown")}

; const s = this.status; if (!s.available) { - return
{trans('status.libsodium_missing')}
; + return ( +
+ {trans("status.libsodium_missing")} +
+ ); } return ( <>
- {this.statusBadge('public', s.has_public_key)} - {this.statusBadge('private', s.private_key_present)} + {this.statusBadge("public", s.has_public_key)} + {this.statusBadge("private", s.private_key_present)}
- {s.healthy &&
{trans('status.healthy')}
} + {s.healthy && ( +
{trans("status.healthy")}
+ )} {!s.has_public_key && !s.private_key_present && (
-

{trans('status.not_setup')}

-
)} - {s.has_public_key && s.private_key_present && s.keys_match === false && ( -
- {trans('status.mismatch_title')} -

{trans('status.mismatch_body')}

-

- '{s.config_key}' -

-
- )} + {s.has_public_key && + s.private_key_present && + s.keys_match === false && ( +
+ {trans("status.mismatch_title")} +

{trans("status.mismatch_body")}

+

+ '{s.config_key}' +

+
+ )} {s.has_public_key && !s.private_key_present && (
- {trans('status.private_missing_title')} -

{trans('status.private_missing_body')}

+ {trans("status.private_missing_title")} +

{trans("status.private_missing_body")}

'{s.config_key}'

)} - {s.has_public_key && this.publicKeyPanel(s.public_key || '', s.healthy)} + {s.has_public_key && this.publicKeyPanel(s.public_key || "", s.healthy)} ); } @@ -245,7 +273,7 @@ export default class EncryptionCard extends Component { publicKeyPanel(publicKey: string, healthy: boolean) { return (
- +
             {publicKey}
@@ -253,26 +281,40 @@ export default class EncryptionCard extends Component {
           
         
-

{healthy ? trans('public_key.help_healthy') : trans('public_key.help_broken')}

-
); } - statusBadge(kind: 'public' | 'private', present: boolean) { + statusBadge(kind: "public" | "private", present: boolean) { return ( -
- +
+ {trans(`status.${kind}_key_label`)} - {trans(`status.${present ? 'present' : 'absent'}`)} + + {trans(`status.${present ? "present" : "absent"}`)} +
); } @@ -280,7 +322,7 @@ export default class EncryptionCard extends Component { copyPublic(publicKey: string) { if (!publicKey) return; if (!navigator.clipboard) { - app.alerts.show({ type: 'error' }, trans('clipboard_unavailable')); + app.alerts.show({ type: "error" }, trans("clipboard_unavailable")); return; } navigator.clipboard @@ -294,26 +336,26 @@ export default class EncryptionCard extends Component { }, 2000); }) .catch((err) => { - console.error('[backup] clipboard writeText failed', err); - app.alerts.show({ type: 'error' }, trans('clipboard_failed')); + console.error("[backup] clipboard writeText failed", err); + app.alerts.show({ type: "error" }, trans("clipboard_failed")); }); } refresh(): Promise { - this.loadState = 'loading'; + this.loadState = "loading"; this.loadError = null; return apiRequest({ - method: 'GET', + method: "GET", url: `${apiUrl()}/backup/encryption/status`, surface: false, }) .then((res) => { this.status = res; - this.loadState = 'ok'; + this.loadState = "ok"; }) .catch((e) => { this.status = null; - this.loadState = 'error'; + this.loadState = "error"; this.loadError = errorDetail(e); }) .then(() => { @@ -323,8 +365,12 @@ export default class EncryptionCard extends Component { async generate(acknowledgeLoss: boolean) { try { - const res = await apiRequest<{ public_key: string; private_key: string; config_key: string }>({ - method: 'POST', + const res = await apiRequest<{ + public_key: string; + private_key: string; + config_key: string; + }>({ + method: "POST", url: `${apiUrl()}/backup/encryption/generate-keypair`, body: { acknowledge_loss: acknowledgeLoss }, surface: false, @@ -335,7 +381,10 @@ export default class EncryptionCard extends Component { configKey: res.config_key, }); } catch (e) { - app.alerts.show({ type: 'error' }, errorDetail(e, String(trans('actions.generate_failed')))); + app.alerts.show( + { type: "error" }, + errorDetail(e, String(trans("actions.generate_failed"))) + ); throw e; } } diff --git a/js/src/admin/components/ExportModal.tsx b/js/src/admin/components/ExportModal.tsx index 89704ee..a29d7ea 100644 --- a/js/src/admin/components/ExportModal.tsx +++ b/js/src/admin/components/ExportModal.tsx @@ -1,10 +1,10 @@ -import app from 'flarum/admin/app'; -import Modal, { IInternalModalAttrs } from 'flarum/common/components/Modal'; -import Button from 'flarum/common/components/Button'; -import LoadingIndicator from 'flarum/common/components/LoadingIndicator'; -import type Mithril from 'mithril'; +import app from "flarum/admin/app"; +import Modal, { IInternalModalAttrs } from "flarum/common/components/Modal"; +import Button from "flarum/common/components/Button"; +import LoadingIndicator from "flarum/common/components/LoadingIndicator"; +import type Mithril from "mithril"; -import { apiRequest, apiUrl, errorDetail, fmtBytes } from '../utils/api'; +import { apiRequest, apiUrl, errorDetail, fmtBytes } from "../utils/api"; export interface ExportModalAttrs extends IInternalModalAttrs { onComplete: () => void; @@ -15,14 +15,14 @@ interface ExtensionEntry { name: string; title: string; version: string; - location: 'workbench' | 'vendor' | 'unknown'; + location: "workbench" | "vendor" | "unknown"; path: string; relative: string; enabled: boolean; } interface ExportProgress { - phase: 'scan' | 'db_dump' | 'bundle' | 'finalize' | 'done' | 'error'; + phase: "scan" | "db_dump" | "bundle" | "finalize" | "done" | "error"; message: string; progress: { total_bytes: number; @@ -62,7 +62,7 @@ const trans = (key: string, params?: Record) => * one in *this* config.php. */ export default class ExportModal extends Modal { - protected stage: 'form' | 'progress' = 'form'; + protected stage: "form" | "progress" = "form"; protected includeDb = true; protected includeAssets = true; @@ -79,14 +79,15 @@ export default class ExportModal extends Modal { protected encryptionEnabled = false; protected encryptionUseExternal = false; - protected externalPublicKey = ''; + protected externalPublicKey = ""; // Target engine the dump should be generated for. Empty string = // "same as source" (the most common case — backing up to restore // onto the same install / a clone of it). The non-empty values // make this a cross-engine migration: e.g. dump from MySQL, // restore onto Postgres. - protected targetDialect: '' | 'mysql' | 'mariadb' | 'postgres' | 'sqlite' = ''; + protected targetDialect: "" | "mysql" | "mariadb" | "postgres" | "sqlite" = + ""; protected starting = false; protected jobId: string | null = null; @@ -94,15 +95,15 @@ export default class ExportModal extends Modal { protected polling = false; className() { - return 'BackupExportModal Modal--medium'; + return "BackupExportModal Modal--medium"; } title() { - return trans('title'); + return trans("title"); } content() { - if (this.stage === 'form') return this.formContent(); + if (this.stage === "form") return this.formContent(); return this.progressContent(); } @@ -111,47 +112,64 @@ export default class ExportModal extends Modal { formContent() { return (
-

{trans('intro')}

+

{trans("intro")}

- {trans('contents_title')} - - {this.checkbox('db', () => this.includeDb, (v) => (this.includeDb = v))} - {this.checkbox('assets', () => this.includeAssets, (v) => (this.includeAssets = v))} - {this.checkbox('storage', () => this.includeStorage, (v) => (this.includeStorage = v))} - {this.checkbox('extensions', () => this.includeExtensions, (v) => { - this.includeExtensions = v; - // Lazy-load the extension inventory the first time - // someone ticks the box. The list comes back fast (no - // disk walking — just metadata from the ExtensionManager). - if (v && !this.extensionsLoaded) this.loadExtensions(); - })} + {trans("contents_title")} + + {this.checkbox( + "db", + () => this.includeDb, + (v) => (this.includeDb = v) + )} + {this.checkbox( + "assets", + () => this.includeAssets, + (v) => (this.includeAssets = v) + )} + {this.checkbox( + "storage", + () => this.includeStorage, + (v) => (this.includeStorage = v) + )} + {this.checkbox( + "extensions", + () => this.includeExtensions, + (v) => { + this.includeExtensions = v; + // Lazy-load the extension inventory the first time + // someone ticks the box. The list comes back fast (no + // disk walking — just metadata from the ExtensionManager). + if (v && !this.extensionsLoaded) this.loadExtensions(); + } + )} {this.includeExtensions && this.extensionList()}
{this.includeDb && (
- {trans('target_title')} -

{trans('target_help')}

+ {trans("target_title")} +

{trans("target_help")}

)}
- {trans('encryption_title')} + {trans("encryption_title")} -

{trans('encryption_help')}

+

{trans("encryption_help")}

{this.encryptionEnabled && ( <> @@ -172,21 +190,27 @@ export default class ExportModal extends Modal { type="checkbox" checked={this.encryptionUseExternal} onchange={(e: Event) => { - this.encryptionUseExternal = (e.target as HTMLInputElement).checked; + this.encryptionUseExternal = ( + e.target as HTMLInputElement + ).checked; }} - />{' '} - {trans('encryption_external')} + />{" "} + {trans("encryption_external")} {this.encryptionUseExternal && ( <> -

{trans('encryption_external_help')}

+

+ {trans("encryption_external_help")} +

{trans('col_when')}{trans('col_size')}{trans('col_contents')}{trans('col_status')}{trans("col_when")}{trans("col_size")}{trans("col_contents")}{trans("col_status")}
- {b.created_at ? humanTime(b.created_at) : '—'} + {b.created_at ? humanTime(new Date(b.created_at)) : "—"}
{b.filename}
{b.target_dialect && ( @@ -74,27 +74,37 @@ export default class BackupList extends Component { // target_dialect and don't need the visual noise.
- {' '} - {trans('target_for', { engine: DIALECT_LABEL[b.target_dialect] || b.target_dialect })} + {" "} + {trans("target_for", { + engine: + DIALECT_LABEL[b.target_dialect] || b.target_dialect, + })}
)}
{fmtBytes(b.size_bytes)} {b.contents.map((c) => ( - {trans('content_' + c)} + + {trans("content_" + c)} + ))} {b.encrypted ? ( - {trans('encrypted')} + {trans("encrypted")} ) : ( - {trans('plain')} + {trans("plain")} )}