Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
253 changes: 253 additions & 0 deletions backend/app/api/handlers/v1/v1_ctrl_pdf_export.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,253 @@
package v1

import (
"fmt"
"net/http"
"strings"

"github.com/google/uuid"
"github.com/hay-kot/httpkit/errchain"
"github.com/hay-kot/httpkit/server"
"github.com/rs/zerolog/log"
"github.com/sysadminsmedia/homebox/backend/internal/core/services"
"github.com/sysadminsmedia/homebox/backend/internal/data/repo"
"github.com/sysadminsmedia/homebox/backend/internal/sys/validate"
)

const (
// maxPDFExportItems caps the number of items in a single PDF export
// to prevent excessive memory usage and request timeouts.
maxPDFExportItems = 500
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would recommend making this a configurable option, those with weak systems might want it lower, and those with very powerful home labs might be able to go much higher.

)

// sanitizeFilename removes or escapes characters that could cause issues
// in HTTP Content-Disposition headers (e.g., header injection via quotes,
// newlines, or semicolons in user-controlled asset IDs).
func sanitizeFilename(name string) string {
replacer := strings.NewReplacer(
"\"", "'",
"\n", "",
"\r", "",
";", "_",
)
return replacer.Replace(name)
}

// HandleItemExportPDF godoc
//
// @Summary Export Single Item as PDF
// @Tags Items
// @Produce application/pdf
// @Param id path string true "Item ID"
// @Param theme query string false "PDF theme (navy, modern, minimal, forest)"
// @Param photos query bool false "Include photos in export (default: true)"
// @Param owner query string false "Owner name for cover page"
// @Success 200 {file} file "PDF document"
// @Router /v1/items/{id}/export/pdf [GET]
// @Security Bearer
func (ctrl *V1Controller) HandleItemExportPDF() errchain.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) error {
// Parse the item ID from the URL path
itemID, err := ctrl.routeID(r)
if err != nil {
return err
}

ctx := services.NewContext(r.Context())

// Build export options from query parameters
opts := services.PDFExportOptions{
Theme: r.URL.Query().Get("theme"),
IncludePhotos: r.URL.Query().Get("photos") != "false", // default true
OwnerName: r.URL.Query().Get("owner"),
}

// Generate the PDF using the export service
pdfSvc := services.NewPDFExportService(ctrl.repo)
pdfBytes, filename, err := pdfSvc.ExportSingleItem(ctx, ctx.GID, itemID, opts)
if err != nil {
log.Err(err).Msg("failed to export item as PDF")
return validate.NewRequestError(err, http.StatusInternalServerError)
}

// Set response headers for PDF file download
w.Header().Set("Content-Type", "application/pdf")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", sanitizeFilename(filename)))
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(pdfBytes)))

_, err = w.Write(pdfBytes)
return err
}
}

// multiExportRequest is the JSON body for bulk PDF export requests.
// Clients send a list of item IDs to include in the report.
type multiExportRequest struct {
ItemIDs []string `json:"itemIds" validate:"required,min=1"`
}

// HandleItemsExportPDF godoc
//
// @Summary Export Multiple Items as PDF
// @Tags Items
// @Accept json
// @Produce application/pdf
// @Param payload body multiExportRequest true "Item IDs to export"
// @Param theme query string false "PDF theme (navy, modern, minimal, forest)"
// @Param photos query bool false "Include photos in export (default: true)"
// @Param owner query string false "Owner name for cover page"
// @Success 200 {file} file "PDF document"
// @Router /v1/items/export/pdf [POST]
// @Security Bearer
func (ctrl *V1Controller) HandleItemsExportPDF() errchain.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) error {
ctx := services.NewContext(r.Context())

// Decode the request body containing item IDs
var body multiExportRequest
if err := server.Decode(r, &body); err != nil {
return validate.NewRequestError(err, http.StatusBadRequest)
}

if len(body.ItemIDs) == 0 {
return validate.NewRequestError(
fmt.Errorf("at least one item ID is required"),
http.StatusBadRequest,
)
}

// Parse string UUIDs into uuid.UUID values
itemIDs, err := parseUUIDs(body.ItemIDs)
if err != nil {
return validate.NewRequestError(err, http.StatusBadRequest)
}

// Build export options from query parameters
opts := services.PDFExportOptions{
Theme: r.URL.Query().Get("theme"),
IncludePhotos: r.URL.Query().Get("photos") != "false",
OwnerName: r.URL.Query().Get("owner"),
}

// Generate the multi-item PDF report
pdfSvc := services.NewPDFExportService(ctrl.repo)
pdfBytes, filename, err := pdfSvc.ExportMultipleItems(ctx, ctx.GID, itemIDs, opts)
if err != nil {
log.Err(err).Msg("failed to export items as PDF")
return validate.NewRequestError(err, http.StatusInternalServerError)
}

// Set response headers for PDF file download
w.Header().Set("Content-Type", "application/pdf")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", sanitizeFilename(filename)))
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(pdfBytes)))

_, err = w.Write(pdfBytes)
return err
}
}

// HandleItemsExportAllPDF godoc
//
// @Summary Export All Items as PDF
// @Tags Items
// @Produce application/pdf
// @Param theme query string false "PDF theme (navy, modern, minimal, forest)"
// @Param photos query bool false "Include photos in export (default: true)"
// @Param owner query string false "Owner name for cover page"
// @Success 200 {file} file "PDF document"
// @Router /v1/items/export/pdf [GET]
// @Security Bearer
func (ctrl *V1Controller) HandleItemsExportAllPDF() errchain.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) error {
ctx := services.NewContext(r.Context())

// Query all items for the user's group with pagination disabled (-1 = all results)
allItems, err := ctrl.repo.Items.QueryByGroup(ctx, ctx.GID, repo.ItemQuery{
Page: -1,
PageSize: -1,
})
if err != nil {
log.Err(err).Msg("failed to query items for PDF export")
return validate.NewRequestError(err, http.StatusInternalServerError)
}

if len(allItems.Items) == 0 {
return validate.NewRequestError(
fmt.Errorf("no items found to export"),
http.StatusNotFound,
)
}

// Enforce item limit to prevent excessive memory usage and timeouts
if len(allItems.Items) > maxPDFExportItems {
return validate.NewRequestError(
fmt.Errorf("too many items to export (%d); maximum is %d — use bulk export with specific item IDs instead", len(allItems.Items), maxPDFExportItems),
http.StatusBadRequest,
)
}

// Collect all item IDs from the query result
itemIDs := make([]uuid.UUID, len(allItems.Items))
for i, item := range allItems.Items {
itemIDs[i] = item.ID
}

// Build export options from query parameters
opts := services.PDFExportOptions{
Theme: r.URL.Query().Get("theme"),
IncludePhotos: r.URL.Query().Get("photos") != "false",
OwnerName: r.URL.Query().Get("owner"),
}

// Generate the full-inventory PDF report
pdfSvc := services.NewPDFExportService(ctrl.repo)
pdfBytes, filename, err := pdfSvc.ExportMultipleItems(ctx, ctx.GID, itemIDs, opts)
if err != nil {
log.Err(err).Msg("failed to export all items as PDF")
return validate.NewRequestError(err, http.StatusInternalServerError)
}

// Set response headers for PDF file download
w.Header().Set("Content-Type", "application/pdf")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", sanitizeFilename(filename)))
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(pdfBytes)))

_, err = w.Write(pdfBytes)
return err
}
}

// HandlePDFThemes godoc
//
// @Summary Get Available PDF Themes
// @Tags Items
// @Produce json
// @Success 200 {object} map[string]string
// @Router /v1/items/export/pdf/themes [GET]
// @Security Bearer
func (ctrl *V1Controller) HandlePDFThemes() errchain.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) error {
// Return a map of theme key -> display name for the frontend to render
themes := make(map[string]string)
for key, theme := range services.PDFThemes {
themes[key] = theme.Name
}

return server.JSON(w, http.StatusOK, themes)
}
}

// parseUUIDs converts a slice of string UUIDs to uuid.UUID values.
// Returns an error if any string is not a valid UUID.
func parseUUIDs(strs []string) ([]uuid.UUID, error) {
ids := make([]uuid.UUID, 0, len(strs))
for _, s := range strs {
id, err := uuid.Parse(s)
if err != nil {
return nil, fmt.Errorf("invalid UUID: %s", s)
}
ids = append(ids, id)
}
return ids, nil
}
5 changes: 5 additions & 0 deletions backend/app/api/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,10 @@ func (a *app) mountRoutes(r *chi.Mux, chain *errchain.ErrChain, repos *repo.AllR
r.Post("/items", chain.ToHandlerFunc(v1Ctrl.HandleItemsCreate(), userMW...))
r.Post("/items/import", chain.ToHandlerFunc(v1Ctrl.HandleItemsImport(), userMW...))
r.Get("/items/export", chain.ToHandlerFunc(v1Ctrl.HandleItemsExport(), userMW...))
// PDF export endpoints — single item, bulk selection, full inventory, and theme listing
r.Get("/items/export/pdf", chain.ToHandlerFunc(v1Ctrl.HandleItemsExportAllPDF(), userMW...))
r.Post("/items/export/pdf", chain.ToHandlerFunc(v1Ctrl.HandleItemsExportPDF(), userMW...))
r.Get("/items/export/pdf/themes", chain.ToHandlerFunc(v1Ctrl.HandlePDFThemes(), userMW...))
r.Get("/items/fields", chain.ToHandlerFunc(v1Ctrl.HandleGetAllCustomFieldNames(), userMW...))
r.Get("/items/fields/values", chain.ToHandlerFunc(v1Ctrl.HandleGetAllCustomFieldValues(), userMW...))

Expand All @@ -154,6 +158,7 @@ func (a *app) mountRoutes(r *chi.Mux, chain *errchain.ErrChain, repos *repo.AllR
r.Patch("/items/{id}", chain.ToHandlerFunc(v1Ctrl.HandleItemPatch(), userMW...))
r.Delete("/items/{id}", chain.ToHandlerFunc(v1Ctrl.HandleItemDelete(), userMW...))
r.Post("/items/{id}/duplicate", chain.ToHandlerFunc(v1Ctrl.HandleItemDuplicate(), userMW...))
r.Get("/items/{id}/export/pdf", chain.ToHandlerFunc(v1Ctrl.HandleItemExportPDF(), userMW...))

// Item attachment endpoints
r.Post("/items/{id}/attachments", chain.ToHandlerFunc(v1Ctrl.HandleItemAttachmentCreate(), userMW...))
Expand Down
1 change: 1 addition & 0 deletions backend/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ module github.com/sysadminsmedia/homebox/backend
go 1.26.0

require (
codeberg.org/go-pdf/fpdf v0.11.1
entgo.io/ent v0.14.5
github.com/XSAM/otelsql v0.41.0
github.com/ardanlabs/conf/v3 v3.11.0
Expand Down
Loading
Loading