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:
12
Dockerfile
Normal file
12
Dockerfile
Normal 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
7
docker-compose.yml
Normal file
@@ -0,0 +1,7 @@
|
||||
services:
|
||||
receipt-manager:
|
||||
build: .
|
||||
container_name: receipt-manager
|
||||
restart: always
|
||||
ports:
|
||||
- "8082:8080"
|
||||
957
receipts.html
Normal file
957
receipts.html
Normal 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">⚙</button>
|
||||
<button class="header-icon-btn" id="logout-btn" title="Log Out">⏻</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">📎</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 ? " — " + escapeHtml(r.note) : ""}</div>
|
||||
</div>
|
||||
<div class="receipt-actions">
|
||||
<button title="Edit" data-edit="${r.id}">✎</button>
|
||||
<button title="Delete" data-delete="${r.id}">×</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
2
requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
requests
|
||||
openpyxl
|
||||
466
server.py
Normal file
466
server.py
Normal 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()
|
||||
Reference in New Issue
Block a user