From 7e360d80a5f4cd073f10ef8943349f7c3f7d345f Mon Sep 17 00:00:00 2001 From: CaYatur Date: Tue, 28 Jul 2026 05:44:44 +0300 Subject: [PATCH 1/2] Store: real product images, availability, and a storefront worth browsing Closes #76, #77, #78, #80, #81 and #82. The store worked, and looked like it had been built to prove it worked. Every product was a URL in a text box and a 60px icon inside a card three times its size, in a flat four-column grid with a gift emoji stuck on the end of the crate names, and no way to click into anything. **Images (#76, #77).** A product icon can now be pasted as a URL or uploaded from disk, in the desktop app and the web panel, with a live thumbnail of exactly what buyers will see, including the broken-image state. Same control for each crate reward, and for up to eight extra pictures per product shown in the new detail view. Uploading from the panel goes to a new store-scoped route rather than the site's existing one, which is gated on `settings` for the website's own server: somebody trusted to edit products should not need the keys to the public site to give one a picture. All of it goes through one validator. Image sources are attacker-controlled - any web user with `store` can set one, and it renders for every visitor - so isSafeImageSrc allows exactly http(s) URLs and /uploads/, and refuses javascript: (script execution), data: (an inline SVG can carry a script), protocol-relative //host/x.png (reads like a path, is not one) and any traversal in the uploads name. Case-insensitively, because a scheme check defeated by the shift key is not a check. It is enforced in upsertProduct for the icon, every reward icon and every gallery image, so both admin UIs and the API pass through the same gate. **Availability (#81).** hidden, stock, perPlayerLimit and sort. Hidden products are dropped at the payload boundary, not styled out - shipping one would leak an unlaunched product's name, price and reward list, and leave its id buyable. Stock decrements inside the same synchronous block as the balance, so two requests arriving together cannot both take the last one. The per-player limit counts from the purchase history, and the asking player's own count travels in the payload so a storefront can say "limit reached" instead of only discovering it when the purchase fails. **The storefront itself (#78, #80, #82).** Cards sized around the picture, an auto-filling grid instead of a fixed four columns, crates and items in separate sections whose order is configurable, an inline SVG crate badge replacing the emoji, a detail view with the full description, gallery and crate contents, and a toolbar with search, type filter and five sort orders. Search reaches into a crate's reward names, which is how people actually look for one. The whole storefront is one shared module the panel and website both paste in, the same arrangement as crateUi.ts, and for the same reason: two hand-kept copies of a store is how the website ended up ignoring the crate animation setting for two releases. One judgement call worth stating: a product with no explicit order sorts AFTER every product that has one. Unset-as-zero is the obvious reading and the wrong one - an operator who numbers three products 1, 2, 3 and leaves twenty alone means "these three first", and zero would bury them under all twenty. Verified with all ten smoke gates green. New coverage: the image allowlist against ten hostile inputs, a hidden product absent from the payload and unbuyable by id, two in stock refusing a third sale, a per-player limit that is per player, section ordering, empty sections not emitted as bare headings, search by crate contents, and both served pages rendering the storefront with a sold-out flag, an SVG badge instead of the emoji, and a hostile icon failing to escape its attribute. --- src/main/ipc/register.ts | 1 + src/main/smoke.ts | 242 ++++++++++++++++++++- src/main/store/economy.ts | 86 +++++++- src/main/web/panelHtml.ts | 117 ++++++++-- src/main/web/publicSiteHtml.ts | 32 +-- src/main/web/server.ts | 35 ++- src/main/web/siteI18n.ts | 38 ++++ src/preload/index.ts | 1 + src/renderer/src/components/ImageField.tsx | 117 ++++++++++ src/renderer/src/locales/en.ts | 19 ++ src/renderer/src/locales/tr.ts | 19 ++ src/renderer/src/views/StoreView.tsx | 156 ++++++++++++- src/shared/ipc.ts | 4 + src/shared/storeUi.ts | 205 +++++++++++++++++ src/shared/storefront.ts | 201 +++++++++++++++++ src/shared/web.ts | 25 +++ 16 files changed, 1254 insertions(+), 44 deletions(-) create mode 100644 src/renderer/src/components/ImageField.tsx create mode 100644 src/shared/storeUi.ts create mode 100644 src/shared/storefront.ts 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..e0b80bf 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, @@ -3143,6 +3151,8 @@ interface StubNode { querySelectorAll(): StubNode[] appendChild(): void addEventListener(): void + focus(): void + setSelectionRange(): void } interface PageRun { @@ -3180,7 +3190,9 @@ function runPageScript(html: string, seed: Record = {}): PageRu querySelector: () => mkNode(''), querySelectorAll: () => [], appendChild: () => {}, - addEventListener: () => {} + addEventListener: () => {}, + focus: () => {}, + setSelectionRange: () => {} } return n } @@ -4193,6 +4205,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 +4443,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..ec5715c 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,6 +35,8 @@ 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[] @@ -60,6 +70,7 @@ function getStore(serverId: string): StoreState { stores[serverId] = { currency: 'Coins', crateAnimation: DEFAULT_CRATE_ANIMATION, + layout: 'crates-first', products: [], categories: DEFAULT_CATEGORIES.map((c) => ({ ...c })), balances: {}, @@ -78,6 +89,8 @@ 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) return stores[serverId] } @@ -153,11 +166,19 @@ 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) let commands: string[] let reward: BuyResult['reward'] @@ -235,7 +256,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 +264,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 +277,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 +306,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 (#81). */ +function ownedCount(st: StoreState, mcName: string, productId: string): number { + return st.txns.filter((t) => t.productId === productId && t.mcName === mcName).length } export function setCurrency(serverId: string, currency: string): void { getStore(serverId).currency = currency.trim() || 'Coins' @@ -289,7 +342,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 +355,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) diff --git a/src/main/web/panelHtml.ts b/src/main/web/panelHtml.ts index 23c6f35..02ef5bc 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 })} /> +