feat: transform NextExpense into NextReceipt — receipt saver for warranty & returns

- Replace events+expenses with flat purchases table
- Add warranty_months, return_days, product_name fields
- Remove currency conversion, CSV/PDF reporting, event filing
- Simplify auth (no onboarding/department/profile)
- Update AI extraction prompts for product/warranty info
- Update all branding: templates, install.sh, Makefile, service file
This commit is contained in:
Claus Lohmar 2026-06-21 18:57:29 +00:00
parent 483a9b72c3
commit f0c576c543
33 changed files with 5875 additions and 1 deletions

26
.env.example Normal file
View file

@ -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

33
.gitignore vendored Normal file
View file

@ -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/

86
Makefile Normal file
View file

@ -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)

View file

@ -1,3 +1,56 @@
# NextReceipt
AI-powered receipt saver for warranty and return tracking
**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

50
build-all.sh Executable file
View file

@ -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/"

View file

@ -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

23
go.mod Normal file
View file

@ -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
)

53
go.sum Normal file
View file

@ -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=

444
install.sh Executable file
View file

@ -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 ""

151
internal/ai/gemini.go Normal file
View file

@ -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
}

145
internal/ai/openai.go Normal file
View file

@ -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
}

141
internal/ai/receipt.go Normal file
View file

@ -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 ""
}

105
internal/auth/otp.go Normal file
View file

@ -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)
}

100
internal/auth/session.go Normal file
View file

@ -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
}

323
internal/database/db.go Normal file
View file

@ -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
}

285
internal/email/smtp.go Normal file
View file

@ -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"
}
}

283
internal/handlers/auth.go Normal file
View file

@ -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, `<div class="error-message" style="color: #fca5a5; margin-bottom: 1rem;">%s</div>`, 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
}

View file

@ -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)
}
}

View file

@ -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/")
}

View file

@ -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, `<div id="receipt-form" hx-swap-oob="true"><div style="background: #064e3b; border: 1px solid #065f46; color: #6ee7b7; padding: 0.75rem; border-radius: 0.5rem; margin-bottom: 1rem;">Purchase saved!</div></div>`)
fmt.Fprintf(w, `<div id="purchase-list" hx-swap-oob="true">%s</div>`, listBuf)
}
// ---------------------------------------------------------------------------
// GET /purchases/{id}/edit — EditPurchase
// ---------------------------------------------------------------------------
// EditPurchase returns the receipt form pre-filled with an existing purchase's data.
func (h *PurchaseHandler) EditPurchase(w http.ResponseWriter, r *http.Request) {
purchaseID := chi.URLParam(r, "id")
if purchaseID == "" {
http.Error(w, "Missing purchase ID", http.StatusBadRequest)
return
}
purchase, err := database.GetPurchaseByID(h.DB, purchaseID)
if err != nil {
log.Printf("ERROR [%s] handlers: EditPurchase: GetPurchaseByID(%s): %v",
time.Now().Format(time.RFC3339), purchaseID, err)
http.Error(w, "Failed to retrieve purchase", http.StatusInternalServerError)
return
}
if purchase == nil {
http.Error(w, "Purchase not found", http.StatusNotFound)
return
}
// Verify ownership.
if purchase.UserID != getUserID(r) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
tmpl := getTemplate("receipt_form.html")
data := map[string]interface{}{
"ImagePath": normalizeImagePath(purchase.ImagePath),
"AIError": "",
"ProductName": purchase.ProductName,
"Store": purchase.Store,
"Category": purchase.Category,
"Amount": strconv.FormatFloat(purchase.Amount, 'f', 2, 64),
"Currency": purchase.Currency,
"Date": purchase.PurchaseDate,
"WarrantyMonths": strconv.Itoa(purchase.WarrantyMonths),
"ReturnDays": strconv.Itoa(purchase.ReturnDays),
"Notes": purchase.Notes,
"EditID": purchase.ID,
"ID": purchase.ID,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.Execute(w, data); err != nil {
log.Printf("ERROR [%s] handlers: EditPurchase: execute template: %v",
time.Now().Format(time.RFC3339), err)
}
}
// ---------------------------------------------------------------------------
// PUT /purchases/{id} — UpdatePurchase
// ---------------------------------------------------------------------------
// UpdatePurchase updates an existing purchase record with form data.
func (h *PurchaseHandler) UpdatePurchase(w http.ResponseWriter, r *http.Request) {
purchaseID := chi.URLParam(r, "id")
if purchaseID == "" {
http.Error(w, "Missing purchase ID", http.StatusBadRequest)
return
}
if err := r.ParseForm(); err != nil {
log.Printf("ERROR [%s] handlers: UpdatePurchase: parse form: %v",
time.Now().Format(time.RFC3339), err)
http.Error(w, "Cannot parse form data", http.StatusBadRequest)
return
}
existing, err := database.GetPurchaseByID(h.DB, purchaseID)
if err != nil || existing == nil {
log.Printf("ERROR [%s] handlers: UpdatePurchase: get existing: %v",
time.Now().Format(time.RFC3339), err)
http.Error(w, "Purchase not found", http.StatusNotFound)
return
}
if existing.UserID != getUserID(r) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
amount, _ := strconv.ParseFloat(r.FormValue("amount"), 64)
warrantyMonths, _ := strconv.Atoi(r.FormValue("warranty_months"))
returnDays, _ := strconv.Atoi(r.FormValue("return_days"))
purchase := database.Purchase{
ID: purchaseID,
ProductName: strings.TrimSpace(r.FormValue("product_name")),
Store: strings.TrimSpace(r.FormValue("store")),
Category: strings.TrimSpace(r.FormValue("category")),
Amount: amount,
Currency: strings.TrimSpace(r.FormValue("currency")),
PurchaseDate: strings.TrimSpace(r.FormValue("date")),
WarrantyMonths: warrantyMonths,
ReturnDays: returnDays,
Notes: strings.TrimSpace(r.FormValue("notes")),
}
if err := database.UpdatePurchase(h.DB, purchase); err != nil {
log.Printf("ERROR [%s] handlers: UpdatePurchase: %v", time.Now().Format(time.RFC3339), err)
http.Error(w, "Failed to update purchase", http.StatusInternalServerError)
return
}
// Return updated purchase list.
userID := getUserID(r)
purchases, err := database.GetPurchasesByUser(h.DB, userID)
if err != nil {
log.Printf("ERROR [%s] handlers: UpdatePurchase: fetch purchases: %v",
time.Now().Format(time.RFC3339), err)
http.Error(w, "Failed to fetch purchases", http.StatusInternalServerError)
return
}
for i := range purchases {
purchases[i].ImagePath = normalizeImagePath(purchases[i].ImagePath)
}
listBuf := renderPurchaseList(purchases)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div id="receipt-form" hx-swap-oob="true"><div style="background: #064e3b; border: 1px solid #065f46; color: #6ee7b7; padding: 0.75rem; border-radius: 0.5rem; margin-bottom: 1rem;">Purchase updated!</div></div>`)
fmt.Fprintf(w, `<div id="purchase-list" hx-swap-oob="true">%s</div>`, listBuf)
}
// ---------------------------------------------------------------------------
// DELETE /purchases/{id} — DeletePurchase
// ---------------------------------------------------------------------------
// DeletePurchase removes an individual purchase after verifying ownership.
func (h *PurchaseHandler) DeletePurchase(w http.ResponseWriter, r *http.Request) {
purchaseID := chi.URLParam(r, "id")
if purchaseID == "" {
http.Error(w, "Missing purchase ID", http.StatusBadRequest)
return
}
existing, err := database.GetPurchaseByID(h.DB, purchaseID)
if err != nil || existing == nil {
log.Printf("ERROR [%s] handlers: DeletePurchase: get existing(%s): %v",
time.Now().Format(time.RFC3339), purchaseID, err)
http.Error(w, "Purchase not found", http.StatusNotFound)
return
}
if existing.UserID != getUserID(r) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
userID := existing.UserID
if err := database.DeletePurchase(h.DB, purchaseID); err != nil {
log.Printf("ERROR [%s] handlers: DeletePurchase: %v", time.Now().Format(time.RFC3339), err)
http.Error(w, "Failed to delete purchase", http.StatusInternalServerError)
return
}
// Fetch the updated purchase list.
purchases, err := database.GetPurchasesByUser(h.DB, userID)
if err != nil {
log.Printf("ERROR [%s] handlers: DeletePurchase: fetch purchases: %v",
time.Now().Format(time.RFC3339), err)
http.Error(w, "Failed to fetch purchases", http.StatusInternalServerError)
return
}
for i := range purchases {
purchases[i].ImagePath = normalizeImagePath(purchases[i].ImagePath)
}
listBuf := renderPurchaseList(purchases)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div id="receipt-form" hx-swap-oob="true"></div>`)
fmt.Fprintf(w, `<div id="purchase-list" hx-swap-oob="true">%s</div>`, listBuf)
}
// ---------------------------------------------------------------------------
// Purchase list rendering helper
// ---------------------------------------------------------------------------
// renderPurchaseList renders the purchase list HTML fragment.
func renderPurchaseList(purchases []database.Purchase) string {
var buf strings.Builder
buf.WriteString(fmt.Sprintf(`<h3 style="margin-bottom: 1rem; font-size: 1rem; font-weight: 600;">Purchases (%d)</h3>`, len(purchases)))
if len(purchases) == 0 {
buf.WriteString(`<div style="text-align: center; padding: 3rem; color: var(--color-text-muted); font-size: 0.875rem;"><p>No purchases yet. Upload a receipt to get started.</p></div>`)
return buf.String()
}
buf.WriteString(`<div style="display: flex; flex-direction: column; gap: 0.5rem;">`)
for _, p := range purchases {
buf.WriteString(renderPurchaseItem(p))
}
buf.WriteString(`</div>`)
return buf.String()
}
// renderPurchaseItem renders a single purchase item HTML.
func renderPurchaseItem(p database.Purchase) string {
warrantyInfo := ""
if p.WarrantyMonths > 0 {
warrantyInfo = fmt.Sprintf(`<div style="font-size: 0.7rem; color: #6ee7b7;">Warranty: %d months</div>`, p.WarrantyMonths)
}
returnInfo := ""
if p.ReturnDays > 0 {
returnInfo = fmt.Sprintf(`<div style="font-size: 0.7rem; color: #fcd34d;">Return: %d days</div>`, p.ReturnDays)
}
notesInfo := ""
if p.Notes != "" {
notesInfo = fmt.Sprintf(`<div style="font-size: 0.7rem; color: var(--color-text-muted);">%s</div>`, template.HTMLEscapeString(p.Notes))
}
imgBtn := ""
imgDiv := ""
if p.ImagePath != "" {
imgBtn = fmt.Sprintf(`<button class="btn btn-sm" style="background: none; border: 1px solid var(--color-border); border-radius: 0.375rem; padding: 0.25rem 0.5rem; font-size: 0.75rem; cursor: pointer; flex-shrink: 0; color: var(--color-text);"
onclick="document.getElementById('img-%s').classList.toggle('hidden')"
title="View receipt image">🖼</button>`, p.ID)
imgDiv = fmt.Sprintf(`<div id="img-%s" class="hidden" style="position: fixed; inset: 0; background: rgba(0,0,0,0.85); z-index: 999; align-items: center; justify-content: center; cursor: pointer; padding: 1rem;"
onclick="this.classList.add('hidden')">
<span style="position: absolute; top: 1rem; right: 1rem; font-size: 2rem; color: #fff; line-height: 1; cursor: pointer; z-index: 1000;">&times;</span>
<img src="/storage/%s" alt="Receipt" style="max-width: 100%%; max-height: 100%%; object-fit: contain; border-radius: 0.5rem;" onclick="event.stopPropagation()">
</div>`, p.ID, template.HTMLEscapeString(p.ImagePath))
}
return fmt.Sprintf(`<div style="display: flex; align-items: center; background: var(--color-card); border: 1px solid var(--color-border); border-radius: 0.5rem; padding: 0.75rem;">
<div style="flex: 1; min-width: 0;">
<div style="font-weight: 500; color: var(--color-text);">%s</div>
<div style="font-size: 0.75rem; color: var(--color-text-muted);">%s · %s · %s %s</div>
%s%s%s
</div>
<div style="text-align: right; margin-right: 0.75rem;">
<div style="font-weight: 600; color: var(--color-text);">%s %.2f</div>
</div>
<div style="display: flex; gap: 0.25rem;">
%s
<button class="btn btn-sm" style="background: none; border: 1px solid var(--color-border); border-radius: 0.375rem; padding: 0.25rem 0.5rem; font-size: 0.75rem; cursor: pointer; flex-shrink: 0; color: var(--color-text);"
hx-get="/purchases/%s/edit"
hx-target="#receipt-form"
hx-swap="innerHTML"
title="Edit purchase"></button>
<button class="btn btn-sm" style="background: none; border: 1px solid #7f1d1d; border-radius: 0.375rem; padding: 0.25rem 0.5rem; font-size: 0.75rem; cursor: pointer; flex-shrink: 0; color: #fca5a5;"
onclick="if(confirm('Delete this purchase?')) htmx.trigger('#del-%s','click')"
title="Delete purchase">🗑</button>
<div hx-delete="/purchases/%s" hx-target="#purchase-list" hx-swap="outerHTML" id="del-%s" style="display:none"></div>
</div>
</div>%s`,
template.HTMLEscapeString(p.ProductName),
template.HTMLEscapeString(p.Store), template.HTMLEscapeString(p.Category), template.HTMLEscapeString(p.PurchaseDate), template.HTMLEscapeString(p.Currency),
warrantyInfo, returnInfo, notesInfo,
template.HTMLEscapeString(p.Currency), p.Amount,
imgBtn,
template.HTMLEscapeString(p.ID), template.HTMLEscapeString(p.ID), template.HTMLEscapeString(p.ID),
imgDiv)
}
// ---------------------------------------------------------------------------
// Shared helpers (from expenses.go)
// ---------------------------------------------------------------------------
// renderUploadError writes an HTMX-compatible error fragment.
func renderUploadError(w http.ResponseWriter, message string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, `<div id="receipt-form"><div style="background: #450a0a; border: 1px solid #7f1d1d; color: #fca5a5; padding: 0.75rem; border-radius: 0.5rem; margin-bottom: 1rem;">%s</div></div>`,
template.HTMLEscapeString(message))
}
// detectImageExtension examines the magic bytes of the provided data to
// determine its image format. Supports JPEG, PNG, WebP, GIF, BMP, TIFF,
// HEIC/HEIF, and PDF.
func detectImageExtension(data []byte) string {
if len(data) < 4 {
return ""
}
// JPEG: FF D8 FF
if len(data) >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF {
return "jpg"
}
// PNG: 89 50 4E 47
if len(data) >= 8 && data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E &&
data[3] == 0x47 && data[4] == 0x0D && data[5] == 0x0A && data[6] == 0x1A && data[7] == 0x0A {
return "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 "webp"
}
// GIF
if len(data) >= 6 && data[0] == 0x47 && data[1] == 0x49 && data[2] == 0x46 &&
data[3] == 0x38 && (data[4] == 0x39 || data[4] == 0x37) && data[5] == 0x61 {
return "gif"
}
// BMP
if data[0] == 0x42 && data[1] == 0x4D {
return "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 "tiff"
}
// PDF
if len(data) >= 4 && data[0] == 0x25 && data[1] == 0x50 && data[2] == 0x44 && data[3] == 0x46 {
return "pdf"
}
// HEIC/HEIF/AVIF
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 "heic"
case "avif":
return "avif"
}
}
return ""
}
// resizeImage resizes image data to a maximum of 2048 pixels on the longest
// side while maintaining aspect ratio. Output is always JPEG at 85% quality.
func resizeImage(data []byte) ([]byte, error) {
img, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
return data, err
}
bounds := img.Bounds()
w, h := bounds.Dx(), bounds.Dy()
const maxDim = 2048
if w <= maxDim && h <= maxDim {
return data, nil
}
var newW, newH int
if w > h {
newW = maxDim
newH = h * maxDim / w
} else {
newH = maxDim
newW = w * maxDim / h
}
dst := image.NewRGBA(image.Rect(0, 0, newW, newH))
draw.CatmullRom.Scale(dst, dst.Bounds(), img, bounds, draw.Over, nil)
var buf bytes.Buffer
if err := jpeg.Encode(&buf, dst, &jpeg.Options{Quality: 85}); err != nil {
return data, err
}
return buf.Bytes(), nil
}

View file

@ -0,0 +1,63 @@
package handlers
import (
"html/template"
"log"
"path/filepath"
"sync"
)
var (
templatesOnce sync.Once
templates map[string]*template.Template
)
// getTemplate returns a cached template by filename (e.g. "dashboard.html").
// Templates are parsed once from the templates/ directory on first call.
func getTemplate(name string) *template.Template {
templatesOnce.Do(loadTemplates)
t := templates[name]
if t == nil {
log.Panicf("template %q not found in cache — did you delete templates/%s?", name, name)
}
return t
}
// loadTemplates walks the templates/ directory and pre-parses all .html files.
func loadTemplates() {
templates = make(map[string]*template.Template)
files, err := filepath.Glob("templates/*.html")
if err != nil {
log.Panicf("list templates: %v", err)
}
// Parse each file into its own named template.
for _, f := range files {
name := filepath.Base(f)
t, err := template.ParseFiles(f)
if err != nil {
log.Panicf("parse template %s: %v", f, err)
}
templates[name] = t
}
// Also register the inline OTP form template.
otpTmpl := template.Must(template.New("otp_form").Parse(otpFormHTML))
templates["otp_form"] = otpTmpl
log.Printf("Loaded %d templates", len(templates))
}
// otpFormHTML is the inline OTP form template fragment with single 6-digit field.
const otpFormHTML = `
<form hx-post="/verify-otp" hx-target="#otp-form" hx-swap="innerHTML">
<input type="hidden" name="email" value="{{.Email}}">
{{if .Error}}<div class="error-message" style="color: #fca5a5; background: #450a0a; border: 1px solid #7f1d1d; padding: 0.75rem; border-radius: 0.5rem; margin-bottom: 1rem;">{{.Error}}</div>{{end}}
<div class="form-group" style="margin: 1rem 0;">
<input type="text" name="otp_code" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" autocomplete="one-time-code" required
placeholder="Enter 6-digit code"
style="width: 100%; padding: 1rem; font-size: 1.5rem; text-align: center; letter-spacing: 0.75rem; border: 2px solid #475569; border-radius: 0.5rem; background: #1e293b; color: #f8fafc; box-sizing: border-box;">
</div>
<button type="submit" class="btn btn-primary btn-block">Verify Code</button>
</form>`

18
internal/utils/uuid.go Normal file
View file

@ -0,0 +1,18 @@
// Package utils provides common utility functions for ReceiptNext.
package utils
import (
"time"
"github.com/google/uuid"
)
// NewUUID generates a new UUID v4 string.
func NewUUID() string {
return uuid.New().String()
}
// Timestamp returns the current UTC time formatted as RFC3339.
func Timestamp() string {
return time.Now().UTC().Format(time.RFC3339)
}

256
main.go Normal file
View file

@ -0,0 +1,256 @@
// NextReceipt — AI-Powered Receipt Saver for Warranty & Returns
//
// A production-ready, mobile-first Progressive Web App (PWA) that uses
// passwordless email OTP login, AI receipt extraction (Gemini / OpenAI),
// and receipt storage for warranty and return tracking.
//
// Usage:
//
// Copy .env.example to .env and fill in credentials, then:
// go run main.go
//
// The server starts on the port specified by the PORT env var (default 8080).
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/joho/godotenv"
"github.com/cclohmar/NextReceipt/internal/auth"
"github.com/cclohmar/NextReceipt/internal/database"
"github.com/cclohmar/NextReceipt/internal/email"
"github.com/cclohmar/NextReceipt/internal/handlers"
"github.com/cclohmar/NextReceipt/internal/utils"
)
func main() {
// -----------------------------------------------------------------------
// Configuration
// -----------------------------------------------------------------------
if err := godotenv.Load(); err != nil {
log.Printf("INFO main: no .env file found, using system environment")
}
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
// SMTP configuration.
smtpHost := os.Getenv("SMTP_HOST")
smtpPort := os.Getenv("SMTP_PORT")
smtpUser := os.Getenv("SMTP_USER")
smtpPass := os.Getenv("SMTP_PASS")
// -----------------------------------------------------------------------
// Database
// -----------------------------------------------------------------------
db, err := database.Init()
if err != nil {
log.Fatalf("FATAL main: database init: %v", err)
}
defer db.Close()
// -----------------------------------------------------------------------
// Services
// -----------------------------------------------------------------------
sessionStore := auth.NewSessionStore()
failureTracker := auth.NewFailureTracker()
// Start background session cleanup.
go func() {
for {
time.Sleep(15 * time.Minute)
sessionStore.Cleanup()
}
}()
// Create the email sender only if SMTP credentials are configured.
var emailSender *email.Sender
if smtpHost != "" && smtpPort != "" && smtpUser != "" && smtpPass != "" {
emailSender = email.NewSender(smtpHost, smtpPort, smtpUser, smtpPass, smtpUser)
log.Printf("INFO main: SMTP sender configured (%s:%s)", smtpHost, smtpPort)
} else {
log.Printf("WARN main: SMTP not configured — OTP emails will not be sent")
}
// -----------------------------------------------------------------------
// Handlers
// -----------------------------------------------------------------------
authHandler := &handlers.AuthHandler{
DB: db,
Sessions: sessionStore,
FailureTracker: failureTracker,
EmailSender: emailSender,
}
dashboardHandler := handlers.NewDashboardHandler(db)
purchaseHandler := handlers.NewPurchaseHandler(db)
// -----------------------------------------------------------------------
// Router
// -----------------------------------------------------------------------
r := chi.NewRouter()
// Middleware.
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.RealIP)
// Request body size limit (10 MB) on all endpoints.
r.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 10<<20)
next.ServeHTTP(w, r)
})
})
// Security headers.
r.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
w.Header().Set("Content-Security-Policy",
"default-src 'self'; img-src 'self' data:; script-src 'self' https://unpkg.com/htmx.org@1.9.10 'unsafe-inline'; style-src 'self' 'unsafe-inline'")
next.ServeHTTP(w, r)
})
})
// Request ID middleware for log tracing.
r.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reqID := r.Header.Get("X-Request-ID")
if reqID == "" {
reqID = utils.NewUUID()[:8]
}
ctx := context.WithValue(r.Context(), "req_id", reqID)
next.ServeHTTP(w, r.WithContext(ctx))
})
})
// PWA headers for service worker.
r.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/sw.js" {
w.Header().Set("Service-Worker-Allowed", "/")
w.Header().Set("Content-Type", "application/javascript")
}
next.ServeHTTP(w, r)
})
})
// Static file serving.
fileServer := http.FileServer(http.Dir("static"))
r.Handle("/static/*", http.StripPrefix("/static/", fileServer))
// Serve PWA files.
r.Get("/sw.js", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Service-Worker-Allowed", "/")
w.Header().Set("Content-Type", "application/javascript")
http.ServeFile(w, r, "static/sw.js")
}))
r.Get("/manifest.json", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
http.ServeFile(w, r, "static/manifest.json")
}))
// iOS PWA / Safari root-level icon requests.
r.Get("/apple-touch-icon.png", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "static/icons/icon-180.png")
}))
r.Get("/apple-touch-icon-120x120.png", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "static/icons/icon-180.png")
}))
r.Get("/favicon.ico", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "static/favicon.svg")
}))
// ---- Public routes (no auth required) ----
r.Get("/", authHandler.LandingPage)
r.Post("/request-otp", authHandler.RequestOTP)
r.Post("/verify-otp", authHandler.VerifyOTP)
// ---- Logout ----
r.Post("/logout", authHandler.Logout)
// ---- Protected routes (auth required) ----
r.Group(func(r chi.Router) {
r.Use(authHandler.RequireAuth)
// Dashboard.
r.Get("/dashboard", dashboardHandler.Dashboard)
// Purchases.
r.Post("/purchases/upload", purchaseHandler.UploadReceipt)
r.Post("/purchases", purchaseHandler.SavePurchase)
r.Get("/purchases/{id}/edit", purchaseHandler.EditPurchase)
r.Put("/purchases/{id}", purchaseHandler.UpdatePurchase)
r.Delete("/purchases/{id}", purchaseHandler.DeletePurchase)
// Storage (receipt images) — protected by auth + path traversal check.
r.Get("/storage/*", func(w http.ResponseWriter, r *http.Request) {
imagePath := strings.TrimPrefix(r.URL.Path, "/storage/")
cleanPath := filepath.Clean(imagePath)
if strings.HasPrefix(cleanPath, "..") || strings.Contains(cleanPath, "../") {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
http.ServeFile(w, r, filepath.Join("storage", cleanPath))
})
})
// -----------------------------------------------------------------------
// Startup
// -----------------------------------------------------------------------
addr := ":" + port
srv := &http.Server{
Addr: addr,
Handler: r,
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
}
// Graceful shutdown on SIGINT / SIGTERM.
go func() {
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
sig := <-sigCh
log.Printf("INFO main: received signal %v, shutting down...", sig)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Printf("ERROR main: graceful shutdown: %v", err)
}
}()
log.Printf("INFO main: NextReceipt server starting on %s", addr)
log.Printf("INFO main: open http://localhost%s in your browser", addr)
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
log.Fatalf("FATAL main: server error: %v", err)
}
log.Printf("INFO main: server stopped")
}

1904
static/css/style.css Normal file

File diff suppressed because it is too large Load diff

4
static/favicon.svg Normal file
View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<rect width="64" height="64" rx="12" fill="#10b981"/>
<text x="32" y="42" font-family="Georgia, 'Times New Roman', serif" font-size="36" font-weight="bold" fill="#ffffff" text-anchor="middle" letter-spacing="-2">Rx</text>
</svg>

After

Width:  |  Height:  |  Size: 317 B

BIN
static/icons/icon-180.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
static/icons/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

BIN
static/icons/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

20
static/manifest.json Normal file
View file

@ -0,0 +1,20 @@
{
"name": "ReceiptNext",
"short_name": "ReceiptNext",
"start_url": "/",
"display": "standalone",
"theme_color": "#10b981",
"background_color": "#0f172a",
"icons": [
{
"src": "/static/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/static/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}

265
static/sw.js Normal file
View file

@ -0,0 +1,265 @@
/* ============================================================
* ReceiptNext Service Worker
* Version: 2.0.0
* Cache name: receiptnext-v2
* Strategy: Cache-first for shell assets, network-only for API
* ============================================================ */
const CACHE_NAME = 'receiptnext-v1';
// Shell assets to pre-cache on install
const SHELL_ASSETS = [
'/',
'/static/css/style.css',
'/static/favicon.svg',
'https://unpkg.com/htmx.org@1.9.10'
];
// API path prefix pattern — requests matching these are never cached
const API_PATTERNS = [
'/api/',
'/auth/',
'/login',
'/logout',
'/register'
];
/* ------------------------------------------------------------
* Utility: determine whether a request targets an API endpoint
* ------------------------------------------------------------ */
function isApiRequest(url) {
return API_PATTERNS.some(pattern => url.pathname.startsWith(pattern));
}
/* ------------------------------------------------------------
* Utility: determine whether a request is a shell asset eligible
* for cache-first strategy
* ------------------------------------------------------------ */
function isShellAsset(url) {
const path = url.pathname;
const origin = url.origin;
// Same-origin shell pages
if (origin === self.location.origin && (path === '/' || path === '')) {
return true;
}
// Same-origin CSS
if (origin === self.location.origin && path.startsWith('/static/css/')) {
return true;
}
// Same-origin JS
if (origin === self.location.origin && path.startsWith('/static/js/')) {
return true;
}
// HTMX CDN (cache-first for fast loading)
if (url.href === 'https://unpkg.com/htmx.org@1.9.10') {
return true;
}
return false;
}
/* ------------------------------------------------------------
* INSTALL Pre-cache shell assets
* ------------------------------------------------------------ */
self.addEventListener('install', event => {
console.log('[SW] Install event — caching shell assets');
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => {
// Use addAll for atomic caching — if one fails, the whole
// install fails and the SW won't activate
return cache.addAll(SHELL_ASSETS).catch(err => {
console.error('[SW] Failed to cache some shell assets:', err);
// Still attempt to activate even if caching is partially
// unsuccessful — the fetch handler will fall back to network
throw err;
});
})
.then(() => {
console.log('[SW] Shell assets cached successfully');
return self.skipWaiting();
})
.catch(err => {
console.error('[SW] Install failed:', err);
// Still try to activate so the SW takes over
return self.skipWaiting();
})
);
});
/* ------------------------------------------------------------
* ACTIVATE Clean up old caches
* ------------------------------------------------------------ */
self.addEventListener('activate', event => {
console.log('[SW] Activate event — cleaning old caches');
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames
.filter(name => name !== CACHE_NAME)
.map(name => {
console.log('[SW] Deleting old cache:', name);
return caches.delete(name);
})
);
}).then(() => {
console.log('[SW] Activated — taking control of all clients');
return self.clients.claim();
}).catch(err => {
console.error('[SW] Activation cleanup failed:', err);
// Continue even if cleanup fails
return self.clients.claim();
})
);
});
/* ------------------------------------------------------------
* FETCH Hybrid strategy
* - Cache-first with network fallback for shell assets
* - Network-only for API / dynamic endpoints
* - Stale-while-revalidate for other same-origin assets
* ------------------------------------------------------------ */
self.addEventListener('fetch', event => {
const request = event.request;
const url = new URL(request.url);
// Ignore non-GET requests (POST, PUT, DELETE, etc.)
if (request.method !== 'GET') {
return;
}
// Ignore browser-extension and non-http(s) requests
if (!url.protocol.startsWith('http')) {
return;
}
// ── Strategy 1: Network-only for API calls ──
if (isApiRequest(url)) {
event.respondWith(
fetch(request).catch(err => {
console.warn('[SW] API fetch failed (offline?):', url.pathname, err);
// Return a lightweight JSON error so the app can handle it gracefully
return new Response(
JSON.stringify({ error: 'You are offline. Please check your connection.' }),
{
status: 503,
statusText: 'Service Unavailable',
headers: { 'Content-Type': 'application/json' }
}
);
})
);
return;
}
// ── Strategy 2: Cache-first with network fallback for shell assets ──
if (isShellAsset(url)) {
event.respondWith(
caches.match(request)
.then(cachedResponse => {
if (cachedResponse) {
// Cache hit — return immediately
return cachedResponse;
}
// Cache miss — fetch from network, then cache for future
return fetch(request)
.then(networkResponse => {
// Only cache valid responses
if (!networkResponse || networkResponse.status !== 200) {
return networkResponse;
}
// Clone the response so we can cache one and return the other
const responseToCache = networkResponse.clone();
caches.open(CACHE_NAME)
.then(cache => {
cache.put(request, responseToCache).catch(err => {
console.error('[SW] Failed to cache asset:', url.pathname, err);
});
})
.catch(err => {
console.error('[SW] Failed to open cache for storing:', err);
});
return networkResponse;
})
.catch(err => {
console.warn('[SW] Network fetch failed for shell asset:', url.pathname, err);
// Return a minimal offline fallback for navigations
if (request.mode === 'navigate') {
return new Response(
'<!DOCTYPE html><html><head><title>Offline — ReceiptNext</title><meta name="viewport" content="width=device-width, initial-scale=1"><style>body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;margin:0;padding:2rem;text-align:center;background:#0f172a;color:#f8fafc}h1{font-size:1.5rem;margin-bottom:0.5rem}p{color:#94a3b8;max-width:24rem}</style></head><body><h1>You\'re offline</h1><p>ReceiptNext needs an internet connection to load. Please check your connection and try again.</p></body></html>',
{
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' } }
);
});
})
);
});

119
templates/dashboard.html Normal file
View file

@ -0,0 +1,119 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="theme-color" content="#10b981">
<title>NextReceipt</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="alternate icon" href="/static/icons/icon-192.png">
<link rel="manifest" href="/manifest.json">
<link rel="stylesheet" href="/static/css/style.css?v=4">
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
</head>
<body>
<div class="app-shell">
<header class="app-header">
<h1 class="app-title"><img src="/static/favicon.svg" width="24" height="24" alt="" style="vertical-align: middle; margin-right: 0.5rem;">NextReceipt</h1>
<button class="btn btn-secondary btn-sm" style="font-size: 0.75rem;"
hx-post="/logout" hx-target="body" hx-push-url="true">Logout</button>
</header>
<main class="main-content">
<div class="dashboard-header">
<h2>My Purchases</h2>
</div>
<!-- Purchase List -->
<div id="purchase-list">
{{if .Purchases}}
<h3 style="margin-bottom: 1rem; font-size: 1rem; font-weight: 600;">Purchases ({{len .Purchases}})</h3>
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
{{range .Purchases}}
<div style="display: flex; align-items: center; background: var(--color-card); border: 1px solid var(--color-border); border-radius: 0.5rem; padding: 0.75rem;">
<div style="flex: 1; min-width: 0;">
<div style="font-weight: 500; color: var(--color-text);">{{.ProductName}}</div>
<div style="font-size: 0.75rem; color: var(--color-text-muted);">{{.Store}} &middot; {{.Category}} &middot; {{.PurchaseDate}}</div>
{{if .Notes}}<div style="font-size: 0.75rem; color: var(--color-text-muted);">{{.Notes}}</div>{{end}}
{{if .WarrantyMonths}}
<div style="font-size: 0.7rem; color: #6ee7b7;">Warranty: {{.WarrantyMonths}} months</div>
{{end}}
{{if .ReturnDays}}
<div style="font-size: 0.7rem; color: #fcd34d;">Return: {{.ReturnDays}} days</div>
{{end}}
</div>
<div style="text-align: right; margin-right: 0.75rem;">
<div style="font-weight: 600; color: var(--color-text);">{{printf "%.2f" .Amount}} {{.Currency}}</div>
</div>
<div style="display: flex; gap: 0.25rem;">
{{if .ImagePath}}
<button class="btn btn-sm" style="background: none; border: 1px solid var(--color-border); border-radius: 0.375rem; padding: 0.25rem 0.5rem; font-size: 0.75rem; cursor: pointer; flex-shrink: 0; color: var(--color-text);"
onclick="document.getElementById('img-{{.ID}}').classList.toggle('hidden')"
title="View receipt image">🖼️</button>
{{end}}
<button class="btn btn-sm" style="background: none; border: 1px solid var(--color-border); border-radius: 0.375rem; padding: 0.25rem 0.5rem; font-size: 0.75rem; cursor: pointer; flex-shrink: 0; color: var(--color-text);"
hx-get="/purchases/{{.ID}}/edit"
hx-target="#receipt-form"
hx-swap="innerHTML"
title="Edit purchase">✏️</button>
<button class="btn btn-sm" style="background: none; border: 1px solid #7f1d1d; border-radius: 0.375rem; padding: 0.25rem 0.5rem; font-size: 0.75rem; cursor: pointer; flex-shrink: 0; color: #fca5a5;"
onclick="if(confirm('Delete this purchase?')) htmx.trigger('#del-{{.ID}}','click')"
title="Delete purchase">🗑️</button>
<div hx-delete="/purchases/{{.ID}}" hx-target="#purchase-list" hx-swap="outerHTML" id="del-{{.ID}}" style="display:none"></div>
</div>
</div>
{{if .ImagePath}}
<div id="img-{{.ID}}" class="hidden" style="position: fixed; inset: 0; background: rgba(0,0,0,0.85); z-index: 999; align-items: center; justify-content: center; cursor: pointer; padding: 1rem;"
onclick="this.classList.add('hidden')">
<span style="position: absolute; top: 1rem; right: 1rem; font-size: 2rem; color: #fff; line-height: 1; cursor: pointer; z-index: 1000;">&times;</span>
<img src="/storage/{{.ImagePath}}" alt="Receipt" style="max-width: 100%; max-height: 100%; object-fit: contain; border-radius: 0.5rem;" onclick="event.stopPropagation()">
</div>
{{end}}
{{end}}
</div>
{{else}}
<div style="text-align: center; padding: 3rem; color: var(--color-text-muted); font-size: 0.875rem;">
<p>No purchases yet. Upload a receipt to get started.</p>
</div>
{{end}}
</div>
<!-- Receipt Form (edit/add) -->
<div id="receipt-form" style="margin-top: 1.5rem;"></div>
<!-- Add Receipt Buttons -->
<div style="margin-top: 1.5rem; display: flex; gap: 0.75rem;">
<label for="receipt-camera" class="btn btn-primary" style="flex: 1; text-align: center; cursor: pointer;">
📷 Camera
</label>
<label for="receipt-upload" class="btn btn-secondary" style="flex: 1; text-align: center; cursor: pointer;">
📁 Upload
</label>
</div>
<!-- Hidden file input: camera (mobile) -->
<input type="file" id="receipt-camera" name="receipt" accept="image/*" capture="environment" style="display: none;"
hx-post="/purchases/upload"
hx-encoding="multipart/form-data"
hx-target="#receipt-form"
hx-swap="innerHTML"
hx-indicator="#upload-indicator"
hx-trigger="change">
<!-- Hidden file input: gallery / PDF upload -->
<input type="file" id="receipt-upload" name="receipt" accept="image/*,.pdf" style="display: none;"
hx-post="/purchases/upload"
hx-encoding="multipart/form-data"
hx-target="#receipt-form"
hx-swap="innerHTML"
hx-indicator="#upload-indicator"
hx-trigger="change">
<div id="upload-indicator" class="htmx-indicator" style="text-align: center; padding: 1rem;">
<div class="spinner"></div>
<p>Analyzing receipt...</p>
</div>
</main>
</div>
</body>
</html>

45
templates/index.html Normal file
View file

@ -0,0 +1,45 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="theme-color" content="#10b981">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<title>NextReceipt</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="alternate icon" href="/static/icons/icon-192.png">
<link rel="manifest" href="/manifest.json">
<link rel="apple-touch-icon" sizes="180x180" href="/static/icons/icon-180.png">
<link rel="stylesheet" href="/static/css/style.css?v=4">
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
</head>
<body>
<div class="login-page">
<div class="card login-card">
<div class="login-brand">
<div class="login-icon"><img src="/static/favicon.svg" width="48" height="48" alt="Rx"></div>
<h1>NextReceipt</h1>
<p class="text-secondary">Receipt Saver for Warranty &amp; Returns</p>
</div>
<div id="otp-form">
<form hx-post="/request-otp" hx-target="#otp-form" hx-swap="innerHTML">
<div class="form-group">
<label for="email">Email Address</label>
<input type="email" id="email" name="email" placeholder="you@example.com" required autocomplete="email" inputmode="email">
</div>
<button type="submit" class="btn btn-primary btn-block">
<span class="spinner htmx-indicator"></span>
Send Verification Code
</button>
</form>
</div>
<p class="text-secondary" style="text-align: center; font-size: 0.875rem; margin-top: 1rem;">
A 6-digit verification code will be sent to your email.
</p>
</div>
</div>
</body>
</html>

107
templates/receipt_form.html Normal file
View file

@ -0,0 +1,107 @@
<div id="receipt-form">
{{if .AIError}}
<div class="error-message" style="background: #fef2f2; border: 1px solid #fecaca; color: #dc2626; padding: 0.75rem; border-radius: 0.5rem; margin-bottom: 1rem;">
{{.AIError}}
</div>
{{end}}
<div class="card" style="padding: 1rem;">
<div style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1rem;">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#10b981" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
</svg>
<span style="font-weight: 600;">Receipt Details</span>
<span class="badge badge-open" style="margin-left: auto;">AI Extracted</span>
</div>
{{if .EditID}}
<form hx-put="/purchases/{{.EditID}}" hx-target="#receipt-form" hx-swap="outerHTML"
hx-indicator="#save-indicator">
{{else}}
<form hx-post="/purchases" hx-target="#receipt-form" hx-swap="outerHTML"
hx-indicator="#save-indicator">
{{end}}
<input type="hidden" name="image_path" value="{{.ImagePath}}">
{{if .EditID}}<input type="hidden" name="edit_id" value="{{.EditID}}">{{end}}
<div class="form-group">
<label for="product_name">Product / Item *</label>
<input type="text" id="product_name" name="product_name" placeholder="e.g. Samsung TV, Nike Shoes..."
value="{{.ProductName}}" required>
</div>
<div class="form-row">
<div class="form-group">
<label for="store">Store *</label>
<input type="text" id="store" name="store" placeholder="Store name"
value="{{.Store}}" required>
</div>
<div class="form-group">
<label for="category">Category *</label>
<select id="category" name="category" required>
<option value="">Select</option>
<option value="Electronics" {{if eq .Category "Electronics"}}selected{{end}}>Electronics</option>
<option value="Furniture" {{if eq .Category "Furniture"}}selected{{end}}>Furniture</option>
<option value="Appliances" {{if eq .Category "Appliances"}}selected{{end}}>Appliances</option>
<option value="Clothing" {{if eq .Category "Clothing"}}selected{{end}}>Clothing</option>
<option value="Home" {{if eq .Category "Home"}}selected{{end}}>Home</option>
<option value="Other" {{if eq .Category "Other"}}selected{{end}}>Other</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="amount">Price *</label>
<input type="number" id="amount" name="amount" step="0.01" min="0" placeholder="0.00"
value="{{.Amount}}" required inputmode="decimal">
</div>
<div class="form-group">
<label for="currency">Currency *</label>
<input type="text" id="currency" name="currency" placeholder="EUR, USD, GBP…"
value="{{.Currency}}" required maxlength="3" style="text-transform: uppercase;">
</div>
</div>
<div class="form-group">
<label for="date">Purchase Date *</label>
<input type="date" id="date" name="date" value="{{.Date}}" required>
</div>
<div class="card" style="padding: 0.75rem; background: #064e3b; border: 1px solid #065f46; border-radius: 0.5rem; margin-bottom: 1rem;">
<div style="font-size: 0.875rem; font-weight: 600; color: #6ee7b7; margin-bottom: 0.5rem;">
Warranty &amp; Return
</div>
<div class="form-row">
<div class="form-group">
<label for="warranty_months">Warranty (months)</label>
<input type="number" id="warranty_months" name="warranty_months" min="0" max="120" placeholder="0"
value="{{.WarrantyMonths}}" inputmode="numeric">
</div>
<div class="form-group">
<label for="return_days">Return Window (days)</label>
<input type="number" id="return_days" name="return_days" min="0" max="365" placeholder="0"
value="{{.ReturnDays}}" inputmode="numeric">
</div>
</div>
</div>
<div class="form-group">
<label for="notes">Notes</label>
<textarea id="notes" name="notes" placeholder="Optional notes...">{{.Notes}}</textarea>
</div>
<div style="display: flex; gap: 0.5rem;">
<button type="submit" class="btn btn-primary" id="save-indicator">
<span class="spinner htmx-indicator"></span>
{{if .EditID}}Update Purchase{{else}}Save Purchase{{end}}
</button>
<button type="button" class="btn btn-secondary"
onclick="document.getElementById('receipt-form').innerHTML = ''">
Cancel
</button>
</div>
</form>
</div>
</div>