Skip to content

Commit

Permalink
fi: support uuid -> username mapping (#290)
Browse files Browse the repository at this point in the history
* fix: introduce user profile queries & mutation
* fix: resolve username from db
* test: add unit tests
  • Loading branch information
vnugent committed May 20, 2023
1 parent 2589e6b commit c8d8b0b
Show file tree
Hide file tree
Showing 27 changed files with 836 additions and 110 deletions.
16 changes: 15 additions & 1 deletion .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@
"--inspect-brk",
"${workspaceRoot}/node_modules/.bin/jest",
"--runInBand",
"MutableClimbDataSource"
"UserData"
],
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
Expand Down Expand Up @@ -99,5 +99,19 @@
"<node_internals>/**"
],
},
{
"type": "node",
"request": "launch",
"name": "Create Users collection",
"program": "${workspaceFolder}/src/db/utils/jobs/migration/CreateUsersCollection.ts",
"preLaunchTask": "tsc: build - tsconfig.json",
"outFiles": [
"${workspaceFolder}/build/**/*.js"
],
"skipFiles": [
"<node_internals>/**"
],
"console": "integratedTerminal",
},
]
}
5 changes: 4 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,8 @@
"standard.enable": true,
"standard.autoFixOnSave": true,
"standard.engine": "ts-standard",
"standard.treatErrorsAsWarnings": true
"standard.treatErrorsAsWarnings": true,
"javascript.format.enable": false,
"javascript.format.semicolons": "remove",
"typescript.format.enable": false
}
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,9 @@
"apollo-server": "^3.9.0",
"axios": "^1.3.6",
"cors": "^2.8.5",
"date-fns": "^2.30.0",
"dot-object": "^2.1.4",
"dotenv": "^10.0.0",
"expiry-map": "^2.0.0",
"glob": "^10.2.2",
"graphql": "^16.5.0",
"graphql-middleware": "^6.1.31",
Expand All @@ -52,14 +52,14 @@
"mongoose-lean-virtuals": "0.9.1",
"node-fetch": "2",
"p-limit": "^4.0.0",
"p-memoize": "^7.1.1",
"pino": "^8.2.0",
"sanitize-html": "^2.7.2",
"sharp": "^0.32.0",
"typesense": "^1.2.1",
"underscore": "^1.13.2",
"uuid": "^8.3.2",
"uuid-mongodb": "^2.5.1"
"uuid-mongodb": "^2.5.1",
"yup": "^1.1.1"
},
"scripts": {
"lint": "yarn ts-standard",
Expand Down
5 changes: 3 additions & 2 deletions src/__tests__/areas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,11 @@ describe('areas API', () => {
variables: { input: wa.metadata.area_id },
userUuid
})

expect(response.statusCode).toBe(200)
const areaResult = response.body.data.area
expect(areaResult.uuid).toBe(muuidToString(wa.metadata.area_id))
expect(areaResult.organizations.length).toBe(1)
expect(areaResult.organizations).toHaveLength(1)
expect(areaResult.organizations[0].orgId).toBe(muuidToString(alphaOrg.orgId))
})

Expand All @@ -98,7 +99,7 @@ describe('areas API', () => {
expect(areaResult.uuid).toBe(muuidToString(ca.metadata.area_id))
// Even though alphaOrg associates with ca's parent, usa, it excludes
// ca and so should not be listed.
expect(areaResult.organizations.length).toBe(0)
expect(areaResult.organizations).toHaveLength(0)
})
})
})
4 changes: 3 additions & 1 deletion src/auth/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,16 @@ export const createContext = async ({ req }): Promise<any> => {

const user: AuthUserType = {
roles: [],
uuid: undefined
uuid: undefined,
isBuilder: false
}

const authHeader = String(headers?.authorization ?? '')
if (authHeader.startsWith('Bearer ')) {
const token = authHeader.substring(7, authHeader.length).trim()
const z = await verifyJWT(token)

user.isBuilder = z?.scope?.includes('builder:default') ?? false
user.roles = z?.['https://tacos.openbeta.io/roles'] ?? []
const uidStr: string | undefined = z?.['https://tacos.openbeta.io/uuid']
user.uuid = uidStr != null ? muid.from(uidStr) : undefined
Expand Down
10 changes: 6 additions & 4 deletions src/auth/permissions.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { shield, allow } from 'graphql-shield'
import { isEditor, isUserAdmin } from './rules.js'
import { shield, allow, or, and } from 'graphql-shield'
import { isEditor, isUserAdmin, isOwner, isBuilderServiceAccount, isValidEmail } from './rules.js'

const permissions = shield({
Query: {
'*': allow
'*': allow,
getUserProfile: or(isOwner, isBuilderServiceAccount)
},
Mutation: {
addOrganization: isUserAdmin,
Expand All @@ -12,7 +13,8 @@ const permissions = shield({
addArea: isEditor,
updateArea: isEditor,
updateClimbs: isEditor,
deleteClimbs: isEditor
deleteClimbs: isEditor,
updateUserProfile: and(isOwner, isValidEmail)
}
},
{
Expand Down
32 changes: 29 additions & 3 deletions src/auth/rules.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,35 @@
import { rule } from 'graphql-shield'
import { rule, inputRule } from 'graphql-shield'
import muuid from 'uuid-mongodb'

export const isEditor = rule()(async (parent, args, ctx, info) => {
return (ctx.user.uuid != null) && ctx.user.roles.includes('editor')
return _hasUserUuid(ctx) && ctx.user.roles.includes('editor')
})

export const isUserAdmin = rule()(async (parent, args, ctx, info) => {
return (ctx.user.uuid != null) && ctx.user.roles.includes('user_admin')
return _hasUserUuid(ctx) && ctx.user.roles.includes('user_admin')
})

export const isOwner = rule()(async (parent, args, ctx, info) => {
return _hasUserUuid(ctx) && ctx.user.uuid === muuid.from(args.userUuid)
})

export const isBuilderServiceAccount = rule()(async (parent, args, ctx: Context, info) => {
return _hasUserUuid(ctx) && ctx.user.isBuilder
})

export const isValidEmail = inputRule()(
(yup) =>
yup.object({
email: yup.string().email('Please provide a valid email')
}),
{ abortEarly: false }
)

interface Context {
user: {
uuid?: string
isBuilder: boolean
}
}

const _hasUserUuid = (ctx: Context): boolean => ctx.user.uuid != null
49 changes: 48 additions & 1 deletion src/db/UserSchema.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import mongoose from 'mongoose'
import muuid from 'uuid-mongodb'

import { ExperimentalUserType } from './UserTypes.js'
import { ExperimentalUserType, User, UsernameInfo } from './UserTypes.js'

const { Schema } = mongoose

Expand All @@ -18,6 +18,53 @@ export const ExperimentalUserSchema = new Schema<ExperimentalUserType>({
timestamps: true
})

/**
* Temporary model used to capture user profile during bulk import of Canada data.
* Use the standard User model instead.
*/
export const getExperimentalUserModel = (): mongoose.Model<ExperimentalUserType> => {
return mongoose.model('exp_users', ExperimentalUserSchema)
}

const UsernameSchema = new Schema<UsernameInfo>({
username: { type: Schema.Types.String }
}, {
_id: false,
timestamps: {
updatedAt: true,
createdAt: false
}
})

export const UserSchema = new Schema<User>({
_id: {
type: 'object',
value: { type: 'Buffer' }
},
email: { type: Schema.Types.String },
emailVerified: { type: Schema.Types.Boolean },
displayName: { type: Schema.Types.String },
bio: { type: Schema.Types.String },
website: { type: Schema.Types.String },
usernameInfo: { type: UsernameSchema, required: false },
createdBy: {
type: 'object',
value: { type: 'Buffer' }
},
updatedBy: {
type: 'object',
value: { type: 'Buffer' }
}
}, {
_id: false,
timestamps: true
})

/**
* For sorting by most recent
*/
UserSchema.index({ createdAt: -1 })

export const getUserModel = (): mongoose.Model<User> => {
return mongoose.model('users', UserSchema)
}
30 changes: 30 additions & 0 deletions src/db/UserTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,33 @@ export interface ExperimentalAuthorType {
displayName: string
url: string
}

export interface UsernameInfo {
username: string
updatedAt: Date
}
export interface User {
_id: MUUID
email?: string
emailVerified?: boolean
displayName?: string
usernameInfo?: UsernameInfo
website?: string
bio?: string
createdAt: Date
updatedAt: Date
createdBy: MUUID
updatedBy?: MUUID
}

type NotUpdatableFields = 'usernameInfo' | 'createdAt' | 'updatedAt' | 'createdBy'

export type UpdateProfileGQLInput = Omit<User, NotUpdatableFields> & {
username?: string
}

export interface GetUsernameReturn {
_id: MUUID
username: string
updatedAt: Date
}
6 changes: 4 additions & 2 deletions src/db/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { getTickModel } from './TickSchema.js'
import { getXMediaModel } from './XMediaSchema.js'
import { getPostModel } from './PostSchema.js'
import { getChangeLogModel } from './ChangeLogSchema.js'
import { getExperimentalUserModel } from './UserSchema.js'
import { getExperimentalUserModel, getUserModel } from './UserSchema.js'
import { logger } from '../logger.js'
import streamListener from './edit/streamListener.js'

Expand Down Expand Up @@ -74,6 +74,7 @@ export const createIndexes = async (): Promise<void> => {
await getPostModel().createIndexes()
await getMediaObjectModel().createIndexes()
await getChangeLogModel().createIndexes()
await getUserModel().createIndexes()
}

export const gracefulExit = async (exitCode: number = 0): Promise<void> => {
Expand Down Expand Up @@ -102,5 +103,6 @@ export {
getXMediaModel,
getPostModel,
getExperimentalUserModel,
getMediaObjectModel
getMediaObjectModel,
getUserModel
}
67 changes: 67 additions & 0 deletions src/db/utils/jobs/migration/CreateUsersCollection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { glob } from 'glob'
import { validate as uuidValidate } from 'uuid'
import muuid from 'uuid-mongodb'
import fs from 'fs'
import { connectDB, gracefulExit, getUserModel } from '../../../index.js'
import { logger } from '../../../../logger.js'
import { User } from '../../../UserTypes.js'

const LOCAL_MEDIA_DIR_UID = process.env.LOCAL_MEDIA_DIR_UID

if (LOCAL_MEDIA_DIR_UID == null) {
throw new Error('LOCAL_MEDIA_DIR_UID env not defined')
}

/**
* Create a new Users collection from uid.json files in local media dir.
*/
const onConnected = async (): Promise<void> => {
logger.info('Creating users collection')
const model = getUserModel()
await model.ensureIndexes()
const uidFIle = await glob(LOCAL_MEDIA_DIR_UID, {
nodir: true,
stat: false,
withFileTypes: true
})

let list: Array<Omit<User, 'createdAt' | 'updatedAt'>> = []
let count = 0
for (const file of uidFIle) {
const folderUuidStr = file.parent?.name ?? ''
if (!uuidValidate(folderUuidStr)) {
logger.error({ file: file.name, parent: folderUuidStr }, 'Error: expect folder name to have uuid format. Found ')
continue
}
const userUuid = muuid.from(folderUuidStr)
const f = fs.readFileSync(file.fullpath(), 'utf-8')

const { uid, ts } = JSON.parse(f)
const newUser: Omit<User, 'createdAt' | 'updatedAt'> = {
_id: userUuid,
usernameInfo: {
username: uid as string,
updatedAt: new Date(ts)
},
createdBy: userUuid
}
list.push(newUser)

if (list.length === 40) {
const rs = await model.insertMany(list)
count = count + rs.length
list = []
}
}

if (list.length > 0) {
await model.insertMany(list)
count = count + list.length
}

logger.info({ count }, 'Finish')

await gracefulExit()
}

void connectDB(onConnected)
Loading

0 comments on commit c8d8b0b

Please sign in to comment.