Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fi: support uuid -> username mapping #290

Merged
merged 16 commits into from
May 20, 2023
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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for fixing this!

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> => {
vnugent marked this conversation as resolved.
Show resolved Hide resolved
return mongoose.model('exp_users', ExperimentalUserSchema)
}

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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Slightly confused too, since below we type this as a "NotUpdatableField", so how come we allow an updatedAt time?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok I understand now why we allow updatedAt. But still wondering why we list as a "NotUpdatableField".

Copy link
Contributor Author

@vnugent vnugent May 20, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updatedAt: true: Mongo will set the value for you whenever there's an update
*Edit: in this case I simply want Mongo to create the updatedAt field for me. We want to set username update timestamp ourselves, but don't want API users to do it.

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 },
vnugent marked this conversation as resolved.
Show resolved Hide resolved
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