Add authentication and date picker to Receipt Manager

Add username/password login with session cookies, change-password modal,
and logout. All API endpoints require a valid session. Default credentials
(admin/admin) are auto-created on first run. Also add a date input field
to the receipt modal, defaulting to today but allowing the user to pick
any date.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-31 20:24:02 -06:00
commit 9c35cc71e9
5 changed files with 1444 additions and 0 deletions

12
Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY server.py receipts.html ./
EXPOSE 8080
CMD ["python3", "server.py"]

7
docker-compose.yml Normal file
View File

@@ -0,0 +1,7 @@
services:
receipt-manager:
build: .
container_name: receipt-manager
restart: always
ports:
- "8082:8080"

957
receipts.html Normal file
View File

@@ -0,0 +1,957 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Receipt Manager</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #f0f2f5;
color: #1a1a2e;
min-height: 100vh;
}
header {
background: #1a1a2e;
color: #fff;
padding: 16px 20px;
display: flex;
justify-content: space-between;
align-items: center;
position: sticky;
top: 0;
z-index: 10;
}
header h1 { font-size: 1.2rem; font-weight: 600; }
.header-right { display: flex; align-items: center; gap: 10px; }
#total-banner {
font-size: 0.9rem;
background: rgba(255,255,255,0.12);
padding: 4px 12px;
border-radius: 20px;
}
#export-btn {
font-size: 0.8rem;
background: rgba(255,255,255,0.15);
color: #fff;
border: 1px solid rgba(255,255,255,0.25);
padding: 4px 12px;
border-radius: 20px;
cursor: pointer;
font-weight: 600;
}
#export-btn:hover { background: rgba(255,255,255,0.25); }
.container { max-width: 600px; margin: 0 auto; padding: 16px; }
/* Add receipt button */
#add-btn {
position: fixed;
bottom: 24px;
right: 24px;
width: 56px;
height: 56px;
border-radius: 50%;
background: #e94560;
color: #fff;
border: none;
font-size: 2rem;
line-height: 1;
cursor: pointer;
box-shadow: 0 4px 12px rgba(233,69,96,0.4);
z-index: 20;
display: flex;
align-items: center;
justify-content: center;
}
#add-btn:active { transform: scale(0.93); }
/* Modal overlay */
.modal-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
z-index: 100;
justify-content: center;
align-items: flex-end;
}
.modal-overlay.open { display: flex; }
.modal {
background: #fff;
width: 100%;
max-width: 600px;
border-radius: 16px 16px 0 0;
padding: 24px 20px 32px;
animation: slideUp 0.25s ease;
}
@keyframes slideUp {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
.modal h2 { font-size: 1.1rem; margin-bottom: 20px; }
.field { margin-bottom: 16px; }
.field label {
display: block;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #666;
margin-bottom: 6px;
}
.field input,
.field select {
width: 100%;
padding: 10px 12px;
border: 1.5px solid #ddd;
border-radius: 8px;
font-size: 1rem;
background: #fafafa;
appearance: none;
-webkit-appearance: none;
}
.field select {
background: #fafafa url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='7'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%23666' fill='none' stroke-width='1.5'/%3E%3C/svg%3E") no-repeat right 12px center;
padding-right: 32px;
}
.field input:focus,
.field select:focus {
outline: none;
border-color: #e94560;
}
/* Photo area */
.photo-area {
border: 2px dashed #ccc;
border-radius: 8px;
padding: 20px;
text-align: center;
cursor: pointer;
color: #888;
position: relative;
overflow: hidden;
min-height: 120px;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 8px;
}
.photo-area.has-photo {
border-style: solid;
border-color: #4caf50;
padding: 0;
}
.photo-area img {
width: 100%;
height: auto;
display: block;
border-radius: 6px;
}
.photo-area svg { opacity: 0.4; }
.btn-row {
display: flex;
gap: 10px;
margin-top: 20px;
}
.btn-row button {
flex: 1;
padding: 12px;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
}
.btn-cancel { background: #eee; color: #333; }
.btn-save { background: #e94560; color: #fff; }
.btn-save:disabled { opacity: 0.5; cursor: not-allowed; }
/* Receipt cards */
.receipt-card {
background: #fff;
border-radius: 12px;
padding: 14px 16px;
margin-bottom: 12px;
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
display: flex;
gap: 14px;
align-items: center;
}
.receipt-card .thumb {
width: 56px;
height: 56px;
border-radius: 8px;
object-fit: cover;
flex-shrink: 0;
background: #eee;
}
.receipt-card .thumb.no-photo {
display: flex;
align-items: center;
justify-content: center;
font-size: 1.4rem;
}
.receipt-info { flex: 1; min-width: 0; }
.receipt-info .amount { font-size: 1.1rem; font-weight: 700; }
.receipt-info .meta {
font-size: 0.8rem;
color: #888;
margin-top: 2px;
}
.receipt-actions { display: flex; gap: 6px; flex-shrink: 0; }
.receipt-actions button {
background: none;
border: none;
cursor: pointer;
font-size: 1.1rem;
padding: 4px;
border-radius: 4px;
color: #888;
}
.receipt-actions button:hover { background: #f0f0f0; }
.category-badge {
display: inline-block;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
padding: 2px 8px;
border-radius: 4px;
margin-left: 8px;
vertical-align: middle;
}
.cat-food { background: #fff3e0; color: #e65100; }
.cat-hotel { background: #e8eaf6; color: #283593; }
.cat-fuel { background: #e0f2f1; color: #00695c; }
.cat-vehicle { background: #fce4ec; color: #b71c1c; }
.cat-other { background: #f3e5f5; color: #6a1b9a; }
.empty-state {
text-align: center;
padding: 60px 20px;
color: #aaa;
}
.empty-state svg { margin-bottom: 12px; opacity: 0.3; }
.empty-state p { font-size: 0.95rem; }
/* Login overlay */
.login-overlay {
position: fixed;
inset: 0;
background: #1a1a2e;
z-index: 500;
display: flex;
justify-content: center;
align-items: center;
}
.login-overlay.hidden { display: none; }
.login-box {
background: #fff;
border-radius: 16px;
padding: 36px 28px;
width: 90%;
max-width: 360px;
box-shadow: 0 8px 32px rgba(0,0,0,0.3);
}
.login-box h2 {
text-align: center;
margin-bottom: 24px;
font-size: 1.3rem;
}
.login-box .field { margin-bottom: 16px; }
.login-box .field label {
display: block;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #666;
margin-bottom: 6px;
}
.login-box .field input {
width: 100%;
padding: 10px 12px;
border: 1.5px solid #ddd;
border-radius: 8px;
font-size: 1rem;
background: #fafafa;
}
.login-box .field input:focus { outline: none; border-color: #e94560; }
.login-box .login-error {
color: #e94560;
font-size: 0.85rem;
text-align: center;
margin-bottom: 12px;
min-height: 1.2em;
}
.login-box button {
width: 100%;
padding: 12px;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
background: #e94560;
color: #fff;
}
.login-box button:disabled { opacity: 0.5; cursor: not-allowed; }
/* Change password modal */
.cp-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
z-index: 300;
justify-content: center;
align-items: center;
}
.cp-overlay.open { display: flex; }
.cp-box {
background: #fff;
border-radius: 16px;
padding: 28px 24px;
width: 90%;
max-width: 380px;
}
.cp-box h2 { margin-bottom: 20px; font-size: 1.1rem; }
.cp-box .field { margin-bottom: 16px; }
.cp-box .field label {
display: block;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #666;
margin-bottom: 6px;
}
.cp-box .field input {
width: 100%;
padding: 10px 12px;
border: 1.5px solid #ddd;
border-radius: 8px;
font-size: 1rem;
background: #fafafa;
}
.cp-box .field input:focus { outline: none; border-color: #e94560; }
.cp-box .cp-error {
color: #e94560;
font-size: 0.85rem;
text-align: center;
margin-bottom: 12px;
min-height: 1.2em;
}
.cp-box .cp-success {
color: #4caf50;
font-size: 0.85rem;
text-align: center;
margin-bottom: 12px;
min-height: 1.2em;
}
.cp-box .btn-row { display: flex; gap: 10px; margin-top: 20px; }
.cp-box .btn-row button {
flex: 1;
padding: 12px;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
}
/* Header icon buttons */
.header-icon-btn {
background: none;
border: none;
color: rgba(255,255,255,0.8);
cursor: pointer;
font-size: 1.2rem;
padding: 4px 6px;
border-radius: 4px;
}
.header-icon-btn:hover { color: #fff; background: rgba(255,255,255,0.15); }
/* Photo preview modal */
.photo-preview-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.85);
z-index: 200;
justify-content: center;
align-items: center;
cursor: pointer;
}
.photo-preview-overlay.open { display: flex; }
.photo-preview-overlay img {
max-width: 95%;
max-height: 90vh;
border-radius: 8px;
}
</style>
</head>
<body>
<header>
<h1>Receipt Manager</h1>
<div class="header-right">
<button id="export-btn">Export Excel</button>
<div id="total-banner">Total: $0.00</div>
<button class="header-icon-btn" id="settings-btn" title="Change Password">&#9881;</button>
<button class="header-icon-btn" id="logout-btn" title="Log Out">&#9211;</button>
</div>
</header>
<div class="container" id="receipt-list">
<div class="empty-state" id="empty-state">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 14l-4 4m0 0l4 4m-4-4h11a4 4 0 000-8h-1"/></svg>
<p>No receipts yet.<br>Tap <strong>+</strong> to add one.</p>
</div>
</div>
<button id="add-btn" aria-label="Add receipt">+</button>
<!-- Add/Edit modal -->
<div class="modal-overlay" id="modal-overlay">
<div class="modal">
<h2 id="modal-title">New Receipt</h2>
<div class="field">
<label>Photo</label>
<div class="photo-area" id="photo-area">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M23 19a2 2 0 01-2 2H3a2 2 0 01-2-2V8a2 2 0 012-2h4l2-3h6l2 3h4a2 2 0 012 2z"/><circle cx="12" cy="13" r="4"/></svg>
<span>Tap to take photo</span>
</div>
<input type="file" id="photo-input" accept="image/*" capture="environment" hidden>
</div>
<div class="field">
<label>Date</label>
<input type="date" id="date-input">
</div>
<div class="field">
<label>Amount ($)</label>
<input type="number" id="amount-input" placeholder="0.00" min="0" step="0.01" inputmode="decimal">
</div>
<div class="field">
<label>Category</label>
<select id="category-input">
<option value="food">Food</option>
<option value="hotel">Hotel</option>
<option value="fuel">Fuel</option>
<option value="vehicle">Vehicle</option>
<option value="other">Other</option>
</select>
</div>
<div class="field">
<label>Note (optional)</label>
<input type="text" id="note-input" placeholder="Short description">
</div>
<div class="btn-row">
<button class="btn-cancel" id="btn-cancel">Cancel</button>
<button class="btn-save" id="btn-save">Save</button>
</div>
</div>
</div>
<!-- Full-size photo viewer -->
<div class="photo-preview-overlay" id="photo-preview">
<img id="photo-preview-img" src="" alt="Receipt photo">
</div>
<!-- Login overlay -->
<div class="login-overlay" id="login-overlay">
<div class="login-box">
<h2>Receipt Manager</h2>
<div class="field">
<label>Username</label>
<input type="text" id="login-username" autocomplete="username">
</div>
<div class="field">
<label>Password</label>
<input type="password" id="login-password" autocomplete="current-password">
</div>
<div class="login-error" id="login-error"></div>
<button id="login-btn">Log In</button>
</div>
</div>
<!-- Change Password modal -->
<div class="cp-overlay" id="cp-overlay">
<div class="cp-box">
<h2>Change Password</h2>
<div class="field">
<label>Current Password</label>
<input type="password" id="cp-current" autocomplete="current-password">
</div>
<div class="field">
<label>New Password</label>
<input type="password" id="cp-new" autocomplete="new-password">
</div>
<div class="field">
<label>Confirm New Password</label>
<input type="password" id="cp-confirm" autocomplete="new-password">
</div>
<div class="cp-error" id="cp-error"></div>
<div class="cp-success" id="cp-success"></div>
<div class="btn-row">
<button class="btn-cancel" id="cp-cancel">Cancel</button>
<button class="btn-save" id="cp-save">Update</button>
</div>
</div>
</div>
<script>
(function () {
let receipts = [];
let editingId = null;
let currentPhotoBlob = null; // Blob from resized image
let currentPhotoUrl = null; // Object URL or /api/photos/<id> URL for display
// DOM refs
const list = document.getElementById("receipt-list");
const emptyState = document.getElementById("empty-state");
const overlay = document.getElementById("modal-overlay");
const modalTitle = document.getElementById("modal-title");
const photoArea = document.getElementById("photo-area");
const photoInput = document.getElementById("photo-input");
const dateInput = document.getElementById("date-input");
const amountInput = document.getElementById("amount-input");
const categoryInput = document.getElementById("category-input");
const noteInput = document.getElementById("note-input");
const btnSave = document.getElementById("btn-save");
const totalBanner = document.getElementById("total-banner");
const photoPreview = document.getElementById("photo-preview");
const photoPreviewImg = document.getElementById("photo-preview-img");
function formatMoney(n) {
return "$" + Number(n).toFixed(2);
}
function categoryLabel(cat) {
return cat.charAt(0).toUpperCase() + cat.slice(1);
}
function updateTotal() {
const total = receipts.reduce((s, r) => s + Number(r.amount), 0);
totalBanner.textContent = "Total: " + formatMoney(total);
}
function photoSrc(r) {
// If the receipt has a photo filename, point to our proxy endpoint
if (r.photo) return "/api/photos/" + r.id;
return null;
}
function render() {
list.querySelectorAll(".receipt-card").forEach(el => el.remove());
if (receipts.length === 0) {
emptyState.style.display = "";
updateTotal();
return;
}
emptyState.style.display = "none";
[...receipts].reverse().forEach(r => {
const card = document.createElement("div");
card.className = "receipt-card";
let thumbHtml;
const src = photoSrc(r);
if (src) {
thumbHtml = `<img class="thumb" src="${src}" alt="Receipt" data-id="${r.id}" style="cursor:pointer">`;
} else {
thumbHtml = `<div class="thumb no-photo">&#128206;</div>`;
}
const date = new Date(r.date).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
card.innerHTML = `
${thumbHtml}
<div class="receipt-info">
<span class="amount">${formatMoney(r.amount)}</span>
<span class="category-badge cat-${r.category}">${categoryLabel(r.category)}</span>
<div class="meta">${date}${r.note ? " &mdash; " + escapeHtml(r.note) : ""}</div>
</div>
<div class="receipt-actions">
<button title="Edit" data-edit="${r.id}">&#9998;</button>
<button title="Delete" data-delete="${r.id}">&times;</button>
</div>
`;
list.appendChild(card);
});
updateTotal();
}
function escapeHtml(str) {
const d = document.createElement("div");
d.textContent = str;
return d.innerHTML;
}
// Open modal
function todayStr() {
return new Date().toISOString().slice(0, 10);
}
function openModal(receipt) {
if (receipt) {
editingId = receipt.id;
modalTitle.textContent = "Edit Receipt";
dateInput.value = (receipt.date || "").slice(0, 10);
amountInput.value = receipt.amount;
categoryInput.value = receipt.category;
noteInput.value = receipt.note || "";
currentPhotoBlob = null; // no new blob yet
currentPhotoUrl = receipt.photo ? "/api/photos/" + receipt.id : null;
} else {
editingId = null;
modalTitle.textContent = "New Receipt";
dateInput.value = todayStr();
amountInput.value = "";
categoryInput.value = "food";
noteInput.value = "";
currentPhotoBlob = null;
currentPhotoUrl = null;
}
updatePhotoArea();
overlay.classList.add("open");
amountInput.focus();
}
function closeModal() {
overlay.classList.remove("open");
photoInput.value = "";
}
function updatePhotoArea() {
if (currentPhotoUrl) {
photoArea.classList.add("has-photo");
photoArea.innerHTML = `<img src="${currentPhotoUrl}" alt="Receipt photo">`;
} else {
photoArea.classList.remove("has-photo");
photoArea.innerHTML = `
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M23 19a2 2 0 01-2 2H3a2 2 0 01-2-2V8a2 2 0 012-2h4l2-3h6l2 3h4a2 2 0 012 2z"/><circle cx="12" cy="13" r="4"/></svg>
<span>Tap to take photo</span>`;
}
}
// Resize image, returns a Promise<Blob>
function resizeImage(file, maxW) {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = ev => {
const img = new Image();
img.onload = () => {
let w = img.width, h = img.height;
if (w > maxW) { h = h * (maxW / w); w = maxW; }
const c = document.createElement("canvas");
c.width = w; c.height = h;
c.getContext("2d").drawImage(img, 0, 0, w, h);
c.toBlob(blob => resolve(blob), "image/jpeg", 0.7);
};
img.src = ev.target.result;
};
reader.readAsDataURL(file);
});
}
// --- Auth helpers ---
const loginOverlay = document.getElementById("login-overlay");
const loginError = document.getElementById("login-error");
const loginUsername = document.getElementById("login-username");
const loginPassword = document.getElementById("login-password");
const loginBtn = document.getElementById("login-btn");
function showLogin() {
loginOverlay.classList.remove("hidden");
loginError.textContent = "";
loginUsername.value = "";
loginPassword.value = "";
loginUsername.focus();
}
function hideLogin() {
loginOverlay.classList.add("hidden");
}
function handleUnauthorized(r) {
if (r.status === 401) { showLogin(); return true; }
return false;
}
loginBtn.addEventListener("click", doLogin);
loginPassword.addEventListener("keydown", e => { if (e.key === "Enter") doLogin(); });
loginUsername.addEventListener("keydown", e => { if (e.key === "Enter") loginPassword.focus(); });
async function doLogin() {
loginError.textContent = "";
loginBtn.disabled = true;
loginBtn.textContent = "Logging in...";
try {
const r = await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: loginUsername.value, password: loginPassword.value })
});
if (r.ok) {
hideLogin();
await initApp();
} else {
loginError.textContent = "Invalid username or password";
}
} catch (e) {
loginError.textContent = "Connection error";
} finally {
loginBtn.disabled = false;
loginBtn.textContent = "Log In";
}
}
document.getElementById("logout-btn").addEventListener("click", async () => {
await fetch("/api/logout", { method: "POST" });
receipts = [];
render();
showLogin();
});
// --- Change Password ---
const cpOverlay = document.getElementById("cp-overlay");
const cpError = document.getElementById("cp-error");
const cpSuccess = document.getElementById("cp-success");
const cpCurrent = document.getElementById("cp-current");
const cpNew = document.getElementById("cp-new");
const cpConfirm = document.getElementById("cp-confirm");
document.getElementById("settings-btn").addEventListener("click", () => {
cpCurrent.value = ""; cpNew.value = ""; cpConfirm.value = "";
cpError.textContent = ""; cpSuccess.textContent = "";
cpOverlay.classList.add("open");
cpCurrent.focus();
});
document.getElementById("cp-cancel").addEventListener("click", () => {
cpOverlay.classList.remove("open");
});
cpOverlay.addEventListener("click", e => {
if (e.target === cpOverlay) cpOverlay.classList.remove("open");
});
document.getElementById("cp-save").addEventListener("click", async () => {
cpError.textContent = ""; cpSuccess.textContent = "";
if (!cpCurrent.value) { cpError.textContent = "Enter current password"; return; }
if (!cpNew.value) { cpError.textContent = "Enter new password"; return; }
if (cpNew.value !== cpConfirm.value) { cpError.textContent = "New passwords do not match"; return; }
try {
const r = await fetch("/api/change-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ current: cpCurrent.value, new: cpNew.value })
});
if (handleUnauthorized(r)) return;
const data = await r.json();
if (r.ok) {
cpSuccess.textContent = "Password updated";
cpCurrent.value = ""; cpNew.value = ""; cpConfirm.value = "";
setTimeout(() => cpOverlay.classList.remove("open"), 1200);
} else {
cpError.textContent = data.error || "Failed to change password";
}
} catch (e) {
cpError.textContent = "Connection error";
}
});
// --- API helpers ---
async function apiLoadReceipts() {
const r = await fetch("/api/receipts");
if (handleUnauthorized(r)) throw new Error("Unauthorized");
if (!r.ok) throw new Error("Failed to load receipts");
return r.json();
}
async function apiSaveReceipt(entry) {
const r = await fetch("/api/receipts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(entry)
});
if (handleUnauthorized(r)) throw new Error("Unauthorized");
if (!r.ok) throw new Error("Failed to save receipt");
return r.json();
}
async function apiDeleteReceipt(id) {
const r = await fetch("/api/receipts/" + id, { method: "DELETE" });
if (handleUnauthorized(r)) throw new Error("Unauthorized");
if (!r.ok) throw new Error("Failed to delete receipt");
}
async function apiUploadPhoto(id, blob) {
const r = await fetch("/api/photos/" + id, {
method: "POST",
headers: { "Content-Type": "image/jpeg" },
body: blob
});
if (handleUnauthorized(r)) throw new Error("Unauthorized");
if (!r.ok) throw new Error("Failed to upload photo");
return r.json();
}
// Events
document.getElementById("add-btn").addEventListener("click", () => openModal(null));
document.getElementById("btn-cancel").addEventListener("click", closeModal);
overlay.addEventListener("click", e => {
if (e.target === overlay) closeModal();
});
photoArea.addEventListener("click", () => photoInput.click());
photoInput.addEventListener("change", async e => {
const file = e.target.files[0];
if (!file) return;
currentPhotoBlob = await resizeImage(file, 1024);
currentPhotoUrl = URL.createObjectURL(currentPhotoBlob);
updatePhotoArea();
});
btnSave.addEventListener("click", async () => {
const amount = parseFloat(amountInput.value);
if (isNaN(amount) || amount <= 0) {
amountInput.focus();
amountInput.style.borderColor = "#e94560";
return;
}
amountInput.style.borderColor = "";
const id = editingId || Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
const hasNewPhoto = !!currentPhotoBlob;
const hadExistingPhoto = editingId && receipts.find(r => r.id === editingId)?.photo;
const entry = {
id: id,
amount: amount,
category: categoryInput.value,
note: noteInput.value.trim(),
photo: (hasNewPhoto || currentPhotoUrl) ? id + ".jpg" : "",
date: dateInput.value ? new Date(dateInput.value + "T12:00:00").toISOString() : new Date().toISOString()
};
btnSave.disabled = true;
btnSave.textContent = "Saving...";
try {
// Upload photo first if we have a new one
if (hasNewPhoto) {
await apiUploadPhoto(id, currentPhotoBlob);
}
await apiSaveReceipt(entry);
// Update local state
if (editingId) {
const idx = receipts.findIndex(r => r.id === editingId);
if (idx !== -1) receipts[idx] = entry;
} else {
receipts.push(entry);
}
render();
closeModal();
} catch (err) {
alert("Error saving receipt: " + err.message);
} finally {
btnSave.disabled = false;
btnSave.textContent = "Save";
}
});
// Delegate clicks on list
list.addEventListener("click", async e => {
const delBtn = e.target.closest("[data-delete]");
if (delBtn) {
const id = delBtn.dataset.delete;
if (confirm("Delete this receipt?")) {
try {
await apiDeleteReceipt(id);
receipts = receipts.filter(r => r.id !== id);
render();
} catch (err) {
alert("Error deleting receipt: " + err.message);
}
}
return;
}
const editBtn = e.target.closest("[data-edit]");
if (editBtn) {
const r = receipts.find(r => r.id === editBtn.dataset.edit);
if (r) openModal(r);
return;
}
const thumb = e.target.closest("img.thumb");
if (thumb && thumb.dataset.id) {
const r = receipts.find(r => r.id === thumb.dataset.id);
if (r && r.photo) {
photoPreviewImg.src = "/api/photos/" + r.id;
photoPreview.classList.add("open");
}
}
});
photoPreview.addEventListener("click", () => {
photoPreview.classList.remove("open");
});
// Export to Excel
document.getElementById("export-btn").addEventListener("click", () => {
if (receipts.length === 0) { alert("No receipts to export."); return; }
window.location.href = "/api/export";
});
// Init — check session, then load
async function initApp() {
try {
receipts = await apiLoadReceipts();
} catch (e) {
console.error("Failed to load receipts:", e);
return;
}
render();
}
(async () => {
const r = await fetch("/api/receipts");
if (r.status === 401) {
showLogin();
} else if (r.ok) {
hideLogin();
receipts = await r.json();
render();
}
})();
})();
</script>
</body>
</html>

2
requirements.txt Normal file
View File

@@ -0,0 +1,2 @@
requests
openpyxl

466
server.py Normal file
View File

@@ -0,0 +1,466 @@
#!/usr/bin/env python3
"""Receipt Manager backend — proxies receipt data and photos to Nextcloud via WebDAV."""
import getpass
import hashlib
import io
import json
import re
import secrets
import sys
from http.server import HTTPServer, BaseHTTPRequestHandler
from http.cookies import SimpleCookie
from urllib.parse import quote as urlquote
import openpyxl
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
import requests
# --- Nextcloud configuration (filled at startup) ---
NC_BASE = "https://nextcloud.sdanywhere.com"
NC_USERNAME = "kamaji"
NC_PASSWORD = "DvB0U2Uj3tOJaD"
NC_DAV_ROOT = "" # set after login, e.g. /remote.php/dav/files/<user>/business-manager/Expenses/Receipts/
NC_AUTH = ()
RECEIPTS_FILE = "receipts.json"
AUTH_FILE = "auth.json"
PHOTOS_DIR = "photos"
# Server-side session store: {token: True}
SESSIONS = {}
def hash_password(password):
"""Hash a password with SHA-256."""
return hashlib.sha256(password.encode()).hexdigest()
def load_auth():
"""Read auth.json from Nextcloud. If missing, create default credentials."""
r = nc_get(AUTH_FILE)
if r.status_code == 404:
default = {"username": "admin", "password_hash": hash_password("admin")}
save_auth(default)
return default
r.raise_for_status()
return r.json()
def save_auth(auth_data):
"""Write auth.json to Nextcloud."""
data = json.dumps(auth_data, indent=2).encode()
r = nc_put(AUTH_FILE, data, "application/json")
r.raise_for_status()
def nc_url(path=""):
"""Build full Nextcloud WebDAV URL for a sub-path under the receipts folder."""
return NC_DAV_ROOT + path
def nc_get(path=""):
"""GET a resource from Nextcloud. Returns requests.Response."""
return requests.get(nc_url(path), auth=NC_AUTH, timeout=30)
def nc_put(path, data, content_type="application/octet-stream"):
"""PUT (upload/overwrite) a resource to Nextcloud."""
return requests.put(nc_url(path), data=data, auth=NC_AUTH,
headers={"Content-Type": content_type}, timeout=60)
def nc_delete(path):
"""DELETE a resource from Nextcloud."""
return requests.delete(nc_url(path), auth=NC_AUTH, timeout=30)
def nc_mkcol(path):
"""Create a collection (folder) on Nextcloud. Ignores 405 (already exists)."""
r = requests.request("MKCOL", nc_url(path), auth=NC_AUTH, timeout=15)
if r.status_code not in (201, 405):
r.raise_for_status()
def nc_propfind(url):
"""PROPFIND on an absolute URL. Returns the response."""
return requests.request("PROPFIND", url, auth=NC_AUTH,
headers={"Depth": "0"}, timeout=15)
# --- Helpers for receipts.json ------------------------------------------------
def load_receipts():
"""Read receipts.json from Nextcloud. Returns a list."""
r = nc_get(RECEIPTS_FILE)
if r.status_code == 404:
return []
r.raise_for_status()
return r.json()
def save_receipts(receipts):
"""Write receipts.json to Nextcloud."""
data = json.dumps(receipts, indent=2).encode()
r = nc_put(RECEIPTS_FILE, data, "application/json")
r.raise_for_status()
def build_excel(receipts):
"""Build an .xlsx file from the receipts list. Returns bytes."""
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "Receipts"
# Styles
header_font = Font(bold=True, color="FFFFFF", size=11)
header_fill = PatternFill("solid", fgColor="1A1A2E")
header_align = Alignment(horizontal="center")
thin_border = Border(
bottom=Side(style="thin", color="DDDDDD"),
)
money_fmt = '#,##0.00'
# Headers
headers = ["Date", "Amount ($)", "Category", "Note"]
col_widths = [14, 14, 14, 40]
for col_idx, (label, width) in enumerate(zip(headers, col_widths), 1):
cell = ws.cell(row=1, column=col_idx, value=label)
cell.font = header_font
cell.fill = header_fill
cell.alignment = header_align
ws.column_dimensions[cell.column_letter].width = width
# Data rows
for row_idx, r in enumerate(receipts, 2):
date_str = r.get("date", "")[:10] # YYYY-MM-DD
ws.cell(row=row_idx, column=1, value=date_str).border = thin_border
amt_cell = ws.cell(row=row_idx, column=2, value=r.get("amount", 0))
amt_cell.number_format = money_fmt
amt_cell.border = thin_border
ws.cell(row=row_idx, column=3,
value=r.get("category", "").capitalize()).border = thin_border
ws.cell(row=row_idx, column=4,
value=r.get("note", "")).border = thin_border
# Total row
if receipts:
total_row = len(receipts) + 2
total_label = ws.cell(row=total_row, column=1, value="TOTAL")
total_label.font = Font(bold=True)
total_cell = ws.cell(
row=total_row, column=2,
value=sum(r.get("amount", 0) for r in receipts))
total_cell.font = Font(bold=True)
total_cell.number_format = money_fmt
buf = io.BytesIO()
wb.save(buf)
return buf.getvalue()
# --- HTTP handler -------------------------------------------------------------
class ReuseTCPServer(HTTPServer):
allow_reuse_address = True
class Handler(BaseHTTPRequestHandler):
def _send_json(self, obj, status=200, extra_headers=None):
body = json.dumps(obj).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
if extra_headers:
for k, v in extra_headers.items():
self.send_header(k, v)
self.end_headers()
self.wfile.write(body)
def _get_session_token(self):
"""Extract session token from cookies."""
cookie_header = self.headers.get("Cookie", "")
cookie = SimpleCookie()
cookie.load(cookie_header)
if "session" in cookie:
return cookie["session"].value
return None
def _check_session(self):
"""Return True if the request has a valid session. Otherwise send 401."""
token = self._get_session_token()
if token and token in SESSIONS:
return True
self._send_json({"error": "Unauthorized"}, 401)
return False
def _read_body(self):
length = int(self.headers.get("Content-Length", 0))
return self.rfile.read(length)
def _send_error(self, status, message):
self._send_json({"error": message}, status)
# --- routing helpers ---
def _match(self, method, pattern):
"""Check method and return regex match against self.path, or None."""
if self.command != method:
return None
return re.fullmatch(pattern, self.path)
# --- GET -----------------------------------------------------------------
def do_GET(self):
# Serve receipts.html
if self.path == "/" or self.path == "/receipts.html":
try:
with open("receipts.html", "rb") as f:
body = f.read()
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
except FileNotFoundError:
self._send_error(404, "receipts.html not found")
return
# GET /api/receipts
if self.path == "/api/receipts":
if not self._check_session():
return
try:
receipts = load_receipts()
self._send_json(receipts)
except Exception as e:
self._send_error(502, str(e))
return
# GET /api/export — export receipts as Excel
if self.path == "/api/export":
if not self._check_session():
return
try:
receipts = load_receipts()
body = build_excel(receipts)
self.send_response(200)
self.send_header("Content-Type",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
self.send_header("Content-Disposition",
'attachment; filename="receipts.xlsx"')
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
except Exception as e:
self._send_error(500, str(e))
return
# GET /api/photos/<id>
m = re.fullmatch(r"/api/photos/([A-Za-z0-9_-]+)", self.path)
if m:
if not self._check_session():
return
photo_id = m.group(1)
try:
r = nc_get(f"{PHOTOS_DIR}/{photo_id}.jpg")
if r.status_code == 404:
self._send_error(404, "Photo not found")
return
r.raise_for_status()
self.send_response(200)
self.send_header("Content-Type", r.headers.get("Content-Type", "image/jpeg"))
self.send_header("Content-Length", str(len(r.content)))
self.send_header("Cache-Control", "max-age=86400")
self.end_headers()
self.wfile.write(r.content)
except Exception as e:
self._send_error(502, str(e))
return
self._send_error(404, "Not found")
# --- POST ----------------------------------------------------------------
def do_POST(self):
# POST /api/login
if self.path == "/api/login":
try:
data = json.loads(self._read_body())
auth = load_auth()
if (data.get("username") == auth["username"]
and hash_password(data.get("password", "")) == auth["password_hash"]):
token = secrets.token_hex(32)
SESSIONS[token] = True
self._send_json({"ok": True}, 200, extra_headers={
"Set-Cookie": f"session={token}; Path=/; HttpOnly; SameSite=Strict"
})
else:
self._send_json({"error": "Invalid credentials"}, 401)
except Exception as e:
self._send_error(500, str(e))
return
# POST /api/logout
if self.path == "/api/logout":
token = self._get_session_token()
if token:
SESSIONS.pop(token, None)
self._send_json({"ok": True}, 200, extra_headers={
"Set-Cookie": "session=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0"
})
return
# POST /api/change-password
if self.path == "/api/change-password":
if not self._check_session():
return
try:
data = json.loads(self._read_body())
auth = load_auth()
if hash_password(data.get("current", "")) != auth["password_hash"]:
self._send_json({"error": "Current password is incorrect"}, 403)
return
new_pw = data.get("new", "")
if len(new_pw) < 1:
self._send_json({"error": "New password cannot be empty"}, 400)
return
auth["password_hash"] = hash_password(new_pw)
save_auth(auth)
self._send_json({"ok": True})
except Exception as e:
self._send_error(500, str(e))
return
# POST /api/receipts — upsert a receipt
if self.path == "/api/receipts":
if not self._check_session():
return
try:
data = json.loads(self._read_body())
receipts = load_receipts()
idx = next((i for i, r in enumerate(receipts) if r["id"] == data["id"]), None)
if idx is not None:
receipts[idx] = data
else:
receipts.append(data)
save_receipts(receipts)
self._send_json(data, 200)
except Exception as e:
self._send_error(500, str(e))
return
# POST /api/photos/<id> — upload photo
m = re.fullmatch(r"/api/photos/([A-Za-z0-9_-]+)", self.path)
if m:
if not self._check_session():
return
photo_id = m.group(1)
try:
body = self._read_body()
r = nc_put(f"{PHOTOS_DIR}/{photo_id}.jpg", body, "image/jpeg")
r.raise_for_status()
self._send_json({"url": f"/api/photos/{photo_id}"})
except Exception as e:
self._send_error(502, str(e))
return
self._send_error(404, "Not found")
# --- DELETE --------------------------------------------------------------
def do_DELETE(self):
m = re.fullmatch(r"/api/receipts/([A-Za-z0-9_-]+)", self.path)
if m:
if not self._check_session():
return
receipt_id = m.group(1)
try:
receipts = load_receipts()
receipts = [r for r in receipts if r["id"] != receipt_id]
save_receipts(receipts)
# Also delete photo (ignore 404)
try:
nc_delete(f"{PHOTOS_DIR}/{receipt_id}.jpg")
except Exception:
pass
self._send_json({"ok": True})
except Exception as e:
self._send_error(500, str(e))
return
self._send_error(404, "Not found")
# Suppress default logging noise
def log_message(self, fmt, *args):
print(f"[{self.command}] {self.path}{args[1] if len(args) > 1 else args[0]}")
# --- Startup ------------------------------------------------------------------
def ensure_folder_path():
"""Create the full folder hierarchy on Nextcloud if it doesn't exist."""
parts = ["business-manager", "Expenses", "Receipts"]
base = f"{NC_BASE}/remote.php/dav/files/{urlquote(NC_USERNAME)}/"
current = base
for part in parts:
current += urlquote(part) + "/"
r = nc_propfind(current)
if r.status_code == 404:
print(f" Creating folder: {part}/")
mr = requests.request("MKCOL", current, auth=NC_AUTH, timeout=15)
if mr.status_code not in (201, 405):
print(f" ERROR creating {current}: {mr.status_code} {mr.text}")
sys.exit(1)
# Also ensure photos/ sub-folder
photos_url = current + urlquote(PHOTOS_DIR) + "/"
r = nc_propfind(photos_url)
if r.status_code == 404:
print(f" Creating folder: {PHOTOS_DIR}/")
mr = requests.request("MKCOL", photos_url, auth=NC_AUTH, timeout=15)
if mr.status_code not in (201, 405):
print(f" ERROR creating photos folder: {mr.status_code}")
sys.exit(1)
def main():
global NC_USERNAME, NC_PASSWORD, NC_DAV_ROOT, NC_AUTH
print("=== Receipt Manager — Nextcloud Backend ===\n")
if not NC_USERNAME:
NC_USERNAME = input("Nextcloud username: ").strip()
if not NC_PASSWORD:
NC_PASSWORD = getpass.getpass("App password: ").strip()
NC_AUTH = (NC_USERNAME, NC_PASSWORD)
NC_DAV_ROOT = (
f"{NC_BASE}/remote.php/dav/files/{urlquote(NC_USERNAME)}"
f"/business-manager/Expenses/Receipts/"
)
print("\nVerifying Nextcloud connectivity...")
try:
r = nc_propfind(
f"{NC_BASE}/remote.php/dav/files/{urlquote(NC_USERNAME)}/"
)
if r.status_code == 401:
print("ERROR: Authentication failed. Check username/app-password.")
sys.exit(1)
r.raise_for_status()
print(" Connected OK.")
except requests.ConnectionError:
print("ERROR: Cannot reach Nextcloud server.")
sys.exit(1)
print("Ensuring folder structure...")
ensure_folder_path()
print(" Folders OK.\n")
port = 8080
server = ReuseTCPServer(("", port), Handler)
print(f"Serving on http://localhost:{port}")
print("Press Ctrl+C to stop.\n")
try:
server.serve_forever()
except KeyboardInterrupt:
print("\nShutting down.")
server.server_close()
if __name__ == "__main__":
main()