export function registerWebhookRoutes(app: Express, route: RouterFunction<User>) { route({ path: Paths.api.webhooks.base + "app-uninstalled", method: "all", requireAuth: false, validateShopifyWebhook: true, queryValidation: gwv.object<Requests.AppUninstalled>({ shop_id: gwv.string(), shop: gwv.string(), }), handler: async function (req, res, next) { const query: Requests.AppUninstalled = req.query; const userSearch = await UserDb.find({ selector: { shopify_shop_id: { $eq: parseInt(query.shop_id) } } }); if (userSearch.length === 0) { inspect(`Could not find owner of shop id ${query.shop_id} during app/uninstalled webhook. Returning true to prevent webhook retries.`); // No user found with that shopId. This webhook may be a duplicate. Return OK to prevent Shopify resending the webhook. res.status(200); return next(); } const user = userSearch[0]; // Shopify access token has already been invalidated at this point. Remove the user's Shopify data. user.shopify_access_token = undefined; user.shopify_domain = undefined; user.shopify_shop_id = undefined; user.shopify_shop_name = undefined; user.charge_id = undefined; user.plan_id = undefined; const update = await UserDb.put(user._id, user, user._rev); // Add the user's id to the auth-invalidation cache, forcing their next request to prompt them to login again. try { await Cache.setValue(CACHE_SEGMENT_AUTH, user._id, true, 21 * 1000 * 60 * 60 * 24 /* 21 days in milliseconds */); } catch (e) { inspect("Failed to delete user data from auth cache after handling app/uninstalled webhook.", e); } res.json({}); return next(); } }) }
export function registerSessionRoutes(app: Express, route: RouterFunction<User>) { route({ method: "post", path: Paths.api.sessions.base, requireAuth: false, bodyValidation: gwv.object<Requests.CreateSession>({ username: gwv.string().required(), password: gwv.string().required(), }), handler: async function (req, res, next) { const model: Requests.CreateSession = req.validatedBody; let user: User; try { user = await UserDb.get(model.username.toLowerCase()); } catch (e) { const err: boom.BoomError = e; if (err.isBoom && err.output.statusCode !== 404) { return next(e); } } if (!user || !compareSync(model.password, user.hashed_password)) { return next(boom.unauthorized("No user found with that username or password combination.")); } // The user has logged in again, so we'll remove their user id from the auth-invalidation cache. try { await Cache.deleteValue(CACHE_SEGMENT_AUTH, user._id); } catch (e) { inspect(`Failed to remove user ${user._id} from auth-invalidation cache.`, e); } res = await res.withSessionToken(user); return next(); } }); }
export default function registerRoutes(app: Express, route: RouterFunction<User>) { route({ method: "post", path: Paths.api.users.base, requireAuth: false, bodyValidation: gwv.object<Requests.CreateOrUpdateAccount>({ username: gwv.string().email().required(), password: gwv.string().min(6).max(100).required(), }), handler: async function (req, res, next) { const model: Requests.CreateOrUpdateAccount = req.validatedBody; if (await UserDb.exists(model.username.toLowerCase())) { return next(boom.badData(`A user with that username already exists.`)); } const user: User = { _id: model.username.toLowerCase(), hashed_password: hashSync(model.password), date_created: new Date().toISOString(), } const createResult = await UserDb.post(user); await res.withSessionToken({ ...user, _id: createResult.id, _rev: createResult.rev }); return next(); } }); route({ method: "put", path: Paths.api.users.base + "username", requireAuth: true, bodyValidation: gwv.object<Requests.CreateOrUpdateAccount>({ username: gwv.string().email().required(), password: gwv.string().required(), }), handler: async function (req, res, next) { const model: Requests.CreateOrUpdateAccount = req.validatedBody; if (await UserDb.exists(model.username.toLowerCase())) { return next(boom.badData(`A user with that username already exists.`)); } let user = await UserDb.get(req.user._id); const { _id, _rev } = user; // Ensure the user's password is correct before changing their username if (!compareSync(model.password, user.hashed_password)) { return next(boom.forbidden(`Your password is incorrect.`)); } try { // CouchDB does not allow modifying a doc's id, so we copy the user to a new document instead. const copyResult = await UserDb.copy(_id, model.username.toLowerCase()); const user = await UserDb.get(copyResult.id); } catch (e) { inspect("Failed to copy user model to new id.", e); return next(boom.badData("Failed to save new user id.")); } try { // Delete the old user document await UserDb.delete(_id, _rev); } catch (e) { inspect(`Failed to delete user doc ${_id} after changing username to ${model.username}`, e); } await res.withSessionToken(user); return next(); } }) route({ method: "post", path: Paths.api.users.base + "password/forgot", requireAuth: false, bodyValidation: gwv.object<Requests.ForgotPassword>({ username: gwv.string().email().required() }), handler: async function (req, res, next) { const model: Requests.ForgotPassword = req.validatedBody; let user: User; if (!UserDb.exists(model.username.toLowerCase())) { // Do not let the client know that the username does not exist. return next(); } const token = await seal({ exp: Date.now() + ((1000 * 60) * 90), // 90 minutes in milliseconds username: model.username, } as ResetToken, Constants.IRON_PASSWORD); const url = `${req.domainWithProtocol}/auth/reset-password?token=${encodeURIComponent(token)}`; const message = { content: { from: { name: "Support", email: `support@${Constants.EMAIL_DOMAIN}`, }, subject: `[${Constants.APP_NAME}] Reset your password.`, html: `<p>Hello,</p><p>You recently requested to reset your password for ${Constants.APP_NAME}. Please click the link below to reset your password.</p><p><a href='${url}'>Click here to reset your password.</a></p><p>If you did not request a password reset, please ignore this email or reply to let us know. This password reset is only valid for the next 90 minutes.</p><p>Thanks, <br/> The ${Constants.APP_NAME} Team</p>` }, recipients: [{ address: { email: model.username, } }] } //Send the password reset email const transporter = createTransport({ transport: 'sparkpost', sparkPostApiKey: Constants.SPARKPOST_API_KEY } as any); transporter.sendMail(message as any, (error, info) => { if (error) { return next(boom.wrap(error)); }; res.json({}); return next(); }); } }) route({ method: "post", path: Paths.api.users.base + "password/reset", requireAuth: false, bodyValidation: gwv.object<Requests.ResetPassword>({ new_password: gwv.string().min(6).max(100).required(), reset_token: gwv.string().required(), }), handler: async function (req, res, next) { const payload: Requests.ResetPassword = req.validatedBody; const token = await unseal<ResetToken>(payload.reset_token, Constants.IRON_PASSWORD); if (token.exp < Date.now()) { // Token has expired return next(boom.unauthorized("Token has expired.")); } const user = await UserDb.get(token.username.toLowerCase()); user.hashed_password = hashSync(payload.new_password); const updateResult = await UserDb.put(user._id, user, user._rev); res.json({}); return next(); } }) route({ method: "put", path: Paths.api.users.base + "password", requireAuth: true, bodyValidation: gwv.object<Requests.UpdatePassword>({ old_password: gwv.string().required(), new_password: gwv.string().min(6).max(100).required() }), handler: async function (req, res, next) { const payload: Requests.UpdatePassword = req.validatedBody; let user = await UserDb.get(req.user._id); // Ensure the user's current password is correct if (!compareSync(payload.old_password, user.hashed_password)) { return next(boom.forbidden(`Your current password is incorrect.`)); } // Change the user's password user.hashed_password = hashSync(payload.new_password); try { const updateResult = await UserDb.put(user._id, user, user._rev); user._rev = updateResult.rev } catch (e) { inspect("Failed to update user's password.", e); return next(e); } await res.withSessionToken(user); return next(); } }) }
export function registerShopifyRoutes(app: Express, route: RouterFunction<User>) { route({ method: "get", path: Paths.api.shopify.base + "url", requireAuth: true, queryValidation: gwv.object<Requests.GetOauthUrl>({ shop_domain: gwv.string().required(), redirect_url: gwv.string().required(), }).unknown(true), handler: async function (req, res, next) { const query: Requests.GetOauthUrl = req.validatedQuery; const isValidUrl = await Auth.isValidShopifyDomain(req.validatedQuery.shop_domain); if (!isValidUrl) { return next(boom.notAcceptable(`${query.shop_domain} is not a valid Shopify shop domain.`)); } const authUrl = await Auth.buildAuthorizationUrl(Constants.DEFAULT_SCOPES, req.validatedQuery.shop_domain, Constants.SHOPIFY_API_KEY, query.redirect_url); res.json({ url: authUrl }); return next(); } }); route({ method: "post", path: Paths.api.shopify.base + "authorize", requireAuth: true, validateShopifyRequest: true, bodyValidation: gwv.object<Requests.Authorize>({ code: gwv.string().required(), shop: gwv.string().required(), hmac: gwv.string().required(), state: gwv.string() }).unknown(true), handler: async function (req, res, next) { const model: Requests.Authorize = req.validatedBody; let user: User; try { user = await UserDb.get(req.user._id); } catch (e) { inspect(`Error getting user ${req.user._id} from database.`, e); return next(e); } const accessToken = await Auth.authorize(model.code, model.shop, Constants.SHOPIFY_API_KEY, Constants.SHOPIFY_SECRET_KEY); // Store the user's shop data user.shopify_domain = model.shop; user.shopify_access_token = accessToken; user.permissions = Constants.DEFAULT_SCOPES; try { const shop = await new Shops(model.shop, accessToken).get({ fields: "name,id" }); user.shopify_shop_name = shop.name; user.shopify_shop_id = shop.id; } catch (e) { inspect(`Failed to get shop data from ${model.shop}`, e); } try { const updateResult = await UserDb.put(user._id, user, user._rev); user._rev = updateResult.rev; } catch (e) { inspect(`Failed to update user ${user._id}'s Shopify access token`, e); return next(e); } await res.withSessionToken(user); // Don't create any webhooks unless this app is running on a real domain. Webhooks cannot point to localhost. if (Constants.ISLIVE) { // Create the AppUninstalled webhook immediately after the user accepts installation const webhooks = new Webhooks(model.shop, accessToken); const existingHooks = await webhooks.list({ topic: "app/uninstalled", fields: "id", limit: 1 }); // Ensure the webhook doesn't already exist if (existingHooks.length === 0) { const hook = await webhooks.create({ address: req.domainWithProtocol + Paths.api.webhooks.base + `app-uninstalled?shop_id=${user.shopify_shop_id}`, topic: "app/uninstalled" }) } } return next(); } }) route({ method: "get", path: Paths.api.shopify.base + "orders", requireAuth: true, queryValidation: gwv.object<Requests.ListOrders>({ limit: gwv.number().default(50), page: gwv.gt(0).default(1), status: gwv.onlyStrings("any"), }).unknown(true), handler: async function (req, res, next) { const service = new Orders(req.user.shopify_domain, req.user.shopify_access_token); const orders = await service.list(req.validatedQuery); res.json(orders); return next(); } }) route({ method: "post", path: Paths.api.shopify.base + "orders", requireAuth: true, bodyValidation: gwv.object<Requests.CreateOrder>({ city: gwv.string().required(), email: gwv.string().required(), line_item: gwv.string().required(), name: gwv.string().required(), quantity: gwv.number().required(), state: gwv.string().required(), street: gwv.string().required(), zip: gwv.string().required(), }), handler: async function (req, res, next) { const model: Requests.CreateOrder = req.validatedBody; const service = new Orders(req.user.shopify_domain, req.user.shopify_access_token); const order = await service.create({ billing_address: { address1: model.street, city: model.city, province: model.state, zip: model.zip, name: model.name, country_code: "US", default: true, }, line_items: [ { name: model.line_item, title: model.line_item, quantity: model.quantity, price: 5, }, ], financial_status: "authorized", email: model.email, }); res.json(order); return next(); } }) route({ method: "post", path: Paths.api.shopify.base + "orders/:id/open", requireAuth: true, paramValidation: gwv.object<Requests.OpenCloseDelete>({ id: gwv.number().required() }), handler: async function (req, res, next) { const params: Requests.OpenCloseDelete = req.validatedParams; const service = new Orders(req.user.shopify_domain, req.user.shopify_access_token); const order = await service.open(params.id); res.json(order); return next(); } }) route({ method: "post", path: Paths.api.shopify.base + "orders/:id/close", requireAuth: true, paramValidation: gwv.object<Requests.OpenCloseDelete>({ id: gwv.number().required() }), handler: async function (req, res, next) { const params: Requests.OpenCloseDelete = req.validatedParams; const service = new Orders(req.user.shopify_domain, req.user.shopify_access_token); const order = await service.close(params.id); res.json(order); return next(); } }) route({ method: "delete", path: Paths.api.shopify.base + "orders/:id", requireAuth: true, paramValidation: gwv.object<Requests.OpenCloseDelete>({ id: gwv.number().required() }), handler: async function (req, res, next) { const params: Requests.OpenCloseDelete = req.validatedParams; const service = new Orders(req.user.shopify_domain, req.user.shopify_access_token); await service.delete(params.id); res.json({}); return next(); } }) }