Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
64 commits
Select commit Hold shift + click to select a range
bf93082
Add basic file upload setup
wout Feb 19, 2026
d0ead84
Add specs for uploader and memory store
wout Feb 19, 2026
f8815a6
Add compatibility with `Lucky::UploadedFile`
wout Feb 19, 2026
a278379
Add better error message for missing stores
wout Feb 19, 2026
c097562
Fix bug in file system storage url builder
wout Feb 19, 2026
077964a
Add missing specs for attachment uploaded file
wout Feb 19, 2026
a75d9f7
Add missing specs for memory storage
wout Feb 19, 2026
e8d529e
Add specs for file system storage
wout Feb 19, 2026
1ed9c18
Add specs for `Lucky::UploadedFile` integration
wout Feb 19, 2026
9dbf953
Clean up storage code and add comments
wout Feb 20, 2026
540c037
Rename uploaded file to stored file for attachments
wout Feb 21, 2026
dc9af32
Restructure attachment directory
wout Feb 21, 2026
62e947a
Add configurable path prefixes for attachments
wout Feb 22, 2026
25382fb
Fix code styling issues
wout Feb 22, 2026
87d1a0a
Add avram modules
wout Feb 22, 2026
442c0e3
Fix code styling issues
wout Feb 22, 2026
870fdd1
Merge branch 'main' into add-file-uploads-with-configurable-storage
wout Feb 25, 2026
62bc50e
Merge branch 'main' into add-file-uploads-with-configurable-storage
wout Mar 1, 2026
f5d6408
Rename `Storage::Base` base class to `Storage` and add S3 shard
wout Mar 1, 2026
0323507
Add `Storage:S3`
wout Mar 6, 2026
9789eb3
Rework Uploader to use extractors to build the metadata hash
wout Mar 7, 2026
90d890b
Install `file` utility in docker container for tests
wout Mar 7, 2026
b72b65d
Add test for extractors
wout Mar 7, 2026
a3c14c4
Install imagemagick in test docker container
wout Mar 7, 2026
3bc3cd2
Add run_command helper for extractors
wout Mar 7, 2026
8176c17
Rework mime from file extractor to use the run command helper
wout Mar 7, 2026
0c19c3f
Add small png logo fixture for dimensions from identify extractor
wout Mar 7, 2026
d8b967a
Add dimensions from identify extractor
wout Mar 7, 2026
2ab8997
Remove debug line from dimensions extractor
wout Mar 7, 2026
b5d83bf
Add comments documenting the extract macro and run command module
wout Mar 7, 2026
1fbef59
Fix bug in extract macro
wout Mar 7, 2026
ab6fdb3
Install ImageMagick in CI flow
wout Mar 7, 2026
11a9b2a
Make sure imagemagick is in the path on windows in CI
wout Mar 7, 2026
6bd90b5
Debugging imagemagick installation on Windows
wout Mar 7, 2026
b6cb933
Remove ImageMagick installation on Windows (already installed)
wout Mar 7, 2026
17fcb20
Rework the dimensions extractor to work with both `magick` and `ident…
wout Mar 7, 2026
396bbc2
Properly handle fallback commands in dimension extractor
wout Mar 7, 2026
735598f
Fix error in run command example comment
wout Mar 8, 2026
4815328
Change signature on `extract` macro for uploaders
wout Mar 13, 2026
3f97610
Rework type declaration for attach macro
wout Mar 13, 2026
fb3a0fe
Make dimensions extract return a string value
wout Mar 13, 2026
eab92ed
Make mime from IO extractor more resilient
wout Mar 13, 2026
e0481c6
Remove duplicate methods from stored file
wout Mar 13, 2026
5c6caaf
Create aliases for built-in extractors
wout Mar 13, 2026
58fbb96
Allow extractors to declare addtional methods for metadata properties
wout Mar 14, 2026
69bca09
Rework dimensions extractor to not return anything
wout Mar 14, 2026
b07307d
Add `[]?` and `extension?` methods to stored file class
wout Mar 14, 2026
63d9ff8
Remove type declaration on extract macro signature
wout Mar 14, 2026
01b7492
Declare path prefix as a class method on uploaders
wout Mar 14, 2026
145f4da
Move avram module to the Avram repo
wout Mar 14, 2026
5764d6a
Make `StoredFile` and abstract class
wout Mar 14, 2026
a7d462f
Allow passing a custom location to the promote method
wout Mar 15, 2026
d8f96fb
Add move overload accepting stored files on the base storage class
wout Mar 15, 2026
84687a4
Use the move method on the storage in uploaders to promote files more…
wout Mar 15, 2026
f41a6da
Use `Lucky::UploadedFile` instead of a plain `IO in uploaders and ext…
wout Mar 17, 2026
fc46de4
Add overload for `run_command` in extractors accepting `Lucky::Upload…
wout Mar 18, 2026
e57392f
Get file size directly from IO in `SizeFromIO` extractor
wout Mar 18, 2026
c96b974
Make storage destinations configurable per uploader
wout Mar 18, 2026
48fe80e
Fix specs to run green on macOS and Windows as well
wout Mar 18, 2026
8b68fe2
Replace extractors constant with a class variable
wout Mar 18, 2026
f0c6f5a
Handle possible malformed return values from ImageMagic in dimensions…
wout Mar 25, 2026
34271fc
Handle possible missing extension
wout Mar 25, 2026
5e4bcdd
Rework header builder method in s3 storage
wout Mar 25, 2026
d16d44f
Add support for multipart uploads to S3 uploader
wout Mar 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ on:
push:
branches: [main]
pull_request:
branches: "*"
branches: '*'

jobs:
check_format:
Expand Down Expand Up @@ -54,6 +54,12 @@ jobs:
- uses: crystal-lang/install-crystal@v1
with:
crystal: ${{matrix.crystal_version}}
- name: Install ImageMagick (Ubuntu)
if: runner.os == 'Linux'
run: sudo apt-get install -y imagemagick
- name: Install ImageMagick (macOS)
if: runner.os == 'macOS'
run: brew install imagemagick
- name: Install shards
run: shards install --skip-postinstall --skip-executables
env:
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ FROM crystallang/crystal:latest
WORKDIR /data

RUN apt-get update && \
apt-get install -y curl libreadline-dev unzip && \
apt-get install -y curl libreadline-dev unzip file imagemagick && \
curl -fsSL https://bun.sh/install | bash && \
ln -s /root/.bun/bin/bun /usr/local/bin/bun && \
# Cleanup leftovers
Expand Down
8 changes: 7 additions & 1 deletion shard.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
name: lucky
version: 1.4.0

crystal: ">= 1.16.3"
crystal: '>= 1.16.3'

authors:
- Paul Smith <paulcsmith0218@gmail.com>
Expand Down Expand Up @@ -56,5 +56,11 @@ development_dependencies:
ameba:
github: crystal-ameba/ameba
version: ~> 1.6.4
awscr-s3:
github: taylorfinnell/awscr-s3
version: ~> 0.10.0
webmock:
github: manastech/webmock.cr
branch: master

license: MIT
Binary file added spec/fixtures/lucky_logo_tiny.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
65 changes: 65 additions & 0 deletions spec/lucky/attachment/extractor/dimensions_from_magick_spec.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
require "../../../spec_helper"

describe Lucky::Attachment::Extractor::DimensionsFromMagick do
describe "#extract" do
subject = Lucky::Attachment::Extractor::DimensionsFromMagick.new
png_path = "spec/fixtures/lucky_logo_tiny.png"

context "when neither magick nor identify is installed" do
it "raises Lucky::Attachment::Error" do
original_path = ENV["PATH"]
ENV["PATH"] = ""
uploaded_file = build_uploaded_file(filename: "test.png")

begin
expect_raises(
Lucky::Attachment::Error,
/The `magick|identify` command-line tool is not installed/
) do
subject.extract(
uploaded_file,
metadata: {} of String => Lucky::Attachment::MetadataValue
)
end
ensure
ENV["PATH"] = original_path
end
end
end

context "when magick or identify is installed" do
it "extracts width and height from a PNG file" do
uploaded_file = build_uploaded_file(
path: png_path,
filename: "lucky_logo_tiny.png"
)
metadata = {} of String => Lucky::Attachment::MetadataValue
result = subject.extract(uploaded_file, metadata: metadata)

result.should be_nil
metadata["width"].should eq(69)
metadata["height"].should eq(16)
end

it "does not modify metadata for an unrecognised file" do
uploaded_file = build_uploaded_file(filename: "empty.bin")
metadata = {} of String => Lucky::Attachment::MetadataValue
subject.extract(uploaded_file, metadata: metadata)

metadata.should be_empty
end
end
end
end

private def build_uploaded_file(
filename : String,
path : String? = nil,
) : Lucky::UploadedFile
headers = HTTP::Headers.new
headers["Content-Disposition"] =
%[form-data; name="file"; filename="#{filename}"]
body = path ? File.open(path) : IO::Memory.new
part = HTTP::FormData::Part.new(headers: headers, body: body)
Lucky::UploadedFile.new(part)
end
58 changes: 58 additions & 0 deletions spec/lucky/attachment/extractor/filename_from_io_spec.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
require "../../../spec_helper"

describe Lucky::Attachment::Extractor::FilenameFromIO do
describe "#extract" do
subject = Lucky::Attachment::Extractor::FilenameFromIO.new

context "when a filename is provided in options" do
it "returns the filename from options, ignoring the uploaded file's filename" do
uploaded_file = build_uploaded_file(filename: "original.jpg")
result = subject.extract(
uploaded_file,
metadata: {} of String => Lucky::Attachment::MetadataValue,
filename: "override.txt"
)

result.should eq("override.txt")
end
end

context "when no filename option is given" do
it "returns the filename from the uploaded file" do
uploaded_file = build_uploaded_file(filename: "photo.jpg")
result = subject.extract(
uploaded_file,
metadata: {} of String => Lucky::Attachment::MetadataValue
)

result.should eq("photo.jpg")
end

it "falls back to the basename of the tempfile path when filename is blank" do
uploaded_file = build_uploaded_file(filename: nil)
result = subject.extract(
uploaded_file,
metadata: {} of String => Lucky::Attachment::MetadataValue
)

result.should match(/^\w{24}$/)
end
end
end
end

private def build_uploaded_file(filename : String?) : Lucky::UploadedFile
headers = HTTP::Headers.new
headers["Content-Disposition"] = content_disposition(filename)
body = IO::Memory.new
part = HTTP::FormData::Part.new(headers: headers, body: body)
Lucky::UploadedFile.new(part)
end

private def content_disposition(filename)
if filename.presence
%[form-data; name="file"; filename="#{filename}"]
else
%[form-data; name="file"]
end
end
70 changes: 70 additions & 0 deletions spec/lucky/attachment/extractor/mime_from_extension_spec.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
require "../../../spec_helper"

describe Lucky::Attachment::Extractor::MimeFromExtension do
describe "#extract" do
subject = Lucky::Attachment::Extractor::MimeFromExtension.new

context "with a known extension" do
it "returns the MIME type for a PNG file" do
uploaded_file = build_uploaded_file(filename: "photo.png")
result = subject.extract(
uploaded_file,
metadata: {} of String => Lucky::Attachment::MetadataValue
)

result.should eq("image/png")
end

it "returns the MIME type for a PDF file" do
uploaded_file = build_uploaded_file(filename: "document.pdf")
result = subject.extract(
uploaded_file,
metadata: {} of String => Lucky::Attachment::MetadataValue
)

result.should eq("application/pdf")
end

it "uses only the last extension for dotted filenames" do
uploaded_file = build_uploaded_file(filename: "my.profile.photo.jpg")
result = subject.extract(
uploaded_file,
metadata: {} of String => Lucky::Attachment::MetadataValue
)

result.should eq("image/jpeg")
end
end

context "with an unknown or missing extension" do
it "returns nil for an unknown extension" do
uploaded_file = build_uploaded_file(filename: "file.unknownextension")
result = subject.extract(
uploaded_file,
metadata: {} of String => Lucky::Attachment::MetadataValue
)

result.should be_nil
end

it "returns nil for a filename with no extension" do
uploaded_file = build_uploaded_file(filename: "Makefile")
result = subject.extract(
uploaded_file,
metadata: {} of String => Lucky::Attachment::MetadataValue
)

result.should be_nil
end
end
end
end

private def build_uploaded_file(filename : String) : Lucky::UploadedFile
headers = HTTP::Headers.new
headers["Content-Disposition"] =
%[form-data; name="file"; filename="#{filename}"]
body = IO::Memory.new
part = HTTP::FormData::Part.new(headers: headers, body: body)
Lucky::UploadedFile.new(part)
end
94 changes: 94 additions & 0 deletions spec/lucky/attachment/extractor/mime_from_file_spec.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
require "../../../spec_helper"

describe Lucky::Attachment::Extractor::MimeFromFile do
describe "#extract" do
subject = Lucky::Attachment::Extractor::MimeFromFile.new

context "when the file is empty" do
it "returns nil without invoking the file utility" do
uploaded_file = build_uploaded_file(content: "", size: 0_u64)
result = subject.extract(
uploaded_file,
metadata: {} of String => Lucky::Attachment::MetadataValue
)

result.should be_nil
end
end

context "when the file utility is not installed" do
it "raises Lucky::Attachment::Error" do
original_path = ENV["PATH"]
ENV["PATH"] = ""
uploaded_file = build_uploaded_file(content: "Hello, world!")

begin
expect_raises(
Lucky::Attachment::Error,
"The `file` command-line tool is not installed"
) do
subject.extract(
uploaded_file,
metadata: {} of String => Lucky::Attachment::MetadataValue
)
end
ensure
ENV["PATH"] = original_path
end
end
end

context "when the file utility is installed" do
it "returns the MIME type for a PNG file" do
png_base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAA" \
"DUlEQVR42mP8z8BQDwADhQGAWjR9awAAAABJRU5ErkJggg=="
uploaded_file = build_uploaded_file(
content: Base64.decode(png_base64),
filename: "test.png"
)
result = subject.extract(
uploaded_file,
metadata: {} of String => Lucky::Attachment::MetadataValue
)

result.should eq("image/png")
end

it "returns the MIME type for plain text" do
uploaded_file = build_uploaded_file(content: "Hello, world!")
result = subject.extract(
uploaded_file,
metadata: {} of String => Lucky::Attachment::MetadataValue
)

result.should eq("text/plain")
end

it "strips surrounding whitespace from the output" do
uploaded_file = build_uploaded_file(content: "Hello, world!")
result = subject.extract(
uploaded_file,
metadata: {} of String => Lucky::Attachment::MetadataValue
)

result.should eq(result.try &.strip)
end
end
end
end

private def build_uploaded_file(
content : String | Bytes,
filename : String = "test.bin",
size : UInt64? = nil,
) : Lucky::UploadedFile
headers = HTTP::Headers.new
disposition = String.build do |io|
io << %[form-data; name="file"; filename="#{filename}"]
io << "; size=#{size}" if size
end
headers["Content-Disposition"] = disposition
body = IO::Memory.new(content)
part = HTTP::FormData::Part.new(headers: headers, body: body)
Lucky::UploadedFile.new(part)
end
Loading
Loading