Skip to content

Commit

Permalink
feat!(awards): add file storage for award logos (#32)
Browse files Browse the repository at this point in the history
Award logos are now stored via s3 or local filesystem storage.

BREAKING CHANGE: Running the awards-backend plugin now requires a `awards.storage.<s3|fs>` configuration to run.

BREAKING CHANGE: The awards-backend router now requires the following three fields from the plugin environment `{ config: Config, discovery: DiscoveryService, tokenManager: TokenManager }`. (Missed note from #31)
  • Loading branch information
zhammer committed Feb 28, 2024
1 parent 7aec768 commit 53cefa7
Show file tree
Hide file tree
Showing 14 changed files with 1,408 additions and 63 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,5 @@ site

# E2E test reports
e2e-test-report/

packages/backend/tmp-awards-storage
4 changes: 4 additions & 0 deletions app-config.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
awards:
storage:
fs: {}

app:
title: SeatGeek Backstage Plugins Demo Site
baseUrl: http://127.0.0.1:3000
Expand Down
31 changes: 30 additions & 1 deletion plugins/awards-backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,36 @@ function makeCreateEnv(config: Config) {

## Configuration

### Slack notifications
### Image storage (required)

The awards-backend requires storage to be configured for award images.

### Filesystem

```yaml
awards:
storage:
fs:
# directory where files will be stored relative to the CWD where the application was started
directory: my-directory # optional: defaults to tmp-awards-storage
```
### S3
```yaml
awards:
storage:
s3:
bucket: backstage-awards # required
region: us-east-1 # required
# Omit the following fields if using IAM roles (https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/loading-node-credentials-iam.html)
accessKeyId: ${AWS_ACCESS_KEY_ID} # optional
secretAccessKey: ${AWS_SECRET_ACCESS_KEY} # optional
# For local development, pass the endpoint for your localstack server
endpoint: http://127.0.0.1:4566 # optional
```
### Slack notifications (optional)
To enable Slack notifications, add the following to your `app-config.yaml` file:

Expand Down
7 changes: 7 additions & 0 deletions plugins/awards-backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"postpack": "backstage-cli package postpack"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.521.0",
"@backstage/backend-common": "^0.20.1",
"@backstage/backend-plugin-api": "^0.6.9",
"@backstage/catalog-client": "^1.6.0",
Expand All @@ -32,9 +33,15 @@
"@backstage/plugin-auth-node": "^0.4.3",
"@seatgeek/backstage-plugin-awards-common": "link:*",
"@slack/webhook": "^7.0.2",
"@tweedegolf/sab-adapter-amazon-s3": "^1.0.13",
"@tweedegolf/sab-adapter-local": "^1.0.5",
"@tweedegolf/storage-abstraction": "^2.1.1",
"@types/express": "*",
"@types/express-fileupload": "^1.4.4",
"express": "^4.17.1",
"express-fileupload": "^1.4.3",
"express-promise-router": "^4.1.0",
"image-size": "^1.1.1",
"knex": "^3.1.0",
"lodash": "^4.17.21",
"node-fetch": "^2.6.7",
Expand Down
61 changes: 60 additions & 1 deletion plugins/awards-backend/src/awards.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,23 @@
* Licensed under the terms of the Apache-2.0 license. See LICENSE file in project root for terms.
*/
import { Award } from '@seatgeek/backstage-plugin-awards-common';
import { Storage } from '@tweedegolf/storage-abstraction';
import { Readable } from 'stream';
import * as winston from 'winston';
import { Awards } from './awards';
import { AwardsStore } from './database/awards';
import { AwardsNotifier } from './notifier';

const frank = 'user:default/frank-ocean';

async function streamToString(stream: Readable) {
let result = '';
for await (const chunk of stream) {
result += chunk;
}
return result;
}

function makeAward(): Award {
return {
uid: '123456',
Expand All @@ -24,6 +34,7 @@ function makeAward(): Award {
describe('Awards', () => {
let db: jest.Mocked<AwardsStore>;
let notifier: jest.Mocked<AwardsNotifier>;
let storage: jest.Mocked<Storage>;
let awards: Awards;

beforeEach(() => {
Expand All @@ -36,10 +47,14 @@ describe('Awards', () => {
notifier = {
notifyNewRecipients: jest.fn(),
};
storage = {
getFileAsStream: jest.fn(),
addFile: jest.fn(),
} as unknown as jest.Mocked<Storage>;
const logger = winston.createLogger({
transports: [new winston.transports.Console({ silent: true })],
});
awards = new Awards(db, notifier, logger);
awards = new Awards(db, notifier, storage, logger);
});

afterEach(() => {
Expand Down Expand Up @@ -109,4 +124,48 @@ describe('Awards', () => {
]);
});
});

describe('getLogo', () => {
it('should get a logo from storage', async () => {
const key = 'logo.png';
storage.getFileAsStream = jest.fn().mockResolvedValue({
value: Readable.from('data'),
});
const out = await awards.getLogo(key);

expect(out).not.toBeNull();
expect(out!.contentType).toEqual('image/png');
expect(await streamToString(out!.body)).toEqual('data');

expect(storage.getFileAsStream).toHaveBeenCalledWith(key);
});

it('should fail on an unknown extension type', async () => {
const key = 'logo.wav';

await expect(awards.getLogo(key)).rejects.toThrow(
'Unknown key extension wav',
);
});

it('should fail when missing extension type', async () => {
const key = 'logo';

await expect(awards.getLogo(key)).rejects.toThrow(
'Invalid key, missing extension',
);
});

it('should return null when storage returns null', async () => {
const key = 'logo.png';
storage.getFileAsStream = jest.fn().mockResolvedValue({
value: null,
});
const out = await awards.getLogo(key);

expect(out).toBeNull();

expect(storage.getFileAsStream).toHaveBeenCalledWith(key);
});
});
});
96 changes: 94 additions & 2 deletions plugins/awards-backend/src/awards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,41 @@
*/
import { NotFoundError } from '@backstage/errors';
import { Award, AwardInput } from '@seatgeek/backstage-plugin-awards-common';
import { Storage } from '@tweedegolf/storage-abstraction';
import sizeOf from 'image-size';
import { Readable } from 'stream';
import { v4 as uuid } from 'uuid';
import { Logger } from 'winston';
import { AwardsStore } from './database/awards';
import { AwardsNotifier } from './notifier';

const extensionByMimetype: Record<string, string> = {
'image/png': 'png',
'image/jpeg': 'jpg',
};

const mimetypeByExtension = Object.fromEntries(
Object.entries(extensionByMimetype).map(([mimetype, extension]) => [
extension,
mimetype,
]),
);

export class Awards {
private readonly db: AwardsStore;
private readonly logger: Logger;
private readonly notifier: AwardsNotifier;

constructor(db: AwardsStore, notifier: AwardsNotifier, logger: Logger) {
private readonly storage: Storage;

constructor(
db: AwardsStore,
notifier: AwardsNotifier,
storage: Storage,
logger: Logger,
) {
this.db = db;
this.notifier = notifier;
this.storage = storage;
this.logger = logger.child({ class: 'Awards' });
this.logger.debug('Constructed');
}
Expand Down Expand Up @@ -102,4 +125,73 @@ export class Awards {

return res[0];
}

async uploadLogo(image: Buffer, mimeType: string): Promise<string> {
// validate image
const { width, height } = sizeOf(image);
if (!width || !height) {
throw new Error('Could not read image metadata');
}
validateAspectRatio(width, height);
validateImageSize(width, height);
validateImageFormat(mimeType);

// upload image to s3
const key = `${uuid()}.${extensionByMimetype[mimeType]}`;
const resp = await this.storage.addFile({
buffer: image,
targetPath: key,
});
if (resp.error) {
throw new Error(resp.error);
}
return key;
}

async getLogo(
key: string,
): Promise<{ body: Readable; contentType: string } | null> {
const parts = key.split('.');
if (parts.length === 1) {
throw new Error('Invalid key, missing extension');
}
const extension = parts[parts.length - 1];
const contentType = mimetypeByExtension[extension];
if (!contentType) {
throw new Error(`Unknown key extension ${extension}`);
}

const resp = await this.storage.getFileAsStream(key);

if (!resp.value) {
return null;
}

return {
body: resp.value,
contentType: mimetypeByExtension[extension!],
};
}
}

function validateAspectRatio(width: number, height: number): void {
if (width / height !== 3) {
throw new Error('Image must have a 3:1 aspect ratio');
}
}

function validateImageSize(width: number, _: number): void {
if (width < 100 || width > 1000) {
throw new Error('Image width must be between 100 and 1000 pixels');
}
}

function validateImageFormat(mimeType: string): void {
if (!(mimeType in extensionByMimetype)) {
throw new Error(
`Image must be of format [${Object.keys(extensionByMimetype).join(
', ',
)}], got ${mimeType}`,
);
}
}
6 changes: 3 additions & 3 deletions plugins/awards-backend/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,28 +14,28 @@ export const awardsPlugin = createBackendPlugin({
register(env) {
env.registerInit({
deps: {
config: coreServices.rootConfig,
database: coreServices.database,
identity: coreServices.identity,
config: coreServices.rootConfig,
logger: coreServices.logger,
httpRouter: coreServices.httpRouter,
discovery: coreServices.discovery,
tokenManager: coreServices.tokenManager,
},
async init({
config,
database,
identity,
config,
logger,
httpRouter,
discovery,
tokenManager,
}) {
httpRouter.use(
await createRouter({
config,
database,
identity,
config,
logger: loggerToWinstonLogger(logger),
discovery,
tokenManager,
Expand Down
Loading

0 comments on commit 53cefa7

Please sign in to comment.