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:
parent
483a9b72c3
commit
f0c576c543
33 changed files with 5875 additions and 1 deletions
26
.env.example
Normal file
26
.env.example
Normal 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
33
.gitignore
vendored
Normal 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
86
Makefile
Normal 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)
|
||||
55
README.md
55
README.md
|
|
@ -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
50
build-all.sh
Executable 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/"
|
||||
25
contrib/nextreceipt.service
Normal file
25
contrib/nextreceipt.service
Normal 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
23
go.mod
Normal 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
53
go.sum
Normal 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
444
install.sh
Executable 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
151
internal/ai/gemini.go
Normal 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
145
internal/ai/openai.go
Normal 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
141
internal/ai/receipt.go
Normal 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
105
internal/auth/otp.go
Normal 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
100
internal/auth/session.go
Normal 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
323
internal/database/db.go
Normal 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
285
internal/email/smtp.go
Normal 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
283
internal/handlers/auth.go
Normal 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
|
||||
}
|
||||
68
internal/handlers/dashboard.go
Normal file
68
internal/handlers/dashboard.go
Normal 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)
|
||||
}
|
||||
}
|
||||
10
internal/handlers/helpers.go
Normal file
10
internal/handlers/helpers.go
Normal 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/")
|
||||
}
|
||||
669
internal/handlers/purchases.go
Normal file
669
internal/handlers/purchases.go
Normal 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;">×</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
|
||||
}
|
||||
63
internal/handlers/templates.go
Normal file
63
internal/handlers/templates.go
Normal 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
18
internal/utils/uuid.go
Normal 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
256
main.go
Normal 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
1904
static/css/style.css
Normal file
File diff suppressed because it is too large
Load diff
4
static/favicon.svg
Normal file
4
static/favicon.svg
Normal 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
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
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
BIN
static/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
20
static/manifest.json
Normal file
20
static/manifest.json
Normal 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
265
static/sw.js
Normal 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
119
templates/dashboard.html
Normal 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}} · {{.Category}} · {{.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;">×</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
45
templates/index.html
Normal 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 & 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
107
templates/receipt_form.html
Normal 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 & 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>
|
||||
Loading…
Reference in a new issue