venus_media_library 0.1.0 → 1.1.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 +76 -0
- data/README.md +93 -34
- data/app/assets/javascripts/venus_media_library/venus_media_library.js +46 -17
- data/app/assets/stylesheets/venus_media_library/layout.css +82 -0
- data/app/assets/stylesheets/venus_media_library/picker.css +32 -0
- data/app/controllers/venus_media_library/application_controller.rb +92 -0
- data/app/controllers/venus_media_library/assets_controller.rb +30 -0
- data/app/controllers/venus_media_library/cloud_assets_controller.rb +12 -0
- data/app/controllers/venus_media_library/community_assets_controller.rb +16 -0
- data/app/controllers/venus_media_library/images_controller.rb +47 -24
- data/app/controllers/venus_media_library/legacy_assets_controller.rb +59 -0
- data/app/controllers/venus_media_library/pickers_controller.rb +127 -10
- data/app/controllers/venus_media_library/settings_controller.rb +43 -0
- data/app/controllers/venus_media_library/static_assets_controller.rb +12 -0
- data/app/helpers/venus_media_library/images_helper.rb +18 -21
- data/app/helpers/venus_media_library/picker_helper.rb +16 -3
- data/app/models/venus_media_library/accepted_types.rb +59 -0
- data/app/models/venus_media_library/asset.rb +10 -0
- data/app/views/layouts/venus_media_library/application.html.erb +17 -5
- data/app/views/venus_media_library/cloud_assets/index.html.erb +4 -0
- data/app/views/venus_media_library/community_assets/index.html.erb +25 -0
- data/app/views/venus_media_library/images/_image.html.erb +6 -2
- data/app/views/venus_media_library/images/index.html.erb +8 -2
- data/app/views/venus_media_library/legacy_assets/index.html.erb +20 -0
- data/app/views/venus_media_library/pickers/_picker.html.erb +18 -8
- data/app/views/venus_media_library/pickers/_tabs.html.erb +14 -0
- data/app/views/venus_media_library/pickers/_tile.html.erb +22 -0
- data/app/views/venus_media_library/pickers/_tile_body.html.erb +8 -0
- data/app/views/venus_media_library/settings/show.html.erb +45 -0
- data/app/views/venus_media_library/shared/_host_assets.html.erb +24 -0
- data/app/views/venus_media_library/shared/_sidebar.html.erb +24 -0
- data/app/views/venus_media_library/static_assets/index.html.erb +4 -0
- data/config/routes.rb +11 -0
- data/db/migrate/20260817190000_create_venus_media_library_assets.rb +10 -0
- data/lib/venus_media_library/engine.rb +8 -2
- data/lib/venus_media_library/version.rb +1 -1
- data/lib/venus_media_library.rb +34 -10
- metadata +49 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 53f9b683221215b76b95f0248dd87938ebba0a7066c36ccc7b02dc23fece50e0
|
|
4
|
+
data.tar.gz: acf9d06d3f9a7712568f9f05c9584ccc9fda2772de1bcad6e1b2090c1cf07e8f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e1d247beff66e7d45750fcfa96813cdcb0bb7ed5a2cb0eb500486602f4b8354001ffc280fe399fbb504ad094b2e97b3715cb578b29e2aabce34d34b82fd4037f
|
|
7
|
+
data.tar.gz: e8928ccd013a969ff08cfef454886735297e5a74d84acc9b66a09335be91ede3a1a9f4f3dfbe59ede7a3b16acc4438b461b73b8c43c550e5ce01e989555b2999
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,82 @@ All notable changes to this project are documented here. The format is based on
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres
|
|
5
5
|
to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [1.1.0] - 2026-08-18
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- **Full-page library navigation.** At the engine mount point (`/media`) the
|
|
11
|
+
library now renders a role-gated vertical left-nav in a 30/70 split — a proper
|
|
12
|
+
browsing/management workspace — while the picker modal stays lean for everyday
|
|
13
|
+
"pick and go." Nav entries are built from the same admin gate that guards the
|
|
14
|
+
controllers, so a link is never shown that the controller would `403`.
|
|
15
|
+
- **Category tabs in the picker modal.** The modal exposes Images / Legacy /
|
|
16
|
+
Static / Cloud / Community tabs (Legacy is admin-only), each loading its
|
|
17
|
+
category into the picker Turbo Frame.
|
|
18
|
+
- **Field-aware type enforcement.** A host field's accepted content types now
|
|
19
|
+
flow (`media_picker_field`/`media_attach_field` → `picker_path(accept:)` →
|
|
20
|
+
frame) into the picker. Assets whose type does not match are rendered
|
|
21
|
+
non-selectable, and uploads of a disallowed type are rejected client-side and
|
|
22
|
+
re-validated server-side (422). Falls back to the configured global allowlist
|
|
23
|
+
when a field declares nothing, and never widens past it.
|
|
24
|
+
|
|
25
|
+
### Notes
|
|
26
|
+
- Static/Cloud assets have no Active Storage `signed_id`; they are pickable into
|
|
27
|
+
URL fields but cannot be attached to `has_one_attached` fields, and selecting
|
|
28
|
+
one never disturbs an existing attachment.
|
|
29
|
+
|
|
30
|
+
## [1.0.0] - 2026-08-17
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
- A stable, documented public release of the mountable Rails media-library
|
|
34
|
+
engine, including private-by-default owner access, optional community sharing,
|
|
35
|
+
administrator imports, and tenant-aware scopes.
|
|
36
|
+
- Browsable library and separate host-configured Static Assets pages at the
|
|
37
|
+
engine mount point.
|
|
38
|
+
- A headless Chrome system test covering picker open, existing-image selection,
|
|
39
|
+
upload, and Escape-to-close behavior.
|
|
40
|
+
- Settings-gated PDF uploads and selection. PDFs are disabled by default,
|
|
41
|
+
rendered as document tiles, and served only as protected attachments.
|
|
42
|
+
- CI coverage for Ruby 3.2/Rails 7.1 and Ruby 3.4/Rails 8.1, plus contributor,
|
|
43
|
+
security, and release documentation.
|
|
44
|
+
|
|
45
|
+
### Security
|
|
46
|
+
- Trusted RubyGems publishing through the tag-triggered GitHub OIDC workflow;
|
|
47
|
+
the required publisher configuration is documented in `docs/RELEASING.md`.
|
|
48
|
+
|
|
49
|
+
## [0.2.0] - 2026-08-17
|
|
50
|
+
|
|
51
|
+
### Added
|
|
52
|
+
- Owner-scoped media records with private-by-default uploads and optional
|
|
53
|
+
community sharing.
|
|
54
|
+
- Host `current_user` and `current_user.admin?` conventions, configurable for
|
|
55
|
+
applications with a different authentication model.
|
|
56
|
+
- Authorization-aware original and thumbnail delivery routes.
|
|
57
|
+
- A tested Rails compatibility range through the 8.x series.
|
|
58
|
+
|
|
59
|
+
### Security
|
|
60
|
+
- Validate detected upload MIME types, require the declared type to match, and
|
|
61
|
+
enforce a configurable size limit.
|
|
62
|
+
- Deliver SVG originals as attachments instead of inline documents.
|
|
63
|
+
- Add configurable asset and legacy-blob tenant scopes; legacy blobs are denied
|
|
64
|
+
by default until explicitly scoped by the host.
|
|
65
|
+
|
|
66
|
+
## [0.1.1] - 2026-08-17
|
|
67
|
+
|
|
68
|
+
### Added
|
|
69
|
+
- A self-contained SQLite dummy app and test suite; contributors and CI no
|
|
70
|
+
longer need PostgreSQL.
|
|
71
|
+
- CI coverage for RuboCop, the full RSpec suite, gem building, and package
|
|
72
|
+
validation.
|
|
73
|
+
- SVG upload coverage. `image/svg+xml` remains part of the default upload
|
|
74
|
+
allowlist.
|
|
75
|
+
|
|
76
|
+
### Changed
|
|
77
|
+
- The dummy app and installation guide now mount the browsable library at
|
|
78
|
+
`/venus_media_library`; the engine root at that URL renders the image grid.
|
|
79
|
+
- `allowed_content_types` now strictly controls uploads instead of implicitly
|
|
80
|
+
permitting every `image/*` MIME type.
|
|
81
|
+
- JSON pagination is capped at 100 images per page.
|
|
82
|
+
|
|
7
83
|
## [0.1.0] - 2026-08-15
|
|
8
84
|
|
|
9
85
|
### Added
|
data/README.md
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
# Media Library
|
|
2
2
|
|
|
3
|
-
A mountable Rails engine that turns **Active Storage** into a browsable media library with an
|
|
3
|
+
A mountable Rails engine that turns **Active Storage** into a browsable media library with an image-and-document picker.
|
|
4
4
|
|
|
5
|
-
Content editors get a modal that lists
|
|
5
|
+
Content editors get a modal that lists authorized engine media and lets them upload new ones. Drop `media_picker_field` next to any URL field (for example an `og:image` field) so editors *select* an approved asset instead of typing a path.
|
|
6
6
|
|
|
7
7
|
It is **storage-agnostic**: it uses whatever Active Storage service the host app configures — local Disk in development, Amazon S3 (or GCS, Azure, ...) in production. The engine never talks to a storage backend directly.
|
|
8
8
|
|
|
9
9
|
- Namespaced under `VenusMediaLibrary::` (isolated engine)
|
|
10
|
-
- Lists
|
|
10
|
+
- Lists owner-scoped Active Storage media records, newest first
|
|
11
11
|
- HTML thumbnail grid **and** a JSON API
|
|
12
12
|
- Upload via `ActiveStorage::Blob.create_and_upload!`
|
|
13
13
|
- A modal picker (Turbo Frame + dependency-free vanilla JS)
|
|
@@ -29,12 +29,21 @@ bundle install
|
|
|
29
29
|
|
|
30
30
|
Active Storage must be installed in the host app (`bin/rails active_storage:install && bin/rails db:migrate`).
|
|
31
31
|
|
|
32
|
+
### Compatibility
|
|
33
|
+
|
|
34
|
+
| Component | Supported |
|
|
35
|
+
| --- | --- |
|
|
36
|
+
| Ruby | 3.2+ |
|
|
37
|
+
| Rails | 7.1 through 8.x |
|
|
38
|
+
| Database | Any host-supported Active Record database |
|
|
39
|
+
| Assets | Propshaft or Sprockets |
|
|
40
|
+
|
|
32
41
|
## Mount the engine
|
|
33
42
|
|
|
34
43
|
In the host app's `config/routes.rb`:
|
|
35
44
|
|
|
36
45
|
```ruby
|
|
37
|
-
mount VenusMediaLibrary::Engine, at: "/
|
|
46
|
+
mount VenusMediaLibrary::Engine, at: "/venus_media_library"
|
|
38
47
|
```
|
|
39
48
|
|
|
40
49
|
Include the picker JavaScript once in your layout (Propshaft/Sprockets):
|
|
@@ -80,6 +89,12 @@ chosen — so submitting the form without picking never detaches the current fil
|
|
|
80
89
|
accepts a `signed_id` natively, so no controller changes are needed beyond
|
|
81
90
|
permitting the attachment param (e.g. `params.permit(:cover)`).
|
|
82
91
|
|
|
92
|
+
### Choosing URL or attachment mode
|
|
93
|
+
|
|
94
|
+
Use `media_picker_field` for a string URL column and `media_attach_field` for
|
|
95
|
+
a `has_one_attached` association. Both modes require host authorization; the
|
|
96
|
+
attachment mode also requires permitting the attachment parameter.
|
|
97
|
+
|
|
83
98
|
### `media_picker_field` options
|
|
84
99
|
|
|
85
100
|
| Option | Default | Description |
|
|
@@ -92,14 +107,18 @@ permitting the attachment param (e.g. `params.permit(:cover)`).
|
|
|
92
107
|
|
|
93
108
|
### Endpoints
|
|
94
109
|
|
|
95
|
-
Mounted at your chosen path (examples assume `/
|
|
110
|
+
Mounted at your chosen path (examples assume `/venus_media_library`):
|
|
96
111
|
|
|
97
112
|
| Method | Path | Purpose |
|
|
98
113
|
| --- | --- | --- |
|
|
99
|
-
| `GET` | `/
|
|
100
|
-
| `GET` | `/
|
|
101
|
-
| `
|
|
102
|
-
| `
|
|
114
|
+
| `GET` | `/venus_media_library` | Browsable HTML thumbnail grid |
|
|
115
|
+
| `GET` | `/venus_media_library/images` | HTML thumbnail grid (also `.json`) |
|
|
116
|
+
| `GET` | `/venus_media_library/images.json` | `{ images: [...], page:, has_more:, total: }` |
|
|
117
|
+
| `POST` | `/venus_media_library/images` | Upload a file (param `file`); returns the image JSON |
|
|
118
|
+
| `GET` | `/venus_media_library/picker?target=<input_id>` | Turbo Frame body for the modal |
|
|
119
|
+
| `GET` | `/venus_media_library/community_assets` | Tenant-scoped community-shared images (also `.json`) |
|
|
120
|
+
| `GET` | `/venus_media_library/static_assets` | Host-configured static-asset page (also `.json`) |
|
|
121
|
+
| `GET` | `/venus_media_library/cloud_assets` | Host-configured cloud-asset page (also `.json`) |
|
|
103
122
|
|
|
104
123
|
Each image payload includes `id`, `signed_id`, `filename`, `content_type`, `byte_size`, `url`, and `thumb_url`.
|
|
105
124
|
|
|
@@ -109,8 +128,10 @@ In an initializer (e.g. `config/initializers/venus_media_library.rb`):
|
|
|
109
128
|
|
|
110
129
|
```ruby
|
|
111
130
|
VenusMediaLibrary.configure do |config|
|
|
112
|
-
# Content types accepted by the uploader
|
|
131
|
+
# Content types accepted by the uploader.
|
|
113
132
|
config.allowed_content_types = %w[image/png image/jpeg image/webp image/gif image/svg+xml]
|
|
133
|
+
# Add application/pdf only when this host application permits PDF uploads.
|
|
134
|
+
# config.allowed_content_types << "application/pdf"
|
|
114
135
|
|
|
115
136
|
# [width, height] for the grid thumbnail variant.
|
|
116
137
|
config.thumbnail_size = [300, 300]
|
|
@@ -118,40 +139,64 @@ VenusMediaLibrary.configure do |config|
|
|
|
118
139
|
# Images per page in the index / picker.
|
|
119
140
|
config.per_page = 40
|
|
120
141
|
|
|
142
|
+
# Reject uploads larger than this many bytes (10 MiB by default).
|
|
143
|
+
config.max_file_size = 10 * 1024 * 1024
|
|
144
|
+
|
|
121
145
|
# Which Active Storage service to store uploads on. nil = the host app's
|
|
122
146
|
# default service (Disk in dev, S3 in prod, etc.).
|
|
123
147
|
config.storage_service = nil
|
|
124
148
|
|
|
125
|
-
#
|
|
126
|
-
# uses rails_storage_proxy_url so images on a PRIVATE bucket in proxy mode are
|
|
127
|
-
# absolute and publicly fetchable by crawlers (e.g. for an og:image).
|
|
149
|
+
# Retained for compatibility. Library URLs are authorization-aware engine URLs.
|
|
128
150
|
config.url_type = :redirect
|
|
129
151
|
|
|
130
152
|
# Optional access gate. A proc run in the engine controller's context before
|
|
131
153
|
# every action, so it can use host helpers (current_user, redirect_to, head,
|
|
132
|
-
# main_app).
|
|
133
|
-
# upload endpoints to admins:
|
|
154
|
+
# main_app). Use it for a custom precondition before engine actions:
|
|
134
155
|
#
|
|
135
156
|
# config.authenticate_with = lambda do
|
|
136
157
|
# redirect_to main_app.root_path unless current_user&.admin?
|
|
137
158
|
# end
|
|
138
159
|
config.authenticate_with = nil
|
|
160
|
+
|
|
161
|
+
# Ownership defaults to the host application's authentication convention.
|
|
162
|
+
config.current_user = -> { current_user }
|
|
163
|
+
config.admin = ->(user) { user.admin? }
|
|
164
|
+
|
|
165
|
+
# Approved host-supplied records for non-library tabs. Each hash needs a
|
|
166
|
+
# filename and URL, with optional content_type and byte_size.
|
|
167
|
+
config.static_assets = -> { [] }
|
|
168
|
+
config.cloud_assets = -> { [] }
|
|
169
|
+
|
|
139
170
|
end
|
|
140
171
|
```
|
|
141
172
|
|
|
142
173
|
### Configuration Details
|
|
143
174
|
|
|
144
|
-
- **`allowed_content_types`** — Restricts uploads to these MIME types. The
|
|
175
|
+
- **`allowed_content_types`** — Restricts uploads to these exact MIME types. The default explicitly includes SVG (`image/svg+xml`) but deliberately excludes PDFs. Add `application/pdf` here to enable PDF selection and uploads; PDFs render as document tiles and are delivered as protected downloads rather than embedded content.
|
|
145
176
|
|
|
146
177
|
- **`thumbnail_size`** — Array of `[width, height]` for grid thumbnails. Larger values give better preview quality at the cost of image processing overhead and bandwidth.
|
|
147
178
|
|
|
148
|
-
- **`per_page`** — Number of images to display per page. Smaller values suit mobile-friendly UIs; larger values reduce pagination clicks.
|
|
179
|
+
- **`per_page`** — Number of images to display per page. Smaller values suit mobile-friendly UIs; larger values reduce pagination clicks. JSON callers can request up to 100 images per page.
|
|
180
|
+
|
|
181
|
+
- **`max_file_size`** — Maximum upload size in bytes. The default is 10 MiB.
|
|
149
182
|
|
|
150
183
|
- **`storage_service`** — Active Storage service name (e.g. `:amazon`, `:google`). Leave `nil` to use the host app's default, making the engine truly storage-agnostic. Uploads automatically inherit the configured service.
|
|
151
184
|
|
|
152
|
-
- **`
|
|
153
|
-
|
|
154
|
-
|
|
185
|
+
- **`current_user`** — A controller-context callback that returns the signed-in host user. It defaults to `current_user`; every engine endpoint requires it to return a user.
|
|
186
|
+
|
|
187
|
+
- **`admin`** — A callback that determines whether that user can manage all media. It defaults to `->(user) { user.admin? }`.
|
|
188
|
+
|
|
189
|
+
- **`asset_scope`** — A controller-context callback receiving the engine asset relation. Use it to restrict all list, picker, download, and admin queries to the current tenant. It defaults to the supplied relation.
|
|
190
|
+
|
|
191
|
+
- **`legacy_blob_scope`** — A controller-context callback receiving unowned image blobs. It defaults to `scope.none`, so legacy blobs cannot cross tenant boundaries accidentally. Configure it explicitly only when the host can prove which legacy blobs belong to the current tenant.
|
|
192
|
+
|
|
193
|
+
- **`static_assets`** — A controller-context callback that returns approved host asset hashes for the separate `/static_assets` page. Each hash needs `filename` and `url`, with optional `content_type` and `byte_size`; the engine never scans host directories.
|
|
194
|
+
|
|
195
|
+
- **`cloud_assets`** — A controller-context callback with the same hash shape for the separate `/cloud_assets` page. Use it to present approved CDN or object-storage assets. The engine never enumerates a bucket, accepts storage credentials, or makes an arbitrary cloud object public.
|
|
196
|
+
|
|
197
|
+
- **Community Assets** — The `/community_assets` tab shows only `community_shared: true` records after the host's `asset_scope` is applied. Private uploads never appear there.
|
|
198
|
+
|
|
199
|
+
- **`url_type`** — Retained for backward-compatible host configuration. Browsing and picker URLs are always protected engine routes, so private uploads are never exposed via an Active Storage signed URL.
|
|
155
200
|
|
|
156
201
|
- **`authenticate_with`** — A proc that gates access to the engine. Runs before every action in the engine's controller context, so you can call host helpers like `current_user`, `redirect_to`, and `head`. Return nothing to allow, or redirect/deny to block. Example:
|
|
157
202
|
```ruby
|
|
@@ -181,6 +226,13 @@ Without these, variant generation fails and thumbnail images won't display.
|
|
|
181
226
|
|
|
182
227
|
## Styling & Customization
|
|
183
228
|
|
|
229
|
+
Include the engine's explicit entrypoints in the host layout:
|
|
230
|
+
|
|
231
|
+
```erb
|
|
232
|
+
<%= stylesheet_link_tag "venus_media_library/application", "venus_media_library/picker" %>
|
|
233
|
+
<%= javascript_include_tag "venus_media_library/venus_media_library", defer: true %>
|
|
234
|
+
```
|
|
235
|
+
|
|
184
236
|
All picker and library CSS is namespaced under `.ml-*` classes to avoid conflicts with the host app. The engine ships two stylesheets:
|
|
185
237
|
|
|
186
238
|
- **`venus_media_library/application.css`** — Layout and structure (grid, pagination, forms).
|
|
@@ -232,16 +284,16 @@ If you must support IE11, you'll need polyfills. No Turbo Drive requirement, but
|
|
|
232
284
|
|
|
233
285
|
### The Picker Flow
|
|
234
286
|
|
|
235
|
-
1. **User clicks "Choose from library"** — Opens a modal via a Turbo Frame (`GET /
|
|
287
|
+
1. **User clicks "Choose from library"** — Opens a modal via a Turbo Frame (`GET /venus_media_library/picker?target=<input_id>`).
|
|
236
288
|
2. **Modal loads the image grid** — The frame fetches the index view, listing images newest-first with pagination.
|
|
237
289
|
3. **User uploads or selects** —
|
|
238
290
|
- **Select:** Click an image tile; JavaScript writes the blob's `signed_id` and URL into the form inputs and closes the modal.
|
|
239
|
-
- **Upload:** Click the upload button; JavaScript posts the file to `POST /
|
|
291
|
+
- **Upload:** Click the upload button; JavaScript posts the file to `POST /venus_media_library/images`, attaches the new blob to the same inputs, and reloads the grid.
|
|
240
292
|
4. **Form submission** — The host app form submits with the image data, storing it as a URL column or Active Storage attachment.
|
|
241
293
|
|
|
242
|
-
###
|
|
294
|
+
### Authorized delivery & storage agnosticism
|
|
243
295
|
|
|
244
|
-
|
|
296
|
+
The engine serves library URLs through authorization-aware routes. It never directly accesses the storage backend; Active Storage routes the authorized byte requests.
|
|
245
297
|
|
|
246
298
|
Upload destinations are determined by `config.storage_service` — if `nil`, the host app's default service is used, allowing per-environment configuration (Disk locally, S3 in production).
|
|
247
299
|
|
|
@@ -256,7 +308,7 @@ cd spec/dummy && bundle exec rspec ../
|
|
|
256
308
|
Or use the included Rakefile:
|
|
257
309
|
|
|
258
310
|
```bash
|
|
259
|
-
bundle exec rake spec
|
|
311
|
+
bundle exec rake app:spec
|
|
260
312
|
```
|
|
261
313
|
|
|
262
314
|
When testing a host app that uses Media Library, you can:
|
|
@@ -275,6 +327,16 @@ When testing a host app that uses Media Library, you can:
|
|
|
275
327
|
|
|
276
328
|
## Troubleshooting
|
|
277
329
|
|
|
330
|
+
### Ownership and community sharing
|
|
331
|
+
|
|
332
|
+
Each upload is owned by the user returned by `config.current_user` and starts private. A member can browse and download their own uploads; checking **Share with community** during upload makes that item visible to other signed-in members. Admins, as determined by `config.admin`, can browse and download every engine-managed upload.
|
|
333
|
+
|
|
334
|
+
The engine sends originals and thumbnails through its own authorization-aware routes. Do not use an Active Storage blob URL as a substitute for an engine media URL, because it bypasses the ownership check.
|
|
335
|
+
|
|
336
|
+
The upload endpoint verifies the detected MIME type with Marcel, requires it to match the browser-declared type, enforces the allowlist and `max_file_size`, and stores the detected type. SVG remains supported, but originals are delivered as downloads rather than inline documents to avoid executing SVG active content in the host application's origin.
|
|
337
|
+
|
|
338
|
+
Blobs that existed before the engine was installed have no owner and are intentionally invisible to normal members. Admins can use **Import unowned legacy uploads** from the library to claim an image into their private library, then use the normal community-sharing control if appropriate. Do not expose legacy blobs by direct Active Storage URLs.
|
|
339
|
+
|
|
278
340
|
### Thumbnails aren't generating
|
|
279
341
|
|
|
280
342
|
**Symptom:** Gray placeholder squares instead of previews in the grid.
|
|
@@ -284,12 +346,6 @@ When testing a host app that uses Media Library, you can:
|
|
|
284
346
|
rails active_storage:install && rails db:migrate
|
|
285
347
|
```
|
|
286
348
|
|
|
287
|
-
### Private S3 bucket — og:image not visible to crawlers
|
|
288
|
-
|
|
289
|
-
**Symptom:** Social media preview cards show no image for posts with private S3 URLs.
|
|
290
|
-
|
|
291
|
-
**Solution:** Set `config.url_type = :proxy` so Rails proxies image bytes through a public endpoint. This requires Rails to stream the file, so monitor for performance impact with large images.
|
|
292
|
-
|
|
293
349
|
### Upload endpoint is open to the public
|
|
294
350
|
|
|
295
351
|
**Symptom:** Anyone can upload images to your media library.
|
|
@@ -313,12 +369,12 @@ end
|
|
|
313
369
|
|
|
314
370
|
**Solution:** Check browser console for errors. Verify:
|
|
315
371
|
1. JavaScript is loaded: `<%= javascript_include_tag "venus_media_library/venus_media_library", defer: true %>`
|
|
316
|
-
2. The engine is mounted and accessible at your chosen path (
|
|
372
|
+
2. The engine is mounted and accessible at your chosen path (recommended: `/venus_media_library`).
|
|
317
373
|
3. No JavaScript errors in other assets are breaking the page.
|
|
318
374
|
|
|
319
375
|
## Development
|
|
320
376
|
|
|
321
|
-
The engine ships with a dummy app under `spec/dummy` (Active Storage configured with the Disk service, engine mounted at `/
|
|
377
|
+
The engine ships with a dummy app under `spec/dummy` (Active Storage configured with the Disk service, engine mounted at `/venus_media_library`).
|
|
322
378
|
|
|
323
379
|
```bash
|
|
324
380
|
bundle install
|
|
@@ -335,7 +391,10 @@ gem build venus_media_library.gemspec # produces venus_media_library-<ver
|
|
|
335
391
|
gem push venus_media_library-<version>.gem # publish to RubyGems
|
|
336
392
|
```
|
|
337
393
|
|
|
338
|
-
|
|
394
|
+
Follow [the release guide](docs/RELEASING.md). Releases normally use GitHub
|
|
395
|
+
Actions trusted publishing; no RubyGems token is stored in the repository or
|
|
396
|
+
GitHub secrets. Bump `VenusMediaLibrary::VERSION` in
|
|
397
|
+
`lib/venus_media_library/version.rb` before each release.
|
|
339
398
|
|
|
340
399
|
## License
|
|
341
400
|
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// Wiring (all via delegated events, no framework required):
|
|
4
4
|
// [data-ml-open][data-ml-target][data-ml-src] open the modal for a field
|
|
5
5
|
// [data-ml-close] close the modal
|
|
6
|
+
// [data-ml-picker-nav] switch category / paginate in-frame
|
|
6
7
|
// .ml-tile[data-ml-url][data-ml-signed-id] choose an image
|
|
7
8
|
// input[data-ml-upload] upload a new image
|
|
8
9
|
//
|
|
@@ -61,6 +62,8 @@
|
|
|
61
62
|
|
|
62
63
|
function chooseTile(tile) {
|
|
63
64
|
if (!activeTargetId) { closeModal(); return; }
|
|
65
|
+
// Assets whose type the field doesn't accept render disabled; never pick one.
|
|
66
|
+
if (tile.disabled || tile.classList.contains("ml-tile--disabled")) return;
|
|
64
67
|
var url = tile.getAttribute("data-ml-url");
|
|
65
68
|
var signedId = tile.getAttribute("data-ml-signed-id");
|
|
66
69
|
|
|
@@ -71,10 +74,12 @@
|
|
|
71
74
|
input.dispatchEvent(new Event("change", { bubbles: true }));
|
|
72
75
|
}
|
|
73
76
|
var hidden = document.querySelector('[data-ml-signed-id-for="' + activeTargetId + '"]');
|
|
74
|
-
if (hidden) {
|
|
77
|
+
if (hidden && signedId) {
|
|
75
78
|
hidden.value = signedId;
|
|
76
79
|
// Attach mode ships the hidden field disabled so an empty value can't
|
|
77
|
-
// detach the current file; enable it now that a signed_id is set.
|
|
80
|
+
// detach the current file; enable it now that a signed_id is set. External
|
|
81
|
+
// assets (static/cloud) carry no signed_id, so we skip this entirely and
|
|
82
|
+
// leave any existing attachment intact.
|
|
78
83
|
hidden.disabled = false;
|
|
79
84
|
}
|
|
80
85
|
|
|
@@ -90,6 +95,13 @@
|
|
|
90
95
|
|
|
91
96
|
var form = new FormData();
|
|
92
97
|
form.append("file", file);
|
|
98
|
+
var share = document.querySelector("[data-ml-community-share]");
|
|
99
|
+
form.append("community_shared", share && share.checked ? "1" : "0");
|
|
100
|
+
// Constrain the upload to the opening field's accepted types (server
|
|
101
|
+
// re-validates); the frame carries them as data-ml-accept.
|
|
102
|
+
var f = frame();
|
|
103
|
+
var accept = f ? f.getAttribute("data-ml-accept") : null;
|
|
104
|
+
if (accept) form.append("accept", accept);
|
|
93
105
|
|
|
94
106
|
var headers = { "Accept": "application/json" };
|
|
95
107
|
var token = csrfToken();
|
|
@@ -100,9 +112,9 @@
|
|
|
100
112
|
if (!r.ok) throw new Error("Upload failed");
|
|
101
113
|
return r.json();
|
|
102
114
|
})
|
|
103
|
-
.then(function (
|
|
104
|
-
if (status) status.textContent = "Uploaded " +
|
|
105
|
-
prependTile(
|
|
115
|
+
.then(function (media) {
|
|
116
|
+
if (status) status.textContent = "Uploaded " + media.filename;
|
|
117
|
+
prependTile(media);
|
|
106
118
|
})
|
|
107
119
|
.catch(function () { if (status) status.textContent = "Upload failed."; });
|
|
108
120
|
}
|
|
@@ -115,7 +127,7 @@
|
|
|
115
127
|
return "images";
|
|
116
128
|
}
|
|
117
129
|
|
|
118
|
-
function prependTile(
|
|
130
|
+
function prependTile(media) {
|
|
119
131
|
var grid = document.querySelector("[data-ml-grid]");
|
|
120
132
|
if (!grid) return;
|
|
121
133
|
// Build with DOM methods (not innerHTML): filenames are user-controlled, so
|
|
@@ -123,21 +135,29 @@
|
|
|
123
135
|
var btn = document.createElement("button");
|
|
124
136
|
btn.type = "button";
|
|
125
137
|
btn.className = "ml-tile";
|
|
126
|
-
btn.setAttribute("data-ml-signed-id",
|
|
127
|
-
btn.setAttribute("data-ml-url",
|
|
128
|
-
btn.setAttribute("data-ml-filename",
|
|
129
|
-
btn.title =
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
138
|
+
btn.setAttribute("data-ml-signed-id", media.signed_id);
|
|
139
|
+
btn.setAttribute("data-ml-url", media.url);
|
|
140
|
+
btn.setAttribute("data-ml-filename", media.filename);
|
|
141
|
+
btn.title = media.filename;
|
|
142
|
+
|
|
143
|
+
if (media.previewable) {
|
|
144
|
+
var img = document.createElement("img");
|
|
145
|
+
img.src = media.thumb_url;
|
|
146
|
+
img.alt = media.filename;
|
|
147
|
+
img.loading = "lazy";
|
|
148
|
+
btn.appendChild(img);
|
|
149
|
+
} else {
|
|
150
|
+
var documentTile = document.createElement("span");
|
|
151
|
+
documentTile.className = "ml-tile__document";
|
|
152
|
+
documentTile.setAttribute("aria-hidden", "true");
|
|
153
|
+
documentTile.textContent = media.content_type === "application/pdf" ? "PDF" : "FILE";
|
|
154
|
+
btn.appendChild(documentTile);
|
|
155
|
+
}
|
|
135
156
|
|
|
136
157
|
var name = document.createElement("span");
|
|
137
158
|
name.className = "ml-tile__name";
|
|
138
|
-
name.textContent =
|
|
159
|
+
name.textContent = media.filename;
|
|
139
160
|
|
|
140
|
-
btn.appendChild(img);
|
|
141
161
|
btn.appendChild(name);
|
|
142
162
|
grid.insertBefore(btn, grid.firstChild);
|
|
143
163
|
}
|
|
@@ -154,6 +174,15 @@
|
|
|
154
174
|
closeModal();
|
|
155
175
|
return;
|
|
156
176
|
}
|
|
177
|
+
// Category tabs / "load older" reload the picker frame in place. When Turbo
|
|
178
|
+
// is present it drives the frame via data-turbo-frame; otherwise fetch it.
|
|
179
|
+
var nav = e.target.closest("[data-ml-picker-nav]");
|
|
180
|
+
if (nav && nav.closest("#" + MODAL_ID)) {
|
|
181
|
+
if (window.Turbo && frame() && frame().tagName.toLowerCase() === "turbo-frame") return;
|
|
182
|
+
e.preventDefault();
|
|
183
|
+
loadFrame(nav.getAttribute("href"));
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
157
186
|
var tile = e.target.closest(".ml-tile");
|
|
158
187
|
if (tile && tile.closest("#" + MODAL_ID)) {
|
|
159
188
|
e.preventDefault();
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Full-page media library management workspace shell. Namespaced under .ml-* to
|
|
3
|
+
* avoid clashing with the host app. This styles the /media full-page context
|
|
4
|
+
* only; the picker modal keeps its own lean layout in picker.css.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
.ml-body { margin: 0; }
|
|
8
|
+
|
|
9
|
+
/* 30% left navigation column beside a 70% content column. */
|
|
10
|
+
.ml-layout {
|
|
11
|
+
display: flex;
|
|
12
|
+
align-items: stretch;
|
|
13
|
+
min-height: 100vh;
|
|
14
|
+
}
|
|
15
|
+
.ml-layout__nav {
|
|
16
|
+
flex: 0 0 30%;
|
|
17
|
+
max-width: 30%;
|
|
18
|
+
box-sizing: border-box;
|
|
19
|
+
background: #f7f8fa;
|
|
20
|
+
border-right: 1px solid #e2e2e2;
|
|
21
|
+
}
|
|
22
|
+
.ml-layout__content {
|
|
23
|
+
flex: 1 1 70%;
|
|
24
|
+
min-width: 0; /* let the grid shrink instead of overflowing the row */
|
|
25
|
+
box-sizing: border-box;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/* Sidebar navigation. */
|
|
29
|
+
.ml-nav {
|
|
30
|
+
position: sticky;
|
|
31
|
+
top: 0;
|
|
32
|
+
padding: 1rem .75rem;
|
|
33
|
+
}
|
|
34
|
+
.ml-nav__brand {
|
|
35
|
+
font-weight: 700;
|
|
36
|
+
font-size: 1.05rem;
|
|
37
|
+
color: #1d4f80;
|
|
38
|
+
padding: .25rem .75rem 1rem;
|
|
39
|
+
}
|
|
40
|
+
.ml-nav__list {
|
|
41
|
+
list-style: none;
|
|
42
|
+
margin: 0;
|
|
43
|
+
padding: 0;
|
|
44
|
+
display: flex;
|
|
45
|
+
flex-direction: column;
|
|
46
|
+
gap: .15rem;
|
|
47
|
+
}
|
|
48
|
+
.ml-nav__link {
|
|
49
|
+
display: block;
|
|
50
|
+
padding: .55rem .75rem;
|
|
51
|
+
color: #333;
|
|
52
|
+
text-decoration: none;
|
|
53
|
+
border-radius: 6px;
|
|
54
|
+
border-left: 3px solid transparent;
|
|
55
|
+
}
|
|
56
|
+
.ml-nav__link:hover { background: #eef1f5; color: #1d4f80; }
|
|
57
|
+
.ml-nav__link[aria-current="page"] {
|
|
58
|
+
background: #e7eefb;
|
|
59
|
+
color: #1d4f80;
|
|
60
|
+
border-left-color: #2b6cb0;
|
|
61
|
+
font-weight: 600;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/* Responsive: stack the nav above the content and lay entries out as a wrapping
|
|
65
|
+
* horizontal bar on narrow viewports. */
|
|
66
|
+
@media (max-width: 640px) {
|
|
67
|
+
.ml-layout { flex-direction: column; min-height: 0; }
|
|
68
|
+
.ml-layout__nav {
|
|
69
|
+
flex-basis: auto;
|
|
70
|
+
max-width: none;
|
|
71
|
+
border-right: 0;
|
|
72
|
+
border-bottom: 1px solid #e2e2e2;
|
|
73
|
+
}
|
|
74
|
+
.ml-nav { position: static; padding: .5rem; }
|
|
75
|
+
.ml-nav__brand { padding: .25rem .5rem .5rem; }
|
|
76
|
+
.ml-nav__list { flex-direction: row; flex-wrap: wrap; gap: .25rem; }
|
|
77
|
+
.ml-nav__link { border-left: 0; border-bottom: 3px solid transparent; }
|
|
78
|
+
.ml-nav__link[aria-current="page"] {
|
|
79
|
+
border-left-color: transparent;
|
|
80
|
+
border-bottom-color: #2b6cb0;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -22,6 +22,11 @@
|
|
|
22
22
|
.ml-library__header { display: flex; align-items: baseline; gap: 1rem; }
|
|
23
23
|
.ml-library__count { color: #666; }
|
|
24
24
|
|
|
25
|
+
.ml-tabs { display: flex; gap: .25rem; flex-wrap: wrap; margin: 0 0 1rem; border-bottom: 1px solid #d7d7d7; }
|
|
26
|
+
.ml-tabs__tab { padding: .5rem .75rem; color: #444; text-decoration: none; border: 1px solid transparent; border-bottom: 0; border-radius: 4px 4px 0 0; }
|
|
27
|
+
.ml-tabs__tab:hover { background: #f5f5f5; color: #222; }
|
|
28
|
+
.ml-tabs__tab[aria-current="page"] { color: #1d4f80; background: #fff; border-color: #d7d7d7; font-weight: 600; margin-bottom: -1px; }
|
|
29
|
+
|
|
25
30
|
.ml-grid {
|
|
26
31
|
display: grid;
|
|
27
32
|
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
|
@@ -42,6 +47,17 @@
|
|
|
42
47
|
}
|
|
43
48
|
.ml-tile:hover { border-color: #2b6cb0; box-shadow: 0 0 0 2px rgba(43,108,176,.2); }
|
|
44
49
|
.ml-tile img { width: 100%; height: 120px; object-fit: cover; display: block; background: #fafafa; }
|
|
50
|
+
.ml-tile__document {
|
|
51
|
+
display: grid;
|
|
52
|
+
place-items: center;
|
|
53
|
+
width: 100%;
|
|
54
|
+
height: 120px;
|
|
55
|
+
background: #f8fafc;
|
|
56
|
+
color: #b42318;
|
|
57
|
+
font-size: 1.05rem;
|
|
58
|
+
font-weight: 700;
|
|
59
|
+
letter-spacing: .08em;
|
|
60
|
+
}
|
|
45
61
|
.ml-tile__name {
|
|
46
62
|
font-size: .75rem;
|
|
47
63
|
padding: .35rem .4rem;
|
|
@@ -51,6 +67,13 @@
|
|
|
51
67
|
text-overflow: ellipsis;
|
|
52
68
|
}
|
|
53
69
|
|
|
70
|
+
/* Disabled tiles: assets whose type the opening field does not accept. */
|
|
71
|
+
.ml-tile--disabled { cursor: not-allowed; opacity: .4; filter: grayscale(1); }
|
|
72
|
+
.ml-tile--disabled:hover { border-color: #e2e2e2; box-shadow: none; }
|
|
73
|
+
|
|
74
|
+
/* Category tabs inside the modal sit just under the upload toolbar. */
|
|
75
|
+
.ml-picker__tabs { margin-top: .25rem; }
|
|
76
|
+
|
|
54
77
|
.ml-empty { color: #777; padding: 1rem; text-align: center; }
|
|
55
78
|
.ml-pagination { display: flex; gap: .5rem; justify-content: center; }
|
|
56
79
|
|
|
@@ -80,3 +103,12 @@
|
|
|
80
103
|
.ml-picker__toolbar { display: flex; align-items: center; gap: 1rem; margin-bottom: .5rem; }
|
|
81
104
|
.ml-picker__hint { color: #666; font-size: .85rem; }
|
|
82
105
|
.ml-picker__more { text-align: center; }
|
|
106
|
+
|
|
107
|
+
/* Administrator configuration overview */
|
|
108
|
+
.ml-settings__intro { max-width: 54rem; color: #555; }
|
|
109
|
+
.ml-settings__section { margin-top: 1.5rem; }
|
|
110
|
+
.ml-settings__section h2 { font-size: 1.05rem; margin-bottom: .5rem; }
|
|
111
|
+
.ml-settings__list { display: grid; grid-template-columns: minmax(10rem, 16rem) 1fr; gap: .5rem 1rem; margin: 0; }
|
|
112
|
+
.ml-settings__list dt { font-weight: 600; }
|
|
113
|
+
.ml-settings__list dd { margin: 0; }
|
|
114
|
+
.ml-settings__types { margin: 0; padding-left: 1.25rem; }
|