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" "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 auth *auth.Service } // 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"), } 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), } for i := range form.Questions { form.Questions[i].ID = uuid.NewString() form.Questions[i].FormID = form.ID } 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), } if req.Body.RequiresAccessCode != nil { form.RequiresAccessCode = *req.Body.RequiresAccessCode } if req.Body.SignatureRequired != nil { form.SignatureRequired = *req.Body.SignatureRequired } for i := range form.Questions { form.Questions[i].ID = uuid.NewString() form.Questions[i].FormID = form.ID } 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 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 } } func validUserIdType(t UserIdType) bool { switch t { case Email, Username, Phone, Guid: 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{} }