diff --git a/packages/backend/src/api/APIError.js b/packages/backend/src/api/APIError.js index e2b99a98..20abbeee 100644 --- a/packages/backend/src/api/APIError.js +++ b/packages/backend/src/api/APIError.js @@ -47,6 +47,10 @@ module.exports = class APIError { `value of ${quot(key)} must be one of: ` + allowed.map(v => quot(v)).join(', ') }, + 'invalid_token': { + status: 400, + message: () => 'Invalid token' + }, // Things 'disallowed_thing': { status: 400, @@ -450,6 +454,18 @@ module.exports = class APIError { `The value for ${quot(key)} has the following errors: ` + errors.join('; ') }, + 'share_expired': { + status: 422, + message: 'This share is expired.' + }, + 'email_must_be_confirmed': { + status: 422, + message: 'Email must be confirmed to apply a share.', + }, + 'can_not_apply_to_this_user': { + status: 422, + message: 'This share can not be applied to this user.', + }, // Chat // TODO: specifying these errors here might be a violation diff --git a/packages/backend/src/routers/share.js b/packages/backend/src/routers/share.js index df75cb43..a648894f 100644 --- a/packages/backend/src/routers/share.js +++ b/packages/backend/src/routers/share.js @@ -484,13 +484,15 @@ const v0_2 = async (req, res) => { }); return svc_token.sign('share', { $: 'token:share', - $v: 'v0.0.0', + $v: '0.0.0', uid: share_uid, + }, { + expiresIn: '14d' }); })(); - const email_link = config.origin + - `/sharelink?token=${share_token}`; + const email_link = + `${config.origin}?share_token=${share_token}`; await svc_email.send_email({ email }, 'share_by_email', { link: email_link, diff --git a/packages/backend/src/services/ShareService.js b/packages/backend/src/services/ShareService.js index ca07b967..6caaee6f 100644 --- a/packages/backend/src/services/ShareService.js +++ b/packages/backend/src/services/ShareService.js @@ -1,3 +1,7 @@ +const APIError = require("../api/APIError"); +const { get_user } = require("../helpers"); +const configurable_auth = require("../middleware/configurable_auth"); +const { Endpoint } = require("../util/expressutil"); const { whatis } = require("../util/langutil"); const { Actor, UserActorType } = require("./auth/Actor"); const BaseService = require("./BaseService"); @@ -7,12 +11,143 @@ class ShareService extends BaseService { static MODULES = { uuidv4: require('uuid').v4, validator: require('validator'), + express: require('express'), }; async _init () { this.db = await this.services.get('database').get(DB_WRITE, 'share'); } + ['__on_install.routes'] (_, { app }) { + // track: scoping iife + const router = (() => { + const require = this.require; + const express = require('express'); + return express.Router(); + })(); + + app.use('/sharelink', router); + + const svc_share = this.services.get('share'); + const svc_token = this.services.get('token'); + + Endpoint({ + route: '/check', + methods: ['POST'], + handler: async (req, res) => { + // Potentially confusing: + // The "share token" and "share cookie token" are different! + // -> "share token" is from the email link; + // it has a longer expiry time and can be used again + // if the share session expires. + // -> "share cookie token" lets the backend know it + // should grant permissions when the correct user + // is logged in. + + const share_token = req.body.token; + + if ( ! share_token ) { + throw APIError.create('field_missing', null, { + key: 'token', + }); + } + + const decoded = await svc_token.verify('share', share_token); + console.log('decoded?', decoded); + if ( decoded.$ !== 'token:share' ) { + throw APIError.create('invalid_token'); + } + + const share = await svc_share.get_share({ + uid: decoded.uid, + }); + + if ( ! share ) { + throw APIError.create('invalid_token'); + } + + res.json({ + $: 'api:share', + uid: share.uid, + email: share.recipient_email, + }); + }, + }).attach(router); + + Endpoint({ + route: '/apply', + methods: ['POST'], + mw: [configurable_auth()], + handler: async (req, res) => { + const share_uid = req.body.uid; + + const share = await svc_share.get_share({ + uid: share_uid, + }); + + share.data = this.db.case({ + mysql: () => share.data, + otherwise: () => + JSON.parse(share.data ?? '{}'), + })(); + + if ( ! share ) { + throw APIError.create('share_expired'); + } + + const actor = Actor.adapt(req.actor ?? req.user); + if ( ! actor ) { + // this shouldn't happen; auth should catch it + throw new Error('actor missing'); + } + + if ( ! actor.type.user.email_confirmed ) { + throw APIError.create('email_must_be_confirmed'); + } + + if ( actor.type.user.email !== share.recipient_email ) { + throw APIError.create('can_not_apply_to_this_user'); + } + + const issuer_user = await get_user({ + id: share.issuer_user_id, + }); + + if ( ! issuer_user ) { + throw APIError.create('share_expired'); + } + + const issuer_actor = await Actor.create(UserActorType, { + user: issuer_user, + }); + + const svc_permission = this.services.get('permission'); + + for ( const permission of share.data.permissions ) { + await svc_permission.grant_user_user_permission( + issuer_actor, + actor.type.user.username, + permission, + ); + } + + res.json({ + $: 'api:status-report', + status: 'success', + }); + } + }).attach(router); + } + + async get_share ({ uid }) { + const [share] = await this.db.read( + 'SELECT * FROM share WHERE uid = ?', + [uid], + ); + + return share; + } + async create_share ({ issuer, email,