@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 +14 -0
- package/db/migrations/009_media.sql +95 -0
- package/nuxt.config.ts +18 -0
- package/package.json +3 -3
- package/server/plugins/media-driver.ts +7 -0
- package/server/utils/content-adapter.ts +7 -0
- package/server/utils/media-driver.ts +218 -0
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.
|
|
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": "
|
|
43
|
-
"@plutocms/utils": "
|
|
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",
|
|
@@ -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
|
+
}
|