Stage 7 · Master
Phase 11 — Notice Service
Attachments
Move notice files out of PostgreSQL by combining presigned uploads, checksum-backed verification, and strict media validation.
Why do notice files bypass PostgreSQL?
Notice attachments are blobs, not relational facts, so they belong in object storage. Keeping PDFs and images in PostgreSQL would bloat backups, slow replication, and make every feed query drag along irrelevant large-object concerns. The API server should issue a presigned upload and step out of the data path; the browser sends bytes straight to MinIO or S3 and the server only records metadata plus later verification status.
When the server does not stream attachment bytes, a large PDF upload cannot monopolize Gin workers or exhaust memory. The application's job becomes authorization, metadata validation, and confirmation, which is much easier to scale and observe.
Which attachment rows are persisted before bytes move?
The attachment row exists before upload so the object key, expected checksum, declared media type, and status are durable even if the client closes its tab mid-transfer. That gives the platform somewhere to record failure_reason when verification later reveals the object never arrived or arrived with mutated metadata.
CREATE TABLE notice_attachments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
notice_id UUID NOT NULL,
object_key TEXT NOT NULL,
file_name TEXT NOT NULL,
content_type TEXT NOT NULL,
size_bytes BIGINT NOT NULL CHECK (size_bytes > 0 AND size_bytes <= 10485760),
checksum_sha256 TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('pending_upload', 'ready', 'failed')),
failure_reason TEXT,
uploaded_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
FOREIGN KEY (notice_id, org_id) REFERENCES notices(id, org_id) ON DELETE CASCADE,
CONSTRAINT notice_attachments_checksum_hex_chk CHECK (checksum_sha256 ~ '^[0-9a-f]{64}$'),
CONSTRAINT notice_attachments_object_key_unique UNIQUE (org_id, object_key)
);
CREATE INDEX notice_attachments_notice_status_idx
ON notice_attachments (org_id, notice_id, status);
CREATE INDEX notice_attachments_pending_idx
ON notice_attachments (org_id, created_at)
WHERE status = 'pending_upload';The composite foreign key keeps every attachment anchored to a notice in the same organization, and the pending index makes it cheap to sweep abandoned uploads later.
DROP TABLE IF EXISTS notice_attachments;How does post-upload verification close the trust gap?
Validating only at presign time is incomplete because the client can still upload bytes with a different Content-Type header or never finish the upload at all. The confirm step performs a storage-side HEAD request and compares the actual object metadata against the declared checksum, size, and allow-listed content type before the row becomes ready.
| Check | Done during presign | Rechecked during confirm |
|---|---|---|
| notice belongs to caller's org | Yes, before issuing any object key | Yes, when loading the attachment row by org_id and attachment_id |
| declared media type is allowed | Yes, reject obvious bad requests early | Yes, because the uploaded object can still claim a different media type |
| checksum matches expected bytes | No, bytes do not exist yet | Yes, HEAD metadata or object attributes must prove the uploaded object matches the declared checksum |
package notice
import (
"context"
"encoding/hex"
"errors"
"fmt"
"path"
"strings"
"time"
"github.com/google/uuid"
)
type AttachmentStatus string
const (
AttachmentPending AttachmentStatus = "pending_upload"
AttachmentReady AttachmentStatus = "ready"
AttachmentFailed AttachmentStatus = "failed"
)
var (
ErrAttachmentValidation = errors.New("notice attachment: validation failed")
ErrAttachmentNotFound = errors.New("notice attachment: not found")
)
type Attachment struct {
ID uuid.UUID
OrgID uuid.UUID
NoticeID uuid.UUID
ObjectKey string
FileName string
ContentType string
SizeBytes int64
ChecksumSHA256 string
Status AttachmentStatus
FailureReason string
UploadedAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
type CreateUploadInput struct {
NoticeID uuid.UUID
FileName string
ContentType string
SizeBytes int64
ChecksumSHA256 string
}
type ConfirmUploadInput struct {
AttachmentID uuid.UUID
}
type PresignedUpload struct {
URL string
Headers map[string]string
ExpiresAt time.Time
}
type ObjectMetadata struct {
ContentType string
SizeBytes int64
ChecksumSHA256 string
}
type AttachmentRepository interface {
NoticeExists(ctx context.Context, orgID, noticeID uuid.UUID) (bool, error)
CreatePendingAttachment(ctx context.Context, attachment Attachment) (Attachment, error)
GetAttachment(ctx context.Context, orgID, attachmentID uuid.UUID) (Attachment, error)
MarkAttachmentReady(ctx context.Context, orgID, attachmentID uuid.UUID, uploadedAt time.Time) (Attachment, error)
MarkAttachmentFailed(ctx context.Context, orgID, attachmentID uuid.UUID, reason string) error
}
type ObjectStore interface {
CreatePresignedPut(ctx context.Context, bucket, objectKey string, expires time.Duration, contentType string, sizeBytes int64, checksumSHA256 string) (PresignedUpload, error)
HeadObject(ctx context.Context, bucket, objectKey string) (ObjectMetadata, error)
}
type AttachmentService struct {
repo AttachmentRepository
store ObjectStore
bucket string
maxSize int64
allowList map[string]struct{}
uploadTTL time.Duration
}
func NewAttachmentService(repo AttachmentRepository, store ObjectStore, bucket string) *AttachmentService {
return &AttachmentService{
repo: repo,
store: store,
bucket: bucket,
maxSize: 10 * 1024 * 1024,
uploadTTL: 15 * time.Minute,
allowList: map[string]struct{}{
"application/pdf": {},
"image/jpeg": {},
"image/png": {},
},
}
}
func (s *AttachmentService) BeginUpload(ctx context.Context, orgID uuid.UUID, input CreateUploadInput) (Attachment, PresignedUpload, error) {
if err := s.validateInput(input); err != nil {
return Attachment{}, PresignedUpload{}, err
}
exists, err := s.repo.NoticeExists(ctx, orgID, input.NoticeID)
if err != nil {
return Attachment{}, PresignedUpload{}, err
}
if !exists {
return Attachment{}, PresignedUpload{}, ErrNoticeNotFound
}
attachmentID := uuid.New()
key := fmt.Sprintf("notices/%s/%s/%s/%s", orgID, input.NoticeID, attachmentID, sanitizeFileName(input.FileName))
attachment := Attachment{
ID: attachmentID,
OrgID: orgID,
NoticeID: input.NoticeID,
ObjectKey: key,
FileName: sanitizeFileName(input.FileName),
ContentType: strings.ToLower(input.ContentType),
SizeBytes: input.SizeBytes,
ChecksumSHA256: strings.ToLower(input.ChecksumSHA256),
Status: AttachmentPending,
}
attachment, err = s.repo.CreatePendingAttachment(ctx, attachment)
if err != nil {
return Attachment{}, PresignedUpload{}, err
}
presigned, err := s.store.CreatePresignedPut(ctx, s.bucket, attachment.ObjectKey, s.uploadTTL, attachment.ContentType, attachment.SizeBytes, attachment.ChecksumSHA256)
if err != nil {
return Attachment{}, PresignedUpload{}, err
}
return attachment, presigned, nil
}
func (s *AttachmentService) ConfirmUpload(ctx context.Context, orgID uuid.UUID, input ConfirmUploadInput) (Attachment, error) {
attachment, err := s.repo.GetAttachment(ctx, orgID, input.AttachmentID)
if err != nil {
return Attachment{}, err
}
meta, err := s.store.HeadObject(ctx, s.bucket, attachment.ObjectKey)
if err != nil {
if markErr := s.repo.MarkAttachmentFailed(ctx, orgID, attachment.ID, "object missing in storage"); markErr != nil {
return Attachment{}, errors.Join(err, markErr)
}
return Attachment{}, err
}
if strings.ToLower(meta.ContentType) != attachment.ContentType {
reason := fmt.Sprintf("content type mismatch: declared=%s actual=%s", attachment.ContentType, meta.ContentType)
if markErr := s.repo.MarkAttachmentFailed(ctx, orgID, attachment.ID, reason); markErr != nil {
return Attachment{}, errors.Join(ErrAttachmentValidation, markErr)
}
return Attachment{}, fmt.Errorf("%w: %s", ErrAttachmentValidation, reason)
}
if meta.SizeBytes != attachment.SizeBytes {
reason := fmt.Sprintf("size mismatch: declared=%d actual=%d", attachment.SizeBytes, meta.SizeBytes)
if markErr := s.repo.MarkAttachmentFailed(ctx, orgID, attachment.ID, reason); markErr != nil {
return Attachment{}, errors.Join(ErrAttachmentValidation, markErr)
}
return Attachment{}, fmt.Errorf("%w: %s", ErrAttachmentValidation, reason)
}
if strings.ToLower(meta.ChecksumSHA256) != attachment.ChecksumSHA256 {
reason := "checksum mismatch"
if markErr := s.repo.MarkAttachmentFailed(ctx, orgID, attachment.ID, reason); markErr != nil {
return Attachment{}, errors.Join(ErrAttachmentValidation, markErr)
}
return Attachment{}, fmt.Errorf("%w: %s", ErrAttachmentValidation, reason)
}
return s.repo.MarkAttachmentReady(ctx, orgID, attachment.ID, time.Now().UTC())
}
func (s *AttachmentService) validateInput(input CreateUploadInput) error {
if input.NoticeID == uuid.Nil {
return fmt.Errorf("%w: notice_id is required", ErrAttachmentValidation)
}
if strings.TrimSpace(input.FileName) == "" {
return fmt.Errorf("%w: file_name is required", ErrAttachmentValidation)
}
if input.SizeBytes <= 0 || input.SizeBytes > s.maxSize {
return fmt.Errorf("%w: size_bytes must be between 1 and %d", ErrAttachmentValidation, s.maxSize)
}
contentType := strings.ToLower(strings.TrimSpace(input.ContentType))
if _, ok := s.allowList[contentType]; !ok {
return fmt.Errorf("%w: content type %s is not allowed", ErrAttachmentValidation, contentType)
}
checksum := strings.ToLower(strings.TrimSpace(input.ChecksumSHA256))
decoded, err := hex.DecodeString(checksum)
if err != nil || len(decoded) != 32 {
return fmt.Errorf("%w: checksum_sha256 must be a 64 character hex digest", ErrAttachmentValidation)
}
return nil
}
func sanitizeFileName(name string) string {
clean := strings.ReplaceAll(path.Base(name), " ", "-")
if clean == "." || clean == "/" || clean == "" {
return "attachment.bin"
}
return clean
}The service trusts only storage-side metadata at confirmation time; presign-time validation is a gate, not proof that the upload really matched expectations.
What should you test with MinIO before shipping?
The most important negative path is a client that asked for application/pdf but actually uploaded image/png, or uploaded fewer bytes than promised. In both cases the row must become failed rather than ready, because a broken attachment reference in the resident feed is worse than rejecting the file outright. Another security check is confirming the object key path always starts with the authenticated org_id and notice_id so clients cannot reuse presigned URLs across tenants.
Applied exercise
Catch a swapped media type after upload
A resident admin asks for a presigned upload using content_type application/pdf, but their client later PUTs a PNG image to the returned URL while keeping the same object key. You need to trace what the server should record when confirm is called.
- Describe which validations happen before the presigned URL is issued.
- List the storage metadata that the confirm step must inspect via HEAD.
- State the final attachment status and failure reason when actual Content-Type is image/png instead of application/pdf.
- Explain why this mismatch would be invisible if the system trusted presign-time validation alone.
Deliverable
A short incident walkthrough showing the pending row, the mismatched HEAD response, the failed status transition, and the exact reason the API returns to the caller.
Completion checks
- The answer includes both presign-time and confirm-time validation rather than treating them as interchangeable.
- The final state is failed, not ready and not deleted silently.
- The explanation ties the mismatch to storage-side metadata instead of to the caller's original JSON request.
Why is issuing a presigned PUT URL not enough to trust an attachment row?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.