Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,15 @@ function focusExisting(): void {
}

// Single-instance lock: two instances on the same launch dir = data corruption.
//
// Scoped to the launch dir, which is what that sentence actually says. Electron
// keys the lock on the userData path, and leaving that at its default made the
// lock app-wide — so a portable copy running from the user's desktop blocked a
// smoke run out of the repo, two installs that share no state at all. Pointing
// userData inside the launch dir makes the lock mean what it claims, and puts
// Electron's own cache next to everything else this app keeps, which is the
// portable behaviour the rest of the program already has.
app.setPath('userData', join(resolveBaseDir(), 'msms-data', 'chrome'))
const gotLock = app.requestSingleInstanceLock()
if (!gotLock) {
// A smoke run that loses the lock has tested nothing, and quitting 0 would
Expand Down
77 changes: 71 additions & 6 deletions src/main/smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3736,7 +3736,22 @@ function runPageScript(html: string, seed: Record<string, unknown> = {}): PageRu
// 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<string, unknown> = path.includes('/api/public/site')
// The map feed, so `mapRefresh` can be exercised end to end through each
// page's own api() (#115). Answering the generic `{servers:[],...}` here
// would make the refresh bail on a missing `dimension` and hide exactly
// the failure the assertion is for.
const body: Record<string, unknown> = /\/map(\?|$)/.test(path)
? {
bridge: false,
dimension: 'overworld',
dimensions: ['overworld'],
players: [],
bounds: { minX: -64, maxX: 64, minZ: -64, maxZ: 64 },
heatmap: [],
cell: 16,
at: Date.now()
}
: path.includes('/api/public/site')
? {
siteName: 'Test',
tagline: '',
Expand Down Expand Up @@ -4546,13 +4561,29 @@ export async function runWebSmoke(): Promise<void> {
// An admin token must not be a player token here either: the endpoint
// decides "owner" from a PLAYER session, and an operator holding a panel
// token is a stranger to every player account.
// An admin panel token is not a player session. It used to fall through
// as "anonymous"; since #120 a credential that was supplied and did not
// resolve is refused outright, which is both clearer and consistent
// with every other player route.
//
// Asserted as `=== 401` rather than "if it happened to be 200": guarding
// the body check behind a status that no longer occurs is a test that
// silently stopped testing, which is what this assertion became when
// the 401 landed.
pr = await sget('/api/public/profile?name=Profiley', ot)
if (pr.status === 200) {
const asAdmin = (await pr.json()) as unknown as Record<string, unknown>
for (const f of GATED) {
if (f in asAdmin) return fail('an admin token read a player\'s ' + f + ' from the public site')
}
if (pr.status !== 401) {
return fail('an admin token on the public profile expected 401, got ' + pr.status)
}
// ...and a dead PLAYER token is refused the same way, which is the
// restart case: the browser still holds a token the server forgot.
pr = await sget('/api/public/profile', 'deadbeef'.repeat(8))
if (pr.status !== 401) {
return fail('a stale player token expected 401, got ' + pr.status)
}
// The anonymous rule is untouched by all of that — no credential still
// means "answer as a stranger", not "refuse".
pr = await sget('/api/public/profile?name=Profiley')
if (pr.status === 401) return fail('an anonymous profile read was refused as if it had a token')
console.log('WEB-SMOKE: public profile OK (own vs stranger, admin token is a stranger, 400 on a bad name)')
} finally {
siteMod.setSiteConfig({ storeServerId: storeBefore, profile: profileBefore })
Expand Down Expand Up @@ -5641,6 +5672,40 @@ export async function runWebSmoke(): Promise<void> {
cell: 16,
at: Date.now()
}
// #115: BOTH pages must be able to fetch a frame with their own api().
// The assertions below seed MAP.data and call mapDraw directly, which is
// exactly why they missed the real bug: the shared module read `r.body`,
// which is the panel's response shape, so on the public site every poll
// threw on `undefined.dimension` and the map never drew at all. Nothing
// that skips mapRefresh can see that.
for (const [label, page] of [['panel', panel], ['site', site]] as const) {
const pctx = page.ctx as Record<string, (...a: unknown[]) => unknown>
const pm = page.ctx as { MAP: { data: unknown } }
pm.MAP.data = null
let threw = ''
const onErr = (e: unknown): void => {
threw = String(e)
}
process.on('unhandledRejection', onErr)
try {
await (pctx['mapRefresh']() as unknown as Promise<void> | undefined)
await sleep(20)
} finally {
process.off('unhandledRejection', onErr)
}
if (threw) return fail('the ' + label + ' map threw while refreshing: ' + threw)
if (!pm.MAP.data) {
return fail('the ' + label + ' map got no frame from its own api() — see #115')
}
// ...and the status came from the response rather than staying on its
// initial text, which is what "Bridge not connected" forever looked
// like.
const state = page.byId('mpState').textContent
if (!/Bridge (live|not connected)/.test(state)) {
return fail('the ' + label + ' map did not set a bridge state: ' + JSON.stringify(state))
}
}

const mapState = panel.ctx as {
MAP: { data: { bridge: boolean; players: unknown[] }; bridge: unknown; msg: string }
}
Expand Down
7 changes: 7 additions & 0 deletions src/main/web/panelHtml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -730,6 +730,13 @@ ${MAP_JS}
function mapServerId(){return current?current.id:''}
function mapFeedUrl(dim,cell){
return '/api/servers/'+mapServerId()+'/map?dim='+encodeURIComponent(dim)+'&cell='+encodeURIComponent(cell)}
/* The map engine does not know how this page wraps a response, and must not:
the two pages disagree, and it used to assume this one (#115). */
function mapGet(u){return api(u).then(function(r){return r.ok?r.body:null}).catch(function(){return null})}
function mapPost(u){return api(u,{method:'POST'}).then(function(r){
/* A refusal still has a body worth showing — "no jar available" is the answer,
not a failure to get one. */
return r.body||null}).catch(function(){return null})}
/* The avatar service, by uuid. Named here rather than hardcoded in the shared
map so an operator running an air-gapped panel can point it elsewhere, and so
the public site can refuse to draw heads at all (#104). */
Expand Down
67 changes: 66 additions & 1 deletion src/main/web/playerAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,17 @@ interface Pending {

let accounts: Account[] = []
const pending = new Map<string, Pending>() // key: mcName lower
/**
* Player sessions, persisted (#120).
*
* They used to live only in this Map, so restarting the app signed out every
* player on the website — except the browser kept the token in localStorage and
* went on believing it was signed in, which turned every authenticated request
* into an anonymous one and made a player's own profile report them as missing.
*
* Written next to the accounts rather than into them: a session is not part of
* an identity, and an operator clearing sessions should not risk the passwords.
*/
const sessions = new Map<string, { mcName: string; expires: number }>()
const startLimit = new Map<string, { count: number; ts: number }>() // per name|ip

Expand All @@ -54,8 +65,54 @@ function save(): void {
writeFileSync(p + '.tmp', JSON.stringify(accounts, null, 2), 'utf-8')
renameSync(p + '.tmp', p)
}
function sessionsFile(): string {
return playerAccountsPath().replace(/\.json$/, '') + '-sessions.json'
}

function loadSessions(): void {
sessions.clear()
try {
if (!existsSync(sessionsFile())) return
const raw = JSON.parse(readFileSync(sessionsFile(), 'utf-8')) as [
string,
{ mcName: string; expires: number }
][]
const now = Date.now()
for (const [token, s] of Array.isArray(raw) ? raw : []) {
// Expired ones are dropped on read rather than carried and checked later:
// the file is the only thing that grows without bound here.
if (s && typeof s.mcName === 'string' && s.expires > now) sessions.set(token, s)
}
} catch {
/* a corrupt session file signs everyone out; it must never stop the app */
}
}

/**
* Sessions are 14 days long and one login mints one, so a player who signs in
* from a new device every day accumulates them. Bounded here rather than left
* to the TTL: the oldest go first, which logs out the least recently used
* device rather than the person who just signed in.
*/
const MAX_SESSIONS = 2000

function saveSessions(): void {
try {
if (sessions.size > MAX_SESSIONS) {
const byOldest = [...sessions.entries()].sort((a, b) => a[1].expires - b[1].expires)
for (const [token] of byOldest.slice(0, sessions.size - MAX_SESSIONS)) sessions.delete(token)
}
const p = sessionsFile()
writeFileSync(p + '.tmp', JSON.stringify([...sessions.entries()]), 'utf-8')
renameSync(p + '.tmp', p)
} catch {
/* a session that cannot be persisted still works until the next restart */
}
}

export function initPlayerAuth(): void {
load()
loadSessions()
}

function hashPw(pw: string, salt: string): string {
Expand Down Expand Up @@ -325,6 +382,7 @@ function dropSessions(nameKey: string): void {
for (const [token, s] of sessions) {
if (s.mcName.toLowerCase() === nameKey) sessions.delete(token)
}
saveSessions()
}

export function login(mcName: string, password: string): { ok: true; token: string; mcName: string } | { ok: false } {
Expand All @@ -336,6 +394,7 @@ export function login(mcName: string, password: string): { ok: true; token: stri
function mintSession(mcName: string): { token: string; mcName: string } {
const token = randomBytes(32).toString('hex')
sessions.set(token, { mcName, expires: Date.now() + SESSION_TTL })
saveSessions()
return { token, mcName }
}

Expand All @@ -345,14 +404,20 @@ export function resolvePlayerSession(token: string | undefined): { mcName: strin
const s = sessions.get(token)
if (!s) return null
if (s.expires < Date.now()) {
// Dropped from memory, NOT written back. This is a read path reachable by
// anyone holding an old token, and persisting here would let a replayed
// expired token force a disk write per request — an amplifier on an
// unauthenticated path, which is the shape closed in #107. The file is
// pruned of expired entries the next time it is loaded or written for a
// real reason.
sessions.delete(token)
return null
}
return { mcName: s.mcName }
}

export function logoutPlayer(token: string): void {
sessions.delete(token)
if (sessions.delete(token)) saveSessions()
}

/** When this name registered on the site, or undefined. Not a secret: it is the
Expand Down
19 changes: 19 additions & 0 deletions src/main/web/publicSiteHtml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,9 @@ function refreshWhoami(){
only public one that parses the world's player files. */
whoamiTried=true;
api('/api/public/profile',null,ptoken).then(function(r){
/* The first authenticated request after a restart is usually this one, so it
is where a dead token is found. */
if(r.s===401){staleSession();return}
if(!r.ok||!r.j.uuid)return;
puuid=r.j.uuid;localStorage.setItem('msms_puuid',puuid);renderChrome()})}
function esc(t){var d=document.createElement('div');d.textContent=(t==null?'':t);return d.innerHTML}
Expand Down Expand Up @@ -442,6 +445,11 @@ function pageMap(){
'<span class="muted" id="mapRoundNote"></span></div>'+${JSON.stringify(MAP_HTML)}+'</div></section>'}
function mapFeedUrl(dim,cell){
return '/api/public/map?dim='+encodeURIComponent(dim)+'&cell='+encodeURIComponent(cell)}
/* This page's api() answers {ok,s,j}; the panel's answers {ok,status,body}. The
map engine used to read .body unconditionally, so on this page every poll
threw on undefined and the map never drew (#115). */
function mapGet(u){return api(u).then(function(r){return r.ok?r.j:null}).catch(function(){return null})}
function mapPost(u){return api(u,{}).then(function(r){return r.j||null}).catch(function(){return null})}
/* No admin server id on the public site: the bridge install affordance is an
operator's, and a visitor offered it would get a 404. */
function mapServerId(){return ''}
Expand All @@ -459,8 +467,19 @@ function pageProfile(name){
function loadProfile(name){
api('/api/public/profile'+(name?('?name='+encodeURIComponent(name)):''),null,ptoken).then(function(r){
var el=document.getElementById('profBox');if(!el)return;
/* The token we hold is dead — the app was restarted, or it expired. Saying
"no such player" about the person holding it is the bug in #120; drop it
and offer a login instead. */
if(r.s===401){staleSession();el.innerHTML='<p class="muted">'+esc(T('auth.sessionExpired'))+'</p>';return}
if(!r.ok){el.innerHTML='<p class="muted">'+esc(T('profile.notFound'))+'</p>';return}
PROFILE=r.j;el.innerHTML=profileHtml(r.j)})}
/* Forget a session the server has already forgotten. Without this the header
goes on showing a name and every request stays silently anonymous. */
function staleSession(){
if(!ptoken)return;
ptoken='';pname='';puuid='';whoamiTried=false;
localStorage.removeItem('msms_ptoken');localStorage.removeItem('msms_pname');localStorage.removeItem('msms_puuid');
renderChrome();openAuth()}
function headImg(uuid,size){
return uuid?('<img class="phead" width="'+size+'" height="'+size+'" src="https://crafatar.com/avatars/'+
encodeURIComponent(uuid)+'?size='+size+'&overlay" alt="" loading="lazy"/>'):''}
Expand Down
9 changes: 8 additions & 1 deletion src/main/web/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -564,7 +564,14 @@ async function handlePublic(
if (sub === 'profile' && method === 'GET') {
const psid = site.siteServerId()
const q = new URL(req.url ?? '/', 'http://localhost').searchParams
const session = playerAuth.resolvePlayerSession(bearer(req))
const tok = bearer(req)
const session = playerAuth.resolvePlayerSession(tok)
// A credential that was SUPPLIED and did not resolve is a different fact
// from no credential at all, and treating them the same is what made a
// restarted app tell a signed-in player they do not exist (#120). This says
// nothing about any name — it is about the token — so the 404-vs-200 rule
// for anonymous requests is untouched.
if (tok && !session) return sendJson(res, 401, { error: 'session-expired' })
const asked = (q.get('name') ?? '').trim() || session?.mcName || ''
if (!MC_NAME_RE.test(asked)) return sendJson(res, 400, { error: 'invalid-name' })
if (!psid || !getServer(psid)) return sendJson(res, 404, { error: 'not-found' })
Expand Down
2 changes: 2 additions & 0 deletions src/main/web/siteI18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export const SITE_STRINGS_EN: Record<string, string> = {
'auth.backToLogin': 'Back to log in',
'auth.noAccount': 'No account?',
'auth.forgot': 'Forgot your password?',
'auth.sessionExpired': 'Your session has expired — please log in again.',
'auth.resetTitle': 'Reset password',
'auth.resetHint':
'Enter your Minecraft username. You must be online on the server — we send a code to your in-game chat.',
Expand Down Expand Up @@ -163,6 +164,7 @@ export const SITE_STRINGS_TR: Record<string, string> = {
'auth.backToLogin': 'Girişe dön',
'auth.noAccount': 'Hesabınız yok mu?',
'auth.forgot': 'Parolanızı mı unuttunuz?',
'auth.sessionExpired': 'Oturumunuzun süresi doldu — lütfen tekrar giriş yapın.',
'auth.resetTitle': 'Parola sıfırlama',
'auth.resetHint':
'Minecraft kullanıcı adınızı girin. Sunucuda çevrimiçi olmalısınız — kodu oyun içi sohbetinize göndeririz.',
Expand Down
Loading
Loading