diff --git a/ansible/inventory/group_vars/all.yaml b/ansible/inventory/group_vars/all.yaml index bef81ac..76d7483 100644 --- a/ansible/inventory/group_vars/all.yaml +++ b/ansible/inventory/group_vars/all.yaml @@ -341,19 +341,19 @@ version: authelia: "4.39.20" # App vaultwarden: "1.37.1" - gitea: "1.27.1" + gitea: "1.27.2" redis: "8.10.0" immich: "v3.1.0" - nextcloud: "34.0.2" - collabora: "26.04.2.4.1" - sure: "0.7.2" + nextcloud: "34.0.3" + collabora: "26.04.3.1.1" + sure: "0.7.3" outline: "1.9.2" memos: "0.30.0" mealie: "v3.22.0" homebox: "0.26.2" - homepage: "v1.13.2" + homepage: "v2.0.0" snappymail: "v2.38.2" - mastodon: "v4.6.4" + mastodon: "v4.6.6" booklogr: "v1.11.1" papra: "26.6.1-rootless" romm: "5.1.0" diff --git a/ansible/roles/app/tasks/services/set_papra.yaml b/ansible/roles/app/tasks/services/set_papra.yaml index 777cef8..ab2781a 100644 --- a/ansible/roles/app/tasks/services/set_papra.yaml +++ b/ansible/roles/app/tasks/services/set_papra.yaml @@ -10,8 +10,21 @@ - "data/containers/papra" - "data/containers/papra/data" - "data/containers/papra/backup" + - "containers/papra" + - "containers/papra/script" + - "containers/papra/db" become: true +- name: Check backup file + ansible.builtin.stat: + path: "{{ node['home_path'] }}/data/containers/papra/backup/db.sqlite" + register: "is_db_backup" + +- name: Check database file + ansible.builtin.stat: + path: "{{ node['home_path'] }}/containers/papra/db/db.sqlite" + register: "is_db" + - name: Pull image containers.podman.podman_image: name: "ghcr.io/papra-hq/papra" @@ -45,6 +58,19 @@ notify: "notification_restart_papra" no_log: true +- name: Restore db file + ansible.builtin.copy: + src: "{{ node['home_path'] }}/data/containers/papra/backup/db.sqlite" + remote_src: true + dest: "{{ node['home_path'] }}/containers/papra/db/db.sqlite" + owner: "{{ services['papra']['subuid'] }}" + group: "{{ node['group'] }}" + mode: "0660" + become: true + when: + - is_db_backup.stat.exists + - not is_db.stat.exists + - name: Deploy papra.container file ansible.builtin.template: src: "{{ hostvars['console']['node']['config_path'] }}/services/containers/app/papra/papra.container.j2" @@ -66,10 +92,10 @@ - "papra-backup.service" - "papra-backup.timer" -- name: Deploy papra exporter +- name: Deploy papra script ansible.builtin.copy: - src: "{{ hostvars['console']['node']['config_path'] }}/services/containers/app/papra/script/papra-exporter.sh" - dest: "{{ node['home_path'] }}/data/containers/papra/" + src: "{{ hostvars['console']['node']['config_path'] }}/services/containers/app/papra/script/" + dest: "{{ node['home_path'] }}/containers/papra/script/" owner: "{{ ansible_user }}" group: "{{ node['group'] }}" mode: "0755" diff --git a/config/services/containers/app/papra/papra.container.j2 b/config/services/containers/app/papra/papra.container.j2 index 171d719..cf62dba 100644 --- a/config/services/containers/app/papra/papra.container.j2 +++ b/config/services/containers/app/papra/papra.container.j2 @@ -14,6 +14,7 @@ PublishPort={{ services['papra']['ports']['http'] }}:1221/tcp # Volumes Volume=%h/data/containers/papra/data:/app/app-data:rw +Volume=%h/containers/papra/db:/app/app-data/db:rw Volume=%h/data/containers/papra/backup:/app/backup:rw # General diff --git a/config/services/containers/app/papra/script/papra-date-change.sh b/config/services/containers/app/papra/script/papra-date-change.sh new file mode 100644 index 0000000..202bd26 --- /dev/null +++ b/config/services/containers/app/papra/script/papra-date-change.sh @@ -0,0 +1,210 @@ +#!/bin/bash + +set -Eeuo pipefail + +URL="" +ORG_ID="" +TOKEN="" +apply=false + +page_size=100 +page_index=0 +documents_read=0 +matched=0 +unmatched=0 +updated=0 +failed=0 + +# usage() function +usage() { + echo "Usage: $0 -u \"URL\" -o \"ORG_ID\" -t \"TOKEN\" [-a]" + echo "-u : Specify the URL of Papra, including https://" + echo "-o : Specify the organization ID of Papra" + echo "-t : Specify the token to access the Papra API" + echo "-a: Apply changes. Without this option, only perform a dry run" + echo "Token requires documents:read and documents:update permissions" + exit 1 +} + +# Log function +log() { + local timestamp + local level="$1" + local msg="$2" + + timestamp="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" + echo "time=\"$timestamp\" level=\"$level\" msg=\"$msg\" source=\"papra-date-change.sh\"" >&2 +} + +# getopts to get arguments +while getopts ":u:o:t:ah" opt; do + case "$opt" in + u) + URL="$OPTARG" + ;; + o) + ORG_ID="$OPTARG" + ;; + t) + TOKEN="$OPTARG" + ;; + a) + apply=true + ;; + h) + usage + ;; + \?) # unknown option + log "error" "Invalid option: -$OPTARG" + usage + ;; + :) # parameter required + log "error" "Option -$OPTARG requires an argument" + usage + ;; + esac +done + +# Validate required arguments +if [[ -z "$URL" || -z "$ORG_ID" || -z "$TOKEN" ]]; then + log "error" "URL, ORG_ID and TOKEN are required" + usage +fi + +# Require an explicit HTTPS URL +if [[ "$URL" != https://* ]]; then + log "error" "URL must start with https://" + exit 1 +fi + +# Remove trailing slash +URL="${URL%/}" + +# Collect all documents before modifying them. +# This prevents pagination results from changing during updates. +documents_file="$(mktemp)" +trap 'rm -f -- "$documents_file"' EXIT + +while true; do + response="$( + curl \ + --fail \ + --silent \ + --show-error \ + --get \ + --header "Authorization: Bearer $TOKEN" \ + --data-urlencode "pageIndex=$page_index" \ + --data-urlencode "pageSize=$page_size" \ + --data-urlencode "sortField=createdAt" \ + --data-urlencode "sortOrder=asc" \ + "$URL/api/organizations/$ORG_ID/documents" + )" + + count="$(jq -r '.documents | length' <<<"$response")" + total="$(jq -r '.documentsCount' <<<"$response")" + + jq -c '.documents[]' <<<"$response" >>"$documents_file" + + documents_read=$((documents_read + count)) + + if (( count == 0 || documents_read >= total )); then + break + fi + + page_index=$((page_index + 1)) +done + +log "info" "Documents read: $documents_read" + +while IFS= read -r document; do + id="$(jq -r '.id' <<<"$document")" + name="$(jq -r '.name // empty' <<<"$document")" + + if [[ -z "$id" || -z "$name" ]]; then + unmatched=$((unmatched + 1)) + log "warning" "Skipped document with missing ID or name" + continue + fi + + # Extract the first _YYYY.MM.DD_ pattern. + if [[ "$name" =~ (^|_)([0-9]{4})\.([0-9]{2})\.([0-9]{2})(_|$) ]]; then + year="${BASH_REMATCH[2]}" + month="${BASH_REMATCH[3]}" + day="${BASH_REMATCH[4]}" + document_date="$year-$month-$day" + else + unmatched=$((unmatched + 1)) + printf '[SKIP] %s: date pattern not found\n' "$name" + continue + fi + + # Reject impossible dates such as 2026.02.30. + parsed_date="$( + date \ + --utc \ + --date="$document_date" \ + +"%Y-%m-%d" \ + 2>/dev/null || + true + )" + + if [[ "$parsed_date" != "$document_date" ]]; then + unmatched=$((unmatched + 1)) + printf '[SKIP] %s: invalid date %s\n' \ + "$name" \ + "$document_date" + continue + fi + + matched=$((matched + 1)) + + if [[ "$apply" != true ]]; then + printf '[DRY-RUN] %s -> %s\n' \ + "$name" \ + "$document_date" + continue + fi + + payload="$( + jq \ + --null-input \ + --compact-output \ + --arg documentDate "$document_date" \ + '{documentDate: $documentDate}' + )" + + if ! curl \ + --fail \ + --silent \ + --show-error \ + --request PATCH \ + --header "Authorization: Bearer $TOKEN" \ + --header "Content-Type: application/json" \ + --data "$payload" \ + --output /dev/null \ + "$URL/api/organizations/$ORG_ID/documents/$id" + then + failed=$((failed + 1)) + log "error" "Failed to update document: $id ($name)" + continue + fi + + updated=$((updated + 1)) + + printf '[%d/%d] %s -> %s\n' \ + "$updated" \ + "$matched" \ + "$name" \ + "$document_date" +done <"$documents_file" + +if [[ "$apply" == true ]]; then + log "info" "Update completed: read=$documents_read matched=$matched unmatched=$unmatched updated=$updated failed=$failed" +else + log "info" "Dry run completed: read=$documents_read matched=$matched unmatched=$unmatched" + log "info" "No documents were modified. Run again with -a to apply" +fi + +if (( failed > 0 )); then + exit 1 +fi diff --git a/config/services/containers/app/papra/services/papra-backup.service b/config/services/containers/app/papra/services/papra-backup.service index 5ffe7eb..de8ed93 100644 --- a/config/services/containers/app/papra/services/papra-backup.service +++ b/config/services/containers/app/papra/services/papra-backup.service @@ -8,7 +8,7 @@ Type=oneshot # Create a consistent SQLite snapshot and atomically publish it. ExecStart=/usr/bin/podman exec -u nonroot papra node --input-type=module -e '\ import { createClient } from "@libsql/client"; \ - import { rename, rm } from "node:fs/promises"; \ + import { rename, copyFile, rm } from "node:fs/promises"; \ \ /* Paths */ \ const databaseUrl = "file:/app/app-data/db/db.sqlite"; \ @@ -22,7 +22,8 @@ ExecStart=/usr/bin/podman exec -u nonroot papra node --input-type=module -e '\ String(now.getDate()).padStart(2, "0"), \ ].join("-"); \ \ - const dst = backupDir + "/db_" + date + ".sqlite"; \ + const dst = backupDir + "/db.sqlite"; \ + const dated_dst = backupDir + "/db_" + date + ".sqlite"; \ const tmp = dst + ".tmp"; \ \ /* Never expose a partially-created backup as a completed one. */ \ @@ -43,6 +44,7 @@ ExecStart=/usr/bin/podman exec -u nonroot papra node --input-type=module -e '\ \ /* Publish only after VACUUM INTO has completed successfully. */ \ await rename(tmp, dst); \ + await copyFile(dst, dated_dst); \ completed = true; \ } finally { \ if (!completed) { \ diff --git a/config/services/systemd/common/crowdsec/bouncers/whitelists.yaml.j2 b/config/services/systemd/common/crowdsec/bouncers/whitelists.yaml.j2 index 4b82000..be46452 100644 --- a/config/services/systemd/common/crowdsec/bouncers/whitelists.yaml.j2 +++ b/config/services/systemd/common/crowdsec/bouncers/whitelists.yaml.j2 @@ -18,7 +18,8 @@ whitelist: - "evt.Meta.target_fqdn == '{{ services['nextcloud']['domain']['public'] }}.{{ domain['public'] }}' && evt.Meta.http_status == '404' && evt.Meta.http_verb == 'GET' && evt.Meta.http_path startsWith '/index.php/core/preview?'" # nextcloud chunks.mjs request false positive - "evt.Meta.target_fqdn == '{{ services['nextcloud']['domain']['public'] }}.{{ domain['public'] }}' && evt.Meta.http_status in ['200', '304'] && evt.Meta.http_verb == 'GET' && evt.Meta.http_path contains 'chunk.mjs'" + # nextcloud app notification false positive (http-proving) + - "evt.Meta.target_fqdn == '{{ services['nextcloud']['domain']['public'] }}.{{ domain['public'] }}' && evt.Meta.http_status == '404' && evt.Meta.http_verb == 'GET' && evt.Meta.http_path startsWith '/ocs/v2.php/apps/notifications/api/v2/notifications/'" # outline POST 401 errors false positive - "evt.Meta.target_fqdn == '{{ services['outline']['domain']['public'] }}.{{ domain['public'] }}' && evt.Meta.http_status == '401' && evt.Meta.http_verb == 'POST' && evt.Meta.http_path startsWith '/api/'" - {% endif %}