From b19cdd19fd96677bafb168a68b3e1b85652e8ebc Mon Sep 17 00:00:00 2001 From: Timeraider <57343973+GitTimeraider@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:52:57 +0200 Subject: [PATCH 1/3] Removal of public sharing --- README.md | 2 +- backend/src/controllers/invoices.ts | 227 +----------- backend/src/controllers/invoices_clean.ts | 25 +- backend/src/database/init.ts | 5 - backend/src/database/migrations.sql | 2 - backend/src/database/migrations_clean.sql | 2 - backend/src/routes/admin.ts | 30 +- backend/src/routes/public.ts | 340 +----------------- backend/src/types/index.ts | 1 - backend/src/utils/uuid.ts | 11 - frontend/src/lib/i18n/locales/en.json | 5 - .../src/routes/invoices/[id]/+page.server.ts | 18 - .../src/routes/invoices/[id]/+page.svelte | 62 +--- .../public/invoices/[token]/+page.server.ts | 8 - .../public/invoices/[token]/+page.svelte | 67 +--- 15 files changed, 19 insertions(+), 786 deletions(-) diff --git a/README.md b/README.md index 51bcc23..00d33c2 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ A modern, self-hosted invoice management platform for freelancers and small to m ## Features -- **Invoice Management** — Create, edit, and track invoices with draft/sent/paid/overdue/voided statuses and public share links +- **Invoice Management** — Create, edit, and track invoices with draft/sent/paid/overdue/voided statuses and direct email delivery - **Customer Database** — Store contact info, tax IDs, company details, and country codes - **Product Catalog** — Define products and services with pricing, units, SKUs, and tax categories - **Template Engine** — Multiple built-in templates (Professional Modern, Minimalist Clean, Slate, Nova) with custom upload support diff --git a/backend/src/controllers/invoices.ts b/backend/src/controllers/invoices.ts index ace8f96..2ef4702 100644 --- a/backend/src/controllers/invoices.ts +++ b/backend/src/controllers/invoices.ts @@ -14,7 +14,7 @@ import { StatusHistoryEntry, UpdateInvoiceRequest, } from "../types/index.ts"; -import { generateShareToken, generateUUID } from "../utils/uuid.ts"; +import { generateUUID } from "../utils/uuid.ts"; type LineTaxInput = { percent: number; @@ -296,7 +296,6 @@ export const createInvoice = ( ): InvoiceWithDetails => { const db = getDatabase(); const invoiceId = generateUUID(); - const shareToken = generateShareToken(); // Prefer client-provided invoiceNumber when unique; otherwise auto-generate let invoiceNumber = data.invoiceNumber; if (invoiceNumber) { @@ -413,7 +412,6 @@ export const createInvoice = ( notes: data.notes, // System fields - shareToken, createdAt: now, updatedAt: now, }; @@ -423,9 +421,9 @@ export const createInvoice = ( `INSERT INTO invoices ( id, invoice_number, customer_id, issue_date, due_date, currency, status, subtotal, discount_amount, discount_percentage, tax_rate, tax_amount, total, - payment_terms, notes, share_token, created_at, updated_at, + payment_terms, notes, created_at, updated_at, prices_include_tax, rounding_mode - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ invoice.id, invoice.invoiceNumber, @@ -442,7 +440,6 @@ export const createInvoice = ( invoice.total, invoice.paymentTerms, invoice.notes, - invoice.shareToken, invoice.createdAt, invoice.updatedAt, pricesIncludeTax ? 1 : 0, @@ -595,7 +592,7 @@ export const getInvoices = (): Invoice[] => { const results = db.query(` SELECT id, invoice_number, customer_id, issue_date, due_date, currency, status, subtotal, discount_amount, discount_percentage, tax_rate, tax_amount, total, - payment_terms, notes, share_token, created_at, updated_at, + payment_terms, notes, created_at, updated_at, prices_include_tax, rounding_mode FROM invoices ORDER BY created_at DESC @@ -610,7 +607,7 @@ export const getInvoiceById = (id: string): InvoiceWithDetails | null => { ` SELECT id, invoice_number, customer_id, issue_date, due_date, currency, status, subtotal, discount_amount, discount_percentage, tax_rate, tax_amount, total, - payment_terms, notes, share_token, created_at, updated_at, + payment_terms, notes, created_at, updated_at, prices_include_tax, rounding_mode FROM invoices WHERE id = ? @@ -708,114 +705,6 @@ export const getInvoiceById = (id: string): InvoiceWithDetails | null => { return { ...invoice, customer, items: itemsWithTaxes, taxes, statusHistory, emailLogs }; }; -export const getInvoiceByShareToken = ( - shareToken: string, -): InvoiceWithDetails | null => { - const db = getDatabase(); - const result = db.query( - ` - SELECT id, invoice_number, customer_id, issue_date, due_date, currency, status, - subtotal, discount_amount, discount_percentage, tax_rate, tax_amount, total, - payment_terms, notes, share_token, created_at, updated_at, - prices_include_tax, rounding_mode - FROM invoices - WHERE share_token = ? - `, - [shareToken], - ); - - if (result.length === 0) return null; - - let invoice = mapRowToInvoice(result[0] as unknown[]); - invoice = applyDerivedOverdue(invoice); - - // Draft invoices must never be exposed via public share links. - if (invoice.status === "draft") return null; - - // Get customer - const customer = getCustomerById(invoice.customerId); - if (!customer) return null; - - // Get items - const itemsResult = db.query( - ` - SELECT id, invoice_id, product_id, description, quantity, unit, unit_price, line_total, notes, sort_order - FROM invoice_items - WHERE invoice_id = ? - ORDER BY sort_order - `, - [invoice.id], - ); - - const items = itemsResult.map((row: unknown[]) => ({ - id: row[0] as string, - invoiceId: row[1] as string, - productId: row[2] ? String(row[2]) : undefined, - description: row[3] as string, - quantity: row[4] as number, - unit: row[5] ? String(row[5]) : undefined, - unitPrice: row[6] as number, - lineTotal: row[7] as number, - notes: row[8] as string, - sortOrder: row[9] as number, - })); - - // Attach per-item taxes - type ItemTaxRow2 = { - taxDefinitionId?: string; - percent: number; - taxableAmount: number; - amount: number; - included: boolean; - note?: string; - }; - let itemsWithTaxes = items.map((it) => ({ ...it })); - if (items.length > 0) { - const placeholders = items.map(() => "?").join(","); - const taxRows = db.query( - `SELECT invoice_item_id, tax_definition_id, percent, taxable_amount, amount, included, note FROM invoice_item_taxes WHERE invoice_item_id IN (${placeholders})`, - items.map((it) => it.id), - ); - const taxesByItem = new Map(); - for (const r of taxRows) { - const itemId = String((r as unknown[])[0]); - const tax: ItemTaxRow2 = { - taxDefinitionId: (r as unknown[])[1] - ? String((r as unknown[])[1]) - : undefined, - percent: Number((r as unknown[])[2]), - taxableAmount: Number((r as unknown[])[3]), - amount: Number((r as unknown[])[4]), - included: Boolean((r as unknown[])[5]), - note: (r as unknown[])[6] as string | undefined, - }; - if (!taxesByItem.has(itemId)) taxesByItem.set(itemId, []); - taxesByItem.get(itemId)!.push(tax); - } - itemsWithTaxes = items.map((it) => ({ - ...it, - taxes: taxesByItem.get(it.id), - })); - } - - // Invoice tax summary - const invTaxRows = db.query( - `SELECT id, invoice_id, tax_definition_id, percent, taxable_amount, tax_amount FROM invoice_taxes WHERE invoice_id = ?`, - [invoice.id], - ); - const taxes = invTaxRows.map((r) => ({ - id: r[0] as string, - invoiceId: r[1] as string, - taxDefinitionId: r[2] ? String(r[2]) : undefined, - percent: Number(r[3] as number), - taxableAmount: Number(r[4] as number), - taxAmount: Number(r[5] as number), - })); - - const statusHistory = getStatusHistory(String(invoice.id)); - return { ...invoice, customer, items: itemsWithTaxes, taxes, statusHistory }; -}; - export const updateInvoice = async ( id: string, data: Partial, @@ -1208,7 +1097,6 @@ export const duplicateInvoice = async ( if (!original) return null; const db = getDatabase(); const newId = generateUUID(); - const newShare = generateShareToken(); const now = new Date(); // Start as draft with a draft invoice number; copy descriptive fields, totals will be recalculated from items const items = original.items || []; @@ -1226,9 +1114,9 @@ export const duplicateInvoice = async ( INSERT INTO invoices ( id, invoice_number, customer_id, issue_date, due_date, currency, status, subtotal, discount_amount, discount_percentage, tax_rate, tax_amount, total, - payment_terms, notes, share_token, created_at, updated_at, + payment_terms, notes, created_at, updated_at, prices_include_tax, rounding_mode - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, [ newId, @@ -1246,7 +1134,7 @@ export const duplicateInvoice = async ( totals.total, original.paymentTerms || null, original.notes || null, - newShare, + "", now, now, (original as Invoice).pricesIncludeTax ? 1 : 0, @@ -1289,96 +1177,6 @@ export const duplicateInvoice = async ( return await getInvoiceById(newId); }; -export const publishInvoice = async ( - id: string, -): Promise<{ shareToken: string; shareUrl: string }> => { - const invoice = await getInvoiceById(id); - if (!invoice) { - throw new Error("Invoice not found"); - } - - // Validate minimal required fields before issuing - const missing: string[] = []; - if (!invoice.customer?.name) missing.push("customer.name"); - if (!invoice.items || invoice.items.length === 0) missing.push("items"); - if (!invoice.currency) missing.push("currency"); - if (!invoice.issueDate) missing.push("issueDate"); - if (missing.length) { - throw new Error( - `Cannot publish invoice. Missing required fields: ${missing.join(", ")}`, - ); - } - - // Update status to 'sent' if it's currently 'draft' - if (invoice.status === "draft") { - const db = getDatabase(); - const now = new Date(); - let num = invoice.invoiceNumber; - if (num.startsWith("DRAFT-")) { - num = getNextInvoiceNumber(); - } - db.execute("BEGIN"); - try { - db.query( - "UPDATE invoices SET status = 'sent', invoice_number = ?, updated_at = ? WHERE id = ?", - [num, now, id], - ); - recordStatusChange(db, id, "sent"); - db.execute("COMMIT"); - } catch (e) { - try { - db.execute("ROLLBACK"); - } catch { - /* ignore */ - } - throw e; - } - } - - const shareUrl = `${ - Deno.env.get("BASE_URL") || "http://localhost:3000" - }/api/v1/public/invoices/${invoice.shareToken}`; - - return { - shareToken: invoice.shareToken, - shareUrl, - }; -}; - -export const unpublishInvoice = async ( - id: string, -): Promise<{ shareToken: string }> => { - const existing = await getInvoiceById(id); - if (!existing) throw new Error("Invoice not found"); - - // Only sent or overdue invoices can be unpublished - if (existing.status !== "sent" && existing.status !== "overdue") { - throw new Error("Only sent or overdue invoices can be unpublished."); - } - - const db = getDatabase(); - const newToken = generateShareToken(); - const now = new Date(); - // Rotate share token to invalidate old public links and revert invoice to draft - db.execute("BEGIN"); - try { - db.query( - "UPDATE invoices SET share_token = ?, status = 'draft', updated_at = ? WHERE id = ?", - [newToken, now, id], - ); - recordStatusChange(db, id, "draft"); - db.execute("COMMIT"); - } catch (e) { - try { - db.execute("ROLLBACK"); - } catch { - /* ignore */ - } - throw e; - } - - return { shareToken: newToken }; -}; export const voidInvoice = async (id: string): Promise<{ success: true }> => { const existing = await getInvoiceById(id); @@ -1446,11 +1244,10 @@ function mapRowToInvoice(row: unknown[]): Invoice { total: row[12] as number, paymentTerms: row[13] as string, notes: row[14] as string, - shareToken: row[15] as string, - createdAt: new Date(row[16] as string), - updatedAt: new Date(row[17] as string), - pricesIncludeTax: Boolean(row[18] as number), - roundingMode: (row[19] as string) || "line", + createdAt: new Date(row[15] as string), + updatedAt: new Date(row[16] as string), + pricesIncludeTax: Boolean(row[17] as number), + roundingMode: (row[18] as string) || "line", }; } diff --git a/backend/src/controllers/invoices_clean.ts b/backend/src/controllers/invoices_clean.ts index 62035a7..113dcef 100644 --- a/backend/src/controllers/invoices_clean.ts +++ b/backend/src/controllers/invoices_clean.ts @@ -5,7 +5,6 @@ import { generateUUID } from "../utils/uuid.ts"; export const createInvoice = (data: Partial) => { const db = getDatabase(); const invoiceId = generateUUID(); - const shareToken = generateUUID(); const invoice: Invoice = { id: invoiceId, @@ -29,7 +28,6 @@ export const createInvoice = (data: Partial) => { notes: data.notes, // System fields - shareToken: shareToken, createdAt: new Date(), updatedAt: new Date(), }; @@ -38,8 +36,8 @@ export const createInvoice = (data: Partial) => { `INSERT INTO invoices ( id, invoice_number, customer_id, issue_date, due_date, currency, status, subtotal, discount_amount, discount_percentage, tax_rate, tax_amount, total, - payment_terms, notes, share_token, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + payment_terms, notes, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` , [ invoice.id, invoice.invoiceNumber, @@ -56,7 +54,6 @@ export const createInvoice = (data: Partial) => { invoice.total, invoice.paymentTerms, invoice.notes, - invoice.shareToken, invoice.createdAt, invoice.updatedAt, ], @@ -76,14 +73,6 @@ export const getInvoiceById = (id: string) => { return result.length > 0 ? result[0] : null; }; -export const getInvoiceByShareToken = (shareToken: string) => { - const db = getDatabase(); - const result = db.query("SELECT * FROM invoices WHERE share_token = ?", [ - shareToken, - ]); - return result.length > 0 ? result[0] : null; -}; - export const updateInvoice = (id: string, data: Partial) => { const db = getDatabase(); db.query( @@ -119,13 +108,3 @@ export const deleteInvoice = (id: string) => { db.query("DELETE FROM invoices WHERE id = ?", [id]); return true; }; - -export const publishInvoice = (id: string) => { - const shareToken = generateUUID(); - const db = getDatabase(); - db.query( - "UPDATE invoices SET share_token = ?, status = 'sent' WHERE id = ?", - [shareToken, id], - ); - return { shareToken }; -}; diff --git a/backend/src/database/init.ts b/backend/src/database/init.ts index 36c4978..15b6897 100644 --- a/backend/src/database/init.ts +++ b/backend/src/database/init.ts @@ -429,7 +429,6 @@ function migrateInvoicesForVoided(database: DB): void { total NUMERIC NOT NULL, payment_terms TEXT, notes TEXT, - share_token TEXT UNIQUE NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, prices_include_tax BOOLEAN DEFAULT 0, @@ -461,10 +460,6 @@ function migrateInvoicesForVoided(database: DB): void { database.execute( "CREATE INDEX IF NOT EXISTS idx_invoices_status ON invoices(status)", ); - database.execute( - "CREATE INDEX IF NOT EXISTS idx_invoices_share_token ON invoices(share_token)", - ); - database.execute("COMMIT"); console.log( " Migrated invoices table to support 'voided' and 'complete' statuses", diff --git a/backend/src/database/migrations.sql b/backend/src/database/migrations.sql index ef8f029..5671e66 100644 --- a/backend/src/database/migrations.sql +++ b/backend/src/database/migrations.sql @@ -68,7 +68,6 @@ CREATE TABLE invoices ( notes TEXT, -- System fields - share_token TEXT UNIQUE NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); @@ -116,7 +115,6 @@ ALTER TABLE templates ADD COLUMN template_type TEXT DEFAULT 'builtin'; CREATE INDEX idx_invoices_number ON invoices(invoice_number); CREATE INDEX idx_invoices_customer ON invoices(customer_id); CREATE INDEX idx_invoices_status ON invoices(status); -CREATE INDEX idx_invoices_share_token ON invoices(share_token); CREATE INDEX idx_invoice_items_invoice ON invoice_items(invoice_id); -- Normalized tax schema (for complex/composite taxes) diff --git a/backend/src/database/migrations_clean.sql b/backend/src/database/migrations_clean.sql index 94982d5..f78a3e4 100644 --- a/backend/src/database/migrations_clean.sql +++ b/backend/src/database/migrations_clean.sql @@ -44,7 +44,6 @@ CREATE TABLE invoices ( notes TEXT, -- System fields - share_token TEXT UNIQUE NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); @@ -105,7 +104,6 @@ INSERT OR IGNORE INTO templates (id, name, html, is_default) VALUES CREATE INDEX IF NOT EXISTS idx_invoices_number ON invoices(invoice_number); CREATE INDEX IF NOT EXISTS idx_invoices_customer ON invoices(customer_id); CREATE INDEX IF NOT EXISTS idx_invoices_status ON invoices(status); -CREATE INDEX IF NOT EXISTS idx_invoices_share_token ON invoices(share_token); CREATE INDEX IF NOT EXISTS idx_invoice_items_invoice ON invoice_items(invoice_id); -- Multi-user system: users & permissions diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts index 5aac738..16ff4ee 100644 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -7,9 +7,7 @@ import { getInvoiceById, getInvoices, getLatestPaidPaymentMethods, - publishInvoice, recordEmailLog, - unpublishInvoice, updateInvoice, voidInvoice, } from "../controllers/invoices.ts"; @@ -443,30 +441,6 @@ adminRoutes.delete( }, ); -adminRoutes.post( - "/invoices/:id/publish", - requirePermission("invoices", "publish"), - async (c) => { - const id = c.req.param("id"); - try { - const result = await publishInvoice(id); - return c.json(result); - } catch (e) { - return c.json({ error: String(e) }, 400); - } - }, -); - -adminRoutes.post( - "/invoices/:id/unpublish", - requirePermission("invoices", "publish"), - async (c) => { - const id = c.req.param("id"); - const result = await unpublishInvoice(id); - return c.json(result); - }, -); - adminRoutes.post( "/invoices/:id/duplicate", requirePermission("invoices", "create"), @@ -1992,9 +1966,7 @@ adminRoutes.post( : null; const origin = c.req.header("origin") || c.req.header("referer")?.replace(/\/$/, "") || ""; - const shareLink = invoice.shareToken && origin - ? `${origin}/public/invoices/${invoice.shareToken}` - : null; + const shareLink = null; // Substitute template variables in subject and message function interpolate(text: string): string { diff --git a/backend/src/routes/public.ts b/backend/src/routes/public.ts index cd8f148..ac2c9e4 100644 --- a/backend/src/routes/public.ts +++ b/backend/src/routes/public.ts @@ -1,15 +1,10 @@ // @ts-nocheck: simplify handlers without explicit typings import { Hono } from "hono"; import { normalize, relative, resolve } from "std/path"; -import { getInvoiceByShareToken } from "../controllers/invoices.ts"; -import { getSettings } from "../controllers/settings.ts"; -import { buildInvoiceHTML, generatePDF } from "../utils/pdf.ts"; -import { generateUBLInvoiceXML } from "../utils/ubl.ts"; // legacy direct import (will be removed after deprecation window) -import { generateInvoiceXML, listXMLProfiles } from "../utils/xmlProfiles.ts"; +import { listXMLProfiles } from "../utils/xmlProfiles.ts"; import { resolveInDataRoot } from "../utils/dataPaths.ts"; import { contentTypeFromLogoPath, - normalizeStoredLogoReference, resolveLogoFsPathFromPublicPath, } from "../utils/logoStorage.ts"; @@ -66,339 +61,6 @@ publicRoutes.get("/_template-assets/:id/:version/*", async (c) => { } }); -publicRoutes.get("/public/invoices/:share_token", async (c) => { - const shareToken = c.req.param("share_token"); - const invoice = await getInvoiceByShareToken(shareToken); - - if (!invoice) { - return c.json({ message: "Invoice not found" }, 404); - } - - return c.json(invoice); -}); - -publicRoutes.get("/public/invoices/:share_token/pdf", async (c) => { - const shareToken = c.req.param("share_token"); - const invoice = await getInvoiceByShareToken(shareToken); - if (!invoice) { - return c.json({ message: "Invoice not found" }, 404); - } - - // Settings map - const settings = getSettings(); - const settingsMap = settings.reduce( - (acc: Record, s) => { - acc[s.key] = s.value; - return acc; - }, - {} as Record, - ); - if (!settingsMap.postalCityFormat && settingsMap.postal_city_format) { - settingsMap.postalCityFormat = settingsMap.postal_city_format; - } - if (!settingsMap.postalCityFormat && settingsMap.postalcityformat) { - settingsMap.postalCityFormat = settingsMap.postalcityformat; - } - if (!settingsMap.logo && settingsMap.logoUrl) { - settingsMap.logo = settingsMap.logoUrl as string; - } - if (typeof settingsMap.logo === "string") { - settingsMap.logo = normalizeStoredLogoReference(settingsMap.logo); - } - - // Construct BusinessSettings with sane defaults; unified single 'logo' field - const businessSettings = { - companyName: settingsMap.companyName || "Your Company", - companyAddress: settingsMap.companyAddress || "", - companyCity: settingsMap.companyCity || "", - companyPostalCode: settingsMap.companyPostalCode || "", - companyCountryCode: settingsMap.companyCountryCode || - settingsMap.countryCode || "", - postalCityFormat: settingsMap.postalCityFormat || "auto", - companyEmail: settingsMap.companyEmail || "", - companyPhone: settingsMap.companyPhone || "", - companyTaxId: settingsMap.companyTaxId || "", - currency: settingsMap.currency || "USD", - taxLabel: settingsMap.taxLabel || undefined, - logo: settingsMap.logo, - paymentMethods: settingsMap.paymentMethods || "Bank Transfer", - bankAccount: settingsMap.bankAccount || "", - paymentTerms: settingsMap.paymentTerms || "Due in 30 days", - defaultNotes: settingsMap.defaultNotes || "", - locale: settingsMap.locale || undefined, - }; - - // Use template/highlight from settings only (no query overrides) - const highlight = settingsMap.highlight ?? undefined; - let selectedTemplateId: string | undefined = settingsMap.templateId - ?.toLowerCase(); - if ( - selectedTemplateId === "professional" || - selectedTemplateId === "professional-modern" - ) { - selectedTemplateId = "professional-modern"; - } else if ( - selectedTemplateId === "minimalist" || - selectedTemplateId === "minimalist-clean" - ) { - selectedTemplateId = "minimalist-clean"; - } - - try { - const embedXml = - String(settingsMap.embedXmlInPdf || "false").toLowerCase() === "true"; - const xmlProfileId = settingsMap.xmlProfileId || "ubl21"; - const pdfBuffer = await generatePDF( - invoice, - businessSettings, - selectedTemplateId, - highlight, - { - embedXml, - embedXmlProfileId: xmlProfileId, - dateFormat: settingsMap.dateFormat, - numberFormat: settingsMap.numberFormat, - locale: settingsMap.locale, - }, - ); - // Detect embedded attachments for diagnostics - let hasAttachment = false; - let attachmentNames: string[] = []; - try { - const { PDFDocument } = await import("pdf-lib"); - const doc = await PDFDocument.load(pdfBuffer); - const maybe = ( - doc as unknown as { getAttachments?: () => Record } - ).getAttachments?.(); - if (maybe && typeof maybe === "object") { - attachmentNames = Object.keys(maybe); - hasAttachment = attachmentNames.length > 0; - } - } catch (_e) { - /* ignore */ - } - return new Response(pdfBuffer, { - headers: { - "Content-Type": "application/pdf", - "Content-Disposition": `attachment; filename="invoice-${ - invoice.invoiceNumber || shareToken - }.pdf"`, - "X-Robots-Tag": "noindex", - ...(hasAttachment - ? { - "X-Embedded-XML": "true", - "X-Embedded-XML-Names": attachmentNames.join(","), - } - : { "X-Embedded-XML": "false" }), - }, - }); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - console.error("/public/invoices/:share_token/pdf failed:", msg); - return c.json({ error: "Failed to generate PDF", details: msg }, 500); - } -}); - -// Return invoice as HTML (same options as PDF, but no PDF generation) -publicRoutes.get("/public/invoices/:share_token/html", async (c) => { - const shareToken = c.req.param("share_token"); - const invoice = await getInvoiceByShareToken(shareToken); - if (!invoice) { - return c.json({ message: "Invoice not found" }, 404); - } - - const settings = getSettings(); - const settingsMap = settings.reduce( - (acc: Record, s) => { - acc[s.key] = s.value; - return acc; - }, - {} as Record, - ); - if (!settingsMap.postalCityFormat && settingsMap.postal_city_format) { - settingsMap.postalCityFormat = settingsMap.postal_city_format; - } - if (!settingsMap.postalCityFormat && settingsMap.postalcityformat) { - settingsMap.postalCityFormat = settingsMap.postalcityformat; - } - if (!settingsMap.logo && settingsMap.logoUrl) { - settingsMap.logo = settingsMap.logoUrl as string; - } - if (typeof settingsMap.logo === "string") { - settingsMap.logo = normalizeStoredLogoReference(settingsMap.logo); - } - - const businessSettings = { - companyName: settingsMap.companyName || "Your Company", - companyAddress: settingsMap.companyAddress || "", - companyCity: settingsMap.companyCity || "", - companyPostalCode: settingsMap.companyPostalCode || "", - companyCountryCode: settingsMap.companyCountryCode || - settingsMap.countryCode || "", - postalCityFormat: settingsMap.postalCityFormat || "auto", - companyEmail: settingsMap.companyEmail || "", - companyPhone: settingsMap.companyPhone || "", - companyTaxId: settingsMap.companyTaxId || "", - currency: settingsMap.currency || "USD", - taxLabel: settingsMap.taxLabel || undefined, - logo: settingsMap.logo, - paymentMethods: settingsMap.paymentMethods || "Bank Transfer", - bankAccount: settingsMap.bankAccount || "", - paymentTerms: settingsMap.paymentTerms || "Due in 30 days", - defaultNotes: settingsMap.defaultNotes || "", - locale: settingsMap.locale || undefined, - }; - - // Use template/highlight from settings only (no query overrides) - const highlight = settingsMap.highlight ?? undefined; - let selectedTemplateId: string | undefined = settingsMap.templateId - ?.toLowerCase(); - if ( - selectedTemplateId === "professional" || - selectedTemplateId === "professional-modern" - ) { - selectedTemplateId = "professional-modern"; - } else if ( - selectedTemplateId === "minimalist" || - selectedTemplateId === "minimalist-clean" - ) { - selectedTemplateId = "minimalist-clean"; - } - - const html = buildInvoiceHTML( - invoice, - businessSettings, - selectedTemplateId, - highlight, - settingsMap.dateFormat, - settingsMap.numberFormat, - settingsMap.locale, - ); - return new Response(html, { - headers: { - "Content-Type": "text/html; charset=utf-8", - "Cache-Control": "no-store", - "X-Robots-Tag": "noindex", - "X-Frame-Options": "SAMEORIGIN", - }, - }); -}); - -// Return invoice as UBL (PEPPOL BIS Billing 3.0) XML -publicRoutes.get("/public/invoices/:share_token/ubl.xml", async (c) => { - const shareToken = c.req.param("share_token"); - const invoice = await getInvoiceByShareToken(shareToken); - if (!invoice) { - return c.json({ message: "Invoice not found" }, 404); - } - - const settings = getSettings(); - const settingsMap = settings.reduce( - (acc: Record, s) => { - acc[s.key] = s.value; - return acc; - }, - {} as Record, - ); - - const businessSettings = { - companyName: settingsMap.companyName || "Your Company", - companyAddress: settingsMap.companyAddress || "", - companyCity: settingsMap.companyCity || "", - companyPostalCode: settingsMap.companyPostalCode || "", - companyCountryCode: settingsMap.companyCountryCode || "", - companyEmail: settingsMap.companyEmail || "", - companyPhone: settingsMap.companyPhone || "", - companyTaxId: settingsMap.companyTaxId || "", - currency: settingsMap.currency || "USD", - taxLabel: settingsMap.taxLabel || undefined, - logo: settingsMap.logo, - paymentMethods: settingsMap.paymentMethods || "Bank Transfer", - bankAccount: settingsMap.bankAccount || "", - paymentTerms: settingsMap.paymentTerms || "Due in 30 days", - defaultNotes: settingsMap.defaultNotes || "", - }; - - const xml = generateUBLInvoiceXML(invoice, businessSettings, { - sellerEndpointId: settingsMap.peppolSellerEndpointId, - sellerEndpointSchemeId: settingsMap.peppolSellerEndpointSchemeId, - buyerEndpointId: settingsMap.peppolBuyerEndpointId, - buyerEndpointSchemeId: settingsMap.peppolBuyerEndpointSchemeId, - sellerCountryCode: settingsMap.companyCountryCode, - buyerCountryCode: invoice.customer.countryCode, - }); - - return new Response(xml, { - headers: { - "Content-Type": "application/xml; charset=utf-8", - "Content-Disposition": `attachment; filename="invoice-${ - invoice.invoiceNumber || shareToken - }.xml"`, - "X-Robots-Tag": "noindex", - }, - }); -}); - -// Generic XML export endpoint selecting a profile (built-in only for now) -// Query param: ?profile=ubl21 (default) -publicRoutes.get("/public/invoices/:share_token/xml", async (c) => { - const shareToken = c.req.param("share_token"); - const invoice = await getInvoiceByShareToken(shareToken); - if (!invoice) return c.json({ message: "Invoice not found" }, 404); - - const settings = getSettings(); - const settingsMap = settings.reduce( - (acc: Record, s) => { - acc[s.key] = s.value; - return acc; - }, - {} as Record, - ); - - const businessSettings = { - companyName: settingsMap.companyName || "Your Company", - companyAddress: settingsMap.companyAddress || "", - companyEmail: settingsMap.companyEmail || "", - companyPhone: settingsMap.companyPhone || "", - companyTaxId: settingsMap.companyTaxId || "", - currency: settingsMap.currency || "USD", - taxLabel: settingsMap.taxLabel || undefined, - logo: settingsMap.logo, - paymentMethods: settingsMap.paymentMethods || "Bank Transfer", - bankAccount: settingsMap.bankAccount || "", - paymentTerms: settingsMap.paymentTerms || "Due in 30 days", - defaultNotes: settingsMap.defaultNotes || "", - companyCountryCode: settingsMap.companyCountryCode || "", - }; - - const url = new URL(c.req.url); - const profileParam = url.searchParams.get("profile") || - settingsMap.xmlProfileId || undefined; - const { xml, profile } = generateInvoiceXML( - profileParam, - invoice, - businessSettings, - { - sellerEndpointId: settingsMap.peppolSellerEndpointId, - sellerEndpointSchemeId: settingsMap.peppolSellerEndpointSchemeId, - buyerEndpointId: settingsMap.peppolBuyerEndpointId, - buyerEndpointSchemeId: settingsMap.peppolBuyerEndpointSchemeId, - sellerCountryCode: settingsMap.companyCountryCode, - buyerCountryCode: invoice.customer.countryCode, - }, - ); - - return new Response(xml, { - headers: { - "Content-Type": `${profile.mediaType}; charset=utf-8`, - "Content-Disposition": `attachment; filename="invoice-${ - invoice.invoiceNumber || shareToken - }.${profile.fileExtension}"`, - "X-Robots-Tag": "noindex", - }, - }); -}); - // List available built-in XML profiles (public; could also require auth, but contents are non-sensitive) publicRoutes.get("/public/xml-profiles", (c) => { const profiles = listXMLProfiles().map((p) => ({ diff --git a/backend/src/types/index.ts b/backend/src/types/index.ts index cb1bf12..12f8549 100644 --- a/backend/src/types/index.ts +++ b/backend/src/types/index.ts @@ -59,7 +59,6 @@ export interface Invoice { locale?: string; // System fields - shareToken: string; createdAt: Date; updatedAt: Date; } diff --git a/backend/src/utils/uuid.ts b/backend/src/utils/uuid.ts index 486f99b..a70f4bf 100644 --- a/backend/src/utils/uuid.ts +++ b/backend/src/utils/uuid.ts @@ -3,14 +3,3 @@ export function generateUUID(): string { return crypto.randomUUID(); } -// Generate a long random token suitable for share links (base64url, 32 bytes => 43 chars) -export function generateShareToken(bytes: number = 32): string { - const arr = new Uint8Array(bytes); - crypto.getRandomValues(arr); - // Convert to base64url without padding - const b64 = btoa(String.fromCharCode(...arr as unknown as number[])) - .replace(/\+/g, "-") - .replace(/\//g, "_") - .replace(/=+$/g, ""); - return b64; -} diff --git a/frontend/src/lib/i18n/locales/en.json b/frontend/src/lib/i18n/locales/en.json index ada306d..7933104 100644 --- a/frontend/src/lib/i18n/locales/en.json +++ b/frontend/src/lib/i18n/locales/en.json @@ -119,7 +119,6 @@ "Invoice Number": "Invoice Number", "Invoice Number Pattern": "Invoice Number Pattern", "Invoice data hidden": "Invoice data hidden", - "Invoice published": "Invoice published", "Invoice total": "Invoice total", "Invoices": "Invoices", "Issue Date": "Issue Date", @@ -194,8 +193,6 @@ "Product Categories": "Product Categories", "Product Units": "Product Units", "Products": "Products", - "Public link": "Public link", - "Publish": "Publish", "Qty": "Qty", "Quantity": "Quantity", "Reactivate": "Reactivate", @@ -267,7 +264,6 @@ "Unit Price": "Unit Price", "Unknown Customer": "Unknown Customer", "Unnamed": "Unnamed", - "Unpublish": "Unpublish", "Update": "Update", "Updated": "Updated", "Upload": "Upload", @@ -283,7 +279,6 @@ "Version": "Version", "Verify and Enable 2FA": "Verify and Enable 2FA", "View HTML": "View HTML", - "View public link": "View public link", "Void Invoice": "Void Invoice", "Warning: you are editing a sent/paid/complete invoice. Ensure this is legally allowed in your jurisdiction.": "Warning: you are editing a sent/paid/complete invoice. Ensure this is legally allowed in your jurisdiction.", "Void this invoice?": "Void this invoice?", diff --git a/frontend/src/routes/invoices/[id]/+page.server.ts b/frontend/src/routes/invoices/[id]/+page.server.ts index 4e29f4e..cc91e3f 100644 --- a/frontend/src/routes/invoices/[id]/+page.server.ts +++ b/frontend/src/routes/invoices/[id]/+page.server.ts @@ -32,10 +32,8 @@ export const load: PageServerLoad = async ({ params, locals, url }) => { const allowProtectedInvoiceChanges = String(settings.allowProtectedInvoiceChanges || "false").toLowerCase() === "true"; - const showPublishedBanner = url.searchParams.get("published") === "1"; return { invoice: invoiceRes.value, - showPublishedBanner, allowProtectedInvoiceChanges, defaultEmailConfigId: typeof settings.defaultEmailConfigId === "string" @@ -64,14 +62,6 @@ export const actions: Actions = { await backendDelete(`/api/v1/invoices/${id}`, locals.authHeader); throw redirect(303, "/invoices"); } - if (intent === "publish") { - await backendPost( - `/api/v1/invoices/${id}/publish`, - locals.authHeader, - {}, - ); - throw redirect(303, `/invoices/${id}?published=1`); - } if (intent === "mark-sent") { await backendPut(`/api/v1/invoices/${id}`, locals.authHeader, { status: "sent", @@ -103,14 +93,6 @@ export const actions: Actions = { if (!newId) throw new Error("Failed to duplicate invoice"); throw redirect(303, `/invoices/${newId}/edit`); } - if (intent === "unpublish") { - await backendPost( - `/api/v1/invoices/${id}/unpublish`, - locals.authHeader, - {}, - ); - throw redirect(303, `/invoices/${id}`); - } if (intent === "void") { await backendPost(`/api/v1/invoices/${id}/void`, locals.authHeader, {}); throw redirect(303, `/invoices/${id}`); diff --git a/frontend/src/routes/invoices/[id]/+page.svelte b/frontend/src/routes/invoices/[id]/+page.svelte index 551cec0..25a10e7 100644 --- a/frontend/src/routes/invoices/[id]/+page.svelte +++ b/frontend/src/routes/invoices/[id]/+page.svelte @@ -1,6 +1,6 @@ - -
- -
-
- {t("Invoice")} -
- -
- - -
- - - - - - - - {t("Download PDF")} - -
-
- - -
-
- -
-
-
+ \ No newline at end of file From 3a79b878a3ecd2c67d84c9ccd842d3d87c6e126d Mon Sep 17 00:00:00 2001 From: Timeraider <57343973+GitTimeraider@users.noreply.github.com> Date: Fri, 31 Jul 2026 15:56:43 +0200 Subject: [PATCH 2/3] Removal of the public link feature --- frontend/src/routes/invoices/[id]/+page.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/routes/invoices/[id]/+page.svelte b/frontend/src/routes/invoices/[id]/+page.svelte index 25a10e7..e492b8e 100644 --- a/frontend/src/routes/invoices/[id]/+page.svelte +++ b/frontend/src/routes/invoices/[id]/+page.svelte @@ -1,6 +1,6 @@