My Purchases
+Purchases ({{len .Purchases}})
+No purchases yet. Upload a receipt to get started.
+Analyzing receipt...
+diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..027c53b --- /dev/null +++ b/.env.example @@ -0,0 +1,26 @@ +# NextReceipt Configuration +# Copy this file to .env and fill in your credentials. +# Run `bash install.sh` for interactive setup. + +# --- AI Provider --- +# Choose one: gemini (default), openai +AI_PROVIDER=gemini + +# For AI_PROVIDER=gemini: +# GEMINI_API_KEY=your-gemini-api-key + +# For AI_PROVIDER=openai (also works with Ollama, LocalAI, etc.): +# OPENAI_API_KEY=sk-... +# AI_MODEL=gpt-4o-mini +# AI_BASE_URL=https://api.openai.com/v1 +# For Ollama: AI_BASE_URL=http://localhost:11434, AI_MODEL=glm-ocr + +# --- SMTP (optional — needed for OTP emails) --- +# SMTP_HOST=smtp.example.com +# SMTP_PORT=587 +# SMTP_USER=your-email@example.com +# SMTP_PASS=your-password + +# --- General --- +PORT=8080 +BASE_URL=http://localhost:8080 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e147a5a --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# Binaries +app +expenseflow +*.exe + +# Database +*.db +*.db-journal +*.db-wal +*.db-shm + +# Environment +.env + +# Storage (uploaded receipt images) +storage/* + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Build output +dist/ +expenseflow-* + +# Go +vendor/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..03a2e6f --- /dev/null +++ b/Makefile @@ -0,0 +1,86 @@ +# NextReceipt — AI-Powered Receipt Saver +# Makefile for build, install, and deployment + +BINARY = app +SERVICE = nextreceipt +OUTDIR = dist +VERSION = v1.0.0 +LDFLAGS = -s -w + +.PHONY: all build clean install uninstall run stop restart logs + +all: build + +# ------------------------------------------------------------------- +# Build +# ------------------------------------------------------------------- + +build: + go build -ldflags="$(LDFLAGS)" -o $(BINARY) . + +build-linux-amd64: + GOOS=linux GOARCH=amd64 go build -ldflags="$(LDFLAGS)" -o $(OUTDIR)/$(BINARY)-linux-amd64 . + +build-linux-arm64: + GOOS=linux GOARCH=arm64 CGO_ENABLED=1 CC=aarch64-linux-gnu-gcc go build -ldflags="$(LDFLAGS)" -o $(OUTDIR)/$(BINARY)-linux-arm64 . + +build-all: build-linux-amd64 build-linux-arm64 + +# ------------------------------------------------------------------- +# Install (systemd service) +# ------------------------------------------------------------------- + +install: build + @echo "==> Installing NextReceipt..." + cp $(BINARY) /usr/local/bin/$(BINARY) + @if [ ! -f /etc/$(SERVICE)/.env ]; then \ + mkdir -p /etc/$(SERVICE); \ + cp .env.example /etc/$(SERVICE)/.env; \ + echo "==> Created /etc/$(SERVICE)/.env — edit it with your credentials"; \ + fi + @if [ ! -f /etc/systemd/system/$(SERVICE).service ]; then \ + cp contrib/$(SERVICE).service /etc/systemd/system/; \ + systemctl daemon-reload; \ + systemctl enable $(SERVICE); \ + echo "==> Systemd service installed"; \ + fi + @echo "==> Run 'systemctl start $(SERVICE)' to start" + @echo "==> Run 'systemctl status $(SERVICE)' to check status" + +uninstall: + -systemctl stop $(SERVICE) 2>/dev/null + -systemctl disable $(SERVICE) 2>/dev/null + -rm -f /etc/systemd/system/$(SERVICE).service + -systemctl daemon-reload + -rm -f /usr/local/bin/$(BINARY) + @echo "==> NextReceipt uninstalled" + +# ------------------------------------------------------------------- +# Service management +# ------------------------------------------------------------------- + +run: + ./$(BINARY) + +start: + systemctl start $(SERVICE) + +stop: + systemctl stop $(SERVICE) + +restart: + systemctl restart $(SERVICE) + +logs: + journalctl -u $(SERVICE) -f + +status: + systemctl status $(SERVICE) + +# ------------------------------------------------------------------- +# Clean +# ------------------------------------------------------------------- + +clean: + rm -f $(BINARY) + rm -rf $(OUTDIR) diff --git a/README.md b/README.md index 3ef2486..6c29919 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,56 @@ # NextReceipt -AI-powered receipt saver for warranty and return tracking \ No newline at end of file +**AI-Powered Receipt Saver for Warranty & Returns** + +Save receipts from private purchases to track warranty coverage and return windows. Upload your receipt, get AI-extracted data, and never lose track of your guarantees again. + +## Features + +- **Passwordless login** via email OTP +- **AI receipt extraction** (Gemini / OpenAI-compatible) +- **Warranty tracking** — save product name, store, price, warranty months, return window +- **Receipt image storage** with UUID filenames +- **Mobile-first PWA** — camera capture, installable, works offline +- **HTMX-powered UI** — no SPA framework, server-driven +- **Pure Go, no CGO** — single static binary + +## Quick Start + +```bash +cp .env.example .env +# Edit .env with your credentials +go run main.go +``` + +Open http://localhost:8080 + +## Tech Stack + +- **Go 1.23+** (pure Go, no CGO) +- **SQLite** via modernc.org/sqlite +- **HTMX 1.9.10** + vanilla CSS +- **chi v5** router + +## Configuration + +| Variable | Purpose | +|----------|---------| +| `AI_PROVIDER` | `gemini` or `openai` | +| `GEMINI_API_KEY` | Google Gemini API key | +| `OPENAI_API_KEY` | OpenAI-compatible API key | +| `AI_MODEL` | Model name (for OpenAI/Ollama) | +| `AI_BASE_URL` | API base URL | +| `SMTP_HOST/PORT/USER/PASS` | SMTP credentials (for OTP emails) | +| `PORT` | Web server port (default 8080) | + +## Install (systemd) + +```bash +bash install.sh +``` + +Updates: `bash install.sh -update` + +## License + +MIT diff --git a/build-all.sh b/build-all.sh new file mode 100755 index 0000000..9f11aca --- /dev/null +++ b/build-all.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# +# build-all.sh — Cross-compile NextReceipt for multiple platforms +# +# Prerequisites: +# linux/amd64: gcc (native) +# linux/arm64: gcc-aarch64-linux-gnu (sudo apt install gcc-aarch64-linux-gnu) +# darwin/amd64: o64-clang (via osxcross) +# darwin/arm64: aarch64-apple-darwin-clang (via osxcross) +# +# Usage: ./build-all.sh +# Output: ./dist/app-{platform} + +set -euo pipefail + +OUTDIR="dist" +mkdir -p "$OUTDIR" + +VERSION="${1:-v1.0.0}" +LDFLAGS="-s -w" + +echo "==> Building NextReceipt $VERSION" + +build() { + local GOOS="$1" GOARCH="$2" CC="$3" SUFFIX="$4" + local OUT="$OUTDIR/app-$SUFFIX" + echo " $SUFFIX ..." + GOOS="$GOOS" GOARCH="$GOARCH" CGO_ENABLED=1 CC="$CC" \ + go build -ldflags="$LDFLAGS" -o "$OUT" . + echo " => $(ls -lh "$OUT" | awk '{print $5}')" +} + +# linux/amd64 — native +build linux amd64 "gcc" "linux-amd64" + +# linux/arm64 — cross +if command -v aarch64-linux-gnu-gcc &>/dev/null; then + build linux arm64 "aarch64-linux-gnu-gcc" "linux-arm64" +fi + +# macOS builds — only if osxcross toolchain is available +if command -v o64-clang &>/dev/null; then + build darwin amd64 "o64-clang" "darwin-amd64" +fi +if command -v aarch64-apple-darwin-clang &>/dev/null; then + build darwin arm64 "aarch64-apple-darwin-clang" "darwin-arm64" +fi + +echo "==> All builds complete. Output in $OUTDIR/" +ls -lh "$OUTDIR/" diff --git a/contrib/nextreceipt.service b/contrib/nextreceipt.service new file mode 100644 index 0000000..9811f6c --- /dev/null +++ b/contrib/nextreceipt.service @@ -0,0 +1,25 @@ +[Unit] +Description=NextReceipt — AI-Powered Receipt Saver for Warranty & Returns +Documentation=https://git.lohmar.co.uk/cclohmar/NextReceipt +After=network.target + +[Service] +Type=simple +User=nextreceipt +Group=nextreceipt +WorkingDirectory=/opt/nextreceipt +ExecStart=/opt/nextreceipt/app +Restart=always +RestartSec=5 +EnvironmentFile=-/opt/nextreceipt/.env +StandardOutput=append:/var/log/nextreceipt.log +StandardError=append:/var/log/nextreceipt.log + +# Security hardening +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=full +ProtectHome=true + +[Install] +WantedBy=multi-user.target diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..30dad61 --- /dev/null +++ b/go.mod @@ -0,0 +1,23 @@ +module github.com/cclohmar/NextReceipt + +go 1.23.0 + +require ( + github.com/go-chi/chi/v5 v5.1.0 + github.com/google/uuid v1.6.0 + github.com/joho/godotenv v1.5.1 + golang.org/x/image v0.18.0 + modernc.org/sqlite v1.37.1 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect + golang.org/x/sys v0.33.0 // indirect + modernc.org/libc v1.65.7 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d953c7f --- /dev/null +++ b/go.sum @@ -0,0 +1,53 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= +github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= +golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= +golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ= +golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E= +golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= +golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= +golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= +golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= +modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s= +modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU= +modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE= +modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8= +modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/libc v1.65.7 h1:Ia9Z4yzZtWNtUIuiPuQ7Qf7kxYrxP1/jeHZzG8bFu00= +modernc.org/libc v1.65.7/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.37.1 h1:EgHJK/FPoqC+q2YBXg7fUmES37pCHFc97sI7zSayBEs= +modernc.org/sqlite v1.37.1/go.mod h1:XwdRtsE1MpiBcL54+MbKcaDvcuej+IYSMfLN6gSKV8g= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..2914a49 --- /dev/null +++ b/install.sh @@ -0,0 +1,444 @@ +#!/usr/bin/env bash +# +# NextReceipt Installer +# ==================== +# +# Usage: +# curl -fsSL https://git.lohmar.co.uk/cclohmar/NextReceipt/raw/branch/main/install.sh | bash +# bash install.sh — Full install +# bash install.sh -update — Pull latest, rebuild, restart +# +# Installs to /opt/nextreceipt/ and sets up a systemd service. +# Uses sudo internally only for operations that require it. +# + +set -euo pipefail + +INSTALL_DIR="/opt/nextreceipt" +SERVICE_NAME="nextreceipt" +REPO_URL="https://git.lohmar.co.uk/cclohmar/NextReceipt.git" + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +NC='\033[0m' + +info() { echo -e "${GREEN}[✓]${NC} $1"; } +warn() { echo -e "${YELLOW}[!]${NC} $1"; } +err() { echo -e "${RED}[✗]${NC} $1"; } +ask() { echo -en "${CYAN}→${NC} $1"; } + +# Helper: run a command with sudo if not already root +sudo_if() { + if [ "$EUID" -eq 0 ]; then + "$@" + else + sudo "$@" + fi +} + +# ────────────────────────────────────────────────────────────────── +# UPDATE MODE +# ────────────────────────────────────────────────────────────────── +if [ "${1:-}" = "-update" ]; then + echo "" + echo " ╔═══════════════════════════════════════╗" + echo " ║ NextReceipt — Update Mode ║" + echo " ╚═══════════════════════════════════════╝" + echo "" + + # Cache sudo credentials upfront so subsequent sudo_if calls don't prompt. + if [ "$EUID" -ne 0 ]; then + sudo -v 2>/dev/null || { err "This update requires sudo access. Please run: sudo ./install.sh -update"; exit 1; } + fi + + if [ ! -d "$INSTALL_DIR" ]; then + err "$INSTALL_DIR does not exist. Run install.sh without -update first." + exit 1 + fi + + cd "$INSTALL_DIR" + + if [ -d .git ]; then + info "Pulling latest code..." + sudo_if git pull + else + warn "Not a git repository — re-cloning..." + cd /tmp + rm -rf receiptnext-update + sudo_if git clone "$REPO_URL" receiptnext-update + sudo_if rsync -a --delete receiptnext-update/ "$INSTALL_DIR/" + rm -rf receiptnext-update + cd "$INSTALL_DIR" + fi + + # Determine the app user from the existing service file (most reliable). + APP_USER="" + if [ -f "/etc/systemd/system/${SERVICE_NAME}.service" ]; then + APP_USER=$(grep -Po '^User=\K.*' "/etc/systemd/system/${SERVICE_NAME}.service" 2>/dev/null || true) + fi + # Fallback: check the owner of the install directory or binary. + if [ -z "$APP_USER" ]; then + APP_USER=$(stat -c '%U' "$INSTALL_DIR/app" 2>/dev/null || stat -c '%U' "$INSTALL_DIR" 2>/dev/null || true) + fi + if [ -z "$APP_USER" ] || [ "$APP_USER" = "root" ]; then + APP_USER="${SUDO_USER:-$(whoami)}" + fi + # Reset ownership so the app user can write files. + sudo_if chown -R "${APP_USER}:${APP_USER}" "$INSTALL_DIR" 2>/dev/null || true + # Ensure key directories have correct permissions. + sudo_if chmod 755 "$INSTALL_DIR" "$INSTALL_DIR/templates" "$INSTALL_DIR/static" 2>/dev/null || true + sudo_if chmod 775 "$INSTALL_DIR/storage" 2>/dev/null || true + + # Backup the database before making any changes. + if [ -f "$INSTALL_DIR/nextreceipt.db" ]; then + BACKUP_DIR="$INSTALL_DIR/backups" + sudo_if mkdir -p "$BACKUP_DIR" + BACKUP_FILE="$BACKUP_DIR/nextreceipt-$(date +%Y%m%d-%H%M%S).db" + sudo_if cp "$INSTALL_DIR/nextreceipt.db" "$BACKUP_FILE" + # Restore file ownership so the app user can still write. + sudo_if chown $(stat -c "%U:%G" "$INSTALL_DIR/nextreceipt.db") "$BACKUP_FILE" 2>/dev/null || true + info "Database backed up to $BACKUP_FILE" + fi + + # Copy updated templates, static assets, and config files. + info "Updating templates and static assets..." + sudo_if rsync -a --delete templates/ "$INSTALL_DIR/templates/" 2>/dev/null || true + sudo_if rsync -a --delete static/ "$INSTALL_DIR/static/" 2>/dev/null || true + sudo_if cp install.sh "$INSTALL_DIR/" 2>/dev/null || true + + # Stop service before rebuilding so the old binary can be removed. + info "Stopping service..." + sudo_if systemctl stop "$SERVICE_NAME" 2>/dev/null || true + + # Ensure Go is installed for building from source. + if ! command -v go &>/dev/null; then + info "Installing Go..." + if command -v apt-get &>/dev/null; then + sudo_if apt-get update -qq && sudo_if apt-get install -y -qq golang-go 2>&1 | tail -1 + elif command -v dnf &>/dev/null; then + sudo_if dnf install -y golang 2>&1 | tail -1 + elif command -v apk &>/dev/null; then + sudo_if apk add go 2>&1 | tail -1 + else + err "No package manager found. Please install Go 1.23+ manually." + exit 1 + fi + fi + + info "Rebuilding binary..." + export GOMODCACHE="${INSTALL_DIR}/.go/mod" + export GOPATH="${INSTALL_DIR}/.go" + export GOCACHE="${INSTALL_DIR}/.go/build" + mkdir -p "${GOMODCACHE}" "${GOCACHE}" 2>/dev/null || sudo_if mkdir -p "${GOMODCACHE}" "${GOCACHE}" + sudo_if chown -R "${APP_USER}" "${INSTALL_DIR}/.go" "${INSTALL_DIR}/app" 2>/dev/null || true + sudo_if rm -f app + CGO_ENABLED=0 go build -buildvcs=false -ldflags="-s -w" -o app . + + # Fix ownership of all files (build cache may be root-owned from earlier builds). + sudo_if chown -R "${APP_USER}:${APP_USER}" "$INSTALL_DIR" 2>/dev/null || true + + info "Starting service..." + sudo_if systemctl start "$SERVICE_NAME" + sleep 2 + + if systemctl is-active --quiet "$SERVICE_NAME"; then + info "Update complete — ReceiptNext is running" + else + warn "Service did not start. Check: systemctl status $SERVICE_NAME" + fi + exit 0 +fi + +# ────────────────────────────────────────────────────────────────── +# FRESH INSTALL +# ────────────────────────────────────────────────────────────────── +echo "" +echo " ╔═══════════════════════════════════════╗" + echo " ║ NextReceipt Installer ║" +echo " ╚═══════════════════════════════════════╝" +echo "" + +# Cache sudo credentials upfront. +if [ "$EUID" -ne 0 ]; then + sudo -v 2>/dev/null || { err "This installer requires sudo access. Please run: sudo ./install.sh"; exit 1; } +fi + +# ── Install dependencies ─────────────────────────────────────────── +if ! command -v git &>/dev/null; then + info "Installing git..." + if command -v apt-get &>/dev/null; then + sudo_if apt-get update -qq && sudo_if apt-get install -y -qq git 2>&1 | tail -1 + elif command -v dnf &>/dev/null; then + sudo_if dnf install -y git 2>&1 | tail -1 + elif command -v apk &>/dev/null; then + sudo_if apk add git 2>&1 | tail -1 + else + err "Please install git manually, then re-run install.sh" + exit 1 + fi +fi +if ! command -v curl &>/dev/null; then + info "Installing curl..." + if command -v apt-get &>/dev/null; then + sudo_if apt-get install -y -qq curl 2>&1 | tail -1 + elif command -v dnf &>/dev/null; then + sudo_if dnf install -y curl 2>&1 | tail -1 + elif command -v apk &>/dev/null; then + sudo_if apk add curl 2>&1 | tail -1 + fi +fi +# python3 is used for parsing release JSON +if ! command -v python3 &>/dev/null; then + info "Installing python3..." + if command -v apt-get &>/dev/null; then + sudo_if apt-get install -y -qq python3 2>&1 | tail -1 + elif command -v dnf &>/dev/null; then + sudo_if dnf install -y python3 2>&1 | tail -1 + elif command -v apk &>/dev/null; then + sudo_if apk add python3 2>&1 | tail -1 + fi +fi + +# ── 1. Clone repo ──────────────────────────────────────────────── +if [ -d "$INSTALL_DIR" ]; then + warn "$INSTALL_DIR already exists — pulling latest..." + # Mark the repo as safe — it was created by root via sudo + sudo_if git config --global --add safe.directory "$INSTALL_DIR" 2>/dev/null || true + cd "$INSTALL_DIR" + sudo_if git pull +else + info "Cloning repository to $INSTALL_DIR..." + sudo_if git clone "$REPO_URL" "$INSTALL_DIR" + cd "$INSTALL_DIR" +fi +# Fix ownership so the build/pull steps can write as the invoking user +sudo_if chown -R "$(whoami):$(whoami)" "$INSTALL_DIR" 2>/dev/null || true + +# ── 2. Build or download binary ────────────────────────────────── +ARCH="$(uname -m)" +case "$ARCH" in + x86_64) ARCH="amd64" ;; + aarch64) ARCH="arm64" ;; + *) err "Unsupported architecture: $ARCH"; exit 1 ;; +esac + +# Ensure Go is installed for building from source. +if ! command -v go &>/dev/null; then + info "Installing Go..." + if command -v apt-get &>/dev/null; then + sudo_if apt-get update -qq && sudo_if apt-get install -y -qq golang-go 2>&1 | tail -1 + elif command -v dnf &>/dev/null; then + sudo_if dnf install -y golang 2>&1 | tail -1 + elif command -v apk &>/dev/null; then + sudo_if apk add go 2>&1 | tail -1 + else + err "No package manager found. Please install Go 1.23+ manually." + exit 1 + fi +fi + +info "Building binary from source (pure Go, no CGO)..." +export GOMODCACHE="${INSTALL_DIR}/.go/mod" +export GOPATH="${INSTALL_DIR}/.go" +export GOCACHE="${INSTALL_DIR}/.go/build" +mkdir -p "${GOMODCACHE}" "${GOCACHE}" 2>/dev/null || sudo_if mkdir -p "${GOMODCACHE}" "${GOCACHE}" +sudo_if chown -R "$(whoami):$(whoami)" "${INSTALL_DIR}/.go" "${INSTALL_DIR}/app" 2>/dev/null || true +sudo_if rm -f app +CGO_ENABLED=0 go build -buildvcs=false -ldflags="-s -w" -o app . +info "Binary ready: $INSTALL_DIR/app" + +# ── 3. Create runtime directories ──────────────────────────────── +sudo_if mkdir -p storage +sudo_if chmod 755 templates static storage + +# ── 4. AI Provider ─────────────────────────────────────────────── +echo "" +echo " ── AI Provider ──" +echo " Which AI should process receipt images?" +echo " 1) Google Gemini (cloud API, needs API key)" +echo " 2) OpenAI / Compatible (also works with Ollama, LocalAI, etc.)" +echo "" +ask " Choose [1-2] (default: 1): " +read -r AI_CHOICE +AI_CHOICE="${AI_CHOICE:-1}" + +case "$AI_CHOICE" in + 2) + AI_PROVIDER="openai" + ask " API key (or press Enter for Ollama): " + read -r OPENAI_API_KEY + ask " Model [gpt-4o-mini]: " + read -r AI_MODEL + AI_MODEL="${AI_MODEL:-gpt-4o-mini}" + ask " Base URL [https://api.openai.com/v1]: " + read -r AI_BASE_URL + AI_BASE_URL="${AI_BASE_URL:-https://api.openai.com/v1}" + ;; + *) + AI_PROVIDER="gemini" + ask " Gemini API key: " + read -r GEMINI_API_KEY + ;; +esac + +# ── 5. SMTP Configuration ──────────────────────────────────────── +echo "" +echo " ── Email (SMTP) ──" +echo " Required for sending OTP codes and expense reports." +echo " Leave blank to skip (OTP codes will be logged to console)." +echo "" +ask " SMTP host: " +read -r SMTP_HOST +if [ -n "$SMTP_HOST" ]; then + ask " SMTP port [587]: " + read -r SMTP_PORT + SMTP_PORT="${SMTP_PORT:-587}" + ask " SMTP user: " + read -r SMTP_USER + ask " SMTP password: " + read -r SMTP_PASS +fi + +# ── 6. General settings ────────────────────────────────────────── +echo "" +echo " ── General ──" +echo " If the app is behind a proxy (e.g. dev.lohmar.co.uk), enter the domain." +echo " Otherwise leave blank to use localhost." +echo "" +ask " Domain name [localhost]: " +read -r DOMAIN +DOMAIN="${DOMAIN:-localhost}" +ask " HTTPS? (y/N): " +read -r USE_HTTPS +if [[ "$USE_HTTPS" =~ ^[Yy]$ ]]; then + BASE_URL="https://${DOMAIN}" +else + BASE_URL="http://${DOMAIN}" +fi +ask " Web server port [8080]: " +read -r PORT +PORT="${PORT:-8080}" +if [ "$DOMAIN" = "localhost" ]; then + BASE_URL="http://localhost:${PORT}" +fi + +# ── 7. Write .env ──────────────────────────────────────────────── +info "Creating .env..." +sudo_if tee "$INSTALL_DIR/.env" > /dev/null << ENVEOF +# ReceiptNext Configuration +# Generated by install.sh on $(date) + +PORT=${PORT} +BASE_URL=${BASE_URL} +AI_PROVIDER=${AI_PROVIDER} +ENVEOF + +case "$AI_PROVIDER" in + openai) + sudo_if tee -a "$INSTALL_DIR/.env" > /dev/null << ENVEOF +OPENAI_API_KEY=${OPENAI_API_KEY} +AI_MODEL=${AI_MODEL} +AI_BASE_URL=${AI_BASE_URL} +ENVEOF + ;; + gemini) + sudo_if tee -a "$INSTALL_DIR/.env" > /dev/null << ENVEOF +GEMINI_API_KEY=${GEMINI_API_KEY} +ENVEOF + ;; +esac + +if [ -n "$SMTP_HOST" ]; then + sudo_if tee -a "$INSTALL_DIR/.env" > /dev/null << ENVEOF +SMTP_HOST=${SMTP_HOST} +SMTP_PORT=${SMTP_PORT} +SMTP_USER=${SMTP_USER} +SMTP_PASS=${SMTP_PASS} +ENVEOF +fi + +sudo_if chmod 600 "$INSTALL_DIR/.env" +info "Configuration saved to $INSTALL_DIR/.env" + +# ── 8. Determine app user ────────────────────────────────────────── +# Detect the real user (the one who invoked the script, not root via sudo) +APP_USER="${SUDO_USER:-$(whoami)}" +# If still root, create a dedicated system user +if [ "$APP_USER" = "root" ]; then + APP_USER="${SERVICE_NAME}" + if ! id -u "${APP_USER}" &>/dev/null 2>&1; then + info "Creating system user '${APP_USER}'..." + sudo_if useradd --system --no-create-home --home-dir "${INSTALL_DIR}" --shell /usr/sbin/nologin "${APP_USER}" + fi +else + info "Using existing user '${APP_USER}'" +fi + +# ── 9. Set ownership ─────────────────────────────────────────────── +sudo_if chown -R "${APP_USER}:${APP_USER}" "${INSTALL_DIR}" +sudo_if chmod 755 "${INSTALL_DIR}" "${INSTALL_DIR}/templates" "${INSTALL_DIR}/static" +# Storage needs write permission for uploaded receipts +sudo_if chmod 775 "${INSTALL_DIR}/storage" + +# ── 10. Systemd service ──────────────────────────────────────────── +info "Creating systemd service..." +sudo_if tee "/etc/systemd/system/${SERVICE_NAME}.service" > /dev/null << SERVEOF +[Unit] +Description=NextReceipt — AI-Powered Receipt Saver +Documentation=https://git.lohmar.co.uk/cclohmar/NextReceipt +After=network.target + +[Service] +Type=simple +User=${APP_USER} +Group=${APP_USER} +WorkingDirectory=${INSTALL_DIR} +ExecStart=${INSTALL_DIR}/app +Restart=always +RestartSec=5 +EnvironmentFile=-${INSTALL_DIR}/.env +StandardOutput=append:/var/log/nextreceipt.log +StandardError=append:/var/log/nextreceipt.log + +[Install] +WantedBy=multi-user.target +SERVEOF + +sudo_if systemctl daemon-reload +sudo_if systemctl enable "${SERVICE_NAME}" +info "Service created: /etc/systemd/system/${SERVICE_NAME}.service" + +# ── 9. Start ───────────────────────────────────────────────────── +info "Starting ReceiptNext..." +sudo_if systemctl restart "${SERVICE_NAME}" 2>/dev/null || true +sleep 2 + +echo "" +echo " ╔═══════════════════════════════════════╗" +echo " ║ Installation Complete! ║" +echo " ╚═══════════════════════════════════════╝" +echo "" +echo " Install: $INSTALL_DIR" +echo " Binary: $INSTALL_DIR/app" +echo " Config: $INSTALL_DIR/.env" +echo " Templates: $INSTALL_DIR/templates/" +echo " Static: $INSTALL_DIR/static/" +echo " Storage: $INSTALL_DIR/storage/" +echo " Service: systemctl status $SERVICE_NAME" +echo " Logs: journalctl -u $SERVICE_NAME -f" +echo "" + +if systemctl is-active --quiet "${SERVICE_NAME}"; then + info "NextReceipt is running on port ${PORT}" + echo " Open http://localhost:${PORT} (or your server IP)" +else + warn "Service did not start. Check: systemctl status ${SERVICE_NAME}" + journalctl -u "${SERVICE_NAME}" -n 20 --no-pager +fi + +echo "" +echo " ── Update ──" +echo " To update later: bash install.sh -update" +echo "" diff --git a/internal/ai/gemini.go b/internal/ai/gemini.go new file mode 100644 index 0000000..989505b --- /dev/null +++ b/internal/ai/gemini.go @@ -0,0 +1,151 @@ +package ai + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "os" + "strings" + "time" +) + +const geminiTimeout = 30 * time.Second + +type geminiRequest struct { + Contents []geminiContent `json:"contents"` +} + +type geminiContent struct { + Parts []geminiPart `json:"parts"` +} + +type geminiPart struct { + Text string `json:"text,omitempty"` + InlineData *geminiFileData `json:"inline_data,omitempty"` +} + +type geminiFileData struct { + MimeType string `json:"mime_type"` + Data string `json:"data"` +} + +type geminiResponse struct { + Candidates []geminiCandidate `json:"candidates"` + Error *struct { + Message string `json:"message"` + } `json:"error,omitempty"` +} + +type geminiCandidate struct { + Content geminiResponseContent `json:"content"` +} + +type geminiResponseContent struct { + Parts []struct { + Text string `json:"text"` + } `json:"parts"` +} + +type geminiProvider struct { + apiKey string + apiURL string +} + +func newGeminiProvider() geminiProvider { + apiKey := os.Getenv("GEMINI_API_KEY") + model := os.Getenv("GEMINI_MODEL") + if model == "" { + model = "gemini-3.1-flash-lite" + } + return geminiProvider{ + apiKey: apiKey, + apiURL: fmt.Sprintf("https://generativelanguage.googleapis.com/v1beta/models/%s:generateContent", model), + } +} + +func (p geminiProvider) ExtractReceipt(imagePath string) (*ReceiptData, error) { + if p.apiKey == "" { + return &ReceiptData{}, errors.New("GEMINI_API_KEY environment variable not set") + } + + imageData, err := readFile(imagePath) + if err != nil { + return &ReceiptData{}, fmt.Errorf("read file: %w", err) + } + + mimeType := detectMimeType(imageData) + if mimeType == "" { + mimeType = "image/jpeg" + } + + b64Data := base64.StdEncoding.EncodeToString(imageData) + + payload := geminiRequest{ + Contents: []geminiContent{{ + Parts: []geminiPart{ + {Text: "Analyze this receipt. Extract as strict JSON with keys: \"product_name\" (string, product or item name on the receipt), \"merchant\" (string, store name), \"amount\" (number), \"currency\" (3-letter code), \"category\" (string: Electronics/Furniture/Appliances/Clothing/Home/Other), \"date\" (YYYY-MM-DD purchase date), \"warranty_months\" (number, 0 if not visible), \"return_days\" (number, 0 if not visible). Return ONLY valid JSON. No markdown."}, + {InlineData: &geminiFileData{MimeType: mimeType, Data: b64Data}}, + }, + }}, + } + + body, err := json.Marshal(payload) + if err != nil { + return &ReceiptData{}, fmt.Errorf("marshal request: %w", err) + } + + req, err := http.NewRequest(http.MethodPost, p.apiURL, bytes.NewReader(body)) + if err != nil { + return &ReceiptData{}, fmt.Errorf("create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-goog-api-key", p.apiKey) + + client := &http.Client{Timeout: geminiTimeout} + resp, err := client.Do(req) + if err != nil { + return &ReceiptData{}, fmt.Errorf("API request: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return &ReceiptData{}, fmt.Errorf("read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return &ReceiptData{}, fmt.Errorf("Gemini status %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody))) + } + + var apiResp geminiResponse + if err := json.Unmarshal(respBody, &apiResp); err != nil { + return &ReceiptData{}, fmt.Errorf("parse response: %w", err) + } + + if apiResp.Error != nil { + return &ReceiptData{}, fmt.Errorf("Gemini error: %s", apiResp.Error.Message) + } + if len(apiResp.Candidates) == 0 { + return &ReceiptData{}, errors.New("no candidates in Gemini response") + } + + parts := apiResp.Candidates[0].Content.Parts + if len(parts) == 0 { + return &ReceiptData{}, errors.New("no response text from Gemini") + } + + contentStr := stripMarkdownFences(parts[0].Text) + var receipt ReceiptData + if err := json.Unmarshal([]byte(contentStr), &receipt); err != nil { + return &ReceiptData{}, fmt.Errorf("parse receipt JSON: %w (content: %s)", err, contentStr) + } + + log.Printf("ExtractReceipt [gemini]: merchant=%q amount=%.2f %s category=%q date=%q", + receipt.Merchant, receipt.Amount, receipt.Currency, receipt.Category, receipt.Date) + return &receipt, nil +} diff --git a/internal/ai/openai.go b/internal/ai/openai.go new file mode 100644 index 0000000..8b9a7fd --- /dev/null +++ b/internal/ai/openai.go @@ -0,0 +1,145 @@ +package ai + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "os" + "strings" + "time" +) + +const openaiTimeout = 30 * time.Second + +type openaiRequest struct { + Model string `json:"model"` + Messages []openaiMessage `json:"messages"` + Temperature float64 `json:"temperature"` +} + +type openaiMessage struct { + Role string `json:"role"` + Content []openaiContent `json:"content"` +} + +type openaiContent struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + ImageURL *openaiImage `json:"image_url,omitempty"` +} + +type openaiImage struct { + URL string `json:"url"` +} + +type openaiResponse struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` + Error *struct { + Message string `json:"message"` + } `json:"error,omitempty"` +} + +type openaiProvider struct { + apiKey string + model string + baseURL string +} + +func newOpenAIProvider() openaiProvider { + return openaiProvider{ + apiKey: os.Getenv("OPENAI_API_KEY"), + model: envOrDefault("AI_MODEL", "gpt-4o-mini"), + baseURL: strings.TrimRight(envOrDefault("AI_BASE_URL", "https://api.openai.com/v1"), "/"), + } +} + +func (p openaiProvider) ExtractReceipt(imagePath string) (*ReceiptData, error) { + if p.apiKey == "" { + return &ReceiptData{}, errors.New("OPENAI_API_KEY environment variable not set") + } + + imageData, err := readFile(imagePath) + if err != nil { + return &ReceiptData{}, fmt.Errorf("read file: %w", err) + } + + mimeType := detectMimeType(imageData) + if mimeType == "" { + mimeType = "image/jpeg" + } + + b64Data := base64.StdEncoding.EncodeToString(imageData) + dataURL := fmt.Sprintf("data:%s;base64,%s", mimeType, b64Data) + + payload := openaiRequest{ + Model: p.model, + Temperature: 0.1, + Messages: []openaiMessage{{ + Role: "user", + Content: []openaiContent{ + {Type: "text", Text: "Analyze this receipt image. Extract the following fields as a strict JSON object with these exact keys: \"product_name\" (string, product or item name), \"merchant\" (string, store name), \"amount\" (number, total paid), \"currency\" (string, 3-letter code like KES, USD, EUR), \"category\" (string, one of: Electronics, Furniture, Appliances, Clothing, Home, Other), \"date\" (string, YYYY-MM-DD), \"warranty_months\" (number, 0 if not visible), \"return_days\" (number, 0 if not visible). Return ONLY valid JSON. No markdown, no explanation, no code fences."}, + {Type: "image_url", ImageURL: &openaiImage{URL: dataURL}}, + }, + }}, + } + + body, err := json.Marshal(payload) + if err != nil { + return &ReceiptData{}, fmt.Errorf("marshal request: %w", err) + } + + apiURL := p.baseURL + "/chat/completions" + req, err := http.NewRequest(http.MethodPost, apiURL, bytes.NewReader(body)) + if err != nil { + return &ReceiptData{}, fmt.Errorf("create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+p.apiKey) + + client := &http.Client{Timeout: openaiTimeout} + resp, err := client.Do(req) + if err != nil { + return &ReceiptData{}, fmt.Errorf("API request: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return &ReceiptData{}, fmt.Errorf("read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return &ReceiptData{}, fmt.Errorf("API status %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody))) + } + + var apiResp openaiResponse + if err := json.Unmarshal(respBody, &apiResp); err != nil { + return &ReceiptData{}, fmt.Errorf("parse response: %w", err) + } + + if apiResp.Error != nil { + return &ReceiptData{}, fmt.Errorf("API error: %s", apiResp.Error.Message) + } + if len(apiResp.Choices) == 0 { + return &ReceiptData{}, errors.New("no response choices from API") + } + + contentStr := stripMarkdownFences(apiResp.Choices[0].Message.Content) + var receipt ReceiptData + if err := json.Unmarshal([]byte(contentStr), &receipt); err != nil { + return &ReceiptData{}, fmt.Errorf("parse receipt JSON: %w (content: %s)", err, contentStr) + } + + log.Printf("ExtractReceipt [openai-%s]: merchant=%q amount=%.2f %s", + p.model, receipt.Merchant, receipt.Amount, receipt.Currency) + return &receipt, nil +} diff --git a/internal/ai/receipt.go b/internal/ai/receipt.go new file mode 100644 index 0000000..6b222b7 --- /dev/null +++ b/internal/ai/receipt.go @@ -0,0 +1,141 @@ +// Package ai provides receipt data extraction from images/PDFs using +// configurable AI providers (Gemini or OpenAI-compatible). +// +// Provider selection is done via environment variables: +// +// AI_PROVIDER=gemini (default, uses GEMINI_API_KEY) +// AI_PROVIDER=openai (uses OPENAI_API_KEY, AI_MODEL, AI_BASE_URL) +// (also works with Ollama, LocalAI, etc.) +package ai + +import ( + "fmt" + "os" + "strings" +) + +// ReceiptData represents the structured data extracted from a receipt image. +type ReceiptData struct { + Amount float64 `json:"amount"` + Currency string `json:"currency"` + Merchant string `json:"merchant"` + ProductName string `json:"product_name"` + Category string `json:"category"` + Date string `json:"date"` + WarrantyMonths int `json:"warranty_months"` + ReturnDays int `json:"return_days"` +} + +// Provider is the interface that wraps receipt extraction. +// Each provider (Gemini, OpenAI, Ollama) implements this interface. +type Provider interface { + ExtractReceipt(imagePath string) (*ReceiptData, error) +} + +// ExtractReceipt dispatches to the configured AI provider. +// The provider is selected based on the AI_PROVIDER environment variable. +func ExtractReceipt(imagePath string) (*ReceiptData, error) { + provider := getProvider() + return provider.ExtractReceipt(imagePath) +} + +// getProvider returns the appropriate Provider based on environment config. +func getProvider() Provider { + providerName := strings.ToLower(strings.TrimSpace(os.Getenv("AI_PROVIDER"))) + + switch providerName { + case "openai": + return newOpenAIProvider() + default: + return newGeminiProvider() + } +} + +// envOrDefault returns the environment variable value or a default if unset. +func envOrDefault(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +// stripMarkdownFences removes markdown code fences from model output. +func stripMarkdownFences(s string) string { + s = strings.TrimSpace(s) + if strings.HasPrefix(s, "```") { + s = s[3:] + if idx := strings.Index(s, "\n"); idx != -1 { + s = s[idx+1:] + } + } + if strings.HasSuffix(s, "```") { + s = s[:len(s)-3] + } + return strings.TrimSpace(s) +} + +// readFile reads the full contents of a file from disk. +func readFile(path string) ([]byte, error) { + info, err := os.Stat(path) + if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("receipt file not found: %w", err) + } + return nil, fmt.Errorf("stat file %q: %w", path, err) + } + if info.IsDir() { + return nil, fmt.Errorf("readFile: %q is a directory, not a file", path) + } + if info.Size() > 10<<20 { + return nil, fmt.Errorf("readFile: %q exceeds 10 MB limit", path) + } + return os.ReadFile(path) +} + +// detectMimeType determines the MIME type from magic bytes. +func detectMimeType(data []byte) string { + if len(data) < 4 { + return "" + } + // JPEG + if data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF { + return "image/jpeg" + } + // PNG + if data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47 { + return "image/png" + } + // WebP + if len(data) >= 12 && data[0] == 0x52 && data[1] == 0x49 && data[2] == 0x46 && + data[3] == 0x46 && data[8] == 0x57 && data[9] == 0x45 && data[10] == 0x42 && data[11] == 0x50 { + return "image/webp" + } + // GIF + if data[0] == 0x47 && data[1] == 0x49 && data[2] == 0x46 { + return "image/gif" + } + // BMP + if data[0] == 0x42 && data[1] == 0x4D { + return "image/bmp" + } + // TIFF + if (data[0] == 0x49 && data[1] == 0x49 && data[2] == 0x2A && data[3] == 0x00) || + (data[0] == 0x4D && data[1] == 0x4D && data[2] == 0x00 && data[3] == 0x2A) { + return "image/tiff" + } + // PDF + if data[0] == 0x25 && data[1] == 0x50 && data[2] == 0x44 && data[3] == 0x46 { + return "application/pdf" + } + // HEIC/HEIF (ftyp box at offset 4) + if len(data) >= 12 && data[4] == 0x66 && data[5] == 0x74 && data[6] == 0x79 && data[7] == 0x70 { + brand := string(data[8:12]) + switch brand { + case "heic", "heix", "hevc", "hevx", "mif1", "msf1": + return "image/heic" + case "avif": + return "image/avif" + } + } + return "" +} diff --git a/internal/auth/otp.go b/internal/auth/otp.go new file mode 100644 index 0000000..e53dd43 --- /dev/null +++ b/internal/auth/otp.go @@ -0,0 +1,105 @@ +// Package auth provides authentication utilities including OTP generation and +// validation, as well as failure tracking for rate-limiting attempts. +package auth + +import ( + "crypto/rand" + "crypto/subtle" + "fmt" + "sync" + "time" +) + +// GenerateOTP generates a 6-digit numeric OTP code using crypto/rand. +// Each digit is derived by reading a random byte and computing modulo 10, +// producing a uniformly distributed digit 0-9. The result is zero-padded +// to always return exactly 6 characters. +func GenerateOTP() (string, error) { + bytes := make([]byte, 6) + if _, err := rand.Read(bytes); err != nil { + return "", fmt.Errorf("failed to generate OTP: %w", err) + } + + code := make([]byte, 6) + for i, b := range bytes { + code[i] = byte(b%10) + '0' + } + return string(code), nil +} + +// ValidateOTP validates a provided OTP against a stored code with an +// expiration check. Returns false if the current time is past expiresAt +// or if the codes do not match. +func ValidateOTP(provided, stored string, expiresAt time.Time) bool { + if time.Now().After(expiresAt) { + return false + } + // Use constant-time comparison to prevent timing side-channel attacks. + return subtle.ConstantTimeCompare([]byte(provided), []byte(stored)) == 1 +} + +// attemptData stores the failure count and timestamp for a single email. +type attemptData struct { + count int + lastAttempt time.Time +} + +// FailureTracker tracks consecutive failed OTP verification attempts per +// email, implementing a 3-attempt lockout window. +type FailureTracker struct { + mu sync.Mutex + attempts map[string]*attemptData +} + +// NewFailureTracker creates and returns a new FailureTracker with an empty +// attempts map. +func NewFailureTracker() *FailureTracker { + return &FailureTracker{ + attempts: make(map[string]*attemptData), + } +} + +// RecordFailure increments the failure count for the given email and records +// the current time as the last attempt. Once the count reaches 3, the email +// becomes locked out until the lockout window expires. +func (ft *FailureTracker) RecordFailure(email string) { + ft.mu.Lock() + defer ft.mu.Unlock() + + data, exists := ft.attempts[email] + if !exists { + data = &attemptData{} + ft.attempts[email] = data + } + data.count++ + data.lastAttempt = time.Now() +} + +// IsLockedOut returns true if the email has 3 or more recorded failures +// within the last 1 minute. Returns false if the email has no failures, +// fewer than 3 failures, or if the last failure was more than 1 minute ago. +func (ft *FailureTracker) IsLockedOut(email string) bool { + ft.mu.Lock() + defer ft.mu.Unlock() + + data, exists := ft.attempts[email] + if !exists { + return false + } + if data.count < 3 { + return false + } + if time.Since(data.lastAttempt) > time.Minute { + return false + } + return true +} + +// Reset clears the failure tracking data for the given email. This should +// be called upon successful OTP verification to allow fresh attempts. +func (ft *FailureTracker) Reset(email string) { + ft.mu.Lock() + defer ft.mu.Unlock() + + delete(ft.attempts, email) +} diff --git a/internal/auth/session.go b/internal/auth/session.go new file mode 100644 index 0000000..30bc49c --- /dev/null +++ b/internal/auth/session.go @@ -0,0 +1,100 @@ +// Package auth provides authentication and session management for ExpenseFlow. +package auth + +import ( + "crypto/rand" + "encoding/hex" + "log" + "sync" + "time" +) + +// sessionData represents the data stored for each session. +type sessionData struct { + userID string + expiresAt time.Time +} + +// SessionStore is an in-memory, thread-safe session store that maps +// session tokens to user sessions with expiration handling. +type SessionStore struct { + mu sync.RWMutex + sessions map[string]sessionData +} + +// NewSessionStore creates and returns a new empty SessionStore. +func NewSessionStore() *SessionStore { + return &SessionStore{ + sessions: make(map[string]sessionData), + } +} + +// Generate creates a new session for the given userID with a 24-hour TTL. +// It returns a cryptographically secure random hex-encoded token string. +func (s *SessionStore) Generate(userID string) (string, error) { + token, err := generateRandomToken() + if err != nil { + log.Printf("auth: failed to generate session token: %v", err) + return "", err + } + + s.mu.Lock() + s.sessions[token] = sessionData{ + userID: userID, + expiresAt: time.Now().Add(24 * time.Hour), + } + s.mu.Unlock() + + return token, nil +} + +// Get returns the userID associated with the given token if the session +// exists and has not expired. Returns "", false otherwise. +func (s *SessionStore) Get(token string) (string, bool) { + s.mu.RLock() + data, ok := s.sessions[token] + s.mu.RUnlock() + + if !ok { + return "", false + } + + if time.Now().After(data.expiresAt) { + // Session is expired; clean it up. + s.Delete(token) + return "", false + } + + return data.userID, true +} + +// Delete removes the session identified by the given token. +func (s *SessionStore) Delete(token string) { + s.mu.Lock() + delete(s.sessions, token) + s.mu.Unlock() +} + +// Cleanup removes all expired sessions from the store. This method is +// safe to call periodically from a background goroutine. +func (s *SessionStore) Cleanup() { + s.mu.Lock() + defer s.mu.Unlock() + + now := time.Now() + for token, data := range s.sessions { + if now.After(data.expiresAt) { + delete(s.sessions, token) + } + } +} + +// generateRandomToken creates a 32-byte cryptographically random token +// and returns its hex-encoded representation (64 hex characters). +func generateRandomToken() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} diff --git a/internal/database/db.go b/internal/database/db.go new file mode 100644 index 0000000..d3f0d48 --- /dev/null +++ b/internal/database/db.go @@ -0,0 +1,323 @@ +// Package database provides SQLite initialization and query helpers for NextReceipt. +// +// It auto-creates the database file and all required tables on startup, +// and exports a shared DB handle for use by other packages. +package database + +import ( + "database/sql" + "log" + "time" + + _ "modernc.org/sqlite" +) + +// DB is the shared database handle, initialized by Init(). +var DB *sql.DB + +// --------------------------------------------------------------------------- +// Struct types +// --------------------------------------------------------------------------- + +// User represents a row in the users table. +type User struct { + ID string + Email string + Onboarded bool + CreatedAt string +} + +// OTP represents a row in the auth_otps table. +type OTP struct { + Email string + OTPCode string + ExpiresAt string +} + +// Purchase represents a saved receipt / purchase for warranty and return tracking. +type Purchase struct { + ID string + UserID string + ProductName string + Store string + Category string + Amount float64 + Currency string + PurchaseDate string + WarrantyMonths int + ReturnDays int + Notes string + ImagePath string + CreatedAt string +} + +// --------------------------------------------------------------------------- +// Initialization +// --------------------------------------------------------------------------- + +// Init opens (or creates) nextreceipt.db, configures the connection pool for +// SQLite safety, and runs the DDL statements for all required tables. +// It also sets the package-level DB variable for shared use. +func Init() (*sql.DB, error) { + var err error + DB, err = sql.Open("sqlite", "nextreceipt.db") + if err != nil { + log.Printf("ERROR [%s] database: failed to open: %v", time.Now().Format(time.RFC3339), err) + return nil, err + } + + // modernc.org/sqlite supports concurrent reads. + // A small pool handles HTMX concurrent requests efficiently. + DB.SetMaxOpenConns(4) + DB.SetMaxIdleConns(2) + + if err = createTables(DB); err != nil { + log.Printf("ERROR [%s] database: table creation failed: %v", time.Now().Format(time.RFC3339), err) + return nil, err + } + + log.Printf("INFO [%s] database: initialized successfully", time.Now().Format(time.RFC3339)) + return DB, nil +} + +// createTables executes the DDL statements for all tables. +func createTables(db *sql.DB) error { + statements := []string{ + `CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + email TEXT UNIQUE NOT NULL, + onboarded INTEGER NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`, + `CREATE TABLE IF NOT EXISTS auth_otps ( + email TEXT PRIMARY KEY, + otp_code TEXT NOT NULL, + expires_at DATETIME NOT NULL + )`, + `CREATE TABLE IF NOT EXISTS purchases ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + product_name TEXT NOT NULL DEFAULT '', + store TEXT NOT NULL DEFAULT '', + category TEXT NOT NULL DEFAULT '', + amount REAL NOT NULL DEFAULT 0, + currency TEXT NOT NULL DEFAULT 'EUR', + purchase_date TEXT NOT NULL DEFAULT '', + warranty_months INTEGER NOT NULL DEFAULT 0, + return_days INTEGER NOT NULL DEFAULT 0, + notes TEXT, + image_path TEXT NOT NULL DEFAULT '', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(user_id) REFERENCES users(id) + )`, + } + + for _, stmt := range statements { + if _, err := db.Exec(stmt); err != nil { + return err + } + } + return nil +} + +// --------------------------------------------------------------------------- +// User queries +// --------------------------------------------------------------------------- + +// CreateUser inserts a new user row. +func CreateUser(db *sql.DB, id, email string) error { + _, err := db.Exec( + "INSERT INTO users (id, email) VALUES (?, ?)", + id, email, + ) + if err != nil { + log.Printf("ERROR [%s] database: CreateUser(%s, %s): %v", + time.Now().Format(time.RFC3339), id, email, err) + } + return err +} + +// GetUserByEmail returns the user with the given email, or nil if not found. +func GetUserByEmail(db *sql.DB, email string) (*User, error) { + row := db.QueryRow("SELECT id, email, onboarded, created_at FROM users WHERE email = ?", email) + u := &User{} + if err := row.Scan(&u.ID, &u.Email, &u.Onboarded, &u.CreatedAt); err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + log.Printf("ERROR [%s] database: GetUserByEmail(%s): %v", + time.Now().Format(time.RFC3339), email, err) + return nil, err + } + return u, nil +} + +// GetUserByID returns the user with the given ID, or nil if not found. +func GetUserByID(db *sql.DB, id string) (*User, error) { + row := db.QueryRow("SELECT id, email, onboarded, created_at FROM users WHERE id = ?", id) + u := &User{} + if err := row.Scan(&u.ID, &u.Email, &u.Onboarded, &u.CreatedAt); err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + log.Printf("ERROR [%s] database: GetUserByID(%s): %v", + time.Now().Format(time.RFC3339), id, err) + return nil, err + } + return u, nil +} + +// MarkUserOnboarded sets the onboarded flag for a user after their first purchase. +func MarkUserOnboarded(db *sql.DB, userID string) error { + _, err := db.Exec("UPDATE users SET onboarded = 1 WHERE id = ?", userID) + if err != nil { + log.Printf("ERROR [%s] database: MarkUserOnboarded(%s): %v", + time.Now().Format(time.RFC3339), userID, err) + } + return err +} + +// --------------------------------------------------------------------------- +// OTP queries +// --------------------------------------------------------------------------- + +// SaveOTP upserts an OTP record for the given email. +func SaveOTP(db *sql.DB, email, code, expiresAt string) error { + _, err := db.Exec( + `INSERT INTO auth_otps (email, otp_code, expires_at) + VALUES (?, ?, ?) + ON CONFLICT(email) DO UPDATE SET otp_code = excluded.otp_code, expires_at = excluded.expires_at`, + email, code, expiresAt, + ) + if err != nil { + log.Printf("ERROR [%s] database: SaveOTP(%s): %v", + time.Now().Format(time.RFC3339), email, err) + } + return err +} + +// GetOTP returns the OTP record for the given email, or nil if not found. +func GetOTP(db *sql.DB, email string) (*OTP, error) { + row := db.QueryRow("SELECT email, otp_code, expires_at FROM auth_otps WHERE email = ?", email) + o := &OTP{} + if err := row.Scan(&o.Email, &o.OTPCode, &o.ExpiresAt); err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + log.Printf("ERROR [%s] database: GetOTP(%s): %v", + time.Now().Format(time.RFC3339), email, err) + return nil, err + } + return o, nil +} + +// DeleteOTP removes the OTP record for the given email. +func DeleteOTP(db *sql.DB, email string) error { + _, err := db.Exec("DELETE FROM auth_otps WHERE email = ?", email) + if err != nil { + log.Printf("ERROR [%s] database: DeleteOTP(%s): %v", + time.Now().Format(time.RFC3339), email, err) + } + return err +} + +// --------------------------------------------------------------------------- +// Purchase queries +// --------------------------------------------------------------------------- + +// CreatePurchase inserts a new purchase row. +func CreatePurchase(db *sql.DB, p Purchase) error { + _, err := db.Exec( + `INSERT INTO purchases (id, user_id, product_name, store, category, amount, currency, + purchase_date, warranty_months, return_days, notes, image_path) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + p.ID, p.UserID, p.ProductName, p.Store, p.Category, + p.Amount, p.Currency, p.PurchaseDate, + p.WarrantyMonths, p.ReturnDays, p.Notes, p.ImagePath, + ) + if err != nil { + log.Printf("ERROR [%s] database: CreatePurchase(%s): %v", + time.Now().Format(time.RFC3339), p.ID, err) + } + return err +} + +// GetPurchasesByUser returns all purchases belonging to a user, ordered by +// purchase date descending (newest first). +func GetPurchasesByUser(db *sql.DB, userID string) ([]Purchase, error) { + rows, err := db.Query( + `SELECT id, user_id, product_name, store, category, amount, currency, + purchase_date, warranty_months, return_days, COALESCE(notes, ''), image_path, created_at + FROM purchases WHERE user_id = ? ORDER BY purchase_date DESC, created_at DESC`, + userID, + ) + if err != nil { + log.Printf("ERROR [%s] database: GetPurchasesByUser(%s): %v", + time.Now().Format(time.RFC3339), userID, err) + return nil, err + } + defer rows.Close() + + var purchases []Purchase + for rows.Next() { + var p Purchase + if err := rows.Scan( + &p.ID, &p.UserID, &p.ProductName, &p.Store, &p.Category, + &p.Amount, &p.Currency, &p.PurchaseDate, + &p.WarrantyMonths, &p.ReturnDays, &p.Notes, &p.ImagePath, &p.CreatedAt, + ); err != nil { + log.Printf("ERROR [%s] database: GetPurchasesByUser scan: %v", + time.Now().Format(time.RFC3339), err) + return nil, err + } + purchases = append(purchases, p) + } + return purchases, rows.Err() +} + +// GetPurchaseByID returns a single purchase by its ID, or nil if not found. +func GetPurchaseByID(db *sql.DB, id string) (*Purchase, error) { + row := db.QueryRow( + `SELECT id, user_id, product_name, store, category, amount, currency, + purchase_date, warranty_months, return_days, COALESCE(notes, ''), image_path, created_at + FROM purchases WHERE id = ?`, id) + p := &Purchase{} + if err := row.Scan( + &p.ID, &p.UserID, &p.ProductName, &p.Store, &p.Category, + &p.Amount, &p.Currency, &p.PurchaseDate, + &p.WarrantyMonths, &p.ReturnDays, &p.Notes, &p.ImagePath, &p.CreatedAt, + ); err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + log.Printf("ERROR [%s] database: GetPurchaseByID(%s): %v", + time.Now().Format(time.RFC3339), id, err) + return nil, err + } + return p, nil +} + +// UpdatePurchase updates all editable fields of an existing purchase. +func UpdatePurchase(db *sql.DB, p Purchase) error { + _, err := db.Exec( + `UPDATE purchases SET product_name=?, store=?, category=?, amount=?, currency=?, + purchase_date=?, warranty_months=?, return_days=?, notes=? WHERE id=?`, + p.ProductName, p.Store, p.Category, p.Amount, p.Currency, + p.PurchaseDate, p.WarrantyMonths, p.ReturnDays, p.Notes, p.ID, + ) + if err != nil { + log.Printf("ERROR [%s] database: UpdatePurchase(%s): %v", + time.Now().Format(time.RFC3339), p.ID, err) + } + return err +} + +// DeletePurchase removes a single purchase by its ID. +func DeletePurchase(db *sql.DB, id string) error { + _, err := db.Exec("DELETE FROM purchases WHERE id = ?", id) + if err != nil { + log.Printf("ERROR [%s] database: DeletePurchase(%s): %v", + time.Now().Format(time.RFC3339), id, err) + } + return err +} diff --git a/internal/email/smtp.go b/internal/email/smtp.go new file mode 100644 index 0000000..fe1da7c --- /dev/null +++ b/internal/email/smtp.go @@ -0,0 +1,285 @@ +// Package email provides SMTP email sending for NextReceipt, including OTP +// verification codes. +// +// Credentials are passed via the constructor; the caller is responsible for +// loading them from environment variables (e.g., SMTP_HOST, SMTP_PORT, etc.). +package email + +import ( + "crypto/tls" + "encoding/base64" + "fmt" + "log" + "mime" + "mime/multipart" + "net" + "net/smtp" + "net/textproto" + "strings" + "time" +) + +// --------------------------------------------------------------------------- +// Struct types +// --------------------------------------------------------------------------- + +// Attachment holds a file to be attached to an outgoing email. +type Attachment struct { + Filename string + Content []byte +} + +// Sender encapsulates SMTP server configuration and provides methods for +// sending transactional emails (e.g., OTP codes, expense reports). +type Sender struct { + host string + port string + user string + pass string + from string +} + +// --------------------------------------------------------------------------- +// Constructor +// --------------------------------------------------------------------------- + +// NewSender creates a new Sender with the given SMTP credentials. The caller +// should load host, port, user, pass, and from from environment variables. +func NewSender(host, port, user, pass, from string) *Sender { + return &Sender{ + host: host, + port: port, + user: user, + pass: pass, + from: from, + } +} + +// --------------------------------------------------------------------------- +// SMTP methods +// --------------------------------------------------------------------------- + +// SendOTP sends a plain-text OTP verification email to the given recipient. +// The email contains a standard subject line and the 6-digit verification code. +func (s *Sender) SendOTP(to, code string) error { + subject := "Your NextReceipt OTP" + body := fmt.Sprintf("Your verification code is: %s", code) + + msg := buildPlainMessage(s.from, to, subject, body) + + if err := s.send(to, msg); err != nil { + log.Printf("ERROR [%s] email: SendOTP(%s): %v", + time.Now().Format(time.RFC3339), to, err) + return err + } + + log.Printf("INFO [%s] email: OTP code %s sent to %s", time.Now().Format(time.RFC3339), code, to) + return nil +} + +// SendReport sends an email with the given subject and body, attaching one or +// more files (report CSV/PDF + ZIP of receipt images). +func (s *Sender) SendReport(to, subject, body string, attachments []*Attachment) error { + msg, err := buildMultipartMessage(s.from, to, subject, body, attachments) + if err != nil { + log.Printf("ERROR [%s] email: SendReport(%s): build failed: %v", + time.Now().Format(time.RFC3339), to, err) + return err + } + + if err := s.send(to, msg); err != nil { + log.Printf("ERROR [%s] email: SendReport(%s): %v", + time.Now().Format(time.RFC3339), to, err) + return err + } + + names := make([]string, len(attachments)) + for i, a := range attachments { + names[i] = a.Filename + } + log.Printf("INFO [%s] email: report sent to %s (%s)", + time.Now().Format(time.RFC3339), to, strings.Join(names, ", ")) + return nil +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +// send performs the actual SMTP delivery: connects to the server, upgrades +// to TLS (STARTTLS on port 587, direct TLS on port 465), authenticates, and +// transmits the message. +func (s *Sender) send(to string, msg []byte) error { + addr := net.JoinHostPort(s.host, s.port) + auth := smtp.PlainAuth("", s.user, s.pass, s.host) + + // Use direct TLS for port 465 (SMTPS), STARTTLS for all other ports. + if s.port == "465" { + return s.sendTLS(addr, auth, to, msg) + } + + return smtp.SendMail(addr, auth, s.from, []string{to}, msg) +} + +// sendTLS dials the SMTP server over an explicit TLS connection (port 465) +// and sends the message. This is required for SMTPS where the connection is +// TLS-secured from the start, rather than upgraded via STARTTLS. +func (s *Sender) sendTLS(addr string, auth smtp.Auth, to string, msg []byte) error { + tlsConfig := &tls.Config{ + ServerName: s.host, + } + + conn, err := tls.Dial("tcp", addr, tlsConfig) + if err != nil { + return fmt.Errorf("TLS dial failed: %w", err) + } + defer conn.Close() + + client, err := smtp.NewClient(conn, s.host) + if err != nil { + return fmt.Errorf("SMTP client creation failed: %w", err) + } + defer client.Close() + + if err = client.Auth(auth); err != nil { + return fmt.Errorf("SMTP auth failed: %w", err) + } + + if err = client.Mail(s.from); err != nil { + return fmt.Errorf("SMTP MAIL FROM failed: %w", err) + } + + if err = client.Rcpt(to); err != nil { + return fmt.Errorf("SMTP RCPT TO failed: %w", err) + } + + w, err := client.Data() + if err != nil { + return fmt.Errorf("SMTP DATA failed: %w", err) + } + + if _, err = w.Write(msg); err != nil { + return fmt.Errorf("SMTP write failed: %w", err) + } + + if err = w.Close(); err != nil { + return fmt.Errorf("SMTP data close failed: %w", err) + } + + return client.Quit() +} + +// buildPlainMessage constructs a simple RFC 5322 plain-text email without +// any MIME encoding. +func buildPlainMessage(from, to, subject, body string) []byte { + var b strings.Builder + + writeHeader(&b, "From", from) + writeHeader(&b, "To", to) + writeHeader(&b, "Subject", subject) + writeHeader(&b, "MIME-Version", "1.0") + writeHeader(&b, "Content-Type", "text/plain; charset=\"utf-8\"") + b.WriteString("\r\n") + b.WriteString(body) + + return []byte(b.String()) +} + +// buildMultipartMessage constructs an RFC 2046 multipart/mixed email with a +// text/plain body and a single attachment encoded as base64. +func buildMultipartMessage(from, to, subject, body string, attachments []*Attachment) ([]byte, error) { + var b strings.Builder + + // Write the main SMTP headers with deliverability improvements. + writeHeader(&b, "From", from) + writeHeader(&b, "To", to) + writeHeader(&b, "Subject", subject) + writeHeader(&b, "Message-ID", fmt.Sprintf("<%d.receiptnext@post.2-4-h.app>", time.Now().UnixNano())) + writeHeader(&b, "Date", time.Now().Format(time.RFC1123Z)) + + // Create a multipart writer using a unique boundary string. + mw := multipart.NewWriter(&b) + boundary := mw.Boundary() + + writeHeader(&b, "MIME-Version", "1.0") + writeHeader(&b, "Content-Type", fmt.Sprintf("multipart/mixed; boundary=%s", boundary)) + b.WriteString("\r\n") + + // --- Text part --- + tw, err := mw.CreatePart(textHeader()) + if err != nil { + return nil, fmt.Errorf("creating text part: %w", err) + } + if _, err := tw.Write([]byte(body)); err != nil { + return nil, fmt.Errorf("writing text part: %w", err) + } + + // --- Attachment parts (report + receipt images zip) --- + for _, att := range attachments { + aw, err := mw.CreatePart(attachmentHeader(att.Filename)) + if err != nil { + return nil, fmt.Errorf("creating attachment part %q: %w", att.Filename, err) + } + + enc := base64.NewEncoder(base64.StdEncoding, aw) + if _, err := enc.Write(att.Content); err != nil { + enc.Close() + return nil, fmt.Errorf("writing attachment %q: %w", att.Filename, err) + } + enc.Close() + } + + mw.Close() + + return []byte(b.String()), nil +} + +// --------------------------------------------------------------------------- +// MIME header helpers +// --------------------------------------------------------------------------- + +// writeHeader writes a single SMTP/MIME header line (field: value) followed +// by CRLF into the provided strings.Builder. +func writeHeader(b *strings.Builder, field, value string) { + b.WriteString(field) + b.WriteString(": ") + b.WriteString(value) + b.WriteString("\r\n") +} + +// textHeader returns the MIME header fields for a text/plain part. +func textHeader() textproto.MIMEHeader { + return textproto.MIMEHeader{ + "Content-Type": {"text/plain; charset=\"utf-8\""}, + } +} + +// attachmentHeader returns the MIME header fields for an attachment part, +// inferring Content-Type from the file extension and setting the required +// Content-Disposition and Content-Transfer-Encoding headers. +func attachmentHeader(filename string) textproto.MIMEHeader { + contentType := attachmentContentType(filename) + + // Encode the filename to handle non-ASCII characters. + encodedFilename := mime.QEncoding.Encode("utf-8", filename) + + return textproto.MIMEHeader{ + "Content-Type": {contentType}, + "Content-Disposition": {fmt.Sprintf(`attachment; filename="%s"`, encodedFilename)}, + "Content-Transfer-Encoding": {"base64"}, + } +} + +// attachmentContentType returns the MIME Content-Type for an attachment based +// on its file extension. Defaults to application/octet-stream for unknown types. +func attachmentContentType(filename string) string { + switch { + case strings.HasSuffix(strings.ToLower(filename), ".csv"): + // Some providers block text/csv; use text/plain as fallback. + return "text/plain; charset=\"utf-8\"" + case strings.HasSuffix(strings.ToLower(filename), ".pdf"): + return "application/pdf" + default: + return "application/octet-stream" + } +} diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go new file mode 100644 index 0000000..845a741 --- /dev/null +++ b/internal/handlers/auth.go @@ -0,0 +1,283 @@ +// Package handlers implements HTTP handlers for NextReceipt, providing +// passwordless email OTP authentication and purchase management. +package handlers + +import ( + "database/sql" + "fmt" + "html/template" + "log" + "net/http" + "os" + "strings" + "sync" + "time" + + "github.com/cclohmar/NextReceipt/internal/auth" + "github.com/cclohmar/NextReceipt/internal/database" + "github.com/cclohmar/NextReceipt/internal/email" + "github.com/cclohmar/NextReceipt/internal/utils" +) + +// --------------------------------------------------------------------------- +// AuthHandler +// --------------------------------------------------------------------------- + +// AuthHandler handles passwordless email OTP authentication endpoints. +type AuthHandler struct { + DB *sql.DB + Sessions *auth.SessionStore + FailureTracker *auth.FailureTracker + EmailSender *email.Sender + + otpMu sync.Mutex // prevents OTP reuse via race conditions +} + +// --------------------------------------------------------------------------- +// Handlers +// --------------------------------------------------------------------------- + +// LandingPage renders the landing page with the email input form for OTP login. +func (h *AuthHandler) LandingPage(w http.ResponseWriter, r *http.Request) { + // If the user already has a valid session, redirect to the dashboard. + if cookie, err := r.Cookie("session_token"); err == nil && cookie.Value != "" { + if _, ok := h.Sessions.Get(cookie.Value); ok { + w.Header().Set("HX-Redirect", "/dashboard") + w.WriteHeader(http.StatusOK) + return + } + } + + tmpl := getTemplate("index.html") + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := tmpl.Execute(w, nil); err != nil { + log.Printf("ERROR [%s] handlers: LandingPage execute template: %v", time.Now().Format(time.RFC3339), err) + } +} + +// RequestOTP handles OTP generation and email delivery. +func (h *AuthHandler) RequestOTP(w http.ResponseWriter, r *http.Request) { + emailAddr := strings.TrimSpace(r.FormValue("email")) + if emailAddr == "" { + renderError(w, "Email is required.") + return + } + + // Rate-limit check: 3 failed attempts trigger a 1-minute lockout. + if h.FailureTracker.IsLockedOut(emailAddr) { + renderError(w, "Too many attempts. Please wait 1 minute before trying again.") + return + } + + // Retrieve or create the user. + user, err := database.GetUserByEmail(h.DB, emailAddr) + if err != nil { + log.Printf("ERROR [%s] handlers: RequestOTP GetUserByEmail(%s): %v", time.Now().Format(time.RFC3339), emailAddr, err) + renderError(w, "An error occurred. Please try again.") + return + } + if user == nil { + userID := utils.NewUUID() + if err := database.CreateUser(h.DB, userID, emailAddr); err != nil { + log.Printf("ERROR [%s] handlers: RequestOTP CreateUser(%s): %v", time.Now().Format(time.RFC3339), emailAddr, err) + renderError(w, "An error occurred. Please try again.") + return + } + user = &database.User{ID: userID, Email: emailAddr} + } + + // Generate a cryptographically secure 6-digit OTP. + code, err := auth.GenerateOTP() + if err != nil { + log.Printf("ERROR [%s] handlers: RequestOTP GenerateOTP: %v", time.Now().Format(time.RFC3339), err) + renderError(w, "An error occurred. Please try again.") + return + } + + // Persist the OTP with a 5-minute expiry. + expiresAt := time.Now().Add(5 * time.Minute) + if err := database.SaveOTP(h.DB, emailAddr, code, expiresAt.Format(time.RFC3339)); err != nil { + log.Printf("ERROR [%s] handlers: RequestOTP SaveOTP(%s): %v", time.Now().Format(time.RFC3339), emailAddr, err) + renderError(w, "An error occurred. Please try again.") + return + } + + // Deliver OTP via email. Log the error but do not fail the request. + if h.EmailSender != nil { + if err := h.EmailSender.SendOTP(emailAddr, code); err != nil { + log.Printf("ERROR [%s] handlers: RequestOTP SendOTP(%s): %v", time.Now().Format(time.RFC3339), emailAddr, err) + } + } else { + log.Printf("WARN [%s] handlers: RequestOTP(%s): SMTP not configured — OTP code %s not delivered via email", + time.Now().Format(time.RFC3339), emailAddr, code) + } + + // Render the OTP verification form as an HTMX fragment. + renderOTPForm(w, emailAddr, "") +} + +// VerifyOTP handles OTP code verification and session creation. +func (h *AuthHandler) VerifyOTP(w http.ResponseWriter, r *http.Request) { + emailAddr := strings.TrimSpace(r.FormValue("email")) + otpCode := collectOTP(r) + + if emailAddr == "" || otpCode == "" { + renderOTPForm(w, emailAddr, "Email and OTP code are required.") + return + } + + // Fetch the stored OTP record. + stored, err := database.GetOTP(h.DB, emailAddr) + if err != nil { + log.Printf("ERROR [%s] handlers: VerifyOTP GetOTP(%s): %v", time.Now().Format(time.RFC3339), emailAddr, err) + renderOTPForm(w, emailAddr, "An error occurred. Please try again.") + return + } + if stored == nil { + renderOTPForm(w, emailAddr, "No OTP found for this email. Please request a new code.") + return + } + + // Parse the stored expiry timestamp. + expiresAt, err := time.Parse(time.RFC3339, stored.ExpiresAt) + if err != nil { + log.Printf("ERROR [%s] handlers: VerifyOTP parse expiry(%s): %v", time.Now().Format(time.RFC3339), stored.ExpiresAt, err) + renderOTPForm(w, emailAddr, "An error occurred. Please try again.") + return + } + + // Validate + delete OTP atomically to prevent race-condition reuse. + h.otpMu.Lock() + if !auth.ValidateOTP(otpCode, stored.OTPCode, expiresAt) { + h.otpMu.Unlock() + h.FailureTracker.RecordFailure(emailAddr) + renderOTPForm(w, emailAddr, "Invalid or expired OTP code. Please try again.") + return + } + + // Successful verification: delete OTP immediately. + h.FailureTracker.Reset(emailAddr) + if err := database.DeleteOTP(h.DB, emailAddr); err != nil { + log.Printf("ERROR [%s] handlers: VerifyOTP DeleteOTP(%s): %v", time.Now().Format(time.RFC3339), emailAddr, err) + } + h.otpMu.Unlock() + + // Retrieve the user record to obtain the user ID. + user, err := database.GetUserByEmail(h.DB, emailAddr) + if err != nil || user == nil { + log.Printf("ERROR [%s] handlers: VerifyOTP GetUserByEmail(%s): err=%v", time.Now().Format(time.RFC3339), emailAddr, err) + renderOTPForm(w, emailAddr, "An error occurred. Please try again.") + return + } + + // Generate an in-memory session token. + token, err := h.Sessions.Generate(user.ID) + if err != nil { + log.Printf("ERROR [%s] handlers: VerifyOTP Session Generate(%s): %v", time.Now().Format(time.RFC3339), user.ID, err) + renderOTPForm(w, emailAddr, "An error occurred. Please try again.") + return + } + + // Set the session cookie (HttpOnly, SameSite=Lax, Secure, 24h). + secure := strings.HasPrefix(os.Getenv("BASE_URL"), "https://") + http.SetCookie(w, &http.Cookie{ + Name: "session_token", + Value: token, + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Secure: secure, + Expires: time.Now().Add(24 * time.Hour), + }) + + // Always redirect to dashboard (no onboarding step needed). + w.Header().Set("HX-Redirect", "/dashboard") + w.WriteHeader(http.StatusOK) +} + +// --------------------------------------------------------------------------- +// Middleware +// --------------------------------------------------------------------------- + +// RequireAuth is HTTP middleware that validates the session cookie on protected +// routes. If the session is invalid or expired it redirects to the landing page. +func (h *AuthHandler) RequireAuth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie("session_token") + if err != nil { + w.Header().Set("HX-Redirect", "/") + w.WriteHeader(http.StatusUnauthorized) + return + } + + userID, ok := h.Sessions.Get(cookie.Value) + if !ok { + w.Header().Set("HX-Redirect", "/") + w.WriteHeader(http.StatusUnauthorized) + return + } + + r.Header.Set("X-User-ID", userID) + next.ServeHTTP(w, r) + }) +} + +// getUserID returns the authenticated user ID from the request. +func getUserID(r *http.Request) string { + return r.Header.Get("X-User-ID") +} + +// --------------------------------------------------------------------------- +// Logout +// --------------------------------------------------------------------------- + +// Logout clears the session cookie and invalidates the server-side session. +func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) { + if cookie, err := r.Cookie("session_token"); err == nil && cookie.Value != "" { + h.Sessions.Delete(cookie.Value) + } + http.SetCookie(w, &http.Cookie{ + Name: "session_token", + Value: "", + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + MaxAge: -1, + }) + w.Header().Set("HX-Redirect", "/") + w.WriteHeader(http.StatusOK) +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +// renderError writes an HTMX-compatible HTML error fragment to the response. +func renderError(w http.ResponseWriter, message string) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprintf(w, `
`, template.HTMLEscapeString(message)) +} + +// renderOTPForm writes the OTP verification form partial as an HTMX fragment. +func renderOTPForm(w http.ResponseWriter, email string, errMsg string) { + tmpl := getTemplate("otp_form") + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := tmpl.Execute(w, map[string]string{"Email": email, "Error": errMsg}); err != nil { + log.Printf("ERROR [%s] handlers: renderOTPForm execute: %v", time.Now().Format(time.RFC3339), err) + } +} + +// collectOTP reads the 6-digit OTP code from the form value. +func collectOTP(r *http.Request) string { + code := r.FormValue("otp_code") + code = strings.Map(func(r rune) rune { + if r >= '0' && r <= '9' { + return r + } + return -1 + }, code) + if len(code) != 6 { + return "" + } + return code +} diff --git a/internal/handlers/dashboard.go b/internal/handlers/dashboard.go new file mode 100644 index 0000000..6b00d33 --- /dev/null +++ b/internal/handlers/dashboard.go @@ -0,0 +1,68 @@ +// Package handlers provides HTTP request handlers for NextReceipt. +// +// This file implements the dashboard handler showing all purchases +// for the authenticated user. +package handlers + +import ( + "database/sql" + "log" + "net/http" + "time" + + "github.com/cclohmar/NextReceipt/internal/database" +) + +// --------------------------------------------------------------------------- +// DashboardHandler +// --------------------------------------------------------------------------- + +// DashboardHandler groups HTTP handlers related to the main dashboard view. +type DashboardHandler struct { + DB *sql.DB +} + +// NewDashboardHandler creates a new DashboardHandler with the given database handle. +func NewDashboardHandler(db *sql.DB) *DashboardHandler { + return &DashboardHandler{DB: db} +} + +// --------------------------------------------------------------------------- +// GET /dashboard — Dashboard +// --------------------------------------------------------------------------- + +// Dashboard renders the main dashboard page showing all purchases belonging +// to the authenticated user, along with the receipt upload controls. +func (h *DashboardHandler) Dashboard(w http.ResponseWriter, r *http.Request) { + userID := getUserID(r) + if userID == "" { + log.Printf("ERROR [%s] handlers: Dashboard: missing user ID", time.Now().Format(time.RFC3339)) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + purchases, err := database.GetPurchasesByUser(h.DB, userID) + if err != nil { + log.Printf("ERROR [%s] handlers: Dashboard: GetPurchasesByUser: %v", + time.Now().Format(time.RFC3339), err) + http.Error(w, "Failed to load purchases", http.StatusInternalServerError) + return + } + + // Normalize ImagePath for all purchases. + for i := range purchases { + purchases[i].ImagePath = normalizeImagePath(purchases[i].ImagePath) + } + + tmpl := getTemplate("dashboard.html") + + data := map[string]interface{}{ + "Purchases": purchases, + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := tmpl.Execute(w, data); err != nil { + log.Printf("ERROR [%s] handlers: Dashboard: execute template: %v", + time.Now().Format(time.RFC3339), err) + } +} diff --git a/internal/handlers/helpers.go b/internal/handlers/helpers.go new file mode 100644 index 0000000..18d0e9a --- /dev/null +++ b/internal/handlers/helpers.go @@ -0,0 +1,10 @@ +package handlers + +import "strings" + +// normalizeImagePath strips a legacy "storage/" prefix if present, so that +// the template can safely build "/storage/{filename}" URLs regardless of +// whether the database entry was stored as "uuid.jpg" or "storage/uuid.jpg". +func normalizeImagePath(path string) string { + return strings.TrimPrefix(path, "storage/") +} diff --git a/internal/handlers/purchases.go b/internal/handlers/purchases.go new file mode 100644 index 0000000..0619df1 --- /dev/null +++ b/internal/handlers/purchases.go @@ -0,0 +1,669 @@ +// Package handlers provides HTTP request handlers for NextReceipt. +// +// This file implements purchase upload, AI extraction, and save handlers +// using HTMX partial responses. +package handlers + +import ( + "bytes" + "database/sql" + "fmt" + "html/template" + "image" + "image/jpeg" + "io" + "log" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "golang.org/x/image/draw" + + "github.com/cclohmar/NextReceipt/internal/ai" + "github.com/cclohmar/NextReceipt/internal/database" + "github.com/cclohmar/NextReceipt/internal/utils" +) + +// --------------------------------------------------------------------------- +// PurchaseHandler +// --------------------------------------------------------------------------- + +// PurchaseHandler groups HTTP handlers related to purchase/receipt management. +type PurchaseHandler struct { + DB *sql.DB +} + +// NewPurchaseHandler creates a new PurchaseHandler with the given database handle. +func NewPurchaseHandler(db *sql.DB) *PurchaseHandler { + return &PurchaseHandler{DB: db} +} + +// --------------------------------------------------------------------------- +// POST /purchases/upload — UploadReceipt +// --------------------------------------------------------------------------- + +// UploadReceipt handles receipt image upload, AI extraction, and returns +// an HTMX fragment with a pre-filled receipt edit form. +func (h *PurchaseHandler) UploadReceipt(w http.ResponseWriter, r *http.Request) { + // 1. Parse multipart form with 10 MB max memory. + if err := r.ParseMultipartForm(10 << 20); err != nil { + log.Printf("ERROR [%s] handlers: UploadReceipt: parse form: %v", + time.Now().Format(time.RFC3339), err) + renderUploadError(w, "Failed to parse upload form.") + return + } + defer r.MultipartForm.RemoveAll() + + // 2. Get the file from the "receipt" form field. + file, header, err := r.FormFile("receipt") + if err != nil { + log.Printf("ERROR [%s] handlers: UploadReceipt: missing receipt field: %v", + time.Now().Format(time.RFC3339), err) + renderUploadError(w, "Missing receipt file.") + return + } + defer file.Close() + + // 3. Validate file size (max 10 MB). + if header.Size > 10<<20 { + log.Printf("ERROR [%s] handlers: UploadReceipt: file too large: %d bytes", + time.Now().Format(time.RFC3339), header.Size) + renderUploadError(w, "File too large. Maximum size is 10 MB.") + return + } + + // 4. Read the full file data. + fileData, err := io.ReadAll(file) + if err != nil { + log.Printf("ERROR [%s] handlers: UploadReceipt: read file: %v", + time.Now().Format(time.RFC3339), err) + renderUploadError(w, "Failed to read uploaded file.") + return + } + + // 5. Validate content type by inspecting magic bytes. + ext := detectImageExtension(fileData) + if ext == "" { + log.Printf("ERROR [%s] handlers: UploadReceipt: unsupported file type") + renderUploadError(w, "Unsupported file format. Please upload a receipt image (JPEG, PNG, HEIC) or PDF.") + return + } + + // Resize the image (max 2048px, JPEG 85%). + resized, resizeErr := resizeImage(fileData) + if resizeErr == nil && len(resized) > 0 { + fileData = resized + if ext != "jpg" && ext != "jpeg" { + ext = "jpg" + } + } + + // 6. Generate a UUID-based filename and ensure the storage directory exists. + filename := utils.NewUUID() + "." + ext + storagePath := filepath.Join("storage", filename) + + if err := os.MkdirAll("storage", 0755); err != nil { + log.Printf("ERROR [%s] handlers: UploadReceipt: mkdir storage: %v", + time.Now().Format(time.RFC3339), err) + renderUploadError(w, "Server error. Please try again.") + return + } + + // 7. Save the image file to disk. + if err := os.WriteFile(storagePath, fileData, 0644); err != nil { + log.Printf("ERROR [%s] handlers: UploadReceipt: write file: %v", + time.Now().Format(time.RFC3339), err) + renderUploadError(w, "Failed to save receipt image. Please try again.") + return + } + + // 8. Call the AI API for receipt data extraction. + receipt, aiErr := ai.ExtractReceipt(filepath.Join("storage", filename)) + + // Strip the storage/ prefix so the template can build a proper URL: /storage/{file} + storagePath = filename + + // 9. Render the receipt_form.html fragment. + tmpl := getTemplate("receipt_form.html") + + data := map[string]interface{}{ + "ImagePath": storagePath, + "AIError": "", + "ProductName": "", + "Store": "", + "Category": "", + "Amount": "", + "Currency": "", + "Date": "", + "WarrantyMonths": "0", + "ReturnDays": "0", + "Notes": "", + } + + if aiErr != nil { + data["AIError"] = "Could not read receipt automatically. Please fill in the fields below." + log.Printf("ERROR [%s] handlers: UploadReceipt: AI extraction failed: %v", + time.Now().Format(time.RFC3339), aiErr) + } else if receipt != nil { + data["ProductName"] = receipt.ProductName + data["Store"] = receipt.Merchant + data["Category"] = receipt.Category + data["Amount"] = strconv.FormatFloat(receipt.Amount, 'f', 2, 64) + data["Currency"] = receipt.Currency + data["Date"] = receipt.Date + if receipt.WarrantyMonths > 0 { + data["WarrantyMonths"] = strconv.Itoa(receipt.WarrantyMonths) + } + if receipt.ReturnDays > 0 { + data["ReturnDays"] = strconv.Itoa(receipt.ReturnDays) + } + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := tmpl.Execute(w, data); err != nil { + log.Printf("ERROR [%s] handlers: UploadReceipt: template execute: %v", + time.Now().Format(time.RFC3339), err) + } +} + +// --------------------------------------------------------------------------- +// POST /purchases — SavePurchase +// --------------------------------------------------------------------------- + +// SavePurchase handles the receipt form submission, saves the purchase to the +// database, and returns an HTMX multi-target response. +func (h *PurchaseHandler) SavePurchase(w http.ResponseWriter, r *http.Request) { + userID := getUserID(r) + if userID == "" { + http.Error(w, "Session expired. Please log in again.", http.StatusUnauthorized) + return + } + + // 1. Parse form fields. + if err := r.ParseForm(); err != nil { + log.Printf("ERROR [%s] handlers: SavePurchase: parse form: %v", + time.Now().Format(time.RFC3339), err) + http.Error(w, "Cannot parse form data", http.StatusBadRequest) + return + } + + productName := strings.TrimSpace(r.FormValue("product_name")) + store := strings.TrimSpace(r.FormValue("store")) + category := strings.TrimSpace(r.FormValue("category")) + amountStr := strings.TrimSpace(r.FormValue("amount")) + currency := strings.TrimSpace(r.FormValue("currency")) + date := strings.TrimSpace(r.FormValue("date")) + notes := strings.TrimSpace(r.FormValue("notes")) + imagePath := strings.TrimSpace(r.FormValue("image_path")) + warrantyStr := strings.TrimSpace(r.FormValue("warranty_months")) + returnStr := strings.TrimSpace(r.FormValue("return_days")) + + // 2. Validate required fields. + var missing []string + if productName == "" { + missing = append(missing, "product name") + } + if store == "" { + missing = append(missing, "store") + } + if category == "" { + missing = append(missing, "category") + } + if amountStr == "" { + missing = append(missing, "amount") + } + if currency == "" { + missing = append(missing, "currency") + } + if date == "" { + missing = append(missing, "purchase date") + } + if len(missing) > 0 { + http.Error(w, "Missing required fields: "+strings.Join(missing, ", "), + http.StatusBadRequest) + return + } + + // 3. Parse numeric fields. + amount, err := strconv.ParseFloat(amountStr, 64) + if err != nil { + http.Error(w, "Invalid amount value", http.StatusBadRequest) + return + } + + warrantyMonths := 0 + if warrantyStr != "" { + warrantyMonths, _ = strconv.Atoi(warrantyStr) + } + + returnDays := 0 + if returnStr != "" { + returnDays, _ = strconv.Atoi(returnStr) + } + + // 4. Build and save the purchase record. + purchase := database.Purchase{ + ID: utils.NewUUID(), + UserID: userID, + ProductName: productName, + Store: store, + Category: category, + Amount: amount, + Currency: currency, + PurchaseDate: date, + WarrantyMonths: warrantyMonths, + ReturnDays: returnDays, + Notes: notes, + ImagePath: imagePath, + } + + if err := database.CreatePurchase(h.DB, purchase); err != nil { + log.Printf("ERROR [%s] handlers: SavePurchase: create purchase: %v", + time.Now().Format(time.RFC3339), err) + http.Error(w, "Failed to save purchase", http.StatusInternalServerError) + return + } + + // Mark user as onboarded on first purchase. + database.MarkUserOnboarded(h.DB, userID) + + // 5. Fetch the updated purchase list. + purchases, err := database.GetPurchasesByUser(h.DB, userID) + if err != nil { + log.Printf("ERROR [%s] handlers: SavePurchase: fetch purchases: %v", + time.Now().Format(time.RFC3339), err) + http.Error(w, "Failed to retrieve purchases", http.StatusInternalServerError) + return + } + + // Normalize ImagePath. + for i := range purchases { + purchases[i].ImagePath = normalizeImagePath(purchases[i].ImagePath) + } + + // 6. Render the purchase list fragment. + listBuf := renderPurchaseList(purchases) + + // 7. Return HTMX multi-target response. + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("HX-Refresh", "false") + fmt.Fprintf(w, `No purchases yet. Upload a receipt to get started.
ReceiptNext needs an internet connection to load. Please check your connection and try again.
', + { + status: 503, + statusText: 'Service Unavailable', + headers: { 'Content-Type': 'text/html; charset=utf-8' } + } + ); + } + + return new Response( + 'Offline — resource not available', + { + status: 503, + statusText: 'Service Unavailable', + headers: { 'Content-Type': 'text/plain; charset=utf-8' } + } + ); + }); + }) + .catch(err => { + console.error('[SW] Cache match error:', err); + // Fall through to network + return fetch(request).catch(() => { + return new Response( + 'An unexpected error occurred.', + { status: 502, headers: { 'Content-Type': 'text/plain' } } + ); + }); + }) + ); + return; + } + + // ── Strategy 3: Network-first for all other (non-shell, non-API) assets ── + // This covers images, fonts, etc. — try network first, fall back to cache + event.respondWith( + fetch(request) + .then(networkResponse => { + // Cache successful responses for offline fallback + if (networkResponse && networkResponse.status === 200) { + const responseToCache = networkResponse.clone(); + caches.open(CACHE_NAME) + .then(cache => { + cache.put(request, responseToCache).catch(err => { + console.error('[SW] Failed to cache dynamic asset:', url.pathname, err); + }); + }) + .catch(err => { + console.error('[SW] Failed to open cache for dynamic asset:', err); + }); + } + return networkResponse; + }) + .catch(err => { + console.warn('[SW] Network failed, trying cache for:', url.pathname, err); + return caches.match(request).then(cachedResponse => { + if (cachedResponse) { + return cachedResponse; + } + // Nothing in cache either — return a basic error + return new Response( + 'Resource unavailable offline.', + { status: 503, headers: { 'Content-Type': 'text/plain' } } + ); + }); + }) + ); +}); diff --git a/templates/dashboard.html b/templates/dashboard.html new file mode 100644 index 0000000..1e0096d --- /dev/null +++ b/templates/dashboard.html @@ -0,0 +1,119 @@ + + + + + + +No purchases yet. Upload a receipt to get started.
+Analyzing receipt...
+Receipt Saver for Warranty & Returns
++ A 6-digit verification code will be sent to your email. +
+