@plutocms/supabase 0.15.5 → 0.17.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.17.0](https://github.com/plutocms/supabase/compare/v0.16.0...v0.17.0) (2026-10-30)
4
+
5
+
6
+ ### Features
7
+
8
+ * media previews, sort and rename in the Supabase driver ([#139](https://github.com/plutocms/supabase/issues/139)) ([8cdf071](https://github.com/plutocms/supabase/commit/8cdf0715c5a11db8e99423751708d2ab0feea455))
9
+
10
+ ## [0.16.0](https://github.com/plutocms/supabase/compare/v0.15.5...v0.16.0) (2026-10-30)
11
+
12
+
13
+ ### Features
14
+
15
+ * the Supabase driver of the core media library ([#135](https://github.com/plutocms/supabase/issues/135)) ([94aa31a](https://github.com/plutocms/supabase/commit/94aa31a5b13ed3866e17a9e074876aa79f2bdba8))
16
+
3
17
  ## [0.15.5](https://github.com/plutocms/supabase/compare/v0.15.4...v0.15.5) (2026-10-29)
4
18
 
5
19
 
@@ -0,0 +1,95 @@
1
+ -- The media library of core (the media skill of @plutocms/pluto), on Supabase.
2
+ -- The table and the bucket were in @plutocms/supabase-storage, which retires.
3
+ -- Every statement is idempotent, so a database with the old storage layer and a
4
+ -- new database get the same result.
5
+
6
+ create table if not exists public.media (
7
+ id bigint generated by default as identity not null,
8
+ created_at timestamp with time zone not null default now(),
9
+ name character varying null, -- the original file name, for people
10
+ alt character varying null,
11
+ storage_path character varying null, -- the object key in `bucket`
12
+ mime_type character varying null,
13
+ size bigint null, -- in bytes
14
+ constraint media_pkey primary key (id)
15
+ );
16
+
17
+ -- The bucket of each file. New files go to `media`. Older files can stay in
18
+ -- another bucket (the shop's `product-media`), so no bytes move.
19
+ alter table public.media add column if not exists bucket text not null default 'media';
20
+ alter table public.media add column if not exists width integer null;
21
+ alter table public.media add column if not exists height integer null;
22
+
23
+ create index if not exists media_created_at_idx on public.media (created_at desc);
24
+
25
+ alter table public.media enable row level security;
26
+
27
+ drop policy if exists "media_select_public" on public.media;
28
+ create policy "media_select_public"
29
+ on public.media for select
30
+ using (true);
31
+
32
+ drop policy if exists "media_insert_authenticated" on public.media;
33
+ create policy "media_insert_authenticated"
34
+ on public.media for insert
35
+ with check (public.has_capability('media:manage'));
36
+
37
+ drop policy if exists "media_update_authenticated" on public.media;
38
+ create policy "media_update_authenticated"
39
+ on public.media for update
40
+ using (public.has_capability('media:manage'))
41
+ with check (public.has_capability('media:manage'));
42
+
43
+ drop policy if exists "media_delete_authenticated" on public.media;
44
+ create policy "media_delete_authenticated"
45
+ on public.media for delete
46
+ using (public.has_capability('media:delete'));
47
+
48
+ grant select on public.media to anon;
49
+ grant select, insert, update, delete on public.media to authenticated;
50
+ grant select, insert, update, delete on public.media to service_role;
51
+
52
+ -- The bucket: public read. A file name never changes, so it can be cached for ever.
53
+ insert into storage.buckets (id, name, public)
54
+ values ('media', 'media', true)
55
+ on conflict (id) do nothing;
56
+
57
+ drop policy if exists "media: public read" on storage.objects;
58
+ create policy "media: public read"
59
+ on storage.objects for select
60
+ using (bucket_id = 'media');
61
+
62
+ drop policy if exists "media: admin insert" on storage.objects;
63
+ create policy "media: admin insert"
64
+ on storage.objects for insert
65
+ to authenticated
66
+ with check (bucket_id = 'media' and public.has_capability('media:manage'));
67
+
68
+ drop policy if exists "media: admin update" on storage.objects;
69
+ create policy "media: admin update"
70
+ on storage.objects for update
71
+ to authenticated
72
+ using (bucket_id = 'media' and public.has_capability('media:manage'))
73
+ with check (bucket_id = 'media' and public.has_capability('media:manage'));
74
+
75
+ drop policy if exists "media: admin delete" on storage.objects;
76
+ create policy "media: admin delete"
77
+ on storage.objects for delete
78
+ to authenticated
79
+ using (bucket_id = 'media' and public.has_capability('media:delete'));
80
+
81
+ -- Older rows: the type and the size from the stored object. A 3D model often
82
+ -- has no real type in Storage, so `.glb` gets its type from the name.
83
+ update public.media m
84
+ set
85
+ mime_type = coalesce(m.mime_type, nullif(o.metadata ->> 'mimetype', 'application/octet-stream')),
86
+ size = coalesce(m.size, (o.metadata ->> 'size')::bigint)
87
+ from storage.objects o
88
+ where o.bucket_id = m.bucket
89
+ and o.name = m.storage_path
90
+ and (m.mime_type is null or m.size is null);
91
+
92
+ update public.media
93
+ set mime_type = 'model/gltf-binary'
94
+ where storage_path ilike '%.glb'
95
+ and mime_type is distinct from 'model/gltf-binary';
@@ -0,0 +1,7 @@
1
+ -- The preview image of a file that is not an image (a 3D model), made in the
2
+ -- browser by the kind of the file. Always in the `media` bucket, even for an
3
+ -- older file in another bucket. See the media skill of @plutocms/pluto.
4
+
5
+ alter table public.media add column if not exists preview_path text null;
6
+ alter table public.media add column if not exists preview_width integer null;
7
+ alter table public.media add column if not exists preview_height integer null;
package/nuxt.config.ts CHANGED
@@ -5,6 +5,16 @@ import type { PlutoMigrationFile } from './shared/types/migrations'
5
5
  const plutoLayer = process.env.PLUTO_PLUTO_PATH || '@plutocms/pluto'
6
6
  const utilsLayer = process.env.PLUTO_UTILS_PATH || '@plutocms/utils'
7
7
 
8
+ // The host of the Supabase project, at build time. `@nuxt/image` may only read
9
+ // remote images of the hosts in `image.domains` (the media files are in Storage).
10
+ function supabaseHost(): string {
11
+ try {
12
+ return new URL(process.env.SUPABASE_URL ?? '').host
13
+ } catch {
14
+ return ''
15
+ }
16
+ }
17
+
8
18
  export default defineNuxtConfig({
9
19
  extends: [[plutoLayer, { install: true }], utilsLayer],
10
20
 
@@ -22,6 +32,10 @@ export default defineNuxtConfig({
22
32
  supabaseKey: process.env.SUPABASE_KEY,
23
33
  plutoRootDir: '',
24
34
  plutoLayerMigrations: {} as Record<string, PlutoMigrationFile[]>,
35
+ public: {
36
+ // The core media library uses the Supabase driver (server/utils/media-driver.ts).
37
+ plutoMedia: { driver: 'supabase' },
38
+ },
25
39
  },
26
40
 
27
41
  alias: {
@@ -45,6 +59,10 @@ export default defineNuxtConfig({
45
59
  },
46
60
  },
47
61
 
62
+ image: {
63
+ domains: supabaseHost() ? [supabaseHost()] : [],
64
+ },
65
+
48
66
  supabase: {
49
67
  types: '#shared/types/supabase',
50
68
  cookiePrefix: 'access_token',
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@plutocms/supabase",
3
3
  "type": "module",
4
- "version": "0.15.5",
4
+ "version": "0.17.0",
5
5
  "trustedDependencies": [
6
6
  "@parcel/watcher",
7
7
  "@plutocms/pluto",
@@ -39,7 +39,7 @@
39
39
  "@nuxt/eslint": "^1.17.0",
40
40
  "@nuxt/ui": "^4.12.0",
41
41
  "@nuxtjs/supabase": "^2.1.0",
42
- "@plutocms/pluto": ">=0.34.0 <1.0.0",
42
+ "@plutocms/pluto": ">=0.36.0 <1.0.0",
43
43
  "@plutocms/utils": ">=0.3.0 <1.0.0",
44
44
  "@supabase/supabase-js": "^2.117.3",
45
45
  "postgres": "^3.4.9",
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Registers the Supabase media driver with the core media library, at Nitro
3
+ * startup. See the media skill of `@plutocms/pluto`.
4
+ */
5
+ export default defineNitroPlugin(() => {
6
+ registerMediaDriver(createSupabaseMediaDriver())
7
+ })
@@ -39,6 +39,13 @@ async function getClient(event: H3Event): Promise<SupabaseClient> {
39
39
  return (await serverSupabaseClient(event)) as unknown as SupabaseClient
40
40
  }
41
41
 
42
+ /**
43
+ * The client of a request: the anonymous client for a public read (no
44
+ * cookie, the same answer for everyone), else the client of the signed-in user.
45
+ * The media driver uses it too.
46
+ */
47
+ export const supabaseRequestClient = getClient
48
+
42
49
  /** The anonymous client and the url/key pair it was built from. */
43
50
  let anonymousClient:
44
51
  { url: string; key: string; client: SupabaseClient } | undefined
@@ -0,0 +1,304 @@
1
+ import type { SupabaseClient } from '@supabase/supabase-js'
2
+ import type { H3Event } from 'h3'
3
+ import { requireCapability } from './capability-guard'
4
+ import { supabaseRequestClient } from './content-adapter'
5
+
6
+ /** The bucket of new files. Older files can be in another bucket (`media.bucket`). */
7
+ export const MEDIA_BUCKET = 'media'
8
+
9
+ interface MediaRow {
10
+ id: number
11
+ created_at: string
12
+ name: string | null
13
+ alt: string | null
14
+ bucket: string | null
15
+ storage_path: string | null
16
+ mime_type: string | null
17
+ size: number | null
18
+ width: number | null
19
+ height: number | null
20
+ preview_path: string | null
21
+ preview_width: number | null
22
+ preview_height: number | null
23
+ }
24
+
25
+ /** `<yyyy>/<mm>/<name>`: the object key of a file, by the month of its record. */
26
+ function monthKey(createdAt: string, fileName: string): string {
27
+ const date = new Date(createdAt)
28
+ return `${date.getUTCFullYear()}/${String(date.getUTCMonth() + 1).padStart(2, '0')}/${fileName}`
29
+ }
30
+
31
+ /** The order of a list. The id breaks a tie, so pages stay stable. */
32
+ const ORDER: Record<PlutoMediaSort, { column: string; ascending: boolean }> = {
33
+ newest: { column: 'created_at', ascending: false },
34
+ oldest: { column: 'created_at', ascending: true },
35
+ name: { column: 'name', ascending: true },
36
+ size: { column: 'size', ascending: false },
37
+ }
38
+
39
+ function toRecord(client: SupabaseClient, row: MediaRow): PlutoMedia {
40
+ const bucket = row.bucket || MEDIA_BUCKET
41
+ return {
42
+ id: String(row.id),
43
+ url: row.storage_path
44
+ ? client.storage.from(bucket).getPublicUrl(row.storage_path).data
45
+ .publicUrl
46
+ : '',
47
+ name: row.name ?? '',
48
+ mimeType: row.mime_type ?? 'application/octet-stream',
49
+ size: Number(row.size ?? 0),
50
+ width: row.width,
51
+ height: row.height,
52
+ alt: row.alt ?? '',
53
+ createdAt: row.created_at,
54
+ // A preview is always in the `media` bucket.
55
+ preview:
56
+ row.preview_path && row.preview_width && row.preview_height
57
+ ? {
58
+ url: client.storage.from(MEDIA_BUCKET).getPublicUrl(row.preview_path)
59
+ .data.publicUrl,
60
+ width: row.preview_width,
61
+ height: row.preview_height,
62
+ }
63
+ : null,
64
+ }
65
+ }
66
+
67
+ function failed(message: string, key: string): Error {
68
+ console.error('[media] Supabase answered with an error:', message)
69
+ return createPlutoError({ statusCode: 500, message: 'The media library failed.', key })
70
+ }
71
+
72
+ function notFound(): Error {
73
+ return createPlutoError({
74
+ statusCode: 404,
75
+ message: 'The file does not exist.',
76
+ key: 'pluto.media.errors.notFound',
77
+ })
78
+ }
79
+
80
+ function numericIds(ids: string[]): number[] {
81
+ return ids.map(Number).filter((id) => Number.isSafeInteger(id) && id > 0)
82
+ }
83
+
84
+ /**
85
+ * The media driver of the Supabase backend: the bytes in Supabase Storage,
86
+ * the record in `public.media` (db/migrations/009_media.sql). Row Level
87
+ * Security checks the capabilities again (`has_capability`). New files go to
88
+ * `media/<yyyy>/<mm>/<name>-<suffix>.<ext>`, and their previews next to them
89
+ * (`db/migrations/010_media_preview.sql`). See the media skill of
90
+ * `@plutocms/pluto`.
91
+ */
92
+ export function createSupabaseMediaDriver(): PlutoMediaDriver {
93
+ async function readRow(client: SupabaseClient, id: string): Promise<MediaRow> {
94
+ const [numeric] = numericIds([id])
95
+ if (!numeric) {
96
+ throw notFound()
97
+ }
98
+ const { data, error } = await client
99
+ .from('media')
100
+ .select('*')
101
+ .eq('id', numeric)
102
+ .maybeSingle()
103
+ if (error) {
104
+ throw failed(error.message, 'pluto.media.errors.saveFailed')
105
+ }
106
+ if (!data) {
107
+ throw notFound()
108
+ }
109
+ return data as MediaRow
110
+ }
111
+
112
+ return {
113
+ name: 'supabase',
114
+
115
+ async authorize(event: H3Event, capability) {
116
+ await requireCapability(event, capability)
117
+ },
118
+
119
+ async list(event, query) {
120
+ const client = await supabaseRequestClient(event)
121
+ const order = ORDER[query.sort ?? 'newest']
122
+ let request = client
123
+ .from('media')
124
+ .select('*', { count: 'exact' })
125
+ .order(order.column, { ascending: order.ascending, nullsFirst: false })
126
+ .order('id', { ascending: order.ascending })
127
+ .range(query.offset, query.offset + query.limit - 1)
128
+ if (query.search) {
129
+ // Commas and brackets have a meaning in a PostgREST `or` filter.
130
+ const text = query.search.replace(/[,()*%\\]/g, ' ').trim()
131
+ if (text) {
132
+ request = request.or(`name.ilike.%${text}%,alt.ilike.%${text}%`)
133
+ }
134
+ }
135
+ if (query.type === 'image') {
136
+ request = request.like('mime_type', 'image/%')
137
+ } else if (query.type?.endsWith('/') || query.type?.endsWith('/*')) {
138
+ request = request.like('mime_type', `${query.type.replace(/\*$/, '')}%`)
139
+ } else if (query.type) {
140
+ request = request.eq('mime_type', query.type)
141
+ }
142
+ const { data, error, count } = await request
143
+ if (error) {
144
+ throw failed(error.message, 'pluto.media.loadFailed')
145
+ }
146
+ return {
147
+ data: (data as MediaRow[]).map((row) => toRecord(client, row)),
148
+ total: count ?? data.length,
149
+ }
150
+ },
151
+
152
+ async get(event, ids) {
153
+ const wanted = numericIds(ids)
154
+ if (wanted.length === 0) {
155
+ return []
156
+ }
157
+ const client = await supabaseRequestClient(event)
158
+ const { data, error } = await client.from('media').select('*').in('id', wanted)
159
+ if (error) {
160
+ throw failed(error.message, 'pluto.media.loadFailed')
161
+ }
162
+ return (data as MediaRow[]).map((row) => toRecord(client, row))
163
+ },
164
+
165
+ async upload(event, input) {
166
+ const client = await supabaseRequestClient(event)
167
+ const path = monthKey(input.createdAt, input.fileName)
168
+ const previewPath = input.preview
169
+ ? monthKey(input.createdAt, input.preview.fileName)
170
+ : null
171
+ const storage = client.storage.from(MEDIA_BUCKET)
172
+ const { error: uploadError } = await storage.upload(path, input.data, {
173
+ contentType: input.mimeType,
174
+ cacheControl: '31536000',
175
+ upsert: false,
176
+ })
177
+ if (uploadError) {
178
+ throw failed(uploadError.message, 'pluto.media.errors.saveFailed')
179
+ }
180
+ if (input.preview && previewPath) {
181
+ const { error: previewError } = await storage.upload(
182
+ previewPath,
183
+ input.preview.data,
184
+ { contentType: input.preview.mimeType, cacheControl: '31536000', upsert: true }
185
+ )
186
+ if (previewError) {
187
+ await storage.remove([path])
188
+ throw failed(previewError.message, 'pluto.media.errors.saveFailed')
189
+ }
190
+ }
191
+ const { data, error } = await client
192
+ .from('media')
193
+ .insert({
194
+ name: input.name,
195
+ alt: input.alt || null,
196
+ bucket: MEDIA_BUCKET,
197
+ storage_path: path,
198
+ mime_type: input.mimeType,
199
+ size: input.size,
200
+ width: input.width,
201
+ height: input.height,
202
+ created_at: input.createdAt,
203
+ preview_path: previewPath,
204
+ preview_width: input.preview?.width ?? null,
205
+ preview_height: input.preview?.height ?? null,
206
+ })
207
+ .select('*')
208
+ .single()
209
+ if (error) {
210
+ // No record without its file, and no file without its record.
211
+ const { error: cleanup } = await storage.remove(
212
+ previewPath ? [path, previewPath] : [path]
213
+ )
214
+ if (cleanup) {
215
+ console.warn('[media] could not remove the file of a failed upload:', path, cleanup.message)
216
+ }
217
+ throw failed(error.message, 'pluto.media.errors.saveFailed')
218
+ }
219
+ return toRecord(client, data as MediaRow)
220
+ },
221
+
222
+ async update(event, id, patch) {
223
+ const client = await supabaseRequestClient(event)
224
+ const row = await readRow(client, id)
225
+ const changes: Partial<MediaRow> = {}
226
+ if (patch.name !== undefined) {
227
+ changes.name = patch.name
228
+ }
229
+ if (patch.alt !== undefined) {
230
+ changes.alt = patch.alt || null
231
+ }
232
+ if (patch.width !== undefined) {
233
+ changes.width = patch.width
234
+ }
235
+ if (patch.height !== undefined) {
236
+ changes.height = patch.height
237
+ }
238
+ const { data, error } = await client
239
+ .from('media')
240
+ .update(changes)
241
+ .eq('id', row.id)
242
+ .select('*')
243
+ .single()
244
+ if (error) {
245
+ throw failed(error.message, 'pluto.media.errors.saveFailed')
246
+ }
247
+ return toRecord(client, data as MediaRow)
248
+ },
249
+
250
+ async setPreview(event, id, preview) {
251
+ const client = await supabaseRequestClient(event)
252
+ const row = await readRow(client, id)
253
+ const storage = client.storage.from(MEDIA_BUCKET)
254
+ const path = monthKey(row.created_at, preview.fileName)
255
+ const { error: uploadError } = await storage.upload(path, preview.data, {
256
+ contentType: preview.mimeType,
257
+ cacheControl: '31536000',
258
+ upsert: true,
259
+ })
260
+ if (uploadError) {
261
+ throw failed(uploadError.message, 'pluto.media.errors.saveFailed')
262
+ }
263
+ const { data, error } = await client
264
+ .from('media')
265
+ .update({
266
+ preview_path: path,
267
+ preview_width: preview.width,
268
+ preview_height: preview.height,
269
+ })
270
+ .eq('id', row.id)
271
+ .select('*')
272
+ .single()
273
+ if (error) {
274
+ throw failed(error.message, 'pluto.media.errors.saveFailed')
275
+ }
276
+ // An older preview with another name (another type) goes.
277
+ if (row.preview_path && row.preview_path !== path) {
278
+ await storage.remove([row.preview_path])
279
+ }
280
+ return toRecord(client, data as MediaRow)
281
+ },
282
+
283
+ async remove(event, id) {
284
+ const client = await supabaseRequestClient(event)
285
+ const row = await readRow(client, id)
286
+ // The file first: a retry after a failed delete of the row then finds no file, which is no error.
287
+ if (row.storage_path) {
288
+ const { error } = await client.storage
289
+ .from(row.bucket || MEDIA_BUCKET)
290
+ .remove([row.storage_path])
291
+ if (error) {
292
+ throw failed(error.message, 'pluto.media.errors.deleteFailed')
293
+ }
294
+ }
295
+ if (row.preview_path) {
296
+ await client.storage.from(MEDIA_BUCKET).remove([row.preview_path])
297
+ }
298
+ const { error } = await client.from('media').delete().eq('id', row.id)
299
+ if (error) {
300
+ throw failed(error.message, 'pluto.media.errors.deleteFailed')
301
+ }
302
+ },
303
+ }
304
+ }