mirror of
https://github.com/marcogll/TaxHacker_s23.git
synced 2026-01-13 13:25:18 +00:00
feat: invoice generator
This commit is contained in:
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
|
||||
37
lib/files.ts
37
lib/files.ts
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
60
lib/uploads.ts
Normal 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
|
||||
}
|
||||
42
lib/utils.ts
42
lib/utils.ts
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user