gemstack 0.2.5 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +4 -0
- data/README.md +7 -3
- data/lib/gemstack/cache/memory_store.rb +69 -0
- data/lib/gemstack/cache/null_store.rb +18 -0
- data/lib/gemstack/cache/redis_store.rb +70 -0
- data/lib/gemstack/cache/store.rb +89 -0
- data/lib/gemstack/cache.rb +61 -0
- data/lib/gemstack/cli/add_generator.rb +258 -0
- data/lib/gemstack/cli/app_generator.rb +142 -0
- data/lib/gemstack/cli/commands/db.rb +136 -0
- data/lib/gemstack/cli/commands/jobs.rb +96 -0
- data/lib/gemstack/cli/controller_generator.rb +73 -0
- data/lib/gemstack/cli/deploy_generator.rb +102 -0
- data/lib/gemstack/cli/doctor/upgrade_check.rb +32 -0
- data/lib/gemstack/cli/doctor.rb +311 -0
- data/lib/gemstack/cli/generator.rb +161 -0
- data/lib/gemstack/cli/job_generator.rb +48 -0
- data/lib/gemstack/cli/migration_generator.rb +52 -0
- data/lib/gemstack/cli/policy_generator.rb +35 -0
- data/lib/gemstack/cli/project.rb +53 -0
- data/lib/gemstack/cli/resource_generator.rb +170 -0
- data/lib/gemstack/cli/resource_spec.rb +95 -0
- data/lib/gemstack/cli.rb +304 -0
- data/lib/gemstack/contract/builder.rb +168 -0
- data/lib/gemstack/contract/docs/index.html +264 -0
- data/lib/gemstack/contract/docs.rb +45 -0
- data/lib/gemstack/contract/openapi.rb +123 -0
- data/lib/gemstack/contract/typescript.rb +162 -0
- data/lib/gemstack/contract.rb +93 -0
- data/lib/gemstack/core.rb +129 -0
- data/lib/gemstack/db/configuration.rb +148 -0
- data/lib/gemstack/db/errors.rb +137 -0
- data/lib/gemstack/db/json_compat.rb +21 -0
- data/lib/gemstack/db/migrator.rb +80 -0
- data/lib/gemstack/db/model.rb +226 -0
- data/lib/gemstack/db/schema_types.rb +28 -0
- data/lib/gemstack/db/tasks.rb +94 -0
- data/lib/gemstack/db/testing.rb +99 -0
- data/lib/gemstack/db.rb +210 -0
- data/lib/gemstack/dev/file_watcher.rb +58 -0
- data/lib/gemstack/dev/gateway.rb +220 -0
- data/lib/gemstack/dev/managed_process.rb +96 -0
- data/lib/gemstack/dev/ports.rb +26 -0
- data/lib/gemstack/dev/supervisor.rb +267 -0
- data/lib/gemstack/dev/terminal.rb +40 -0
- data/lib/gemstack/dev/toolchain.rb +86 -0
- data/lib/gemstack/dev.rb +43 -0
- data/lib/gemstack/dotenv.rb +61 -0
- data/lib/gemstack/environment.rb +37 -0
- data/lib/gemstack/error_mapping.rb +37 -0
- data/lib/gemstack/errors.rb +100 -0
- data/lib/gemstack/http/app.rb +30 -0
- data/lib/gemstack/http/config.rb +84 -0
- data/lib/gemstack/http/controller.rb +343 -0
- data/lib/gemstack/http/error_page.rb +111 -0
- data/lib/gemstack/http/error_renderer.rb +63 -0
- data/lib/gemstack/http/json_codec.rb +114 -0
- data/lib/gemstack/http/middleware/body_limit.rb +69 -0
- data/lib/gemstack/http/middleware/compression.rb +127 -0
- data/lib/gemstack/http/middleware/cors.rb +77 -0
- data/lib/gemstack/http/middleware/error_handler.rb +39 -0
- data/lib/gemstack/http/middleware/etags.rb +24 -0
- data/lib/gemstack/http/middleware/health_check.rb +28 -0
- data/lib/gemstack/http/middleware/request_id.rb +31 -0
- data/lib/gemstack/http/middleware/request_logger.rb +39 -0
- data/lib/gemstack/http/middleware/security_headers.rb +31 -0
- data/lib/gemstack/http/middleware_stack.rb +96 -0
- data/lib/gemstack/http/page.rb +36 -0
- data/lib/gemstack/http/params.rb +140 -0
- data/lib/gemstack/http/request.rb +64 -0
- data/lib/gemstack/http/router.rb +315 -0
- data/lib/gemstack/http.rb +41 -0
- data/lib/gemstack/inflector.rb +133 -0
- data/lib/gemstack/job.rb +154 -0
- data/lib/gemstack/jobs/adapters/async.rb +94 -0
- data/lib/gemstack/jobs/adapters/database.rb +188 -0
- data/lib/gemstack/jobs/adapters/inline.rb +34 -0
- data/lib/gemstack/jobs/adapters/sidekiq.rb +65 -0
- data/lib/gemstack/jobs/adapters/test.rb +59 -0
- data/lib/gemstack/jobs/executor.rb +70 -0
- data/lib/gemstack/jobs/testing.rb +55 -0
- data/lib/gemstack/jobs/worker.rb +137 -0
- data/lib/gemstack/jobs.rb +144 -0
- data/lib/gemstack/logger.rb +131 -0
- data/lib/gemstack/mail/delivery_job.rb +20 -0
- data/lib/gemstack/mail/testing.rb +33 -0
- data/lib/gemstack/mail.rb +230 -0
- data/lib/gemstack/plugins.rb +38 -0
- data/lib/gemstack/schema.rb +251 -0
- data/lib/gemstack/serializer.rb +186 -0
- data/lib/gemstack/settings.rb +86 -0
- data/lib/gemstack/storage/endpoint.rb +112 -0
- data/lib/gemstack/storage/services/disk.rb +60 -0
- data/lib/gemstack/storage/services/s3.rb +65 -0
- data/lib/gemstack/storage/testing.rb +30 -0
- data/lib/gemstack/storage.rb +183 -0
- data/lib/gemstack/types.rb +163 -0
- data/lib/gemstack/version.rb +6 -0
- data/lib/gemstack.rb +1 -1
- data/templates/app/Gemfile.tt +27 -0
- data/templates/app/README.md.tt +29 -0
- data/templates/app/app/controllers/application_controller.rb +6 -0
- data/templates/app/app/jobs/application_job.rb +9 -0
- data/templates/app/app/mailers/application_mailer.rb +8 -0
- data/templates/app/app/mailers/templates/dot_keep +0 -0
- data/templates/app/app/models/application_model.rb +19 -0
- data/templates/app/app/serializers/application_serializer.rb +6 -0
- data/templates/app/bin/gemstack +7 -0
- data/templates/app/config/app.rb.tt +60 -0
- data/templates/app/config/database.yml.tt +61 -0
- data/templates/app/config/environments/development.rb.tt +35 -0
- data/templates/app/config/environments/production.rb.tt +40 -0
- data/templates/app/config/environments/test.rb.tt +21 -0
- data/templates/app/config/puma.rb +21 -0
- data/templates/app/config/routes.rb +10 -0
- data/templates/app/config.ru +6 -0
- data/templates/app/db/migrations/dot_keep +0 -0
- data/templates/app/db/seeds.rb +5 -0
- data/templates/app/dot_env.example.tt +19 -0
- data/templates/app/dot_gitignore +16 -0
- data/templates/app/dot_node-version.tt +1 -0
- data/templates/app/dot_nvmrc.tt +1 -0
- data/templates/app/dot_ruby-version.tt +1 -0
- data/templates/app/dot_tool-versions.tt +4 -0
- data/templates/app/test/health_test.rb +18 -0
- data/templates/app/test/test_helper.rb.tt +25 -0
- data/templates/auth/app/controllers/api_tokens_controller.rb +35 -0
- data/templates/auth/app/controllers/email_verifications_controller.rb +31 -0
- data/templates/auth/app/controllers/password_resets_controller.rb +40 -0
- data/templates/auth/app/controllers/registrations_controller.rb +19 -0
- data/templates/auth/app/controllers/sessions_controller.rb +29 -0
- data/templates/auth/app/mailers/auth_mailer.rb +20 -0
- data/templates/auth/app/mailers/templates/auth_mailer/email_verification.html.erb +2 -0
- data/templates/auth/app/mailers/templates/auth_mailer/email_verification.text.erb +2 -0
- data/templates/auth/app/mailers/templates/auth_mailer/password_reset.html.erb +3 -0
- data/templates/auth/app/mailers/templates/auth_mailer/password_reset.text.erb +6 -0
- data/templates/auth/app/models/auth_token.rb +15 -0
- data/templates/auth/app/models/user.rb +12 -0
- data/templates/auth/app/policies/application_policy.rb +6 -0
- data/templates/auth/app/serializers/api_token_serializer.rb +6 -0
- data/templates/auth/app/serializers/new_api_token_serializer.rb +9 -0
- data/templates/auth/app/serializers/user_serializer.rb +6 -0
- data/templates/auth/db/migrations/%timestamp%_create_auth_tables.rb +41 -0
- data/templates/auth/frontend/app/account/page.tsx +116 -0
- data/templates/auth/frontend/app/forgot-password/page.tsx +48 -0
- data/templates/auth/frontend/app/login/page.tsx +26 -0
- data/templates/auth/frontend/app/reset-password/page.tsx +11 -0
- data/templates/auth/frontend/app/signup/page.tsx +27 -0
- data/templates/auth/frontend/app/verify-email/page.tsx +11 -0
- data/templates/auth/frontend/components/auth/CredentialsForm.tsx +58 -0
- data/templates/auth/frontend/components/auth/ResetPasswordForm.tsx +43 -0
- data/templates/auth/frontend/components/auth/VerifyEmail.tsx +28 -0
- data/templates/auth/frontend/lib/auth.ts +113 -0
- data/templates/auth/test/controllers/auth_test.rb +108 -0
- data/templates/controller/app/controllers/%file_name%_controller.rb.tt +10 -0
- data/templates/controller/test/controllers/%file_name%_controller_test.rb.tt +14 -0
- data/templates/deploy/Caddyfile.tt +19 -0
- data/templates/deploy/Dockerfile.tt +63 -0
- data/templates/deploy/Procfile.tt +6 -0
- data/templates/deploy/compose.yaml.tt +129 -0
- data/templates/deploy/dot_dockerignore +14 -0
- data/templates/frontend/app/globals.css +171 -0
- data/templates/frontend/app/layout.tsx.tt +19 -0
- data/templates/frontend/app/page.module.css +315 -0
- data/templates/frontend/app/page.tsx.tt +183 -0
- data/templates/frontend/app/providers.tsx +24 -0
- data/templates/frontend/lib/gemstack/client.ts +125 -0
- data/templates/frontend/next-env.d.ts +5 -0
- data/templates/frontend/next.config.ts +17 -0
- data/templates/frontend/package.json.tt +23 -0
- data/templates/frontend/tsconfig.json +21 -0
- data/templates/job/app/jobs/%file_name%.rb.tt +15 -0
- data/templates/job/test/jobs/%file_name%_test.rb.tt +15 -0
- data/templates/migration/db/migrations/%timestamp%_%file_name%.rb.tt +17 -0
- data/templates/policy/app/policies/%file_name%_policy.rb.tt +21 -0
- data/templates/policy/test/policies/%file_name%_policy_test.rb.tt +9 -0
- data/templates/realtime/config/channels.rb +14 -0
- data/templates/realtime/frontend/lib/gemstack/realtime.ts +120 -0
- data/templates/resource/controller/app/controllers/%plural%_controller.rb.tt +51 -0
- data/templates/resource/controller/test/controllers/%plural%_controller_test.rb.tt +74 -0
- data/templates/resource/frontend/frontend/app/%url_segment%/[id]/edit/page.tsx.tt +45 -0
- data/templates/resource/frontend/frontend/app/%url_segment%/[id]/page.tsx.tt +49 -0
- data/templates/resource/frontend/frontend/app/%url_segment%/new/page.tsx.tt +27 -0
- data/templates/resource/frontend/frontend/app/%url_segment%/page.tsx.tt +51 -0
- data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Card.tsx.tt +15 -0
- data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Form.tsx.tt +98 -0
- data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Table.tsx.tt +36 -0
- data/templates/resource/frontend/frontend/lib/format.ts +12 -0
- data/templates/resource/frontend/frontend/lib/queries/%url_segment%.ts.tt +64 -0
- data/templates/resource/migration/db/migrations/%timestamp%_create_%table%.rb.tt +18 -0
- data/templates/resource/model/app/models/%file_name%.rb.tt +15 -0
- data/templates/resource/model/test/models/%file_name%_test.rb.tt +29 -0
- data/templates/resource/serializer/app/serializers/%file_name%_serializer.rb.tt +7 -0
- data/templates/storage/app/controllers/uploads_controller.rb.tt +33 -0
- data/templates/storage/app/serializers/upload_serializer.rb +11 -0
- data/templates/storage/frontend/lib/upload.ts +52 -0
- data/templates/storage/test/controllers/uploads_test.rb.tt +30 -0
- metadata +251 -39
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemStack
|
|
4
|
+
# Explicit, typed JSON representations. Nothing is exposed unless listed.
|
|
5
|
+
#
|
|
6
|
+
# class ProductSerializer < GemStack::Serializer
|
|
7
|
+
# attributes :id, :name, :price, :active, :created_at # types inferred from Product's fields
|
|
8
|
+
# attribute :display_price, :string do |product|
|
|
9
|
+
# "€#{product.price}"
|
|
10
|
+
# end
|
|
11
|
+
# attribute :category, CategorySerializer # nested
|
|
12
|
+
# attribute :reviews, [ReviewSerializer] # nested list
|
|
13
|
+
# end
|
|
14
|
+
#
|
|
15
|
+
# ProductSerializer.serialize(product) # => { id: 1, name: "Lamp", price: "9.99", ... }
|
|
16
|
+
# ProductSerializer.many(products) # => [ {...}, ... ]
|
|
17
|
+
#
|
|
18
|
+
# Values are dumped by type: decimals become strings, times ISO 8601 UTC.
|
|
19
|
+
# Types come from an explicit type argument, else from the model's field
|
|
20
|
+
# declarations (the model is found by name: ProductSerializer → Product),
|
|
21
|
+
# and drive the generated TypeScript interfaces.
|
|
22
|
+
class Serializer
|
|
23
|
+
Attribute = Struct.new(:name, :type, :block, :nullable, keyword_init: true) do
|
|
24
|
+
def serializer? = type.is_a?(Class) && type <= Serializer
|
|
25
|
+
def list? = type.is_a?(Array)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
CONVENTIONAL_TYPES = { id: [:bigint, false], created_at: [:datetime, false], updated_at: [:datetime, false] }.freeze
|
|
29
|
+
|
|
30
|
+
class << self
|
|
31
|
+
def attributes_list
|
|
32
|
+
@attributes_list ||= superclass.respond_to?(:attributes_list) ? superclass.attributes_list.dup : {}
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# attributes :id, :name (types inferred)
|
|
36
|
+
# attributes name: :string (explicit)
|
|
37
|
+
def attributes(*names, **typed)
|
|
38
|
+
names.each { |name| attribute(name) }
|
|
39
|
+
typed.each { |name, type| attribute(name, type) }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def attribute(name, type = nil, nullable: nil, &block)
|
|
43
|
+
if type && !type.is_a?(Array) && !(type.is_a?(Class) && type <= Serializer)
|
|
44
|
+
type = Types::CLASS_ALIASES.fetch(type, type).to_sym
|
|
45
|
+
Types.fetch(type)
|
|
46
|
+
end
|
|
47
|
+
attributes_list[name.to_sym] = Attribute.new(name: name.to_sym, type: type, block: block, nullable: nullable)
|
|
48
|
+
@resolved_attributes = nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The model used for type inference. Defaults to the class named like
|
|
52
|
+
# the serializer without "Serializer" (Admin::ProductSerializer → Admin::Product, then Product).
|
|
53
|
+
def model(klass = nil)
|
|
54
|
+
@model = klass if klass
|
|
55
|
+
return @model if defined?(@model) && @model
|
|
56
|
+
|
|
57
|
+
base = name&.delete_suffix("Serializer")
|
|
58
|
+
return nil if base.nil? || base.empty?
|
|
59
|
+
|
|
60
|
+
[base, base.split("::").last].uniq.each do |candidate|
|
|
61
|
+
constant = Object.const_get(candidate) if Object.const_defined?(candidate)
|
|
62
|
+
return constant if constant.respond_to?(:gemstack_fields)
|
|
63
|
+
rescue NameError
|
|
64
|
+
next
|
|
65
|
+
end
|
|
66
|
+
nil
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
attr_writer :type_name
|
|
70
|
+
|
|
71
|
+
# TypeScript/OpenAPI name: ProductSerializer → "Product", Admin::ProductSerializer → "AdminProduct".
|
|
72
|
+
def type_name
|
|
73
|
+
@type_name || (name && name.delete_suffix("Serializer").split("::").join)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Attributes with resolved types: [{name:, type:, nullable:}], type being
|
|
77
|
+
# a type Symbol, a Serializer class, or [Serializer]/[:type] for lists.
|
|
78
|
+
def resolved_attributes
|
|
79
|
+
@resolved_attributes ||= attributes_list.values.map do |attr|
|
|
80
|
+
type, nullable = infer(attr)
|
|
81
|
+
{ name: attr.name, type: type, nullable: attr.nullable.nil? ? nullable : attr.nullable }
|
|
82
|
+
end.freeze
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# [[name, block_or_nil, dumper_or_nil], ...] — types resolved to
|
|
86
|
+
# dumpers once, so serializing an object does no type lookups.
|
|
87
|
+
def compiled
|
|
88
|
+
@compiled ||= resolved_attributes.map do |attr|
|
|
89
|
+
[attr[:name], attributes_list[attr[:name]].block, dumper_for(attr[:type])]
|
|
90
|
+
end.freeze
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def serialize(object, context = {})
|
|
94
|
+
object.nil? ? nil : new(object, context).to_h
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def many(objects, context = {})
|
|
98
|
+
objects = objects.all if objects.respond_to?(:all) && !objects.is_a?(Array) # Sequel datasets
|
|
99
|
+
objects.map { |object| new(object, context).to_h }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Convention-based rendering, shared by controllers (`render`) and
|
|
103
|
+
# realtime (`GemStack.broadcast`): an object of class Product uses
|
|
104
|
+
# ProductSerializer, arrays and datasets of them too; plain JSON values
|
|
105
|
+
# and objects without a serializer pass through unchanged.
|
|
106
|
+
def render(value, context = {})
|
|
107
|
+
case value
|
|
108
|
+
when Hash, String, Numeric, Symbol, true, false, nil then value
|
|
109
|
+
when Array
|
|
110
|
+
serializer = value.first && self.for(value.first.class)
|
|
111
|
+
serializer ? serializer.many(value, context) : value
|
|
112
|
+
else
|
|
113
|
+
if value.respond_to?(:model) && value.respond_to?(:all) # a dataset / query
|
|
114
|
+
serializer = self.for(value.model)
|
|
115
|
+
return serializer ? serializer.many(value, context) : value.all
|
|
116
|
+
end
|
|
117
|
+
serializer = self.for(value.class)
|
|
118
|
+
serializer ? serializer.serialize(value, context) : value
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Defaults for convention-based lookup: ProductSerializer for Product.
|
|
123
|
+
def for(object_class)
|
|
124
|
+
return nil unless object_class.name
|
|
125
|
+
|
|
126
|
+
name = "#{object_class.name}Serializer"
|
|
127
|
+
Object.const_defined?(name) ? Object.const_get(name) : nil
|
|
128
|
+
rescue NameError
|
|
129
|
+
nil
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
private
|
|
133
|
+
|
|
134
|
+
# A lambda (value, context) -> JSON value, or nil when values pass through.
|
|
135
|
+
def dumper_for(type)
|
|
136
|
+
case type
|
|
137
|
+
when Array
|
|
138
|
+
item = dumper_for(type.first)
|
|
139
|
+
return nil unless item
|
|
140
|
+
|
|
141
|
+
->(values, ctx) { values.map { |v| v.nil? ? nil : item.call(v, ctx) } }
|
|
142
|
+
when Symbol then scalar_dumper(Types.fetch(type))
|
|
143
|
+
else ->(value, ctx) { type.serialize(value, ctx) } # nested serializer
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def scalar_dumper(type)
|
|
148
|
+
if %i[string text].include?(type.name)
|
|
149
|
+
->(value, _) { value.is_a?(String) ? value : type.coerce(value) }
|
|
150
|
+
elsif type.dumper
|
|
151
|
+
dump = type.dumper
|
|
152
|
+
->(value, _) { dump.call(value) }
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Explicit types are non-null unless declared nullable or the model's
|
|
157
|
+
# field allows null; inferred types follow the model's field.
|
|
158
|
+
def infer(attr)
|
|
159
|
+
field = model&.gemstack_fields&.[](attr.name)
|
|
160
|
+
return [attr.type, field ? field.options[:null] != false : false] if attr.type
|
|
161
|
+
return [field.type, field.options[:null] != false] if field
|
|
162
|
+
|
|
163
|
+
CONVENTIONAL_TYPES.fetch(attr.name) { [:json, true] }
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
attr_reader :object, :context
|
|
168
|
+
|
|
169
|
+
def initialize(object, context = {})
|
|
170
|
+
@object = object
|
|
171
|
+
@context = context
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Hot path: iterates the plan compiled once per serializer class
|
|
175
|
+
# (docs/performance.md — this loop dominates JSON response time).
|
|
176
|
+
def to_h
|
|
177
|
+
hash = {}
|
|
178
|
+
self.class.compiled.each do |name, block, dumper|
|
|
179
|
+
value = block ? instance_exec(object, &block) : object.public_send(name)
|
|
180
|
+
hash[name] = value.nil? || dumper.nil? ? value : dumper.call(value, context)
|
|
181
|
+
end
|
|
182
|
+
hash
|
|
183
|
+
end
|
|
184
|
+
alias as_json to_h
|
|
185
|
+
end
|
|
186
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemStack
|
|
4
|
+
# A small DSL for declaring configuration with defaults.
|
|
5
|
+
#
|
|
6
|
+
# class HTTPConfig < GemStack::Settings
|
|
7
|
+
# setting :api_path, default: "/api"
|
|
8
|
+
# setting :health_path, default: -> { "#{api_path}/health" }
|
|
9
|
+
# namespace :cors do
|
|
10
|
+
# setting :origins, default: []
|
|
11
|
+
# end
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# - A callable default is evaluated lazily, in the context of the settings
|
|
15
|
+
# object, the first time it is read, and then memoized. This lets defaults
|
|
16
|
+
# depend on ENV or on other settings.
|
|
17
|
+
# - Array/Hash defaults are duplicated per instance, so instances never share
|
|
18
|
+
# mutable state.
|
|
19
|
+
# - Reading or writing an undeclared setting raises NoMethodError (with
|
|
20
|
+
# Ruby's did_you_mean suggestions), so typos fail fast.
|
|
21
|
+
class Settings
|
|
22
|
+
class << self
|
|
23
|
+
def definitions
|
|
24
|
+
@definitions ||= superclass <= Settings ? superclass.definitions.dup : {}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def namespaces
|
|
28
|
+
@namespaces ||= superclass <= Settings ? superclass.namespaces.dup : {}
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def setting(name, default: nil)
|
|
32
|
+
name = name.to_sym
|
|
33
|
+
definitions[name] = default
|
|
34
|
+
define_method(name) { read(name) }
|
|
35
|
+
define_method(:"#{name}=") { |value| @values[name] = value }
|
|
36
|
+
name
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Declares a nested settings group. Pass a Settings subclass, or a block
|
|
40
|
+
# that is evaluated in a new anonymous subclass. Modules use this to add
|
|
41
|
+
# their own namespace to GemStack::Config without core knowing about them.
|
|
42
|
+
def namespace(name, klass = nil, &)
|
|
43
|
+
name = name.to_sym
|
|
44
|
+
klass ||= Class.new(Settings, &)
|
|
45
|
+
namespaces[name] = klass
|
|
46
|
+
define_method(name) do |&configure|
|
|
47
|
+
group = (@groups[name] ||= klass.new)
|
|
48
|
+
configure&.call(group)
|
|
49
|
+
group
|
|
50
|
+
end
|
|
51
|
+
klass
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def initialize
|
|
56
|
+
@values = {}
|
|
57
|
+
@groups = {}
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def read(name)
|
|
61
|
+
return @values[name] if @values.key?(name)
|
|
62
|
+
|
|
63
|
+
default = self.class.definitions.fetch(name)
|
|
64
|
+
@values[name] =
|
|
65
|
+
case default
|
|
66
|
+
when Proc then instance_exec(&default)
|
|
67
|
+
when Array, Hash then default.dup
|
|
68
|
+
else default
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def set?(name)
|
|
73
|
+
@values.key?(name.to_sym)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def settings
|
|
77
|
+
self.class.definitions.keys
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def to_h
|
|
81
|
+
hash = settings.to_h { |name| [name, read(name)] }
|
|
82
|
+
self.class.namespaces.each_key { |name| hash[name] = public_send(name).to_h }
|
|
83
|
+
hash
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemStack
|
|
4
|
+
module Storage
|
|
5
|
+
# Serves the :disk service's signed URLs:
|
|
6
|
+
# PUT <path>/<token> stores the request body (type and size as signed)
|
|
7
|
+
# GET <path>/<token> streams the file
|
|
8
|
+
# Unsigned, expired or tampered tokens get 404/403; nothing else is exposed.
|
|
9
|
+
class Endpoint
|
|
10
|
+
def initialize(app) = @app = app
|
|
11
|
+
|
|
12
|
+
def call(env)
|
|
13
|
+
prefix = "#{Storage.config.path}/"
|
|
14
|
+
path = env[Rack::PATH_INFO].to_s
|
|
15
|
+
return @app.call(env) unless path.start_with?(prefix)
|
|
16
|
+
|
|
17
|
+
token = path.delete_prefix(prefix)
|
|
18
|
+
case env[Rack::REQUEST_METHOD]
|
|
19
|
+
when "PUT" then put(env, token)
|
|
20
|
+
when "GET", "HEAD" then get(env, token)
|
|
21
|
+
else raise MethodNotAllowed.new(headers: { "allow" => "GET, HEAD, PUT" })
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def service
|
|
28
|
+
service = Storage.service
|
|
29
|
+
raise NotFound unless service.is_a?(Services::Disk)
|
|
30
|
+
|
|
31
|
+
service
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def put(env, token)
|
|
35
|
+
claims = Storage.verify(token, purpose: "disk-put") or raise Forbidden.new("Invalid or expired upload URL",
|
|
36
|
+
code: "invalid_signature")
|
|
37
|
+
check_headers!(env, claims)
|
|
38
|
+
service.upload(claims["k"], LimitedReader.new(env["rack.input"], claims["n"]))
|
|
39
|
+
unless service.size(claims["k"]) == claims["n"]
|
|
40
|
+
service.delete(claims["k"])
|
|
41
|
+
raise BadRequest.new("The file is smaller than declared", code: "size_mismatch")
|
|
42
|
+
end
|
|
43
|
+
[201, { "content-length" => "0" }, []]
|
|
44
|
+
rescue LimitedReader::TooLarge
|
|
45
|
+
FileUtils.rm_f("#{service.path_for(claims["k"])}.part")
|
|
46
|
+
raise PayloadTooLarge.new("The file is larger than declared", code: "size_mismatch")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def check_headers!(env, claims)
|
|
50
|
+
type = env["CONTENT_TYPE"].to_s.split(";").first.to_s.strip.downcase
|
|
51
|
+
if type != claims["t"]
|
|
52
|
+
raise BadRequest.new("Content-Type must be #{claims["t"]}",
|
|
53
|
+
code: "content_type_mismatch")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
length = Integer(env["CONTENT_LENGTH"].to_s, exception: false)
|
|
57
|
+
return unless length && length != claims["n"]
|
|
58
|
+
|
|
59
|
+
raise BadRequest.new("Content-Length must be #{claims["n"]}", code: "size_mismatch")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def get(env, token)
|
|
63
|
+
claims = Storage.verify(token, purpose: "disk-get") or raise NotFound
|
|
64
|
+
file = service.path_for(claims["k"])
|
|
65
|
+
raise NotFound unless File.file?(file)
|
|
66
|
+
|
|
67
|
+
disposition = claims["d"] == "inline" ? "inline" : "attachment"
|
|
68
|
+
disposition += "; filename=\"#{claims["f"].gsub(/["\\\r\n]/, "")}\"" if claims["f"]
|
|
69
|
+
headers = {
|
|
70
|
+
"content-type" => Storage.content_type_for(claims["k"]), "content-length" => File.size(file).to_s,
|
|
71
|
+
"content-disposition" => disposition, "cache-control" => "private, max-age=300",
|
|
72
|
+
# Uploaded files are untrusted: never sniffed, never scripted.
|
|
73
|
+
"x-content-type-options" => "nosniff", "content-security-policy" => "default-src 'none'; sandbox"
|
|
74
|
+
}
|
|
75
|
+
body = env[Rack::REQUEST_METHOD] == "HEAD" ? [] : FileBody.new(file)
|
|
76
|
+
[200, headers, body]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Reads at most `limit` bytes; more means the client lied about the size.
|
|
80
|
+
class LimitedReader
|
|
81
|
+
class TooLarge < StandardError; end
|
|
82
|
+
|
|
83
|
+
def initialize(io, limit)
|
|
84
|
+
@io = io
|
|
85
|
+
@left = limit
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def read(length = nil, buffer = nil)
|
|
89
|
+
chunk = @io.read(length || 65_536, buffer)
|
|
90
|
+
return chunk if chunk.nil?
|
|
91
|
+
|
|
92
|
+
@left -= chunk.bytesize
|
|
93
|
+
raise TooLarge if @left.negative?
|
|
94
|
+
|
|
95
|
+
chunk
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
class FileBody
|
|
100
|
+
def initialize(path) = @path = path
|
|
101
|
+
|
|
102
|
+
def each
|
|
103
|
+
File.open(@path, "rb") do |file|
|
|
104
|
+
while (chunk = file.read(65_536))
|
|
105
|
+
yield chunk
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module GemStack
|
|
6
|
+
module Storage
|
|
7
|
+
module Services
|
|
8
|
+
# Files on the local disk. Signed URLs point at Storage::Endpoint, so
|
|
9
|
+
# the browser code is identical to S3's: PUT to upload, GET to download.
|
|
10
|
+
class Disk
|
|
11
|
+
attr_reader :root, :path
|
|
12
|
+
|
|
13
|
+
def initialize(root:, path:)
|
|
14
|
+
@root = File.expand_path(root)
|
|
15
|
+
@path = path
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def upload(key, io, content_type: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
19
|
+
target = path_for(key)
|
|
20
|
+
FileUtils.mkdir_p(File.dirname(target))
|
|
21
|
+
File.open("#{target}.part", "wb") { |file| IO.copy_stream(io, file) }
|
|
22
|
+
File.rename("#{target}.part", target)
|
|
23
|
+
key
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def download(key) = File.binread(path_for(key))
|
|
27
|
+
def exist?(key) = File.file?(path_for(key))
|
|
28
|
+
|
|
29
|
+
def delete(key)
|
|
30
|
+
FileUtils.rm_f(path_for(key))
|
|
31
|
+
true
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def size(key) = File.size(path_for(key))
|
|
35
|
+
|
|
36
|
+
def presigned_upload(key, content_type:, byte_size:, expires_in:)
|
|
37
|
+
token = Storage.sign({ "k" => key, "t" => content_type, "n" => byte_size }, purpose: "disk-put",
|
|
38
|
+
expires_in: expires_in)
|
|
39
|
+
{ url: "#{path}/#{token}", method: "PUT", headers: { "content-type" => content_type } }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def url(key, expires_in:, disposition:, filename: nil)
|
|
43
|
+
token = Storage.sign({ "k" => key, "d" => disposition, "f" => filename }, purpose: "disk-get",
|
|
44
|
+
expires_in: expires_in)
|
|
45
|
+
"#{path}/#{token}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Keys never escape the root (no "..", no absolute paths).
|
|
49
|
+
def path_for(key)
|
|
50
|
+
key = key.to_s
|
|
51
|
+
if key.empty? || key.start_with?("/") || key.split("/").any? { |part| part.empty? || part.start_with?(".") }
|
|
52
|
+
raise ArgumentError, "invalid storage key #{key.inspect}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
File.join(root, key)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
begin
|
|
4
|
+
require "aws-sdk-s3"
|
|
5
|
+
rescue LoadError
|
|
6
|
+
raise GemStack::ConfigurationError, "config.storage.service = :s3 needs gem \"aws-sdk-s3\" in the Gemfile"
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
module GemStack
|
|
10
|
+
module Storage
|
|
11
|
+
module Services
|
|
12
|
+
# Amazon S3 and S3-compatible services (Cloudflare R2, MinIO, …).
|
|
13
|
+
# Browsers PUT directly to a presigned URL; its signature covers the
|
|
14
|
+
# content type and exact size, so neither can be changed.
|
|
15
|
+
#
|
|
16
|
+
# The bucket needs a CORS rule allowing PUT from your site (docs/storage.md).
|
|
17
|
+
class S3
|
|
18
|
+
attr_reader :bucket, :client
|
|
19
|
+
|
|
20
|
+
def initialize(bucket:, region:, endpoint: nil, client: nil, **options)
|
|
21
|
+
raise ConfigurationError, "set S3_BUCKET (config.storage.bucket) to use the :s3 storage service" if
|
|
22
|
+
bucket.to_s.empty? && client.nil?
|
|
23
|
+
|
|
24
|
+
@bucket = bucket
|
|
25
|
+
options = { region: region, endpoint: endpoint,
|
|
26
|
+
force_path_style: !endpoint.nil? || nil }.compact.merge(options)
|
|
27
|
+
@client = client || Aws::S3::Client.new(**options)
|
|
28
|
+
@presigner = Aws::S3::Presigner.new(client: @client)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def upload(key, io, content_type:)
|
|
32
|
+
client.put_object(bucket: bucket, key: key, body: io, content_type: content_type)
|
|
33
|
+
key
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def download(key) = client.get_object(bucket: bucket, key: key).body.read
|
|
37
|
+
|
|
38
|
+
def exist?(key)
|
|
39
|
+
client.head_object(bucket: bucket, key: key)
|
|
40
|
+
true
|
|
41
|
+
rescue Aws::S3::Errors::NotFound, Aws::S3::Errors::NoSuchKey
|
|
42
|
+
false
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def delete(key)
|
|
46
|
+
client.delete_object(bucket: bucket, key: key)
|
|
47
|
+
true
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def presigned_upload(key, content_type:, byte_size:, expires_in:)
|
|
51
|
+
url = @presigner.presigned_url(:put_object, bucket: bucket, key: key, content_type: content_type,
|
|
52
|
+
content_length: byte_size, expires_in: expires_in,
|
|
53
|
+
whitelist_headers: ["content-length"]) # sign the size too
|
|
54
|
+
{ url: url, method: "PUT", headers: { "content-type" => content_type } }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def url(key, expires_in:, disposition:, filename: nil)
|
|
58
|
+
header = filename ? "#{disposition}; filename=\"#{filename.to_s.gsub(/["\\\r\n]/, "")}\"" : disposition
|
|
59
|
+
@presigner.presigned_url(:get_object, bucket: bucket, key: key, expires_in: expires_in,
|
|
60
|
+
response_content_disposition: header)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "gemstack/storage"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
|
|
6
|
+
module GemStack
|
|
7
|
+
module Storage
|
|
8
|
+
# Tests store files on disk in a temporary directory, cleared after each test.
|
|
9
|
+
module Testing
|
|
10
|
+
def before_setup
|
|
11
|
+
super
|
|
12
|
+
@gemstack_storage_root = Dir.mktmpdir("gemstack-storage")
|
|
13
|
+
Storage.service = Services::Disk.new(root: @gemstack_storage_root, path: Storage.config.path)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def after_teardown
|
|
17
|
+
FileUtils.rm_rf(@gemstack_storage_root) if @gemstack_storage_root
|
|
18
|
+
Storage.reset!
|
|
19
|
+
super
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Stores a file as if the browser had uploaded it; returns its signed_id.
|
|
23
|
+
def upload_fixture(content, filename: "file.png", content_type: "image/png")
|
|
24
|
+
key = Storage.generate_key("uploads", filename, content_type)
|
|
25
|
+
Storage.upload(key, StringIO.new(content), content_type: content_type)
|
|
26
|
+
Storage.sign(key, purpose: "upload")
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|