237 lines
7.7 KiB
JavaScript
237 lines
7.7 KiB
JavaScript
/**
|
|
* Cook recipe feedback and search signals (YunYouJun/cook#100 follow-up).
|
|
*
|
|
* - `report`: a user flags a recipe (dead video, wrong ingredients, ...).
|
|
* Signing in is optional; anonymous reports are keyed by a random device id.
|
|
* - `signal`: anonymous, aggregated search signals (a selection with no
|
|
* results, or which result was opened at which rank) used to tune filtering.
|
|
* - `export`: maintainers pull everything for the recipe-data-review skill.
|
|
* Only over the HTTP trigger with the COOK_FEEDBACK_EXPORT_TOKEN header.
|
|
*
|
|
* All collections are ADMINONLY; browsers only reach them through this
|
|
* function. Raw device ids and uids are hashed before they are stored as a
|
|
* reporter key; the uid itself is kept only on reports from signed-in users so
|
|
* maintainers can follow up.
|
|
*/
|
|
|
|
const { Buffer } = require('node:buffer')
|
|
const crypto = require('node:crypto')
|
|
const process = require('node:process')
|
|
|
|
const FEEDBACK = 'cook_recipe_feedback'
|
|
const SIGNALS = 'cook_search_signals'
|
|
const QUOTA = 'cook_feedback_quota'
|
|
|
|
const KINDS = new Set(['video', 'stuff', 'info', 'other'])
|
|
const MODES = new Set(['loose', 'strict', 'survival'])
|
|
const SIGNAL_TYPES = new Set(['empty', 'open'])
|
|
const DAILY_QUOTA = 200
|
|
const MAX_NOTE = 200
|
|
const MAX_NAME = 100
|
|
const MAX_STUFF = 12
|
|
const MAX_WORD = 20
|
|
const DEVICE_ID_RE = /^[\w-]{16,64}$/
|
|
const EXPORT_PAGE = 1000
|
|
const ANON_UIDS = new Set(['', 'anon'])
|
|
|
|
let app
|
|
|
|
function getApp() {
|
|
if (!app) {
|
|
// Loaded lazily so tests can inject a fake app without the SDK installed.
|
|
const tcb = require('@cloudbase/node-sdk')
|
|
app = tcb.init({ env: tcb.SYMBOL_CURRENT_ENV })
|
|
}
|
|
return app
|
|
}
|
|
|
|
function callerUid() {
|
|
try {
|
|
const info = getApp().auth().getUserInfo()
|
|
const uid = info && info.uid ? String(info.uid) : ''
|
|
return ANON_UIDS.has(uid) || (info && info.isAnonymous === true) ? '' : uid
|
|
}
|
|
catch {
|
|
return ''
|
|
}
|
|
}
|
|
|
|
function fail(code) {
|
|
return { ok: false, code }
|
|
}
|
|
|
|
function hash(text) {
|
|
return crypto.createHash('sha256').update(text).digest('hex').slice(0, 32)
|
|
}
|
|
|
|
/** Calendar day in China, which is when the quota resets. */
|
|
function today(now) {
|
|
return new Date(now + 8 * 3600 * 1000).toISOString().slice(0, 10)
|
|
}
|
|
|
|
function isWord(value, max = MAX_WORD) {
|
|
return typeof value === 'string' && value.trim().length > 0 && value.length <= max
|
|
}
|
|
|
|
function isIndex(value, max) {
|
|
return Number.isInteger(value) && value >= 0 && value <= max
|
|
}
|
|
|
|
/** Who is calling: the account when signed in, otherwise the device. */
|
|
function readReporter(event) {
|
|
const uid = callerUid()
|
|
if (uid)
|
|
return { key: hash(`uid:${uid}`), uid }
|
|
if (typeof event.deviceId === 'string' && DEVICE_ID_RE.test(event.deviceId))
|
|
return { key: hash(`device:${event.deviceId}`), uid: '' }
|
|
return null
|
|
}
|
|
|
|
/** Count one call against the reporter's daily quota. */
|
|
async function takeQuota(reporter, now) {
|
|
const db = getApp().database()
|
|
const id = hash(`${reporter.key}|${today(now)}`)
|
|
const doc = db.collection(QUOTA).doc(id)
|
|
const result = await doc.update({ count: db.command.inc(1) })
|
|
if (!result || !result.updated) {
|
|
await doc.set({ count: 1, day: today(now) })
|
|
return true
|
|
}
|
|
const current = await doc.get()
|
|
const count = current && current.data && current.data[0] ? current.data[0].count : 0
|
|
return count <= DAILY_QUOTA
|
|
}
|
|
|
|
async function report(event, reporter, now) {
|
|
const { recipeId, recipeName, kind } = event
|
|
const note = typeof event.note === 'string' ? event.note.trim().slice(0, MAX_NOTE) : ''
|
|
if (!isIndex(recipeId, 1e6) || !isWord(recipeName, MAX_NAME) || !KINDS.has(kind))
|
|
return fail('INVALID_REPORT')
|
|
if (!await takeQuota(reporter, now))
|
|
return fail('RATE_LIMITED')
|
|
|
|
// One report per reporter, recipe and kind per day; repeats just update the note.
|
|
const day = today(now)
|
|
const id = hash(`${reporter.key}|${recipeId}|${kind}|${day}`)
|
|
await getApp().database().collection(FEEDBACK).doc(id).set({
|
|
recipeId,
|
|
recipeName: recipeName.trim(),
|
|
kind,
|
|
note,
|
|
reporter: reporter.key,
|
|
uid: reporter.uid,
|
|
day,
|
|
createdAt: now,
|
|
})
|
|
return { ok: true }
|
|
}
|
|
|
|
function readSelection(event) {
|
|
const stuff = Array.isArray(event.stuff) ? event.stuff : null
|
|
if (!stuff || !stuff.length || stuff.length > MAX_STUFF || !stuff.every(item => isWord(item)))
|
|
return null
|
|
const tool = event.tool ?? ''
|
|
const mode = event.mode ?? 'loose'
|
|
if ((tool && !isWord(tool)) || !MODES.has(mode))
|
|
return null
|
|
return { stuff: [...new Set(stuff.map(item => item.trim()))].sort(), tool, mode }
|
|
}
|
|
|
|
async function signal(event, reporter, now) {
|
|
const selection = readSelection(event)
|
|
if (!selection || !SIGNAL_TYPES.has(event.type))
|
|
return fail('INVALID_SIGNAL')
|
|
const open = event.type === 'open'
|
|
if (open && (!isIndex(event.recipeId, 1e6) || !isIndex(event.rank, 999)))
|
|
return fail('INVALID_SIGNAL')
|
|
if (!await takeQuota(reporter, now))
|
|
return fail('RATE_LIMITED')
|
|
|
|
const db = getApp().database()
|
|
const recipeId = open ? event.recipeId : -1
|
|
const rank = open ? event.rank : 0
|
|
const id = hash([event.type, selection.stuff.join('、'), selection.tool, selection.mode, recipeId].join('|'))
|
|
const doc = db.collection(SIGNALS).doc(id)
|
|
const day = today(now)
|
|
const result = await doc.update({ count: db.command.inc(1), rankSum: db.command.inc(rank), lastDay: day })
|
|
if (!result || !result.updated)
|
|
await doc.set({ type: event.type, ...selection, recipeId, count: 1, rankSum: rank, firstDay: day, lastDay: day })
|
|
return { ok: true }
|
|
}
|
|
|
|
function sameToken(given, expected) {
|
|
if (typeof given !== 'string' || !expected)
|
|
return false
|
|
const a = Buffer.from(given)
|
|
const b = Buffer.from(expected)
|
|
return a.length === b.length && crypto.timingSafeEqual(a, b)
|
|
}
|
|
|
|
async function readAll(name, where) {
|
|
const db = getApp().database()
|
|
const rows = []
|
|
for (let skip = 0; ; skip += EXPORT_PAGE) {
|
|
const query = where ? db.collection(name).where(where) : db.collection(name)
|
|
const result = await query.skip(skip).limit(EXPORT_PAGE).get()
|
|
const page = (result && result.data) || []
|
|
rows.push(...page.map(({ _id, _openid, uid, ...rest }) => ({ ...rest, signedIn: Boolean(uid) })))
|
|
if (page.length < EXPORT_PAGE)
|
|
return rows
|
|
}
|
|
}
|
|
|
|
async function exportAll(since) {
|
|
const db = getApp().database()
|
|
const after = Number.isFinite(since) && since > 0 ? since : 0
|
|
return {
|
|
ok: true,
|
|
feedback: await readAll(FEEDBACK, after ? { createdAt: db.command.gt(after) } : null),
|
|
signals: await readAll(SIGNALS, null),
|
|
}
|
|
}
|
|
|
|
/** Requests through the HTTP trigger: only the token-protected export. */
|
|
async function handleHttp(event) {
|
|
const headers = event.headers || {}
|
|
if (!sameToken(headers['x-cook-token'], process.env.COOK_FEEDBACK_EXPORT_TOKEN || ''))
|
|
return { statusCode: 401, body: JSON.stringify(fail('UNAUTHORIZED')) }
|
|
const params = event.queryStringParameters || {}
|
|
const body = await exportAll(Number(params.since))
|
|
return { statusCode: 200, headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }
|
|
}
|
|
|
|
async function main(event = {}) {
|
|
try {
|
|
if (event.httpMethod)
|
|
return await handleHttp(event)
|
|
|
|
const reporter = readReporter(event)
|
|
if (!reporter)
|
|
return fail('REPORTER_REQUIRED')
|
|
const now = Date.now()
|
|
switch (event.action) {
|
|
case 'report':
|
|
return await report(event, reporter, now)
|
|
case 'signal':
|
|
return await signal(event, reporter, now)
|
|
default:
|
|
return fail('UNKNOWN_ACTION')
|
|
}
|
|
}
|
|
catch (error) {
|
|
console.error('[cook-feedback]', event.action || event.httpMethod, error && error.message)
|
|
return fail('INTERNAL_ERROR')
|
|
}
|
|
}
|
|
|
|
exports.main = main
|
|
exports._internal = {
|
|
DAILY_QUOTA,
|
|
FEEDBACK,
|
|
QUOTA,
|
|
SIGNALS,
|
|
setAppForTest(nextApp) {
|
|
app = nextApp
|
|
},
|
|
}
|