diff --git a/src/main/index.ts b/src/main/index.ts index ad6d7a6..01701c1 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -116,7 +116,17 @@ function focusExisting(): void { // Single-instance lock: two instances on the same launch dir = data corruption. const gotLock = app.requestSingleInstanceLock() if (!gotLock) { - app.quit() + // A smoke run that loses the lock has tested nothing, and quitting 0 would + // report that as a pass. One stray instance left behind by an earlier run + // would then turn every gate green while executing none of them — which is + // worse than a failing test, because it looks like a working one. + if (Object.keys(process.env).some((k) => k.startsWith('MSMS_SMOKE'))) { + // eslint-disable-next-line no-console + console.log('SMOKE: FAIL - another instance holds the single-instance lock; nothing ran') + app.exit(1) + } else { + app.quit() + } } else { app.on('second-instance', focusExisting) diff --git a/src/main/ipc/register.ts b/src/main/ipc/register.ts index f235b8c..14813aa 100644 --- a/src/main/ipc/register.ts +++ b/src/main/ipc/register.ts @@ -462,6 +462,7 @@ export function registerIpc(): void { economy.deleteCategory(id, categoryId) ) H(IPC.storeCurrency, (_e, id: string, currency: string) => economy.setCurrency(id, currency)) + H(IPC.storeLayout, (_e, id: string, layout: string) => economy.setStoreLayout(id, layout)) H(IPC.storeUpsert, (_e, id: string, product: Product) => economy.upsertProduct(id, product)) H(IPC.storeDelete, (_e, id: string, productId: string) => economy.deleteProduct(id, productId)) H( diff --git a/src/main/smoke.ts b/src/main/smoke.ts index 62140b0..67b26f4 100644 --- a/src/main/smoke.ts +++ b/src/main/smoke.ts @@ -20,6 +20,14 @@ import type { LedgerEntry, Product, Scope } from '@shared/web' import { categoryName, filterLedger, ledgerSummary } from '@shared/economy' import { ANALYSIS_EVENT_LIMIT, ANALYSIS_EVENT_TYPES } from '@shared/analysis' import { effectiveScopes, normalizeScopes } from '@shared/rbac' +import { + filterProducts, + isSafeImageSrc, + normalizeLayout, + sanitizeImages, + sections, + MAX_PRODUCT_IMAGES +} from '@shared/storefront' import * as rolesMod from './web/roles' import { CRATE_ANIMATIONS, @@ -3131,7 +3139,8 @@ interface StubNode { value: string checked: boolean className: string - style: Record & { cssText?: string } + style: Record & { cssText?: string; setProperty(k: string, v: string): void } + lang?: string classList: { add(c: string): void remove(c: string): void @@ -3143,6 +3152,8 @@ interface StubNode { querySelectorAll(): StubNode[] appendChild(): void addEventListener(): void + focus(): void + setSelectionRange(): void } interface PageRun { @@ -3165,7 +3176,8 @@ function runPageScript(html: string, seed: Record = {}): PageRu value: '', checked: false, className: '', - style: {}, + lang: '', + style: Object.assign(Object.create(null), { setProperty: () => {} }), classList: { add: (c) => void cls.add(c), remove: (c) => void cls.delete(c), @@ -3180,7 +3192,9 @@ function runPageScript(html: string, seed: Record = {}): PageRu querySelector: () => mkNode(''), querySelectorAll: () => [], appendChild: () => {}, - addEventListener: () => {} + addEventListener: () => {}, + focus: () => {}, + setSelectionRange: () => {} } return n } @@ -3218,7 +3232,9 @@ function runPageScript(html: string, seed: Record = {}): PageRu querySelectorAll: (): StubNode[] => [], addEventListener: () => {}, head: mkNode('head'), - body: mkNode('body') + body: mkNode('body'), + // Both pages set `documentElement.lang` and read `.style` off it on load. + documentElement: mkNode('html') } const ctx: Record = { @@ -3237,6 +3253,10 @@ function runPageScript(html: string, seed: Record = {}): PageRu encodeURIComponent, addEventListener: () => {}, removeEventListener: () => {}, + scrollTo: () => {}, + scrollY: 0, + innerWidth: 1280, + innerHeight: 800, matchMedia: () => ({ matches: false, addEventListener: () => {} }), IntersectionObserver: class { observe(): void {} @@ -3245,7 +3265,22 @@ function runPageScript(html: string, seed: Record = {}): PageRu navigator: { language: 'en', languages: ['en'], clipboard: { writeText: () => Promise.resolve() } }, fetch: (path: string, opts?: { method?: string; body?: string }) => { calls.push(['fetch', path, opts?.method ?? 'GET', opts?.body]) - return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({}) }) + // Both pages call something on load. An empty `{}` makes those bootstrap + // paths throw asynchronously, and an unhandled rejection in the test + // output is how a real one later goes unnoticed — so the shapes they + // destructure on startup are answered plausibly. + const body: Record = path.includes('/api/public/site') + ? { + siteName: 'Test', + tagline: '', + description: '', + servers: [], + posts: [], + showStore: true, + i18n: { defaultLang: 'en', langs: { en: {} } } + } + : { servers: [], products: [], lines: [], entries: [] } + return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(body) }) }, ...seed } @@ -4193,6 +4228,172 @@ export async function runWebSmoke(): Promise { console.log('WEB-SMOKE: per-crate animation OK (own beats default, odds published, commands never are)') } + // ---- storefront: images, availability, sections, search (#76-#82) ---- + { + // Image sources are attacker-controlled - any store-scoped web user can + // set one, and it renders for every visitor to the public site. + for (const good of [ + '', + 'https://cdn.example/x.png', + 'http://cdn.example/x.png', + '/uploads/a-b_c.1.png', + 'HTTPS://CDN.EXAMPLE/X.PNG' + ]) { + if (!isSafeImageSrc(good)) return fail('a legitimate image source was refused: ' + good) + } + for (const bad of [ + 'javascript:alert(1)', + 'JaVaScRiPt:alert(1)', + 'data:image/svg+xml;base64,PHN2Zz48c2NyaXB0Pg==', + '//evil.example/x.png', + '/uploads/../../secrets.json', + '/uploads/sub/dir.png', + '/uploads/.env', + 'file:///etc/passwd', + 'vbscript:msgbox', + 42 as unknown as string + ]) { + if (isSafeImageSrc(bad)) return fail('an unsafe image source was accepted: ' + String(bad)) + } + if (sanitizeImages(['https://a/1.png', 'javascript:x', '/uploads/b.png']).length !== 2) { + return fail('sanitizeImages kept something it should have dropped') + } + if (sanitizeImages(Array(40).fill('https://a/1.png')).length !== MAX_PRODUCT_IMAGES) { + return fail('sanitizeImages did not cap the gallery') + } + + const sfid = 'storefront-' + Date.now() + const mk = (over: Partial): Product => + economy.upsertProduct(sfid, { + id: '', + type: 'item', + name: 'X', + description: '', + price: 10, + commands: [], + rewards: [], + ...over + } as Product) + + // The chokepoint drops a hostile icon rather than storing it. + const dirty = mk({ + name: 'Dirty', + icon: 'javascript:alert(1)', + images: ['https://ok.example/a.png', 'data:image/svg+xml,'], + type: 'crate', + rewards: [{ name: 'R', weight: 1, icon: 'javascript:alert(2)', commands: [] }] + }) + if (dirty.icon) return fail('a javascript: icon was stored: ' + dirty.icon) + if (dirty.images?.length !== 1) return fail('a data: gallery image was stored') + if (dirty.rewards[0].icon) return fail('a javascript: reward icon was stored') + + // Hidden means absent from the payload, not merely styled out - shipping + // it would leak an unlaunched product's name, price and reward list, and + // leave its id buyable. + const secretProduct = mk({ name: 'Unlaunched', hidden: true, price: 999 }) + const stocked = mk({ name: 'Limited', stock: 2, price: 1 }) + const capped = mk({ name: 'Capped', perPlayerLimit: 1, price: 1 }) + mk({ name: 'Zebra', price: 300, sort: 5 }) + mk({ name: 'Apple', price: 50, sort: 1, type: 'crate', rewards: [{ name: 'Sword', weight: 1, commands: [] }] }) + + const anon = JSON.stringify(economy.publicStore(sfid)) + if (anon.includes('Unlaunched')) return fail('a hidden product reached the public payload') + if (economy.publicStore(sfid).products.some((p) => p.id === secretProduct.id)) { + return fail('a hidden product was listed') + } + economy.addBalance(sfid, 'Steve', 500, 'smoke') + // ...and it is not buyable by id either. + const sneak = economy.purchase(sfid, 'Steve', secretProduct.id) + if (sneak.ok) return fail('a hidden product was bought by id') + + // Stock cannot oversell, and the count is visible. + if (economy.purchase(sfid, 'Steve', stocked.id).ok !== true) return fail('first stocked buy failed') + if (economy.purchase(sfid, 'Steve', stocked.id).ok !== true) return fail('second stocked buy failed') + const third = economy.purchase(sfid, 'Steve', stocked.id) + if (third.ok) return fail('a product with 2 in stock sold 3') + if (third.error !== 'out-of-stock') return fail('overselling reported as ' + String(third.error)) + const stockedPub = economy.publicStore(sfid).products.find((p) => p.id === stocked.id) + if (stockedPub?.stock !== 0) return fail('remaining stock not published: ' + String(stockedPub?.stock)) + + // Per-player limit counts from the purchase history. + if (!economy.purchase(sfid, 'Steve', capped.id).ok) return fail('first capped buy failed') + const over = economy.purchase(sfid, 'Steve', capped.id) + if (over.ok || over.error !== 'limit-reached') return fail('per-player limit not enforced') + // ...and it is per player, not global. + economy.addBalance(sfid, 'Alex', 50, 'smoke') + if (!economy.purchase(sfid, 'Alex', capped.id).ok) { + return fail('one player hitting their limit blocked everybody else') + } + // The asking player's own count travels, so the UI can say so up front. + const forSteve = economy.publicStore(sfid, 'Steve').products.find((p) => p.id === capped.id) + if (forSteve?.owned !== 1) return fail('owned count not reported: ' + String(forSteve?.owned)) + if (economy.publicStore(sfid).products.find((p) => p.id === capped.id)?.owned !== undefined) { + return fail('an anonymous visitor was told somebody else owned counts') + } + + // Sections, search and sort are one shared rule for all three UIs. + const cat = economy.publicStore(sfid).products + const secs = sections(cat, 'crates-first') + if (secs[0].type !== 'crate') return fail('crates-first did not put crates first') + if (sections(cat, 'items-first')[0].type !== 'item') return fail('items-first is not honoured') + if (sections(cat, 'mixed').length !== 1) return fail('mixed should be one section') + if (sections(cat.filter((p) => p.type === 'item'), 'crates-first').length !== 1) { + return fail('an empty section was emitted as a heading with nothing under it') + } + if (normalizeLayout('nonsense') !== 'crates-first') return fail('a bad layout was not coerced') + + const byPrice = filterProducts(cat, { sort: 'price-asc' }).map((p) => p.price) + if (byPrice.join() !== [...byPrice].sort((a, b) => a - b).join()) return fail('price sort is wrong') + const featured = filterProducts(cat, { sort: 'featured' }) + if (featured[0].name !== 'Apple') return fail('featured order ignored the sort field') + // Searching a crate by what is inside it is the whole point of indexing rewards. + const found = filterProducts(cat, { text: 'sword' }) + if (found.length !== 1 || found[0].name !== 'Apple') { + return fail('searching a crate by its contents found ' + found.map((p) => p.name).join(',')) + } + if (filterProducts(cat, { type: 'crate' }).some((p) => p.type !== 'crate')) { + return fail('the type filter let something else through') + } + // The two new admin routes carry the `store` scope, not `settings`. + const layoutUrl = '/api/servers/' + id + '/store/admin/layout' + r = await post(layoutUrl, { layout: 'items-first' }, ft) + if (r.status !== 403) return fail('non-store layout change expected 403, got ' + r.status) + r = await post(layoutUrl, { layout: 'items-first' }, ot) + if (r.status !== 200) return fail('layout change expected 200, got ' + r.status) + if (economy.getStoreConfig(id).layout !== 'items-first') return fail('layout did not persist') + r = await post(layoutUrl, { layout: 'nonsense' }, ot) + if (((await r.json()) as { layout: string }).layout !== 'crates-first') { + return fail('a nonsense layout was stored rather than coerced') + } + + // Product images upload under the store scope. Deliberately NOT the + // site's /api/site/upload, which needs `settings` - a store manager + // should not need the keys to the public website to add a picture. + const upUrl = base + '/api/servers/' + id + '/store/admin/upload' + const png = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', + 'base64' + ) + const upload = (tok: string, type: string): Promise => + fetch(upUrl, { + method: 'POST', + headers: { 'Content-Type': type, Authorization: 'Bearer ' + tok }, + body: png + }) + r = await upload(ft, 'image/png') + if (r.status !== 403) return fail('non-store image upload expected 403, got ' + r.status) + r = await upload(ot, 'text/html') + if (r.status !== 415) return fail('an html upload expected 415, got ' + r.status) + r = await upload(ot, 'image/png') + if (r.status !== 200) return fail('store image upload expected 200, got ' + r.status) + const up = (await r.json()) as { name: string; src: string } + if (!up.src.startsWith('/uploads/')) return fail('upload did not return a servable path: ' + up.src) + if (!isSafeImageSrc(up.src)) return fail('upload returned a path its own validator refuses') + rmSync(join(uploadsDir(), up.name), { force: true }) + + console.log('WEB-SMOKE: storefront OK (image allowlist, hidden/stock/limit enforced, sections + search)') + } + // ---- the served pages actually run, and the crate editor has its picker ---- { let panel: PageRun @@ -4265,7 +4466,67 @@ export async function runWebSmoke(): Promise { if (siteHtml.includes('5.2s cubic-bezier') || siteHtml.includes('},5300)')) { return fail('the public site still has its hardcoded 5.3s reel') } - console.log('WEB-SMOKE: panel + site scripts parse; crate editor picker, save shape and contents escaping OK') + // #78/#80/#82: the storefront a buyer sees, rendered by both pages from + // the same shared code, so they cannot disagree about it. + const evilIcon = 'x" onerror="alert(1)' + const GIFT_EMOJI = String.fromCodePoint(0x1f381) + for (const page of [panel, site]) { + const ctx = page.ctx as Record unknown> & { + SF: { products: unknown[]; layout: string; text: string; type: string; sort: string } + } + ctx.SF.products = [ + { + id: 'p1', + type: 'crate', + name: 'Mythic Crate', + description: 'good stuff', + price: 100, + icon: evilIcon, + rewards: [{ name: 'Netherite', chancePct: 5 }] + }, + { id: 'p2', type: 'item', name: 'VIP Rank', description: '', price: 50 }, + { id: 'p3', type: 'item', name: 'Sold Out Thing', description: '', price: 5, stock: 0 } + ] + ctx.SF.layout = 'crates-first' + ctx['sfRender']() + const box = page.byId('sfBox').innerHTML + if (!box.includes('sf-grid')) return fail('the storefront rendered no grid') + // The gift emoji is gone: it renders as a different picture on every + // platform and carries no accessible name. + if (box.includes(GIFT_EMOJI)) return fail('the storefront still uses the gift emoji') + if (!box.includes(' box.indexOf('VIP Rank')) { + return fail('crates-first put items above crates') + } + // Sold out is stated, and the button is disabled rather than failing on click. + if (!box.includes('sold-out') || !box.includes('disabled')) { + return fail('a sold-out product was still offered for sale') + } + if (box.includes('onerror="alert(1)"')) return fail('a product icon escaped its attribute') + + // Search reaches into a crate's contents, which is how people look for one. + ctx.SF.text = 'netherite' + ctx['sfRender']() + const searched = page.byId('sfBox').innerHTML + if (!searched.includes('Mythic Crate') || searched.includes('VIP Rank')) { + return fail('searching by crate contents did not filter correctly') + } + ctx.SF.text = 'nothing-matches-this' + ctx['sfRender']() + if (!page.byId('sfBox').innerHTML.includes('sf-empty')) { + return fail('an empty search result said nothing') + } + ctx.SF.text = '' + + // The detail view opens and carries the contents list. + ctx['sfOpen']('p1') + const detail = page.byId('sfDetail').innerHTML + if (!detail.includes('Mythic Crate')) return fail('the detail view did not open the product') + if (!detail.includes('5%')) return fail('the detail view did not list the crate odds') + if (detail.includes('onerror="alert(1)"')) return fail('the detail view escaped nothing') + } + console.log('WEB-SMOKE: panel + site scripts parse; crate picker, storefront sections, search, detail and escaping OK') } // ---- API keys (#48) + safety rails (#50) ---- diff --git a/src/main/store/economy.ts b/src/main/store/economy.ts index 3e67b48..1e1ca95 100644 --- a/src/main/store/economy.ts +++ b/src/main/store/economy.ts @@ -11,6 +11,14 @@ import { resolveCrateAnimation } from '@shared/crate' import type { CrateAnimation } from '@shared/crate' +import { + buyBlock, + isSafeImageSrc, + normalizeLayout, + sanitizeImages, + MAX_PRODUCT_IMAGES +} from '@shared/storefront' +import type { StoreLayout } from '@shared/storefront' import type { BuyResult, CrateReward, @@ -27,11 +35,23 @@ import type { interface StoreState { currency: string crateAnimation: CrateAnimation + /** Section order on the storefront (#80). */ + layout: StoreLayout products: Product[] /** Economy categories - independent of `products` (#13). */ categories: EconomyCategory[] balances: Record txns: Txn[] + /** + * How many of each product each player has bought, ever: productId -> mcName + * -> count (#81). + * + * Deliberately NOT derived from `txns`, which is trimmed to the newest 500. + * Counting a per-player limit from a trimmed history means the limit quietly + * stops working once a store is busy enough for old rows to fall off - which + * is exactly the store busy enough to need it. + */ + purchases: Record> ledger: LedgerEntry[] queue: { mcName: string; commands: string[]; at: number }[] } @@ -60,10 +80,12 @@ function getStore(serverId: string): StoreState { stores[serverId] = { currency: 'Coins', crateAnimation: DEFAULT_CRATE_ANIMATION, + layout: 'crates-first', products: [], categories: DEFAULT_CATEGORIES.map((c) => ({ ...c })), balances: {}, txns: [], + purchases: {}, ledger: [], queue: [] } @@ -78,6 +100,20 @@ function getStore(serverId: string): StoreState { // ...and files that predate configurable crate animations. Normalised on read // so a hand-edited json cannot leave the panel with an animation it cannot play. stores[serverId].crateAnimation = normalizeCrateAnimation(stores[serverId].crateAnimation) + // ...and files that predate the section layout. + stores[serverId].layout = normalizeLayout(stores[serverId].layout) + // ...and files that predate per-product purchase counters. Seeded from the + // transaction history, which is the best that can be reconstructed - it is + // trimmed, so an old store may under-count, but under-counting once at + // migration beats a limit that keeps drifting forever. + if (!stores[serverId].purchases) { + const seeded: Record> = {} + for (const t of stores[serverId].txns ?? []) { + seeded[t.productId] = seeded[t.productId] ?? {} + seeded[t.productId][t.mcName] = (seeded[t.productId][t.mcName] ?? 0) + 1 + } + stores[serverId].purchases = seeded + } return stores[serverId] } @@ -153,11 +189,21 @@ export function purchase(serverId: string, mcName: string, productId: string): B const st = getStore(serverId) const p = st.products.find((x) => x.id === productId) if (!p) return { ok: false, error: 'no-product' } + // Availability before money (#81). A hidden product is not "sold out" and not + // "too expensive" - as far as a buyer is concerned it does not exist, which + // is also why it is checked server-side rather than hidden in CSS. + const block = buyBlock(p, ownedCount(st, mcName, p.id)) + if (block) return { ok: false, error: block === 'hidden' ? 'no-product' : block } const bal = st.balances[mcName] ?? 0 if (bal < p.price) return { ok: false, error: 'insufficient', balance: bal } - // Atomic deduct — no await before this completes + persists. + // Atomic deduct — no await before this completes + persists. Stock goes in + // the same synchronous block for the same reason: two requests arriving + // together must not both see the last one in stock. st.balances[mcName] = bal - p.price + if (typeof p.stock === 'number') p.stock = Math.max(0, p.stock - 1) + st.purchases[p.id] = st.purchases[p.id] ?? {} + st.purchases[p.id][mcName] = (st.purchases[p.id][mcName] ?? 0) + 1 let commands: string[] let reward: BuyResult['reward'] @@ -235,7 +281,7 @@ function publicRewards(rewards: CrateReward[]): PublicReward[] { * for that reason: a new field on `Product` must be opted in, not leaked by * default. */ -function toPublic(p: Product, storeDefault: CrateAnimation): ProductPublic { +function toPublic(p: Product, storeDefault: CrateAnimation, owned?: number): ProductPublic { return { id: p.id, type: p.type, @@ -243,6 +289,11 @@ function toPublic(p: Product, storeDefault: CrateAnimation): ProductPublic { description: p.description, price: p.price, icon: p.icon, + ...(p.images?.length ? { images: p.images } : {}), + ...(typeof p.stock === 'number' ? { stock: p.stock } : {}), + ...(typeof p.perPlayerLimit === 'number' ? { perPlayerLimit: p.perPlayerLimit } : {}), + ...(owned !== undefined ? { owned } : {}), + ...(typeof p.sort === 'number' ? { sort: p.sort } : {}), ...(p.type === 'crate' ? { rewards: publicRewards(p.rewards), @@ -251,11 +302,22 @@ function toPublic(p: Product, storeDefault: CrateAnimation): ProductPublic { : {}) } } -export function publicStore(serverId: string): StorePublic { +/** + * @param mcName the signed-in buyer, when there is one. Only used to fill in + * how many of each product they already own, so the storefront can grey out a + * per-player limit that has been reached instead of failing the purchase. + */ +export function publicStore(serverId: string, mcName?: string): StorePublic { const st = getStore(serverId) return { currency: st.currency, - products: st.products.map((p) => toPublic(p, st.crateAnimation)), + layout: st.layout, + // Hidden products are dropped here, at the boundary. Filtering them in the + // UI would still ship the name, price and reward list of something the + // operator has not launched yet - and leave the id buyable. + products: st.products + .filter((p) => !p.hidden) + .map((p) => toPublic(p, st.crateAnimation, mcName ? ownedCount(st, mcName, p.id) : undefined)), crateAnimation: st.crateAnimation } } @@ -269,7 +331,23 @@ export function getTxns(serverId: string, mcName: string): Txn[] { // ---- admin (trusted: desktop, or web users with 'store' scope) ---- export function getStoreConfig(serverId: string): StoreConfig { const st = getStore(serverId) - return { currency: st.currency, products: st.products, crateAnimation: st.crateAnimation } + return { + currency: st.currency, + products: st.products, + crateAnimation: st.crateAnimation, + layout: st.layout + } +} +export function setStoreLayout(serverId: string, layout: unknown): StoreLayout { + const st = getStore(serverId) + st.layout = normalizeLayout(layout) + save() + return st.layout +} + +/** How many of this product `mcName` has already bought, ever (#81). */ +function ownedCount(st: StoreState, mcName: string, productId: string): number { + return st.purchases[productId]?.[mcName] ?? 0 } export function setCurrency(serverId: string, currency: string): void { getStore(serverId).currency = currency.trim() || 'Coins' @@ -289,7 +367,11 @@ export function upsertProduct(serverId: string, product: Product): Product { name: product.name || 'Product', description: product.description || '', price: Math.max(0, Math.floor(product.price) || 0), - icon: product.icon, + // Any store-scoped web user can set these, and they render for every + // visitor. One chokepoint for both admin UIs; a refused source is dropped + // rather than rejecting the whole save, so a bad icon does not cost the + // operator the rest of their edit. + icon: isSafeImageSrc(product.icon) ? product.icon : '', commands: Array.isArray(product.commands) ? product.commands : [], rewards: Array.isArray(product.rewards) ? product.rewards : [], // Only a crate carries one, and only when it was actually set. Storing an @@ -298,8 +380,25 @@ export function upsertProduct(serverId: string, product: Product): Product { // silently stop affecting it. ...(product.type === 'crate' && product.crateAnimation ? { crateAnimation: normalizeCrateAnimation(product.crateAnimation) } + : {}), + ...(product.images?.length ? { images: sanitizeImages(product.images, MAX_PRODUCT_IMAGES) } : {}), + ...(product.hidden ? { hidden: true } : {}), + // `undefined` is "unlimited" and 0 is "sold out" - two different things, so + // this cannot collapse to a truthiness check. + ...(product.stock === undefined || product.stock === null + ? {} + : { stock: Math.max(0, Math.floor(Number(product.stock)) || 0) }), + ...(product.perPlayerLimit === undefined || product.perPlayerLimit === null + ? {} + : { perPlayerLimit: Math.max(0, Math.floor(Number(product.perPlayerLimit)) || 0) }), + ...(typeof product.sort === 'number' && Number.isFinite(product.sort) + ? { sort: Math.floor(product.sort) } : {}) } + clean.rewards = clean.rewards.map((r) => ({ + ...r, + icon: isSafeImageSrc(r.icon) ? r.icon : '' + })) const i = st.products.findIndex((x) => x.id === clean.id) if (i >= 0) st.products[i] = clean else st.products.push(clean) @@ -309,6 +408,11 @@ export function upsertProduct(serverId: string, product: Product): Product { export function deleteProduct(serverId: string, productId: string): void { const st = getStore(serverId) st.products = st.products.filter((p) => p.id !== productId) + // The counters go with it. A new product would never reuse the id, so keeping + // them is dead weight that grows forever. The ledger and txn history still + // record that the purchase happened - this is a cache of "how many", not the + // record of "what happened". + delete st.purchases[productId] save() } /** Add (or, with a negative amount, remove) balance. Audited. */ diff --git a/src/main/web/panelHtml.ts b/src/main/web/panelHtml.ts index 23c6f35..6f1a6b0 100644 --- a/src/main/web/panelHtml.ts +++ b/src/main/web/panelHtml.ts @@ -1,6 +1,7 @@ // Self-contained, responsive (mobile-friendly) web panel served by the embedded // HTTP server. Vanilla JS; authenticates with a bearer token in localStorage. import { CRATE_CSS, CRATE_JS, CRATE_MODAL_HTML } from '@shared/crateUi' +import { STORE_CSS, STORE_JS, STORE_MODAL_HTML, CRATE_ICON_SVG } from '@shared/storeUi' export function getPanelHtml(): string { return ` @@ -118,8 +119,9 @@ input:focus,select:focus,textarea:focus{outline:none;border-color:var(--accent); .np-preview .pv-body{white-space:pre-wrap;line-height:1.6;font-size:14px} .np-preview .pv-gal{display:flex;flex-wrap:wrap;gap:8px;margin-top:10px} .np-preview .pv-gal img{width:96px;height:72px;object-fit:cover;border-radius:8px} -/* crate — one implementation, shared with the public site (see crateUi.ts) */ +/* crate + storefront — one implementation, shared with the public site */ ${CRATE_CSS} +${STORE_CSS} .findings{display:flex;flex-direction:column;gap:8px;margin:10px 0} .finding{border:1px solid var(--border);border-left-width:3px;border-radius:9px;padding:9px 11px;background:var(--elev)} .finding.info{border-left-color:#60a5fa} @@ -136,6 +138,8 @@ ${CRATE_CSS} .pm-box{background:linear-gradient(160deg,#17151b,#0c0c11);border:1px solid var(--border);border-radius:16px;padding:20px;width:min(560px,95vw);max-height:88vh;overflow:auto;box-shadow:0 30px 70px rgba(0,0,0,.65)} .pm-box label{display:block;font-size:12px;color:var(--dim);margin-top:8px} .rw-card{border:1px solid var(--border);border-radius:10px;padding:10px;margin:8px 0;background:var(--elev)} +.pm-thumb{width:38px;height:38px;flex:none;border-radius:8px;border:1px solid var(--border);background:var(--elev);display:grid;place-items:center;overflow:hidden} +.pm-thumb img{width:100%;height:100%;object-fit:contain;image-rendering:pixelated} .title{font-weight:800;font-size:18px;margin:2px 0 12px;display:flex;align-items:center;gap:9px} .title::before{content:'';width:4px;height:17px;border-radius:3px;background:var(--accent);box-shadow:0 0 12px var(--glow)} h2{margin:8px 0;font-weight:800;letter-spacing:-.4px} @@ -310,7 +314,7 @@ h2{margin:8px 0;font-weight:800;letter-spacing:-.4px}
Player credits
@@ -368,9 +375,11 @@ h2{margin:8px 0;font-weight:800;letter-spacing:-.4px}
+ ${CRATE_MODAL_HTML} +${STORE_MODAL_HTML} diff --git a/src/main/web/server.ts b/src/main/web/server.ts index fac4fcd..c9a6482 100644 --- a/src/main/web/server.ts +++ b/src/main/web/server.ts @@ -334,7 +334,11 @@ async function handlePublic( const sid = site.siteServerId() if (sub === 'store' && method === 'GET') { if (!sid || !getServer(sid)) return sendJson(res, 200, { currency: '', products: [] }) - return sendJson(res, 200, economy.publicStore(sid)) + // Anonymous visitors get the catalogue; a signed-in one also gets their own + // purchase counts, so a per-player limit can be shown as reached instead of + // only discovered when the purchase is refused (#81). + const who = playerAuth.resolvePlayerSession(bearer(req)) + return sendJson(res, 200, economy.publicStore(sid, who?.mcName)) } // ---- player-token-only endpoints (never satisfied by an admin token) ---- @@ -716,7 +720,7 @@ async function handlePanel(req: IncomingMessage, res: ServerResponse): Promise ({}))) as { layout?: string } + return sendJson(res, 200, { layout: economy.setStoreLayout(id, b.layout) }) + } + if (rest === 'admin/upload' && method === 'POST') { + // Its own route rather than reusing /api/site/upload, which is gated on + // the `settings` scope for the website's own server. A store manager who + // may edit products should be able to give one a picture without also + // being handed the keys to the public site. + if (!gate('store')) return + const mime = String(req.headers['content-type'] || '').split(';')[0].trim() + try { + const buf = await readRawBody(req, site.MAX_UPLOAD) + const name = site.saveImageBuffer(buf, mime) + return sendJson(res, 200, { name, src: '/uploads/' + name }) + } catch (e) { + const msg = String((e as Error)?.message ?? e) + const code = + msg === 'body-too-large' || msg === 'image-too-large' + ? 413 + : msg === 'unsupported-image-type' + ? 415 + : 500 + return sendJson(res, code, { error: msg }) + } + } if (rest === 'admin/crate-animation' && method === 'POST') { if (!gate('store')) return const b = (await readBody(req).catch(() => ({}))) as { animation?: string } diff --git a/src/main/web/siteI18n.ts b/src/main/web/siteI18n.ts index 398f907..2b5f231 100644 --- a/src/main/web/siteI18n.ts +++ b/src/main/web/siteI18n.ts @@ -52,6 +52,25 @@ export const SITE_STRINGS_EN: Record = { 'auth.badCode': 'Wrong code', 'auth.expired': 'Code expired — request a new one', 'auth.weakPassword': 'Password is too short', + 'store.search': 'Search products…', + 'store.noMatch': 'Nothing matches that search.', + 'store.crate': 'Crate', + 'store.contents': 'What is inside', + 'store.type_all': 'Everything', + 'store.type_crate': 'Crates', + 'store.type_item': 'Items', + 'store.sort_featured': 'Featured', + 'store.sort_price-asc': 'Price: low to high', + 'store.sort_price-desc': 'Price: high to low', + 'store.sort_name-asc': 'Name: A-Z', + 'store.sort_name-desc': 'Name: Z-A', + 'store.section_crate': 'Crates', + 'store.section_item': 'Items', + 'store.outOfStock': 'Sold out', + 'store.limitReached': 'Limit reached', + 'store.stockLeft': '{n} left', + 'store.limitOf': 'Max {n} per player', + 'store.plays': 'Opens with', 'crate.opening': 'Opening crate…', 'crate.congrats': 'You won', 'crate.ok': 'Awesome!', @@ -115,6 +134,25 @@ export const SITE_STRINGS_TR: Record = { 'auth.badCode': 'Hatalı kod', 'auth.expired': 'Kodun süresi doldu — yenisini isteyin', 'auth.weakPassword': 'Parola çok kısa', + 'store.search': 'Ürün ara…', + 'store.noMatch': 'Bu aramayla eşleşen bir şey yok.', + 'store.crate': 'Kasa', + 'store.contents': 'İçinde ne var', + 'store.type_all': 'Hepsi', + 'store.type_crate': 'Kasalar', + 'store.type_item': 'Eşyalar', + 'store.sort_featured': 'Öne çıkanlar', + 'store.sort_price-asc': 'Fiyat: artan', + 'store.sort_price-desc': 'Fiyat: azalan', + 'store.sort_name-asc': 'Ad: A-Z', + 'store.sort_name-desc': 'Ad: Z-A', + 'store.section_crate': 'Kasalar', + 'store.section_item': 'Eşyalar', + 'store.outOfStock': 'Tükendi', + 'store.limitReached': 'Sınıra ulaşıldı', + 'store.stockLeft': '{n} adet kaldı', + 'store.limitOf': 'Oyuncu başına en fazla {n}', + 'store.plays': 'Açılış animasyonu', 'crate.opening': 'Kasa açılıyor…', 'crate.congrats': 'Kazandınız', 'crate.ok': 'Harika!', diff --git a/src/preload/index.ts b/src/preload/index.ts index 05ae163..cc0a91e 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -136,6 +136,7 @@ const api: MsmsApi = { getStore: (id) => ipcRenderer.invoke(IPC.storeGet, id), setStoreCurrency: (id, currency) => ipcRenderer.invoke(IPC.storeCurrency, id, currency), + setStoreLayout: (id, layout) => ipcRenderer.invoke(IPC.storeLayout, id, layout), upsertStoreProduct: (id, product) => ipcRenderer.invoke(IPC.storeUpsert, id, product), deleteStoreProduct: (id, productId) => ipcRenderer.invoke(IPC.storeDelete, id, productId), addStoreBalance: (id, mcName, amount, reason, category) => diff --git a/src/renderer/src/components/ImageField.tsx b/src/renderer/src/components/ImageField.tsx new file mode 100644 index 0000000..9a5d4f1 --- /dev/null +++ b/src/renderer/src/components/ImageField.tsx @@ -0,0 +1,117 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Upload, X, ImageOff } from 'lucide-react' +import { isSafeImageSrc } from '@shared/storefront' + +/** + * A picture chosen either by pasting a URL or by uploading a file, with a live + * thumbnail of exactly what buyers will see (#76). + * + * Uploads land in the same folder the website's CMS uses, and are referred to + * as `/uploads/` — the form the public site and web panel serve them at. + * The desktop app cannot load that path (there is no HTTP server involved when + * the listener is off, and the packaged renderer's CSP would block it anyway), + * so previews go through the `msms-img://` scheme that already exists for the + * CMS. One stored value, two ways of reading it, rather than two stored values + * that can disagree. + */ +export function previewSrc(src: string | undefined): string { + const v = (src ?? '').trim() + if (!v) return '' + if (v.startsWith('/uploads/')) { + return `msms-img://upload/${encodeURIComponent(v.slice('/uploads/'.length))}` + } + return v +} + +export function ImageField({ + value, + onChange, + label, + compact +}: { + value: string | undefined + onChange: (next: string) => void + label?: string + /** Inline variant for a reward row, where a full field would dominate. */ + compact?: boolean +}): JSX.Element { + const { t } = useTranslation() + const [broken, setBroken] = useState(false) + const v = (value ?? '').trim() + // Warn rather than block: the operator is mid-typing, and a field that + // refuses input while you are halfway through a URL is worse than one that + // tells you the value will not be kept. The save path drops it either way. + const unsafe = !!v && !isSafeImageSrc(v) + const src = previewSrc(v) + + return ( +
+ {label && } +
+
+ {src && !broken ? ( + setBroken(true)} + style={{ width: '100%', height: '100%', objectFit: 'contain', imageRendering: 'pixelated' }} + /> + ) : ( + + )} +
+ { + setBroken(false) + onChange(e.target.value) + }} + /> + + {v && ( + + )} +
+ {unsafe && ( +

+ {t('store.unsafeImage')} +

+ )} + {broken && !unsafe && ( +

{t('store.imageBroken')}

+ )} +
+ ) +} diff --git a/src/renderer/src/locales/en.ts b/src/renderer/src/locales/en.ts index e3f05d1..3251d71 100644 --- a/src/renderer/src/locales/en.ts +++ b/src/renderer/src/locales/en.ts @@ -731,6 +731,25 @@ export default { newCategory: 'New category name', catAll: 'All categories', catNone: 'Uncategorised', + gallery: 'Extra images', + addImage: 'Add image', + uploadImage: 'Upload', + noImage: 'No image', + imageBroken: 'That image did not load. Check the address, or upload the file instead.', + unsafeImage: 'Only https:// addresses and uploaded files are kept. This one will be dropped on save.', + stock: 'Stock', + perPlayerLimit: 'Limit per player', + sortOrder: 'Order', + unlimited: 'Unlimited', + hidden: 'Hidden', + outOfStock: 'Sold out', + stockLeft: '{{n}} left', + availabilityHint: + 'Leave stock and the per-player limit blank for unlimited. Stock counts down on each purchase and cannot oversell. Hidden products are not sent to the storefront at all, so nobody can buy one by guessing its id. Order sorts the "Featured" view — lower comes first, and anything left blank comes after everything numbered.', + layout: 'Storefront sections', + 'layout_crates-first': 'Crates first, then items', + 'layout_items-first': 'Items first, then crates', + layout_mixed: 'One mixed grid', crateAnimation: 'Crate opening animation', animInherit: 'Store default ({{name}})', preview: 'Preview', diff --git a/src/renderer/src/locales/tr.ts b/src/renderer/src/locales/tr.ts index a6d037a..7a9e413 100644 --- a/src/renderer/src/locales/tr.ts +++ b/src/renderer/src/locales/tr.ts @@ -735,6 +735,25 @@ const tr: typeof en = { newCategory: 'Yeni kategori adı', catAll: 'Tüm kategoriler', catNone: 'Kategorisiz', + gallery: 'Ek görseller', + addImage: 'Görsel ekle', + uploadImage: 'Yükle', + noImage: 'Görsel yok', + imageBroken: 'Bu görsel yüklenemedi. Adresi kontrol et ya da dosyayı yükle.', + unsafeImage: 'Yalnızca https:// adresleri ve yüklenen dosyalar saklanır. Bu değer kaydederken atılacak.', + stock: 'Stok', + perPlayerLimit: 'Oyuncu başına sınır', + sortOrder: 'Sıra', + unlimited: 'Sınırsız', + hidden: 'Gizli', + outOfStock: 'Tükendi', + stockLeft: '{{n}} adet kaldı', + availabilityHint: + 'Stok ve oyuncu başına sınırı boş bırakırsan sınırsız olur. Stok her satışta azalır ve fazladan satış yapılamaz. Gizli ürünler mağaza sayfasına hiç gönderilmez, dolayısıyla kimse kimliğini tahmin ederek satın alamaz. Sıra, "Öne çıkanlar" görünümünü belirler — küçük olan önce gelir, boş bırakılanlar ise numaralandırılmış olanların ardından gelir.', + layout: 'Mağaza bölümleri', + 'layout_crates-first': 'Önce kasalar, sonra eşyalar', + 'layout_items-first': 'Önce eşyalar, sonra kasalar', + layout_mixed: 'Tek karışık ızgara', crateAnimation: 'Sandık açma animasyonu', animInherit: 'Mağaza varsayılanı ({{name}})', preview: 'Ön izleme', diff --git a/src/renderer/src/views/StoreView.tsx b/src/renderer/src/views/StoreView.tsx index 07a24db..3e135bb 100644 --- a/src/renderer/src/views/StoreView.tsx +++ b/src/renderer/src/views/StoreView.tsx @@ -19,6 +19,9 @@ import { categoryName, filterLedger, ledgerSummary } from '@shared/economy' import { CRATE_ANIMATIONS, DEFAULT_CRATE_ANIMATION, resolveCrateAnimation } from '@shared/crate' import type { CrateAnimation } from '@shared/crate' import { CratePreview } from '../components/CratePreview' +import { ImageField, previewSrc } from '../components/ImageField' +import { MAX_PRODUCT_IMAGES, STORE_LAYOUTS, normalizeLayout } from '@shared/storefront' +import type { StoreLayout } from '@shared/storefront' import type { LedgerKind } from '@shared/economy' import type { Product, @@ -53,6 +56,7 @@ export function StoreView(): JSX.Element { const [balCategory, setBalCategory] = useState('') const [newCat, setNewCat] = useState('') const [crateAnim, setCrateAnim] = useState(DEFAULT_CRATE_ANIMATION) + const [layout, setLayout] = useState('crates-first') const [edit, setEdit] = useState(null) const [cmdText, setCmdText] = useState('') // What the preview modal is playing, and with which rewards. Null = closed. @@ -73,6 +77,7 @@ export function StoreView(): JSX.Element { setData(d) setCurrency(d.currency) setCrateAnim(d.crateAnimation ?? DEFAULT_CRATE_ANIMATION) + setLayout(normalizeLayout(d.layout)) setLedger(await window.msms.getStoreLedger(id)) } useEffect(() => { @@ -149,6 +154,17 @@ export function StoreView(): JSX.Element { toast('error', String((e as Error)?.message ?? e)) } } + const saveLayout = async (next: StoreLayout): Promise => { + const previous = layout + setLayout(next) + try { + setLayout(await window.msms.setStoreLayout(id, next)) + toast('success', 'store.saved') + } catch (e) { + setLayout(previous) + toast('error', String((e as Error)?.message ?? e)) + } + } const addCategory = async (): Promise => { const name = newCat.trim() if (!name) return @@ -232,6 +248,23 @@ export function StoreView(): JSX.Element { {t('store.preview')} + + {/* Crates and items are different things to shop for, so the storefront + can put them in separate sections (#80). */} +
+ + +
@@ -455,10 +488,34 @@ export function StoreView(): JSX.Element { ) : (
{data.products.map((p) => ( -
- {p.type === 'crate' ? : } +
+ {p.icon ? ( + + ) : p.type === 'crate' ? ( + + ) : ( + + )}
-
{p.name} {t(`store.${p.type}`)}
+
+ {p.name} {t(`store.${p.type}`)} + {p.hidden && {t('store.hidden')}} + {typeof p.stock === 'number' && ( + + {p.stock === 0 ? t('store.outOfStock') : t('store.stockLeft', { n: p.stock })} + + )} +
{p.price} {currency} · {p.description}
@@ -490,10 +547,95 @@ export function StoreView(): JSX.Element { setEdit({ ...edit, description: e.target.value })} />
+ setEdit({ ...edit, icon })} + /> + + {/* A rank or a kit is worth more than one picture (#77). */}
- - setEdit({ ...edit, icon: e.target.value })} /> + + {(edit.images ?? []).map((img, i) => ( + + setEdit({ + ...edit, + // An emptied slot is removed rather than kept as a + // blank, so clearing one is how you delete it. + images: (edit.images ?? []) + .map((x, idx) => (idx === i ? next : x)) + .filter((x) => x.trim()) + }) + } + /> + ))} + {(edit.images?.length ?? 0) < MAX_PRODUCT_IMAGES && ( + + )} +
+ + {/* Availability (#81). Blank means unlimited; 0 means sold out. */} +
+
+ + + setEdit({ ...edit, stock: e.target.value === '' ? undefined : Number(e.target.value) }) + } + /> +
+
+ + + setEdit({ + ...edit, + perPlayerLimit: e.target.value === '' ? undefined : Number(e.target.value) + }) + } + /> +
+
+ + + setEdit({ ...edit, sort: e.target.value === '' ? undefined : Number(e.target.value) }) + } + /> +
+
+

{t('store.availabilityHint')}

{edit.type === 'item' ? (
@@ -563,7 +705,9 @@ export function StoreView(): JSX.Element { {Math.round((Math.max(0, r.weight) / totalW) * 100)}%
- updReward(i, { icon: e.target.value })} /> +
+ updReward(i, { icon })} /> +