kereru-forms/backend/internal/api/server.go

1189 lines
41 KiB
Go
Raw Permalink Normal View History

package api
import (
"bytes"
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"math/big"
"net/mail"
"net/url"
"path"
"regexp"
"strings"
"time"
"github.com/google/uuid"
"github.com/rs/zerolog"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
2026-10-23 19:36:02 +00:00
"kereru.forms/internal/auth"
"kereru.forms/internal/database"
"kereru.forms/internal/pdf"
)
// Server implements the generated StrictServerInterface.
type Server struct {
store database.Store
logger zerolog.Logger
version string
startedAt time.Time
tracer trace.Tracer
2026-10-23 19:36:02 +00:00
auth *auth.Service
}
2026-10-23 19:36:02 +00:00
// NewServer constructs the API implementation. Authentication is optional so
// tests can exercise handlers directly; the application always supplies it.
func NewServer(store database.Store, logger zerolog.Logger, version string, authService ...*auth.Service) *Server {
server := &Server{
store: store,
logger: logger,
version: version,
startedAt: time.Now(),
tracer: otel.Tracer("kereru.forms/api"),
}
2026-10-23 19:36:02 +00:00
if len(authService) > 0 {
server.auth = authService[0]
}
return server
}
func errJSON(message, code string) ErrorJSONResponse {
e := Error{Error: message}
if code != "" {
c := code
e.Code = &c
}
return ErrorJSONResponse(e)
}
func (s *Server) GetHealth(ctx context.Context, _ GetHealthRequestObject) (GetHealthResponseObject, error) {
_, span := s.tracer.Start(ctx, "GetHealth")
defer span.End()
uptime := int64(time.Since(s.startedAt).Seconds())
return GetHealth200JSONResponse(Health{
Status: "ok",
Version: s.version,
UptimeSeconds: &uptime,
}), nil
}
func (s *Server) ListForms(ctx context.Context, req ListFormsRequestObject) (ListFormsResponseObject, error) {
ctx, span := s.tracer.Start(ctx, "ListForms")
defer span.End()
includeInactive := req.Params.IncludeInactive != nil && *req.Params.IncludeInactive
if req.Params.ActiveOnly != nil && *req.Params.ActiveOnly {
includeInactive = false
}
forms, err := s.store.ListForms(ctx, includeInactive)
if err != nil {
s.logger.Error().Err(err).Msg("list forms")
return ListForms500JSONResponse{ErrorJSONResponse: errJSON("could not list forms", "internal_error")}, nil
}
out := make([]FormSummary, 0, len(forms))
for _, f := range forms {
out = append(out, toAPIFormSummary(f))
}
return ListForms200JSONResponse(out), nil
}
func (s *Server) CreateForm(ctx context.Context, req CreateFormRequestObject) (CreateFormResponseObject, error) {
ctx, span := s.tracer.Start(ctx, "CreateForm")
defer span.End()
if req.Body == nil {
return CreateForm400JSONResponse{ErrorJSONResponse: errJSON("request body is required", "validation_error")}, nil
}
if err := validateForm(req.Body.Title, req.Body.Questions); err != nil {
return CreateForm400JSONResponse{ErrorJSONResponse: errJSON(err.Error(), "validation_error")}, nil
}
if err := validateLogoURL(req.Body.LogoUrl); err != nil {
return CreateForm400JSONResponse{ErrorJSONResponse: errJSON(err.Error(), "validation_error")}, nil
}
if err := validatePDFURL(req.Body.PdfUrl); err != nil {
return CreateForm400JSONResponse{ErrorJSONResponse: errJSON(err.Error(), "validation_error")}, nil
}
externalReferenceRequired := req.Body.ExternalReferenceRequired != nil && *req.Body.ExternalReferenceRequired
externalReferenceLabel, err := normalizeExternalReferenceLabel(req.Body.ExternalReferenceLabel, externalReferenceRequired)
if err != nil {
return CreateForm400JSONResponse{ErrorJSONResponse: errJSON(err.Error(), "validation_error")}, nil
}
now := time.Now().UTC()
form := &database.Form{
ID: uuid.NewString(),
FormGroupID: uuid.NewString(),
Version: 1,
Title: strings.TrimSpace(req.Body.Title),
Description: req.Body.Description,
Introduction: req.Body.Introduction,
IsActive: true,
RequiresAccessCode: req.Body.RequiresAccessCode != nil && *req.Body.RequiresAccessCode,
SignatureRequired: req.Body.SignatureRequired != nil && *req.Body.SignatureRequired,
ExternalReferenceRequired: externalReferenceRequired,
ExternalReferenceLabel: externalReferenceLabel,
LogoURL: req.Body.LogoUrl,
PDFURL: req.Body.PdfUrl,
CompanyName: req.Body.CompanyName,
ContactEmail: req.Body.ContactEmail,
ContactPhone: req.Body.ContactPhone,
ContactURL: req.Body.ContactUrl,
CreatedAt: now,
UpdatedAt: now,
Questions: questionInputsToRecords(req.Body.Questions),
2026-10-25 10:53:17 +00:00
Sections: sectionsFromInputs(req.Body.Sections),
}
if err := assignSectionIDs(form); err != nil {
return CreateForm400JSONResponse{ErrorJSONResponse: errJSON(err.Error(), "validation_error")}, nil
}
// Ensure section IDs are set and attached to form
for i := range form.Sections {
form.Sections[i].FormID = form.ID
if form.Sections[i].ID == "" {
form.Sections[i].ID = uuid.NewString()
}
}
for i := range form.Questions {
form.Questions[i].ID = uuid.NewString()
form.Questions[i].FormID = form.ID
2026-10-25 10:53:17 +00:00
// If sectionId is set, ensure it points to a valid section on this form
if form.Questions[i].SectionID != nil && *form.Questions[i].SectionID != "" {
sid := *form.Questions[i].SectionID
found := false
for _, sec := range form.Sections {
if sec.ID == sid {
found = true
break
}
}
if !found {
form.Questions[i].SectionID = nil
}
}
}
if err := s.store.CreateForm(ctx, form); err != nil {
if errors.Is(err, database.ErrConflict) {
return CreateForm400JSONResponse{ErrorJSONResponse: errJSON("a form with this key already exists", "conflict")}, nil
}
s.logger.Error().Err(err).Msg("create form")
return CreateForm500JSONResponse(errJSON("could not create form", "internal_error")), nil
}
span.SetAttributes(attribute.String("form.id", form.ID), attribute.String("form.group", form.FormGroupID))
return CreateForm201JSONResponse(toAPIForm(form)), nil
}
func (s *Server) GetForm(ctx context.Context, req GetFormRequestObject) (GetFormResponseObject, error) {
ctx, span := s.tracer.Start(ctx, "GetForm")
defer span.End()
span.SetAttributes(attribute.String("form.id", req.FormId.String()))
form, err := s.store.GetForm(ctx, req.FormId.String())
if errors.Is(err, database.ErrNotFound) {
return GetForm404JSONResponse{ErrorJSONResponse: errJSON("form not found", "not_found")}, nil
}
if err != nil {
s.logger.Error().Err(err).Msg("get form")
return GetForm500JSONResponse(errJSON("could not load form", "internal_error")), nil
}
return GetForm200JSONResponse(toAPIForm(form)), nil
}
func (s *Server) UpdateForm(ctx context.Context, req UpdateFormRequestObject) (UpdateFormResponseObject, error) {
ctx, span := s.tracer.Start(ctx, "UpdateForm")
defer span.End()
span.SetAttributes(attribute.String("form.id", req.FormId.String()))
if req.Body == nil {
return UpdateForm400JSONResponse{ErrorJSONResponse: errJSON("request body is required", "validation_error")}, nil
}
if err := validateForm(req.Body.Title, req.Body.Questions); err != nil {
return UpdateForm400JSONResponse{ErrorJSONResponse: errJSON(err.Error(), "validation_error")}, nil
}
if err := validateLogoURL(req.Body.LogoUrl); err != nil {
return UpdateForm400JSONResponse{ErrorJSONResponse: errJSON(err.Error(), "validation_error")}, nil
}
if err := validatePDFURL(req.Body.PdfUrl); err != nil {
return UpdateForm400JSONResponse{ErrorJSONResponse: errJSON(err.Error(), "validation_error")}, nil
}
existing, err := s.store.GetForm(ctx, req.FormId.String())
if errors.Is(err, database.ErrNotFound) {
return UpdateForm404JSONResponse(errJSON("form not found", "not_found")), nil
}
if err != nil {
s.logger.Error().Err(err).Msg("load form for update")
return UpdateForm500JSONResponse(errJSON("could not load form", "internal_error")), nil
}
// Editing a definition creates a new version so historical submissions keep
// pointing at the exact definition they answered.
externalReferenceRequired := existing.ExternalReferenceRequired
if req.Body.ExternalReferenceRequired != nil {
externalReferenceRequired = *req.Body.ExternalReferenceRequired
}
externalReferenceLabel := existing.ExternalReferenceLabel
if req.Body.ExternalReferenceLabel != nil {
label, err := normalizeExternalReferenceLabel(req.Body.ExternalReferenceLabel, externalReferenceRequired)
if err != nil {
return UpdateForm400JSONResponse{ErrorJSONResponse: errJSON(err.Error(), "validation_error")}, nil
}
externalReferenceLabel = label
} else if externalReferenceRequired && (externalReferenceLabel == nil || *externalReferenceLabel == "") {
label := defaultExternalReferenceLabel
externalReferenceLabel = &label
}
now := time.Now().UTC()
form := &database.Form{
ID: uuid.NewString(),
FormGroupID: existing.FormGroupID,
Version: existing.Version + 1,
Title: strings.TrimSpace(req.Body.Title),
Description: req.Body.Description,
Introduction: req.Body.Introduction,
IsActive: true,
RequiresAccessCode: existing.RequiresAccessCode,
SignatureRequired: existing.SignatureRequired,
ExternalReferenceRequired: externalReferenceRequired,
ExternalReferenceLabel: externalReferenceLabel,
LogoURL: req.Body.LogoUrl,
PDFURL: req.Body.PdfUrl,
CompanyName: req.Body.CompanyName,
ContactEmail: req.Body.ContactEmail,
ContactPhone: req.Body.ContactPhone,
ContactURL: req.Body.ContactUrl,
CreatedAt: now,
UpdatedAt: now,
Questions: questionInputsToRecords(req.Body.Questions),
2026-10-25 10:53:17 +00:00
Sections: sectionsFromInputs(req.Body.Sections),
}
if req.Body.RequiresAccessCode != nil {
form.RequiresAccessCode = *req.Body.RequiresAccessCode
}
if req.Body.SignatureRequired != nil {
form.SignatureRequired = *req.Body.SignatureRequired
}
2026-10-25 10:53:17 +00:00
if err := assignSectionIDs(form); err != nil {
return UpdateForm400JSONResponse{ErrorJSONResponse: errJSON(err.Error(), "validation_error")}, nil
}
for i := range form.Sections {
form.Sections[i].FormID = form.ID
if form.Sections[i].ID == "" {
form.Sections[i].ID = uuid.NewString()
}
}
for i := range form.Questions {
form.Questions[i].ID = uuid.NewString()
form.Questions[i].FormID = form.ID
2026-10-25 10:53:17 +00:00
if form.Questions[i].SectionID != nil && *form.Questions[i].SectionID != "" {
sid := *form.Questions[i].SectionID
found := false
for _, sec := range form.Sections {
if sec.ID == sid {
found = true
break
}
}
if !found {
form.Questions[i].SectionID = nil
}
}
}
if err := s.store.CreateForm(ctx, form); err != nil {
if errors.Is(err, database.ErrConflict) {
return UpdateForm400JSONResponse{ErrorJSONResponse: errJSON("a newer version of this form already exists", "conflict")}, nil
}
s.logger.Error().Err(err).Msg("update form")
return UpdateForm500JSONResponse(errJSON("could not update form", "internal_error")), nil
}
if err := s.store.SetFormActive(ctx, existing.ID, false); err != nil {
s.logger.Warn().Err(err).Str("form.id", existing.ID).Msg("could not inactivate previous form version")
}
span.SetAttributes(attribute.Int("form.version", form.Version))
return UpdateForm200JSONResponse(toAPIForm(form)), nil
}
func (s *Server) DeleteForm(ctx context.Context, req DeleteFormRequestObject) (DeleteFormResponseObject, error) {
ctx, span := s.tracer.Start(ctx, "DeleteForm")
defer span.End()
span.SetAttributes(attribute.String("form.id", req.FormId.String()))
err := s.store.DeleteForm(ctx, req.FormId.String())
if errors.Is(err, database.ErrNotFound) {
return DeleteForm404JSONResponse{ErrorJSONResponse: errJSON("form not found", "not_found")}, nil
}
if err != nil {
s.logger.Error().Err(err).Msg("delete form")
return DeleteForm500JSONResponse(errJSON("could not delete form", "internal_error")), nil
}
return DeleteForm204Response{}, nil
}
func (s *Server) InactivateForm(ctx context.Context, req InactivateFormRequestObject) (InactivateFormResponseObject, error) {
form, err := s.setFormActive(ctx, req.FormId.String(), false)
if errors.Is(err, database.ErrNotFound) {
return InactivateForm404JSONResponse{ErrorJSONResponse: errJSON("form not found", "not_found")}, nil
}
if err != nil {
return InactivateForm500JSONResponse(errJSON(err.Error(), "internal_error")), nil
}
return InactivateForm200JSONResponse(toAPIForm(form)), nil
}
func (s *Server) ActivateForm(ctx context.Context, req ActivateFormRequestObject) (ActivateFormResponseObject, error) {
form, err := s.setFormActive(ctx, req.FormId.String(), true)
if errors.Is(err, database.ErrNotFound) {
return ActivateForm404JSONResponse{ErrorJSONResponse: errJSON("form not found", "not_found")}, nil
}
if err != nil {
return ActivateForm500JSONResponse(errJSON(err.Error(), "internal_error")), nil
}
return ActivateForm200JSONResponse(toAPIForm(form)), nil
}
func (s *Server) setFormActive(ctx context.Context, id string, active bool) (*database.Form, error) {
ctx, span := s.tracer.Start(ctx, "SetFormActive")
defer span.End()
span.SetAttributes(attribute.String("form.id", id), attribute.Bool("form.active", active))
if err := s.store.SetFormActive(ctx, id, active); err != nil {
if !errors.Is(err, database.ErrNotFound) {
s.logger.Error().Err(err).Bool("active", active).Msg("set form active")
}
return nil, err
}
form, err := s.store.GetForm(ctx, id)
if err != nil {
s.logger.Error().Err(err).Msg("load form after activation change")
return nil, err
}
return form, nil
}
func (s *Server) CreateFormVersion(ctx context.Context, req CreateFormVersionRequestObject) (CreateFormVersionResponseObject, error) {
ctx, span := s.tracer.Start(ctx, "CreateFormVersion")
defer span.End()
span.SetAttributes(attribute.String("form.id", req.FormId.String()))
existing, err := s.store.GetForm(ctx, req.FormId.String())
if errors.Is(err, database.ErrNotFound) {
return CreateFormVersion404JSONResponse{ErrorJSONResponse: errJSON("form not found", "not_found")}, nil
}
if err != nil {
s.logger.Error().Err(err).Msg("load form for new version")
return CreateFormVersion500JSONResponse(errJSON("could not load form", "internal_error")), nil
}
now := time.Now().UTC()
version := &database.Form{
ID: uuid.NewString(),
FormGroupID: existing.FormGroupID,
Version: existing.Version + 1,
Title: existing.Title,
Description: existing.Description,
Introduction: existing.Introduction,
IsActive: true,
RequiresAccessCode: existing.RequiresAccessCode,
SignatureRequired: existing.SignatureRequired,
ExternalReferenceRequired: existing.ExternalReferenceRequired,
ExternalReferenceLabel: existing.ExternalReferenceLabel,
LogoURL: existing.LogoURL,
CompanyName: existing.CompanyName,
ContactEmail: existing.ContactEmail,
ContactPhone: existing.ContactPhone,
ContactURL: existing.ContactURL,
CreatedAt: now,
UpdatedAt: now,
Questions: make([]database.Question, 0, len(existing.Questions)),
}
for _, q := range existing.Questions {
clone := q
clone.ID = uuid.NewString()
clone.FormID = version.ID
version.Questions = append(version.Questions, clone)
}
if err := s.store.CreateForm(ctx, version); err != nil {
s.logger.Error().Err(err).Msg("create form version")
return CreateFormVersion500JSONResponse(errJSON("could not create form version", "internal_error")), nil
}
if err := s.store.SetFormActive(ctx, existing.ID, false); err != nil {
s.logger.Warn().Err(err).Str("form.id", existing.ID).Msg("could not inactivate previous form version")
}
return CreateFormVersion201JSONResponse(toAPIForm(version)), nil
}
func (s *Server) GetFormPdf(ctx context.Context, req GetFormPdfRequestObject) (GetFormPdfResponseObject, error) {
ctx, span := s.tracer.Start(ctx, "GetFormPdf")
defer span.End()
span.SetAttributes(attribute.String("form.id", req.FormId.String()))
form, err := s.store.GetForm(ctx, req.FormId.String())
if errors.Is(err, database.ErrNotFound) {
return GetFormPdf404JSONResponse{ErrorJSONResponse: errJSON("form not found", "not_found")}, nil
}
if err != nil {
s.logger.Error().Err(err).Msg("load form for pdf")
return GetFormPdf500JSONResponse(errJSON("could not load form", "internal_error")), nil
}
data, err := pdf.Generate(form)
if err != nil {
s.logger.Error().Err(err).Msg("generate pdf")
return GetFormPdf500JSONResponse(errJSON("could not generate pdf", "internal_error")), nil
}
return GetFormPdf200ApplicationpdfResponse{
Body: bytes.NewReader(data),
ContentLength: int64(len(data)),
}, nil
}
func (s *Server) ListFormSubmissions(ctx context.Context, req ListFormSubmissionsRequestObject) (ListFormSubmissionsResponseObject, error) {
ctx, span := s.tracer.Start(ctx, "ListFormSubmissions")
defer span.End()
span.SetAttributes(attribute.String("form.id", req.FormId.String()))
if _, err := s.store.GetForm(ctx, req.FormId.String()); err != nil {
if errors.Is(err, database.ErrNotFound) {
return ListFormSubmissions404JSONResponse{ErrorJSONResponse: errJSON("form not found", "not_found")}, nil
}
s.logger.Error().Err(err).Msg("load form for submissions")
return ListFormSubmissions500JSONResponse(errJSON("could not load form", "internal_error")), nil
}
subs, err := s.store.ListSubmissions(ctx, req.FormId.String())
if err != nil {
s.logger.Error().Err(err).Msg("list submissions")
return ListFormSubmissions500JSONResponse(errJSON("could not list submissions", "internal_error")), nil
}
out := make([]SubmissionSummary, 0, len(subs))
for _, sub := range subs {
out = append(out, toAPISubmissionSummary(sub))
}
return ListFormSubmissions200JSONResponse(out), nil
}
func (s *Server) SubmitForm(ctx context.Context, req SubmitFormRequestObject) (SubmitFormResponseObject, error) {
ctx, span := s.tracer.Start(ctx, "SubmitForm")
defer span.End()
span.SetAttributes(attribute.String("form.id", req.FormId.String()))
form, err := s.store.GetForm(ctx, req.FormId.String())
if errors.Is(err, database.ErrNotFound) {
return SubmitForm404JSONResponse(errJSON("form not found", "not_found")), nil
}
if err != nil {
s.logger.Error().Err(err).Msg("load form for submission")
return SubmitForm500JSONResponse(errJSON("could not load form", "internal_error")), nil
}
if !form.IsActive {
return SubmitForm410JSONResponse(errJSON("this form is no longer accepting responses", "form_inactive")), nil
}
if req.Body == nil {
return SubmitForm400JSONResponse{ErrorJSONResponse: errJSON("request body is required", "validation_error")}, nil
}
userIdType := UserIdType(strings.ToLower(strings.TrimSpace(string(req.Body.UserIdType))))
if !validUserIdType(userIdType) {
return SubmitForm400JSONResponse{ErrorJSONResponse: errJSON("userIdType must be one of email, username, phone, foreign key or guid", "validation_error")}, nil
}
userId := strings.TrimSpace(req.Body.UserId)
if userId == "" {
return SubmitForm400JSONResponse{ErrorJSONResponse: errJSON("userId is required", "validation_error")}, nil
}
if userIdType == Email {
if _, err := mail.ParseAddress(userId); err != nil {
return SubmitForm400JSONResponse{ErrorJSONResponse: errJSON("userId is not a valid email address", "validation_error")}, nil
}
}
externalReference := strings.TrimSpace(derefString(req.Body.ExternalReference))
if form.ExternalReferenceRequired && externalReference == "" {
label := defaultExternalReferenceLabel
if form.ExternalReferenceLabel != nil && *form.ExternalReferenceLabel != "" {
label = *form.ExternalReferenceLabel
}
return SubmitForm400JSONResponse{ErrorJSONResponse: errJSON(label+" is required", "validation_error")}, nil
}
if len(externalReference) > maxExternalReferenceLength {
return SubmitForm400JSONResponse{ErrorJSONResponse: errJSON(fmt.Sprintf("externalReference must be %d characters or fewer", maxExternalReferenceLength), "validation_error")}, nil
}
var externalReferencePtr *string
if externalReference != "" {
externalReferencePtr = &externalReference
}
answers, err := validateAnswers(form, req.Body.Answers, req.Body.OfflinePdf == nil)
if err != nil {
return SubmitForm400JSONResponse{ErrorJSONResponse: errJSON(err.Error(), "validation_error")}, nil
}
signature, offlinePDF, offlinePDFName, err := validateSignatureAndOfflinePDF(form, req.Body)
if err != nil {
return SubmitForm400JSONResponse{ErrorJSONResponse: errJSON(err.Error(), "validation_error")}, nil
}
var accessCode string
if form.RequiresAccessCode {
accessCode = strings.ToUpper(strings.TrimSpace(derefString(req.Body.AccessCode)))
if accessCode == "" {
return SubmitForm400JSONResponse{ErrorJSONResponse: errJSON("an access code is required for this form", "access_code_required")}, nil
}
}
meta := requestMeta(ctx)
now := time.Now().UTC()
submission := &database.Submission{
ID: uuid.NewString(),
FormID: form.ID,
FormVersion: form.Version,
UserID: userId,
UserIDType: string(userIdType),
SubmittedAt: now,
IPAddress: meta.ip,
UserAgent: meta.userAgent,
ExternalReference: externalReferencePtr,
Signature: signature,
OfflinePDFName: offlinePDFName,
OfflinePDF: offlinePDF,
Answers: answers,
}
if req.Body.Tracking != nil {
submission.Locale = req.Body.Tracking.Locale
submission.Timezone = req.Body.Tracking.Timezone
submission.Place = req.Body.Tracking.Place
submission.Referer = req.Body.Tracking.Referer
}
if err := s.store.CreateSubmission(ctx, submission, accessCode); err != nil {
switch {
case errors.Is(err, database.ErrAccessCode):
return SubmitForm409JSONResponse(errJSON("the access code is not valid for this form", "invalid_access_code")), nil
case errors.Is(err, database.ErrCodeAlreadyIn):
return SubmitForm409JSONResponse(errJSON("the access code has already been used", "access_code_used")), nil
default:
s.logger.Error().Err(err).Msg("create submission")
return SubmitForm500JSONResponse(errJSON("could not save submission", "internal_error")), nil
}
}
span.SetAttributes(attribute.String("submission.id", submission.ID))
return SubmitForm201JSONResponse(toAPISubmission(submission)), nil
}
func (s *Server) GetSubmission(ctx context.Context, req GetSubmissionRequestObject) (GetSubmissionResponseObject, error) {
ctx, span := s.tracer.Start(ctx, "GetSubmission")
defer span.End()
span.SetAttributes(attribute.String("submission.id", req.SubmissionId.String()))
sub, err := s.store.GetSubmission(ctx, req.SubmissionId.String())
if errors.Is(err, database.ErrNotFound) {
return GetSubmission404JSONResponse{ErrorJSONResponse: errJSON("submission not found", "not_found")}, nil
}
if err != nil {
s.logger.Error().Err(err).Msg("get submission")
return GetSubmission500JSONResponse(errJSON("could not load submission", "internal_error")), nil
}
return GetSubmission200JSONResponse(toAPISubmission(sub)), nil
}
func (s *Server) GetSubmissionOfflinePdf(ctx context.Context, req GetSubmissionOfflinePdfRequestObject) (GetSubmissionOfflinePdfResponseObject, error) {
headerCtx := ctx
ctx, span := s.tracer.Start(ctx, "GetSubmissionOfflinePdf")
defer span.End()
span.SetAttributes(attribute.String("submission.id", req.SubmissionId.String()))
data, name, err := s.store.GetSubmissionOfflinePDF(ctx, req.SubmissionId.String())
if errors.Is(err, database.ErrNotFound) {
return GetSubmissionOfflinePdf404JSONResponse{ErrorJSONResponse: errJSON("offline PDF not found", "not_found")}, nil
}
if err != nil {
s.logger.Error().Err(err).Msg("get submission offline pdf")
return GetSubmissionOfflinePdf500JSONResponse(errJSON("could not load offline PDF", "internal_error")), nil
}
if c, ok := headerCtx.(interface{ Header(string, string) }); ok {
c.Header("Content-Disposition", attachmentDisposition(name))
}
return GetSubmissionOfflinePdf200ApplicationpdfResponse{
Body: bytes.NewReader(data),
ContentLength: int64(len(data)),
}, nil
}
func attachmentDisposition(name string) string {
clean := strings.Map(func(r rune) rune {
if r < ' ' || r == 127 || r == '"' || r == '\\' {
return '-'
}
return r
}, name)
if strings.TrimSpace(clean) == "" {
clean = "submission.pdf"
}
return fmt.Sprintf("attachment; filename=%q", clean)
}
func (s *Server) ListAccessCodes(ctx context.Context, req ListAccessCodesRequestObject) (ListAccessCodesResponseObject, error) {
ctx, span := s.tracer.Start(ctx, "ListAccessCodes")
defer span.End()
span.SetAttributes(attribute.String("form.id", req.FormId.String()))
if _, err := s.store.GetForm(ctx, req.FormId.String()); err != nil {
if errors.Is(err, database.ErrNotFound) {
return ListAccessCodes404JSONResponse{ErrorJSONResponse: errJSON("form not found", "not_found")}, nil
}
s.logger.Error().Err(err).Msg("load form for access codes")
return ListAccessCodes500JSONResponse(errJSON("could not load form", "internal_error")), nil
}
codes, err := s.store.ListAccessCodes(ctx, req.FormId.String())
if err != nil {
s.logger.Error().Err(err).Msg("list access codes")
return ListAccessCodes500JSONResponse(errJSON("could not list access codes", "internal_error")), nil
}
out := make([]AccessCode, 0, len(codes))
for _, c := range codes {
out = append(out, toAPIAccessCode(c))
}
return ListAccessCodes200JSONResponse(out), nil
}
func (s *Server) CreateAccessCodes(ctx context.Context, req CreateAccessCodesRequestObject) (CreateAccessCodesResponseObject, error) {
ctx, span := s.tracer.Start(ctx, "CreateAccessCodes")
defer span.End()
span.SetAttributes(attribute.String("form.id", req.FormId.String()))
if req.Body == nil {
return CreateAccessCodes400JSONResponse{ErrorJSONResponse: errJSON("request body is required", "validation_error")}, nil
}
if req.Body.Count < 1 || req.Body.Count > 1000 {
return CreateAccessCodes400JSONResponse{ErrorJSONResponse: errJSON("count must be between 1 and 1000", "validation_error")}, nil
}
if _, err := s.store.GetForm(ctx, req.FormId.String()); err != nil {
if errors.Is(err, database.ErrNotFound) {
return CreateAccessCodes404JSONResponse(errJSON("form not found", "not_found")), nil
}
s.logger.Error().Err(err).Msg("load form for access code creation")
return CreateAccessCodes500JSONResponse(errJSON("could not load form", "internal_error")), nil
}
prefix := ""
if req.Body.Prefix != nil {
prefix = strings.ToUpper(strings.TrimSpace(*req.Body.Prefix))
if prefix != "" && !codePrefixPattern.MatchString(prefix) {
return CreateAccessCodes400JSONResponse{ErrorJSONResponse: errJSON("prefix may only contain letters, digits and hyphens", "validation_error")}, nil
}
}
now := time.Now().UTC()
codes := make([]database.AccessCode, 0, req.Body.Count)
for i := 0; i < req.Body.Count; i++ {
code, err := generateCode(prefix)
if err != nil {
s.logger.Error().Err(err).Msg("generate access code")
return CreateAccessCodes500JSONResponse(errJSON("could not generate access codes", "internal_error")), nil
}
codes = append(codes, database.AccessCode{
ID: uuid.NewString(),
FormID: req.FormId.String(),
Code: code,
CreatedAt: now,
})
}
if err := s.store.CreateAccessCodes(ctx, codes); err != nil {
s.logger.Error().Err(err).Msg("create access codes")
return CreateAccessCodes500JSONResponse(errJSON("could not create access codes", "internal_error")), nil
}
out := make([]AccessCode, 0, len(codes))
for _, c := range codes {
out = append(out, toAPIAccessCode(c))
}
return CreateAccessCodes201JSONResponse(out), nil
}
var (
questionKeyPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_-]{0,63}$`)
codePrefixPattern = regexp.MustCompile(`^[A-Z0-9-]{1,16}$`)
codeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
)
func generateCode(prefix string) (string, error) {
var b strings.Builder
for i := 0; i < 8; i++ {
if i == 4 {
b.WriteByte('-')
}
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(codeAlphabet))))
if err != nil {
return "", err
}
b.WriteByte(codeAlphabet[n.Int64()])
}
code := b.String()
if prefix != "" {
code = prefix + "-" + code
}
return code, nil
}
func validateLogoURL(raw *string) error {
if raw == nil || strings.TrimSpace(*raw) == "" {
return nil
}
u, err := url.Parse(strings.TrimSpace(*raw))
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return errors.New("logoUrl must be an absolute http or https URL")
}
return nil
}
func validatePDFURL(raw *string) error {
if raw == nil || strings.TrimSpace(*raw) == "" {
return nil
}
u, err := url.Parse(strings.TrimSpace(*raw))
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return errors.New("pdfUrl must be an absolute http or https URL")
}
return nil
}
const (
maxSignatureBytes = 2 << 20
maxOfflinePDFSize = 10 << 20
)
const (
defaultExternalReferenceLabel = "External reference"
maxExternalReferenceLabel = 100
maxExternalReferenceLength = 200
)
func normalizeExternalReferenceLabel(raw *string, required bool) (*string, error) {
label := strings.TrimSpace(derefString(raw))
if len(label) > maxExternalReferenceLabel {
return nil, fmt.Errorf("externalReferenceLabel must be %d characters or fewer", maxExternalReferenceLabel)
}
if label == "" && required {
label = defaultExternalReferenceLabel
}
if label == "" {
return nil, nil
}
return &label, nil
}
func validateSignatureAndOfflinePDF(form *database.Form, req *SubmitFormRequest) (*database.Signature, []byte, *string, error) {
var (
offline []byte
offlineName *string
)
if req.OfflinePdf != nil {
data, err := decodeDataURL(*req.OfflinePdf, "application/pdf", maxOfflinePDFSize)
if err != nil {
return nil, nil, nil, fmt.Errorf("offlinePdf: %w", err)
}
if !bytes.HasPrefix(data, []byte("%PDF")) {
return nil, nil, nil, errors.New("offlinePdf: file does not look like a PDF")
}
name := "offline-submission.pdf"
if req.OfflinePdfName != nil {
if trimmed := strings.TrimSpace(*req.OfflinePdfName); trimmed != "" {
name = path.Base(strings.ReplaceAll(trimmed, "\\", "/"))
}
}
if len(name) > 255 {
return nil, nil, nil, errors.New("offlinePdfName must be 255 characters or fewer")
}
offline = data
offlineName = &name
}
var signature *database.Signature
if req.Signature != nil {
kind := strings.ToLower(strings.TrimSpace(string(req.Signature.Type)))
if kind != "drawn" && kind != "image" {
return nil, nil, nil, errors.New("signature.type must be drawn or image")
}
_, mediaType, err := decodeSignatureImage(req.Signature.DataUrl)
if err != nil {
return nil, nil, nil, fmt.Errorf("signature.dataUrl: %w", err)
}
if kind == "drawn" && mediaType != "image/png" {
return nil, nil, nil, errors.New("signature.dataUrl must be a PNG for drawn signatures")
}
name := strings.TrimSpace(req.Signature.Name)
if name == "" {
return nil, nil, nil, errors.New("signature.name is required")
}
if len(name) > 200 {
return nil, nil, nil, errors.New("signature.name must be 200 characters or fewer")
}
var title *string
if req.Signature.Title != nil {
trimmed := strings.TrimSpace(*req.Signature.Title)
if len(trimmed) > 200 {
return nil, nil, nil, errors.New("signature.title must be 200 characters or fewer")
}
if trimmed != "" {
title = &trimmed
}
}
signature = &database.Signature{
Type: kind,
DataURL: req.Signature.DataUrl,
Name: name,
Title: title,
SignedAt: time.Now().UTC(),
}
} else if form.SignatureRequired && len(offline) == 0 {
return nil, nil, nil, errors.New("a signature is required for this form")
}
return signature, offline, offlineName, nil
}
func decodeSignatureImage(raw string) ([]byte, string, error) {
data, mediaType, err := decodeDataURLWithMedia(raw, maxSignatureBytes)
if err != nil {
return nil, "", err
}
switch mediaType {
case "image/png":
if !bytes.HasPrefix(data, []byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a}) {
return nil, "", errors.New("file does not look like a PNG image")
}
case "image/jpeg":
if !bytes.HasPrefix(data, []byte{0xff, 0xd8, 0xff}) {
return nil, "", errors.New("file does not look like a JPEG image")
}
default:
return nil, "", errors.New("must be a PNG or JPEG data URL")
}
return data, mediaType, nil
}
func decodeDataURL(raw, expectedMedia string, maxBytes int) ([]byte, error) {
data, mediaType, err := decodeDataURLWithMedia(raw, maxBytes)
if err != nil {
return nil, err
}
if mediaType != expectedMedia {
return nil, fmt.Errorf("must be a %s data URL", expectedMedia)
}
return data, nil
}
func decodeDataURLWithMedia(raw string, maxBytes int) ([]byte, string, error) {
if !strings.HasPrefix(raw, "data:") {
return nil, "", errors.New("must be a base64 data URL")
}
header, payload, ok := strings.Cut(raw[len("data:"):], ",")
if !ok {
return nil, "", errors.New("must be a base64 data URL")
}
mediaType, encoding, ok := strings.Cut(header, ";")
if !ok || !strings.EqualFold(strings.TrimSpace(encoding), "base64") {
return nil, "", errors.New("must be a base64 data URL")
}
mediaType = strings.ToLower(strings.TrimSpace(mediaType))
data, err := base64.StdEncoding.DecodeString(payload)
if err != nil {
return nil, "", errors.New("contains invalid base64 data")
}
if len(data) == 0 {
return nil, "", errors.New("file is empty")
}
if len(data) > maxBytes {
return nil, "", fmt.Errorf("file exceeds the %d MB limit", maxBytes/(1<<20))
}
return data, mediaType, nil
}
func validateForm(title string, questions []QuestionInput) error {
if strings.TrimSpace(title) == "" {
return errors.New("title is required")
}
seenKeys := make(map[string]struct{}, len(questions))
for i, q := range questions {
label := fmt.Sprintf("question %d", i+1)
key := strings.TrimSpace(q.Key)
if key == "" {
return fmt.Errorf("%s: key is required", label)
}
if !questionKeyPattern.MatchString(key) {
return fmt.Errorf("%s: key must start with a letter and contain only letters, digits, underscores or hyphens", label)
}
if _, exists := seenKeys[key]; exists {
return fmt.Errorf("%s: duplicate key %q", label, key)
}
seenKeys[key] = struct{}{}
if strings.TrimSpace(q.Label) == "" {
return fmt.Errorf("%s: label is required", label)
}
switch q.Type {
case Boolean, Number, Select, Text, Date:
case "":
return fmt.Errorf("%s: type is required", label)
default:
return fmt.Errorf("%s: unsupported type %q", label, q.Type)
}
if q.Type == Select {
if q.Options == nil || len(*q.Options) == 0 {
return fmt.Errorf("%s: select questions require at least one option", label)
}
seenValues := make(map[string]struct{}, len(*q.Options))
for _, opt := range *q.Options {
value := strings.TrimSpace(opt.Value)
if value == "" {
return fmt.Errorf("%s: option value is required", label)
}
if strings.TrimSpace(opt.Label) == "" {
return fmt.Errorf("%s: option label is required", label)
}
if _, exists := seenValues[value]; exists {
return fmt.Errorf("%s: duplicate option value %q", label, value)
}
seenValues[value] = struct{}{}
}
}
if q.Type == Number && q.MinValue != nil && q.MaxValue != nil && *q.MinValue > *q.MaxValue {
return fmt.Errorf("%s: minValue must be less than or equal to maxValue", label)
}
}
return nil
}
func validateAnswers(form *database.Form, answers []Answer, requireRequired bool) ([]database.Answer, error) {
byID := make(map[string]database.Question, len(form.Questions))
for _, q := range form.Questions {
byID[q.ID] = q
}
seen := make(map[string]struct{}, len(answers))
out := make([]database.Answer, 0, len(answers))
for _, ans := range answers {
id := ans.QuestionId.String()
q, ok := byID[id]
if !ok {
return nil, fmt.Errorf("question %s does not belong to this form", id)
}
if _, dup := seen[id]; dup {
return nil, fmt.Errorf("duplicate answer for question %q", q.Key)
}
seen[id] = struct{}{}
if err := validateAnswerValue(q, ans.Value); err != nil {
return nil, err
}
raw, err := json.Marshal(ans.Value)
if err != nil {
return nil, fmt.Errorf("question %q: could not encode answer", q.Key)
}
out = append(out, database.Answer{
QuestionID: id,
QuestionKey: q.Key,
Value: raw,
})
}
for _, q := range form.Questions {
if !requireRequired || !q.Required {
continue
}
if _, ok := seen[q.ID]; !ok {
return nil, fmt.Errorf("question %q requires an answer", q.Key)
}
}
return out, nil
}
func validateAnswerValue(q database.Question, value interface{}) error {
if value == nil {
return fmt.Errorf("question %q: answer value is required", q.Key)
}
switch q.Type {
case "boolean":
if _, ok := value.(bool); !ok {
return fmt.Errorf("question %q: expected a true/false value", q.Key)
}
case "number":
num, ok := toFloat(value)
if !ok {
return fmt.Errorf("question %q: expected a numeric value", q.Key)
}
if q.MinValue != nil && num < *q.MinValue {
return fmt.Errorf("question %q: value must be at least %g", q.Key, *q.MinValue)
}
if q.MaxValue != nil && num > *q.MaxValue {
return fmt.Errorf("question %q: value must be at most %g", q.Key, *q.MaxValue)
}
case "select":
str, ok := value.(string)
if !ok {
return fmt.Errorf("question %q: expected one of the configured options", q.Key)
}
found := false
for _, opt := range q.Options {
if opt.Value == str {
found = true
break
}
}
if !found {
return fmt.Errorf("question %q: %q is not a configured option", q.Key, str)
}
case "date":
str, ok := value.(string)
if !ok {
return fmt.Errorf("question %q: expected a date in YYYY-MM-DD form", q.Key)
}
date, err := time.Parse("2006-01-02", strings.TrimSpace(str))
if err != nil || date.Format("2006-01-02") != strings.TrimSpace(str) {
return fmt.Errorf("question %q: expected a date in YYYY-MM-DD form", q.Key)
}
case "text":
str, ok := value.(string)
if !ok {
return fmt.Errorf("question %q: expected a text value", q.Key)
}
if len(str) > 10000 {
return fmt.Errorf("question %q: answer is too long", q.Key)
}
if !q.Multiline && strings.ContainsAny(str, "\r\n") {
return fmt.Errorf("question %q: this answer must be a single line", q.Key)
}
default:
return fmt.Errorf("question %q: unsupported type %q", q.Key, q.Type)
}
return nil
}
func toFloat(v interface{}) (float64, bool) {
switch n := v.(type) {
case float64:
return n, true
case float32:
return float64(n), true
case int:
return float64(n), true
case int64:
return float64(n), true
case json.Number:
f, err := n.Float64()
return f, err == nil
default:
return 0, false
}
}
2026-10-25 10:53:17 +00:00
func sectionsFromInputs(inputs *[]SectionInput) []database.Section {
if inputs == nil {
return nil
}
out := make([]database.Section, 0, len(*inputs))
for i, s := range *inputs {
ident := strings.TrimSpace(s.Identifier)
if ident == "" {
continue
}
out = append(out, database.Section{
ID: uuid.NewString(),
Position: i,
Identifier: ident,
Title: s.Title,
Description: s.Description,
})
}
return out
}
func assignSectionIDs(form *database.Form) error {
if len(form.Sections) == 0 {
return nil
}
for i := range form.Sections {
if strings.TrimSpace(form.Sections[i].Identifier) == "" {
return errors.New("section identifier is required")
}
if form.Sections[i].ID == "" {
form.Sections[i].ID = uuid.NewString()
}
form.Sections[i].Position = i
}
// Map questions that have sectionId set to valid UUIDs of created sections if needed
// Build map by identifier in case frontend passes different forms
byIdent := make(map[string]string, len(form.Sections))
for i := range form.Sections {
byIdent[form.Sections[i].Identifier] = form.Sections[i].ID
}
for i := range form.Questions {
if form.Questions[i].SectionID != nil && *form.Questions[i].SectionID != "" {
sid := *form.Questions[i].SectionID
// If it's already a valid UUID of one of our sections, keep it
found := false
for _, sec := range form.Sections {
if sec.ID == sid {
found = true
break
}
}
if !found {
// Try to match by identifier as fallback
if mapped, ok := byIdent[sid]; ok {
form.Questions[i].SectionID = &mapped
continue
}
// Otherwise clear to avoid FK violation
form.Questions[i].SectionID = nil
}
}
}
return nil
}
func validUserIdType(t UserIdType) bool {
switch t {
2026-10-25 10:53:17 +00:00
case Email, Username, Phone, ForeignkeyGuid:
return true
default:
return false
}
}
func derefString(v *string) string {
if v == nil {
return ""
}
return *v
}
const metaKey = "kereru.request_meta"
type requestMetadata struct {
ip *string
userAgent *string
}
// requestMeta returns the server-side captured request metadata.
func requestMeta(ctx context.Context) requestMetadata {
if v, ok := ctx.Value(metaKey).(requestMetadata); ok {
return v
}
return requestMetadata{}
}