Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 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
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.
55 changes: 55 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,55 @@
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 magick is not installed" do
it "raises Lucky::Attachment::Error" do
original_path = ENV["PATH"]
ENV["PATH"] = ""
io = IO::Memory.new

begin
expect_raises(
Lucky::Attachment::Error,
/The `magick|identify` command-line tool is not installed/
) do
subject.extract(io, metadata: Lucky::Attachment::MetadataHash.new)
end
ensure
ENV["PATH"] = original_path
end
end
end

context "when magick is installed" do
it "extracts width and height from a PNG file" do
file = File.open(png_path)
metadata = Lucky::Attachment::MetadataHash.new
result = subject.extract(file, metadata: metadata)

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

it "does not modify metadata when magick returns no output" do
io = IO::Memory.new
metadata = Lucky::Attachment::MetadataHash.new
subject.extract(io, metadata: metadata)

metadata.should be_empty
end

it "rewinds the IO after reading" do
file = File.open(png_path)
metadata = Lucky::Attachment::MetadataHash.new
subject.extract(file, metadata: metadata)

file.pos.should eq(0)
end
end
end
end
142 changes: 142 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,142 @@
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" do
io = PlainIO.new
result = subject.extract(io, metadata: nil, filename: "override.txt")

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

it "prefers options filename over #original_filename" do
io = IOWithOriginalFilename.new("ignored.txt")
result = subject.extract(io, metadata: nil, filename: "override.txt")

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

context "when the IO responds to #original_filename" do
it "returns the original filename" do
io = IOWithOriginalFilename.new("photo.jpg")
result = subject.extract(io, metadata: nil)

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

it "returns nil when original_filename is nil" do
io = IOWithOriginalFilename.new(nil)
result = subject.extract(io, metadata: nil)

result.should be_nil
end
end

context "when the IO responds to #filename but not #original_filename" do
it "returns the filename" do
io = IOWithFilename.new("document.pdf")
result = subject.extract(io, metadata: nil)

result.should eq("document.pdf")
end

it "returns nil when filename is blank" do
io = IOWithFilename.new("")
result = subject.extract(io, metadata: nil)

result.should be_nil
end

it "returns nil when filename is nil" do
io = IOWithFilename.new(nil)
result = subject.extract(io, metadata: nil)

result.should be_nil
end
end

context "when the IO responds to #path but not #original_filename or #filename" do
it "returns the basename of the path" do
io = IOWithPath.new("/uploads/tmp/archive.zip")
result = subject.extract(io, metadata: nil)

result.should eq("archive.zip")
end

it "returns just the filename when path has no directory component" do
io = IOWithPath.new("archive.zip")
result = subject.extract(io, metadata: nil)

result.should eq("archive.zip")
end
end

context "when the IO responds to none of the known methods" do
it "returns nil" do
io = PlainIO.new
result = subject.extract(io, metadata: nil)

result.should be_nil
end
end
end
end

private class PlainIO < IO
def read(slice : Bytes)
raise "not implemented"
end

def write(slice : Bytes) : Nil
raise "not implemented"
end
end

private class IOWithOriginalFilename < IO
getter original_filename : String?

def initialize(@original_filename : String?)
end

def read(slice : Bytes)
raise "not implemented"
end

def write(slice : Bytes) : Nil
raise "not implemented"
end
end

private class IOWithFilename < IO
getter filename : String?

def initialize(@filename : String?)
end

def read(slice : Bytes)
raise "not implemented"
end

def write(slice : Bytes) : Nil
raise "not implemented"
end
end

private class IOWithPath < IO
getter path : String

def initialize(@path : String)
end

def read(slice : Bytes)
raise "not implemented"
end

def write(slice : Bytes) : Nil
raise "not implemented"
end
end
Loading
Loading