feat(papra, romm): add feature

This commit is contained in:
il
2026-07-09 12:22:54 +09:00
parent e0df8cfba5
commit 3a38271969
38 changed files with 1372 additions and 111 deletions
@@ -0,0 +1,43 @@
[Quadlet]
DefaultDependencies=false
[Unit]
Description=Papra
[Container]
Image=ghcr.io/papra-hq/papra:{{ version['containers']['papra'] }}
ContainerName=papra
HostName=papra
PublishPort={{ services['papra']['ports']['http'] }}:1221/tcp
# Volumes
Volume=%h/data/containers/papra/data:/app/app-data:rw
Volume=%h/data/containers/papra/backup:/app/backup:rw
# General
Environment="TZ={{ timezone }}"
Environment="APP_BASE_URL=https://{{ services['papra']['domain']['public'] }}.{{ domain['public'] }}"
# Authentication
Environment="AUTH_PROVIDERS_EMAIL_IS_ENABLED=false"
Environment="AUTH_IS_PASSWORD_RESET_ENABLED=false"
Environment="AUTH_SHOW_LEGAL_LINKS=false"
Secret=PAPRA_AUTH_SECRET,type=env,target=AUTH_SECRET
# OIDC
Secret=PAPRA_AUTH_PROVIDERS_CUSTOMS,type=env,target=AUTH_PROVIDERS_CUSTOMS
# Documents
Environment="DOCUMENTS_CONTENT_EXTRACTION_ENABLED=true"
Environment="DOCUMENTS_OCR_LANGUAGES=eng,kor"
Environment="DOCUMENT_STORAGE_MAX_UPLOAD_SIZE=0"
[Service]
Restart=always
RestartSec=10s
TimeoutStopSec=120
[Install]
WantedBy=default.target
@@ -0,0 +1,151 @@
#!/bin/bash
set -Eeuo pipefail
URL=""
ORG_ID=""
TOKEN=""
output_dir="$PWD/papra-export"
page_size=100
page_index=0
downloaded=0
# usage() function
usage() {
echo "Usage: $0 -u \"URL\" -o \"ORG_ID\" -t \"TOKEN\""
echo "-u <URL>: Specify the URL of Papra, including https://"
echo "-o <ORG_ID>: Specify the organization ID of Papra"
echo "-t <TOKEN>: Specify the token to access the Papra API"
echo "Token requires READ 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-exporter.sh\"" >&2
}
# getopts to get arguments
while getopts ":u:o:t:" opt; do
case "$opt" in
u)
URL="$OPTARG"
;;
o)
ORG_ID="$OPTARG"
;;
t)
TOKEN="$OPTARG"
;;
\?) # 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%/}"
# Always create a fresh export
rm -rf -- "$output_dir"
mkdir -p -- "$output_dir"
while true; do
response="$(
curl \
--fail \
--silent \
--show-error \
--get \
--header "Authorization: Bearer $TOKEN" \
--data-urlencode "pageIndex=$page_index" \
--data-urlencode "pageSize=$page_size" \
"$URL/api/organizations/$ORG_ID/documents"
)"
count="$(jq -r '.documents | length' <<<"$response")"
total="$(jq -r '.documentsCount' <<<"$response")"
while IFS= read -r document; do
id="$(jq -r '.id' <<<"$document")"
name="$(jq -r '.name // empty' <<<"$document")"
# Prevent document names from creating subdirectories.
name="${name//\//_}"
if [[ -z "$name" || "$name" == "." || "$name" == ".." ]]; then
name="$id"
fi
destination="$output_dir/$name"
# Preserve both files when names collide.
if [[ -e "$destination" ]]; then
if [[ "$name" == *.* && "$name" != .* ]]; then
stem="${name%.*}"
extension=".${name##*.}"
destination="$output_dir/${stem}__${id}${extension}"
else
destination="$output_dir/${name}__${id}"
fi
fi
temporary="${destination}.part"
rm -f -- "$temporary"
if ! curl \
--fail \
--silent \
--show-error \
--location \
--header "Authorization: Bearer $TOKEN" \
--output "$temporary" \
"$URL/api/organizations/$ORG_ID/documents/$id/file"
then
rm -f -- "$temporary"
log "error" "Failed to download document: $id"
exit 1
fi
mv -f -- "$temporary" "$destination"
downloaded=$((downloaded + 1))
printf '[%d/%d] %s\n' \
"$downloaded" \
"$total" \
"${destination#"$output_dir"/}"
done < <(jq -c '.documents[]' <<<"$response")
if (( count == 0 || downloaded >= total )); then
break
fi
page_index=$((page_index + 1))
done
log "info" "Export completed: $downloaded document(s)"
@@ -0,0 +1,68 @@
[Unit]
Description=Papra sqlite backup
Requires=papra.service
After=papra.service
[Service]
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"; \
\
/* Paths */ \
const databaseUrl = "file:/app/app-data/db/db.sqlite"; \
const backupDir = "/app/backup"; \
\
/* Build the backup name from the container-local date. */ \
const now = new Date(); \
const date = [ \
now.getFullYear(), \
String(now.getMonth() + 1).padStart(2, "0"), \
String(now.getDate()).padStart(2, "0"), \
].join("-"); \
\
const dst = backupDir + "/db_" + date + ".sqlite"; \
const tmp = dst + ".tmp"; \
\
/* Never expose a partially-created backup as a completed one. */ \
let completed = false; \
try { \
await rm(tmp, { force: true }); \
\
/* Create a transactionally consistent SQLite snapshot. */ \
const db = createClient({ url: databaseUrl }); \
try { \
await db.execute({ \
sql: "VACUUM INTO ?", \
args: [tmp], \
}); \
} finally { \
db.close(); \
} \
\
/* Publish only after VACUUM INTO has completed successfully. */ \
await rename(tmp, dst); \
completed = true; \
} finally { \
if (!completed) { \
await rm(tmp, { force: true }); \
} \
}'
ExecStart=/usr/bin/podman exec -u nonroot papra sh -c "find /app/backup/ -maxdepth 1 -type f -name 'db_*.sqlite' -mtime +7 -delete"
# Verify
#podman exec -u nonroot -w /app papra \
# node --input-type=module -e '
# const m=await import(process.argv[1]);
# const db=m.createClient({url:process.argv[2]});
# try {
# const r=await db.execute("PRAGMA quick_check");
# console.log(r.rows);
# } finally {
# db.close();
# }
# ' \
# "@libsql/client" \
# "file:/app/backup/test-backup.sqlite"
# > Result: [ { quick_check: 'ok' } ]
@@ -0,0 +1,14 @@
[Unit]
Description=Run Papra Data Backup service every day
[Timer]
# Execute service after 1 min on booting
OnBootSec=1min
OnCalendar=*-*-* 23:50:00
# When timer is activated, Service also starts.
Persistent=true
[Install]
WantedBy=timers.target