diff --git a/.env.sample b/.env.sample index 4b559526..50b9ee26 100644 --- a/.env.sample +++ b/.env.sample @@ -10,8 +10,13 @@ PORT=5001 # Rollbar error logging ROLLBAR_ENV=localhost + +# Rollbar post_server_item, for chatbot & graphql ROLLBAR_TOKEN= +# Rollbar post_client_item, for LIFF +ROLLBAR_CLIENT_TOKEN= + # This should match rumors-api's RUMORS_LINE_BOT_SECRET APP_SECRET=CHANGE_ME @@ -33,7 +38,6 @@ USERID_BLACKLIST= # LIFF URL for reason input LIFF_URL=line://app/1631030389-xb5mnDdN -LIFF_CORS_ORIGIN=https://cofacts.github.io # Facebook App ID for share dialog FACEBOOK_APP_ID=719656818195367 diff --git a/.eslintrc.js b/.eslintrc.js index c270699f..9b673eca 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -6,7 +6,7 @@ module.exports = { 'plugin:import/warnings', 'prettier', ], - env: {node: true, es6: true, jest: true, browser: true}, + env: {node: true, es6: true, jest: true}, plugins: [ 'prettier', ], diff --git a/README.md b/README.md index 66a8f3df..e49cffa5 100644 --- a/README.md +++ b/README.md @@ -75,14 +75,36 @@ We recommend using [ngrok configuration file](https://ngrok.com/docs#config) to We are using LIFF to collect user's reason when submitting article & negative feedbacks. -It is accessible under `/liff` of dev server (http://localhost:5001) or production chatbot server. +If you don't need to develop LIFF, you can directly use `LIFF_URL` provided in `.env.sample`. + +If you want to modify LIFF, you may need to follow these steps: + +#### Creating your own LIFF app + +To create LIFF apps, please follow instructions under [official document](https://developers.line.biz/en/docs/liff/getting-started/), which involves +- Creating a LINE login channel +- Select `chat_message.write` in scope (for LIFF to send messages) +After acquiring LIFF URL, place it in `.env` as `LIFF_URL`. +- Set `Endpoint URL` to start with your chabbot endpoint, and add `/liff/index.html` as postfix. + +#### Developing LIFF + +To develop LIFF, after `npm run dev`, it is accessible under `/liff/index.html` of dev server (http://localhost:5001) or production chatbot server. In development mode, it spins a webpack-dev-server on `localhost:` (default to `8080`), and `/liff` of chatbot server proxies all requests to the webpack-dev-server. -In production, LIFF files are compiled to `/liff` directory and served as static files by the chatbot server. +A tip to develop LIFF in browser is: +1. trigger LIFF in the mobile phone +2. Get LIFF token from dev server proxy log (something like `GET /liff/index.html?p=&token= proxy to -> ...`) +3. Visit `https:///liff/index.html?p=&token=` in desktop browser for easier development + +`liff.init()` would still work in desktop browser, so that the app renders, enabling us to debug web layouts on desktop. +`liff.sendMessages()` would not work, though. + +#### How LIFF is deployed on production -To connect with chatbot, some setup on [LINE developer console](https://developers.line.biz/console/) are required. +On production, LIFF files are compiled to `/liff` directory and served as static files by the chatbot server. ### Process image message(using Tesseract-OCR) diff --git a/package.json b/package.json index 37eb2371..98fa8a39 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,6 @@ }, "homepage": "https://github.com/cofacts/rumors-line-bot#readme", "dependencies": { - "@koa/cors": "^2.2.3", "@line/bot-sdk": "^6.8.4", "apollo-server-koa": "^2.11.0", "googleapis": "^36.0.0", diff --git a/src/graphql/__tests__/context.js b/src/graphql/__tests__/context.js index 6b56ad84..70f358fa 100644 --- a/src/graphql/__tests__/context.js +++ b/src/graphql/__tests__/context.js @@ -25,8 +25,8 @@ it('Returns user context', async () => { { context { state - issuedAt data { + sessionId searchedText } } @@ -37,8 +37,8 @@ it('Returns user context', async () => { userId: 'U12345678', userContext: { state: 'CHOOSING_ARTICLE', - issuedAt: 1586013070089, data: { + sessionId: 1586013070089, searchedText: 'Foo', }, }, @@ -50,8 +50,8 @@ it('Returns user context', async () => { "context": Object { "data": Object { "searchedText": "Foo", + "sessionId": "1586013070089", }, - "issuedAt": 1586013070089, "state": "CHOOSING_ARTICLE", }, }, diff --git a/src/graphql/__tests__/index.js b/src/graphql/__tests__/index.js index b470499f..164b6d56 100644 --- a/src/graphql/__tests__/index.js +++ b/src/graphql/__tests__/index.js @@ -1,6 +1,7 @@ jest.mock('src/lib/redisClient'); import { getContext } from '../'; +import { sign } from 'src/lib/jwt'; import redis from 'src/lib/redisClient'; beforeEach(() => { @@ -14,49 +15,107 @@ describe('getContext', () => { expect(context).toMatchInlineSnapshot(` Object { "userContext": null, - "userId": "", + "userId": null, } `); }); it('generates null context for wrong credentials', async () => { redis.get.mockImplementationOnce(() => ({ - nonce: 'correctpass', + data: { + sessionId: 'correct-session-id', + }, })); - const context = await getContext({ - ctx: { - req: { - headers: { - authorization: `basic ${Buffer.from('user1:wrongpass').toString( - 'base64' - )}`, + const jwtWithWrongSession = sign({ + sessionId: 'wrong-session-id', + sub: 'user1', + exp: Date.now() / 1000 + 10, // future timestamp + }); + + expect( + await getContext({ + ctx: { + req: { + headers: { + authorization: `Bearer ${jwtWithWrongSession}`, + }, }, }, - }, + }) + ).toMatchInlineSnapshot(` + Object { + "userContext": null, + "userId": "user1", + } + `); + + const jwtWithExpiredSession = sign({ + sessionId: 'correct-session-id', + sub: 'user1', + exp: Date.now() / 1000 - 10, // past timestamp }); - expect(context).toMatchInlineSnapshot(` + // Expired JWTs will fail verification, thus its userId is not resolved either + expect( + await getContext({ + ctx: { + req: { + headers: { + authorization: `Bearer ${jwtWithExpiredSession}`, + }, + }, + }, + }) + ).toMatchInlineSnapshot(` Object { "userContext": null, - "userId": "user1", + "userId": null, + } + `); + + const jwtWithNoSub = sign({ + sessionId: 'correct-session-id', + exp: Date.now() / 1000 + 10, // future timestamp + }); + + expect( + await getContext({ + ctx: { + req: { + headers: { + authorization: `Bearer ${jwtWithNoSub}`, + }, + }, + }, + }) + ).toMatchInlineSnapshot(` + Object { + "userContext": null, + "userId": null, } `); }); it('reads userContext for logged-in requests', async () => { redis.get.mockImplementationOnce(() => ({ - nonce: 'correctpass', + data: { + sessionId: 'correct-session-id', + }, foo: 'bar', })); + const jwt = sign({ + sessionId: 'correct-session-id', + sub: 'user1', + exp: Date.now() / 1000 + 10, // future timestamp + }); + const context = await getContext({ ctx: { req: { headers: { - authorization: `basic ${Buffer.from('user1:correctpass').toString( - 'base64' - )}`, + authorization: `Bearer ${jwt}`, }, }, }, @@ -65,8 +124,10 @@ describe('getContext', () => { expect(context).toMatchInlineSnapshot(` Object { "userContext": Object { + "data": Object { + "sessionId": "correct-session-id", + }, "foo": "bar", - "nonce": "correctpass", }, "userId": "user1", } diff --git a/src/graphql/index.js b/src/graphql/index.js index fc2460a8..6469cd51 100644 --- a/src/graphql/index.js +++ b/src/graphql/index.js @@ -2,6 +2,7 @@ import fs from 'fs'; import path from 'path'; import { ApolloServer, makeExecutableSchema } from 'apollo-server-koa'; import redis from 'src/lib/redisClient'; +import { verify, read } from 'src/lib/jwt'; export const schema = makeExecutableSchema({ typeDefs: fs.readFileSync(path.join(__dirname, `./typeDefs.graphql`), { @@ -25,29 +26,39 @@ export const schema = makeExecutableSchema({ }, {}), }); +// Empty context for non-auth public APIs +const EMPTY_CONTEXT = { + userId: null, + userContext: null, +}; + /** * @param {{ctx: Koa.Context}} * @returns {object} */ export async function getContext({ ctx: { req } }) { - const [userId, nonce] = Buffer.from( - (req.headers.authorization || '').replace(/^basic /, ''), - 'base64' - ) - .toString() - .split(':'); - - let userContext = null; - if (userId && nonce) { - const context = await redis.get(userId); - if (context && context.nonce === nonce) { - userContext = context; - } + const jwt = (req.headers.authorization || '').replace(/^Bearer /, ''); + if (!jwt || !verify(jwt)) { + return EMPTY_CONTEXT; } + const parsed = read(jwt); + + if (!parsed || !parsed.sub) { + return EMPTY_CONTEXT; + } + + const context = await redis.get(parsed.sub); + return { - userId, - userContext, + userId: parsed.sub, + userContext: + context && + context.data && + parsed.sessionId && + context.data.sessionId === parsed.sessionId + ? context + : null, }; } diff --git a/src/graphql/typeDefs.graphql b/src/graphql/typeDefs.graphql index 36cf2819..9733547a 100644 --- a/src/graphql/typeDefs.graphql +++ b/src/graphql/typeDefs.graphql @@ -145,11 +145,11 @@ enum MessagingAPIInsightStatus { type UserContext { state: String - issuedAt: Float data: StateData } type StateData { + sessionId: String searchedText: String selectedArticleId: ID selectedArticleText: String diff --git a/src/index.js b/src/index.js index b4a04f73..ddd16c99 100644 --- a/src/index.js +++ b/src/index.js @@ -1,6 +1,5 @@ import Koa from 'koa'; import Router from 'koa-router'; -import cors from '@koa/cors'; import serve from 'koa-static-server'; import path from 'path'; @@ -26,26 +25,15 @@ router.get('/', ctx => { ctx.body = JSON.stringify({ version }); }); -// TODO: legacy route, remove this -router.get( - '/context/:userId', - cors({ - origin: process.env.LIFF_CORS_ORIGIN, - }), - async ctx => { - const { state, issuedAt } = (await redis.get(ctx.params.userId)) || {}; - - ctx.body = { - state, - issuedAt, - }; - } -); router.use('/callback', webhookRouter.routes(), webhookRouter.allowedMethods()); if (process.env.NODE_ENV === 'production') { app.use( - serve({ rootDir: path.join(__dirname, '../liff'), rootPath: '/liff' }) + serve({ + rootDir: path.join(__dirname, '../liff'), + rootPath: '/liff', + maxage: 31536000 * 1000, // https://stackoverflow.com/a/7071880/1582110 + }) ); } else { app.use( diff --git a/src/liff/.eslintrc.js b/src/liff/.eslintrc.js new file mode 100644 index 00000000..6b0ca8f4 --- /dev/null +++ b/src/liff/.eslintrc.js @@ -0,0 +1,15 @@ +module.exports = { + env: { + browser: true, + node: false, + jest: false, + }, + globals: { + // global scripts include in index.html + rollbar: 'readonly', + liff: 'readonly', + + // Define plugin + LIFF_ID: 'readonly', + } +}; diff --git a/src/liff/App.svelte b/src/liff/App.svelte index 1723be3b..d5ee67eb 100644 --- a/src/liff/App.svelte +++ b/src/liff/App.svelte @@ -1,21 +1,57 @@ - - -

{t`Hello ${translated} !`}

-

{@html t`Click here for more info.`}

- - - - +{#if expired} +

{t`Sorry, the button is expired.`}

+{:else} + +{/if} diff --git a/src/liff/index.html b/src/liff/index.html index 2b9be174..997d64cf 100644 --- a/src/liff/index.html +++ b/src/liff/index.html @@ -4,7 +4,31 @@ Cofacts + + +
+ + + Loading... +
\ No newline at end of file diff --git a/src/liff/index.js b/src/liff/index.js index ac0f4e7b..aadff929 100644 --- a/src/liff/index.js +++ b/src/liff/index.js @@ -1,15 +1,15 @@ import 'core-js'; import 'normalize.css'; import './index.scss'; +import { isDuringLiffRedirect } from './lib'; import App from './App.svelte'; -const app = new App({ - target: document.body, - props: { - name: 'world', - }, -}); - -window.app = app; +liff.init({ liffId: LIFF_ID }).then(() => { + // liff.init should have corrected the path now, don't initialize app and just wait... + // Ref: https://www.facebook.com/groups/linebot/permalink/2380490388948200/?comment_id=2380868955577010 + if (isDuringLiffRedirect) return; -export default app; + // Cleanup loading + document.getElementById('loading').remove(); + new App({ target: document.body }); +}); diff --git a/src/liff/index.scss b/src/liff/index.scss index d121ddf9..950d2d62 100644 --- a/src/liff/index.scss +++ b/src/liff/index.scss @@ -36,5 +36,6 @@ caption { } body { + box-sizing: border-box; padding: 16px; } diff --git a/src/liff/lib.js b/src/liff/lib.js new file mode 100644 index 00000000..e21072c6 --- /dev/null +++ b/src/liff/lib.js @@ -0,0 +1,77 @@ +import { writable } from 'svelte/store'; + +const params = new URLSearchParams(location.search); + +/** + * Boolean value indicating if we are in the middle of LIFF redirect. + * Ref: https://www.facebook.com/groups/linebot/permalink/2380490388948200/?comment_id=2380868955577010 + */ +export const isDuringLiffRedirect = !!params.get('liff.state'); + +/** + * Current page. Initialized from URL param. + */ +export const page = writable(params.get('p')); + +/** + * Original JWT token from URL param. + */ +export const token = params.get('token'); + +/** + * Data parsed from JWT token (Javascript object). + * + * Note: the JWT token is taken from URL and is not validated, thus its data cannot be considered as + * safe from XSS. + */ +export const parsedToken = token ? JSON.parse(atob(token.split('.')[1])) : {}; + +/** + * Usage: gql`query {...}`(variables) + * + * @returns {(variables: object): Promise} + */ +export const gql = (query, ...substitutions) => variables => { + const queryAndVariable = { + query: String.raw(query, ...substitutions), + }; + + if (variables) queryAndVariable.variables = variables; + + let status; + + return fetch('/graphql', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(queryAndVariable), + }) + .then(r => { + status = r.status; + return r.json(); + }) + .then(resp => { + if (status === 400) { + throw new Error( + `GraphQL Error: ${resp.errors + .map(({ message }) => message) + .join('\n')}` + ); + } + if (resp.errors) { + // When status is 200 but have error, just print them out. + console.error('GraphQL operation contains error:', resp.errors); + rollbar.error( + 'GraphQL error', + { + body: JSON.stringify(queryAndVariable), + url: URL, + }, + { resp } + ); + } + return resp; + }); +}; diff --git a/src/liff/pages/NegativeFeedback.svelte b/src/liff/pages/NegativeFeedback.svelte new file mode 100644 index 00000000..b82e309b --- /dev/null +++ b/src/liff/pages/NegativeFeedback.svelte @@ -0,0 +1,9 @@ + + + + {t`Comment on the not-useful reply`} + + +

Negative Feedback

\ No newline at end of file diff --git a/src/liff/pages/PositiveFeedback.svelte b/src/liff/pages/PositiveFeedback.svelte new file mode 100644 index 00000000..610612dd --- /dev/null +++ b/src/liff/pages/PositiveFeedback.svelte @@ -0,0 +1,9 @@ + + + + {t`Comment on the useful reply`} + + +

Positive feedback

\ No newline at end of file diff --git a/src/liff/pages/Reason.svelte b/src/liff/pages/Reason.svelte new file mode 100644 index 00000000..bd7a8f1f --- /dev/null +++ b/src/liff/pages/Reason.svelte @@ -0,0 +1,9 @@ + + + + {t`Provide more info on the message`} (2/2) + + +

Reason Input

\ No newline at end of file diff --git a/src/liff/pages/Source.svelte b/src/liff/pages/Source.svelte new file mode 100644 index 00000000..7ee3e94d --- /dev/null +++ b/src/liff/pages/Source.svelte @@ -0,0 +1,19 @@ + + + + {t`Provide more info on the message`} (1/2) + + +

Source select

+ +
{JSON.stringify(context, null, '  ')}
+ + + \ No newline at end of file diff --git a/src/webhook/handlers/__tests__/__snapshots__/askingArticleSubmissionConsent.test.js.snap b/src/webhook/handlers/__tests__/__snapshots__/askingArticleSubmissionConsent.test.js.snap index 37f831cc..c522d613 100644 --- a/src/webhook/handlers/__tests__/__snapshots__/askingArticleSubmissionConsent.test.js.snap +++ b/src/webhook/handlers/__tests__/__snapshots__/askingArticleSubmissionConsent.test.js.snap @@ -119,7 +119,7 @@ A LINE group", "action": Object { "label": "Provide more info", "type": "uri", - "uri": "line://app/1631030389-xb5mnDdN?p=reason&token=eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjE1Nzc5MjMyMDB9.zfrE2uvmsu7mPH23-TZ5L_mfjiNEeiX-x5Md4Lvsk7I", + "uri": "line://app/1631030389-xb5mnDdN/liff/index.html?p=reason&token=eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjE1Nzc5MjMyMDB9.zfrE2uvmsu7mPH23-TZ5L_mfjiNEeiX-x5Md4Lvsk7I", }, "color": "#ffb600", "style": "primary", diff --git a/src/webhook/handlers/__tests__/__snapshots__/askingReplyRequestReason.test.js.snap b/src/webhook/handlers/__tests__/__snapshots__/askingReplyRequestReason.test.js.snap index e1c5446b..ff3e4e24 100644 --- a/src/webhook/handlers/__tests__/__snapshots__/askingReplyRequestReason.test.js.snap +++ b/src/webhook/handlers/__tests__/__snapshots__/askingReplyRequestReason.test.js.snap @@ -42,7 +42,7 @@ A LINE group", "action": Object { "label": "Provide more info", "type": "uri", - "uri": "line://app/1631030389-xb5mnDdN?p=reason&token=eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjE1Nzc5MjMyMDB9.zfrE2uvmsu7mPH23-TZ5L_mfjiNEeiX-x5Md4Lvsk7I", + "uri": "line://app/1631030389-xb5mnDdN/liff/index.html?p=reason&token=eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjE1Nzc5MjMyMDB9.zfrE2uvmsu7mPH23-TZ5L_mfjiNEeiX-x5Md4Lvsk7I", }, "color": "#ffb600", "style": "primary", diff --git a/src/webhook/handlers/__tests__/__snapshots__/choosingArticle.test.js.snap b/src/webhook/handlers/__tests__/__snapshots__/choosingArticle.test.js.snap index 14045e21..1dfe71f7 100644 --- a/src/webhook/handlers/__tests__/__snapshots__/choosingArticle.test.js.snap +++ b/src/webhook/handlers/__tests__/__snapshots__/choosingArticle.test.js.snap @@ -53,7 +53,7 @@ Object { "action": Object { "label": "🆕 Submit to database", "type": "uri", - "uri": "line://app/1631030389-xb5mnDdN?p=source&token=eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjE1Nzc5MjMyMDB9.zfrE2uvmsu7mPH23-TZ5L_mfjiNEeiX-x5Md4Lvsk7I", + "uri": "line://app/1631030389-xb5mnDdN/liff/index.html?p=source&token=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJVYzc2ZDhhZTljY2QxYWRhNGYwNmM0ZTE1MTVkNDY0NjYiLCJleHAiOjE1Nzc5MjMyMDB9.ZxHN5etvaneWUumNx9bqdBA_6IPmBIQ5sfz7jneJNvI", }, "color": "#ffb600", "style": "primary", @@ -1554,7 +1554,7 @@ Object { "action": Object { "label": "ℹī¸ Provide more info", "type": "uri", - "uri": "line://app/1631030389-xb5mnDdN?p=source&token=eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjE1Nzc5MjMyMDB9.zfrE2uvmsu7mPH23-TZ5L_mfjiNEeiX-x5Md4Lvsk7I", + "uri": "line://app/1631030389-xb5mnDdN/liff/index.html?p=source&token=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJVYzc2ZDhhZTljY2QxYWRhNGYwNmM0ZTE1MTVkNDY0NjYiLCJleHAiOjE1Nzc5MjMyMDB9.ZxHN5etvaneWUumNx9bqdBA_6IPmBIQ5sfz7jneJNvI", }, "color": "#ffb600", "style": "primary", diff --git a/src/webhook/handlers/__tests__/__snapshots__/choosingReply.test.js.snap b/src/webhook/handlers/__tests__/__snapshots__/choosingReply.test.js.snap index 5c1fa369..53e855d7 100644 --- a/src/webhook/handlers/__tests__/__snapshots__/choosingReply.test.js.snap +++ b/src/webhook/handlers/__tests__/__snapshots__/choosingReply.test.js.snap @@ -62,7 +62,7 @@ https://cofacts.hacktabl.org/article/AWDZYXxAyCdS-nWhumlz", "action": Object { "label": "Yes", "type": "uri", - "uri": "line://app/1631030389-xb5mnDdN?p=feedback/yes&token=eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjE1Nzc5MjMyMDB9.zfrE2uvmsu7mPH23-TZ5L_mfjiNEeiX-x5Md4Lvsk7I", + "uri": "line://app/1631030389-xb5mnDdN/liff/index.html?p=feedback/yes&token=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJVYWRkYzc0ZGY4YTNhMTc2YjkwMWQ5ZDY0OGIwZmM0ZmUiLCJleHAiOjE1Nzc5MjMyMDB9.rVpljRKoxb65cvss9Rtuup-N9vF0y2n46pQ9SXNRufw", }, "color": "#ffb600", "style": "primary", @@ -72,7 +72,7 @@ https://cofacts.hacktabl.org/article/AWDZYXxAyCdS-nWhumlz", "action": Object { "label": "No", "type": "uri", - "uri": "line://app/1631030389-xb5mnDdN?p=feedback/no&token=eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjE1Nzc5MjMyMDB9.zfrE2uvmsu7mPH23-TZ5L_mfjiNEeiX-x5Md4Lvsk7I", + "uri": "line://app/1631030389-xb5mnDdN/liff/index.html?p=feedback/no&token=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJVYWRkYzc0ZGY4YTNhMTc2YjkwMWQ5ZDY0OGIwZmM0ZmUiLCJleHAiOjE1Nzc5MjMyMDB9.rVpljRKoxb65cvss9Rtuup-N9vF0y2n46pQ9SXNRufw", }, "color": "#ffb600", "style": "primary", @@ -157,7 +157,7 @@ https://cofacts.hacktabl.org/article/AWDZYXxAyCdS-nWhumlz", "action": Object { "label": "Yes", "type": "uri", - "uri": "line://app/1631030389-xb5mnDdN?p=feedback/yes&token=eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjE1Nzc5MjMyMDB9.zfrE2uvmsu7mPH23-TZ5L_mfjiNEeiX-x5Md4Lvsk7I", + "uri": "line://app/1631030389-xb5mnDdN/liff/index.html?p=feedback/yes&token=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJVYWRkYzc0ZGY4YTNhMTc2YjkwMWQ5ZDY0OGIwZmM0ZmUiLCJleHAiOjE1Nzc5MjMyMDB9.rVpljRKoxb65cvss9Rtuup-N9vF0y2n46pQ9SXNRufw", }, "color": "#ffb600", "style": "primary", @@ -167,7 +167,7 @@ https://cofacts.hacktabl.org/article/AWDZYXxAyCdS-nWhumlz", "action": Object { "label": "No", "type": "uri", - "uri": "line://app/1631030389-xb5mnDdN?p=feedback/no&token=eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjE1Nzc5MjMyMDB9.zfrE2uvmsu7mPH23-TZ5L_mfjiNEeiX-x5Md4Lvsk7I", + "uri": "line://app/1631030389-xb5mnDdN/liff/index.html?p=feedback/no&token=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJVYWRkYzc0ZGY4YTNhMTc2YjkwMWQ5ZDY0OGIwZmM0ZmUiLCJleHAiOjE1Nzc5MjMyMDB9.rVpljRKoxb65cvss9Rtuup-N9vF0y2n46pQ9SXNRufw", }, "color": "#ffb600", "style": "primary", diff --git a/src/webhook/handlers/__tests__/__snapshots__/initState.test.js.snap b/src/webhook/handlers/__tests__/__snapshots__/initState.test.js.snap index bf944bdb..6521f8d1 100644 --- a/src/webhook/handlers/__tests__/__snapshots__/initState.test.js.snap +++ b/src/webhook/handlers/__tests__/__snapshots__/initState.test.js.snap @@ -365,7 +365,7 @@ My reason", "action": Object { "label": "Provide more info", "type": "uri", - "uri": "line://app/1631030389-xb5mnDdN?p=reason&token=eyJhbGciOiJIUzI1NiJ9.eyJzZXNzaW9uSWQiOjE0OTc5OTQwMTc0NDcsImV4cCI6MTU3NzkyMzIwMH0.swCHAII1dNMjmwCyhIIBZII_Csw8ME_xe5U6TgjNRJQ", + "uri": "line://app/1631030389-xb5mnDdN/liff/index.html?p=reason&token=eyJhbGciOiJIUzI1NiJ9.eyJzZXNzaW9uSWQiOjE0OTc5OTQwMTc0NDcsInN1YiI6IlVjNzZkOGFlOWNjZDFhZGE0ZjA2YzRlMTUxNWQ0NjQ2NiIsImV4cCI6MTU3NzkyMzIwMH0.V4OTSDA0wDDrlc665JRyjkC0Ux3RFqUM-RN_VNKNCcs", }, "color": "#ffb600", "style": "primary", @@ -475,7 +475,7 @@ My reason", "action": Object { "label": "Provide more info", "type": "uri", - "uri": "line://app/1631030389-xb5mnDdN?p=reason&token=eyJhbGciOiJIUzI1NiJ9.eyJzZXNzaW9uSWQiOjE0OTc5OTQwMTc0NDcsImV4cCI6MTU3NzkyMzIwMH0.swCHAII1dNMjmwCyhIIBZII_Csw8ME_xe5U6TgjNRJQ", + "uri": "line://app/1631030389-xb5mnDdN/liff/index.html?p=reason&token=eyJhbGciOiJIUzI1NiJ9.eyJzZXNzaW9uSWQiOjE0OTc5OTQwMTc0NDcsInN1YiI6IlVjNzZkOGFlOWNjZDFhZGE0ZjA2YzRlMTUxNWQ0NjQ2NiIsImV4cCI6MTU3NzkyMzIwMH0.V4OTSDA0wDDrlc665JRyjkC0Ux3RFqUM-RN_VNKNCcs", }, "color": "#ffb600", "style": "primary", @@ -696,7 +696,7 @@ Object { "action": Object { "label": "🆕 Submit to database", "type": "uri", - "uri": "line://app/1631030389-xb5mnDdN?p=source&token=eyJhbGciOiJIUzI1NiJ9.eyJzZXNzaW9uSWQiOjE0OTc5OTQwMTc0NDcsImV4cCI6MTU3NzkyMzIwMH0.swCHAII1dNMjmwCyhIIBZII_Csw8ME_xe5U6TgjNRJQ", + "uri": "line://app/1631030389-xb5mnDdN/liff/index.html?p=source&token=eyJhbGciOiJIUzI1NiJ9.eyJzZXNzaW9uSWQiOjE0OTc5OTQwMTc0NDcsInN1YiI6IlVjNzZkOGFlOWNjZDFhZGE0ZjA2YzRlMTUxNWQ0NjQ2NiIsImV4cCI6MTU3NzkyMzIwMH0.V4OTSDA0wDDrlc665JRyjkC0Ux3RFqUM-RN_VNKNCcs", }, "color": "#ffb600", "style": "primary", diff --git a/src/webhook/handlers/utils.js b/src/webhook/handlers/utils.js index 09a25183..9165e426 100644 --- a/src/webhook/handlers/utils.js +++ b/src/webhook/handlers/utils.js @@ -106,9 +106,16 @@ const LIFF_EXP_SEC = 86400; // LIFF JWT is only valid for 1 day * @returns {string} */ export function getLIFFURL(page, userId, sessionId) { - const jwt = sign({ sessionId, exp: Date.now() / 1000 + LIFF_EXP_SEC }); + const jwt = sign({ + sessionId, + sub: userId, + exp: Math.round(Date.now() / 1000) + LIFF_EXP_SEC, + }); - return `${process.env.LIFF_URL}?p=${page}&token=${jwt}`; + // /liff/index.html is required due to weird & undocumented redirect behavior of LIFF SDK v2 + // https://www.facebook.com/groups/linebot/permalink/2380490388948200/?comment_id=2380868955577010 + // + return `${process.env.LIFF_URL}/liff/index.html?p=${page}&token=${jwt}`; } export const FLEX_MESSAGE_ALT_TEXT = `📱 ${ diff --git a/webpack.config.js b/webpack.config.js index 85c3c39b..85f75c92 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -6,6 +6,7 @@ require('dotenv').config(); // https://github.com/sveltejs/template-webpack // https://github.com/hperrin/smui-example-webpack/blob/master/webpack.config.js +const { DefinePlugin } = require('webpack'); const MiniCssExtractPlugin = require('mini-css-extract-plugin'); const HtmlWebpackPlugin = require('html-webpack-plugin'); const CompressionPlugin = require('compression-webpack-plugin'); @@ -118,8 +119,16 @@ module.exports = { new HtmlWebpackPlugin({ inject: true, template: './src/liff/index.html', + // custom constants passed to index.html via htmlWebpackPlugin.options + ROLLBAR_ENV: process.env.ROLLBAR_ENV, + ROLLBAR_CLIENT_TOKEN: process.env.ROLLBAR_CLIENT_TOKEN, }), new CompressionPlugin(), + new DefinePlugin({ + LIFF_ID: JSON.stringify( + (process.env.LIFF_URL || '').replace('https://liff.line.me/', '') + ), + }), ], devtool: prod ? false : 'source-map', optimization: {