#!/bin/sh
#
# filature-report - report a bug to the SliTaz developers
#
# Filature - SliTaz developer platform
# Copyright (C) 2026 SliTaz GNU/Linux - BSD License, see LICENSE
#
# The engine collects anonymous system information, builds the report
# and POSTs it with BusyBox wget. Front ends (this script's interactive
# mode, the GTK3 dialog) only call it. The format is the one of TazHelper,
# and so are the system lines and the privacy filter: no MAC address,
# IP address, user name, host name or serial number leaves the machine,
# and nothing is sent before the user saw the whole report.
#
# Usage:
#   filature-report                   ask, show the report, send on yes
#   filature-report system            print the system lines
#   filature-report tech              print the technical lines
#   filature-report make [-t] [-x FILE] TITLE [CONTACT] < description
#                                     print a bug report, -t with the
#                                     technical lines, -x FILE with the
#                                     key=value lines of FILE
#   filature-report make -k reprise [-t] [-x FILE] [< comment]
#                                     print a reprise feedback report:
#                                     no title, no contact, the id of
#                                     this installation
#   filature-report known [FILE]      what reprise knows of this system:
#                                     hints on stdout, its lines in FILE
#   filature-report new-id            draw a new id (root)
#   filature-report send FILE         send a report file, "-" for stdin
#   filature-report help              print this help
#
# Config: /etc/slitaz/filature-report.conf (URL, TIMEOUT), or the
# FILATURE_REPORT_URL environment variable. FILATURE_REPORT_ROOT=DIR
# reads the release, flavor, arch, packages, locale and logs in DIR, a
# system mounted from a rescue live CD; the hardware lines stay the
# ones of the running system.
#
# A reprise report carries install=, a random id drawn once and kept in
# /var/lib/filature-report/install: the developers count how many
# machines meet a problem, it tells nothing else. Bug reports never carry
# it, so they cannot be tied to reprise reports. new-id draws another.
#

VERSION=1
CONF=/etc/slitaz/filature-report.conf
ID_FILE=var/lib/filature-report/install
URL="https://filature.slitaz.org/report"
TIMEOUT=20
REPRISE_TIMEOUT=30
MAX_VALUE=500
MAX_LINE=1024
MAX_REPORT=16384

[ -f "$CONF" ] && . "$CONF"
[ -n "$FILATURE_REPORT_URL" ] && URL="$FILATURE_REPORT_URL"

ROOT="${FILATURE_REPORT_ROOT%/}"
if [ -n "$FILATURE_REPORT_ROOT" ] && [ ! -d "$ROOT/etc" ]; then
    echo "filature-report: $FILATURE_REPORT_ROOT is not a system root" >&2
    exit 1
fi

# The user and host names to hide, as sed expressions, worked out once:
# this user, this host and, in rescue, the host of the inspected system
names_sed() {
    sedx="s/^//"
    user="$(id -un 2>/dev/null)"
    case "$user" in
        root|tux|""|*[!a-z0-9_-]*) ;;
        *) sedx="$sedx;s/(^|[^[:alnum:]])$user([^[:alnum:]]|$)/\1<user>\2/g" ;;
    esac
    for host in "$(hostname 2>/dev/null)" "$(cat "$ROOT/etc/hostname" 2>/dev/null)"; do
        case "$host" in
            slitaz|localhost|""|*[!A-Za-z0-9.-]*) ;;
            *) host="$(echo "$host" | sed 's/\./\\./g')"
               sedx="$sedx;s/(^|[^[:alnum:]])$host([^[:alnum:]]|$)/\1<host>\2/g" ;;
        esac
    done
}

# Each stdin line made anonymous: from TazHelper's clean_value
anonymize() {
    sed -E \
        -e 's/([0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2}/<mac>/g' \
        -e 's/[0-9]{1,3}(\.[0-9]{1,3}){3}/<ip>/g' \
        -e 's|/home/[A-Za-z0-9._-]+|/home/<user>|g' \
        -e "$sedx" \
        -e 's/^ +//;s/ +$//' | cut -c 1-$MAX_VALUE
}

# One value, made anonymous
clean_value() {
    tr '\n\t\r' '   ' | tr -d '\000-\037\177' | anonymize
}

# Display controllers: "vendor:device driver", from sysfs (no lspci)
gpu_info() {
    for dev in /sys/bus/pci/devices/*; do
        case "$(cat $dev/class 2>/dev/null)" in 0x03*) ;; *) continue ;; esac
        drv="none"
        [ -e "$dev/driver" ] && drv="$(basename "$(readlink "$dev/driver")")"
        printf '%s:%s %s, ' "$(sed 's/^0x//' $dev/vendor)" \
            "$(sed 's/^0x//' $dev/device)" "$drv"
    done | sed 's/, $//'
}

# The system lines, the same keys as TazHelper
system_lines() {
    flavor="unknown"
    [ -s "$ROOT/etc/slitaz/flavor" ] && flavor="$(head -n 1 "$ROOT/etc/slitaz/flavor")"
    arch="$(uname -m)"
    locale="${LANG:-C}"
    if [ -n "$ROOT" ]; then
        a="$(sed -n 's/^SLITAZ_ARCH="\{0,1\}\([a-z0-9_]*\)"\{0,1\}$/\1/p' \
            "$ROOT/etc/slitaz/slitaz.conf" 2>/dev/null | tail -n 1)"
        [ -n "$a" ] && arch="$a"
        locale="$(sed -n 's/^LANG="\{0,1\}\([^"]*\)"\{0,1\}$/\1/p' \
            "$ROOT/etc/locale.conf" 2>/dev/null | tail -n 1)"
        locale="${locale:-C}"
    fi
    boot="bios"
    [ -d /sys/firmware/efi ] && boot="efi"
    live="no"
    case "$(awk '$2 == "/" { print $3 }' /proc/mounts | tail -n 1)" in
        rootfs|tmpfs) live="yes" ;;
    esac
    cpu="$(sed -n 's/^model name[[:space:]]*: //p' /proc/cpuinfo | head -n 1)"
    cores="$(grep -c '^processor' /proc/cpuinfo)"
    ram="$(awk '/^MemTotal:/ { print int($2 / 1024) }' /proc/meminfo)"
    {
        echo "date=$(date -u +%Y-%m-%d)"
        echo "release=$(cat "$ROOT/etc/slitaz-release" 2>/dev/null || echo unknown)"
        echo "flavor=$flavor"
        echo "arch=$arch"
        echo "kernel=$(uname -r)"
        echo "boot=$boot"
        echo "live=$live"
        echo "cpu=$cpu"
        echo "cpu_cores=$cores"
        echo "ram_mb=$ram"
        echo "gpu=$(gpu_info)"
        echo "packages=$(ls "$ROOT/var/lib/tazpkg/installed" 2>/dev/null | wc -l)"
        echo "locale=$locale"
        [ -n "$ROOT" ] && echo "rescue=yes"
    } | while IFS= read -r line; do
        echo "${line%%=*}=$(printf '%s' "${line#*=}" | clean_value)"
    done
}

# install_id [new]: the id of this installation, of the inspected system
# in rescue. Drawn on first use, or anew with "new", when we may write
# it: root. Fails, printing nothing, when there is none to give.
install_id() {
    f="$ROOT/$ID_FILE"
    if [ "$1" = new ] || ! grep -qx '[0-9a-f]\{8\}' "$f" 2>/dev/null; then
        id="$(od -An -N4 -tx1 /dev/urandom | tr -d ' \n')"
        mkdir -p "${f%/*}" 2>/dev/null &&
            echo "$id" > "$f.tmp" 2>/dev/null &&
            chmod 644 "$f.tmp" && mv -f "$f.tmp" "$f" || return 1
    fi
    cat "$f"
}

# PCI devices of a class prefix: "vendor:device driver", one per line
pci_drivers() {
    for dev in /sys/bus/pci/devices/*; do
        case "$(cat $dev/class 2>/dev/null)" in $1*) ;; *) continue ;; esac
        drv="none"
        [ -e "$dev/driver" ] && drv="$(basename "$(readlink "$dev/driver")")"
        echo "$(sed 's/^0x//' $dev/vendor):$(sed 's/^0x//' $dev/device) $drv"
    done
}

# USB devices, hubs left out: "vendor:product driver,driver"
usb_drivers() {
    for dev in /sys/bus/usb/devices/*; do
        [ -f "$dev/idVendor" ] || continue
        [ "$(cat $dev/bDeviceClass)" = "09" ] && continue
        drv="$(for i in $dev/*:*; do
            [ -e "$i/driver" ] && basename "$(readlink "$i/driver")"
        done | sort -u | tr '\n' ',' | sed 's/,$//')"
        echo "$(cat $dev/idVendor):$(cat $dev/idProduct) ${drv:-none}"
    done
}

# KEY: stdin lines as "KEY=value" lines, made anonymous. Disk UUIDs go,
# and so do whole lines naming a serial number or a wifi network.
tech_key() {
    grep -viE 'serial|ssid' | sed -E \
        -e 's/^\[ *[0-9.]+\] //' \
        -e 's/[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}/<uuid>/g' |
    while IFS= read -r line; do
        line="$(printf '%s' "$line" | clean_value)"
        [ -n "$line" ] && echo "$1=$line"
    done
}

# The technical lines: what helpers ask first, a few KiB at most
tech_lines() {
    pci_drivers 0x02 | tech_key network
    pci_drivers 0x04 | tech_key sound
    usb_drivers | tech_key usb
    lsmod 2>/dev/null | awk 'NR > 1 { printf "%s ", $1 } END { print "" }' |
        fold -s -w 400 | tech_key modules
    tech_key cmdline < /proc/cmdline
    dmesg 2>/dev/null | grep -iE 'error|fail|warn|missing|not found|timed? ?out' | tail -n 10 |
        tech_key dmesg
    # The logs of the inspected system, in rescue
    xlog="$ROOT/var/log/Xorg.0.log"
    [ -z "$ROOT" ] && [ -f "$HOME/.local/share/xorg/Xorg.0.log" ] &&
        xlog="$HOME/.local/share/xorg/Xorg.0.log"
    grep -E '^\[[ 0-9.]+\] \((EE|WW)\)' "$xlog" 2>/dev/null | tail -n 10 | tech_key xorg
    sed "s/$(printf '\033')\[[0-9;]*[a-zA-Z]//g" "$ROOT/var/log/boot.log" 2>/dev/null |
        grep -iE 'error|fail' | tail -n 10 | tech_key boot
}

# extra_lines FILE REPORT RESERVE: the lines of FILE that may join the
# REPORT file, RESERVE bytes kept for what comes after them. Lines the
# server would refuse are dropped: bad key, too long; other bytes than
# ASCII become "?", so the text is valid UTF-8 whatever its source. The
# values go through the same filter as ours, and a key the report holds
# already, or reserved, is ignored: the engine always wins. What does
# not fit in the size limit is counted in extra_dropped.
extra_lines() {
    work="$(mktemp -d "${TMPDIR:-/tmp}/filature-report.XXXXXX")" || return 1
    LC_ALL=C tr -d '\000-\011\013-\037\177' < "$1" | LC_ALL=C tr '\200-\377' '?' |
        LC_ALL=C grep -E '^[a-z0-9][a-z0-9_.-]{0,63}=' > "$work/lines"
    sed 's/=.*//' "$work/lines" > "$work/keys"
    sed 's/^[^=]*=//' "$work/lines" | anonymize > "$work/values"
    LC_ALL=C awk -v values="$work/values" -v used="$(wc -c < "$2")" \
        -v reserve="$3" -v max=$MAX_REPORT -v line_max=$MAX_LINE '
        BEGIN {
            split("filature_report reprise_report tazhelper_version title " \
                  "description contact rescue install extra_dropped", r, " ")
            for (i in r) taken[r[i]] = 1
            room = max - reserve - 32
        }
        FNR == NR { k = $0; sub(/=.*/, "", k); taken[k] = 1; next }
        {
            getline v < values
            line = $0 "=" v
            if ($0 in taken || length(line) > line_max)
                next
            if (used + length(line) + 1 > room) {
                dropped++
                next
            }
            print line
            used += length(line) + 1
        }
        END { if (dropped) print "extra_dropped=" dropped }
    ' "$2" "$work/keys"
    rm -rf "$work"
}

# make [-k bug|reprise] [-t] [-x FILE] [TITLE [CONTACT]] < description:
# the report on stdout. -t adds the technical lines, -x FILE the
# key=value lines of FILE (reprise). A bug report needs a title and a
# description; the contact is what the user chose to give and is kept as
# typed, devs only see it. A reprise report is feedback on a repair: no
# title, no contact, the comment on stdin is optional.
make_report() {
    kind=bug tech="" extra=""
    while :; do
        case "$1" in
            -t) tech=yes ;;
            -k|-x)
                [ $# -ge 2 ] || { echo "filature-report: $1 needs a value" >&2; return 1; }
                case "$1" in -k) kind="$2" ;; -x) extra="$2" ;; esac
                shift ;;
            --) shift; break ;;
            *) break ;;
        esac
        shift
    done
    case "$kind" in
        bug|reprise) ;;
        *) echo "filature-report: unknown kind $kind" >&2; return 1 ;;
    esac
    if [ -n "$extra" ] && [ ! -r "$extra" ]; then
        echo "filature-report: cannot read $extra" >&2
        return 1
    fi
    desc=""
    contact=""
    if [ "$kind" = bug ]; then
        title="$(printf '%s' "$1" | clean_value)"
        [ -n "$title" ] || { echo "filature-report: a title is needed" >&2; return 1; }
        [ -n "$2" ] && contact="contact=$(printf '%s' "$2" | tr -d '\000-\037\177' | cut -c 1-200)"
    elif [ $# -gt 0 ]; then
        echo "filature-report: a reprise report has no title and no contact" >&2
        return 1
    fi
    # The GTK front end hands a text without a final newline: keep its
    # last line. A reprise report reads a comment only if one is piped.
    if [ "$kind" = bug ] || [ ! -t 0 ]; then
        while IFS= read -r line || [ -n "$line" ]; do
            desc="${desc}description=$(printf '%s' "$line" | clean_value)
"
        done
    fi
    if [ "$kind" = bug ] && [ -z "$desc" ]; then
        echo "filature-report: a description is needed" >&2
        return 1
    fi
    report="$(mktemp "${TMPDIR:-/tmp}/filature-report.XXXXXX")" || return 1
    {
        if [ "$kind" = bug ]; then
            echo "filature_report=$VERSION"
            echo "title=$title"
        else
            echo "reprise_report=$VERSION"
            id="$(install_id)" && echo "install=$id"
        fi
        printf '%s' "$desc"
        system_lines
        [ -n "$tech" ] && tech_lines
    } > "$report"
    cat "$report"
    if [ -n "$extra" ]; then
        reserve=0
        [ -n "$contact" ] && reserve=$(printf '%s\n' "$contact" | wc -c)
        extra_lines "$extra" "$report" $reserve
    fi
    rm -f "$report"
    [ -n "$contact" ] && echo "$contact"
    return 0
}

# known [FILE]: ask reprise, if installed, what it knows of this system.
# Known problems and their repair go to stdout for the user; with FILE,
# reprise's lines are written there for make -x. Never fails and never
# stops a report: a known fix may not work on this very machine.
known() {
    [ -n "$1" ] && : > "$1"
    command -v reprise > /dev/null 2>&1 || return 0
    echo "reprise checks the system, this can take a few seconds..." >&2
    lines="$(mktemp "${TMPDIR:-/tmp}/filature-report.XXXXXX")" || return 0
    if command -v timeout > /dev/null 2>&1; then
        timeout $REPRISE_TIMEOUT reprise --check > "$lines" 2> /dev/null
    else
        reprise --check > "$lines" 2> /dev/null
    fi
    case $? in
        0|1) ;;
        2) echo "reprise=broken" > "$lines" ;;
        *) echo "reprise=timeout" > "$lines" ;;
    esac
    LC_ALL=C tr -d '\000-\011\013-\037\177' < "$lines" | awk -v web="${URL%/report}" '
        { k = $0; sub(/=.*/, "", k); v = substr($0, length(k) + 2) }
        k ~ /^issue\./ && v == "matched" { ids[++n] = substr(k, 7); next }
        k ~ /^issue\./ { val[k] = v }
        END {
            for (i = 1; i <= n; i++) {
                h = val["issue." ids[i] ".hint"]
                b = val["issue." ids[i] ".bug"]
                print "Known problem: " (h != "" ? h : ids[i])
                if (b ~ /^[0-9]+$/)
                    print "  More: " web "/bug/" b
            }
            if (n)
                print "reprise can repair it: su -c '\''reprise --fix'\''"
        }'
    [ -n "$1" ] && cat "$lines" > "$1"
    rm -f "$lines"
    return 0
}

# send FILE: POST it, print the server answer, 0 if stored ("OK ...")
send_report() {
    file="$1"
    if [ "$file" = "-" ]; then
        file="$(mktemp "${TMPDIR:-/tmp}/filature-report.XXXXXX")" || return 1
        cat > "$file"
        trap 'rm -f "$file"' EXIT
    fi
    [ -s "$file" ] || { echo "filature-report: no report in $1" >&2; return 1; }
    answer="$(wget -q -T "$TIMEOUT" -O - --header 'Content-Type: text/plain' \
        --post-file "$file" "$URL" 2> /dev/null)"
    if [ -z "$answer" ]; then
        echo "filature-report: no answer from $URL" >&2
        return 1
    fi
    echo "$answer" | head -n 3
    case "$answer" in
        OK*) return 0 ;;
        *) return 1 ;;
    esac
}

# Interactive mode, plain prompts: works on a console and in a terminal
interactive() {
    echo "Report a bug to the SliTaz developers."
    echo "The report is anonymous unless you leave a contact: a mail address is"
    echo "seen by the developers only, a nick is credited on the public bug."
    echo "You will see all of it before anything is sent."
    echo
    printf 'Title (one line): '
    IFS= read -r title || return 1
    echo "Description, end with a line holding a single dot:"
    desc=""
    while IFS= read -r line; do
        [ "$line" = "." ] && break
        desc="$desc$line
"
    done
    checks="$(mktemp "${TMPDIR:-/tmp}/filature-report.XXXXXX")" || return 1
    known "$checks"
    printf 'Contact for questions (mail: devs only, nick: public credit, empty: anonymous): '
    IFS= read -r contact
    printf 'Add technical details (drivers, kernel, X and boot errors)? [Y/n] '
    IFS= read -r tech
    case "$tech" in n|N|no) tech="" ;; *) tech="-t" ;; esac
    report="$(mktemp "${TMPDIR:-/tmp}/filature-report.XXXXXX")" || return 1
    # reprise's checks go with the technical details
    if [ -n "$tech" ] && [ -s "$checks" ]; then
        printf '%s' "$desc" | make_report -t -x "$checks" "$title" "$contact" > "$report"
    else
        printf '%s' "$desc" | make_report $tech "$title" "$contact" > "$report"
    fi
    status=$?
    rm -f "$checks"
    if [ "$status" != 0 ]; then
        rm -f "$report"
        return 1
    fi
    echo
    echo "---- The report, exactly as it will be sent ----"
    cat "$report"
    echo "------------------------------------------------"
    printf 'Send it to %s? [y/N] ' "$URL"
    IFS= read -r yes
    case "$yes" in
        y|Y|yes) send_report "$report"; status=$? ;;
        *) echo "Not sent. The report is kept in $report"; return 1 ;;
    esac
    if [ "$status" = 0 ]; then
        rm -f "$report"
    else
        echo "Not sent. The report is kept in $report"
    fi
    return $status
}

usage() {
    sed -n '/^# Usage:/,/^# Config/p' "$0" | sed 's/^# \{0,1\}//'
}

names_sed
case "$1" in
    "") interactive ;;
    system) system_lines ;;
    tech) tech_lines ;;
    make) shift; make_report "$@" ;;
    known) known "$2" ;;
    new-id)
        install_id new || {
            echo "filature-report: cannot write $ROOT/$ID_FILE (root only)" >&2
            exit 1
        } ;;
    send) send_report "${2:--}" ;;
    help|-h|--help) usage ;;
    *) usage >&2; exit 1 ;;
esac
