feat(papra, romm): add feature
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user