Skip to content

Commit 159be05

Browse files
authored
Added Google Cloud Storage (GCS) integration for image uploads (#533)
Ref https://linear.app/ghost/issue/AP-1062/ Summary of changes:- - Integrated Google Cloud Storage (GCS) for handling image uploads. - Added a new Docker Compose service fake-gcs to simulate GCS locally. - Configured environment variables in local/test services to connect with the emulator. - Added @google-cloud/storage as a new dependency. - Validation on boot: -- Initializes the GCS client -- Validates and creates the bucket (if running with the emulator) - Introduced a new POST API: ./ghost/activitypub/upload/image -- Validates file type and size -- Uploads the image to the GCS bucket with a unique path -- Returns a public URL to access the image - Added unit tests for the upload logic. - Added new Cucumber step definitions and a test scenario to verify the upload flow end-to-end.
1 parent a186909 commit 159be05

File tree

14 files changed

+643
-33
lines changed

14 files changed

+643
-33
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,3 +75,6 @@ typings/
7575
dist/
7676

7777
cedar/data-generation/output/*
78+
79+
# Fake GCS data
80+
fake-gcs/storage*

docker-compose.yml

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ services:
2020
- MQ_PUBSUB_SUBSCRIPTION_NAME=fedify-subscription
2121
- MQ_PUBSUB_GHOST_TOPIC_NAME=ghost-topic
2222
- MQ_PUBSUB_GHOST_SUBSCRIPTION_NAME=ghost-subscription
23+
- GCP_BUCKET_NAME=activitypub
24+
- GCP_STORAGE_EMULATOR_HOST=http://fake-gcs:4443
2325
- ACTIVITYPUB_COLLECTION_PAGE_SIZE=20
2426
command: yarn build:watch
2527
depends_on:
@@ -29,6 +31,8 @@ services:
2931
condition: service_healthy
3032
pubsub:
3133
condition: service_healthy
34+
fake-gcs:
35+
condition: service_healthy
3236

3337
jaeger:
3438
image: jaegertracing/all-in-one:1.62.0
@@ -99,8 +103,6 @@ services:
99103
retries: 120
100104
start_period: 5s
101105

102-
# Testing
103-
104106
activitypub-testing:
105107
networks:
106108
- test_network
@@ -126,13 +128,17 @@ services:
126128
- MQ_PUBSUB_SUBSCRIPTION_NAME=fedify-subscription
127129
- MQ_PUBSUB_GHOST_TOPIC_NAME=ghost-topic
128130
- MQ_PUBSUB_GHOST_SUBSCRIPTION_NAME=ghost-subscription
131+
- GCP_BUCKET_NAME=activitypub
132+
- GCP_STORAGE_EMULATOR_HOST=http://fake-gcs:4443
129133
- ACTIVITYPUB_COLLECTION_PAGE_SIZE=2
130134
command: yarn build:watch
131135
depends_on:
132136
mysql-testing:
133137
condition: service_healthy
134138
pubsub-testing:
135139
condition: service_healthy
140+
fake-gcs:
141+
condition: service_healthy
136142
healthcheck:
137143
test: "if [ ! -f /tmp/health.txt ]; then (wget --spider http://0.0.0.0:8083/ping || exit 1) && touch /tmp/health.txt ; else echo \"healthcheck already executed\"; fi"
138144
interval: 1s
@@ -165,6 +171,8 @@ services:
165171
- MYSQL_PORT=3306
166172
- MYSQL_DATABASE=activitypub
167173
- NODE_ENV=testing
174+
- GCP_BUCKET_NAME=activitypub
175+
- GCP_STORAGE_EMULATOR_HOST=http://fake-gcs:4443
168176
- TAGS
169177
command: /opt/activitypub/node_modules/.bin/cucumber-js
170178
depends_on:
@@ -174,6 +182,8 @@ services:
174182
condition: service_started
175183
activitypub-testing:
176184
condition: service_healthy
185+
fake-gcs:
186+
condition: service_healthy
177187

178188
mysql-testing:
179189
networks:
@@ -242,6 +252,29 @@ services:
242252
- "8084:8080"
243253
entrypoint: [ "/docker-entrypoint.sh", "--global-response-templating", "--disable-gzip", "--verbose" ]
244254

255+
fake-gcs:
256+
build: fake-gcs
257+
container_name: fake-gcs
258+
ports:
259+
- "4443:4443"
260+
environment:
261+
- GCP_BUCKET_NAME=activitypub
262+
- GCP_PROJECT_ID=activitypub
263+
volumes:
264+
- ./fake-gcs/storage:/storage
265+
networks:
266+
default:
267+
aliases:
268+
- fake-gcs
269+
test_network:
270+
aliases:
271+
- fake-gcs
272+
healthcheck:
273+
test: "curl -f http://localhost:4443/storage/v1/b/${GCP_BUCKET_NAME}"
274+
interval: 1s
275+
retries: 120
276+
start_period: 5s
277+
245278
networks:
246279
test_network:
247280
driver: bridge

fake-gcs/Dockerfile

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
FROM fsouza/fake-gcs-server
2+
3+
# Install curl
4+
RUN apk add --no-cache curl
5+
6+
# Copy the initialization script
7+
COPY start.sh /start.sh
8+
RUN chmod +x /start.sh
9+
10+
# Set the entrypoint
11+
ENTRYPOINT ["/start.sh"]

fake-gcs/start.sh

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
#!/bin/sh
2+
3+
# This script initializes the fake-gcs server and ensures the required bucket exists.
4+
# It starts the server, waits for it to be ready, and creates the bucket if it doesn't exist.
5+
6+
# Ensure storage directory exists and has proper permissions
7+
mkdir -p /storage
8+
chmod 777 /storage
9+
10+
# Start the fake-gcs server in the background
11+
fake-gcs-server -scheme http -port 4443 -external-url http://fake-gcs:4443 -data /storage &
12+
13+
# Wait for the server to be ready
14+
sleep 1
15+
16+
# Check if bucket exists and create if it doesn't
17+
if ! curl -s "http://localhost:4443/storage/v1/b/${GCP_BUCKET_NAME}" | grep -q "\"name\": \"${GCP_BUCKET_NAME}\""; then
18+
curl -X POST \
19+
-H "Content-Type: application/json" \
20+
-d "{\"name\": \"${GCP_BUCKET_NAME}\"}" \
21+
"http://localhost:4443/storage/v1/b?project=${GCP_PROJECT_ID}"
22+
fi
23+
24+
# Keep the container running
25+
tail -f /dev/null

features/step_definitions/stepdefs.js

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -699,6 +699,26 @@ When(
699699
},
700700
);
701701

702+
When(
703+
/^an authenticated (\"(post|put)\"\s)?request is made to "(.*)" with a file$/,
704+
async function (method, path) {
705+
// Create a test file
706+
const file = new File(['test image content'], 'test.jpg', {
707+
type: 'image/jpeg',
708+
});
709+
const formData = new FormData();
710+
formData.append('file', file);
711+
712+
this.response = await fetchActivityPub(
713+
`http://fake-ghost-activitypub.test${path}`,
714+
{
715+
method: method || 'post',
716+
body: formData,
717+
},
718+
);
719+
},
720+
);
721+
702722
When('an unauthenticated request is made to {string}', async function (path) {
703723
this.response = await fetchActivityPub(
704724
`http://fake-ghost-activitypub.test${path}`,
@@ -1992,6 +2012,19 @@ Then('the response contains {string} account details', async function (name) {
19922012
assert.equal(typeof responseJson.followsMe, 'boolean');
19932013
});
19942014

2015+
Then('the response contains a file URL', async function () {
2016+
const responseJson = await this.response.clone().json();
2017+
assert(responseJson.fileUrl, 'Response should contain a fileUrl');
2018+
assert(
2019+
typeof responseJson.fileUrl === 'string',
2020+
'fileUrl should be a string',
2021+
);
2022+
assert(
2023+
responseJson.fileUrl.startsWith('http'),
2024+
'fileUrl should be a valid URL',
2025+
);
2026+
});
2027+
19952028
Then('the response contains the account details:', async function (data) {
19962029
const responseJson = await this.response.clone().json();
19972030

features/upload-image.feature

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
Feature: Image Upload API
2+
As an authenticated user
3+
I want to upload images
4+
5+
Scenario: Upload an image
6+
When an authenticated "post" request is made to "/.ghost/activitypub/upload/image" with a file
7+
Then the request is accepted with a 200
8+
And the response contains a file URL

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060
"@google-cloud/opentelemetry-cloud-trace-exporter": "2.4.1",
6161
"@google-cloud/opentelemetry-cloud-trace-propagator": "0.20.0",
6262
"@google-cloud/pubsub": "4.11.0",
63+
"@google-cloud/storage": "7.16.0",
6364
"@hono/node-server": "1.14.1",
6465
"@js-temporal/polyfill": "0.5.1",
6566
"@logtape/logtape": "0.8.0",

src/app.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ import {
110110
createGetThreadHandler,
111111
createPostPublishedWebhookHandler,
112112
createSearchHandler,
113+
createStorageHandler,
113114
createUpdateAccountHandler,
114115
handleCreateNote,
115116
} from './http/api';
@@ -126,6 +127,7 @@ import {
126127
} from './mq/gcloud-pubsub-push/mq';
127128
import { PostService } from './post/post.service';
128129
import { type Site, SiteService } from './site/site.service';
130+
import { GCPStorageService } from './storage/gcloud-storage/gcp-storage.service';
129131

130132
const logging = getLogger(['activitypub']);
131133

@@ -188,6 +190,19 @@ export type ContextData = {
188190

189191
const fedifyKv = await KnexKvStore.create(client, 'key_value');
190192

193+
const gcpStorageService = new GCPStorageService();
194+
195+
try {
196+
logging.info('Initialising GCP storage service');
197+
await gcpStorageService.init();
198+
logging.info('GCP storage service initialised');
199+
} catch (err) {
200+
logging.error('Failed to initialise GCP storage service {error}', {
201+
error: err,
202+
});
203+
process.exit(1);
204+
}
205+
191206
let queue: GCloudPubSubPushMessageQueue | undefined;
192207

193208
if (process.env.USE_MQ === 'true') {
@@ -1054,6 +1069,11 @@ app.get(
10541069
createGetNotificationsHandler(accountService, notificationService),
10551070
),
10561071
);
1072+
app.post(
1073+
'/.ghost/activitypub/upload/image',
1074+
requireRole(GhostRole.Owner, GhostRole.Administrator),
1075+
spanWrapper(createStorageHandler(accountService, gcpStorageService)),
1076+
);
10571077
/** Federation wire up */
10581078

10591079
app.get(

src/http/api/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,6 @@ export * from './notification';
44
export * from './note';
55
export * from './post';
66
export * from './search';
7+
export * from './storage';
78
export * from './thread';
89
export * from './webhook';

src/http/api/storage.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import type { AccountService } from 'account/account.service';
2+
import { exhaustiveCheck, getError, getValue, isError } from 'core/result';
3+
import type { Context } from 'hono';
4+
import type { GCPStorageService } from 'storage/gcloud-storage/gcp-storage.service';
5+
6+
export function createStorageHandler(
7+
accountService: AccountService,
8+
storageService: GCPStorageService,
9+
) {
10+
/**
11+
* Handle an upload to GCloud Storage bucket
12+
*/
13+
return async function handleUpload(ctx: Context) {
14+
const logger = ctx.get('logger');
15+
const formData = await ctx.req.formData();
16+
const file = formData.get('file');
17+
18+
if (!file || !(file instanceof File)) {
19+
return new Response('No valid file provided', { status: 400 });
20+
}
21+
22+
const account = await accountService.getAccountForSite(ctx.get('site'));
23+
const result = await storageService.saveFile(file, account.uuid);
24+
25+
if (isError(result)) {
26+
const error = getError(result);
27+
switch (error) {
28+
case 'file-too-large':
29+
logger.error(`File is too large: ${file.size} bytes`);
30+
return new Response('File is too large', { status: 413 });
31+
case 'file-type-not-supported':
32+
logger.error(`File type ${file.type} is not supported`);
33+
return new Response(
34+
`File type ${file.type} is not supported`,
35+
{ status: 415 },
36+
);
37+
default:
38+
exhaustiveCheck(error);
39+
}
40+
}
41+
42+
const fileUrl = getValue(result);
43+
return new Response(JSON.stringify({ fileUrl }), {
44+
headers: {
45+
'Content-Type': 'application/json',
46+
},
47+
status: 200,
48+
});
49+
};
50+
}

0 commit comments

Comments
 (0)