You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
importbodyParserfrom"body-parser";importexpressfrom"express";importsessionfrom"express-session";importjwtfrom"jsonwebtoken";importmongoose,{Document,Model,ObjectId,Schema}from"mongoose";importpassportfrom"passport";import{StrategyasAnonymousStrategy}from"passport-anonymous";import{StrategyasJwtStrategy}from"passport-jwt";import{StrategyasLocalStrategy}from"passport-local";import{logger}from"./logger";exportinterfaceEnv{NODE_ENV?: string;PORT?: string;SENTRY_DSN?: string;SLACK_WEBHOOK?: string;// JWTTOKEN_SECRET?: string;TOKEN_EXPIRES_IN?: string;TOKEN_ISSUER?: string;// AUTHSESSION_SECRET?: string;}// TODOS:// Support bulk actions// Support more complex query fields// Rate limitingconstSPECIAL_QUERY_PARAMS=["limit","page"];exporttypeRESTMethod="list"|"create"|"read"|"update"|"delete";interfaceGooseTransformer<T>{// Runs before create or update operations. Allows throwing out fields that the user should be// able to write to, modify data, check permissions, etc.transform?: (obj: Partial<T>,method: "create"|"update",user?: User)=>Partial<T>|undefined;// Runs after create/update operations but before data is returned from the API. Serialize fetched// data, dropping fields based on user, changing data, etc.serialize?: (obj: T,user?: User)=>Partial<T>|undefined;}typeUserType="anon"|"auth"|"owner"|"admin";interfaceUser{_id: ObjectId|string;id: string;admin: boolean;isAnonymous?: boolean;token?: string;}exportinterfaceUserModelextendsModel<User>{createAnonymousUser?: (id?: string)=>Promise<User>;postCreate?: (body: any)=>Promise<void>;createStrategy(): any;serializeUser(): any;// Allows additional setup during signup. This will be passed the rest of req.body from the signupdeserializeUser(): any;}exporttypePermissionMethod<T>=(method: RESTMethod,user?: User,obj?: T)=>boolean|Promise<boolean>;interfaceRESTPermissions<T>{create: PermissionMethod<T>[];list: PermissionMethod<T>[];read: PermissionMethod<T>[];update: PermissionMethod<T>[];delete: PermissionMethod<T>[];}interfaceGooseRESTOptions<T>{permissions: RESTPermissions<T>;queryFields?: string[];// return null to prevent the query from runningqueryFilter?: (user?: User,query?: Record<string,any>)=>Record<string,any>|null;transformer?: GooseTransformer<T>;sort?: string|{[key: string]: "ascending"|"descending"};defaultQueryParams?: {[key: string]: any};populatePaths?: string[];defaultLimit?: number;// defaults to 100maxLimit?: number;// defaults to 500endpoints?: (router: any)=>void;preCreate?: (value: any,request: express.Request)=>T|null;preUpdate?: (value: any,request: express.Request)=>T|null;preDelete?: (value: any,request: express.Request)=>T|null;postCreate?: (value: any,request: express.Request)=>void|Promise<void>;postUpdate?: (value: any,request: express.Request)=>void|Promise<void>;postDelete?: (request: express.Request)=>void|Promise<void>;}exportconstOwnerQueryFilter=(user?: User)=>{if(user){return{ownerId: user?.id};}// Return a null, so we know to return no results.returnnull;};exportconstPermissions={IsAuthenticatedOrReadOnly: (method: RESTMethod,user?: User)=>{if(user?.id&&!user?.isAnonymous){returntrue;}returnmethod==="list"||method==="read";},IsOwnerOrReadOnly: (method: RESTMethod,user?: User,obj?: any)=>{// When checking if we can possibly perform the action, return true.if(!obj){returntrue;}if(user?.admin){returntrue;}if(user?.id&&obj?.ownerId&&String(obj?.ownerId)===String(user?.id)){returntrue;}returnmethod==="list"||method==="read";},IsAny: ()=>{returntrue;},IsOwner: (method: RESTMethod,user?: User,obj?: any)=>{// When checking if we can possibly perform the action, return true.if(!obj){returntrue;}if(!user){returnfalse;}if(user?.admin){returntrue;}returnuser?.id&&obj?.ownerId&&String(obj?.ownerId)===String(user?.id);},IsAdmin: (method: RESTMethod,user?: User)=>{returnBoolean(user?.admin);},IsAuthenticated: (method: RESTMethod,user?: User)=>{if(!user){returnfalse;}returnBoolean(user.id);},};// Defaults closedexportasyncfunctioncheckPermissions<T>(method: RESTMethod,permissions: PermissionMethod<T>[],user?: User,obj?: T): Promise<boolean>{letanyTrue=false;for(constpermofpermissions){// May or may not be a promise.if(!(awaitperm(method,user,obj))){returnfalse;}else{anyTrue=true;}}returnanyTrue;}exportfunctiontokenPlugin(schema: Schema,options: {expiresIn?: number}={}){schema.add({token: {type: String,index: true}});schema.pre("save",function(next){// Add created when creating the objectif(!this.token){consttokenOptions: any={expiresIn: "10h",};if((process.envasEnv).TOKEN_EXPIRES_IN){tokenOptions.expiresIn=(process.envasEnv).TOKEN_EXPIRES_IN;}if((process.envasEnv).TOKEN_ISSUER){tokenOptions.issuer=(process.envasEnv).TOKEN_ISSUER;}constsecretOrKey=(process.envasEnv).TOKEN_SECRET;if(!secretOrKey){thrownewError(`TOKEN_SECRET must be set in env.`);}this.token=jwt.sign({id: this._id.toString()},secretOrKey,tokenOptions);}// On any save, update the updated field.this.updated=newDate();next();});}exportinterfaceBaseUser{admin: boolean;email: string;}exportfunctionbaseUserPlugin(schema: Schema){schema.add({admin: {type: Boolean,default: false}});schema.add({email: {type: String,index: true}});}exportinterfaceIsDeleted{deleted: boolean;}exportfunctionisDeletedPlugin(schema: Schema,defaultValue=false){schema.add({deleted: {type: Boolean,default: defaultValue,index: true}});schema.pre("find",function(){constquery=this.getQuery();if(query&&query.deleted===undefined){this.where({deleted: {$ne: true}});}});}exportinterfaceCreatedDeleted{updated: Date;created: Date;}exportfunctioncreatedDeletedPlugin(schema: Schema){schema.add({updated: {type: Date,index: true}});schema.add({created: {type: Date,index: true}});schema.pre("save",function(next){if(this.disableCreatedDeletedPlugin===true){next();return;}// If we aren't specifying created, use now.if(!this.created){this.created=newDate();}// All writes change the updated time.this.updated=newDate();next();});schema.pre("update",function(next){this.update({},{$set: {updated: newDate()}});next();});}exportfunctionfirebaseJWTPlugin(schema: Schema){schema.add({firebaseId: {type: String,index: true}});}exportfunctionauthenticateMiddleware(anonymous=false){conststrategies=["jwt"];if(anonymous){strategies.push("anonymous");}returnpassport.authenticate(strategies,{session: false});}exportasyncfunctionsignupUser(userModel: UserModel,email: string,password: string,body?: any){try{constuser=await(userModelasany).register({email},password);if(user.postCreate){deletebody.email;deletebody.password;try{awaituser.postCreate(body);}catch(e){logger.error("Error in user.postCreate",e);throwe;}}awaituser.save();if(!user.token){thrownewError("Token not created");}returnuser;}catch(error){throwerror;}}// TODO allow customizationexportfunctionsetupAuth(app: express.Application,userModel: UserModel){passport.use(newAnonymousStrategy());passport.use("signup",newLocalStrategy({usernameField: "email",passwordField: "password",passReqToCallback: true,},async(req,email,password,done)=>{try{done(undefined,awaitsignupUser(userModel,email,password,req.body));}catch(e){returndone(e);}}));passport.use("login",newLocalStrategy({usernameField: "email",passwordField: "password",},async(email,password,done)=>{try{constuser=awaituserModel.findOne({email});if(!user){logger.debug("Could not find login user for",email);returndone(null,false,{message: "User not found"});}constvalidate=await(userasany).authenticate(password);if(!validate){logger.debug("Invalid password for",email);returndone(null,false,{message: "Wrong Password"});}returndone(null,user,{message: "Logged in Successfully"});}catch(error){logger.error("Login error",error);returndone(error);}}));if(!userModel.createStrategy){thrownewError("setupAuth userModel must have .createStrategy()");}if(!userModel.serializeUser){thrownewError("setupAuth userModel must have .serializeUser()");}if(!userModel.deserializeUser){thrownewError("setupAuth userModel must have .deserializeUser()");}// use static serialize and deserialize of model for passport session supportpassport.serializeUser(userModel.serializeUser());passport.deserializeUser(userModel.deserializeUser());if((process.envasEnv).TOKEN_SECRET){logger.debug("Setting up JWT Authentication");constcustomExtractor=function(req: express.Request){lettoken=null;if(req?.cookies?.jwt){token=req.cookies.jwt;}elseif(req?.headers?.authorization){token=req?.headers?.authorization.split(" ")[1];}returntoken;};constsecretOrKey=(process.envasEnv).TOKEN_SECRET;if(!secretOrKey){thrownewError(`TOKEN_SECRET must be set in env.`);}constjwtOpts={// jwtFromRequest: ExtractJwt.fromAuthHeaderWithScheme("Bearer"),jwtFromRequest: customExtractor,
secretOrKey,issuer: (process.envasEnv).TOKEN_ISSUER,};passport.use("jwt",newJwtStrategy(jwtOpts,asyncfunction(payload: {id: string;iat: number;exp: number},done: any){letuser;if(!payload){returndone(null,false);}try{user=awaituserModel.findById((payloadasany).id);}catch(e){logger.warn("[jwt] Error finding user from id",e);returndone(e,false);}if(user){returndone(null,user);}else{if(userModel.createAnonymousUser){logger.info("[jwt] Creating anonymous user");user=awaituserModel.createAnonymousUser();returndone(null,user);}else{logger.info("[jwt] No user found from token");returndone(null,false);}}}));}constrouter=express.Router();router.post("/login",passport.authenticate("login",{session: false}),function(req: any,res: any){returnres.json({data: {userId: req.user._id,token: req.user.token}});});router.post("/signup",passport.authenticate("signup",{session: false}),asyncfunction(req: any,res: any){returnres.json({data: {userId: req.user._id,token: req.user.token}});});router.get("/me",authenticateMiddleware(),async(req,res)=>{if(!req.user?.id){returnres.status(401).send();}constdata=awaituserModel.findById(req.user.id);if(!data){returnres.status(404).send();}constdataObject=data.toObject();(dataObjectasany).id=data._id;returnres.json({data: dataObject});});router.patch("/me",authenticateMiddleware(),async(req,res)=>{if(!req.user?.id){returnres.status(401).send();}// TODO support limited updates for profile.// try {// body = transform(req.body, "update", req.user);// } catch (e) {// return res.status(403).send({message: (e as any).message});// }try{constdata=awaituserModel.findOneAndUpdate({_id: req.user.id},req.body,{new: true});if(data===null){returnres.status(404).send();}constdataObject=data.toObject();(dataObjectasany).id=data._id;returnres.json({data: dataObject});}catch(e){returnres.status(403).send({message: (easany).message});}});app.use(session({secret: (process.envasEnv).SESSION_SECRETasstring,resave: true,saveUninitialized: true,})asany);app.use(bodyParser.urlencoded({extended: false})asany);app.use(passport.initialize()asany);app.use(passport.session());app.set("etag",false);app.use("/auth",router);}functiongetUserType(user?: User,obj?: any): UserType{if(user?.admin){return"admin";}if(obj&&user&&String(obj?.ownerId)===String(user?.id)){return"owner";}if(user?.id){return"auth";}return"anon";}exportfunctionAdminOwnerTransformer<T>(options: {// TODO: do something with KeyOf here.anonReadFields?: string[];authReadFields?: string[];ownerReadFields?: string[];adminReadFields?: string[];anonWriteFields?: string[];authWriteFields?: string[];ownerWriteFields?: string[];adminWriteFields?: string[];}): GooseTransformer<T>{functionpickFields(obj: Partial<T>,fields: any[]): Partial<T>{constnewData: Partial<T>={};for(constfieldoffields){if(obj[field]!==undefined){newData[field]=obj[field];}}returnnewData;}return{transform: (obj: Partial<T>,method: "create"|"update",user?: User)=>{constuserType=getUserType(user,obj);letallowedFields: any;if(userType==="admin"){allowedFields=options.adminWriteFields??[];}elseif(userType==="owner"){allowedFields=options.ownerWriteFields??[];}elseif(userType==="auth"){allowedFields=options.authWriteFields??[];}else{allowedFields=options.anonWriteFields??[];}constunallowedFields=Object.keys(obj).filter((k)=>!allowedFields.includes(k));if(unallowedFields.length){thrownewError(`User of type ${userType} cannot write fields: ${unallowedFields.join(", ")}`);}returnobj;},serialize: (obj: T,user?: User)=>{constuserType=getUserType(user,obj);if(userType==="admin"){returnpickFields(obj,[...(options.adminReadFields??[]),"id"]);}elseif(userType==="owner"){returnpickFields(obj,[...(options.ownerReadFields??[]),"id"]);}elseif(userType==="auth"){returnpickFields(obj,[...(options.authReadFields??[]),"id"]);}else{returnpickFields(obj,[...(options.anonReadFields??[]),"id"]);}},};}exportfunctiongooseRestRouter<T>(model: Model<any>,options: GooseRESTOptions<T>): express.Router{constrouter=express.Router();functiontransform(data: Partial<T>|Partial<T>[],method: "create"|"update",user?: User){if(!options.transformer?.transform){returndata;}// TS doesn't realize this is defined otherwise...consttransformFn=options.transformer?.transform;if(!Array.isArray(data)){returntransformFn(data,method,user);}else{returndata.map((d)=>transformFn(d,method,user));}}functionserialize(data: Document<T,{},{}>|Document<T,{},{}>[],user?: User){constserializeFn=(serializeData: Document<T,{},{}>,seralizeUser?: User)=>{constdataObject=serializeData.toObject()asT;(dataObjectasany).id=serializeData._id;if(options.transformer?.serialize){returnoptions.transformer?.serialize(dataObject,seralizeUser);}else{returndataObject;}};if(!Array.isArray(data)){returnserializeFn(data,user);}else{returndata.map((d)=>serializeFn(d,user));}}// Do before the other router options so endpoints take priority.if(options.endpoints){options.endpoints(router);}// TODO Toggle anonymous auth middleware based on settings for route.router.post("/",authenticateMiddleware(true),async(req,res)=>{if(!(awaitcheckPermissions("create",options.permissions.create,req.user))){logger.warn(`Access to CREATE on ${model.name} denied for ${req.user?.id}`);returnres.status(405).send();}letbody;try{body=transform(req.body,"create",req.user);}catch(e){returnres.status(403).send({message: (easany).message});}if(options.preCreate){try{body=options.preCreate(body,req);}catch(e){returnres.status(400).send({message: `Pre Create error: ${(easany).message}`});}if(body===null){returnres.status(403).send({message: "Pre Create returned null"});}}letdata;try{data=awaitmodel.create(body);}catch(e){returnres.status(400).send({message: (easany).message});}if(options.postCreate){try{awaitoptions.postCreate(data,req);}catch(e){returnres.status(400).send({message: `Post Create error: ${(easany).message}`});}}returnres.status(201).json({data: serialize(data,req.user)});});// TODO add rate limitrouter.get("/",authenticateMiddleware(true),async(req,res)=>{if(!(awaitcheckPermissions("list",options.permissions.list,req.user))){logger.warn(`Access to LIST on ${model.name} denied for ${req.user?.id}`);returnres.status(403).send();}letquery={};for(constqueryParamofObject.keys(options.defaultQueryParams??[])){query[queryParam]=(options.defaultQueryParams??{})[queryParam];}// TODO we can make this much more complicated with ands and ors, but for now, simple queries// will do.for(constqueryParamofObject.keys(req.query)){if((options.queryFields??[]).concat(SPECIAL_QUERY_PARAMS).includes(queryParam)){// Not sure if this is necessary or if mongoose does the right thing.if(req.query[queryParam]==="true"){query[queryParam]=true;}elseif(req.query[queryParam]==="false"){query[queryParam]=false;}else{query[queryParam]=req.query[queryParam];}}else{logger.debug("Unallowed query param",queryParam);returnres.status(400).json({message: `${queryParam} is not allowed as a query param.`});}}// Special operators. NOTE: these request Mongo Atlas.if(req.query.$search){mongoose.connection.db.collection(model.collection.collectionName);}if(req.query.$autocomplete){mongoose.connection.db.collection(model.collection.collectionName);}if(options.queryFilter){letqueryFilter;try{queryFilter=awaitoptions.queryFilter(req.user,query);}catch(e){returnres.status(400).json({message: `Query filter error: ${e}`});}// If the query filter returns null specifically, we know this is a query that shouldn't// return any results.if(queryFilter===null){returnres.json({data: []});}query={...query, ...queryFilter};}letlimit=options.defaultLimit??100;if(Number(req.query.limit)){limit=Math.min(Number(req.query.limit),options.maxLimit??500);}letbuiltQuery=model.find(query).limit(limit);if(req.query.page){builtQuery=builtQuery.skip((Number(req.query.page)-1)*limit);}if(options.sort){builtQuery=builtQuery.sort(options.sort);}// TODO: we should handle nested serializers here.for(constpopulatePathofoptions.populatePaths??[]){builtQuery=builtQuery.populate(populatePath);}letdata: Document<T,{},{}>[];try{data=awaitbuiltQuery.exec();}catch(e){logger.error(`List error: ${(easany).stack}`);returnres.status(500).send();}// TODO add paginationtry{returnres.json({data: serialize(data,req.user)});}catch(e){logger.error("Serialization error",e);returnres.status(500).send();}});router.get("/:id",authenticateMiddleware(true),async(req,res)=>{if(!(awaitcheckPermissions("read",options.permissions.read,req.user))){logger.warn(`Access to READ on ${model.name} denied for ${req.user?.id}`);returnres.status(405).send();}constdata=awaitmodel.findById(req.params.id);if(!data){returnres.status(404).send();}if(!(awaitcheckPermissions("read",options.permissions.read,req.user,data))){logger.warn(`Access to READ on ${model.name}:${req.params.id} denied for ${req.user?.id}`);returnres.status(403).send();}returnres.json({data: serialize(data,req.user)});});router.put("/:id",authenticateMiddleware(true),async(req,res)=>{// Patch is what we want 90% of the timereturnres.status(500);});router.patch("/:id",authenticateMiddleware(true),async(req,res)=>{if(!(awaitcheckPermissions("update",options.permissions.update,req.user))){logger.warn(`Access to PATCH on ${model.name} denied for ${req.user?.id}`);returnres.status(405).send();}letdoc=awaitmodel.findById(req.params.id);if(!doc){returnres.status(404).send();}if(!(awaitcheckPermissions("update",options.permissions.update,req.user,doc))){logger.warn(`Patch not allowed for user ${req.user?.id} on doc ${doc._id}`);returnres.status(403).send();}letbody;try{body=transform(req.body,"update",req.user);}catch(e){logger.warn(`Patch failed for user ${req.user?.id}: ${(easany).message}`);returnres.status(403).send({message: (easany).message});}if(options.preUpdate){try{body=options.preUpdate(body,req);}catch(e){returnres.status(400).send({message: `Pre Update error: ${(easany).message}`});}if(body===null){returnres.status(403).send({message: "Pre Update returned null"});}}try{doc=awaitmodel.findOneAndUpdate({_id: req.params.id},bodyasany,{new: true});}catch(e){returnres.status(400).send({message: (easany).message});}if(options.postUpdate){try{awaitoptions.postUpdate(doc,req);}catch(e){returnres.status(400).send({message: `Post Update error: ${(easany).message}`});}}returnres.json({data: serialize(doc,req.user)});});router.delete("/:id",authenticateMiddleware(true),async(req,res)=>{if(!(awaitcheckPermissions("delete",options.permissions.delete,req.user))){logger.warn(`Access to DELETE on ${model.name} denied for ${req.user?.id}`);returnres.status(405).send();}constdata=awaitmodel.findById(req.params.id);if(!data){returnres.status(404).send();}if(!(awaitcheckPermissions("delete",options.permissions.delete,req.user,data))){logger.warn(`Access to DELETE on ${model.name}:${req.params.id} denied for ${req.user?.id}`);returnres.status(403).send();}if(options.preDelete){try{constbody=options.preDelete(data,req);if(body===null){returnres.status(403).send({message: "Pre Delete returned null"});}}catch(e){returnres.status(400).send({message: `Pre Delete error: ${(easany).message}`});}}// Support .deleted from isDeleted pluginif(Object.keys(model.schema.paths).includes("deleted")&&model.schema.paths.deleted.instance==="Boolean"){data.deleted=true;awaitdata.save();}else{// For models without the isDeleted plugintry{awaitdata.remove();}catch(e){returnres.status(400).send({message: (easany).message});}}if(options.postDelete){try{awaitoptions.postDelete(req);}catch(e){returnres.status(400).send({message: `Post Delete error: ${(easany).message}`});}}returnres.status(204).send();});returnrouter;}
7ff1fa2c5b7a1b2dd01c95196964a623edc4687d
The text was updated successfully, but these errors were encountered:
add rate limit
ferns-api/src/mongooseRestFramework.ts
Line 639 in 82994f2
7ff1fa2c5b7a1b2dd01c95196964a623edc4687d
The text was updated successfully, but these errors were encountered: