package handlers import ( "database/sql" "embed" "encoding/json" "fmt" "html/template" "io" "log" "net/http" "os" "path/filepath" "regexp" "strconv" "strings" "time" "github.com/google/uuid" "pdf-form-api/db" "pdf-form-api/models" pdfutil "pdf-form-api/pdf" "pdf-form-api/worker" ) //go:embed templates/*.html var templateFS embed.FS type WizardData struct { PDFID int64 Description string } type Server struct { db *sql.DB storeDir string aiConfig worker.Config wizardTmpl *template.Template } func NewServer(db *sql.DB, storeDir string) *Server { tmpl, err := template.ParseFS(templateFS, "templates/*.html") if err != nil { log.Printf("[server] warning: could not load wizard template: %v", err) tmpl = nil } return &Server{ db: db, storeDir: storeDir, aiConfig: worker.DefaultConfig(), wizardTmpl: tmpl, } } func (s *Server) UploadPDF(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } if err := r.ParseMultipartForm(50 << 20); err != nil { jsonError(w, "file too large (max 50MB)", http.StatusBadRequest) return } file, header, err := r.FormFile("file") if err != nil { jsonError(w, "no file provided", http.StatusBadRequest) return } defer file.Close() ext := filepath.Ext(header.Filename) if !strings.EqualFold(ext, ".pdf") { jsonError(w, "only PDF files accepted", http.StatusBadRequest) return } safeName := sanitizeFilename(header.Filename) uuidPrefix := uuid.New().String()[:8] storeName := uuidPrefix + "_" + safeName filePath := filepath.Join(s.storeDir, storeName) dst, err := os.Create(filePath) if err != nil { jsonError(w, fmt.Sprintf("storing file: %v", err), http.StatusInternalServerError) return } defer dst.Close() data, err := io.ReadAll(file) if err != nil { jsonError(w, fmt.Sprintf("reading upload: %v", err), http.StatusInternalServerError) return } if _, err := dst.Write(data); err != nil { os.Remove(filePath) jsonError(w, fmt.Sprintf("writing file: %v", err), http.StatusInternalServerError) return } pdfID, err := db.InsertPDF(s.db, storeName, header.Filename, filePath, int64(len(data))) if err != nil { os.Remove(filePath) jsonError(w, fmt.Sprintf("storing in db: %v", err), http.StatusInternalServerError) return } fields, err := pdfutil.ExtractFields(filePath) if err != nil { os.Remove(filePath) jsonError(w, fmt.Sprintf("extracting fields: %v", err), http.StatusInternalServerError) return } for i, f := range fields { choices, _ := json.Marshal(f.Choices) fieldID, err := db.InsertFormField(s.db, pdfID, f.Name, string(f.Type), string(choices), f.Value, f.DefaultVal, f.Title, f.Required, f.Page, f.Rect) if err != nil { jsonError(w, fmt.Sprintf("storing field: %v", err), http.StatusInternalServerError) return } fields[i].ID = fieldID } // Start background question generation go worker.ProcessPDFQuestions(s.db, pdfID, filePath, fields, s.aiConfig, s.storeDir) pdf := models.PDF{ ID: pdfID, Filename: storeName, Original: header.Filename, FilePath: filePath, Size: int64(len(data)), CreatedAt: time.Now(), } resp := models.UploadResponse{PDF: pdf, Fields: fields} jsonResponse(w, resp, http.StatusCreated) } func (s *Server) GetPDF(w http.ResponseWriter, r *http.Request, id int64) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } _, _, err := db.GetPDF(s.db, id) if err == sql.ErrNoRows { jsonError(w, "pdf not found", http.StatusNotFound) return } if err != nil { jsonError(w, fmt.Sprintf("db error: %v", err), http.StatusInternalServerError) return } description, _ := db.GetPDFDescription(s.db, id) rows, err := db.GetFormFields(s.db, id) if err != nil { jsonError(w, fmt.Sprintf("db error: %v", err), http.StatusInternalServerError) return } var fields []models.FormField for _, row := range rows { var choices []string if row[3] != nil && row[3].(string) != "" { json.Unmarshal([]byte(row[3].(string)), &choices) } value := "" if row[4].(string) != "" { value = row[4].(string) } defaultVal := "" if row[5].(string) != "" { defaultVal = row[5].(string) } title := "" if row[6].(string) != "" { title = row[6].(string) } page := 0 if p, ok := row[8].(int); ok { page = p } rect := "" if row[9] != nil && row[9].(string) != "" { rect = row[9].(string) } question := "" if row[10] != nil && row[10].(string) != "" { question = row[10].(string) } valueGroup := "" if row[11] != nil && row[11].(string) != "" { valueGroup = row[11].(string) } var wizardPage *int if wp, ok := row[12].(int); ok && wp > 0 { wizardPage = &wp } fields = append(fields, models.FormField{ ID: int64(row[0].(int)), PDFID: id, Name: row[1].(string), Type: models.FieldType(row[2].(string)), Choices: choices, Value: value, DefaultVal: defaultVal, Title: title, Required: row[7].(bool), Page: page, Rect: rect, Question: question, ValueGroup: valueGroup, WizardPage: wizardPage, }) } resp := GetPDFResponse{ Description: description, Fields: fields, } jsonResponse(w, resp, http.StatusOK) } type GetPDFResponse struct { Description string `json:"description"` Fields []models.FormField `json:"fields"` } func (s *Server) FillPDF(w http.ResponseWriter, r *http.Request, id int64) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } _, filePath, err := db.GetPDF(s.db, id) if err == sql.ErrNoRows { jsonError(w, "pdf not found", http.StatusNotFound) return } if err != nil { jsonError(w, fmt.Sprintf("db error: %v", err), http.StatusInternalServerError) return } var req models.FillRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { jsonError(w, "invalid JSON body", http.StatusBadRequest) return } buf, err := pdfutil.FillPDF(filePath, req.Fields) if err != nil { jsonError(w, fmt.Sprintf("filling pdf: %v", err), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/pdf") w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=filled-%d.pdf", id)) w.Header().Set("Content-Length", strconv.Itoa(buf.Len())) w.WriteHeader(http.StatusOK) w.Write(buf.Bytes()) } func (s *Server) GetPageImage(w http.ResponseWriter, r *http.Request, id, pageNum int64, annotated bool) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } _, _, err := db.GetPDF(s.db, id) if err == sql.ErrNoRows { jsonError(w, "pdf not found", http.StatusNotFound) return } if err != nil { jsonError(w, fmt.Sprintf("db error: %v", err), http.StatusInternalServerError) return } suffix := "" if annotated { suffix = "_annotated" } imagePath := fmt.Sprintf("%s/%d/pages/%d/page_%d%s.png", s.storeDir, id, pageNum, pageNum, suffix) if _, err := os.Stat(imagePath); err != nil { jsonError(w, "page image not found", http.StatusNotFound) return } w.Header().Set("Content-Type", "image/png") w.Header().Set("Cache-Control", "public, max-age=86400") http.ServeFile(w, r, imagePath) } func (s *Server) WizardPage(w http.ResponseWriter, r *http.Request, id int64) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } _, _, err := db.GetPDF(s.db, id) if err == sql.ErrNoRows { http.Error(w, "pdf not found", http.StatusNotFound) return } if err != nil { http.Error(w, fmt.Sprintf("db error: %v", err), http.StatusInternalServerError) return } description, _ := db.GetPDFDescription(s.db, id) if s.wizardTmpl == nil { http.Error(w, "wizard template not available", http.StatusInternalServerError) return } data := WizardData{ PDFID: id, Description: description, } w.Header().Set("Content-Type", "text/html; charset=utf-8") s.wizardTmpl.Execute(w, data) } func (s *Server) Routes() http.Handler { mux := http.NewServeMux() mux.HandleFunc("/pdf/upload", s.UploadPDF) wizardPattern := regexp.MustCompile(`^/pdf/(\d+)/wizard$`) pagePattern := regexp.MustCompile(`^/pdf/(\d+)/page/(\d+)/annotated$`) pageCleanPattern := regexp.MustCompile(`^/pdf/(\d+)/page/(\d+)$`) fillPattern := regexp.MustCompile(`^/pdf/(\d+)/fill$`) pdfPattern := regexp.MustCompile(`^/pdf/(\d+)$`) mux.HandleFunc("/pdf/", func(w http.ResponseWriter, r *http.Request) { path := r.URL.Path if wizardPattern.MatchString(path) { matches := wizardPattern.FindStringSubmatch(path) id, _ := strconv.ParseInt(matches[1], 10, 64) s.WizardPage(w, r, id) return } if pagePattern.MatchString(path) { matches := pagePattern.FindStringSubmatch(path) id, _ := strconv.ParseInt(matches[1], 10, 64) pageNum, _ := strconv.ParseInt(matches[2], 10, 64) s.GetPageImage(w, r, id, pageNum, true) return } if pageCleanPattern.MatchString(path) { matches := pageCleanPattern.FindStringSubmatch(path) id, _ := strconv.ParseInt(matches[1], 10, 64) pageNum, _ := strconv.ParseInt(matches[2], 10, 64) s.GetPageImage(w, r, id, pageNum, false) return } if fillPattern.MatchString(path) { matches := fillPattern.FindStringSubmatch(path) id, _ := strconv.ParseInt(matches[1], 10, 64) s.FillPDF(w, r, id) return } if pdfPattern.MatchString(path) { matches := pdfPattern.FindStringSubmatch(path) id, _ := strconv.ParseInt(matches[1], 10, 64) s.GetPDF(w, r, id) return } http.NotFound(w, r) }) return mux } func sanitizeFilename(name string) string { re := regexp.MustCompile(`[^a-zA-Z0-9._-]`) sanitized := re.ReplaceAllString(name, "_") if sanitized == "" { sanitized = "unnamed" + filepath.Ext(name) } return sanitized } func jsonResponse(w http.ResponseWriter, data interface{}, status int) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) json.NewEncoder(w).Encode(data) } func jsonError(w http.ResponseWriter, msg string, status int) { jsonResponse(w, map[string]string{"error": msg}, status) }