Skip to content

feat: add file upload & storage support (Roadmap 2.2)#267

Merged
adnaan merged 6 commits intomainfrom
file-upload
Mar 21, 2026
Merged

feat: add file upload & storage support (Roadmap 2.2)#267
adnaan merged 6 commits intomainfrom
file-upload

Conversation

@adnaan
Copy link
Copy Markdown
Contributor

@adnaan adnaan commented Mar 21, 2026

Summary

  • Add file and image field types to the code generator: lvt gen resource gallery title:string photo:image doc:file
  • New pkg/storage package with Store interface, LocalStore (filesystem), and S3Store (AWS S3)
  • New pkg/s3presigner package ported from livetemplate core (generates presigned PUT URLs)
  • New pkg/imaging package for thumbnail generation using disintegration/imaging
  • Handler templates generate WithUpload config, ctx.GetCompletedUploadsstorage.Save flow
  • Form templates add <input type="file" lvt-upload="..."> with upload progress and edit preview
  • SQL templates expand file fields to 4 columns (url, filename, content_type, size)
  • Type inference for common file/image field names (avatar, photo, document, etc.)

Test plan

  • Parser tests: file/image type parsing, IsFile/IsImage flags, MapType, GetFieldMetadata
  • Storage tests: LocalStore Save/Open/Delete/URL round-trips, S3Store.URL construction
  • Imaging tests: thumbnail decode/resize/encode, aspect ratio preservation, invalid input
  • S3Presigner tests: config defaults, key generation, path traversal prevention, presign URL structure
  • Golden file tests: handler snapshot for both standard and file-upload resources
  • Content validation test: handler has storage imports, WithUpload, upload processing; SQL has expanded columns; template has lvt-upload attributes
  • Full flow compile test: generate app → generate resource with photo:image + doc:file → wire route → sqlc generate → go build succeeds
  • All 35 test packages pass

🤖 Generated with Claude Code

Add file/image field types to the code generator, enabling resources
like `lvt gen resource gallery title:string photo:image doc:file`.

New packages:
- pkg/storage: Store interface with LocalStore (filesystem) and S3Store
- pkg/s3presigner: S3 presigned URL generator (ported from livetemplate core)
- pkg/imaging: thumbnail generation using disintegration/imaging

Parser & generator:
- file/image types in parser with IsFile/IsImage flags on Field/FieldData
- File fields excluded from AddInput/UpdateInput (data arrives via upload protocol)
- NonFileFields(), FileFields() helpers on ResourceData
- UseUpload flag on ComponentUsage drives conditional code generation
- getDisplayField skips file/image fields
- Type inference for common names (avatar, photo, document, etc.)

Templates (multi + single kits):
- Handler: WithUpload config, ctx.GetCompletedUploads → storage.Save,
  defer f.Close(), error-checked GetByID, file cleanup on delete
- Form: file input with lvt-upload attribute, upload progress, edit preview
- Detail: image preview, file download link
- SQL: file fields expand to 4 columns (url, filename, content_type, size)

Route injection skipped for upload resources (requires manual storage setup).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings March 21, 2026 09:11
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds first-class file/image upload support to LiveTemplate’s code generator and runtime helpers, enabling lvt gen resource ... photo:image doc:file to generate upload-capable handlers, templates, and SQL schema.

Changes:

  • Introduces file/image field types in the parser + generator plumbing, including expanded SQL columns for file metadata.
  • Adds new runtime packages for storage backends (pkg/storage), S3 presigned PUT URL generation (pkg/s3presigner), and thumbnail generation (pkg/imaging).
  • Updates resource handler + UI templates to configure uploads and process completed uploads into storage.

Reviewed changes

Copilot reviewed 33 out of 34 changed files in this pull request and generated 14 comments.

Show a summary per file
File Description
testdata/golden/resource_handler.go.golden Updates baseline handler golden output.
testdata/golden/file_upload_handler.go.golden Adds golden output for an upload-enabled resource handler.
pkg/storage/storage.go Adds storage.Store interface for upload persistence.
pkg/storage/local.go Implements filesystem-backed LocalStore.
pkg/storage/s3.go Implements S3-backed S3Store.
pkg/storage/storage_test.go Adds tests for LocalStore + S3Store URL behavior.
pkg/s3presigner/s3presigner.go Adds S3 presigned PUT URL generator.
pkg/s3presigner/s3presigner_test.go Adds tests for S3 presigning + key generation/sanitization.
pkg/imaging/thumbnail.go Adds thumbnail generation helper using disintegration/imaging.
pkg/imaging/thumbnail_test.go Adds tests for thumbnail generation behavior.
internal/parser/fields.go Adds file/image parsing + IsFile/IsImage flags.
internal/parser/fields_test.go Adds tests for file/image field parsing + metadata.
internal/generator/components.go Detects upload usage (UseUpload) based on fields.
internal/generator/types.go Adds FileFields/NonFileFields helpers and IsFile/IsImage in template data.
internal/generator/resource.go Skips route auto-injection when uploads require storage wiring.
internal/kits/system/single/templates/resource/schema.sql.tmpl Expands file/image fields to 4 DB columns in schema.
internal/kits/system/single/templates/resource/migration.sql.tmpl Expands file/image fields to 4 DB columns in migrations.
internal/kits/system/single/templates/resource/queries.sql.tmpl Expands INSERT/UPDATE columns/placeholders for file fields.
internal/kits/system/single/templates/resource/embedded_queries.sql.tmpl Expands embedded resource SQL for file fields.
internal/kits/system/single/templates/resource/handler.go.tmpl Generates upload config + completed-upload → storage save flow.
internal/kits/system/single/components/form.tmpl Adds upload inputs + progress/error UI and edit previews.
internal/kits/system/single/components/detail.tmpl Renders images/files on detail page.
internal/kits/system/multi/templates/resource/schema.sql.tmpl Expands file/image fields to 4 DB columns in schema.
internal/kits/system/multi/templates/resource/migration.sql.tmpl Expands file/image fields to 4 DB columns in migrations.
internal/kits/system/multi/templates/resource/queries.sql.tmpl Expands INSERT/UPDATE columns/placeholders for file fields.
internal/kits/system/multi/templates/resource/embedded_queries.sql.tmpl Expands embedded resource SQL for file fields.
internal/kits/system/multi/templates/resource/handler.go.tmpl Generates upload config + completed-upload → storage save flow.
internal/kits/system/multi/components/form.tmpl Adds upload inputs + progress/error UI and edit previews.
internal/kits/system/multi/components/detail.tmpl Renders images/files on detail page.
commands/gen.go Prints manual route + storage setup instructions for upload resources.
integration_test.go Adds integration coverage for upload resource generation and full-flow compile.
golden_test.go Refactors golden testing helper + adds upload handler golden test.
go.mod Adds imaging dependency (and x/image indirect).
go.sum Records new module sums for imaging + transitive deps.

Comment on lines 620 to 624
state.Filtered[[.ResourceNamePlural]] = [][[.ResourceName]]Item{}
query := strings.ToLower(state.SearchQuery)
for _, item := range [[.ResourceNameLower]]s {
// Search across all text fields
// Search across all text fields (skip file/image fields)
[[- range .Fields]]
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This hunk still relies on the [[.ResourceNameLower]]s pattern for plural identifiers (e.g., for _, item := range [[.ResourceNameLower]]s). For resource names like "gallery" this generates awkward/incorrect plurals like gallerys. Consider using [[.TableName]] (already pluralized) or otherwise deriving a proper plural-lower name for template identifiers.

Copilot uses AI. Check for mistakes.
Comment thread pkg/storage/storage.go
Comment on lines +11 to +16
// Store is the interface for file storage backends.
type Store interface {
Save(ctx context.Context, key string, reader io.Reader) error
Open(ctx context.Context, key string) (io.ReadCloser, error)
Delete(ctx context.Context, key string) error
URL(key string) string
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Store interface uses parameter name key for Open/Delete/URL, but LocalStore.Delete accepts either a key or a URL (strips urlPrefix) and the generator appears to persist URLs. This makes the contract ambiguous and is currently incompatible with S3Store. Consider clarifying the contract in the interface docs (key vs URL) and aligning all implementations + generated code accordingly.

Copilot uses AI. Check for mistakes.
Comment thread pkg/storage/local.go
Comment on lines +29 to +31
func (s *LocalStore) Save(_ context.Context, key string, reader io.Reader) error {
path := filepath.Join(s.baseDir, filepath.FromSlash(key))

Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LocalStore.Save builds the destination path with filepath.Join(baseDir, filepath.FromSlash(key)) without validating/sanitizing key. If key contains path traversal segments (e.g. "../"), this can write outside baseDir and overwrite arbitrary files. Consider cleaning the key and rejecting absolute/parent-relative paths, and/or verifying the final path is within baseDir before creating directories/files.

Copilot uses AI. Check for mistakes.
Comment on lines +149 to +156
key := fmt.Sprintf("[[$.TableName]]/%s/%s", id, entry.ClientName)
if err := c.Store.Save(dbCtx, key, f); err != nil {
return state, fmt.Errorf("failed to save file: %w", err)
}
[[.Name]]Val = c.Store.URL(key)
[[.Name]]Filename = entry.ClientName
[[.Name]]ContentType = entry.ClientType
[[.Name]]Size = entry.ClientSize
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The storage key includes entry.ClientName directly. Client-provided filenames can contain path separators or traversal sequences ("../", "..\"), which becomes part of the storage key and can lead to unsafe paths for LocalStore. Sanitize the filename (e.g., strip directories, normalize separators) before building the key, or ensure the storage backend enforces a safe key policy.

Copilot uses AI. Check for mistakes.
Comment on lines +395 to +401
[[- if .Components.UseUpload]]
// Delete associated files from storage
if existing, err := c.Queries.Get[[.ResourceNameSingular]]ByID(dbCtx, state.PendingDeleteID); err == nil {
[[- range .FileFields]]
if existing.[[.Name | camelCase]] != "" {
_ = c.Store.Delete(dbCtx, existing.[[.Name | camelCase]])
}
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ConfirmDelete passes existing. to Store.Delete, but earlier the code sets to c.Store.URL(key), so the DB value is a URL rather than the storage key. This will break backends that expect a key (e.g. the current S3Store.Delete) and makes deletions unreliable when CDN prefixes/custom endpoints are used. Consider storing the storage key in the DB (derive the public URL at render time), or standardize Store.Delete/Open to accept URLs and reliably normalize them back to keys for all backends.

Copilot uses AI. Check for mistakes.
Comment thread pkg/storage/s3.go
Comment on lines +91 to +99
// Delete removes the object at the given key from S3.
func (s *S3Store) Delete(ctx context.Context, key string) error {
_, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: aws.String(s.config.Bucket),
Key: aws.String(key),
})
if err != nil {
return fmt.Errorf("storage: s3 delete: %w", err)
}
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

S3Store.Delete treats its argument as a raw S3 object key, but generated handlers store file fields as Store.URL(key) and then pass the stored value into Delete. That means S3Store.Delete will be called with a full URL and will attempt to delete the wrong key. Either normalize URL→key inside S3Store (similar to LocalStore.resolveKey), or change the generator/data model to persist object keys rather than URLs.

Copilot uses AI. Check for mistakes.
Comment on lines +105 to +109
// generateKey creates S3 object key from upload entry.
// Format: {KeyPrefix}/{entryID}/{sanitized_filename}
func (p *S3Presigner) generateKey(entry *livetemplate.UploadEntry) string {
filename := filepath.Base(entry.ClientName)

Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

generateKey uses filepath.Base(entry.ClientName) as its only sanitization. On non-Windows builds, filepath.Base does not treat backslashes ("\") as path separators, so a client filename like "..\..\evil.txt" won’t be reduced to a safe basename. Consider normalizing separators (replace "\" with "/") and using path.Base or otherwise rejecting any name containing path traversal characters.

Copilot uses AI. Check for mistakes.
Comment thread pkg/imaging/thumbnail_test.go Outdated
Comment on lines +39 to +45
if len(data) == 0 {
t.Error("GenerateThumbnail() returned empty result")
}

// Verify JPEG header (starts with 0xFF 0xD8)
if data[0] != 0xFF || data[1] != 0xD8 {
t.Error("GenerateThumbnail() output is not JPEG")
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test indexes data[0] and data[1] after only checking len(data) == 0. If the output were unexpectedly 1 byte long, this would panic and obscure the real failure. Guard with len(data) < 2 before checking the JPEG magic bytes.

Copilot uses AI. Check for mistakes.
Comment on lines +149 to +156
key := fmt.Sprintf("[[$.TableName]]/%s/%s", id, entry.ClientName)
if err := c.Store.Save(dbCtx, key, f); err != nil {
return state, fmt.Errorf("failed to save file: %w", err)
}
[[.Name]]Val = c.Store.URL(key)
[[.Name]]Filename = entry.ClientName
[[.Name]]ContentType = entry.ClientType
[[.Name]]Size = entry.ClientSize
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The storage key includes entry.ClientName directly. Client-provided filenames can contain path separators or traversal sequences ("../", "..\"), which becomes part of the storage key and can lead to unsafe paths for LocalStore. Sanitize the filename (e.g., strip directories, normalize separators) before building the key, or ensure the storage backend enforces a safe key policy.

Copilot uses AI. Check for mistakes.
Comment on lines +395 to +401
[[- if .Components.UseUpload]]
// Delete associated files from storage
if existing, err := c.Queries.Get[[.ResourceNameSingular]]ByID(dbCtx, state.PendingDeleteID); err == nil {
[[- range .FileFields]]
if existing.[[.Name | camelCase]] != "" {
_ = c.Store.Delete(dbCtx, existing.[[.Name | camelCase]])
}
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ConfirmDelete passes existing. to Store.Delete, but earlier the code sets to c.Store.URL(key), so the DB value is a URL rather than the storage key. This will break backends that expect a key (e.g. the current S3Store.Delete) and makes deletions unreliable when CDN prefixes/custom endpoints are used. Consider storing the storage key in the DB (derive the public URL at render time), or standardize Store.Delete/Open to accept URLs and reliably normalize them back to keys for all backends.

Copilot uses AI. Check for mistakes.
@claude
Copy link
Copy Markdown

claude bot commented Mar 21, 2026

Code Review: feat: add file upload & storage support (Roadmap 2.2)

This is a substantial, well-structured PR. The storage abstraction is clean, test coverage is thorough, and the golden file + integration test approach gives strong confidence in generated output. A few items to address before merging:


Security

Path traversal in LocalStore (medium severity)

LocalStore.Save, Open, and Delete all do:

path := filepath.Join(s.baseDir, filepath.FromSlash(key))

filepath.Join calls filepath.Clean internally, which resolves .. — so filepath.Join("/uploads", "../etc/passwd") yields /etc/passwd. If a key ever contains ../ sequences, this escapes the base directory.

In practice, the generated handler builds keys from server-controlled upload entry IDs, and S3Presigner.generateKey correctly calls filepath.Base(entry.ClientName) to strip path components. But LocalStore has no equivalent guard, so the interface contract is weaker than it should be.

Suggested fix (add to all three methods, or factor into a shared resolvePath):

func (s *LocalStore) resolvePath(key string) (string, error) {
    path := filepath.Join(s.baseDir, filepath.FromSlash(key))
    base := filepath.Clean(s.baseDir) + string(os.PathSeparator)
    if !strings.HasPrefix(path, base) {
        return "", fmt.Errorf("storage: key escapes base directory")
    }
    return path, nil
}

Bugs

S3Store.Delete doesn't strip URL prefixes (unlike LocalStore.Delete)

LocalStore.Delete accepts both raw keys and full URLs (via resolveKey), since the DB stores the URL returned by URL(). S3Store.Delete has no equivalent stripping. Calling it with a full S3 URL will silently attempt to delete a non-existent key (S3 returns 204 for missing keys), leaving the object orphaned.

The handler template calls c.Store.Delete(ctx, existing.Photo) where existing.Photo is the URL stored in the DB. This will silently fail for S3.

Fix: add key resolution to S3Store.Delete (strip CDN/endpoint/standard S3 prefix to recover the raw key), or document that callers must pass raw keys and have the template store raw keys rather than full URLs.

S3Store.Save doesn't set ContentType

PutObject is called without a ContentType:

_, err := s.client.PutObject(ctx, &s3.PutObjectInput{
    Bucket: aws.String(s.config.Bucket),
    Key:    aws.String(key),
    Body:   reader,
})

Objects will be stored with binary/octet-stream, breaking browser rendering for images served directly from S3. The Store.Save signature could accept contentType string as a parameter, or a separate SaveWithMeta variant.


Dependencies

golang.org/x/image is pinned to a 2019 snapshot

golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8

This is ~6 years old. The current stable tagged version is v0.27.0. The old version may have unpatched vulnerabilities in image decoding (the exact attack surface that pkg/imaging exposes). Update to a recent tagged release:

go get golang.org/x/image@latest

The disintegration/imaging library itself is also unmaintained (last release 2019, v1.6.2). Worth noting in docs/comments in case a future maintainer wants to migrate to golang.org/x/image/draw directly.


Minor / Nitpicks

Manual setup instructions hard-code LocalStore

The post-generation message in commands/gen.go prints:

store := storage.NewLocalStore("uploads", "/uploads")

This is helpful for quick-start, but users who have an S3 setup get no guidance. Could mention both options or link to docs.

Store.URL not on the interface

URL(key string) string is defined on both LocalStore and S3Store but is absent from the Store interface in storage.go. The generated handler calls c.Store.URL(key) which means it currently only works via the concrete type, not the interface. Either add URL(key string) string to the Store interface, or the template needs to cast the type. (This may cause a compile error in generated code if c.Store is typed as storage.Store.)

imaging package is not used in generated code

pkg/imaging is added as a dependency but the generated handler templates don't appear to call GenerateThumbnail. If thumbnail generation is deferred to a future milestone, the import in go.mod and the package itself shouldn't be included yet — it adds a dependency with no current consumer. If it's intentional for app developers to call it themselves, it should be mentioned in the post-gen output or docs.


What's well done

  • The Store interface is minimal and composable — easy to test and swap implementations
  • LocalStore.resolveKey accepting both URL and raw key is ergonomic
  • S3Presigner correctly uses filepath.Base to prevent path traversal in key generation
  • The img = nil GC hint in GenerateThumbnail before encoding is a nice touch
  • Test coverage is excellent: parser, storage, imaging, presigner, golden files, content validation, and a full compile test
  • Skipping route injection for upload resources and guiding the user through manual wiring is the right tradeoff

Summary

The two most important items: LocalStore path traversal (security) and Store interface missing URL (likely compile error in generated code). The S3Store.Delete URL-stripping issue will cause silent data leaks in production. Everything else is polish.

adnaan and others added 2 commits March 21, 2026 10:25
Security:
- LocalStore: add safePath() to reject path traversal in Save/Open/Delete
- S3Presigner: normalize backslashes before extracting filename basename
- Handler template: sanitize client filenames with path.Base before building storage keys

Store interface:
- Clarify interface docs: Delete accepts both keys and URLs
- S3Store: add resolveKey() to strip known URL prefixes in Delete
- Aligns S3Store.Delete behavior with LocalStore.Delete

Templates:
- Add rel="noopener noreferrer" to target="_blank" links in detail views
- Fix err := vs err = scoping in Update method when UseUpload varies

CLI:
- Add resource package import to manual route setup instructions

Tests:
- Guard JPEG header check with len(data) < 2 to prevent panic

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@claude
Copy link
Copy Markdown

claude bot commented Mar 21, 2026

PR Review: feat: add file upload and storage support (Roadmap 2.2)\n\nThis is a solid, well-structured feature addition. The storage abstraction is clean, test coverage is thorough, and path traversal protection is handled correctly in both the S3 key generator and the LocalStore. The page-mode refactor from per-request handler cloning to a shared handler with query-param routing is a meaningful improvement. Notes below are roughly ordered by severity.\n\n---\n\n### Bugs / Correctness\n\n1. Old files not deleted on update (resource leak)\n\nIn the generated Update handler, when a new file is uploaded the old file stored at existing.Photo / existing.Doc is never removed from storage. Only ConfirmDelete cleans up files. Over time this will silently accumulate orphaned files in both LocalStore and S3.\n\n2. Stale golang.org/x/image dependency\n\nThe pinned commit is from October 2019 (~6 years old). Worth bumping to at least v0.18.0.\n\n---\n\n### Design / API Concerns\n\n3. pkg/imaging is not wired into generated code\n\nGenerateThumbnail is implemented and tested but the generated handler template never calls it.\n\n4. disintegration/imaging is an archived package\n\nThe upstream repo is archived (read-only, no new releases).\n\n5. No server-side MIME type validation\n\nThe generated handler trusts entry.ClientType (client-provided) for content type.\n\n---\n\n### Minor / Polish\n\n6. Silent file deletion errors in ConfirmDelete - a log.Printf would help operators detect orphaned files.\n\n7. queries.sql.tmpl UPDATE uses .Fields instead of .NonReferenceFields\n\n8. _resource_id query param: mutating r.URL directly - Passing via context.WithValue would be cleaner.\n\n---\n\n### Positives\n\n- Clean Store interface with consistent URL-vs-key semantics\n- Path traversal protection is correct in safePath and key sanitization\n- IsFile fields are correctly excluded from search, sort, and form input structs\n- Golden file tests for the upload handler are a nice addition\n- Full flow test (generate -> sqlc -> build) that catches schema/query/model alignment is excellent\n- The page-mode handler refactor is a real improvement over per-request template cloning

@claude
Copy link
Copy Markdown

claude bot commented Mar 21, 2026

Code Review: feat: add file upload & storage support (Roadmap 2.2)

This is a well-scoped, well-tested feature addition. The architecture is clean -- Store interface with LocalStore/S3Store implementations, clear field flag propagation (IsFile/IsImage), and good coverage with unit + golden + integration tests. A few issues worth addressing before merge.

1. Old file not deleted on update (storage leak): In handler.go.tmpl, the Update handler reads the existing record and falls back to existing file values when no new upload is provided -- but it never deletes the old file from storage when a replacement is uploaded. Over time this leaks orphaned files in both LocalStore and S3. After saving the new file, the old URL should be passed to c.Store.Delete.

2. S3Store.Save() drops the Content-Type: pkg/storage/s3.go calls PutObject without setting ContentType. Files served directly from S3 will default to application/octet-stream, so images will not render inline in browsers. Compare with the presigner which correctly sets ContentType.

3. defer f.Close() inside a range loop: In the generated Add and Update handlers, each iteration over FileFields opens a temp file and defers its close. With multiple file fields the defers only fire when the function returns, holding OS file descriptors for the full handler duration. Prefer closing explicitly after Store.Save returns.

4. Client-supplied MIME type is not server-validated: The generated handler stores entry.ClientType directly in the database as ContentType. This value comes from the browser and can be spoofed. For image fields, consider validating actual bytes (http.DetectContentType) before saving.

5. Filename stored verbatim: entry.ClientName is stored as-is and rendered into the DOM. Go html/template auto-escapes in most contexts so this is likely safe, but worth a deliberate check -- especially the href rendering path.

6. 10 MB limit is hardcoded: MaxFileSize: 10 << 20 will be wrong for many use cases. Consider making this a generator argument or named constant.

7. pkg/imaging is shipped but not wired: pkg/imaging.GenerateThumbnail is added with tests but the generated handler never calls it. Either wire it in for image fields or document it as a manual utility.

8. S3Store.Delete() is not idempotent: LocalStore.Delete() silently ignores os.IsNotExist; S3Store.Delete() propagates any error. Aligning the two implementations is cleaner.

9. Redundant fieldTypeTable entries for file/image: These are also handled by the early-return branch in ParseFields that sets IsFile/IsImage. The table entries produce a Field without those flags set, creating a subtly different code path if MapType is called directly.

10. err2 variable in integration test: integration_test.go uses err2 when reading main.go. The prior err has already been checked and can be reused.

Test coverage that is good: parser tests for IsFile/IsImage flags, LocalStore round-trip, path traversal prevention in safePath and generateKey, golden file for file-upload handler, content-validity assertions in TestFileUploadResourceGeneration, full compile flow in TestFileUploadFullFlow.

Missing coverage to consider: S3Store.Delete with a non-existent key; verification that old file is cleaned up during Update (once issue 1 is fixed); Content-Type header on S3Store.Save.


Overall this is solid work. The critical items are 1 (storage leak on update) and 2 (missing Content-Type on S3 upload) -- the rest are improvements.

@claude
Copy link
Copy Markdown

claude bot commented Mar 21, 2026

REPLACE_COMMENT_PLACEHOLDER

@claude
Copy link
Copy Markdown

claude bot commented Mar 21, 2026

This is a solid, well-structured feature addition. The storage abstraction is clean, test coverage is thorough, and path traversal protection is handled correctly in both the S3 key generator and the LocalStore. The page-mode refactor is a meaningful improvement. Notes below are roughly ordered by severity.

Bug 1 - Old files not deleted on update: In the generated Update handler, when a new file is uploaded the old file at existing.Photo or existing.Doc is never removed from storage. Only ConfirmDelete cleans up files. Over time orphaned files accumulate in both LocalStore and S3. Fix: delete the old key before saving the new one, similar to the ConfirmDelete pattern.

Bug 2 - Stale golang.org/x/image dependency: v0.0.0-20191009234506-e7c1f5e7dbb8 is from October 2019 (~6 years old). The current release includes security and correctness fixes. Worth bumping to at least v0.18.0.

Design 1 - pkg/imaging is not wired into generated code: GenerateThumbnail is implemented and tested but the generated handler template never calls it. Files are saved as-is, so image resources are not actually thumbnailed. Either wire it into the handler template or clarify in the package doc that this is a utility callers opt into manually.

Design 2 - disintegration/imaging is an archived package: The upstream repo is archived (read-only, no new releases). For a code-generation tool shipping a new pkg/imaging package, this is a long-term maintenance concern. Alternatives include golang.org/x/image directly or stdlib image/* packages for basic resize.

Design 3 - No server-side MIME type validation: The generated handler trusts entry.ClientType (client-provided) for content type. For image fields there is no server-side check that the uploaded bytes are actually an image. Consider using net/http.DetectContentType on the first 512 bytes before saving.

Minor 1 - Silent file deletion errors in ConfirmDelete: Ignoring Store.Delete errors is reasonable since the DB row is deleted regardless, but a log.Printf would help operators detect orphaned files.

Minor 2 - queries.sql.tmpl UPDATE uses .Fields instead of .NonReferenceFields: Both queries.sql.tmpl and embedded_queries.sql.tmpl use .Fields for the UPDATE SET clause, meaning foreign key reference fields are included in UPDATE. Pre-existing behavior, but since this PR touches these templates it might be worth aligning with the handler use of NonReferenceFields.

Minor 3 - Mutating r.URL directly in page-mode handler: r.URL.RawQuery = q.Encode() mutates the incoming request URL in place. Passing the resource ID via context.WithValue would be cleaner and avoid potential edge cases with middleware that reads the URL.

Positives: Clean Store interface, correct path traversal protection in safePath and key sanitization, IsFile fields correctly excluded from search/sort/input structs, golden file tests for upload handler, excellent full flow test (generate->sqlc->build), and the page-mode refactor to a single shared handler is a real improvement over per-request template cloning.

…mage

- Re-add old file deletion in Update handler when a replacement is uploaded
- Bump golang.org/x/image from 2019 commit to v0.37.0
- Update golden files

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@claude
Copy link
Copy Markdown

claude bot commented Mar 21, 2026

PR Review: feat: add file upload and storage support (Roadmap 2.2)

This is a solid, well-structured feature addition. Good test coverage and clean architecture. Below are observations ranging from bugs to minor nits.


Bugs / Correctness Issues

1. S3Store.Save missing ContentType

pkg/storage/s3.go Save() does not set ContentType on PutObjectInput. Images uploaded to S3 will be served as application/octet-stream, breaking img-tag preview in browsers. The Store interface does not accept content type, so this needs either an interface change or inferring the type from the key extension via mime.TypeByExtension.

2. disintegration/imaging marked as indirect in go.mod

pkg/imaging/thumbnail.go imports this package directly, but go.mod marks it as // indirect. Running go mod tidy should fix this.

3. go.sum missing /go.mod entry for golang.org/x/tools v0.42.0

The diff adds the h1: hash but the corresponding /go.mod h1: line is absent. Other packages have both. This may cause go mod verify failures.


Security Concerns

4. No MIME type validation for image fields

The generated handler stores any uploaded file without verifying it is actually an image. An attacker could upload HTML, SVG, or an executable to an image field. Consider validating that entry.ClientType has prefix image/ in the generated upload processing code.

5. Client-reported file size stored verbatim

The generated code uses entry.ClientSize (client-reported) rather than actual bytes written. Consider tracking bytes via io.TeeReader in Store.Save, or document that this value is unverified client data.

6. LocalStore.FileServer() serves entire baseDir without auth

http.FileServer(http.Dir(baseDir)) exposes directory listings and all files under baseDir. A doc comment noting this is development-only (use a CDN or signed URLs in production) would set expectations.


Design / API Issues

7. Store.Delete accepting URLs is a surprising contract

The interface says "Delete also accepts URLs returned by URL()" but callers must remember this dual key/URL behavior. The generated handler passes a URL (the existing stored URL) to Delete. A comment at the generated call-site would reduce confusion for anyone reading the output code.

8. Inconsistent not-found behavior: LocalStore vs S3Store

LocalStore.Delete swallows os.IsNotExist errors gracefully. S3Store.Delete returns an error if the object does not exist. The generated handler masks this with _ = ... but it will surprise anyone using S3Store.Delete directly.


Minor Issues

  • err2 in TestFileUploadFullFlow: unusual variable naming; restructuring the block to reuse err would be cleaner.
  • GenerateThumbnail doc missing upscale note: imaging.Fit does not upscale small images; the test covers this but the doc comment does not mention it.
  • Route skip message does not mention S3Store: the printed instructions only reference NewLocalStore; a mention of NewS3Store for production would improve discoverability.

What is Done Well

  • safePath path traversal prevention in LocalStore is correct and well-tested
  • 4-column expansion (url, filename, content_type, size) is a clean schema design
  • Skipping auto-injection for upload resources with clear manual setup instructions is the right call
  • NonFileFields() and FileFields() helpers are a clean separation of concerns in the template data layer
  • Search and sort correctly skip file/image fields
  • Type inference for common field names (avatar, photo, document, etc.) is user-friendly
  • Golden file test for the file upload handler is a valuable regression guard

The most important items before merging: 1 (S3 ContentType, affects correctness of the primary use case), 3 (go.sum integrity), and 4 (MIME validation in generated code, since this lands in user apps).

Generated with Claude Code

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@claude
Copy link
Copy Markdown

claude bot commented Mar 21, 2026

Code Review: feat: add file upload & storage support (Roadmap 2.2)

Great work on this feature — a substantial, well-structured addition. The Store interface design, 4-column SQL expansion, UseUpload flag to skip auto-injection, and path-traversal protection are all sound. Test coverage across the new packages is notably thorough. Here is what I found that deserves attention before merging.


Security

[Medium] http.FileServer exposes directory listings on the uploads folder

pkg/storage/local.go, FileServer method:

http.FileServer(http.Dir(s.baseDir))

http.Dir-based file servers serve directory listings by default. If a user navigates to /uploads/ they will see all uploaded filenames — likely unintended information disclosure for most apps. Consider returning 403 on directory requests:

type noListFS struct{ http.FileSystem }
func (f noListFS) Open(name string) (http.File, error) {
    file, err := f.FileSystem.Open(name)
    if err != nil { return nil, err }
    if stat, _ := file.Stat(); stat.IsDir() { file.Close(); return nil, fs.ErrPermission }
    return file, nil
}

[Low] S3Store.resolveKey has an empty-prefix edge case

pkg/storage/s3.go:

for _, prefix := range []string{
    s.config.CDNPrefix + "/",               // becomes "/" when CDNPrefix is ""
    fmt.Sprintf("%s/%s/", s.config.Endpoint, s.config.Bucket), // becomes "/bucket/" when Endpoint is ""
    ...

The guard prefix != "/" prevents the first case from matching, but when Endpoint is empty the second entry becomes "/bucket/" — any key starting with /bucket/ would be incorrectly stripped, potentially targeting the wrong object. A test covering the empty-endpoint case would surface this.


Bugs / Correctness

[Medium] defer f.Close() inside a loop holds all file descriptors until the handler returns

handler.go.tmpl (generated Add/Update functions):

// for each file field:
f, err := os.Open(entry.TempPath)
...
defer f.Close()   // ← deferred until Add/Update returns, not until after Store.Save

With N file fields, N descriptors stay open simultaneously. The fix is to close explicitly after Store.Save:

f, err := os.Open(entry.TempPath)
...
_, err = c.Store.Save(ctx, key, f, contentType)
f.Close()  // explicit, not deferred

[Low] Store.Delete is called with a URL, not a key — undocumented dual contract

handler.go.tmpl, Update block: the existing field value (stored as a URL from c.Store.URL(key)) is passed directly to Store.Delete. LocalStore and S3Store both implement resolveKey to handle this, but the Store interface documentation does not mention it. A future store implementation that expects a bare key would silently fail to delete old files. Please document this on the interface.

[Low] pkg/imaging/thumbnail.go is never called from generated handlers

GenerateThumbnail is shipped and tested but the image field upload path does not invoke it — images are stored at full resolution. This should either be wired in (e.g., auto-thumbnail on image field save) or documented as an opt-in utility, otherwise it is dead code from day one.


Code Quality

[Low] Duplicated AWS config initialization in pkg/s3presigner and pkg/storage/s3.go

Both packages have nearly identical if cfg.AccessKeyID != "" && cfg.SecretAccessKey != "" blocks constructing aws.Config with static credentials (~20 lines each). A shared internal helper would reduce future maintenance cost.

[Low] Golden file struct alignment likely fails gofmt

testdata/golden/file_upload_handler.go.golden (and the corresponding template):

PhotoContentType: photoContentType,
PhotoSize:         photoSize,   // extra space here

The extra space in the template alignment means go fmt will reformat the generated output, breaking golden tests on any CI that runs gofmt. Please verify the template renders gofmt-clean output.

[Low] TestFileUploadFullFlow uses os.Chdir — add a non-parallelizable marker

integration_test.go: os.Chdir(tmpDir) changes the process-wide working directory. The test does not call t.Parallel(), which is correct, but there is no comment to warn future maintainers. Please add:

// This test must not be run in parallel — it calls os.Chdir which affects the whole process.

Test Coverage Gaps

  • LocalStore.Delete is not tested with a URL containing .. after the prefix (path-traversal via Delete)
  • S3Store.Delete / resolveKey with an empty Endpoint is not covered
  • No test verifying GenerateThumbnail is exercised on image field upload (see dead-code note above)

Performance Notes

  • S3 upload body content-length: S3Store.Save passes an io.Reader directly as PutObject.Body. The AWS SDK v2 may buffer the entire file in memory if it cannot determine content length from the reader. Consider setting ContentLength explicitly from the multipart file header size when available.
  • The extra GetByID query in Update before updating file fields is necessary for "keep if not replaced" semantics — acceptable trade-off.

Summary

Priority Count
Medium 2 (directory listing, defer in loop)
Low 5 (edge cases, dead code, docs, formatting)

The medium issues should be addressed before merging. The low-priority items are mostly polish and documentation gaps that would be fine in a follow-up. Overall this is solid work — the architecture is clean and the test suite is comprehensive for a feature of this scope.

- S3Store.Save: infer ContentType from key extension via mime.TypeByExtension
- Handler template: replace defer f.Close() with explicit close after Save
  (avoids holding all FDs in range loop until handler returns)
- S3Store.resolveKey: fix empty-endpoint edge case that could match wrong keys
- LocalStore.FileServer: disable directory listings (noListDir wrapper)
- Integration test: remove err2 variable, reuse err
- S3Store.Delete: document idempotent behavior
- Update golden files

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants