Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ DEBUG = true
COMPANY_ABBREVIATION = exp
OLD_COMPANY_NAME = oldExampleCompany
NEW_COMPANY_NAME = newExampleCompany
AVATAR_URL = url

SES_EMAIL = secret
SES_REGION = eu-west-1
Expand Down
1 change: 0 additions & 1 deletion .github/workflows/reusable-build-job.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ jobs:
COMPANY_ABBREVIATION: ${{ secrets.COMPANY_ABBREVIATION }}
OLD_COMPANY_NAME: ${{ secrets.OLD_COMPANY_NAME }}
NEW_COMPANY_NAME: ${{ secrets.NEW_COMPANY_NAME }}
AVATAR_URL: ${{ secrets.AVATAR_URL }}
LARA_VERSION: ${{ github.ref_name }}
FRONTEND_URL: ${{ secrets.FRONTEND_URL }}
BACKEND_URL: ${{ secrets.BACKEND_URL }}
Expand Down
1 change: 0 additions & 1 deletion .github/workflows/reusable-deploy-job.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ jobs:
COMPANY_ABBREVIATION: ${{ secrets.COMPANY_ABBREVIATION }}
OLD_COMPANY_NAME: ${{ secrets.OLD_COMPANY_NAME }}
NEW_COMPANY_NAME: ${{ secrets.NEW_COMPANY_NAME }}
AVATAR_URL: ${{ secrets.AVATAR_URL }}
LARA_VERSION: ${{ github.ref_name }}
SES_REGION: ${{ secrets.SES_REGION }}
FRONTEND_URL: ${{ secrets.FRONTEND_URL }}
Expand Down
12 changes: 5 additions & 7 deletions packages/api/schema.gql
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,11 @@ type Mutation {
Create OAuth Code
"""
createOAuthCode: String!

"""
Get Avatar Bucket Upload URL
"""
getAvatarSignedUrl(id: String!): String
}

type LaraConfig {
Expand Down Expand Up @@ -343,10 +348,6 @@ enum ReportStatus {
}

type Trainee implements UserInterface {
"""
The url for the users avatar image.
"""
avatar: String!
company: Company!
course: String
createdAt: String!
Expand Down Expand Up @@ -408,7 +409,6 @@ input UpdateTrainerInput {
}

type Trainer implements UserInterface {
avatar: String!
createdAt: String!
firstName: String!
id: ID!
Expand All @@ -426,7 +426,6 @@ type Trainer implements UserInterface {
}

type Admin implements UserInterface {
avatar: String!
createdAt: String!
firstName: String!
id: ID!
Expand Down Expand Up @@ -454,7 +453,6 @@ input UserInput {
}

interface UserInterface {
avatar: String!
createdAt: String!
firstName: String!
id: ID!
Expand Down
17 changes: 8 additions & 9 deletions packages/api/src/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ export type Scalars = {
export type GqlAdmin = GqlUserInterface & {
__typename?: 'Admin';
alexaSkillLinked?: Maybe<Scalars['Boolean']['output']>;
avatar: Scalars['String']['output'];
createdAt: Scalars['String']['output'];
email: Scalars['String']['output'];
firstName: Scalars['String']['output'];
Expand Down Expand Up @@ -165,6 +164,8 @@ export type GqlMutation = {
createTrainer?: Maybe<GqlTrainer>;
/** Deletes an entry by the given ID. Only considers entries made by the current user. Returns the ID of the deleted entry. */
deleteEntry: GqlMutateEntryPayload;
/** Get Avatar Bucket Upload URL */
getAvatarSignedUrl?: Maybe<Scalars['String']['output']>;
/** Link Alexa account */
linkAlexa?: Maybe<GqlUserInterface>;
/** Login via microsoft */
Expand Down Expand Up @@ -251,6 +252,11 @@ export type GqlMutationDeleteEntryArgs = {
};


export type GqlMutationGetAvatarSignedUrlArgs = {
id: Scalars['String']['input'];
};


export type GqlMutationLinkAlexaArgs = {
code: Scalars['String']['input'];
state: Scalars['String']['input'];
Expand Down Expand Up @@ -429,8 +435,6 @@ export type GqlSuggestion = {
export type GqlTrainee = GqlUserInterface & {
__typename?: 'Trainee';
alexaSkillLinked?: Maybe<Scalars['Boolean']['output']>;
/** The url for the users avatar image. */
avatar: Scalars['String']['output'];
company: GqlCompany;
course?: Maybe<Scalars['String']['output']>;
createdAt: Scalars['String']['output'];
Expand All @@ -457,7 +461,6 @@ export type GqlTrainee = GqlUserInterface & {
export type GqlTrainer = GqlUserInterface & {
__typename?: 'Trainer';
alexaSkillLinked?: Maybe<Scalars['Boolean']['output']>;
avatar: Scalars['String']['output'];
createdAt: Scalars['String']['output'];
deleteAt?: Maybe<Scalars['String']['output']>;
email: Scalars['String']['output'];
Expand Down Expand Up @@ -514,7 +517,6 @@ export type GqlUserInput = {

export type GqlUserInterface = {
alexaSkillLinked?: Maybe<Scalars['Boolean']['output']>;
avatar: Scalars['String']['output'];
createdAt: Scalars['String']['output'];
email: Scalars['String']['output'];
firstName: Scalars['String']['output'];
Expand Down Expand Up @@ -683,7 +685,6 @@ export type GqlResolversParentTypes = ResolversObject<{

export type GqlAdminResolvers<ContextType = Context, ParentType extends GqlResolversParentTypes['Admin'] = GqlResolversParentTypes['Admin']> = ResolversObject<{
alexaSkillLinked?: Resolver<Maybe<GqlResolversTypes['Boolean']>, ParentType, ContextType>;
avatar?: Resolver<GqlResolversTypes['String'], ParentType, ContextType>;
createdAt?: Resolver<GqlResolversTypes['String'], ParentType, ContextType>;
email?: Resolver<GqlResolversTypes['String'], ParentType, ContextType>;
firstName?: Resolver<GqlResolversTypes['String'], ParentType, ContextType>;
Expand Down Expand Up @@ -781,6 +782,7 @@ export type GqlMutationResolvers<ContextType = Context, ParentType extends GqlRe
createTrainee?: Resolver<Maybe<GqlResolversTypes['Trainee']>, ParentType, ContextType, RequireFields<GqlMutationCreateTraineeArgs, 'input'>>;
createTrainer?: Resolver<Maybe<GqlResolversTypes['Trainer']>, ParentType, ContextType, RequireFields<GqlMutationCreateTrainerArgs, 'input'>>;
deleteEntry?: Resolver<GqlResolversTypes['MutateEntryPayload'], ParentType, ContextType, RequireFields<GqlMutationDeleteEntryArgs, 'id'>>;
getAvatarSignedUrl?: Resolver<Maybe<GqlResolversTypes['String']>, ParentType, ContextType, RequireFields<GqlMutationGetAvatarSignedUrlArgs, 'id'>>;
linkAlexa?: Resolver<Maybe<GqlResolversTypes['UserInterface']>, ParentType, ContextType, RequireFields<GqlMutationLinkAlexaArgs, 'code' | 'state'>>;
login?: Resolver<Maybe<GqlResolversTypes['OAuthPayload']>, ParentType, ContextType, RequireFields<GqlMutationLoginArgs, 'email'>>;
markUserForDeletion?: Resolver<Maybe<GqlResolversTypes['UserInterface']>, ParentType, ContextType, RequireFields<GqlMutationMarkUserForDeletionArgs, 'id'>>;
Expand Down Expand Up @@ -849,7 +851,6 @@ export type GqlSuggestionResolvers<ContextType = Context, ParentType extends Gql

export type GqlTraineeResolvers<ContextType = Context, ParentType extends GqlResolversParentTypes['Trainee'] = GqlResolversParentTypes['Trainee']> = ResolversObject<{
alexaSkillLinked?: Resolver<Maybe<GqlResolversTypes['Boolean']>, ParentType, ContextType>;
avatar?: Resolver<GqlResolversTypes['String'], ParentType, ContextType>;
company?: Resolver<GqlResolversTypes['Company'], ParentType, ContextType>;
course?: Resolver<Maybe<GqlResolversTypes['String']>, ParentType, ContextType>;
createdAt?: Resolver<GqlResolversTypes['String'], ParentType, ContextType>;
Expand All @@ -876,7 +877,6 @@ export type GqlTraineeResolvers<ContextType = Context, ParentType extends GqlRes

export type GqlTrainerResolvers<ContextType = Context, ParentType extends GqlResolversParentTypes['Trainer'] = GqlResolversParentTypes['Trainer']> = ResolversObject<{
alexaSkillLinked?: Resolver<Maybe<GqlResolversTypes['Boolean']>, ParentType, ContextType>;
avatar?: Resolver<GqlResolversTypes['String'], ParentType, ContextType>;
createdAt?: Resolver<GqlResolversTypes['String'], ParentType, ContextType>;
deleteAt?: Resolver<Maybe<GqlResolversTypes['String']>, ParentType, ContextType>;
email?: Resolver<GqlResolversTypes['String'], ParentType, ContextType>;
Expand Down Expand Up @@ -908,7 +908,6 @@ export type GqlUpdateReportPayloadResolvers<ContextType = Context, ParentType ex
export type GqlUserInterfaceResolvers<ContextType = Context, ParentType extends GqlResolversParentTypes['UserInterface'] = GqlResolversParentTypes['UserInterface']> = ResolversObject<{
__resolveType: TypeResolveFn<'Admin' | 'Trainee' | 'Trainer', ParentType, ContextType>;
alexaSkillLinked?: Resolver<Maybe<GqlResolversTypes['Boolean']>, ParentType, ContextType>;
avatar?: Resolver<GqlResolversTypes['String'], ParentType, ContextType>;
createdAt?: Resolver<GqlResolversTypes['String'], ParentType, ContextType>;
email?: Resolver<GqlResolversTypes['String'], ParentType, ContextType>;
firstName?: Resolver<GqlResolversTypes['String'], ParentType, ContextType>;
Expand Down
2 changes: 1 addition & 1 deletion packages/api/src/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export type User = Trainee | Trainer | Admin
* during the runtime in the backend. Only after beeing
* transformed by GraphQL these fields are available
*/
type ResolvedUserFields = 'avatar' | 'username' | 'alexaSkillLinked'
type ResolvedUserFields = 'username' | 'alexaSkillLinked'

export type UserInterface = Omit<GqlUserInterface, ResolvedUserFields> & {
email: string
Expand Down
1 change: 1 addition & 0 deletions packages/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"@aws-sdk/client-dynamodb": "3.883.0",
"@aws-sdk/client-lambda": "3.883.0",
"@aws-sdk/lib-dynamodb": "3.883.0",
"@aws-sdk/s3-request-presigner": "3.883.0",
"@aws-sdk/util-dynamodb": "3.883.0",
"@graphql-tools/schema": "^10.0.25",
"@lara/api": "^1.0.0",
Expand Down
3 changes: 3 additions & 0 deletions packages/backend/src/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { resolvers } from './resolvers'
import { handleAuthorizeRequest } from './routes/authorize'
import { validateJWT } from './services/oauth.service'
import { parseBearerAuth } from './utils/security'
import { handleAvatarDeletion, handleAvatarUpload } from './routes/avatar'

const { STAGE, AUTH_HEADER } = process.env

Expand Down Expand Up @@ -91,6 +92,8 @@ export const server: APIGatewayProxyHandler = apolloServer.createHandler({
app.use(cors(corsOptions))

app.post('/oauth/token', handleAuthorizeRequest)
app.post('/avatar', handleAvatarUpload)
app.delete('/avatar', handleAvatarDeletion)

app.use(middleware)

Expand Down
3 changes: 1 addition & 2 deletions packages/backend/src/resolvers/admin.resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,12 @@ import { allUsers, saveUser, updateUser, userByEmail, userById } from '../reposi
import { sendDeletionMail } from '../services/email.service'
import { deleteTrainee, generateReports, generateTrainee, validateTrainee } from '../services/trainee.service'
import { deleteTrainer, generateTrainer, validateTrainer } from '../services/trainer.service'
import { avatar, username } from '../services/user.service'
import { username } from '../services/user.service'
import { parseISODateString } from '../utils/date'
import { t } from '../i18n'

export const adminResolver: GqlResolvers<AdminContext> = {
Admin: {
avatar,
username,
},
Query: {
Expand Down
46 changes: 46 additions & 0 deletions packages/backend/src/resolvers/avatar.resolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { S3Client, GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
import { AuthenticatedContext, GqlResolvers } from '@lara/api'

const { IS_OFFLINE, AVATAR_BUCKET } = process.env

const s3Client = new S3Client(
IS_OFFLINE
? {
forcePathStyle: true,
credentials: {
accessKeyId: 'S3RVER', // This specific key is required when working offline
secretAccessKey: 'S3RVER',
},
endpoint: 'http://localhost:8181',
}
: { region: 'eu-central-1' }
)

export const avatarResolver: GqlResolvers<AuthenticatedContext> = {
Mutation: {
getAvatarSignedUrl: async (_parent, { id }, _context) => {
const key = `${id}`

try {
await s3Client.send(
new HeadObjectCommand({
Bucket: AVATAR_BUCKET,
Key: key,
})
)
} catch (_) {
return undefined
}

const command = new GetObjectCommand({
Bucket: AVATAR_BUCKET,
Key: key,
})

const url = await getSignedUrl(s3Client, command, { expiresIn: 60 })
if (!url) throw new Error('Could not generate signed get URL')
return url
},
},
}
3 changes: 3 additions & 0 deletions packages/backend/src/resolvers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { reportResolver, reportTraineeResolver } from './report.resolver'
import { traineeResolver, traineeTraineeResolver } from './trainee.resolver'
import { trainerResolver } from './trainer.resolver'
import { userResolver } from './user.resolver'
import { avatarResolver } from './avatar.resolver'

export const resolvers = [
configResolver,
Expand Down Expand Up @@ -36,4 +37,6 @@ export const resolvers = [
trainerAdminResolver,

alexaResolver,

avatarResolver,
]
3 changes: 1 addition & 2 deletions packages/backend/src/resolvers/trainee.resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { company } from '../services/company.service'
import { createPrintReportData, createPrintUserData, invokePrintLambda, savePrintData } from '../services/print.service'
import { reportsWithinApprenticeship } from '../services/report.service'
import { endOfToolUsage, startOfToolUsage, validateTrainee } from '../services/trainee.service'
import { avatar, username } from '../services/user.service'
import { username } from '../services/user.service'
import { filterNullish } from '../utils/array'

export const traineeResolver: GqlResolvers<AuthenticatedContext> = {
Expand Down Expand Up @@ -51,7 +51,6 @@ export const traineeTraineeResolver: GqlResolvers<TraineeContext> = {
},
startOfToolUsage: (model) => startOfToolUsage(model).toISOString(),
endOfToolUsage: (model) => endOfToolUsage(model).toISOString(),
avatar,
alexaSkillLinked,
},
Query: {
Expand Down
3 changes: 1 addition & 2 deletions packages/backend/src/resolvers/trainer.resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,14 @@ import { reportByYearAndWeek } from '../repositories/report.repo'
import { allTrainees, traineeById, traineesByTrainerId } from '../repositories/trainee.repo'
import { updateUser } from '../repositories/user.repo'
import { alexaSkillLinked } from '../services/alexa.service'
import { avatar, username } from '../services/user.service'
import { username } from '../services/user.service'
import { createT } from '../i18n'

export const trainerResolver: GqlResolvers<TrainerContext> = {
Trainer: {
trainees: async (model) => {
return traineesByTrainerId(model.id)
},
avatar,
username,
alexaSkillLinked,
},
Expand Down
3 changes: 1 addition & 2 deletions packages/backend/src/resolvers/user.resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,13 @@ import { generateAdmin } from '../services/admin.service'
import { alexaSkillLinked } from '../services/alexa.service'
import { generateTrainee } from '../services/trainee.service'
import { generateTrainer } from '../services/trainer.service'
import { avatar, username } from '../services/user.service'
import { username } from '../services/user.service'

export const userResolver: GqlResolvers<AuthenticatedContext> = {
UserInterface: {
__resolveType: (model) => {
return model.type
},
avatar,
username,
alexaSkillLinked,
},
Expand Down
63 changes: 63 additions & 0 deletions packages/backend/src/routes/avatar.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { DeleteObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3'
import { Request, Response } from 'express'

const { IS_OFFLINE, AVATAR_BUCKET } = process.env

const s3Client = new S3Client(
IS_OFFLINE
? {
forcePathStyle: true,
credentials: {
accessKeyId: 'S3RVER', // This specific key is required when working offline
secretAccessKey: 'S3RVER',
},
endpoint: 'http://localhost:8181',
}
: { region: 'eu-central-1' }
)

export const handleAvatarUpload = async (req: Request, res: Response) => {
const key = req.headers['x-user-id'] as string

let base64String: string
try {
const parsed = JSON.parse(req.body.toString())
base64String = parsed.data
} catch {
base64String = req.body.data
}

if (!base64String) return res.status(400).send('No file provided in request')

const buffer = Buffer.from(base64String, 'base64')

const maxSize = 250 * 1024
if (buffer.length > maxSize) {
return res.status(413).send(`File too large. Max size is 250 KB.`)
}

s3Client.send(
new PutObjectCommand({
Bucket: AVATAR_BUCKET,
Key: key,
Body: buffer,
ContentType: req.headers['content-type'],
ContentLength: buffer.length,
})
)

return res.status(200).send()
}

export const handleAvatarDeletion = async (req: Request, res: Response) => {
const key = req.headers['x-user-id'] as string

s3Client.send(
new DeleteObjectCommand({
Bucket: AVATAR_BUCKET,
Key: key,
})
)

return res.status(200).send()
}
Loading