Skip to content
Open
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
74 changes: 73 additions & 1 deletion shared/shared.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { describe, expect, it, test } from 'vitest'

import { bwrapProjectDir, describeValue, leanVersionCompare, toolchainHasModules } from './shared.ts'
import {
bwrapProjectDir,
describeValue,
findSuitableAlphaNumName,
leanVersionCompare,
toolchainHasModules,
} from './shared.ts'

describe('leanVersionCompare', () => {
it('should throw on non-lean version numbers', () => {
Expand Down Expand Up @@ -47,3 +53,69 @@ test('describeValue', () => {
expect(describeValue([])).toBe('array')
expect(describeValue(true)).toBe('boolean')
})

describe('findSuitableAlphaNumName', () => {
it('should let acceptable identifiers through', async () => {
expect(await findSuitableAlphaNumName('abc', async () => true)).toBe('abc')
expect(await findSuitableAlphaNumName('ab_12_-', async () => true)).toBe('ab_12_-')
expect(await findSuitableAlphaNumName('0x123-', async () => true)).toBe('0x123-')
})

it('should add numeric postfixes to collisions', async () => {
expect(await findSuitableAlphaNumName('abc', async c => c !== 'abc')).toBe('abc1')
expect(await findSuitableAlphaNumName('abc', async c => c !== 'abc' && c !== 'abc1')).toBe('abc2')
expect(await findSuitableAlphaNumName('0x123', async c => c !== '0x123')).toBe('0x1')
expect(await findSuitableAlphaNumName('123test', async c => c !== '123test')).toBe('123test1')
})

it('should truncate unacceptable characters', async () => {
expect(await findSuitableAlphaNumName('abc@example.com', async () => true)).toBe('abc')
})

it('should anonymize candidates that start with unacceptable characters', async () => {
expect(await findSuitableAlphaNumName('../root', async () => true)).toBe('anon')
expect(await findSuitableAlphaNumName('../root', async c => c !== 'anon')).toBe('anon1')
expect(await findSuitableAlphaNumName('../root', async c => c.length >= 6)).toBe('anon10')
})

it('should truncate overlong names', async () => {
expect(
await findSuitableAlphaNumName(
'a23456789012345678902234567890323456789042345678905234567890623456789072345678908234567890923456789xxx',
async () => true,
),
).toBe('a23456789012345678902234567890323456789042345678905234567890623456789072345678908234567890923456789x')
})

it('should fill in gaps between names', async () => {
expect(
await findSuitableAlphaNumName('fred3', async a => ['fred', 'fred1', 'fred3', 'fred4'].every(b => a !== b)),
).toBe('fred2')
})

it('should accept non-conflicting numeric identifiers', async () => {
expect(await findSuitableAlphaNumName('123', async () => true)).toBe('123')
expect(await findSuitableAlphaNumName('0xabc', async () => true)).toBe('0xabc')
})

it('should turn conflicting numeric identifiers into "anon" identifiers', async () => {
expect(await findSuitableAlphaNumName('123', async c => c !== '123' && c !== 'anon')).toBe('anon1')
})

it('should trim long names to keep them under 100 characters', async () => {
expect(
await findSuitableAlphaNumName(
'a234567890123456789022345678903234567890423456789052345678906234567890723456789082345678909234abcde',
async c => c.endsWith('100'),
),
).toBe('a234567890123456789022345678903234567890423456789052345678906234567890723456789082345678909234abc100')
})

it('should fall back to the "anon" prefix for a test that rejects most inputs', async () => {
expect(await findSuitableAlphaNumName('test', async c => !/^test/.test(c))).toMatch(/^anon/)
})

it('should throw if there are no suitable names', async () => {
await expect(findSuitableAlphaNumName('test', async () => false)).rejects.toThrow()
})
})
30 changes: 30 additions & 0 deletions shared/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,3 +208,33 @@ export type BaseProject = z.output<typeof zBaseProject>

/** Filesystem location of the unix domain socket that connects the next.js server to the shard manager */
export const SHARD_MANAGER_SOCK = '/tmp/lean-workbench/shard-manager.sock'

/**
* Given a candidate string (e.g. a username) and a test to see if a string is ok,
* return a string matching `ALPHANUM_NAME_RE` and starting with a non-numeric character.
*/
export async function findSuitableAlphaNumName(candidate: string, isOk: (str: string) => Promise<boolean>) {
const candidateAlphaNum = candidate.match(/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,99}/)?.[0] ?? 'anon'

// Try to just accept this candidate
if (await isOk(candidateAlphaNum)) {
return candidateAlphaNum
}

// Remove any numeric postfix, and try successive numeric postfixes
// An entirely numeric id will become "anon"
let candidatePrefix = candidateAlphaNum.match(/^([a-zA-Z0-9_-]*[^0-9])[0-9]*$/)?.[1] ?? 'anon'
for (let i = 1; ; i++) {
if (`${candidatePrefix}${i}`.length > 100) {
candidatePrefix = candidatePrefix.slice(0, -1)
}
const newCandidate = `${candidatePrefix}${i}`
if (await isOk(newCandidate)) return newCandidate

// Protect against non-termination due to an isOk test that's overbroad
if (i > 1_000) candidatePrefix = 'anon'

// Protect harder
if (i > 10_000) throw new Error('findSuitableAlphaNumName() unable to find an acceptable candidate')
}
}
38 changes: 32 additions & 6 deletions src/lib/server/auth.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import 'server-only'

import { adminEmail, devModePassword, zUserName } from '@leanprover/workbench-shared'
import { adminEmail, devModePassword, findSuitableAlphaNumName } from '@leanprover/workbench-shared'
import { isDevMode } from '@leanprover/workbench-shared/node'
import { betterAuth, generateId, type SocialProviders } from 'better-auth'
import { prismaAdapter } from 'better-auth/adapters/prisma'
Expand Down Expand Up @@ -33,6 +33,35 @@ async function ensureAdminUserExists() {
}
}

/**
* Usernames that might conflict with route components, or that we choose to disallow for other reasons.
*/
const FORBIDDEN_USERNAMES = new Set([
'admin', // route conflict between /admin and /[userName]
'api', // route conflict between /api and /[userName]
'components', // potential route conflict between /components (if a page.tsx ends up in that directory) and `/[userName]`
'dev', // reserve for future use
'error', // reserve for future use
'lean', // reserve for future use
'lean4', // reserve for future use
'pub', // potential future conflict with <userName>.example.com-style subdomains
'profile', // potential future route conflict between /profile /[userName]
'settings', // potential future route conflict between /settings and /[userName]
'setup', // route conflict between /setup and /[userName]
'view', // reserve for future use
'www', // potential future conflict with <userName>.example.com-style subdomains
])

/** Returns true if a candidate name is not in the forbidden usernames list and is not a currently registered username. */
async function isAvailableUsername(candidate: string) {
const candidateLower = candidate.toLowerCase()
if (FORBIDDEN_USERNAMES.has(candidateLower)) return false
// potential future route conflict with <userName>.example.com-style subdomains
if (/^shard\d+$/.test(candidateLower)) return false
if (/^worker\d+$/.test(candidateLower)) return false
return null === (await getDb().user.findUnique({ where: { name: candidate } }))
}

async function createAuth() {
const config = getConfig()
const socialProviders: SocialProviders = {}
Expand Down Expand Up @@ -113,16 +142,13 @@ async function createAuth() {
user: {
create: {
before: async user => {
const parsed = zUserName.safeParse(user.name)
if (!parsed.success) return false
const name = parsed.data
if (getConfig().registrationMode === 'restricted') {
const allowed = await getDb().allowedGithubUser.findUnique({
where: { githubUsername: name },
where: { githubUsername: user.name },
})
if (!allowed) return false
}
return { data: { ...user, name } }
return { data: { ...user, name: await findSuitableAlphaNumName(user.name, isAvailableUsername) } }
},
},
},
Expand Down
Loading