1076 lines
38 KiB
Go
1076 lines
38 KiB
Go
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, 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
|
|
}
|
|
}
|
|
|
|
func validUserIdType(t UserIdType) bool {
|
|
switch t {
|
|
case Email, Username, Phone, ForeignKey, 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{}
|
|
}
|