Skip to content

Commit 920156a

Browse files
authored
Feat: S3 Compatible Protocol (#444)
* feat: s3 protocol
1 parent 73a31f7 commit 920156a

File tree

99 files changed

+6644
-670
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

99 files changed

+6644
-670
lines changed

.env.sample

+8
Original file line numberDiff line numberDiff line change
@@ -65,10 +65,18 @@ UPLOAD_FILE_SIZE_LIMIT=524288000
6565
UPLOAD_FILE_SIZE_LIMIT_STANDARD=52428800
6666
UPLOAD_SIGNED_URL_EXPIRATION_TIME=60
6767

68+
#######################################
69+
# TUS Protocol
70+
#######################################
6871
TUS_URL_PATH=/upload/resumable
6972
TUS_URL_EXPIRY_MS=3600000
7073
TUS_PART_SIZE=50
7174

75+
#######################################
76+
# S3 Protocol
77+
#######################################
78+
S3_PROTOCOL_ACCESS_KEY_ID=b585f311d839730f8a980a3457be2787
79+
S3_PROTOCOL_ACCESS_KEY_SECRET=67d161a7a8a46a24a17a75b26e7724f11d56b8d49a119227c66b13b6595601fb
7280

7381
#######################################
7482
# Storage Backend Driver

.env.test.sample

+6-1
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,12 @@ AUTHENTICATED_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJhdXRoZW50aWNhd
22
ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlhdCI6MTYxMzUzMTk4NSwiZXhwIjoxOTI5MTA3OTg1fQ.mqfi__KnQB4v6PkIjkhzfwWrYyF94MEbSC6LnuvVniE
33
SERVICE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoic2VydmljZV9yb2xlIiwiaWF0IjoxNjEzNTMxOTg1LCJleHAiOjE5MjkxMDc5ODV9.th84OKK0Iz8QchDyXZRrojmKSEZ-OuitQm_5DvLiSIc
44

5+
S3_PROTOCOL_ACCESS_KEY_ID=b585f311d839730f8a980a3457be2787
6+
S3_PROTOCOL_ACCESS_KEY_SECRET=67d161a7a8a46a24a17a75b26e7724f11d56b8d49a119227c66b13b6595601fb
7+
S3_PROTOCOL_ALLOWS_SERVICE_KEY_AS_SECRET=false
8+
59
TENANT_ID=bjhaohmqunupljrqypxz
6-
ENABLE_DEFAULT_METRICS=false
10+
DEFAULT_METRICS_ENABLED=false
711
PG_QUEUE_ENABLE=false
812
MULTI_TENANT=false
913
ADMIN_API_KEYS=apikey
@@ -18,3 +22,4 @@ AWS_DEFAULT_REGION=ap-southeast-1
1822
STORAGE_S3_ENDPOINT=http://127.0.0.1:9000
1923
STORAGE_S3_PROTOCOL=http
2024
STORAGE_S3_FORCE_PATH_STYLE=true
25+
REQUEST_X_FORWARDED_HOST_REGEXP=

.github/workflows/ci.yml

+3-3
Original file line numberDiff line numberDiff line change
@@ -56,16 +56,13 @@ jobs:
5656
SERVICE_KEY: ${{ secrets.SERVICE_KEY }}
5757
TENANT_ID: ${{ secrets.TENANT_ID }}
5858
REGION: ${{ secrets.REGION }}
59-
POSTGREST_URL: ${{ secrets.POSTGREST_URL }}
6059
GLOBAL_S3_BUCKET: ${{ secrets.GLOBAL_S3_BUCKET }}
6160
PGRST_JWT_SECRET: ${{ secrets.PGRST_JWT_SECRET }}
6261
AUTHENTICATED_KEY: ${{ secrets.AUTHENTICATED_KEY }}
6362
DATABASE_URL: postgresql://postgres:[email protected]/postgres
64-
PGOPTIONS: -c search_path=storage,public
6563
FILE_SIZE_LIMIT: '52428800'
6664
STORAGE_BACKEND: s3
6765
MULTITENANT_DATABASE_URL: postgresql://postgres:[email protected]:5433/postgres
68-
POSTGREST_URL_SUFFIX: /rest/v1
6966
ADMIN_API_KEYS: apikey
7067
ENABLE_IMAGE_TRANSFORMATION: true
7168
IMGPROXY_URL: http://127.0.0.1:50020
@@ -79,6 +76,9 @@ jobs:
7976
ENABLE_DEFAULT_METRICS: false
8077
PG_QUEUE_ENABLE: false
8178
MULTI_TENANT: false
79+
S3_PROTOCOL_ACCESS_KEY_ID: ${{ secrets.TENANT_ID }}
80+
S3_PROTOCOL_ACCESS_KEY_SECRET: ${{ secrets.SERVICE_KEY }}
81+
8282

8383
- name: Upload coverage results to Coveralls
8484
uses: coverallsapp/github-action@master

README.md

+9-1
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,19 @@ A scalable, light-weight object storage service.
66

77
> Read [this post](https://supabase.io/blog/2021/03/30/supabase-storage) on why we decided to build a new object storage service.
88
9+
- Multi-protocol support (HTTP, TUS, S3)
910
- Uses Postgres as its datastore for storing metadata
1011
- Authorization rules are written as Postgres Row Level Security policies
11-
- Integrates with S3 as the storage backend (with more in the pipeline!)
12+
- Integrates with S3 Compatible Storages
1213
- Extremely lightweight and performant
1314

15+
16+
**Supported Protocols**
17+
18+
- [x] HTTP/REST
19+
- [x] TUS Resumable Upload
20+
- [x] S3 Compatible API
21+
1422
![Architecture](./static/architecture.png?raw=true 'Architecture')
1523

1624
## Documentation

docker-compose.yml

+1-2
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ services:
66
image: supabase/storage-api:latest
77
ports:
88
- '5000:5000'
9-
- '5001:5001'
109
depends_on:
1110
tenant_db:
1211
condition: service_healthy
@@ -39,7 +38,7 @@ services:
3938
UPLOAD_SIGNED_URL_EXPIRATION_TIME: 120
4039
TUS_URL_PATH: /upload/resumable
4140
TUS_URL_EXPIRY_MS: 3600000
42-
# Image Tranformation
41+
# Image Transformation
4342
IMAGE_TRANSFORMATION_ENABLED: "true"
4443
IMGPROXY_URL: http://imgproxy:8080
4544
IMGPROXY_REQUEST_TIMEOUT: 15

jest-setup.ts

+4
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
11
import { getConfig, setEnvPaths } from './src/config'
22

33
setEnvPaths(['.env.test', '.env'])
4+
5+
beforeEach(() => {
6+
getConfig({ reload: true })
7+
})

jest.config.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ module.exports = {
44
transform: {
55
'^.+\\.(t|j)sx?$': 'ts-jest',
66
},
7-
setupFiles: ['<rootDir>/jest-setup.ts'],
7+
setupFilesAfterEnv: ['<rootDir>/jest-setup.ts'],
88
testEnvironment: 'node',
99
testPathIgnorePatterns: ['node_modules', 'dist'],
1010
coverageProvider: 'v8',

jest.sequencer.js

+7-1
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,21 @@ const isTusTest = (test) => {
1111
return test.path.includes('tus')
1212
}
1313

14+
const isS3Test = (test) => {
15+
return test.path.includes('s3')
16+
}
17+
1418
class CustomSequencer extends Sequencer {
1519
sort(tests) {
1620
const copyTests = Array.from(tests)
17-
const normalTests = copyTests.filter((t) => !isRLSTest(t) && !isTusTest(t))
21+
const normalTests = copyTests.filter((t) => !isRLSTest(t) && !isTusTest(t) && !isS3Test(t))
1822
const tusTests = copyTests.filter((t) => isTusTest(t))
23+
const s3Tests = copyTests.filter((t) => isS3Test(t))
1924
const rlsTests = copyTests.filter((t) => isRLSTest(t))
2025
return super
2126
.sort(normalTests)
2227
.concat(tusTests)
28+
.concat(s3Tests)
2329
.concat(rlsTests.sort((a, b) => (a.path > b.path ? 1 : -1)))
2430
}
2531
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
2+
3+
CREATE TABLE IF NOT EXISTS tenants_s3_credentials (
4+
id UUID PRIMARY KEY default gen_random_uuid(),
5+
description text NOT NULL,
6+
tenant_id text REFERENCES tenants(id) ON DELETE CASCADE,
7+
access_key text NOT NULL,
8+
secret_key text NOT NULL,
9+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
10+
);
11+
12+
CREATE INDEX IF NOT EXISTS tenants_s3_credentials_tenant_id_idx ON tenants_s3_credentials(tenant_id);
13+
CREATE UNIQUE INDEX IF NOT EXISTS tenants_s3_credentials_access_key_idx ON tenants_s3_credentials(tenant_id, access_key);
14+
15+
16+
CREATE OR REPLACE FUNCTION tenants_s3_credentials_update_notify_trigger ()
17+
RETURNS TRIGGER
18+
AS $$
19+
BEGIN
20+
PERFORM
21+
pg_notify('tenants_s3_credentials_update', '"' || NEW.id || ':' || NEW.access_key || '"');
22+
RETURN NULL;
23+
END;
24+
$$
25+
LANGUAGE plpgsql;
26+
27+
CREATE OR REPLACE FUNCTION tenants_s3_credentials_delete_notify_trigger ()
28+
RETURNS TRIGGER
29+
AS $$
30+
BEGIN
31+
PERFORM
32+
pg_notify('tenants_s3_credentials_update', '"' || OLD.id || ':' || OLD.access_key || '"');
33+
RETURN NULL;
34+
END;
35+
$$
36+
LANGUAGE plpgsql;
37+
38+
CREATE TRIGGER tenants_s3_credentials_update_notify_trigger
39+
AFTER UPDATE ON tenants_s3_credentials
40+
FOR EACH ROW
41+
EXECUTE PROCEDURE tenants_s3_credentials_update_notify_trigger ();
42+
43+
CREATE TRIGGER tenants_s3_credentials_delete_notify_trigger
44+
AFTER DELETE ON tenants_s3_credentials
45+
FOR EACH ROW
46+
EXECUTE PROCEDURE tenants_s3_credentials_delete_notify_trigger ();
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
2+
3+
ALTER TABLE tenants_s3_credentials ADD COLUMN claims json NOT NULL DEFAULT '{}';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
2+
3+
CREATE OR REPLACE FUNCTION storage.list_objects_with_delimiter(bucket_id text, prefix_param text, delimiter_param text, max_keys integer default 100, start_after text DEFAULT '', next_token text DEFAULT '')
4+
RETURNS TABLE (name text, id uuid, metadata jsonb, updated_at timestamptz) AS
5+
$$
6+
BEGIN
7+
RETURN QUERY EXECUTE
8+
'SELECT DISTINCT ON(name COLLATE "C") * from (
9+
SELECT
10+
CASE
11+
WHEN position($2 IN substring(name from length($1) + 1)) > 0 THEN
12+
substring(name from 1 for length($1) + position($2 IN substring(name from length($1) + 1)))
13+
ELSE
14+
name
15+
END AS name, id, metadata, updated_at
16+
FROM
17+
storage.objects
18+
WHERE
19+
bucket_id = $5 AND
20+
name ILIKE $1 || ''%'' AND
21+
CASE
22+
WHEN $6 != '''' THEN
23+
name COLLATE "C" > $6
24+
ELSE true END
25+
AND CASE
26+
WHEN $4 != '''' THEN
27+
CASE
28+
WHEN position($2 IN substring(name from length($1) + 1)) > 0 THEN
29+
substring(name from 1 for length($1) + position($2 IN substring(name from length($1) + 1))) COLLATE "C" > $4
30+
ELSE
31+
name COLLATE "C" > $4
32+
END
33+
ELSE
34+
true
35+
END
36+
ORDER BY
37+
name COLLATE "C" ASC) as e order by name COLLATE "C" LIMIT $3'
38+
USING prefix_param, delimiter_param, max_keys, next_token, bucket_id, start_after;
39+
END;
40+
$$ LANGUAGE plpgsql;
41+
42+
CREATE INDEX IF NOT EXISTS idx_objects_bucket_id_name
43+
ON storage.objects (bucket_id, (name COLLATE "C"));
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
CREATE TABLE IF NOT EXISTS storage.s3_multipart_uploads (
2+
id text PRIMARY KEY,
3+
in_progress_size int NOT NULL default 0,
4+
upload_signature text NOT NULL,
5+
bucket_id text NOT NULL references storage.buckets(id),
6+
key text COLLATE "C" NOT NULL ,
7+
version text NOT NULL,
8+
owner_id text NULL,
9+
created_at timestamptz NOT NULL default now()
10+
);
11+
12+
CREATE TABLE IF NOT EXISTS storage.s3_multipart_uploads_parts (
13+
id uuid PRIMARY KEY default gen_random_uuid(),
14+
upload_id text NOT NULL references storage.s3_multipart_uploads(id) ON DELETE CASCADE,
15+
size int NOT NULL default 0,
16+
part_number int NOT NULL,
17+
bucket_id text NOT NULL references storage.buckets(id),
18+
key text COLLATE "C" NOT NULL,
19+
etag text NOT NULL,
20+
owner_id text NULL,
21+
version text NOT NULL,
22+
created_at timestamptz NOT NULL default now()
23+
);
24+
25+
CREATE INDEX IF NOT EXISTS idx_multipart_uploads_list
26+
ON storage.s3_multipart_uploads (bucket_id, (key COLLATE "C"), created_at ASC);
27+
28+
CREATE OR REPLACE FUNCTION storage.list_multipart_uploads_with_delimiter(bucket_id text, prefix_param text, delimiter_param text, max_keys integer default 100, next_key_token text DEFAULT '', next_upload_token text default '')
29+
RETURNS TABLE (key text, id text, created_at timestamptz) AS
30+
$$
31+
BEGIN
32+
RETURN QUERY EXECUTE
33+
'SELECT DISTINCT ON(key COLLATE "C") * from (
34+
SELECT
35+
CASE
36+
WHEN position($2 IN substring(key from length($1) + 1)) > 0 THEN
37+
substring(key from 1 for length($1) + position($2 IN substring(key from length($1) + 1)))
38+
ELSE
39+
key
40+
END AS key, id, created_at
41+
FROM
42+
storage.s3_multipart_uploads
43+
WHERE
44+
bucket_id = $5 AND
45+
key ILIKE $1 || ''%'' AND
46+
CASE
47+
WHEN $4 != '''' AND $6 = '''' THEN
48+
CASE
49+
WHEN position($2 IN substring(key from length($1) + 1)) > 0 THEN
50+
substring(key from 1 for length($1) + position($2 IN substring(key from length($1) + 1))) COLLATE "C" > $4
51+
ELSE
52+
key COLLATE "C" > $4
53+
END
54+
ELSE
55+
true
56+
END AND
57+
CASE
58+
WHEN $6 != '''' THEN
59+
id COLLATE "C" > $6
60+
ELSE
61+
true
62+
END
63+
ORDER BY
64+
key COLLATE "C" ASC, created_at ASC) as e order by key COLLATE "C" LIMIT $3'
65+
USING prefix_param, delimiter_param, max_keys, next_key_token, bucket_id, next_upload_token;
66+
END;
67+
$$ LANGUAGE plpgsql;
68+
69+
ALTER TABLE storage.s3_multipart_uploads ENABLE ROW LEVEL SECURITY;
70+
ALTER TABLE storage.s3_multipart_uploads_parts ENABLE ROW LEVEL SECURITY;
71+
72+
DO $$
73+
DECLARE
74+
anon_role text = COALESCE(current_setting('storage.anon_role', true), 'anon');
75+
authenticated_role text = COALESCE(current_setting('storage.authenticated_role', true), 'authenticated');
76+
service_role text = COALESCE(current_setting('storage.service_role', true), 'service_role');
77+
BEGIN
78+
EXECUTE 'revoke all on storage.s3_multipart_uploads from ' || anon_role || ', ' || authenticated_role;
79+
EXECUTE 'revoke all on storage.s3_multipart_uploads_parts from ' || anon_role || ', ' || authenticated_role;
80+
EXECUTE 'GRANT ALL ON TABLE storage.s3_multipart_uploads TO ' || service_role;
81+
EXECUTE 'GRANT ALL ON TABLE storage.s3_multipart_uploads_parts TO ' || service_role;
82+
EXECUTE 'GRANT SELECT ON TABLE storage.s3_multipart_uploads TO ' || authenticated_role || ', ' || anon_role;
83+
EXECUTE 'GRANT SELECT ON TABLE storage.s3_multipart_uploads_parts TO ' || authenticated_role || ', ' || anon_role;
84+
END$$;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
ALTER TABLE storage.s3_multipart_uploads ALTER COLUMN in_progress_size TYPE bigint;
2+
ALTER TABLE storage.s3_multipart_uploads_parts ALTER COLUMN size TYPE bigint;

0 commit comments

Comments
 (0)