130 lines
3.4 KiB
JavaScript
130 lines
3.4 KiB
JavaScript
/**
|
|
* 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
|
|
},
|
|
}
|