chore: 导入清理前的完整工程状态(含 Capacitor 原生壳,作为回滚备份点)
This commit is contained in:
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* 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
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "cook-feedback",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"main": "index.js",
|
||||
"dependencies": {
|
||||
"@cloudbase/node-sdk": "^3.18.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Cook favorites / history sync (YunYouJun/cook#98).
|
||||
*
|
||||
* The `cook_user_data` collection is ADMINONLY: browsers never read or write
|
||||
* it directly. This function takes the caller from the verified CloudBase
|
||||
* session, never from the request, and always uses it as the document id, so
|
||||
* nobody can read, overwrite or pre-create another account's document.
|
||||
*/
|
||||
|
||||
const { Buffer } = require('node:buffer')
|
||||
|
||||
const COLLECTION = 'cook_user_data'
|
||||
const USER_DATA_VERSION = 1
|
||||
const MAX_DOCUMENT_BYTES = 256 * 1024
|
||||
const LIST_LIMITS = {
|
||||
favorites: 5000,
|
||||
favoriteRemovals: 1000,
|
||||
histories: 500,
|
||||
historyRemovals: 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
|
||||
}
|
||||
|
||||
/** The signed-in, non-anonymous caller, or '' when there is none. */
|
||||
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 isRecord(value) {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the shape and size of an uploaded document. Entry contents are
|
||||
* normalised again by every client that reads them.
|
||||
*/
|
||||
function readDocument(value) {
|
||||
if (!isRecord(value) || value.version !== USER_DATA_VERSION)
|
||||
return null
|
||||
if (Buffer.byteLength(JSON.stringify(value), 'utf8') > MAX_DOCUMENT_BYTES)
|
||||
return null
|
||||
|
||||
const document = { version: USER_DATA_VERSION }
|
||||
for (const [key, limit] of Object.entries(LIST_LIMITS)) {
|
||||
const list = value[key] ?? []
|
||||
if (!Array.isArray(list) || list.length > limit || !list.every(isRecord))
|
||||
return null
|
||||
document[key] = list
|
||||
}
|
||||
const clearedAt = value.historyClearedAt ?? 0
|
||||
if (typeof clearedAt !== 'number' || !Number.isFinite(clearedAt) || clearedAt < 0)
|
||||
return null
|
||||
document.historyClearedAt = clearedAt
|
||||
return document
|
||||
}
|
||||
|
||||
function stripMeta(doc) {
|
||||
if (!isRecord(doc))
|
||||
return null
|
||||
const { _id, _openid, ...rest } = doc
|
||||
return rest
|
||||
}
|
||||
|
||||
async function getDocument(uid) {
|
||||
const result = await getApp().database().collection(COLLECTION).doc(uid).get()
|
||||
const doc = result && Array.isArray(result.data) ? result.data[0] : null
|
||||
return { ok: true, document: stripMeta(doc) }
|
||||
}
|
||||
|
||||
async function setDocument(uid, input, now) {
|
||||
const document = readDocument(input)
|
||||
if (!document)
|
||||
return fail('INVALID_DOCUMENT')
|
||||
await getApp().database().collection(COLLECTION).doc(uid).set({ ...document, updatedAt: now })
|
||||
return { ok: true, updatedAt: now }
|
||||
}
|
||||
|
||||
async function main(event = {}) {
|
||||
const uid = callerUid()
|
||||
if (!uid)
|
||||
return fail('LOGIN_REQUIRED')
|
||||
|
||||
try {
|
||||
switch (event.action) {
|
||||
case 'get':
|
||||
return await getDocument(uid)
|
||||
case 'set':
|
||||
return await setDocument(uid, event.document, Date.now())
|
||||
default:
|
||||
return fail('UNKNOWN_ACTION')
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('[cook-user-data]', event.action, error && error.message)
|
||||
return fail('INTERNAL_ERROR')
|
||||
}
|
||||
}
|
||||
|
||||
exports.main = main
|
||||
exports._internal = {
|
||||
COLLECTION,
|
||||
LIST_LIMITS,
|
||||
MAX_DOCUMENT_BYTES,
|
||||
readDocument,
|
||||
setAppForTest(nextApp) {
|
||||
app = nextApp
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "cook-user-data",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"main": "index.js",
|
||||
"dependencies": {
|
||||
"@cloudbase/node-sdk": "^3.18.1"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user