@plutocms/supabase 0.15.4 → 0.16.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.16.0](https://github.com/plutocms/supabase/compare/v0.15.5...v0.16.0) (2026-10-30)
4
+
5
+
6
+ ### Features
7
+
8
+ * the Supabase driver of the core media library ([#135](https://github.com/plutocms/supabase/issues/135)) ([94aa31a](https://github.com/plutocms/supabase/commit/94aa31a5b13ed3866e17a9e074876aa79f2bdba8))
9
+
10
+ ## [0.15.5](https://github.com/plutocms/supabase/compare/v0.15.4...v0.15.5) (2026-10-29)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * **deps:** accept every 0.x version of the [@plutocms](https://github.com/plutocms) packages; shared CI and the update workflow ([#123](https://github.com/plutocms/supabase/issues/123)) ([7dd582e](https://github.com/plutocms/supabase/commit/7dd582e6ed325ddf0eda62fcf2eed07619e0510a))
16
+
3
17
  ## [0.15.4](https://github.com/plutocms/supabase/compare/v0.15.3...v0.15.4) (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';
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.4",
4
+ "version": "0.16.0",
5
5
  "trustedDependencies": [
6
6
  "@parcel/watcher",
7
7
  "@plutocms/pluto",
@@ -39,8 +39,8 @@
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",
43
- "@plutocms/utils": "^0.3.0",
42
+ "@plutocms/pluto": ">=0.35.0 <1.0.0",
43
+ "@plutocms/utils": ">=0.3.0 <1.0.0",
44
44
  "@supabase/supabase-js": "^2.117.3",
45
45
  "postgres": "^3.4.9",
46
46
  "supabase": "^2.121.0",
@@ -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,218 @@
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
+ }
21
+
22
+ function toRecord(client: SupabaseClient, row: MediaRow): PlutoMedia {
23
+ const bucket = row.bucket || MEDIA_BUCKET
24
+ return {
25
+ id: String(row.id),
26
+ url: row.storage_path
27
+ ? client.storage.from(bucket).getPublicUrl(row.storage_path).data
28
+ .publicUrl
29
+ : '',
30
+ name: row.name ?? '',
31
+ mimeType: row.mime_type ?? 'application/octet-stream',
32
+ size: Number(row.size ?? 0),
33
+ width: row.width,
34
+ height: row.height,
35
+ alt: row.alt ?? '',
36
+ createdAt: row.created_at,
37
+ }
38
+ }
39
+
40
+ function failed(message: string, key: string): Error {
41
+ console.error('[media] Supabase answered with an error:', message)
42
+ return createPlutoError({ statusCode: 500, message: 'The media library failed.', key })
43
+ }
44
+
45
+ function notFound(): Error {
46
+ return createPlutoError({
47
+ statusCode: 404,
48
+ message: 'The file does not exist.',
49
+ key: 'pluto.media.errors.notFound',
50
+ })
51
+ }
52
+
53
+ function numericIds(ids: string[]): number[] {
54
+ return ids.map(Number).filter((id) => Number.isSafeInteger(id) && id > 0)
55
+ }
56
+
57
+ /**
58
+ * The media driver of the Supabase backend: the bytes in Supabase Storage,
59
+ * the record in `public.media` (db/migrations/009_media.sql). Row Level
60
+ * Security checks the capabilities again (`has_capability`). New files go to
61
+ * `media/<yyyy>/<mm>/<name>-<suffix>.<ext>`. See the media skill of
62
+ * `@plutocms/pluto`.
63
+ */
64
+ export function createSupabaseMediaDriver(): PlutoMediaDriver {
65
+ async function readRow(client: SupabaseClient, id: string): Promise<MediaRow> {
66
+ const [numeric] = numericIds([id])
67
+ if (!numeric) {
68
+ throw notFound()
69
+ }
70
+ const { data, error } = await client
71
+ .from('media')
72
+ .select('*')
73
+ .eq('id', numeric)
74
+ .maybeSingle()
75
+ if (error) {
76
+ throw failed(error.message, 'pluto.media.errors.saveFailed')
77
+ }
78
+ if (!data) {
79
+ throw notFound()
80
+ }
81
+ return data as MediaRow
82
+ }
83
+
84
+ return {
85
+ name: 'supabase',
86
+
87
+ async authorize(event: H3Event, capability) {
88
+ await requireCapability(event, capability)
89
+ },
90
+
91
+ async list(event, query) {
92
+ const client = await supabaseRequestClient(event)
93
+ let request = client
94
+ .from('media')
95
+ .select('*', { count: 'exact' })
96
+ .order('created_at', { ascending: false })
97
+ .order('id', { ascending: false })
98
+ .range(query.offset, query.offset + query.limit - 1)
99
+ if (query.search) {
100
+ // Commas and brackets have a meaning in a PostgREST `or` filter.
101
+ const text = query.search.replace(/[,()*%\\]/g, ' ').trim()
102
+ if (text) {
103
+ request = request.or(`name.ilike.%${text}%,alt.ilike.%${text}%`)
104
+ }
105
+ }
106
+ if (query.type === 'image') {
107
+ request = request.like('mime_type', 'image/%')
108
+ } else if (query.type?.endsWith('/') || query.type?.endsWith('/*')) {
109
+ request = request.like('mime_type', `${query.type.replace(/\*$/, '')}%`)
110
+ } else if (query.type) {
111
+ request = request.eq('mime_type', query.type)
112
+ }
113
+ const { data, error, count } = await request
114
+ if (error) {
115
+ throw failed(error.message, 'pluto.media.loadFailed')
116
+ }
117
+ return {
118
+ data: (data as MediaRow[]).map((row) => toRecord(client, row)),
119
+ total: count ?? data.length,
120
+ }
121
+ },
122
+
123
+ async get(event, ids) {
124
+ const wanted = numericIds(ids)
125
+ if (wanted.length === 0) {
126
+ return []
127
+ }
128
+ const client = await supabaseRequestClient(event)
129
+ const { data, error } = await client.from('media').select('*').in('id', wanted)
130
+ if (error) {
131
+ throw failed(error.message, 'pluto.media.loadFailed')
132
+ }
133
+ return (data as MediaRow[]).map((row) => toRecord(client, row))
134
+ },
135
+
136
+ async upload(event, input) {
137
+ const client = await supabaseRequestClient(event)
138
+ const date = new Date(input.createdAt)
139
+ const path = `${date.getUTCFullYear()}/${String(date.getUTCMonth() + 1).padStart(2, '0')}/${input.fileName}`
140
+ const storage = client.storage.from(MEDIA_BUCKET)
141
+ const { error: uploadError } = await storage.upload(path, input.data, {
142
+ contentType: input.mimeType,
143
+ cacheControl: '31536000',
144
+ upsert: false,
145
+ })
146
+ if (uploadError) {
147
+ throw failed(uploadError.message, 'pluto.media.errors.saveFailed')
148
+ }
149
+ const { data, error } = await client
150
+ .from('media')
151
+ .insert({
152
+ name: input.name,
153
+ alt: input.alt || null,
154
+ bucket: MEDIA_BUCKET,
155
+ storage_path: path,
156
+ mime_type: input.mimeType,
157
+ size: input.size,
158
+ width: input.width,
159
+ height: input.height,
160
+ created_at: input.createdAt,
161
+ })
162
+ .select('*')
163
+ .single()
164
+ if (error) {
165
+ // No record without its file, and no file without its record.
166
+ const { error: cleanup } = await storage.remove([path])
167
+ if (cleanup) {
168
+ console.warn('[media] could not remove the file of a failed upload:', path, cleanup.message)
169
+ }
170
+ throw failed(error.message, 'pluto.media.errors.saveFailed')
171
+ }
172
+ return toRecord(client, data as MediaRow)
173
+ },
174
+
175
+ async update(event, id, patch) {
176
+ const client = await supabaseRequestClient(event)
177
+ const row = await readRow(client, id)
178
+ const changes: Partial<MediaRow> = {}
179
+ if (patch.alt !== undefined) {
180
+ changes.alt = patch.alt || null
181
+ }
182
+ if (patch.width !== undefined) {
183
+ changes.width = patch.width
184
+ }
185
+ if (patch.height !== undefined) {
186
+ changes.height = patch.height
187
+ }
188
+ const { data, error } = await client
189
+ .from('media')
190
+ .update(changes)
191
+ .eq('id', row.id)
192
+ .select('*')
193
+ .single()
194
+ if (error) {
195
+ throw failed(error.message, 'pluto.media.errors.saveFailed')
196
+ }
197
+ return toRecord(client, data as MediaRow)
198
+ },
199
+
200
+ async remove(event, id) {
201
+ const client = await supabaseRequestClient(event)
202
+ const row = await readRow(client, id)
203
+ // The file first: a retry after a failed delete of the row then finds no file, which is no error.
204
+ if (row.storage_path) {
205
+ const { error } = await client.storage
206
+ .from(row.bucket || MEDIA_BUCKET)
207
+ .remove([row.storage_path])
208
+ if (error) {
209
+ throw failed(error.message, 'pluto.media.errors.deleteFailed')
210
+ }
211
+ }
212
+ const { error } = await client.from('media').delete().eq('id', row.id)
213
+ if (error) {
214
+ throw failed(error.message, 'pluto.media.errors.deleteFailed')
215
+ }
216
+ },
217
+ }
218
+ }