#!/usr/bin/env bash # Attach a source document onto a Dolibarr invoice in the SANDBOX (GED upload) # — IDEMPOTENT by (object, filename, sha256): re-attaching the same file is a # no-op, and the same filename with DIFFERENT content ABORTS (a conflict for a # human, never an overwrite). erp#43. # # Input: a JSON object on stdin (or a file path / inline JSON in $1): # modulepart (required) "facture"|"invoice" → customer invoice GED # "facture_fournisseur"|"invoice_supplier"| # "supplier_invoice" → supplier invoice GED # object_id invoice id on the target (one of object_id / ref # ref Dolibarr's own ref, e.g. FAF2026013 required; both given = # cross-checked) # file (required) path to the file to attach. Resolved against the CWD; # promote-apply resolves manifest-relative paths BEFORE invoking. # filename stored name in the GED (default: basename of file) # # Idempotency (erp#43, the erp#44 pattern): BEFORE any POST the object's GED # directory is listed (GET /documents) and a same-named entry — if any — is # DOWNLOADED BACK and sha256-compared against the local file: # - same sha256 → {"deduped": true}, no upload (re-attach = no-op); # - different sha → ABORT. The idempotency check is OUR OWN sha256 compare, # never Dolibarr's overwriteifexists flag (whose semantics vary by version): # silently replacing a stored piece would rewrite evidence — refuse, never # repair. overwriteifexists is always sent as 0. # - a listing/download failure other than "no documents yet" (HTTP 404 on an # object we just proved exists) ABORTS — uploading blind could duplicate. # After an upload the GED is re-listed and the file downloaded back and # sha256-verified: read-back proof the GED holds exactly the bytes we sent. # # Path gotcha: supplier-invoice documents live under an id-derived get_exdir # prefix (e.g. fournisseur/facture/0/3/FAF2026014/…), so /documents/download # needs "0/3/REF/file.pdf", not "REF/file.pdf". The module-relative path is # derived from the listing's fullname (substring after the last "/facture/"). # # Emits {"object_id", "ref", "modulepart", "filename", "sha256", "size", # "deduped"} on stdout. All requests go through dol-write.sh (or $DOL_WRITE), # which refuses any host that is not the sandbox. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" W="${DOL_WRITE:-${SCRIPT_DIR}/dol-write.sh}" SRC="${1:-}" if [[ -z "${SRC}" || "${SRC}" == "-" ]]; then INPUT="$(cat)" elif [[ -f "${SRC}" ]]; then INPUT="$(cat "${SRC}")" else INPUT="${SRC}"; fi # inline JSON TMPD="$(mktemp -d -t docatt.XXXXXX)"; trap 'rm -rf "${TMPD}"' EXIT # --- 1. Validate + normalize the input (before ANY request) -------------------- cat > "${TMPD}/validate.py" <<'PY' import json, os, sys ALLOWED = ["modulepart", "object_id", "ref", "file", "filename"] MP = {"facture": "facture", "invoice": "facture", "facture_fournisseur": "facture_fournisseur", "invoice_supplier": "facture_fournisseur", "supplier_invoice": "facture_fournisseur"} try: d = json.loads(sys.stdin.read() or "null") except Exception as e: sys.exit("document-attach.sh: input is not valid JSON: %s" % e) if not isinstance(d, dict): sys.exit("document-attach.sh: input must be a JSON object") bad = sorted(k for k in d if k not in ALLOWED) if bad: sys.exit("document-attach.sh: REFUSED — unknown field(s): %s\n allowed: %s" % (", ".join(bad), ", ".join(ALLOWED))) mp_raw = str(d.get("modulepart", "")).strip().lower() if mp_raw not in MP: sys.exit("document-attach.sh: 'modulepart' must be one of %s (got %r) — this " "op attaches onto customer or supplier invoices only" % ("/".join(sorted(set(MP))), mp_raw)) mp = MP[mp_raw] oid = d.get("object_id") ref = str(d.get("ref") or "").strip() if oid in (None, "") and not ref: sys.exit("document-attach.sh: one of 'object_id' / 'ref' is required") if oid not in (None, "") and not str(oid).isdigit(): sys.exit("document-attach.sh: 'object_id' must be numeric, got %r" % oid) path = str(d.get("file") or "") if not path: sys.exit("document-attach.sh: 'file' is required") if not os.path.isfile(path): sys.exit("document-attach.sh: file not found: %s\n (paths resolve against " "the CWD; promote-apply resolves manifest-relative paths first)" % path) filename = str(d.get("filename") or "").strip() or os.path.basename(path) if "/" in filename or filename in (".", ".."): sys.exit("document-attach.sh: 'filename' must be a bare file name, got %r" % filename) print(mp) print("/invoices" if mp == "facture" else "/supplierinvoices") print(str(oid) if oid not in (None, "") else "") print(ref) print(path) print(filename) PY MAPPED="$(printf '%s' "${INPUT}" | python3 "${TMPD}/validate.py")" MP="$(sed -n 1p <<<"${MAPPED}")" ENDPOINT="$(sed -n 2p <<<"${MAPPED}")" OID="$(sed -n 3p <<<"${MAPPED}")" REF_IN="$(sed -n 4p <<<"${MAPPED}")" FILE="$(sed -n 5p <<<"${MAPPED}")" FILENAME="$(sed -n 6p <<<"${MAPPED}")" # Local sha256 + base64 (binary-safe, portable — no macOS/Linux base64 flag # drift; the base64 goes to a file, never through argv, so size doesn't matter). LOCAL_SHA="$(python3 - "${FILE}" "${TMPD}/b64.txt" <<'PY' import base64, hashlib, sys data = open(sys.argv[1], "rb").read() open(sys.argv[2], "w").write(base64.b64encode(data).decode()) print(hashlib.sha256(data).hexdigest()) PY )" urlenc() { python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1], safe=''))" "$1"; } # --- 2. Resolve the object on the target (id ⇄ ref) ---------------------------- if [[ -n "${OID}" ]]; then if ! "${W}" GET "${ENDPOINT}/${OID}" > "${TMPD}/obj.json" 2> "${TMPD}/obj.err"; then cat "${TMPD}/obj.err" >&2 echo "document-attach.sh: object ${ENDPOINT}/${OID} not readable on the target — refusing to upload blind" >&2 exit 1 fi REF="$(python3 -c "import json,sys; print(json.load(sys.stdin).get('ref') or '')" < "${TMPD}/obj.json")" if [[ -z "${REF}" ]]; then echo "document-attach.sh: object ${ENDPOINT}/${OID} has no ref — cannot address its GED directory" >&2 exit 1 fi if [[ -n "${REF_IN}" && "${REF_IN}" != "${REF}" ]]; then echo "document-attach.sh: ABORT — object_id ${OID} is '${REF}' on the target, not the requested ref '${REF_IN}'" >&2 exit 1 fi else # ref-only: look the object up by its Dolibarr ref — 0 or 2+ matches abort. FLT="$(urlenc "(t.ref:=:'${REF_IN}')")" set +e "${W}" GET "${ENDPOINT}?limit=2&sqlfilters=${FLT}" > "${TMPD}/lookup.json" 2> "${TMPD}/lookup.err" rc=$? set -e if [[ ${rc} -ne 0 ]]; then if grep -q "HTTP 404" "${TMPD}/lookup.err"; then printf '[]' > "${TMPD}/lookup.json" else cat "${TMPD}/lookup.err" >&2 echo "document-attach.sh: could not look up ref '${REF_IN}' on ${ENDPOINT} — refusing to upload blind" >&2 exit 1 fi fi OID="$(python3 - "${TMPD}/lookup.json" "${REF_IN}" <<'PY' import json, sys try: rows = json.load(open(sys.argv[1])) except Exception: rows = [] rows = rows if isinstance(rows, list) else [] if len(rows) == 0: sys.exit("document-attach.sh: ref %r matched nothing on the target" % sys.argv[2]) if len(rows) > 1: sys.exit("document-attach.sh: ref %r is ambiguous (%d matches) — pass object_id" % (sys.argv[2], len(rows))) print(int(rows[0]["id"])) PY )" REF="${REF_IN}" fi # --- 3. List the object's GED directory (the dedupe source of truth) ----------- list_documents() { # $1 = output file; 404 on a proven-existing object = "no docs yet" set +e "${W}" GET "/documents?modulepart=${MP}&id=${OID}" > "$1" 2> "${TMPD}/list.err" local rc=$? set -e if [[ ${rc} -ne 0 ]]; then if grep -q "HTTP 404" "${TMPD}/list.err"; then printf '[]' > "$1" else cat "${TMPD}/list.err" >&2 echo "document-attach.sh: could not list documents for ${MP} id ${OID} — refusing to upload blind (dedupe impossible)" >&2 exit 1 fi fi } list_documents "${TMPD}/list.json" # Find a same-named entry and derive its module-relative download path from # fullname (everything after the last "/facture/" — covers both moduleparts, # including the supplier-invoice get_exdir prefix like 0/3/REF/…). cat > "${TMPD}/match.py" <<'PY' import json, os, sys try: rows = json.load(open(sys.argv[1])) except Exception: rows = [] rows = rows if isinstance(rows, list) else [] fn = sys.argv[2] hits = [] for r in rows: if str(r.get("type") or "") == "dir": continue name = str(r.get("relativename") or "").strip() \ or os.path.basename(str(r.get("fullname") or "")) if name == fn: hits.append(r) if len(hits) > 1: sys.exit("document-attach.sh: ABORT — %d GED entries named %r on this object; " "refusing to guess" % (len(hits), fn)) if hits: full = str(hits[0].get("fullname") or "") i = full.rfind("/facture/") if i < 0: sys.exit("document-attach.sh: ABORT — cannot derive the module-relative " "path from %r" % full) print(json.dumps({"relpath": full[i + len("/facture/"):], "size": hits[0].get("size")})) PY remote_sha() { # $1 = module-relative path → prints sha256 of the stored bytes local enc dl enc="$(urlenc "$1")" if ! dl="$("${W}" GET "/documents/download?modulepart=${MP}&original_file=${enc}" 2> "${TMPD}/dl.err")"; then cat "${TMPD}/dl.err" >&2 echo "document-attach.sh: could not download back '$1' to verify its sha256 — refusing to act blind" >&2 exit 1 fi python3 -c " import base64, hashlib, json, sys d = json.load(sys.stdin) print(hashlib.sha256(base64.b64decode(d['content'])).hexdigest())" <<<"${dl}" } MATCH="$(python3 "${TMPD}/match.py" "${TMPD}/list.json" "${FILENAME}")" if [[ -n "${MATCH}" ]]; then RELPATH="$(python3 -c "import json,sys; print(json.load(sys.stdin)['relpath'])" <<<"${MATCH}")" SIZE="$(python3 -c "import json,sys; print(json.load(sys.stdin)['size'])" <<<"${MATCH}")" STORED_SHA="$(remote_sha "${RELPATH}")" if [[ "${STORED_SHA}" == "${LOCAL_SHA}" ]]; then echo "document-attach.sh: '${FILENAME}' already attached to ${REF} with identical sha256 — deduped, no upload" >&2 python3 -c " import json, sys print(json.dumps({'object_id': int(sys.argv[1]), 'ref': sys.argv[2], 'modulepart': sys.argv[3], 'filename': sys.argv[4], 'sha256': sys.argv[5], 'size': int(sys.argv[6]), 'deduped': True}))" "${OID}" "${REF}" "${MP}" "${FILENAME}" "${LOCAL_SHA}" "${SIZE}" exit 0 fi echo "document-attach.sh: ABORT — '${FILENAME}' already exists on ${REF} with DIFFERENT content" >&2 echo " stored sha256: ${STORED_SHA}" >&2 echo " local sha256: ${LOCAL_SHA}" >&2 echo " Same name + different bytes is a conflict for a human to resolve — never an overwrite." >&2 exit 1 fi # --- 4. Upload (no same-named entry) ------------------------------------------- python3 - "${FILENAME}" "${MP}" "${REF}" "${TMPD}/b64.txt" > "${TMPD}/upload.json" <<'PY' import json, sys print(json.dumps({"filename": sys.argv[1], "modulepart": sys.argv[2], "ref": sys.argv[3], "filecontent": open(sys.argv[4]).read().strip(), "fileencoding": "base64", "overwriteifexists": "0"})) PY RESP="$("${W}" POST /documents/upload @"${TMPD}/upload.json")" python3 - "${RESP}" "${FILENAME}" <<'PY' import json, sys try: got = json.loads(sys.argv[1]) except Exception: got = None if got != sys.argv[2]: sys.exit("document-attach.sh: upload did not confirm the filename " "(expected %r, got %s)" % (sys.argv[2], sys.argv[1])) PY # --- 5. Read-back: re-list + download + sha256 compare ------------------------- list_documents "${TMPD}/list2.json" MATCH2="$(python3 "${TMPD}/match.py" "${TMPD}/list2.json" "${FILENAME}")" if [[ -z "${MATCH2}" ]]; then echo "document-attach.sh: read-back FAILED — '${FILENAME}' not listed on ${REF} after upload" >&2 exit 1 fi RELPATH2="$(python3 -c "import json,sys; print(json.load(sys.stdin)['relpath'])" <<<"${MATCH2}")" SIZE2="$(python3 -c "import json,sys; print(json.load(sys.stdin)['size'])" <<<"${MATCH2}")" STORED_SHA2="$(remote_sha "${RELPATH2}")" if [[ "${STORED_SHA2}" != "${LOCAL_SHA}" ]]; then echo "document-attach.sh: read-back FAILED — stored sha256 ${STORED_SHA2} != local ${LOCAL_SHA}" >&2 exit 1 fi python3 -c " import json, sys print(json.dumps({'object_id': int(sys.argv[1]), 'ref': sys.argv[2], 'modulepart': sys.argv[3], 'filename': sys.argv[4], 'sha256': sys.argv[5], 'size': int(sys.argv[6]), 'deduped': False}))" "${OID}" "${REF}" "${MP}" "${FILENAME}" "${LOCAL_SHA}" "${SIZE2}"