feat: invoice generator

This commit is contained in:
Vasily Zubarev
2025-05-07 14:53:13 +02:00
parent 287abbb219
commit 8b5a2e8056
59 changed files with 2606 additions and 124 deletions

View File

@@ -16,6 +16,7 @@ export type UserProfile = {
name: string
email: string
avatar?: string
membershipPlan: string
storageUsed: number
storageLimit: number
aiBalance: number
@@ -37,7 +38,7 @@ export const auth = betterAuth({
updateAge: 24 * 60 * 60, // 24 hours
cookieCache: {
enabled: true,
maxAge: 24 * 60 * 60, // 24 hours
maxAge: 7 * 24 * 60 * 60, // 7 days
},
},
advanced: {

View File

@@ -30,6 +30,18 @@ const config = {
},
upload: {
acceptedMimeTypes: "image/*,.pdf,.doc,.docx,.xls,.xlsx",
images: {
maxWidth: 1800,
maxHeight: 1800,
quality: 90,
},
pdfs: {
maxPages: 10,
dpi: 150,
quality: 90,
maxWidth: 1500,
maxHeight: 1500,
},
},
selfHosted: {
isEnabled: env.SELF_HOSTED_MODE === "true",

View File

@@ -6,34 +6,39 @@ import config from "./config"
export const FILE_UPLOAD_PATH = path.resolve(process.env.UPLOAD_PATH || "./uploads")
export const FILE_UNSORTED_DIRECTORY_NAME = "unsorted"
export const FILE_PREVIEWS_DIRECTORY_NAME = "previews"
export const FILE_STATIC_DIRECTORY_NAME = "static"
export const FILE_IMPORT_CSV_DIRECTORY_NAME = "csv"
export async function getUserUploadsDirectory(user: User) {
return path.join(FILE_UPLOAD_PATH, user.email)
export function getUserUploadsDirectory(user: User) {
return safePathJoin(FILE_UPLOAD_PATH, user.email)
}
export async function getUserPreviewsDirectory(user: User) {
return path.join(FILE_UPLOAD_PATH, user.email, FILE_PREVIEWS_DIRECTORY_NAME)
export function getStaticDirectory(user: User) {
return safePathJoin(getUserUploadsDirectory(user), FILE_STATIC_DIRECTORY_NAME)
}
export async function unsortedFilePath(fileUuid: string, filename: string): Promise<string> {
export function getUserPreviewsDirectory(user: User) {
return safePathJoin(getUserUploadsDirectory(user), FILE_PREVIEWS_DIRECTORY_NAME)
}
export function unsortedFilePath(fileUuid: string, filename: string) {
const fileExtension = path.extname(filename)
return path.join(FILE_UNSORTED_DIRECTORY_NAME, `${fileUuid}${fileExtension}`)
return safePathJoin(FILE_UNSORTED_DIRECTORY_NAME, `${fileUuid}${fileExtension}`)
}
export async function previewFilePath(fileUuid: string, page: number): Promise<string> {
return path.join(FILE_PREVIEWS_DIRECTORY_NAME, `${fileUuid}.${page}.webp`)
export function previewFilePath(fileUuid: string, page: number) {
return safePathJoin(FILE_PREVIEWS_DIRECTORY_NAME, `${fileUuid}.${page}.webp`)
}
export async function getTransactionFileUploadPath(fileUuid: string, filename: string, transaction: Transaction) {
export function getTransactionFileUploadPath(fileUuid: string, filename: string, transaction: Transaction) {
const fileExtension = path.extname(filename)
const storedFileName = `${fileUuid}${fileExtension}`
return formatFilePath(storedFileName, transaction.issuedAt || new Date())
}
export async function fullPathForFile(user: User, file: File) {
const userUploadsDirectory = await getUserUploadsDirectory(user)
return path.join(userUploadsDirectory, path.normalize(file.path))
export function fullPathForFile(user: User, file: File) {
const userUploadsDirectory = getUserUploadsDirectory(user)
return safePathJoin(userUploadsDirectory, file.path)
}
function formatFilePath(filename: string, date: Date, format = "{YYYY}/{MM}/{name}{ext}") {
@@ -45,6 +50,14 @@ function formatFilePath(filename: string, date: Date, format = "{YYYY}/{MM}/{nam
return format.replace("{YYYY}", String(year)).replace("{MM}", month).replace("{name}", name).replace("{ext}", ext)
}
export function safePathJoin(basePath: string, ...paths: string[]) {
const joinedPath = path.join(basePath, path.normalize(path.join(...paths)))
if (!joinedPath.startsWith(basePath)) {
throw new Error("Path traversal detected")
}
return joinedPath
}
export async function fileExists(filePath: string) {
try {
await access(path.normalize(filePath), constants.F_OK)

View File

@@ -1,27 +1,25 @@
"use server"
import { fileExists, getUserPreviewsDirectory } from "@/lib/files"
import { fileExists, getUserPreviewsDirectory, safePathJoin } from "@/lib/files"
import { User } from "@/prisma/client"
import fs from "fs/promises"
import path from "path"
import sharp from "sharp"
const MAX_WIDTH = 1800
const MAX_HEIGHT = 1800
const QUALITY = 90
import config from "../config"
export async function resizeImage(
user: User,
origFilePath: string,
maxWidth: number = MAX_WIDTH,
maxHeight: number = MAX_HEIGHT
maxWidth: number = config.upload.images.maxWidth,
maxHeight: number = config.upload.images.maxHeight,
quality: number = config.upload.images.quality
): Promise<{ contentType: string; resizedPath: string }> {
try {
const userPreviewsDirectory = await getUserPreviewsDirectory(user)
const userPreviewsDirectory = getUserPreviewsDirectory(user)
await fs.mkdir(userPreviewsDirectory, { recursive: true })
const basename = path.basename(origFilePath, path.extname(origFilePath))
const outputPath = path.join(userPreviewsDirectory, `${basename}.webp`)
const outputPath = safePathJoin(userPreviewsDirectory, `${basename}.webp`)
if (await fileExists(outputPath)) {
const metadata = await sharp(outputPath).metadata()
@@ -37,7 +35,7 @@ export async function resizeImage(
fit: "inside",
withoutEnlargement: true,
})
.webp({ quality: QUALITY })
.webp({ quality: quality })
.toFile(outputPath)
return {

View File

@@ -1,26 +1,21 @@
"use server"
import { fileExists, getUserPreviewsDirectory } from "@/lib/files"
import { fileExists, getUserPreviewsDirectory, safePathJoin } from "@/lib/files"
import { User } from "@/prisma/client"
import fs from "fs/promises"
import path from "path"
import { fromPath } from "pdf2pic"
const MAX_PAGES = 10
const DPI = 150
const QUALITY = 90
const MAX_WIDTH = 1500
const MAX_HEIGHT = 1500
import config from "../config"
export async function pdfToImages(user: User, origFilePath: string): Promise<{ contentType: string; pages: string[] }> {
const userPreviewsDirectory = await getUserPreviewsDirectory(user)
const userPreviewsDirectory = getUserPreviewsDirectory(user)
await fs.mkdir(userPreviewsDirectory, { recursive: true })
const basename = path.basename(origFilePath, path.extname(origFilePath))
// Check if converted pages already exist
const existingPages: string[] = []
for (let i = 1; i <= MAX_PAGES; i++) {
const convertedFilePath = path.join(userPreviewsDirectory, `${basename}.${i}.webp`)
for (let i = 1; i <= config.upload.pdfs.maxPages; i++) {
const convertedFilePath = safePathJoin(userPreviewsDirectory, `${basename}.${i}.webp`)
if (await fileExists(convertedFilePath)) {
existingPages.push(convertedFilePath)
} else {
@@ -34,13 +29,13 @@ export async function pdfToImages(user: User, origFilePath: string): Promise<{ c
// If not — convert the file as store in previews folder
const pdf2picOptions = {
density: DPI,
density: config.upload.pdfs.dpi,
saveFilename: basename,
savePath: userPreviewsDirectory,
format: "webp",
quality: QUALITY,
width: MAX_WIDTH,
height: MAX_HEIGHT,
quality: config.upload.pdfs.quality,
width: config.upload.pdfs.maxWidth,
height: config.upload.pdfs.maxHeight,
preserveAspectRatio: true,
}

60
lib/uploads.ts Normal file
View File

@@ -0,0 +1,60 @@
import { User } from "@/prisma/client"
import { mkdir } from "fs/promises"
import path from "path"
import sharp from "sharp"
import config from "./config"
import { getStaticDirectory, isEnoughStorageToUploadFile, safePathJoin } from "./files"
export async function uploadStaticImage(
user: User,
file: File,
saveFileName: string,
maxWidth: number = config.upload.images.maxWidth,
maxHeight: number = config.upload.images.maxHeight,
quality: number = config.upload.images.quality
) {
const uploadDirectory = getStaticDirectory(user)
if (!isEnoughStorageToUploadFile(user, file.size)) {
throw Error("Not enough space to upload the file")
}
await mkdir(uploadDirectory, { recursive: true })
// Get target format from saveFileName extension
const targetFormat = path.extname(saveFileName).slice(1).toLowerCase()
if (!targetFormat) {
throw Error("Target filename must have an extension")
}
// Convert image and save to static folder
const uploadFilePath = safePathJoin(uploadDirectory, saveFileName)
const arrayBuffer = await file.arrayBuffer()
const buffer = Buffer.from(arrayBuffer)
const sharpInstance = sharp(buffer).rotate().resize(maxWidth, maxHeight, {
fit: "inside",
withoutEnlargement: true,
})
// Set output format and quality
switch (targetFormat) {
case "png":
await sharpInstance.png().toFile(uploadFilePath)
break
case "jpg":
case "jpeg":
await sharpInstance.jpeg({ quality }).toFile(uploadFilePath)
break
case "webp":
await sharpInstance.webp({ quality }).toFile(uploadFilePath)
break
case "avif":
await sharpInstance.avif({ quality }).toFile(uploadFilePath)
break
default:
throw Error(`Unsupported target format: ${targetFormat}`)
}
return uploadFilePath
}

View File

@@ -8,14 +8,19 @@ export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function formatCurrency(total: number, currency: string) {
return new Intl.NumberFormat(LOCALE, {
style: "currency",
currency: currency,
minimumFractionDigits: 2,
maximumFractionDigits: 2,
useGrouping: true,
}).format(total / 100)
export function formatCurrency(total: number, currency: string, separator: string = "") {
try {
return new Intl.NumberFormat(LOCALE, {
style: "currency",
currency: currency,
minimumFractionDigits: 2,
maximumFractionDigits: 2,
useGrouping: true,
}).format(total / 100)
} catch (error) {
// can happen with custom currencies and crypto
return `${currency} ${total / 100}`
}
}
export function formatBytes(bytes: number) {
@@ -49,3 +54,24 @@ export function codeFromName(name: string, maxLength: number = 16) {
export function randomHexColor() {
return "#" + Math.floor(Math.random() * 16777215).toString(16)
}
export async function fetchAsBase64(url: string): Promise<string | null> {
try {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`Failed to fetch image: ${response.statusText}`)
}
const blob = await response.blob()
return await new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onloadend = () => resolve(reader.result as string)
reader.onerror = reject
reader.readAsDataURL(blob)
})
} catch (error) {
console.error("Error fetching image as data URL:", error)
return null
}
}