Files
what-to-eat/cloudfunctions/cook-user-data/index.js
T

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
},
}