Skill: sydneyphoenix26/owncloudsync (version 1.0.0)

--- name: owncloudsync description: Check that new files on Google Drive are present on OwnCloud + send email report version: 1.0.0 author: SydneyPhoenix26 tags: [sync, google-drive, owncloud, backup, report] license: MIT metadata: { "openclaw": { "requires": { "bins": ["zsh", "curl", "jq", "gog"], "env" : [], "files" : ["owncloud.json"] }, "install": [ { "id": "brew", "kind": "brew", "formula": "steipete/tap/gogcli", "bins": ["gog"], "label": "Install gog (brew)", } ], } } --- # owncloud-sync ## Purpose This skill tracks files created or imported into Google Drive over a specified period (e.g., last 30 days), verifies their presence on the OwnCloud server, and generates a daily report emailed to the user. The report lists new Google Drive files and flags any missing or outdated files on OwnCloud, helping the user decide which files to copy. ## Rationale Google Drive serves as a convenient workspace to drop and edit files. This skill helps decide whether to push files to OwnCloud or to delete/retain them on Google Drive. ## Operating Mode - The script `owncloud-sync.sh` is located in `.openclaw/skills/owncloud-sync/`. - Run the script manually or on demand; it requires Zsh. ## Configuration - The script require a local config file owncloud.json. - The variables `ALLFILES_URL`, `ALLFILES_USER`, and `ALLFILES_PASS` in `owncloud.json` specify the OwnCloud indexing service URL and credentials. - `GOG_ACCOUNT` (Google Drive account) and `EMAIL_RECIPIENT` (report recipient) are also defined in `owncloud.json`. - `PERIOD_DAYS` defines the date range for querying Google Drive, also in `owncloud.json`. ## Report - All files created or imported during the specified period are checked on OwnCloud. - The report indicates each file's status: OK, MISSING, or NEED UPDATE (if OwnCloud's version is older than Google Drive's).

path: owncloud-sync.sh
content: #!/usr/bin/env zsh
# ==============================================================================
# Script:       owncloud-sync.sh
# Generated on: 2026-03-01
#
# Description:  Queries Google Drive for recently modified files, compares them
#               against the OwnCloud file inventory, and emails a sync report
#               highlighting MISSING or NEED UPDATE entries.
# ==============================================================================

# ==============================================================================
# PARAMETERS
# ==============================================================================

CONFIG_FILE="$(dirname "$0")/owncloud.json"

if [[ ! -f "$CONFIG_FILE" ]]; then
    echo "FATAL: Configuration file not found: $CONFIG_FILE" >&2
    exit 1
fi

# Load OwnCloud connection parameters from JSON config via jq
ALLFILES_URL=$(jq -r '.ALLFILES_URL // empty' "$CONFIG_FILE")
ALLFILES_USER=$(jq -r '.ALLFILES_USER // empty' "$CONFIG_FILE")
ALLFILES_PASS=$(jq -r '.ALLFILES_PASS // empty' "$CONFIG_FILE")
GOG_ACCOUNT=$(jq -r '.GOG_ACCOUNT // empty' "$CONFIG_FILE")
EMAIL_RECIPIENT=$(jq -r '.EMAIL_RECIPIENT // empty' "$CONFIG_FILE")
PERIOD_DAYS=$(jq -r '.PERIOD_DAYS // empty' "$CONFIG_FILE")

if [[ -z "$ALLFILES_URL" || -z "$ALLFILES_USER" || -z "$ALLFILES_PASS" ]]; then
    echo "FATAL: ALLFILES_URL, ALLFILES_USER, or ALLFILES_PASS missing/empty in $CONFIG_FILE" >&2
    exit 1
fi

if [[ -z "$GOG_ACCOUNT" || -z "$EMAIL_RECIPIENT" || -z "$PERIOD_DAYS" ]]; then
    echo "FATAL: GOG_ACCOUNT, EMAIL_RECIPIENT, or PERIOD_DAYS missing/empty in $CONFIG_FILE" >&2
    exit 1
fi

# ---------------------------------------------------------------------------
# Validate ALLFILES_URL against a strict allowlist before use: must be HTTPS,
# must not contain credentials, redirects, or path traversal sequences.
# This limits the blast radius of a compromised owncloud.json by ensuring
# the URL cannot be silently redirected to an arbitrary external server.
# ---------------------------------------------------------------------------
if [[ ! "$ALLFILES_URL" =~ ^https://[a-zA-Z0-9._-]+(:[0-9]+)?(/[a-zA-Z0-9/_.-]*)?$ ]]; then
    echo "FATAL: ALLFILES_URL does not match expected format (HTTPS only, no credentials): '$ALLFILES_URL'" >&2
    exit 1
fi

export GOG_ACCOUNT

# Compute START_DATE: PERIOD_DAYS days before today
if [[ "$(uname)" == "Darwin" ]]; then
    START_DATE=$(date -v-${PERIOD_DAYS}d +%Y-%m-%d)
else
    START_DATE=$(date -d "${PERIOD_DAYS} days ago" +%Y-%m-%d)
fi

# Could be used to narrow scope
# GOOGLE_DRIVE_QUERY="after:${START_DATE} -type:image"

GOOGLE_DRIVE_QUERY="after:${START_DATE}"

# ==============================================================================
# TEMPORARY FILES & CLEANUP
# ==============================================================================
WORK_DIR=$(mktemp -d "${TMPDIR:-/tmp}/owncloud_sync.XXXXXX")
REPORT_FILE="${WORK_DIR}/report.txt"
GD_RAW_FILE="${WORK_DIR}/gdrive_raw.txt"
OC_RAW_FILE="${WORK_DIR}/owncloud_raw.txt"

cleanup() {
    rm -rf "$WORK_DIR"
}
trap cleanup EXIT INT TERM

# ==============================================================================
# HELPER FUNCTIONS
# ==============================================================================

# --------------------------------------------------------------------------
# date_to_epoch: Convert a date string to epoch seconds for comparison.
#   Handles: "YYYY-MM-DD HH:MM", "YYYY-MM-DD HH:MM:SS",
#            "YYYY-MM-DDTHH:MM:SSZ", "YYYY-MM-DDTHH:MM:SS.nnn+00:00"
# --------------------------------------------------------------------------
date_to_epoch() {
    local dt="$1"

    # Nettoyage agressif
    dt="${dt//T/ }"
    dt="${dt//Z/}"
    dt="${dt%%+*}"
    dt="${dt%%.*}"          # enlève millisecondes

    # Si on a exactement YYYY-MM-DD HH:MM → on ajoute :00
    if [[ $dt =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}\ [0-9]{2}:[0-9]{2}$ ]]; then
        dt="${dt}:00"
    fi

    if [[ "$(uname)" == "Darwin" ]]; then
        date -j -f "%Y-%m-%d %H:%M:%S" "$dt" "+%s" 2>/dev/null || echo "0"
    else
        date -d "$dt" "+%s" 2>/dev/null || echo "0"
    fi
}

# --------------------------------------------------------------------------
# clean_filename: Normalize whitespace in a filename.
#   - Tabs → spaces
#   - Collapse consecutive spaces
#   - Trim leading/trailing whitespace
# --------------------------------------------------------------------------
clean_filename() {
    local name="$1"
    name="${name//$'\t'/ }"                       # tabs → spaces
    name=$(echo "$name" | sed 's/  */ /g')        # collapse spaces
    name="${name## }"                              # trim leading
    name="${name%% }"                              # trim trailing
    echo "$name"
}

# ---------------------------------------------------------------------------
# sed_escape: Sanitize a string for safe interpolation into a sed replacement.
# Escapes characters that carry special meaning in sed's substitution syntax
# (& triggers a back-reference, | and \ break the delimiter or escape chain),
# preventing unintended behavior when variable content is untrusted or
# contains URL-like strings, paths, or arbitrary user input.
# ---------------------------------------------------------------------------
sed_escape() {
    printf '%s' "$1" | sed 's/[&\|]/\\&/g'
}

# ==============================================================================
# TIMING
# ==============================================================================

TIMER_START=$SECONDS
RUN_DATETIME=$(date "+%Y-%m-%d %H:%M:%S %Z")

echo "INFO: =============================================="
echo "INFO: OwnCloud Sync Check — $RUN_DATETIME"
echo "INFO: Period : last $PERIOD_DAYS days (since $START_DATE)"
echo "INFO: Query  : $GOOGLE_DRIVE_QUERY"
echo "INFO: =============================================="

# ==============================================================================
# STEP 1 — Fetch Google Drive file list
# ==============================================================================

echo "INFO: [Step 1/6] Querying Google Drive..."

if ! gog drive search "$GOOGLE_DRIVE_QUERY" > "$GD_RAW_FILE" 2>/dev/null; then
    echo "FATAL: 'gog drive search' failed (exit $?)" >&2
    exit 1
fi

echo "INFO:   → $(wc -l < "$GD_RAW_FILE" | tr -d ' ') raw lines returned"

# ==============================================================================
# STEP 2 — Fetch OwnCloud file list
# ==============================================================================

echo "INFO: [Step 2/6] Fetching OwnCloud file list..."

if ! curl -s -f \
        --proto '=https' \
        --proto-redir '=https' \
        --max-redirs 0 \
        -u "${ALLFILES_USER}:${ALLFILES_PASS}" \
        "${ALLFILES_URL}" \
        > "$OC_RAW_FILE" 2>/dev/null; then
    echo "FATAL: curl to OwnCloud failed (exit $?)" >&2
    exit 1
fi

echo "INFO:   → $(wc -l < "$OC_RAW_FILE" | tr -d ' ') lines returned"

# ==============================================================================
# STEP 3 — Parse OwnCloud file list
#   Format per line:  filename|YYYY-MM-DD HH:MM
#   Example:          Introducing ownCloud.pdf|2022-10-29 22:40
# ==============================================================================

echo "INFO: [Step 3/6] Parsing OwnCloud file list..."

typeset -A oc_dates   # oc_dates[cleaned_filename] = "YYYY-MM-DD HH:MM"

while IFS='|' read -r oc_raw_name oc_raw_date; do
    [[ -z "$oc_raw_name" ]] && continue

    # Trim each field
    oc_raw_name="${oc_raw_name## }"; oc_raw_name="${oc_raw_name%% }"
    oc_raw_date="${oc_raw_date## }"; oc_raw_date="${oc_raw_date%% }"

    oc_clean=$(clean_filename "$oc_raw_name")
    oc_dates["$oc_clean"]="$oc_raw_date"

done < "$OC_RAW_FILE"

echo "INFO:   → ${#oc_dates[@]} files indexed from OwnCloud"

# ==============================================================================
# STEP 4 — Parse Google Drive output
#
#   The 'gog drive search' command returns more than just a name and date.
#   Typical output may look like (tab-separated or fixed-width):
#
#     Id                                Name                       MimeType          ModifiedTime                 Size
#     1BxiMVs0XRA5nFMdKvBdBZjgmUUqpt   facture Q1.pdf             application/pdf   2025-06-15T10:30:00.000Z     123456
#
#   Strategy:
#     A) If tabs are present → split by tab, locate the date column by regex.
#     B) Otherwise → locate the ISO-8601 date via regex, extract the filename
#        from the text preceding it (stripping a leading ID hash).
#
#   In all cases the filename is cleaned (tabs→spaces, collapse, trim).
# ==============================================================================

echo "INFO: [Step 4/6] Parsing Google Drive output..."

typeset -a gd_filenames
typeset -a gd_moddates

IS_FIRST_LINE=true

while IFS= read -r raw_line; do

    # --- Skip the header line ---
    if $IS_FIRST_LINE; then
        IS_FIRST_LINE=false
        # If it looks like a header row, skip it
        if [[ "$raw_line" == *"Name"* || "$raw_line" == *"Id"* \
           || "$raw_line" == *"Modified"* || "$raw_line" == *"Mime"* ]]; then
            continue
        fi
        # Otherwise fall through and parse it as data
    fi

    # Skip blank lines
    [[ -z "${raw_line//[[:space:]]/}" ]] && continue

    gd_name=""
    gd_date=""

    # ------------------------------------------------------------------
    # Strategy A: Tab-separated fields
    # ------------------------------------------------------------------
    if [[ "$raw_line" == *$'\t'* ]]; then

        # Split on literal tab into an array
        local -a tab_fields=("${(@s/	/)raw_line}")

        # Locate the field that looks like an ISO-8601 date
        local date_idx=0
        for (( fi = 1; fi <= ${#tab_fields[@]}; fi++ )); do
            if [[ "${tab_fields[$fi]}" =~ [0-9]{4}-[0-9]{2}-[0-9]{2}[T\ ][0-9]{2}:[0-9]{2} ]]; then
                date_idx=$fi
                break
            fi
        done

        if (( date_idx > 0 )); then
            gd_date="${tab_fields[$date_idx]}"
            # Name is expected in the 2nd field (after the ID)
            if (( ${#tab_fields[@]} >= 2 )); then
                gd_name="${tab_fields[2]}"
            fi
        elif (( ${#tab_fields[@]} >= 2 )); then
            # Fallback: assume field 1 = name, field 2 = date
            gd_name="${tab_fields[1]}"
            gd_date="${tab_fields[2]}"
        fi

    # ------------------------------------------------------------------
    # Strategy B: Locate ISO-8601 date in the line with regex
    # ------------------------------------------------------------------
    elif [[ "$raw_line" =~ ([0-9]{4}-[0-9]{2}-[0-9]{2}[T\ ][0-9]{2}:[0-9]{2}(:[0-9]{2})?(\.[0-9]+)?(Z|[+-][0-9]{2}:?[0-9]{2})?) ]]; then
        gd_date="${match[1]}"

        # Everything before the date is "ID + Name + Type + Size"
        local before_date="${raw_line%%${gd_date}*}"

        # 1. Strip a leading Google Drive file-ID (si présent)
        before_date=$(echo "$before_date" | sed -E 's/^[a-zA-Z0-9_-]{15,}[[:space:]]+//')

        # 2. Strip the SIZE at the end (handles "-", "5 B", "100.2 KB", etc.)
        # On cherche un bloc final qui est soit "-" soit "chiffres + espace optionnel + unité"
        before_date=$(echo "$before_date" | sed -E 's/[[:space:]]+([0-9.]+[[:space:]]*[A-Z]*|-)[[:space:]]*$//')

        # 3. Strip the TYPE / MIME-TYPE (ex: "file", "dir", "application/pdf")
        # Maintenant que la taille est partie, c'est le dernier mot restant avant le nom
        before_date=$(echo "$before_date" | sed -E 's/[[:space:]]+[^[:space:]]+[[:space:]]*$//')

        gd_name="$before_date"
    fi

    # ------------------------------------------------------------------
    # Clean extracted values
    # ------------------------------------------------------------------
    gd_name=$(clean_filename "$gd_name")

    # Normalize date to "YYYY-MM-DD HH:MM" (matches OwnCloud format)
    gd_date="${gd_date//T/ }"
    gd_date="${gd_date//Z/}"
    gd_date="${gd_date%%+*}"       # strip timezone offset
    gd_date="${gd_date%%.*}"       # strip fractional seconds
    gd_date="${gd_date:0:16}"      # keep YYYY-MM-DD HH:MM

    if [[ -n "$gd_name" ]]; then
        gd_filenames+=("$gd_name")
        gd_moddates+=("$gd_date")
    fi

done < "$GD_RAW_FILE"

echo "INFO:   → ${#gd_filenames[@]} files parsed from Google Drive"

# ==============================================================================
# STEP 5 — Compare Google Drive files against OwnCloud & build report
# ==============================================================================

echo "INFO: [Step 5/6] Comparing files and generating report..."

COUNT_OK=0
COUNT_MISSING=0
COUNT_NEEDUPDATE=0

{
    cat <<'BANNER'
================================================================
         O W N C L O U D   S Y N C   R E P O R T
================================================================
BANNER
    echo ""
    echo "  Run date/time     : $RUN_DATETIME"
    echo "  Period            : Last $PERIOD_DAYS days (since $START_DATE)"
    echo "  Google Drive query: gog drive search \"$GOOGLE_DRIVE_QUERY\""
    echo "  OwnCloud endpoint : $ALLFILES_URL"
    echo "  Google Drive files: ${#gd_filenames[@]}"
    echo "  OwnCloud files    : ${#oc_dates[@]}"
    echo ""
    echo "----------------------------------------------------------------"
    printf "  %-55s | %-12s\n" "FILENAME" "STATUS"
    echo "----------------------------------------------------------------"

    for (( i = 1; i <= ${#gd_filenames[@]}; i++ )); do
        fname="${gd_filenames[$i]}"
        fdate="${gd_moddates[$i]}"
        sync_status=""

        # Check presence in OwnCloud ( ${+array[key]} returns 1 if key exists )
        # Remplacer la ligne dans le Step 5 :
        if (( ! ${+oc_dates["$fname"]} )); then
            sync_status="MISSING"
            (( COUNT_MISSING++ ))
        else
            # File exists — compare modification dates
            oc_date_val="${oc_dates["$fname"]}"
            gd_epoch=$(date_to_epoch "$fdate")
            oc_epoch=$(date_to_epoch "$oc_date_val")

            if (( oc_epoch < gd_epoch )); then
                sync_status="NEED UPDATE"
                (( COUNT_NEEDUPDATE++ ))
            else
                sync_status="OK"
                (( COUNT_OK++ ))
            fi
        fi

        printf "  %-55s | %-12s\n" "$fname" "$sync_status"
    done

    echo "----------------------------------------------------------------"
    echo ""
    echo "  SUMMARY"
    echo "  ─────────────────────────"
    echo "    OK          : $COUNT_OK"
    echo "    MISSING     : $COUNT_MISSING"
    echo "    NEED UPDATE : $COUNT_NEEDUPDATE"
    echo "    TOTAL       : ${#gd_filenames[@]}"
    echo ""


} > "$REPORT_FILE"

# Fix the literal $VAR references inside the heredoc-style block
# (We used double-quoted echo so they expanded, but the cat <<'BANNER' is literal)
# Re-do the dynamic header lines properly:
sed -i.bak \
    -e "s|\$RUN_DATETIME|$(sed_escape "${RUN_DATETIME}")|g" \
    -e "s|\$PERIOD_DAYS|$(sed_escape "${PERIOD_DAYS}")|g" \
    -e "s|\$START_DATE|$(sed_escape "${START_DATE}")|g" \
    -e "s|\$GOOGLE_DRIVE_QUERY|$(sed_escape "${GOOGLE_DRIVE_QUERY}")|g" \
    -e "s|\$ALLFILES_URL|$(sed_escape "${ALLFILES_URL}")|g" \
    "$REPORT_FILE" 2>/dev/null
rm -f "${REPORT_FILE}.bak"

# ==============================================================================
# STEP 6 — Append elapsed time & send report
# ==============================================================================

ELAPSED=$((SECONDS - TIMER_START))

{
    echo "----------------------------------------------------------------"
    echo "  Elapsed time      : ${ELAPSED} second(s)"
    echo "  Report finalised  : $(date '+%Y-%m-%d %H:%M:%S %Z')"
    echo "================================================================"
} >> "$REPORT_FILE"

echo "INFO: [Step 6/6] Sending report to $EMAIL_RECIPIENT..."

if gog mail send \
        --to "$EMAIL_RECIPIENT" \
        --subject "OwnCloud Sync Report" \
        --body-file "$REPORT_FILE"; then
    TOTAL_ELAPSED=$((SECONDS - TIMER_START))
    echo "INFO: Report sent successfully."
    echo "INFO: Total elapsed time (including send): ${TOTAL_ELAPSED} second(s)"
else
    echo "ERROR: 'gog mail send' failed (exit $?)" >&2
    # Still show the report on stdout for diagnosis
    echo "--- REPORT CONTENT ---"
    cat "$REPORT_FILE"
    echo "--- END REPORT ---"
    exit 1
fi

echo "INFO: ===== OwnCloud Sync Check Complete ====="
exit 0


path: allfiles-service/allfiles-service.go
content: package main

import (
	"crypto/subtle"
	"io"
	"log"
	"net/http"
	"os"
)

var (
	username = os.Getenv("ALLFILES_USER")
	[REDACTED_SECRET]"ALLFILES_PASS")
	listen   = ":8443" // change to ":443" if you want standard port
	cert     = "/etc/allfiles/certs/fullchain.pem"
	key      = "/etc/allfiles/certs/privkey.pem"
)

func basicAuth(next http.HandlerFunc) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		user, pass, ok := r.BasicAuth()
		if !ok || subtle.ConstantTimeCompare([]byte(user), []byte(username)) != 1 ||
			subtle.ConstantTimeCompare([]byte(pass), []byte(password)) != 1 {
			w.Header().Set("WWW-Authenticate", `Basic realm="allfiles"`)
			http.Error(w, "Unauthorized", http.StatusUnauthorized)
			return
		}
		next(w, r)
	}
}

func allfilesHandler(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodGet {
		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
		return
	}

	// ---------------------------------------------------------------------------
	// Guard against symlink attacks: reject any attempt to serve /tmp/allfiles.txt
	// if it has been replaced by a symbolic link. A local attacker could otherwise
	// point it to an arbitrary file (e.g. /etc/passwd) and leak its contents
	// through the /allfiles endpoint.
	// ---------------------------------------------------------------------------
	info, err := os.Lstat("/tmp/allfiles.txt")
	if err != nil {
		http.Error(w, "File not found", http.StatusNotFound)
		return
	}
	if info.Mode()&os.ModeSymlink != 0 {
		log.Println("SECURITY: /tmp/allfiles.txt is a symbolic link — possible symlink attack detected.")
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}

	f, err := os.Open("/tmp/allfiles.txt")
	if err != nil {
		http.Error(w, "File not found", http.StatusNotFound)
		return
	}
	defer f.Close()

	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
	w.Header().Set("Cache-Control", "no-cache, no-store")
	io.Copy(w, f)
}

func main() {
	if username == "" || password == "" {
		log.Fatal("Environnement variables BASIC_USER and BASIC_PASS are mandatory")
	}

	http.HandleFunc("/allfiles", basicAuth(allfilesHandler))

	log.Printf("✅ Service started", listen)
	log.Fatal(http.ListenAndServeTLS(listen, cert, key, nil))
}


path: allfiles-service/README.md
content: ## Prerequisites on OwnCloud Server
- OwnCloud lacks a satisfactory built-in file search service, so an indexing service is required.
- A small script must be configured then installed with crontab (run daily): findallfiles.sh
- A Go program (`allfiles-service.go`) is provided to serve the result of the above script
- Compile the Go program with:
  ```
  go build -ldflags="-s -w" -o /usr/local/bin/allfiles-service allfiles-service.go
  ```
- Configure credentials (`ALLFILES_USER`, `ALLFILES_PASS`) in the service definition (`allfiles.service`) to match the ones in the owncloud.json file.
- Install and enable the provided systemd service `allfiles.service`.
- Place valid certificates (default location: `/etc/allfiles/certs/`).


path: allfiles-service/findallfiles.sh
content: #!/usr/bin/bash
# Date 26/02/2026
# This script is part of the allfiles service
# The allfiles service is used by the openclaw skill Owncloud-sync
#
# Narrower scope version : exclude images (if used, adapt google query)
#  -type f ! -name "*.png" ! -name "*.jpg" -printf '%f|%TY-%Tm-%Td %TH:%TM\n' > /tmp/allfiles.txt

# ---------------------------------------------------------------------------
# Guard against symlink attacks: reject any attempt to serve /tmp/allfiles.txt
# if it has been replaced by a symbolic link. A local attacker could otherwise
# point it to an arbitrary file (e.g. /etc/passwd) and leak its contents
# through the /allfiles endpoint.
# ---------------------------------------------------------------------------
if [[ -L "/tmp/allfiles.txt" ]]; then
    echo "FATAL: /tmp/allfiles.txt is a symbolic link — possible symlink attack detected." >&2
    exit 1
fi

# WARNING: ADAPT NEXT 2 LINES !!
OWNCLOUD_USERNAME=ChangeUserNameHere
OWNCLOUD_ROOT_INSTALL_DIR=/var/www/html/data

# ---------------------------------------------------------------------------
# Validate configuration variables against an explicit allowlist of safe
# characters before interpolating them into the find command. Although these
# values are set statically by an administrator, rejecting unexpected characters
# (spaces, shell metacharacters, path traversal sequences) prevents accidental
# or malicious misconfiguration from causing unintended command behavior.
# ---------------------------------------------------------------------------
if [[ ! "$OWNCLOUD_USERNAME" =~ ^[a-zA-Z0-9._-]+$ ]]; then
    echo "FATAL: OWNCLOUD_USERNAME contains invalid characters: '$OWNCLOUD_USERNAME'" >&2
    exit 1
fi

if [[ ! "$OWNCLOUD_ROOT_INSTALL_DIR" =~ ^/[a-zA-Z0-9/_-]+$ ]]; then
    echo "FATAL: OWNCLOUD_ROOT_INSTALL_DIR contains invalid characters: '$OWNCLOUD_ROOT_INSTALL_DIR'" >&2
    exit 1
fi

/usr/bin/find "$OWNCLOUD_ROOT_INSTALL_DIR"/"$OWNCLOUD_USERNAME"/ -type f -printf '%f|%TY-%Tm-%Td %TH:%TM\n' > /tmp/allfiles.txt


path: allfiles-service/allfiles.service.txt
content: [Unit]
Description=Allfiles REST Service (Go)
After=network.target

[Service]
Type=simple
User=www-data
Environment="ALLFILES_USER=admin"
Environment="ALLFILES_PASS=SuperSecretPasswordChangeMe2026!"
ExecStart=/usr/local/bin/allfiles-service
Restart=always
RestartSec=3
NoNewPrivileges=true
ProtectSystem=strict
ReadWritePaths=/tmp
ReadOnlyPaths=/etc/allfiles/certs

[Install]
WantedBy=multi-user.target



path: LICENSE.txt
content: MIT License

Copyright (c) 2026 SydneyPhoenix26

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


path: owncloud.json
content: ALLFILES_URL: https://xxxx.xxx:8443/allfiles
ALLFILES_USER: admin
ALLFILES_PASS: SuperSecretPasswordChangeMe2026!
GOG_ACCOUNT: [REDACTED_SECRET]
EMAIL_RECIPIENT: [REDACTED_SECRET]
PERIOD_DAYS: 60

Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)