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.
Files changed (199) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +4 -0
  3. data/README.md +7 -3
  4. data/lib/gemstack/cache/memory_store.rb +69 -0
  5. data/lib/gemstack/cache/null_store.rb +18 -0
  6. data/lib/gemstack/cache/redis_store.rb +70 -0
  7. data/lib/gemstack/cache/store.rb +89 -0
  8. data/lib/gemstack/cache.rb +61 -0
  9. data/lib/gemstack/cli/add_generator.rb +258 -0
  10. data/lib/gemstack/cli/app_generator.rb +142 -0
  11. data/lib/gemstack/cli/commands/db.rb +136 -0
  12. data/lib/gemstack/cli/commands/jobs.rb +96 -0
  13. data/lib/gemstack/cli/controller_generator.rb +73 -0
  14. data/lib/gemstack/cli/deploy_generator.rb +102 -0
  15. data/lib/gemstack/cli/doctor/upgrade_check.rb +32 -0
  16. data/lib/gemstack/cli/doctor.rb +311 -0
  17. data/lib/gemstack/cli/generator.rb +161 -0
  18. data/lib/gemstack/cli/job_generator.rb +48 -0
  19. data/lib/gemstack/cli/migration_generator.rb +52 -0
  20. data/lib/gemstack/cli/policy_generator.rb +35 -0
  21. data/lib/gemstack/cli/project.rb +53 -0
  22. data/lib/gemstack/cli/resource_generator.rb +170 -0
  23. data/lib/gemstack/cli/resource_spec.rb +95 -0
  24. data/lib/gemstack/cli.rb +304 -0
  25. data/lib/gemstack/contract/builder.rb +168 -0
  26. data/lib/gemstack/contract/docs/index.html +264 -0
  27. data/lib/gemstack/contract/docs.rb +45 -0
  28. data/lib/gemstack/contract/openapi.rb +123 -0
  29. data/lib/gemstack/contract/typescript.rb +162 -0
  30. data/lib/gemstack/contract.rb +93 -0
  31. data/lib/gemstack/core.rb +129 -0
  32. data/lib/gemstack/db/configuration.rb +148 -0
  33. data/lib/gemstack/db/errors.rb +137 -0
  34. data/lib/gemstack/db/json_compat.rb +21 -0
  35. data/lib/gemstack/db/migrator.rb +80 -0
  36. data/lib/gemstack/db/model.rb +226 -0
  37. data/lib/gemstack/db/schema_types.rb +28 -0
  38. data/lib/gemstack/db/tasks.rb +94 -0
  39. data/lib/gemstack/db/testing.rb +99 -0
  40. data/lib/gemstack/db.rb +210 -0
  41. data/lib/gemstack/dev/file_watcher.rb +58 -0
  42. data/lib/gemstack/dev/gateway.rb +220 -0
  43. data/lib/gemstack/dev/managed_process.rb +96 -0
  44. data/lib/gemstack/dev/ports.rb +26 -0
  45. data/lib/gemstack/dev/supervisor.rb +267 -0
  46. data/lib/gemstack/dev/terminal.rb +40 -0
  47. data/lib/gemstack/dev/toolchain.rb +86 -0
  48. data/lib/gemstack/dev.rb +43 -0
  49. data/lib/gemstack/dotenv.rb +61 -0
  50. data/lib/gemstack/environment.rb +37 -0
  51. data/lib/gemstack/error_mapping.rb +37 -0
  52. data/lib/gemstack/errors.rb +100 -0
  53. data/lib/gemstack/http/app.rb +30 -0
  54. data/lib/gemstack/http/config.rb +84 -0
  55. data/lib/gemstack/http/controller.rb +343 -0
  56. data/lib/gemstack/http/error_page.rb +111 -0
  57. data/lib/gemstack/http/error_renderer.rb +63 -0
  58. data/lib/gemstack/http/json_codec.rb +114 -0
  59. data/lib/gemstack/http/middleware/body_limit.rb +69 -0
  60. data/lib/gemstack/http/middleware/compression.rb +127 -0
  61. data/lib/gemstack/http/middleware/cors.rb +77 -0
  62. data/lib/gemstack/http/middleware/error_handler.rb +39 -0
  63. data/lib/gemstack/http/middleware/etags.rb +24 -0
  64. data/lib/gemstack/http/middleware/health_check.rb +28 -0
  65. data/lib/gemstack/http/middleware/request_id.rb +31 -0
  66. data/lib/gemstack/http/middleware/request_logger.rb +39 -0
  67. data/lib/gemstack/http/middleware/security_headers.rb +31 -0
  68. data/lib/gemstack/http/middleware_stack.rb +96 -0
  69. data/lib/gemstack/http/page.rb +36 -0
  70. data/lib/gemstack/http/params.rb +140 -0
  71. data/lib/gemstack/http/request.rb +64 -0
  72. data/lib/gemstack/http/router.rb +315 -0
  73. data/lib/gemstack/http.rb +41 -0
  74. data/lib/gemstack/inflector.rb +133 -0
  75. data/lib/gemstack/job.rb +154 -0
  76. data/lib/gemstack/jobs/adapters/async.rb +94 -0
  77. data/lib/gemstack/jobs/adapters/database.rb +188 -0
  78. data/lib/gemstack/jobs/adapters/inline.rb +34 -0
  79. data/lib/gemstack/jobs/adapters/sidekiq.rb +65 -0
  80. data/lib/gemstack/jobs/adapters/test.rb +59 -0
  81. data/lib/gemstack/jobs/executor.rb +70 -0
  82. data/lib/gemstack/jobs/testing.rb +55 -0
  83. data/lib/gemstack/jobs/worker.rb +137 -0
  84. data/lib/gemstack/jobs.rb +144 -0
  85. data/lib/gemstack/logger.rb +131 -0
  86. data/lib/gemstack/mail/delivery_job.rb +20 -0
  87. data/lib/gemstack/mail/testing.rb +33 -0
  88. data/lib/gemstack/mail.rb +230 -0
  89. data/lib/gemstack/plugins.rb +38 -0
  90. data/lib/gemstack/schema.rb +251 -0
  91. data/lib/gemstack/serializer.rb +186 -0
  92. data/lib/gemstack/settings.rb +86 -0
  93. data/lib/gemstack/storage/endpoint.rb +112 -0
  94. data/lib/gemstack/storage/services/disk.rb +60 -0
  95. data/lib/gemstack/storage/services/s3.rb +65 -0
  96. data/lib/gemstack/storage/testing.rb +30 -0
  97. data/lib/gemstack/storage.rb +183 -0
  98. data/lib/gemstack/types.rb +163 -0
  99. data/lib/gemstack/version.rb +6 -0
  100. data/lib/gemstack.rb +1 -1
  101. data/templates/app/Gemfile.tt +27 -0
  102. data/templates/app/README.md.tt +29 -0
  103. data/templates/app/app/controllers/application_controller.rb +6 -0
  104. data/templates/app/app/jobs/application_job.rb +9 -0
  105. data/templates/app/app/mailers/application_mailer.rb +8 -0
  106. data/templates/app/app/mailers/templates/dot_keep +0 -0
  107. data/templates/app/app/models/application_model.rb +19 -0
  108. data/templates/app/app/serializers/application_serializer.rb +6 -0
  109. data/templates/app/bin/gemstack +7 -0
  110. data/templates/app/config/app.rb.tt +60 -0
  111. data/templates/app/config/database.yml.tt +61 -0
  112. data/templates/app/config/environments/development.rb.tt +35 -0
  113. data/templates/app/config/environments/production.rb.tt +40 -0
  114. data/templates/app/config/environments/test.rb.tt +21 -0
  115. data/templates/app/config/puma.rb +21 -0
  116. data/templates/app/config/routes.rb +10 -0
  117. data/templates/app/config.ru +6 -0
  118. data/templates/app/db/migrations/dot_keep +0 -0
  119. data/templates/app/db/seeds.rb +5 -0
  120. data/templates/app/dot_env.example.tt +19 -0
  121. data/templates/app/dot_gitignore +16 -0
  122. data/templates/app/dot_node-version.tt +1 -0
  123. data/templates/app/dot_nvmrc.tt +1 -0
  124. data/templates/app/dot_ruby-version.tt +1 -0
  125. data/templates/app/dot_tool-versions.tt +4 -0
  126. data/templates/app/test/health_test.rb +18 -0
  127. data/templates/app/test/test_helper.rb.tt +25 -0
  128. data/templates/auth/app/controllers/api_tokens_controller.rb +35 -0
  129. data/templates/auth/app/controllers/email_verifications_controller.rb +31 -0
  130. data/templates/auth/app/controllers/password_resets_controller.rb +40 -0
  131. data/templates/auth/app/controllers/registrations_controller.rb +19 -0
  132. data/templates/auth/app/controllers/sessions_controller.rb +29 -0
  133. data/templates/auth/app/mailers/auth_mailer.rb +20 -0
  134. data/templates/auth/app/mailers/templates/auth_mailer/email_verification.html.erb +2 -0
  135. data/templates/auth/app/mailers/templates/auth_mailer/email_verification.text.erb +2 -0
  136. data/templates/auth/app/mailers/templates/auth_mailer/password_reset.html.erb +3 -0
  137. data/templates/auth/app/mailers/templates/auth_mailer/password_reset.text.erb +6 -0
  138. data/templates/auth/app/models/auth_token.rb +15 -0
  139. data/templates/auth/app/models/user.rb +12 -0
  140. data/templates/auth/app/policies/application_policy.rb +6 -0
  141. data/templates/auth/app/serializers/api_token_serializer.rb +6 -0
  142. data/templates/auth/app/serializers/new_api_token_serializer.rb +9 -0
  143. data/templates/auth/app/serializers/user_serializer.rb +6 -0
  144. data/templates/auth/db/migrations/%timestamp%_create_auth_tables.rb +41 -0
  145. data/templates/auth/frontend/app/account/page.tsx +116 -0
  146. data/templates/auth/frontend/app/forgot-password/page.tsx +48 -0
  147. data/templates/auth/frontend/app/login/page.tsx +26 -0
  148. data/templates/auth/frontend/app/reset-password/page.tsx +11 -0
  149. data/templates/auth/frontend/app/signup/page.tsx +27 -0
  150. data/templates/auth/frontend/app/verify-email/page.tsx +11 -0
  151. data/templates/auth/frontend/components/auth/CredentialsForm.tsx +58 -0
  152. data/templates/auth/frontend/components/auth/ResetPasswordForm.tsx +43 -0
  153. data/templates/auth/frontend/components/auth/VerifyEmail.tsx +28 -0
  154. data/templates/auth/frontend/lib/auth.ts +113 -0
  155. data/templates/auth/test/controllers/auth_test.rb +108 -0
  156. data/templates/controller/app/controllers/%file_name%_controller.rb.tt +10 -0
  157. data/templates/controller/test/controllers/%file_name%_controller_test.rb.tt +14 -0
  158. data/templates/deploy/Caddyfile.tt +19 -0
  159. data/templates/deploy/Dockerfile.tt +63 -0
  160. data/templates/deploy/Procfile.tt +6 -0
  161. data/templates/deploy/compose.yaml.tt +129 -0
  162. data/templates/deploy/dot_dockerignore +14 -0
  163. data/templates/frontend/app/globals.css +171 -0
  164. data/templates/frontend/app/layout.tsx.tt +19 -0
  165. data/templates/frontend/app/page.module.css +315 -0
  166. data/templates/frontend/app/page.tsx.tt +183 -0
  167. data/templates/frontend/app/providers.tsx +24 -0
  168. data/templates/frontend/lib/gemstack/client.ts +125 -0
  169. data/templates/frontend/next-env.d.ts +5 -0
  170. data/templates/frontend/next.config.ts +17 -0
  171. data/templates/frontend/package.json.tt +23 -0
  172. data/templates/frontend/tsconfig.json +21 -0
  173. data/templates/job/app/jobs/%file_name%.rb.tt +15 -0
  174. data/templates/job/test/jobs/%file_name%_test.rb.tt +15 -0
  175. data/templates/migration/db/migrations/%timestamp%_%file_name%.rb.tt +17 -0
  176. data/templates/policy/app/policies/%file_name%_policy.rb.tt +21 -0
  177. data/templates/policy/test/policies/%file_name%_policy_test.rb.tt +9 -0
  178. data/templates/realtime/config/channels.rb +14 -0
  179. data/templates/realtime/frontend/lib/gemstack/realtime.ts +120 -0
  180. data/templates/resource/controller/app/controllers/%plural%_controller.rb.tt +51 -0
  181. data/templates/resource/controller/test/controllers/%plural%_controller_test.rb.tt +74 -0
  182. data/templates/resource/frontend/frontend/app/%url_segment%/[id]/edit/page.tsx.tt +45 -0
  183. data/templates/resource/frontend/frontend/app/%url_segment%/[id]/page.tsx.tt +49 -0
  184. data/templates/resource/frontend/frontend/app/%url_segment%/new/page.tsx.tt +27 -0
  185. data/templates/resource/frontend/frontend/app/%url_segment%/page.tsx.tt +51 -0
  186. data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Card.tsx.tt +15 -0
  187. data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Form.tsx.tt +98 -0
  188. data/templates/resource/frontend/frontend/components/%url_segment%/%class_name%Table.tsx.tt +36 -0
  189. data/templates/resource/frontend/frontend/lib/format.ts +12 -0
  190. data/templates/resource/frontend/frontend/lib/queries/%url_segment%.ts.tt +64 -0
  191. data/templates/resource/migration/db/migrations/%timestamp%_create_%table%.rb.tt +18 -0
  192. data/templates/resource/model/app/models/%file_name%.rb.tt +15 -0
  193. data/templates/resource/model/test/models/%file_name%_test.rb.tt +29 -0
  194. data/templates/resource/serializer/app/serializers/%file_name%_serializer.rb.tt +7 -0
  195. data/templates/storage/app/controllers/uploads_controller.rb.tt +33 -0
  196. data/templates/storage/app/serializers/upload_serializer.rb +11 -0
  197. data/templates/storage/frontend/lib/upload.ts +52 -0
  198. data/templates/storage/test/controllers/uploads_test.rb.tt +30 -0
  199. 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