diff --git a/.gitignore b/.gitignore index 7547636..522fea4 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,3 @@ *.db config.yaml .env - -.kotlin/ -.netlify/ -.firebase/ diff --git a/README.md b/README.md index f5cde10..9bb8f23 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Abhängigkeiten des Backends: `gopkg.in/yaml.v3`, `modernc.org/sqlite`, `github. ## Sichere Defaults -- Konten: `POST /api/auth/register|login|logout`, `GET /api/auth/me`; Passwörter als bcrypt-Hash, Tokens zufällig + gehasht gespeichert, 30 Tage gültig, Logout widerruft. Sessions sind pro Nutzer getrennt. Login-Rate-Limit pro IP. `registration_mode`: `open` | `invite` | `closed` (ohne Angabe gilt `allow_registration`). Im Modus `invite` braucht jede Registrierung einen Einladungscode (`BC-XXXX-XXXX`), den nur der Admin erstellt: `POST /api/invites` (`max_uses`, Standard 1; `expires_in_hours`, optional), `GET /api/invites`, `DELETE /api/invites/{code}`. Der Code wird in derselben Transaktion wie das Konto verbraucht. Öffentlich: `GET /api/auth/config` liefert den Modus. Optionaler statischer `auth_token` (mind. 16 Zeichen) = Admin, sieht alle Sessions; Vergleich in konstanter Zeit. +- Konten: `POST /api/auth/register|login|logout`, `GET /api/auth/me`; Passwörter als bcrypt-Hash, Tokens zufällig + gehasht gespeichert, 30 Tage gültig, Logout widerruft. Sessions sind pro Nutzer getrennt. Login-Rate-Limit pro IP. `allow_registration` (Standard an). Optionaler statischer `auth_token` (mind. 16 Zeichen) = Admin, sieht alle Sessions; Vergleich in konstanter Zeit. - Lauscht standardmäßig nur auf `127.0.0.1:8787`. - Browser-Origins: nur gleicher Host oder explizit in `allowed_origins`; `*` muss bewusst gesetzt werden. Native Clients (ohne `Origin`-Header) brauchen trotzdem das Token. - TLS-Zertifikatsprüfung ist überall an (LLM-Provider, `web_fetch`); es gibt keinen Schalter zum Abschalten. diff --git a/android/.kotlin/errors/errors-1793502564485.log b/android/.kotlin/errors/errors-1793502564485.log new file mode 100644 index 0000000..1219b50 --- /dev/null +++ b/android/.kotlin/errors/errors-1793502564485.log @@ -0,0 +1,4 @@ +kotlin version: 2.0.21 +error message: The daemon has terminated unexpectedly on startup attempt #1 with error code: 0. The daemon process output: + 1. Kotlin compile daemon is ready + diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 5bef1eb..a0b2532 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -31,8 +31,8 @@ android { applicationId = "dev.byteclaw.client" minSdk = 26 targetSdk = 35 - versionCode = 5 - versionName = "0.3.2" + versionCode = 4 + versionName = "0.3.1" } signingConfigs { diff --git a/android/app/src/main/java/dev/byteclaw/client/MainActivity.kt b/android/app/src/main/java/dev/byteclaw/client/MainActivity.kt index 269cd6c..7b9d59a 100644 --- a/android/app/src/main/java/dev/byteclaw/client/MainActivity.kt +++ b/android/app/src/main/java/dev/byteclaw/client/MainActivity.kt @@ -69,7 +69,7 @@ class MainActivity : ComponentActivity() { } NavHost(nav, startDestination = if (loggedIn) "chat" else "login") { composable("login") { - LoginScreen(state, onAuthenticate = { u, n, p, r, i -> vm.authenticate(u, n, p, r, i) }, onCheckMode = vm::checkRegistrationMode, + LoginScreen(state, onAuthenticate = { u, n, p, r -> vm.authenticate(u, n, p, r) }, onAdminToken = { u, t -> vm.useAdminToken(u, t) }) } composable("chat") { @@ -93,8 +93,7 @@ class MainActivity : ComponentActivity() { SettingsScreen(state, onSave = vm::saveSettings, onTest = { s, cb -> vm.testConnection(s, cb) }, onBack = { if (!nav.popBackStack()) nav.navigate("chat") { popUpTo("settings") { inclusive = true } } }, onLoadProviders = { vm.loadProviders() }, onLoadModels = { vm.loadModels(it) }, - onSelectProvider = { p, m -> vm.selectProvider(p, m) }, onLogout = vm::logout, - onLoadInvites = { vm.loadInvites() }, onCreateInvite = { n, h -> vm.createInvite(n, h) }, onRevokeInvite = { vm.revokeInvite(it) }) + onSelectProvider = { p, m -> vm.selectProvider(p, m) }, onLogout = vm::logout) } } } diff --git a/android/app/src/main/java/dev/byteclaw/client/data/ByteClawApi.kt b/android/app/src/main/java/dev/byteclaw/client/data/ByteClawApi.kt index 3c8a020..b3c89ac 100644 --- a/android/app/src/main/java/dev/byteclaw/client/data/ByteClawApi.kt +++ b/android/app/src/main/java/dev/byteclaw/client/data/ByteClawApi.kt @@ -60,18 +60,6 @@ class ByteClawApi(private val settings: ServerSettings) { suspend fun messages(sessionId: String): List = json.decodeFromString(call(Request.Builder().url(url("/api/sessions/$sessionId/messages")).build())) - suspend fun invites(): List = - json.decodeFromString(call(Request.Builder().url(url("/api/invites")).build())) - - suspend fun createInvite(maxUses: Int, expiresInHours: Int): Invite { - val body = json.encodeToString(InviteRequest.serializer(), InviteRequest(maxUses, expiresInHours)).toRequestBody(JSON) - return json.decodeFromString(call(Request.Builder().url(url("/api/invites")).post(body).build())) - } - - suspend fun deleteInvite(code: String) { - call(Request.Builder().url(url("/api/invites/$code")).delete().build()) - } - suspend fun deleteSession(sessionId: String) { call(Request.Builder().url(url("/api/sessions/$sessionId")).delete().build()) } @@ -144,11 +132,10 @@ class ByteClawApi(private val settings: ServerSettings) { private val authJson = Json { ignoreUnknownKeys = true; explicitNulls = false } /** POST /api/auth/login or /register without a bearer token. Errors carry German text. */ - suspend fun authenticate(serverUrl: String, username: String, password: String, register: Boolean, inviteCode: String = ""): AuthResponse = + suspend fun authenticate(serverUrl: String, username: String, password: String, register: Boolean): AuthResponse = withContext(Dispatchers.IO) { val path = if (register) "/api/auth/register" else "/api/auth/login" - val creds = Credentials(username.trim(), password, inviteCode.trim().uppercase().takeIf { register && it.isNotBlank() }) - val body = authJson.encodeToString(Credentials.serializer(), creds).toRequestBody(JSON) + val body = authJson.encodeToString(Credentials.serializer(), Credentials(username.trim(), password)).toRequestBody(JSON) val req = Request.Builder().url((serverUrl.trim().trimEnd('/') + path).toHttpUrl()).post(body).build() shared.newCall(req).execute().use { r -> val text = r.body?.string().orEmpty() @@ -157,18 +144,6 @@ class ByteClawApi(private val settings: ServerSettings) { } } - /** Registration mode of a server; older servers without the endpoint count as "open". */ - suspend fun registrationMode(serverUrl: String): String = withContext(Dispatchers.IO) { - runCatching { - val req = Request.Builder().url((serverUrl.trim().trimEnd('/') + "/api/auth/config").toHttpUrl()).build() - shared.newCall(req).execute().use { r -> - if (r.code == 404) "open" - else if (!r.isSuccessful) null - else authJson.decodeFromString(AuthConfig.serializer(), r.body?.string().orEmpty()).registrationMode - } - }.getOrNull() ?: "unknown" - } - private val JSON = "application/json".toMediaType() private val shared = OkHttpClient.Builder() .connectTimeout(15, TimeUnit.SECONDS) diff --git a/android/app/src/main/java/dev/byteclaw/client/data/Models.kt b/android/app/src/main/java/dev/byteclaw/client/data/Models.kt index 8eae52b..399ac78 100644 --- a/android/app/src/main/java/dev/byteclaw/client/data/Models.kt +++ b/android/app/src/main/java/dev/byteclaw/client/data/Models.kt @@ -94,29 +94,7 @@ object ProviderPresets { data class RenameRequest(val title: String) @Serializable -data class Credentials( - val username: String, - val password: String, - @SerialName("invite_code") val inviteCode: String? = null, -) - -/** GET /api/auth/config (public): "open", "invite" or "closed". */ -@Serializable -data class AuthConfig(@SerialName("registration_mode") val registrationMode: String = "open") - -/** Invite code (admin only). */ -@Serializable -data class Invite( - val code: String = "", - @SerialName("max_uses") val maxUses: Int = 1, - val uses: Int = 0, - @SerialName("expires_at") val expiresAt: String? = null, - @SerialName("created_at") val createdAt: String = "", - @SerialName("used_by") val usedBy: List = emptyList(), -) - -@Serializable -data class InviteRequest(@SerialName("max_uses") val maxUses: Int = 1, @SerialName("expires_in_hours") val expiresInHours: Int = 0) +data class Credentials(val username: String, val password: String) @Serializable data class AuthUser(val id: Long = 0, val username: String = "", val admin: Boolean = false) @@ -129,10 +107,6 @@ object AuthErrors { fun german(code: Int, body: String): String = when { code == 401 -> "Benutzername oder Passwort falsch" code == 409 -> "Benutzername ist bereits vergeben" - code == 400 && body.contains("invite code required") -> "Einladungscode erforderlich" - code == 403 && body.contains("invalid invite") -> "Einladungscode ist ungültig" - code == 403 && body.contains("already used") -> "Einladungscode wurde bereits verwendet" - code == 403 && body.contains("expired") -> "Einladungscode ist abgelaufen" code == 403 -> "Registrierung ist auf diesem Server deaktiviert" code == 429 -> "Zu viele Versuche – bitte später erneut probieren" code == 400 && body.contains("username") -> "Benutzername: 3–32 Zeichen (Buchstaben, Ziffern, . _ -)" diff --git a/android/app/src/main/java/dev/byteclaw/client/ui/ChatViewModel.kt b/android/app/src/main/java/dev/byteclaw/client/ui/ChatViewModel.kt index c571f78..feb1bd4 100644 --- a/android/app/src/main/java/dev/byteclaw/client/ui/ChatViewModel.kt +++ b/android/app/src/main/java/dev/byteclaw/client/ui/ChatViewModel.kt @@ -4,7 +4,6 @@ import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import dev.byteclaw.client.data.ActiveProvider -import dev.byteclaw.client.data.Invite import dev.byteclaw.client.data.ByteClawApi import dev.byteclaw.client.data.AuthErrors import dev.byteclaw.client.data.UnauthorizedException @@ -47,11 +46,6 @@ data class UiState( val authError: String? = null, /** From /api/auth/me: only the admin may change providers. */ val isAdmin: Boolean = false, - /** Registration mode of the server entered on the login screen: open, invite, closed or unknown. */ - val registrationMode: String = "unknown", - /** Admin: invite codes and status of the last invite action. */ - val invites: List = emptyList(), - val inviteStatus: String? = null, ) { /** What the current chat effectively uses: session override, else the global active provider. */ val effectiveProvider: ActiveProvider? get() = sessionProvider ?: provider.active @@ -87,40 +81,13 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) { } /** Login or registration; on success the token is stored encrypted and the chat opens. */ - fun checkRegistrationMode(serverUrl: String) { - if (ByteClawApi.validateUrl(serverUrl) != null) return - viewModelScope.launch { - val m = ByteClawApi.registrationMode(serverUrl) - _state.update { it.copy(registrationMode = m) } - } - } - - fun loadInvites() = viewModelScope.launch { - runCatching { api().invites() } - .onSuccess { l -> _state.update { it.copy(invites = l) } } - .onFailure { e -> _state.update { it.copy(inviteStatus = "Codes laden fehlgeschlagen: ${e.message}") } } - } - - fun createInvite(maxUses: Int, expiresInHours: Int) = viewModelScope.launch { - runCatching { api().createInvite(maxUses, expiresInHours) } - .onSuccess { i -> _state.update { it.copy(inviteStatus = "Neuer Code: ${i.code}") }; loadInvites() } - .onFailure { e -> _state.update { it.copy(inviteStatus = "Fehler: ${e.message}") } } - } - - fun revokeInvite(code: String) = viewModelScope.launch { - runCatching { api().deleteInvite(code) } - .onSuccess { _state.update { it.copy(inviteStatus = "Code $code widerrufen") }; loadInvites() } - .onFailure { e -> _state.update { it.copy(inviteStatus = "Fehler: ${e.message}") } } - } - - fun authenticate(serverUrl: String, username: String, password: String, register: Boolean, inviteCode: String = "") { + fun authenticate(serverUrl: String, username: String, password: String, register: Boolean) { val urlErr = ByteClawApi.validateUrl(serverUrl) val err = urlErr ?: AuthErrors.validate(username, password) - ?: if (register && _state.value.registrationMode == "invite" && inviteCode.isBlank()) "Einladungscode erforderlich" else null if (err != null) { _state.update { it.copy(authError = err) }; return } _state.update { it.copy(authBusy = true, authError = null) } viewModelScope.launch { - runCatching { ByteClawApi.authenticate(serverUrl, username, password, register, inviteCode) } + runCatching { ByteClawApi.authenticate(serverUrl, username, password, register) } .onSuccess { r -> store.save(ServerSettings(serverUrl, r.token, r.user.username)) _state.update { UiState(settings = store.load()) } diff --git a/android/app/src/main/java/dev/byteclaw/client/ui/InviteSection.kt b/android/app/src/main/java/dev/byteclaw/client/ui/InviteSection.kt deleted file mode 100644 index b6f916d..0000000 --- a/android/app/src/main/java/dev/byteclaw/client/ui/InviteSection.kt +++ /dev/null @@ -1,86 +0,0 @@ -package dev.byteclaw.client.ui - -import android.content.Intent -import androidx.compose.foundation.layout.* -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ContentCopy -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.Share -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.unit.dp -import dev.byteclaw.client.data.Invite - -private val expiryOptions = listOf(0 to "Nie", 24 to "1 Tag", 24 * 7 to "7 Tage", 24 * 30 to "30 Tage") - -/** Admin only: create, copy/share, list and revoke invite codes. */ -@Composable -fun InviteSection( - state: UiState, - onLoad: () -> Unit, - onCreate: (maxUses: Int, expiresInHours: Int) -> Unit, - onRevoke: (String) -> Unit, -) { - LaunchedEffect(Unit) { onLoad() } - var maxUses by remember { mutableStateOf("1") } - var expiry by remember { mutableIntStateOf(0) } - var confirm by remember { mutableStateOf(null) } - val clip = LocalClipboardManager.current - val ctx = LocalContext.current - fun share(code: String) { - val text = "Einladung zu ByteClaw: ${state.settings.serverUrl}\nEinladungscode: $code" - val send = Intent(Intent.ACTION_SEND).setType("text/plain").putExtra(Intent.EXTRA_TEXT, text) - runCatching { ctx.startActivity(Intent.createChooser(send, null)) } - } - Card(Modifier.fillMaxWidth()) { - Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text("Einladungscodes", style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f)) - TextButton(onClick = onLoad) { Text("Aktualisieren") } - } - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedTextField(maxUses, { v -> maxUses = v.filter { it.isDigit() }.take(4) }, Modifier.width(110.dp), - label = { Text("Nutzungen") }, singleLine = true) - var open by remember { mutableStateOf(false) } - Box { - OutlinedButton(onClick = { open = true }) { Text("Gültig: " + expiryOptions.first { it.first == expiry }.second) } - DropdownMenu(open, { open = false }) { - expiryOptions.forEach { (h, l) -> DropdownMenuItem(text = { Text(l) }, onClick = { expiry = h; open = false }) } - } - } - } - Button(onClick = { onCreate((maxUses.toIntOrNull() ?: 1).coerceIn(1, 1000), expiry) }) { Text("Code erstellen") } - state.inviteStatus?.let { Text(it, style = MaterialTheme.typography.bodySmall) } - if (state.invites.isEmpty()) Text("Noch keine Codes.", style = MaterialTheme.typography.bodySmall) - state.invites.forEach { inv -> InviteRow(inv, onCopy = { clip.setText(AnnotatedString(inv.code)) }, onShare = { share(inv.code) }, onRevoke = { confirm = inv.code }) } - } - } - confirm?.let { code -> - AlertDialog(onDismissRequest = { confirm = null }, title = { Text("Code widerrufen?") }, - text = { Text("$code kann danach nicht mehr verwendet werden. Bereits registrierte Konten bleiben bestehen.") }, - confirmButton = { TextButton(onClick = { onRevoke(code); confirm = null }) { Text("Widerrufen") } }, - dismissButton = { TextButton(onClick = { confirm = null }) { Text("Abbrechen") } }) - } -} - -@Composable -private fun InviteRow(inv: Invite, onCopy: () -> Unit, onShare: () -> Unit, onRevoke: () -> Unit) { - val usedUp = inv.uses >= inv.maxUses - Row(verticalAlignment = Alignment.CenterVertically) { - Column(Modifier.weight(1f)) { - Text(inv.code, fontFamily = FontFamily.Monospace, color = if (usedUp) MaterialTheme.colorScheme.outline else MaterialTheme.colorScheme.onSurface) - val exp = inv.expiresAt?.take(10)?.let { " · bis $it" } ?: "" - val by = if (inv.usedBy.isNotEmpty()) " · " + inv.usedBy.joinToString() else "" - Text("${inv.uses}/${inv.maxUses} genutzt$exp$by", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.outline) - } - IconButton(onClick = onCopy) { Icon(Icons.Filled.ContentCopy, "Kopieren") } - IconButton(onClick = onShare, enabled = !usedUp) { Icon(Icons.Filled.Share, "Teilen") } - IconButton(onClick = onRevoke) { Icon(Icons.Filled.Delete, "Widerrufen") } - } -} diff --git a/android/app/src/main/java/dev/byteclaw/client/ui/LoginScreen.kt b/android/app/src/main/java/dev/byteclaw/client/ui/LoginScreen.kt index bad7abe..5580298 100644 --- a/android/app/src/main/java/dev/byteclaw/client/ui/LoginScreen.kt +++ b/android/app/src/main/java/dev/byteclaw/client/ui/LoginScreen.kt @@ -18,8 +18,7 @@ import dev.byteclaw.client.data.ByteClawApi @Composable fun LoginScreen( state: UiState, - onAuthenticate: (url: String, user: String, pass: String, register: Boolean, invite: String) -> Unit, - onCheckMode: (url: String) -> Unit = {}, + onAuthenticate: (url: String, user: String, pass: String, register: Boolean) -> Unit, onAdminToken: (url: String, token: String) -> Unit = { _, _ -> }, initialRegister: Boolean = false, initialUser: String? = null, @@ -31,9 +30,6 @@ fun LoginScreen( var pass2 by remember { mutableStateOf("") } var adminMode by remember { mutableStateOf(false) } var token by remember { mutableStateOf("") } - var invite by remember { mutableStateOf("") } - LaunchedEffect(url, register) { if (register) { kotlinx.coroutines.delay(400); onCheckMode(url) } } - val needInvite = register && state.registrationMode == "invite" val urlError = if (url.isBlank()) null else ByteClawApi.validateUrl(url) val mismatch = register && pass2.isNotEmpty() && pass != pass2 @@ -69,22 +65,14 @@ fun LoginScreen( OutlinedTextField(pass2, { pass2 = it }, Modifier.fillMaxWidth(), label = { Text("Passwort wiederholen") }, singleLine = true, isError = mismatch, supportingText = if (mismatch) { { Text("Passwörter stimmen nicht überein") } } else null, visualTransformation = PasswordVisualTransformation(), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password)) - if (state.registrationMode == "closed") { - Text("Registrierung ist auf diesem Server deaktiviert.", color = MaterialTheme.colorScheme.error) - } - if (needInvite) { - OutlinedTextField(invite, { invite = it.uppercase() }, Modifier.fillMaxWidth(), label = { Text("Einladungscode") }, - placeholder = { Text("BC-XXXX-XXXX") }, singleLine = true, - supportingText = { Text("Diesen Server kann man nur mit Einladung nutzen – frag den Admin nach einem Code.") }) - } } } state.authError?.let { Text(it, color = MaterialTheme.colorScheme.error) } Button( modifier = Modifier.fillMaxWidth(), enabled = !state.authBusy && url.isNotBlank() && urlError == null && - (if (adminMode) token.isNotBlank() else user.isNotBlank() && pass.isNotEmpty() && (!register || pass == pass2 && (!needInvite || invite.isNotBlank()))), - onClick = { if (adminMode) onAdminToken(url, token) else onAuthenticate(url, user, pass, register, invite) }, + (if (adminMode) token.isNotBlank() else user.isNotBlank() && pass.isNotEmpty() && (!register || pass == pass2)), + onClick = { if (adminMode) onAdminToken(url, token) else onAuthenticate(url, user, pass, register) }, ) { if (state.authBusy) CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp) else Text(when { adminMode -> "Mit Token verbinden"; register -> "Konto erstellen"; else -> "Anmelden" }) diff --git a/android/app/src/main/java/dev/byteclaw/client/ui/Screens.kt b/android/app/src/main/java/dev/byteclaw/client/ui/Screens.kt index f58002c..6021bfb 100644 --- a/android/app/src/main/java/dev/byteclaw/client/ui/Screens.kt +++ b/android/app/src/main/java/dev/byteclaw/client/ui/Screens.kt @@ -271,9 +271,6 @@ fun SettingsScreen( onSelectProvider: (String, String) -> Unit = { _, _ -> }, initialStatus: String? = null, onLogout: () -> Unit = {}, - onLoadInvites: () -> Unit = {}, - onCreateInvite: (Int, Int) -> Unit = { _, _ -> }, - onRevokeInvite: (String) -> Unit = {}, ) { var status by remember { mutableStateOf(initialStatus) } LaunchedEffect(state.settings) { if (state.settings.isConfigured) onLoadProviders() } @@ -301,7 +298,6 @@ fun SettingsScreen( if (state.settings.isConfigured) { if (state.isAdmin) { item { ProviderSection(state.provider, onLoadProviders, onLoadModels, onSelectProvider) } - item { InviteSection(state, onLoadInvites, onCreateInvite, onRevokeInvite) } } else { item { Text("LLM-Provider: " + (state.provider.active?.let { ProviderPresets.label(it.provider) + " · " + it.model } ?: "–") + diff --git a/android/app/src/test/java/dev/byteclaw/client/preview/PreviewScreenshots.kt b/android/app/src/test/java/dev/byteclaw/client/preview/PreviewScreenshots.kt index cdd9304..6cbe174 100644 --- a/android/app/src/test/java/dev/byteclaw/client/preview/PreviewScreenshots.kt +++ b/android/app/src/test/java/dev/byteclaw/client/preview/PreviewScreenshots.kt @@ -11,7 +11,6 @@ import dev.byteclaw.client.ui.ChatScreen import dev.byteclaw.client.ui.SessionsScreen import dev.byteclaw.client.ui.SettingsScreen import dev.byteclaw.client.ui.LoginScreen -import dev.byteclaw.client.ui.InviteSection import dev.byteclaw.client.ui.UiState import dev.byteclaw.client.ui.ProviderUi import dev.byteclaw.client.data.ProviderInfo @@ -153,25 +152,13 @@ class PreviewScreenshots(private val dark: Boolean) { } @Test fun login() = snap("login") { - LoginScreen(state = UiState(isAdmin = true, settings = ServerSettings("https://demo.byteclaw.example", "", "crypto")), onAuthenticate = { _, _, _, _, _ -> }) + LoginScreen(state = UiState(isAdmin = true, settings = ServerSettings("https://demo.byteclaw.example", "", "crypto")), onAuthenticate = { _, _, _, _ -> }) } @Test fun loginRegister() = snap("login_register") { LoginScreen( - state = UiState(isAdmin = true, settings = ServerSettings("https://demo.byteclaw.example"), registrationMode = "invite", authError = "Einladungscode wurde bereits verwendet"), - onAuthenticate = { _, _, _, _, _ -> }, initialRegister = true, initialUser = "crypto", - ) - } - - @Test fun invites() = snap("invites") { - InviteSection( - state = UiState(isAdmin = true, settings = ServerSettings("https://byteclaw.jodabreit-ai.de", "t", "admin"), inviteStatus = "Neuer Code: BC-7KQM-X4TP", - invites = listOf( - dev.byteclaw.client.data.Invite("BC-7KQM-X4TP", 1, 0, null, "2026-11-01T05:00:00Z"), - dev.byteclaw.client.data.Invite("BC-H9RW-2NFD", 5, 2, "2026-11-08T05:00:00Z", "2026-11-01T04:00:00Z", listOf("anna", "ben")), - dev.byteclaw.client.data.Invite("BC-3VXE-8MZA", 1, 1, null, "2026-10-31T20:00:00Z", listOf("crypto")), - )), - onLoad = {}, onCreate = { _, _ -> }, onRevoke = {}, + state = UiState(isAdmin = true, settings = ServerSettings("https://demo.byteclaw.example"), authError = "Benutzername ist bereits vergeben"), + onAuthenticate = { _, _, _, _ -> }, initialRegister = true, initialUser = "crypto", ) } } diff --git a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_invites[0]_invites_light.png b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_invites[0]_invites_light.png deleted file mode 100644 index 174997e..0000000 Binary files a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_invites[0]_invites_light.png and /dev/null differ diff --git a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_invites[1]_invites_dark.png b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_invites[1]_invites_dark.png deleted file mode 100644 index 0afab55..0000000 Binary files a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_invites[1]_invites_dark.png and /dev/null differ diff --git a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_loginRegister[0]_login_register_light.png b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_loginRegister[0]_login_register_light.png index 86c8705..93d8bb0 100644 Binary files a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_loginRegister[0]_login_register_light.png and b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_loginRegister[0]_login_register_light.png differ diff --git a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_loginRegister[1]_login_register_dark.png b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_loginRegister[1]_login_register_dark.png index 54b8da2..62c1ef8 100644 Binary files a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_loginRegister[1]_login_register_dark.png and b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_loginRegister[1]_login_register_dark.png differ diff --git a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_providers[0]_providers_light.png b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_providers[0]_providers_light.png index 98f70db..48101b0 100644 Binary files a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_providers[0]_providers_light.png and b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_providers[0]_providers_light.png differ diff --git a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_providers[1]_providers_dark.png b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_providers[1]_providers_dark.png index b8a72bf..87d9bd8 100644 Binary files a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_providers[1]_providers_dark.png and b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_providers[1]_providers_dark.png differ diff --git a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_settings[0]_settings_light.png b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_settings[0]_settings_light.png index fdaadd9..9058377 100644 Binary files a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_settings[0]_settings_light.png and b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_settings[0]_settings_light.png differ diff --git a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_settings[1]_settings_dark.png b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_settings[1]_settings_dark.png index cce9ae3..0c61a8a 100644 Binary files a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_settings[1]_settings_dark.png and b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_settings[1]_settings_dark.png differ diff --git a/cmd/byteclaw/main.go b/cmd/byteclaw/main.go index b967ffa..23a1b40 100644 --- a/cmd/byteclaw/main.go +++ b/cmd/byteclaw/main.go @@ -86,7 +86,7 @@ func run(cfgPath string, log *slog.Logger) error { log.Error("invalid trusted_proxies", "err", err) os.Exit(1) } - srv := &server.Server{TrustedProxies: trusted, Agent: ag, Store: st, Token: cfg.AuthToken, AllowRegistration: cfg.RegistrationAllowed(), RegistrationMode: cfg.Registration(), TokenTTL: time.Duration(cfg.TokenTTLHours) * time.Hour, AllowedOrigins: cfg.AllowedOrigins, Log: log, Providers: pm, + srv := &server.Server{TrustedProxies: trusted, Agent: ag, Store: st, Token: cfg.AuthToken, AllowRegistration: cfg.RegistrationAllowed(), TokenTTL: time.Duration(cfg.TokenTTLHours) * time.Hour, AllowedOrigins: cfg.AllowedOrigins, Log: log, Providers: pm, Info: server.Info{Version: version, Provider: cfg.LLM.Provider, Model: cfg.LLM.Model, BaseURL: cfg.LLM.BaseURL}, RunTimeout: time.Duration(cfg.LLM.TimeoutSec*cfg.Agent.MaxIterations) * time.Second} diff --git a/config.example.yaml b/config.example.yaml index b709482..e3eb03d 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -3,8 +3,7 @@ # BYTECLAW_LISTEN, BYTECLAW_DATA_DIR, BYTECLAW_WORKSPACE, BYTECLAW_ALLOWED_ORIGINS (comma separated) listen: 127.0.0.1:8787 # bind to 0.0.0.0 only behind TLS (reverse proxy) or in a trusted LAN auth_token: "" # optional static admin token (>= 16 chars, sees all sessions). Generate: openssl rand -hex 32 -allow_registration: true # legacy switch; registration_mode wins if set -registration_mode: "" # open | invite | closed (invite: needs a code from POST /api/invites, admin only) +allow_registration: true # users sign up via POST /api/auth/register; set false after creating your account token_ttl_hours: 720 # login token lifetime trusted_proxies: [] # reverse proxy IPs/CIDRs whose X-Forwarded-For is trusted (empty = loopback only) allowed_origins: [] # browser origins allowed besides same host; "*" only if you really mean it diff --git a/internal/config/config.go b/internal/config/config.go index 5323366..abbddee 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -80,8 +80,6 @@ type Config struct { AuthToken string `yaml:"auth_token" json:"auth_token"` // AllowRegistration enables self-service sign-up (default true). AllowRegistration *bool `yaml:"allow_registration" json:"allow_registration"` - // RegistrationMode: open | invite | closed. Empty = derived from allow_registration. - RegistrationMode string `yaml:"registration_mode" json:"registration_mode"` // TokenTTLHours is the lifetime of login tokens (default 720 = 30 days). TokenTTLHours int `yaml:"token_ttl_hours" json:"token_ttl_hours"` // AllowedOrigins for browser WebSocket/CORS. Empty = same host only. "*" must be set explicitly. @@ -166,7 +164,6 @@ func applyEnv(c *Config, get func(string) string) { b := v == "1" || strings.EqualFold(v, "true") || strings.EqualFold(v, "yes") c.AllowRegistration = &b } - set("BYTECLAW_REGISTRATION_MODE", &c.RegistrationMode) if v := get("BYTECLAW_ALLOWED_ORIGINS"); v != "" { c.AllowedOrigins = nil for _, o := range strings.Split(v, ",") { @@ -180,29 +177,12 @@ func applyEnv(c *Config, get func(string) string) { // RegistrationAllowed reports allow_registration (default true). func (c Config) RegistrationAllowed() bool { return c.AllowRegistration == nil || *c.AllowRegistration } -// Registration returns the effective registration mode (open, invite or closed). -func (c Config) Registration() string { - if m := strings.ToLower(strings.TrimSpace(c.RegistrationMode)); m != "" { - return m - } - if c.RegistrationAllowed() { - return "open" - } - return "closed" -} - // Validate enforces required fields and secure settings. func (c Config) Validate() error { var errs []error if c.AuthToken != "" && len(c.AuthToken) < 16 { errs = append(errs, errors.New("auth_token must be at least 16 characters when set (BYTECLAW_AUTH_TOKEN)")) } - if m := c.Registration(); m != "open" && m != "invite" && m != "closed" { - errs = append(errs, errors.New("registration_mode must be open, invite or closed")) - } - if c.Registration() == "invite" && c.AuthToken == "" { - errs = append(errs, errors.New("registration_mode invite needs auth_token (only the admin can create invite codes)")) - } if c.LLM.BaseURL == "" || c.LLM.Model == "" { errs = append(errs, errors.New("llm.base_url and llm.model are required")) } diff --git a/internal/server/auth.go b/internal/server/auth.go index 6070453..c2521da 100644 --- a/internal/server/auth.go +++ b/internal/server/auth.go @@ -215,9 +215,8 @@ func (s *Server) auth(h http.Handler) http.Handler { } type credentials struct { - Username string `json:"username"` - Password string `json:"password"` - InviteCode string `json:"invite_code,omitempty"` + Username string `json:"username"` + Password string `json:"password"` } func validUsername(u string) bool { @@ -248,8 +247,7 @@ func (s *Server) issue(w http.ResponseWriter, r *http.Request, u store.User, cod func (s *Server) register(w http.ResponseWriter, r *http.Request) { s.initAuth() - mode := s.regMode() - if mode == "closed" { + if !s.AllowRegistration { writeErr(w, http.StatusForbidden, "registration disabled") return } @@ -271,24 +269,13 @@ func (s *Server) register(w http.ResponseWriter, r *http.Request) { writeErr(w, http.StatusBadRequest, "password must be 8-72 characters") return } - code := "" - if mode == "invite" { - if code = store.NormalizeInviteCode(c.InviteCode); code == "" { - writeErr(w, http.StatusBadRequest, "invite code required") - return - } - } s.limiter.Fail(ip) // every registration counts against the IP budget hash, err := bcrypt.GenerateFromPassword([]byte(c.Password), bcrypt.DefaultCost) if err != nil { s.storeErr(w, err) return } - u, err := s.Store.CreateUserWithInvite(r.Context(), c.Username, string(hash), code) - if errors.Is(err, store.ErrInviteInvalid) || errors.Is(err, store.ErrInviteUsed) || errors.Is(err, store.ErrInviteExpired) { - writeErr(w, http.StatusForbidden, err.Error()) - return - } + u, err := s.Store.CreateUser(r.Context(), c.Username, string(hash)) if errors.Is(err, store.ErrUserExists) { writeErr(w, http.StatusConflict, "username already taken") return @@ -352,15 +339,3 @@ func (s *Server) owns(w http.ResponseWriter, r *http.Request, id string) bool { } return true } - -// regMode is the effective registration mode. -func (s *Server) regMode() string { - switch s.RegistrationMode { - case "open", "invite", "closed": - return s.RegistrationMode - } - if s.AllowRegistration { - return "open" - } - return "closed" -} diff --git a/internal/server/invites.go b/internal/server/invites.go deleted file mode 100644 index a83b0f4..0000000 --- a/internal/server/invites.go +++ /dev/null @@ -1,54 +0,0 @@ -package server - -import ( - "net/http" - "time" -) - -type inviteReq struct { - MaxUses int `json:"max_uses"` - ExpiresInHours int `json:"expires_in_hours"` -} - -func (s *Server) createInvite(w http.ResponseWriter, r *http.Request) { - if !requireAdmin(w, r) { - return - } - var b inviteReq - if r.ContentLength != 0 && !decode(w, r, &b) { - return - } - if b.MaxUses < 0 || b.MaxUses > 1000 || b.ExpiresInHours < 0 || b.ExpiresInHours > 24*365 { - writeErr(w, http.StatusBadRequest, "max_uses must be 1-1000, expires_in_hours 0-8760") - return - } - inv, err := s.Store.CreateInvite(r.Context(), b.MaxUses, time.Duration(b.ExpiresInHours)*time.Hour) - if err != nil { - s.storeErr(w, err) - return - } - writeJSON(w, http.StatusCreated, inv) -} - -func (s *Server) listInvites(w http.ResponseWriter, r *http.Request) { - if !requireAdmin(w, r) { - return - } - list, err := s.Store.ListInvites(r.Context()) - if err != nil { - s.storeErr(w, err) - return - } - writeJSON(w, 200, list) -} - -func (s *Server) deleteInvite(w http.ResponseWriter, r *http.Request) { - if !requireAdmin(w, r) { - return - } - if err := s.Store.DeleteInvite(r.Context(), r.PathValue("code")); err != nil { - s.storeErr(w, err) - return - } - w.WriteHeader(http.StatusNoContent) -} diff --git a/internal/server/invites_test.go b/internal/server/invites_test.go deleted file mode 100644 index 0eb5a1a..0000000 --- a/internal/server/invites_test.go +++ /dev/null @@ -1,145 +0,0 @@ -package server - -import ( - "context" - "encoding/json" - "io" - "regexp" - "strings" - "sync" - "testing" - "time" -) - -func regBody(u, p, code string) string { - b, _ := json.Marshal(map[string]string{"username": u, "password": p, "invite_code": code}) - return string(b) -} - - -func TestInviteRegistration(t *testing.T) { - ts, st, s := authSetup(t, true) - s.RegistrationMode = "invite" - adm := bearerHdr(tok) - - var cfg map[string]string - json.NewDecoder(do(t, "GET", ts.URL+"/api/auth/config", "", nil).Body).Decode(&cfg) - if cfg["registration_mode"] != "invite" { - t.Fatalf("%v", cfg) - } - if r := do(t, "POST", ts.URL+"/api/auth/register", creds("alice", "password-1"), nil); r.StatusCode != 400 { - t.Fatal("no code must be 400", r.StatusCode) - } - if r := do(t, "POST", ts.URL+"/api/auth/register", regBody("alice", "password-1", "BC-NOPE-NOPE"), nil); r.StatusCode != 403 { - t.Fatal("unknown code must be 403", r.StatusCode) - } - // only admin may manage codes - ut := "" - { - s.RegistrationMode = "open" - ut = registerUser(t, ts.URL, "bob", "password-1") - s.RegistrationMode = "invite" - } - for _, m := range []string{"GET", "POST"} { - if r := do(t, m, ts.URL+"/api/invites", "", bearerHdr(ut)); r.StatusCode != 403 { - t.Fatal(m, "invites must be admin only", r.StatusCode) - } - } - r := do(t, "POST", ts.URL+"/api/invites", "", adm) - if r.StatusCode != 201 { - b, _ := io.ReadAll(r.Body) - t.Fatal(r.StatusCode, string(b)) - } - var inv struct { - Code string `json:"code"` - MaxUses int `json:"max_uses"` - } - json.NewDecoder(r.Body).Decode(&inv) - if !regexp.MustCompile(`^BC-[2-9A-HJKMNP-Z]{4}-[2-9A-HJKMNP-Z]{4}$`).MatchString(inv.Code) || inv.MaxUses != 1 { - t.Fatalf("%+v", inv) - } - // lower-case input is accepted - if r := do(t, "POST", ts.URL+"/api/auth/register", regBody("alice", "password-1", " "+strings.ToLower(inv.Code)), nil); r.StatusCode != 201 { - b, _ := io.ReadAll(r.Body) - t.Fatal("register with code", r.StatusCode, string(b)) - } - r = do(t, "POST", ts.URL+"/api/auth/register", regBody("carol", "password-1", inv.Code), nil) - b, _ := io.ReadAll(r.Body) - if r.StatusCode != 403 || !strings.Contains(string(b), "already used") { - t.Fatal("reuse", r.StatusCode, string(b)) - } - // list shows usage - var list []struct { - Code string `json:"code"` - Uses int `json:"uses"` - UsedBy []string `json:"used_by"` - } - json.NewDecoder(do(t, "GET", ts.URL+"/api/invites", "", adm).Body).Decode(&list) - if len(list) != 1 || list[0].Uses != 1 || len(list[0].UsedBy) != 1 || list[0].UsedBy[0] != "alice" { - t.Fatalf("%+v", list) - } - // multi-use + revoke - r = do(t, "POST", ts.URL+"/api/invites", `{"max_uses":5,"expires_in_hours":24}`, adm) - json.NewDecoder(r.Body).Decode(&inv) - if inv.MaxUses != 5 { - t.Fatalf("%+v", inv) - } - if r := do(t, "DELETE", ts.URL+"/api/invites/"+inv.Code, "", adm); r.StatusCode != 204 { - t.Fatal("revoke", r.StatusCode) - } - if r := do(t, "POST", ts.URL+"/api/auth/register", regBody("dave", "password-1", inv.Code), nil); r.StatusCode != 403 { - t.Fatal("revoked code", r.StatusCode) - } - if r := do(t, "DELETE", ts.URL+"/api/invites/"+inv.Code, "", adm); r.StatusCode != 404 { - t.Fatal("revoke twice", r.StatusCode) - } - // expired - ex, _ := st.CreateInvite(context.Background(), 1, time.Millisecond) - time.Sleep(5 * time.Millisecond) - r = do(t, "POST", ts.URL+"/api/auth/register", regBody("erin", "password-1", ex.Code), nil) - b, _ = io.ReadAll(r.Body) - if r.StatusCode != 403 || !strings.Contains(string(b), "expired") { - t.Fatal("expired", r.StatusCode, string(b)) - } - // closed mode - s.RegistrationMode = "closed" - if r := do(t, "POST", ts.URL+"/api/auth/register", regBody("frank", "password-1", ""), nil); r.StatusCode != 403 { - t.Fatal("closed", r.StatusCode) - } -} - -func TestInviteConsumedAtomically(t *testing.T) { - _, st, _ := authSetup(t, true) - ctx := context.Background() - inv, err := st.CreateInvite(ctx, 3, 0) - if err != nil { - t.Fatal(err) - } - var wg sync.WaitGroup - var mu sync.Mutex - ok := 0 - for i := 0; i < 10; i++ { - wg.Add(1) - go func(i int) { - defer wg.Done() - if _, err := st.CreateUserWithInvite(ctx, "u"+string(rune('a'+i))+"xx", "h", inv.Code); err == nil { - mu.Lock() - ok++ - mu.Unlock() - } - }(i) - } - wg.Wait() - if ok != 3 { - t.Fatalf("expected exactly 3 registrations, got %d", ok) - } - // failed user insert must not burn a use - inv2, _ := st.CreateInvite(ctx, 1, 0) - st.CreateUser(ctx, "dupe", "h") - if _, err := st.CreateUserWithInvite(ctx, "dupe", "h", inv2.Code); err == nil { - t.Fatal("duplicate username must fail") - } - if _, err := st.CreateUserWithInvite(ctx, "fresh", "h", inv2.Code); err != nil { - t.Fatal("code must still be usable after failed insert:", err) - } -} diff --git a/internal/server/server.go b/internal/server/server.go index 3fa1a91..6357b84 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -37,8 +37,6 @@ type Server struct { Providers *providers.Manager // AllowRegistration enables POST /api/auth/register. AllowRegistration bool - // RegistrationMode overrides AllowRegistration: "open", "invite" or "closed". - RegistrationMode string // TokenTTL is the lifetime of user tokens (default 30 days). TokenTTL time.Duration // TrustedProxies whose X-Forwarded-For is honoured (empty = loopback only). @@ -61,16 +59,10 @@ func (s *Server) Handler() http.Handler { s.initAuth() mux.HandleFunc("POST /api/auth/register", s.register) mux.HandleFunc("POST /api/auth/login", s.login) - mux.HandleFunc("GET /api/auth/config", func(w http.ResponseWriter, _ *http.Request) { - writeJSON(w, 200, map[string]string{"registration_mode": s.regMode()}) - }) api := http.NewServeMux() api.HandleFunc("POST /api/auth/logout", s.logout) api.HandleFunc("GET /api/auth/me", s.me) api.HandleFunc("GET /api/info", s.info) - api.HandleFunc("GET /api/invites", s.listInvites) - api.HandleFunc("POST /api/invites", s.createInvite) - api.HandleFunc("DELETE /api/invites/{code}", s.deleteInvite) api.HandleFunc("GET /api/providers", s.listProviders) api.HandleFunc("PUT /api/providers/active", s.setActiveProvider) api.HandleFunc("GET /api/providers/{name}/models", s.listModels) diff --git a/internal/store/invites.go b/internal/store/invites.go deleted file mode 100644 index bc758d3..0000000 --- a/internal/store/invites.go +++ /dev/null @@ -1,163 +0,0 @@ -package store - -import ( - "context" - "crypto/rand" - "database/sql" - "errors" - "math/big" - "strings" - "time" -) - -var ( - ErrInviteInvalid = errors.New("invalid invite code") - ErrInviteUsed = errors.New("invite code already used") - ErrInviteExpired = errors.New("invite code expired") -) - -// Invite is a registration code. ExpiresAt is zero when it never expires. -type Invite struct { - Code string `json:"code"` - MaxUses int `json:"max_uses"` - Uses int `json:"uses"` - ExpiresAt *time.Time `json:"expires_at,omitempty"` - CreatedAt time.Time `json:"created_at"` - UsedBy []string `json:"used_by"` -} - -// No 0/O/1/I/L to keep codes easy to read and type. -const inviteAlphabet = "23456789ABCDEFGHJKMNPQRSTUVWXYZ" - -func newInviteCode() (string, error) { - var b strings.Builder - b.WriteString("BC-") - for i := 0; i < 8; i++ { - if i == 4 { - b.WriteByte('-') - } - n, err := rand.Int(rand.Reader, big.NewInt(int64(len(inviteAlphabet)))) - if err != nil { - return "", err - } - b.WriteByte(inviteAlphabet[n.Int64()]) - } - return b.String(), nil -} - -// NormalizeInviteCode upper-cases and trims user input ("bc-abcd-efgh " -> "BC-ABCD-EFGH"). -func NormalizeInviteCode(c string) string { return strings.ToUpper(strings.TrimSpace(c)) } - -// CreateInvite stores a new random code. maxUses < 1 means 1; ttl <= 0 means no expiry. -func (s *Store) CreateInvite(ctx context.Context, maxUses int, ttl time.Duration) (Invite, error) { - if maxUses < 1 { - maxUses = 1 - } - now := time.Now().UTC() - inv := Invite{MaxUses: maxUses, CreatedAt: now, UsedBy: []string{}} - var exp int64 - if ttl > 0 { - t := now.Add(ttl) - inv.ExpiresAt = &t - exp = t.UnixMilli() - } - for attempt := 0; attempt < 5; attempt++ { - code, err := newInviteCode() - if err != nil { - return Invite{}, err - } - _, err = s.db.ExecContext(ctx, `INSERT INTO invites(code,max_uses,uses,expires_at,created_at) VALUES(?,?,0,?,?)`, code, maxUses, exp, now.UnixMilli()) - if err == nil { - inv.Code = code - return inv, nil - } - if !strings.Contains(err.Error(), "UNIQUE") { - return Invite{}, err - } - } - return Invite{}, errors.New("could not generate unique invite code") -} - -// ListInvites returns all codes, newest first, with the usernames that used them. -func (s *Store) ListInvites(ctx context.Context) ([]Invite, error) { - rows, err := s.db.QueryContext(ctx, `SELECT code,max_uses,uses,expires_at,created_at FROM invites ORDER BY created_at DESC`) - if err != nil { - return nil, err - } - var out []Invite - idx := map[string]int{} - for rows.Next() { - var inv Invite - var exp, c int64 - if err := rows.Scan(&inv.Code, &inv.MaxUses, &inv.Uses, &exp, &c); err != nil { - rows.Close() - return nil, err - } - inv.CreatedAt = time.UnixMilli(c).UTC() - if exp > 0 { - t := time.UnixMilli(exp).UTC() - inv.ExpiresAt = &t - } - inv.UsedBy = []string{} - idx[inv.Code] = len(out) - out = append(out, inv) - } - rows.Close() - if err := rows.Err(); err != nil { - return nil, err - } - ur, err := s.db.QueryContext(ctx, `SELECT code,username FROM invite_uses ORDER BY used_at`) - if err != nil { - return nil, err - } - defer ur.Close() - for ur.Next() { - var code, name string - if err := ur.Scan(&code, &name); err != nil { - return nil, err - } - if i, ok := idx[code]; ok { - out[i].UsedBy = append(out[i].UsedBy, name) - } - } - if out == nil { - out = []Invite{} - } - return out, ur.Err() -} - -// DeleteInvite revokes a code. Users who already registered with it stay. -func (s *Store) DeleteInvite(ctx context.Context, code string) error { - res, err := s.db.ExecContext(ctx, `DELETE FROM invites WHERE code=?`, NormalizeInviteCode(code)) - if err != nil { - return err - } - if n, _ := res.RowsAffected(); n == 0 { - return ErrNotFound - } - return nil -} - -func consumeInvite(ctx context.Context, tx *sql.Tx, code string, now time.Time) error { - var maxUses, uses int - var exp int64 - err := tx.QueryRowContext(ctx, `SELECT max_uses,uses,expires_at FROM invites WHERE code=?`, code).Scan(&maxUses, &uses, &exp) - if errors.Is(err, sql.ErrNoRows) { - return ErrInviteInvalid - } - if err != nil { - return err - } - if exp > 0 && now.UnixMilli() >= exp { - return ErrInviteExpired - } - // Conditional update: never exceeds max_uses even under concurrency. - res, err := tx.ExecContext(ctx, `UPDATE invites SET uses=uses+1 WHERE code=? AND usesSelbst gehosteter, schlanker KI-Assistent – Go-Backend + Android-App.

Ein einzelnes Go-Binary mit SQLite spricht mit jedem OpenAI-kompatiblen Anbieter, nutzt Tools und streamt Antworten live in die native Android-App. Deine Schlüssel bleiben auf deinem Server.

@@ -89,7 +89,7 @@ curl -H "Authorization: Bearer $BYTECLAW_AUTH_TOKEN" \

ByteClaw · selbst gehostet, Go + Android

-

Screenshots zeigen Demo-Daten. APK v0.3.2 · Android 8+ · signiert.

+

Screenshots zeigen Demo-Daten. APK v0.3.1 · Android 8+ · signiert.