glib-web 7.0.0.beta1 → 7.0.1.beta1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 38b655bd80465e224c8ed56c8e63694af4786e89a4028da3915737bf8ac96120
4
- data.tar.gz: 6baa89e800c1223673fe1d9bbad61694e6a59c46123f42faff344d819e143dbf
3
+ metadata.gz: 7a88117fbaf57f748a21abf1ea95fc18fdec09171c7b93e87ac8ef5e02850f93
4
+ data.tar.gz: d37adcb5e04c61400b5f073ada5a65d0cd5e5f39b62df49c3a28c93c93b06a41
5
5
  SHA512:
6
- metadata.gz: 30a1adca1ba187b2f23ff52f54b41259742e15582abf95404b07b658e6eb4a408e2a236b8ffe7b4b45459e7ad3abfb9f8017e8701887fe5b4f5028d045834d23
7
- data.tar.gz: c4645773a9b5aea3d3b3ba0aea2e24c609e09b45bc7b4904b76bad3af3dd486ed9b4b1b38dba4b956da4620656127462970a1b73c960c231a21da42685fd6f71
6
+ metadata.gz: a47b8e5826f8ac505d0e38b4530a9294140dcd8e5280178c636e2abe78d38d4c323a04d02e0915aec9bcaf73441fecb90d5d0f89650059e7cdc9a7658f737b56
7
+ data.tar.gz: 7c8ce8df263b855e5a09cd4e42d5e0bf039c0b24d9d08b1f6f6767e5d8857f4cd33d1f9b5a272f3a4c8b8aa16291ff26c57f4dfa350d8881d5b79129dd77f2be
@@ -22,7 +22,11 @@ module Glib::Auth
22
22
  # - Need to find a solution where we can reuse a single public policy
23
23
  # after_action :verify_authorized
24
24
 
25
- helper_method :policy, :can?, :cannot?
25
+ # Guarded because not every includer has `helper_method`: ActionController::Base
26
+ # does, but bare ActionController::API does not (gems like jbuilder may include
27
+ # ActionController::Helpers into it in a booted app, and a plain non-controller
28
+ # class never has it). Same guard Pundit's own included block uses.
29
+ helper_method :policy, :can?, :cannot? if respond_to?(:helper_method)
26
30
  end
27
31
 
28
32
  def assert_current_user_present
@@ -0,0 +1,37 @@
1
+ module Glib::Auth
2
+ # Bearer-token authentication for machine-facing APIs. The token's storage column and
3
+ # the user model are app-specific, so the lookup is a template method the app's base
4
+ # controller implements (return the user for a valid token, nil otherwise).
5
+ module TokenAuthenticatable
6
+ extend ActiveSupport::Concern
7
+
8
+ # Public to mirror the browser side, where Devise's current_user is public: glib
9
+ # hands policies the controller (Glib::ApplicationPolicy#controller), and host-app
10
+ # policies call these with an EXPLICIT receiver (`controller.current_user`,
11
+ # `delegate :user_signed_in?, to: :controller`). A private current_user would raise
12
+ # NoMethodError on the API line only -- the two surfaces must behave the same.
13
+ def current_user
14
+ @current_user
15
+ end
16
+
17
+ # Provided by Devise's helpers on the browser side; defined here so glib policies
18
+ # that delegate it to the controller work unchanged on API controllers.
19
+ def user_signed_in?
20
+ current_user.present?
21
+ end
22
+
23
+ private
24
+ def authenticate_api_token!
25
+ token = request.headers['Authorization'].to_s.sub(/\ABearer\s+/i, '')
26
+ @current_user = glib_find_user_by_api_token(token) if token.present?
27
+ return if @current_user
28
+
29
+ render json: { error: 'unauthorized' }, status: :unauthorized
30
+ end
31
+
32
+ # App implements: return the user for a valid token, nil otherwise.
33
+ def glib_find_user_by_api_token(_token)
34
+ raise NotImplementedError, "#{self.class} must define glib_find_user_by_api_token(token)"
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,52 @@
1
+ # The ApplicationController analog for agent- or machine-facing JSON APIs.
2
+ #
3
+ # Posture (do not re-derive it from a sibling app):
4
+ # - The Bearer token IS the credential; possession decides authentication. There is
5
+ # deliberately no cookie/session path in: ActionController::API has no CSRF protection,
6
+ # so a cookie-authenticated write endpoint here would be a CSRF target. Do not add one.
7
+ # - Authorization works exactly as on the browser side: policies decide, one per resource,
8
+ # derived by `glib_authorize_resource` from the controller name (override with
9
+ # `class:` when the derived name is wrong, as on any glib controller).
10
+ # - Every subclass defines `glib_load_resource` (glib has no default) and is covered by a
11
+ # policy, including index-only controllers (define it as a no-op there).
12
+ # - Opt OUT of authentication with `skip_before_action :authenticate_api_token!` for a
13
+ # public endpoint; never opt in ad hoc.
14
+ #
15
+ # Callback order is declaration order and is load-bearing:
16
+ # authenticate_api_token! -> glib_load_resource -> glib_authorize_resource
17
+ # Token auth is declared before glib_auth_init so no request can reach record loading or
18
+ # authorization unauthenticated. Keep the includes in that order in subclasses too.
19
+ #
20
+ # Unhandled exceptions keep Rails' default error body, which is HTML in development
21
+ # unless the app is api-only. Consumers wanting JSON there should set
22
+ # `config.debug_exception_response_format = :api` or add their own
23
+ # `rescue_from` (the browser side's json_libs_rescue_500 does this for the same reason).
24
+ class Glib::ApiController < ActionController::API
25
+ include Glib::Auth::TokenAuthenticatable
26
+ before_action :authenticate_api_token!
27
+
28
+ include Glib::Auth::Policy
29
+ glib_auth_init
30
+
31
+ rescue_from ActiveRecord::RecordNotFound do
32
+ render json: { error: 'not found' }, status: :not_found
33
+ end
34
+
35
+ rescue_from ActiveRecord::RecordInvalid do |e|
36
+ render json: { errors: e.record.errors }, status: :unprocessable_entity
37
+ end
38
+
39
+ rescue_from ActionController::ParameterMissing do
40
+ render json: { error: 'bad request' }, status: :bad_request
41
+ end
42
+
43
+ # The parent class, not Glib::Auth::Policy::UnauthorizedError: this covers both glib's
44
+ # raise sites (the subclass) and Pundit's own authorize(), which raises the parent --
45
+ # otherwise a consumer calling authorize() would get a 500 instead of JSON.
46
+ rescue_from Pundit::NotAuthorizedError do
47
+ # Token auth halts unauthenticated requests earlier, so this is normally 403;
48
+ # keep the 401 branch so the class is safe if a subclass skips token auth.
49
+ status = current_user ? :forbidden : :unauthorized
50
+ render json: { error: status.to_s }, status: status
51
+ end
52
+ end
@@ -211,6 +211,51 @@ class Glib::JsonUi::ViewBuilder
211
211
  end
212
212
  end
213
213
 
214
+ # Timing options for the `onChange` / `onChangeAndLoad` actions of fields
215
+ # whose value changes while the user is still typing.
216
+ #
217
+ # Honoured by the single-line text family (text, number, email, url,
218
+ # password -- all one Vue component) and by textarea. Other fields ignore
219
+ # them: the discrete ones (check, radio, select, chip group, date pickers)
220
+ # commit a value the moment it is picked, and richText/phone/file keep
221
+ # their own reporting. The options are declared on `Text`, so its other
222
+ # subclasses accept them without effect -- same as `leftIcon` and
223
+ # `onTypeStart` there.
224
+ #
225
+ # In both modes the change is also reported when focus leaves the field.
226
+ # Under `input` that only flushes a change already pending in the debounce
227
+ # window -- it cannot produce a second run, because a value that was
228
+ # already reported is not reported again.
229
+ module ChangeTrigger
230
+ def self.included(base)
231
+ # When the `onChange` / `onChangeAndLoad` actions fire.
232
+ #
233
+ # - `input` (default): after the user pauses typing for `changeDelay`.
234
+ # - `blur`: only once focus leaves the field. Use for actions too
235
+ # expensive to run mid-word -- a server round trip, a recalculation
236
+ # that rewrites other fields under the user's cursor.
237
+ #
238
+ # @example Round-trip only once the user is done with the field
239
+ # form.fields_text \
240
+ # name: 'user[promo_code]',
241
+ # label: 'Promo code',
242
+ # changeOn: 'blur',
243
+ # onChange: ->(action) { action.http_get url: verify_promo_path }
244
+ base.enum :changeOn, options: %w[input blur]
245
+
246
+ # Milliseconds of no typing before `changeOn: 'input'` fires.
247
+ # Defaults to 300. Ignored under `changeOn: 'blur'`.
248
+ #
249
+ # @example Slow down a search-as-you-type round trip
250
+ # form.fields_text \
251
+ # name: 'q',
252
+ # label: 'Search',
253
+ # changeDelay: 800,
254
+ # onChange: ->(action) { action.http_get url: search_path }
255
+ base.int :changeDelay
256
+ end
257
+ end
258
+
214
259
  # Basic text input field for single-line text entry.
215
260
  #
216
261
  # The most common field type, supporting various customizations like
@@ -228,6 +273,8 @@ class Glib::JsonUi::ViewBuilder
228
273
  #
229
274
  # @see app/views/json_ui/garage/forms/basic.json.jbuilder Garage example
230
275
  class Text < AbstractField
276
+ include ChangeTrigger
277
+
231
278
  # Maximum number of characters allowed.
232
279
  int :maxLength
233
280
 
@@ -659,7 +706,10 @@ class Glib::JsonUi::ViewBuilder
659
706
  class ChipGroup < AbstractField
660
707
  color :color
661
708
  array :options
662
- bool :multiple
709
+ # cache: true for the same reason as Upload's — without it a
710
+ # multiple-selection ChipGroup submits under a single key and only the
711
+ # last selected value survives server-side parsing.
712
+ bool :multiple, cache: true
663
713
  end
664
714
 
665
715
  class TimeZone < AbstractField
@@ -797,7 +847,11 @@ class Glib::JsonUi::ViewBuilder
797
847
  include Glib::JsonUi::FileUploadErrorHandler
798
848
 
799
849
  action :onFinishUpload
800
- bool :multiple
850
+ # cache: true so `@multiple` is set before AbstractField#created computes
851
+ # the submit key — without it the `[]` array suffix is dropped and a
852
+ # multi-file submit collapses to the last file server-side (silently
853
+ # saves one Document per drop instead of one per file).
854
+ bool :multiple, cache: true
801
855
  hash :placeholderView, required: [:type, :width, :height], optional: [:url]
802
856
  hash :inputView, optional: [:files, :variant]
803
857
  hash :clipboardView, optional: [:icon]
@@ -855,13 +909,88 @@ class Glib::JsonUi::ViewBuilder
855
909
  end
856
910
  end
857
911
 
912
+ # Signature field that lets the user draw a signature on a canvas and
913
+ # upload it as an image via ActiveStorage direct upload.
914
+ #
915
+ # The form shows a placeholder sized by `width`/`height`; tapping it opens
916
+ # a dialog with the drawing canvas, and Confirm uploads the drawing as a
917
+ # PNG to `directUploadUrl` and submits the resulting signed id under
918
+ # `name`. The placeholder then shows the signature, and reopening the
919
+ # dialog lets the user edit it. Because the canvas is not in the form,
920
+ # the placeholder can be given the same height as a neighbouring input
921
+ # (e.g. a typed-name alternative) without losing drawing space.
922
+ #
923
+ # `label` titles the dialog; `placeholder` replaces the "Tap to sign"
924
+ # prompt.
925
+ #
926
+ # With `typedName` the dialog gains Draw/Type tabs: the user can type
927
+ # their name instead of drawing, and the placeholder then shows the name
928
+ # in a handwriting style (the `glib-sign-typed` hook carries the font).
929
+ # The typed name is submitted under `typedName[:name]`; exactly one of the
930
+ # signed id and the typed name is ever non-blank. `typedName[:value]`
931
+ # pre-populates an existing name.
932
+ #
933
+ # @example Basic signature field
934
+ # form.fields_sign \
935
+ # name: 'user[signature]',
936
+ # label: 'Your signature',
937
+ # directUploadUrl: glib_direct_uploads_url,
938
+ # width: 320,
939
+ # height: 160,
940
+ # validation: { required: { message: 'add your signature!' } }
941
+ #
942
+ # @example Drawn or typed
943
+ # form.fields_sign \
944
+ # name: 'user[signature]',
945
+ # label: 'Your signature',
946
+ # typedName: { name: 'user[signature_name]', label: 'Type your full name', placeholder: 'Full name' },
947
+ # directUploadUrl: glib_direct_uploads_url,
948
+ # width: 320,
949
+ # height: 160
950
+ #
951
+ # @example Compact signature without validation
952
+ # form.fields_sign \
953
+ # id: 'signature_compact',
954
+ # name: 'user[signature_compact]',
955
+ # directUploadUrl: glib_direct_uploads_url,
956
+ # width: 220,
957
+ # height: 100
958
+ #
959
+ # @note A saved signature is a signed id that cannot be redrawn from the
960
+ # canvas: when a record is re-edited the field pre-populates `value`
961
+ # with the existing attachment's signed id (the component renders a
962
+ # read-only preview instead of ink). An untouched field therefore
963
+ # re-submits that same signed id, so consumers should treat a
964
+ # same-signed-id re-attach as "no change". Resize it via
965
+ # `components_set`/`logics_set` with `width`/`height`.
966
+ #
967
+ # @see app/views/json_ui/garage/test_page/fields_sign.json.jbuilder Garage examples
858
968
  class Sign < AbstractField
859
969
  string :directUploadUrl
860
970
  required :directUploadUrl
861
971
 
972
+ # URL of an already-attached signature image to display as a read-only
973
+ # preview (typically `file_url(attachment)` when re-editing a record
974
+ # that has a drawn signature). Pair with `prop:` so the field also
975
+ # submits the existing signed id when left untouched.
976
+ string :fileUrl
977
+
978
+ # Enables the Type tab. `value` pre-populates the previously typed
979
+ # name; `label`/`placeholder` decorate the text input in the dialog.
980
+ # `name` is still accepted but unused: the typed name is submitted
981
+ # through the field's own param (single-param contract), and the
982
+ # server routes it to the name attribute.
983
+ hash :typedName, optional: [:name, :value, :label, :placeholder]
984
+
862
985
  # Override
863
- # Signature field doesn't have default value
864
- def value(value)
986
+ # A stored signature is an attachment -- submit its signed id (mirrors
987
+ # File#determine_value) so re-editing a signed submission pre-fills the
988
+ # field with the existing blob's signed id instead of serializing the
989
+ # whole attachment proxy.
990
+ def determine_value(context, prop)
991
+ if (value = context.field_value(prop)).attached?
992
+ value.signed_id || ''
993
+ end
865
994
  end
866
995
  end
867
996
 
@@ -0,0 +1,32 @@
1
+ module Glib
2
+ # Daily cleanup for ActiveStorage blobs that have no attachments. Excludes blobs referenced by
3
+ # any `SnapshotBlobReference` row, so old file versions that still appear in some snapshot's
4
+ # timeline (i.e. a previous version of an attached file) survive the purge.
5
+ #
6
+ # Opt-in: if the host app has not run the gem's `create_snapshot_blob_references` migration,
7
+ # `SnapshotBlobReference.table_exists?` is false and the exclusion subquery short-circuits —
8
+ # the job then behaves identically to Rails' built-in unattached-blob cleanup, just delayed
9
+ # by `Glib::PurgeUnattached::Config.min_age` (8 hours by default) to give upload flows time
10
+ # to attach.
11
+ #
12
+ # Schedule from the host app (e.g. via sidekiq-cron):
13
+ # Glib::PurgeUnattachedJob.perform_later
14
+ #
15
+ # Adjust the grace window from the host app (e.g. for a slower upload flow):
16
+ # Glib::PurgeUnattached::Config.min_age = 10.hours
17
+ class PurgeUnattachedJob < ApplicationJob
18
+ queue_as :cleanup
19
+
20
+ def perform(*_args)
21
+ scope = ActiveStorage::Blob
22
+ .unattached
23
+ .where('active_storage_blobs.created_at < ?', Glib::PurgeUnattached::Config.min_age.ago)
24
+
25
+ if SnapshotBlobReference.table_exists?
26
+ scope = scope.where.not(id: SnapshotBlobReference.select(:blob_id))
27
+ end
28
+
29
+ scope.find_each(&:purge_later)
30
+ end
31
+ end
32
+ end