diff --git a/.firebaserc b/.firebaserc index 9316bba..96f7355 100644 --- a/.firebaserc +++ b/.firebaserc @@ -1 +1 @@ -{"projects":{"default":"byteclaw-app-65807"}} +{"projects":{"default":"byteclaw-app"}} 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..243aba5 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. +- Start verweigert ohne `auth_token` (mind. 16 Zeichen); 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. @@ -164,8 +164,6 @@ Adaptive Icon als Vektor (`res/drawable/ic_launcher_{background,foreground,monoc In der App unter Einstellungen Server-URL (`https://…`; im Debug-Build auch `http://10.0.2.2:8787` für den Emulator) und Token eintragen, „Verbindung testen", speichern. -Öffentlicher Server: Die App schlägt standardmäßig `https://byteclaw.jodabreit-ai.de` vor (Registrierung offen). Hinter einem Reverse-Proxy dessen IP/CIDR in `trusted_proxies` eintragen, damit das Login-Rate-Limit die echte Client-IP aus `X-Forwarded-For` nutzt. - ## Nächste Schritte - CI (GitHub Actions) für Go + Android diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 5bef1eb..78c3d76 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 = 2 + versionName = "0.2.0" } 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..9f7346b 100644 --- a/android/app/src/main/java/dev/byteclaw/client/MainActivity.kt +++ b/android/app/src/main/java/dev/byteclaw/client/MainActivity.kt @@ -26,7 +26,6 @@ import dev.byteclaw.client.ui.ChatScreen import dev.byteclaw.client.ui.ChatViewModel import dev.byteclaw.client.ui.SessionsScreen import dev.byteclaw.client.ui.SettingsScreen -import dev.byteclaw.client.ui.LoginScreen class MainActivity : ComponentActivity() { private var voiceCallback: ((String) -> Unit)? = null @@ -62,16 +61,7 @@ class MainActivity : ComponentActivity() { val vm: ChatViewModel = viewModel() val state by vm.state.collectAsStateWithLifecycle() val nav = rememberNavController() - val loggedIn = state.settings.isConfigured - androidx.compose.runtime.LaunchedEffect(loggedIn) { - val target = if (loggedIn) "chat" else "login" - if (nav.currentDestination?.route != target) nav.navigate(target) { popUpTo(0) { inclusive = true } } - } - 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, - onAdminToken = { u, t -> vm.useAdminToken(u, t) }) - } + NavHost(nav, startDestination = if (state.settings.isConfigured) "chat" else "settings") { composable("chat") { androidx.compose.runtime.LaunchedEffect(state.settings) { if (state.settings.isConfigured) vm.loadProviders() } ChatScreen( @@ -93,8 +83,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) }) } } } 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..4ea46a0 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 @@ -17,10 +17,7 @@ import okhttp3.WebSocketListener import java.io.IOException import java.util.concurrent.TimeUnit -open class ApiException(msg: String, val code: Int = 0) : IOException(msg) - -/** Token missing, expired or revoked: the app returns to the login screen. */ -class UnauthorizedException : ApiException("Sitzung abgelaufen – bitte erneut anmelden", 401) +class ApiException(msg: String) : IOException(msg) /** REST + WebSocket client for the ByteClaw Go backend. TLS verification uses system defaults. */ class ByteClawApi(private val settings: ServerSettings) { @@ -36,8 +33,7 @@ class ByteClawApi(private val settings: ServerSettings) { private suspend fun call(req: Request): String = withContext(Dispatchers.IO) { http.newCall(req).execute().use { r: Response -> val body = r.body?.string().orEmpty() - if (r.code == 401) throw UnauthorizedException() - if (!r.isSuccessful) throw ApiException("HTTP ${r.code}: ${body.take(200)}", r.code) + if (!r.isSuccessful) throw ApiException("HTTP ${r.code}: ${body.take(200)}") body } } @@ -46,13 +42,6 @@ class ByteClawApi(private val settings: ServerSettings) { http.newCall(Request.Builder().url(url("/healthz")).build()).execute().use { it.isSuccessful } } - suspend fun me(): AuthUser = json.decodeFromString(call(Request.Builder().url(url("/api/auth/me")).build())) - - /** Revokes the current token on the server. */ - suspend fun logout() { - call(Request.Builder().url(url("/api/auth/logout")).post(ByteArray(0).toRequestBody(null)).build()) - } - suspend fun info(): ServerInfo = json.decodeFromString(call(Request.Builder().url(url("/api/info")).build())) suspend fun sessions(): List = json.decodeFromString(call(Request.Builder().url(url("/api/sessions")).build())) @@ -60,18 +49,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()) } @@ -141,34 +118,6 @@ class ByteClawApi(private val settings: ServerSettings) { } companion object { - 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 = - 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 req = Request.Builder().url((serverUrl.trim().trimEnd('/') + path).toHttpUrl()).post(body).build() - shared.newCall(req).execute().use { r -> - val text = r.body?.string().orEmpty() - if (!r.isSuccessful) throw ApiException(AuthErrors.german(r.code, text), r.code) - authJson.decodeFromString(AuthResponse.serializer(), text) - } - } - - /** 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..c9aebf0 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 @@ -92,59 +92,3 @@ object ProviderPresets { @Serializable 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) - -@Serializable -data class AuthUser(val id: Long = 0, val username: String = "", val admin: Boolean = false) - -@Serializable -data class AuthResponse(val token: String, val user: AuthUser = AuthUser()) - -/** Maps server auth errors to German UI text. */ -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, . _ -)" - code == 400 && body.contains("password") -> "Passwort: mindestens 8 Zeichen" - else -> "Fehler (HTTP $code)" - } - - /** Client-side check before contacting the server; null = ok. */ - fun validate(username: String, password: String): String? { - val u = username.trim() - if (u.length !in 3..32 || !u.all { it.isLetterOrDigit() && it.code < 128 || it in "._-" }) return "Benutzername: 3–32 Zeichen (Buchstaben, Ziffern, . _ -)" - if (password.length < 8) return "Passwort: mindestens 8 Zeichen" - return null - } -} diff --git a/android/app/src/main/java/dev/byteclaw/client/data/SettingsStore.kt b/android/app/src/main/java/dev/byteclaw/client/data/SettingsStore.kt index f48ff42..df170e4 100644 --- a/android/app/src/main/java/dev/byteclaw/client/data/SettingsStore.kt +++ b/android/app/src/main/java/dev/byteclaw/client/data/SettingsStore.kt @@ -5,7 +5,7 @@ import android.content.SharedPreferences import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKey -data class ServerSettings(val serverUrl: String = "", val token: String = "", val username: String = "") { +data class ServerSettings(val serverUrl: String = "", val token: String = "") { val isConfigured get() = serverUrl.isNotBlank() && token.isNotBlank() } @@ -20,21 +20,16 @@ class SettingsStore(context: Context) { ) fun load() = ServerSettings( - serverUrl = prefs.getString(KEY_URL, null)?.takeIf { it.isNotBlank() } ?: DEFAULT_SERVER_URL, + serverUrl = prefs.getString(KEY_URL, "") ?: "", token = prefs.getString(KEY_TOKEN, "") ?: "", - username = prefs.getString(KEY_USER, "") ?: "", ) fun save(s: ServerSettings) { - prefs.edit().putString(KEY_URL, s.serverUrl.trim().trimEnd('/')).putString(KEY_TOKEN, s.token.trim()) - .putString(KEY_USER, s.username.trim()).apply() + prefs.edit().putString(KEY_URL, s.serverUrl.trim().trimEnd('/')).putString(KEY_TOKEN, s.token.trim()).apply() } - companion object { - /** Public ByteClaw server, prefilled on the login screen (user can change it). */ - const val DEFAULT_SERVER_URL = "https://byteclaw.jodabreit-ai.de" - private const val KEY_URL = "server_url" - private const val KEY_TOKEN = "token" - private const val KEY_USER = "username" + private companion object { + const val KEY_URL = "server_url" + const val KEY_TOKEN = "token" } } 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..398323a 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,10 +4,7 @@ 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 import dev.byteclaw.client.data.ProviderInfo import dev.byteclaw.client.data.ProviderPresets import dev.byteclaw.client.data.ServerSettings @@ -42,16 +39,6 @@ data class UiState( val sessionProvider: ActiveProvider? = null, /** null = unknown, true/false = last health check result. */ val online: Boolean? = null, - /** Login screen state. */ - val authBusy: Boolean = false, - 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 @@ -86,81 +73,6 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) { _state.update { it.copy(settings = store.load(), error = null) } } - /** 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 = "") { - 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) } - .onSuccess { r -> - store.save(ServerSettings(serverUrl, r.token, r.user.username)) - _state.update { UiState(settings = store.load()) } - } - .onFailure { e -> _state.update { it.copy(authBusy = false, authError = e.message ?: "Verbindung fehlgeschlagen") } } - } - } - - /** Back-compat: sign in with the server's static admin token. */ - fun useAdminToken(serverUrl: String, token: String) { - val err = ByteClawApi.validateUrl(serverUrl) ?: if (token.trim().length < 16) "Token: mindestens 16 Zeichen" else null - if (err != null) { _state.update { it.copy(authError = err) }; return } - _state.update { it.copy(authBusy = true, authError = null) } - viewModelScope.launch { - val s = ServerSettings(serverUrl, token, "admin") - runCatching { ByteClawApi(s).me() } - .onSuccess { u -> store.save(s.copy(username = u.username)); _state.update { UiState(settings = store.load()) } } - .onFailure { e -> _state.update { it.copy(authBusy = false, authError = if (e is UnauthorizedException) "Token ungültig" else e.message) } } - } - } - - fun logout() { - val s = _state.value.settings - job?.cancel() - viewModelScope.launch { runCatching { ByteClawApi(s).logout() } } - clearSession(null) - } - - /** Drops the token (keeps URL + username for the login form). */ - private fun clearSession(reason: String?) { - val s = _state.value.settings - store.save(s.copy(token = "")) - _state.update { UiState(settings = store.load(), authError = reason) } - } - - /** Central error handler: 401 sends the user back to the login screen. */ - private fun fail(e: Throwable) { - if (e is UnauthorizedException) clearSession(e.message) else _state.update { it.copy(error = e.message) } - } - fun testConnection(s: ServerSettings, onResult: (String) -> Unit) = viewModelScope.launch { val msg = runCatching { val i = ByteClawApi(s).info() @@ -179,7 +91,7 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) { fun refreshSessions() = viewModelScope.launch { runCatching { api().sessions() } .onSuccess { list -> _state.update { it.copy(sessions = list) } } - .onFailure { e -> fail(e) } + .onFailure { e -> _state.update { it.copy(error = e.message) } } } fun openSession(id: String) = viewModelScope.launch { @@ -201,7 +113,7 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) { _state.update { it.copy(sessionProvider = ss.provider.takeIf { p -> p.isNotBlank() }?.let { p -> ActiveProvider(p, ss.model) }) } } } - .onFailure { e -> fail(e) } + .onFailure { e -> _state.update { it.copy(error = e.message) } } } fun deleteSession(id: String) = viewModelScope.launch { @@ -210,7 +122,7 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) { if (_state.value.sessionId == id) newChat() refreshSessions() } - .onFailure { e -> fail(e) } + .onFailure { e -> _state.update { it.copy(error = e.message) } } } fun send(text: String) { @@ -236,7 +148,7 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) { System.currentTimeMillis() - started) } "cancelled" -> markCancelled() - "error" -> if (ev.text == "HTTP 401") clearSession("Sitzung abgelaufen – bitte erneut anmelden") else _state.update { s -> + "error" -> _state.update { s -> s.copy(busy = false, error = ev.text, lines = s.lines.filterNot { it.role == "assistant" && it.text.isEmpty() }) } } @@ -259,7 +171,7 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) { if (t.isEmpty()) return@launch runCatching { api().renameSession(id, t) } .onSuccess { _state.update { s -> s.copy(sessions = s.sessions.map { if (it.id == id) it.copy(title = t) else it }) } } - .onFailure { e -> fail(e) } + .onFailure { e -> _state.update { it.copy(error = e.message) } } } /** Stop button: cancel on the server (partial answer is saved there), fall back to closing the stream. */ @@ -296,15 +208,14 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) { viewModelScope.launch { runCatching { api().setSessionProvider(sid, name, model) } .onSuccess { a -> _state.update { it.copy(sessionProvider = a.takeIf { x -> x.provider.isNotBlank() }) } } - .onFailure { e -> fail(e) } + .onFailure { e -> _state.update { it.copy(error = e.message) } } } } fun loadProviders() = viewModelScope.launch { - runCatching { api().me() }.onSuccess { u -> _state.update { it.copy(isAdmin = u.admin) } } runCatching { api().providers() } .onSuccess { r -> _state.update { it.copy(provider = it.provider.copy(providers = r.providers, active = r.active, status = null)) } } - .onFailure { e -> if (e is UnauthorizedException) fail(e) else _state.update { it.copy(provider = it.provider.copy(status = "Provider laden fehlgeschlagen: ${e.message}")) } } + .onFailure { e -> _state.update { it.copy(provider = it.provider.copy(status = "Provider laden fehlgeschlagen: ${e.message}")) } } } fun loadModels(name: String) = viewModelScope.launch { 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 deleted file mode 100644 index bad7abe..0000000 --- a/android/app/src/main/java/dev/byteclaw/client/ui/LoginScreen.kt +++ /dev/null @@ -1,99 +0,0 @@ -package dev.byteclaw.client.ui - -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.unit.dp -import dev.byteclaw.client.data.ByteClawApi - -/** Shown whenever no valid token is stored: sign in, sign up, or (advanced) use the static admin token. */ -@Composable -fun LoginScreen( - state: UiState, - onAuthenticate: (url: String, user: String, pass: String, register: Boolean, invite: String) -> Unit, - onCheckMode: (url: String) -> Unit = {}, - onAdminToken: (url: String, token: String) -> Unit = { _, _ -> }, - initialRegister: Boolean = false, - initialUser: String? = null, -) { - var register by remember { mutableStateOf(initialRegister) } - var url by remember { mutableStateOf(state.settings.serverUrl) } - var user by remember { mutableStateOf(initialUser ?: state.settings.username) } - var pass by remember { mutableStateOf("") } - 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 - - Scaffold { pad -> - Column( - Modifier.padding(pad).fillMaxSize().verticalScroll(rememberScrollState()).padding(24.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Spacer(Modifier.height(24.dp)) - Text("ByteClaw", style = MaterialTheme.typography.headlineLarge, fontWeight = FontWeight.Bold) - Text(if (register) "Neues Konto erstellen" else "Bei deinem Server anmelden", style = MaterialTheme.typography.bodyLarge) - if (!adminMode) { - SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth()) { - SegmentedButton(!register, { register = false }, SegmentedButtonDefaults.itemShape(0, 2)) { Text("Anmelden") } - SegmentedButton(register, { register = true }, SegmentedButtonDefaults.itemShape(1, 2)) { Text("Registrieren") } - } - } - OutlinedTextField(url, { url = it }, Modifier.fillMaxWidth(), label = { Text("Server-URL") }, - placeholder = { Text("https://claw.example.org") }, singleLine = true, - isError = urlError != null, supportingText = urlError?.let { { Text(it) } }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri)) - if (adminMode) { - OutlinedTextField(token, { token = it }, Modifier.fillMaxWidth(), label = { Text("Admin-Token") }, singleLine = true, - visualTransformation = PasswordVisualTransformation(), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password)) - } else { - OutlinedTextField(user, { user = it }, Modifier.fillMaxWidth(), label = { Text("Benutzername") }, singleLine = true, - supportingText = if (register) { { Text("3–32 Zeichen: Buchstaben, Ziffern, . _ -") } } else null) - OutlinedTextField(pass, { pass = it }, Modifier.fillMaxWidth(), label = { Text("Passwort") }, singleLine = true, - visualTransformation = PasswordVisualTransformation(), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), - supportingText = if (register) { { Text("Mindestens 8 Zeichen") } } else null) - if (register) { - 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 (state.authBusy) CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp) - else Text(when { adminMode -> "Mit Token verbinden"; register -> "Konto erstellen"; else -> "Anmelden" }) - } - TextButton(onClick = { adminMode = !adminMode }) { - Text(if (adminMode) "Zurück zur Anmeldung" else "Stattdessen Admin-Token verwenden") - } - Text("Das Login-Token wird verschlüsselt (Android Keystore) gespeichert. Release-Builds erlauben nur HTTPS.", - style = MaterialTheme.typography.bodySmall) - } - } -} 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..5d52401 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 @@ -270,12 +270,11 @@ fun SettingsScreen( onLoadModels: (String) -> Unit = {}, onSelectProvider: (String, String) -> Unit = { _, _ -> }, initialStatus: String? = null, - onLogout: () -> Unit = {}, - onLoadInvites: () -> Unit = {}, - onCreateInvite: (Int, Int) -> Unit = { _, _ -> }, - onRevokeInvite: (String) -> Unit = {}, ) { + var url by remember { mutableStateOf(state.settings.serverUrl) } + var token by remember { mutableStateOf(state.settings.token) } var status by remember { mutableStateOf(initialStatus) } + val urlError = if (url.isBlank()) null else ByteClawApi.validateUrl(url) LaunchedEffect(state.settings) { if (state.settings.isConfigured) onLoadProviders() } Scaffold(topBar = { TopAppBar(title = { Text("Einstellungen") }, @@ -283,31 +282,33 @@ fun SettingsScreen( }) { pad -> LazyColumn(Modifier.padding(pad).fillMaxSize(), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { item { - Card(Modifier.fillMaxWidth()) { - Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { - Text("Konto", style = MaterialTheme.typography.titleMedium) - Text("Angemeldet als ${state.settings.username.ifBlank { "unbekannt" }}") - Text("Server: ${state.settings.serverUrl}", style = MaterialTheme.typography.bodySmall) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedButton(onClick = { - status = "Teste…"; onTest(state.settings) { status = it } - }) { Text("Verbindung testen") } - Button(onClick = onLogout, colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)) { Text("Abmelden") } - } - } + OutlinedTextField(url, { url = it }, Modifier.fillMaxWidth(), label = { Text("Server-URL") }, + placeholder = { Text("https://claw.example.org") }, singleLine = true, + isError = urlError != null, supportingText = { urlError?.let { Text(it) } }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri)) + } + item { + OutlinedTextField(token, { token = it }, Modifier.fillMaxWidth(), label = { Text("Token") }, singleLine = true, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password)) + } + item { + Text("Token und URL werden verschlüsselt (Android Keystore) gespeichert. Release-Builds erlauben nur HTTPS.", + style = MaterialTheme.typography.bodySmall) + } + item { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(enabled = urlError == null && url.isNotBlank() && token.isNotBlank(), onClick = { + onSave(ServerSettings(url, token)); onBack() + }) { Text("Speichern") } + OutlinedButton(enabled = urlError == null && url.isNotBlank(), onClick = { + status = "Teste…"; onTest(ServerSettings(url.trim().trimEnd('/'), token.trim())) { status = it } + }) { Text("Verbindung testen") } } } status?.let { item { Text(it) } } 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 } ?: "–") + - "\nNur der Server-Admin kann den Provider ändern.", style = MaterialTheme.typography.bodySmall) - } - } + item { ProviderSection(state.provider, onLoadProviders, onLoadModels, onSelectProvider) } } } } @@ -381,7 +382,7 @@ private fun SessionProviderChip(state: UiState, onPick: (String) -> Unit, initia Box { AssistChip( onClick = { open = true }, label = { Text(label, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.labelSmall) }, - enabled = state.settings.isConfigured && !state.busy && state.isAdmin, + enabled = state.settings.isConfigured && !state.busy, modifier = Modifier.height(28.dp).widthIn(max = 230.dp), ) DropdownMenu(expanded = open, onDismissRequest = { open = false }) { diff --git a/android/app/src/test/java/dev/byteclaw/client/AuthTest.kt b/android/app/src/test/java/dev/byteclaw/client/AuthTest.kt deleted file mode 100644 index 521229c..0000000 --- a/android/app/src/test/java/dev/byteclaw/client/AuthTest.kt +++ /dev/null @@ -1,36 +0,0 @@ -package dev.byteclaw.client - -import dev.byteclaw.client.data.AuthErrors -import dev.byteclaw.client.data.AuthResponse -import dev.byteclaw.client.data.ServerSettings -import kotlinx.serialization.json.Json -import org.junit.Assert.* -import org.junit.Test - -class AuthTest { - @Test fun validation() { - assertNotNull(AuthErrors.validate("ab", "longenough")) - assertNotNull(AuthErrors.validate("a".repeat(33), "longenough")) - assertNotNull(AuthErrors.validate("bad name", "longenough")) - assertNotNull(AuthErrors.validate("bob", "short")) - assertNull(AuthErrors.validate("crypto_1.x", "longenough")) - } - - @Test fun germanErrors() { - assertEquals("Benutzername oder Passwort falsch", AuthErrors.german(401, "")) - assertEquals("Benutzername ist bereits vergeben", AuthErrors.german(409, "")) - assertTrue(AuthErrors.german(429, "").startsWith("Zu viele")) - assertTrue(AuthErrors.german(400, "{\"error\":\"password must be\"}").startsWith("Passwort")) - } - - @Test fun parsesAuthResponse() { - val r = Json { ignoreUnknownKeys = true }.decodeFromString(AuthResponse.serializer(), - """{"token":"bc_x","expires_at":"2026-12-01T00:00:00Z","user":{"id":2,"username":"alice"}}""") - assertEquals("bc_x", r.token); assertEquals("alice", r.user.username) - } - - @Test fun configuredNeedsToken() { - assertFalse(ServerSettings("https://x", "", "alice").isConfigured) - assertTrue(ServerSettings("https://x", "bc_x", "alice").isConfigured) - } -} 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..37d3920 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 @@ -10,8 +10,6 @@ import dev.byteclaw.client.ui.ChatLine 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 @@ -37,14 +35,14 @@ class PreviewScreenshots(private val dark: Boolean) { maxPercentDifference = 0.1, ) - private val settings = ServerSettings("https://demo.byteclaw.example", "demo-token-not-real", "crypto") + private val settings = ServerSettings("https://demo.byteclaw.example", "demo-token-not-real") private fun snap(name: String, content: @Composable () -> Unit) = paparazzi.snapshot(name + if (dark) "_dark" else "_light") { ByteClawTheme(dark = dark, content = content) } @Test fun chat() = snap("chat") { ChatScreen( - state = UiState(isAdmin = true, + state = UiState( settings = settings, sessionId = "demo", busy = true, lines = listOf( ChatLine("user", "[DEMO] Wie spät ist es in Berlin, und was steht auf golang.org?"), @@ -69,7 +67,7 @@ class PreviewScreenshots(private val dark: Boolean) { @Test fun chatProvider() = snap("chat_provider") { ChatScreen( - state = UiState(isAdmin = true, + state = UiState( settings = settings, sessionId = "demo", provider = demoProviders, sessionProvider = ActiveProvider("gonkarouter", "MiniMaxAI/MiniMax-M2.7"), lines = listOf( @@ -84,7 +82,7 @@ class PreviewScreenshots(private val dark: Boolean) { @Test fun chatMarkdown() = snap("chat_markdown") { ChatScreen( - state = UiState(isAdmin = true, + state = UiState( settings = settings, sessionId = "demo", online = true, lines = listOf( ChatLine("user", "[DEMO] Wie lese ich in Go eine Datei zeilenweise?"), @@ -97,7 +95,7 @@ class PreviewScreenshots(private val dark: Boolean) { @Test fun sessionsSearch() = snap("sessions_search") { SessionsScreen( - state = UiState(isAdmin = true, + state = UiState( settings = settings, sessions = listOf( Session("1", "[DEMO] Go-Datei zeilenweise lesen", "2026-10-25T03:32:00Z"), @@ -111,7 +109,7 @@ class PreviewScreenshots(private val dark: Boolean) { @Test fun sessions() = snap("sessions") { SessionsScreen( - state = UiState(isAdmin = true, + state = UiState( settings = settings, sessions = listOf( Session("1", "[DEMO] Wie spät ist es in Berlin?", "2026-10-25T03:32:00Z"), @@ -127,14 +125,14 @@ class PreviewScreenshots(private val dark: Boolean) { @Test fun settings() = snap("settings") { SettingsScreen( - state = UiState(isAdmin = true, settings = settings), onSave = {}, onTest = { _, _ -> }, onBack = {}, + state = UiState(settings = settings), onSave = {}, onTest = { _, _ -> }, onBack = {}, initialStatus = "Verbindung OK · Provider: Dahl (dahl.global) · Modell: MiniMaxAI/MiniMax-M2.7 [DEMO]", ) } @Test fun providers() = snap("providers") { SettingsScreen( - state = UiState(isAdmin = true, + state = UiState( settings = settings, provider = ProviderUi( active = ActiveProvider("dahl", "MiniMaxAI/MiniMax-M2.7"), @@ -151,27 +149,4 @@ class PreviewScreenshots(private val dark: Boolean) { onSave = {}, onTest = { _, _ -> }, onBack = {}, ) } - - @Test fun login() = snap("login") { - 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 = {}, - ) - } } 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 deleted file mode 100644 index 86c8705..0000000 Binary files a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_loginRegister[0]_login_register_light.png and /dev/null 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 deleted file mode 100644 index 54b8da2..0000000 Binary files a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_loginRegister[1]_login_register_dark.png and /dev/null differ diff --git a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_login[0]_login_light.png b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_login[0]_login_light.png deleted file mode 100644 index 78976a9..0000000 Binary files a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_login[0]_login_light.png and /dev/null differ diff --git a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_login[1]_login_dark.png b/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_login[1]_login_dark.png deleted file mode 100644 index c8bc193..0000000 Binary files a/android/app/src/test/snapshots/images/dev.byteclaw.client.preview_PreviewScreenshots_login[1]_login_dark.png and /dev/null 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..f50dc3c 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..3e99d6c 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..e93b138 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..d6e7320 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..f94cdfa 100644 --- a/cmd/byteclaw/main.go +++ b/cmd/byteclaw/main.go @@ -81,12 +81,7 @@ func run(cfgPath string, log *slog.Logger) error { p, _, err := pm.ForSession(ctx, sid) return p, err }} - trusted, err := server.ParseCIDRs(cfg.TrustedProxies) - if err != nil { - 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{Agent: ag, Store: st, Token: cfg.AuthToken, 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..7646181 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -2,11 +2,7 @@ # BYTECLAW_AUTH_TOKEN, BYTECLAW_LLM_API_KEY, BYTECLAW_LLM_BASE_URL, BYTECLAW_LLM_MODEL, # 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) -token_ttl_hours: 720 # login token lifetime -trusted_proxies: [] # reverse proxy IPs/CIDRs whose X-Forwarded-For is trusted (empty = loopback only) +auth_token: "" # required, >= 16 chars. Generate: openssl rand -hex 32 allowed_origins: [] # browser origins allowed besides same host; "*" only if you really mean it data_dir: ./data workspace: "" # default: /workspace; file tools cannot leave it diff --git a/firebase.json b/firebase.json index 556bce3..c9d0359 100644 --- a/firebase.json +++ b/firebase.json @@ -1,26 +1,12 @@ { "hosting": { "public": "site", - "ignore": [ - "firebase.json", - "**/.*", - "_headers", - "**/*.apk" - ], + "ignore": ["firebase.json", "**/.*", "_headers"], "headers": [ - { - "source": "**/*.apk", - "headers": [ - { - "key": "Content-Type", - "value": "application/vnd.android.package-archive" - }, - { - "key": "Content-Disposition", - "value": "attachment" - } - ] - } + {"source": "**/*.apk", "headers": [ + {"key": "Content-Type", "value": "application/vnd.android.package-archive"}, + {"key": "Content-Disposition", "value": "attachment"} + ]} ] } -} \ No newline at end of file +} diff --git a/go.mod b/go.mod index 23d4f17..09ca5b2 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/crypto/byteclaw -go 1.26.0 +go 1.25.0 require ( github.com/coder/websocket v1.8.13 @@ -14,9 +14,8 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - golang.org/x/crypto v0.58.0 // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect - golang.org/x/sys v0.49.0 // indirect + golang.org/x/sys v0.34.0 // indirect modernc.org/libc v1.66.3 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go.sum b/go.sum index 80e03e3..bca909b 100644 --- a/go.sum +++ b/go.sum @@ -12,8 +12,6 @@ github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdh github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= -golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= @@ -23,8 +21,6 @@ golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= -golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= diff --git a/internal/config/config.go b/internal/config/config.go index 5323366..3ae6924 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -75,19 +75,10 @@ type Tools struct { } type Config struct { - Listen string `yaml:"listen" json:"listen"` - // AuthToken is an optional static admin token (sees all sessions). Users log in via /api/auth. + Listen string `yaml:"listen" json:"listen"` 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. AllowedOrigins []string `yaml:"allowed_origins" json:"allowed_origins"` - // TrustedProxies (IPs/CIDRs) whose X-Forwarded-For is used for rate limiting. Empty = loopback only. - TrustedProxies []string `yaml:"trusted_proxies" json:"trusted_proxies"` DataDir string `yaml:"data_dir" json:"data_dir"` Workspace string `yaml:"workspace" json:"workspace"` LLM LLM `yaml:"llm" json:"llm"` @@ -162,11 +153,6 @@ func applyEnv(c *Config, get func(string) string) { set("BYTECLAW_LLM_BASE_URL", &c.LLM.BaseURL) set("BYTECLAW_LLM_API_KEY", &c.LLM.APIKey) set("BYTECLAW_LLM_MODEL", &c.LLM.Model) - if v := get("BYTECLAW_ALLOW_REGISTRATION"); v != "" { - 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, ",") { @@ -177,31 +163,11 @@ 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 len(c.AuthToken) < 16 { + errs = append(errs, errors.New("auth_token must be set and at least 16 characters (BYTECLAW_AUTH_TOKEN)")) } if c.LLM.BaseURL == "" || c.LLM.Model == "" { errs = append(errs, errors.New("llm.base_url and llm.model are required")) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index f985f87..7d09438 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -36,21 +36,9 @@ func TestLoadJSON(t *testing.T) { } } -func TestValidateShortToken(t *testing.T) { - t.Setenv("BYTECLAW_AUTH_TOKEN", "short") - if _, err := Load(""); err == nil { - t.Fatal("expected error for short token") - } +func TestValidateRequiresToken(t *testing.T) { t.Setenv("BYTECLAW_AUTH_TOKEN", "") - c, err := Load("") - if err != nil { - t.Fatalf("token is optional now: %v", err) - } - if !c.RegistrationAllowed() { - t.Fatal("registration must default to true") - } - t.Setenv("BYTECLAW_ALLOW_REGISTRATION", "false") - if c, _ = Load(""); c.RegistrationAllowed() { - t.Fatal("env must disable registration") + if _, err := Load(""); err == nil { + t.Fatal("expected error without token") } } diff --git a/internal/server/admin_test.go b/internal/server/admin_test.go deleted file mode 100644 index 544e89a..0000000 --- a/internal/server/admin_test.go +++ /dev/null @@ -1,43 +0,0 @@ -package server - -import ( - "encoding/json" - "testing" -) - -func TestProviderChangesAdminOnly(t *testing.T) { - ts, _ := setupFull(t, echo{}) - ut := registerUser(t, ts.URL, "alice", "correct-horse-1") - uh := bearerHdr(ut) - if r := do(t, "GET", ts.URL+"/api/providers", "", uh); r.StatusCode != 200 { - t.Fatal("users may list providers", r.StatusCode) - } - if r := do(t, "PUT", ts.URL+"/api/providers/active", `{"provider":"dahl"}`, uh); r.StatusCode != 403 { - t.Fatal("user must not change global provider", r.StatusCode) - } - var me map[string]any - json.NewDecoder(do(t, "GET", ts.URL+"/api/auth/me", "", uh).Body).Decode(&me) - if me["admin"] != false { - t.Fatalf("%v", me) - } - // own session: chat ok, provider change forbidden - r := do(t, "POST", ts.URL+"/api/chat", `{"text":"hi"}`, uh) - if r.StatusCode != 200 { - t.Fatal("user chat", r.StatusCode) - } - var cr struct { - SessionID string `json:"session_id"` - } - json.NewDecoder(r.Body).Decode(&cr) - if cr.SessionID != "" { - if r := do(t, "PUT", ts.URL+"/api/sessions/"+cr.SessionID+"/provider", `{"provider":"openai"}`, uh); r.StatusCode != 403 { - t.Fatal("session provider", r.StatusCode) - } - } - if r := do(t, "POST", ts.URL+"/api/chat", `{"text":"hi","provider":"dahl"}`, uh); r.StatusCode != 403 { - t.Fatal("chat with provider", r.StatusCode) - } - if r := do(t, "PUT", ts.URL+"/api/providers/active", `{"provider":"dahl"}`, authH); r.StatusCode != 200 { - t.Fatal("admin allowed", r.StatusCode) - } -} diff --git a/internal/server/auth.go b/internal/server/auth.go deleted file mode 100644 index 6070453..0000000 --- a/internal/server/auth.go +++ /dev/null @@ -1,366 +0,0 @@ -package server - -import ( - "context" - "crypto/subtle" - "errors" - "net" - "net/http" - "strings" - "sync" - "time" - "unicode/utf8" - - "golang.org/x/crypto/bcrypt" - - "github.com/crypto/byteclaw/internal/store" -) - -// Principal is the authenticated caller. Admin (static auth_token) sees all sessions. -type Principal struct { - UserID int64 - Username string - Admin bool - token string -} - -// Scope is the user filter for store queries. -func (p Principal) Scope() int64 { - if p.Admin { - return store.AllUsers - } - return p.UserID -} - -// OwnerID is the user_id new sessions get. -func (p Principal) OwnerID() int64 { - if p.Admin { - return 0 - } - return p.UserID -} - -type ctxKey struct{} - -func principal(ctx context.Context) Principal { - p, _ := ctx.Value(ctxKey{}).(Principal) - return p -} - -const ( - defaultTokenTTL = 30 * 24 * time.Hour - loginWindow = 15 * time.Minute - loginMaxFails = 10 -) - -// dummyHash keeps the timing of "unknown user" close to "wrong password". -var dummyHash, _ = bcrypt.GenerateFromPassword([]byte("byteclaw-dummy-password"), bcrypt.DefaultCost) - -// limiter counts failed attempts per IP in a fixed window. -type limiter struct { - mu sync.Mutex - hits map[string][]time.Time - max int - win time.Duration - now func() time.Time -} - -func newLimiter(max int, win time.Duration) *limiter { - return &limiter{hits: map[string][]time.Time{}, max: max, win: win, now: time.Now} -} - -func (l *limiter) prune(key string) []time.Time { - cut := l.now().Add(-l.win) - h := l.hits[key] - i := 0 - for i < len(h) && h[i].Before(cut) { - i++ - } - h = h[i:] - if len(h) == 0 { - delete(l.hits, key) - } else { - l.hits[key] = h - } - return h -} - -func (l *limiter) Blocked(key string) bool { - l.mu.Lock() - defer l.mu.Unlock() - return len(l.prune(key)) >= l.max -} - -func (l *limiter) Fail(key string) { - l.mu.Lock() - defer l.mu.Unlock() - l.hits[key] = append(l.prune(key), l.now()) -} - -func (l *limiter) Reset(key string) { - l.mu.Lock() - defer l.mu.Unlock() - delete(l.hits, key) -} - -// clientIP returns the peer address. If the peer is a trusted proxy, the -// right-most X-Forwarded-For entry that is not itself a trusted proxy is used. -func (s *Server) clientIP(r *http.Request) string { - h, _, err := net.SplitHostPort(r.RemoteAddr) - if err != nil { - h = r.RemoteAddr - } - if !s.isTrustedProxy(h) { - return h - } - var parts []string - for _, v := range r.Header.Values("X-Forwarded-For") { - for _, p := range strings.Split(v, ",") { - if p = strings.TrimSpace(p); p != "" { - parts = append(parts, p) - } - } - } - for i := len(parts) - 1; i >= 0; i-- { - if net.ParseIP(parts[i]) == nil { - break - } - if !s.isTrustedProxy(parts[i]) { - return parts[i] - } - } - return h -} - -func (s *Server) isTrustedProxy(ip string) bool { - p := net.ParseIP(ip) - if p == nil { - return false - } - if len(s.TrustedProxies) == 0 { - return p.IsLoopback() - } - for _, c := range s.TrustedProxies { - if c.Contains(p) { - return true - } - } - return false -} - -// ParseCIDRs turns IPs or CIDRs into networks (bare IPs become /32 or /128). -func ParseCIDRs(in []string) ([]*net.IPNet, error) { - var out []*net.IPNet - for _, v := range in { - if !strings.Contains(v, "/") { - if ip := net.ParseIP(v); ip != nil && ip.To4() != nil { - v += "/32" - } else { - v += "/128" - } - } - _, n, err := net.ParseCIDR(v) - if err != nil { - return nil, err - } - out = append(out, n) - } - return out, nil -} - -func (s *Server) initAuth() { - s.authOnce.Do(func() { - if s.limiter == nil { - s.limiter = newLimiter(loginMaxFails, loginWindow) - } - if s.TokenTTL <= 0 { - s.TokenTTL = defaultTokenTTL - } - }) -} - -func bearer(r *http.Request) string { - if t, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer "); ok { - return strings.TrimSpace(t) - } - // Browsers cannot set headers on WebSocket upgrades: allow ?token= there only. - if r.URL.Path == "/api/ws" { - return r.URL.Query().Get("token") - } - return "" -} - -// auth accepts the static admin token (constant-time) or a user token. -func (s *Server) auth(h http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - got := bearer(r) - var p Principal - switch { - case got == "": - writeErr(w, http.StatusUnauthorized, "unauthorized") - return - case s.Token != "" && subtle.ConstantTimeCompare([]byte(got), []byte(s.Token)) == 1: - p = Principal{Admin: true, Username: "admin"} - default: - u, err := s.Store.UserByToken(r.Context(), got) - if err != nil { - writeErr(w, http.StatusUnauthorized, "unauthorized") - return - } - p = Principal{UserID: u.ID, Username: u.Username} - } - p.token = got - h.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxKey{}, p))) - }) -} - -type credentials struct { - Username string `json:"username"` - Password string `json:"password"` - InviteCode string `json:"invite_code,omitempty"` -} - -func validUsername(u string) bool { - if n := utf8.RuneCountInString(u); n < 3 || n > 32 { - return false - } - for _, c := range u { - if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_' || c == '-' || c == '.') { - return false - } - } - return true -} - -func validPassword(p string) bool { - // bcrypt only uses the first 72 bytes; reject longer input instead of silently truncating. - return utf8.RuneCountInString(p) >= 8 && len(p) <= 72 -} - -func (s *Server) issue(w http.ResponseWriter, r *http.Request, u store.User, code int) { - tok, exp, err := s.Store.IssueToken(r.Context(), u.ID, s.TokenTTL) - if err != nil { - s.storeErr(w, err) - return - } - writeJSON(w, code, map[string]any{"token": tok, "expires_at": exp, "user": map[string]any{"id": u.ID, "username": u.Username}}) -} - -func (s *Server) register(w http.ResponseWriter, r *http.Request) { - s.initAuth() - mode := s.regMode() - if mode == "closed" { - writeErr(w, http.StatusForbidden, "registration disabled") - return - } - ip := s.clientIP(r) - if s.limiter.Blocked(ip) { - writeErr(w, http.StatusTooManyRequests, "too many attempts, try again later") - return - } - var c credentials - if !decode(w, r, &c) { - return - } - c.Username = strings.TrimSpace(c.Username) - if !validUsername(c.Username) { - writeErr(w, http.StatusBadRequest, "username must be 3-32 characters (letters, digits, . _ -)") - return - } - if !validPassword(c.Password) { - 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 - } - if errors.Is(err, store.ErrUserExists) { - writeErr(w, http.StatusConflict, "username already taken") - return - } - if err != nil { - s.storeErr(w, err) - return - } - s.issue(w, r, u, http.StatusCreated) -} - -func (s *Server) login(w http.ResponseWriter, r *http.Request) { - s.initAuth() - ip := s.clientIP(r) - if s.limiter.Blocked(ip) { - writeErr(w, http.StatusTooManyRequests, "too many attempts, try again later") - return - } - var c credentials - if !decode(w, r, &c) { - return - } - u, err := s.Store.UserByName(r.Context(), strings.TrimSpace(c.Username)) - hash := dummyHash - if err == nil { - hash = []byte(u.PasswordHash()) - } else if !errors.Is(err, store.ErrNotFound) { - s.storeErr(w, err) - return - } - if bcrypt.CompareHashAndPassword(hash, []byte(c.Password)) != nil || err != nil { - s.limiter.Fail(ip) - writeErr(w, http.StatusUnauthorized, "invalid username or password") - return - } - s.limiter.Reset(ip) - s.issue(w, r, u, http.StatusOK) -} - -func (s *Server) logout(w http.ResponseWriter, r *http.Request) { - p := principal(r.Context()) - if !p.Admin { - if err := s.Store.RevokeToken(r.Context(), p.token); err != nil { - s.storeErr(w, err) - return - } - } - w.WriteHeader(http.StatusNoContent) -} - -func (s *Server) me(w http.ResponseWriter, r *http.Request) { - p := principal(r.Context()) - writeJSON(w, 200, map[string]any{"id": p.UserID, "username": p.Username, "admin": p.Admin}) -} - -// owns writes 404 and returns false unless the caller may access session id. -func (s *Server) owns(w http.ResponseWriter, r *http.Request, id string) bool { - if err := s.Store.SessionOwnedBy(r.Context(), principal(r.Context()).Scope(), id); err != nil { - s.storeErr(w, err) - return false - } - 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/auth_test.go b/internal/server/auth_test.go deleted file mode 100644 index adce7bd..0000000 --- a/internal/server/auth_test.go +++ /dev/null @@ -1,200 +0,0 @@ -package server - -import ( - "encoding/json" - "io" - "log/slog" - "net/http" - "net/http/httptest" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/crypto/byteclaw/internal/agent" - "github.com/crypto/byteclaw/internal/store" - "github.com/crypto/byteclaw/internal/tools" -) - -func authSetup(t *testing.T, allowReg bool) (*httptest.Server, *store.Store, *Server) { - st, err := store.Open(filepath.Join(t.TempDir(), "s.db")) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { st.Close() }) - s := &Server{Store: st, Token: tok, AllowRegistration: allowReg, Log: slog.New(slog.NewTextHandler(io.Discard, nil)), RunTimeout: 5 * time.Second, - Agent: &agent.Agent{LLM: echo{}, Store: st, Tools: tools.NewRegistry(), MaxIterations: 3}} - ts := httptest.NewServer(s.Handler()) - t.Cleanup(ts.Close) - return ts, st, s -} - -type authResp struct { - Token string `json:"token"` - User struct { - ID int64 `json:"id"` - Username string `json:"username"` - } `json:"user"` -} - -func creds(u, p string) string { - b, _ := json.Marshal(map[string]string{"username": u, "password": p}) - return string(b) -} - -func registerUser(t *testing.T, base, u, p string) string { - t.Helper() - r := do(t, "POST", base+"/api/auth/register", creds(u, p), nil) - if r.StatusCode != 201 { - b, _ := io.ReadAll(r.Body) - t.Fatalf("register %s: %d %s", u, r.StatusCode, b) - } - var a authResp - json.NewDecoder(r.Body).Decode(&a) - if a.Token == "" || a.User.Username != u { - t.Fatalf("bad register response %+v", a) - } - return a.Token -} - -func bearerHdr(t string) map[string]string { return map[string]string{"Authorization": "Bearer " + t} } - -func TestRegisterLoginMeLogout(t *testing.T) { - ts, _, _ := authSetup(t, true) - t1 := registerUser(t, ts.URL, "alice", "supersecret") - if r := do(t, "POST", ts.URL+"/api/auth/register", creds("ALICE", "supersecret"), nil); r.StatusCode != 409 { - t.Fatalf("duplicate (case-insensitive) must be 409, got %d", r.StatusCode) - } - r := do(t, "GET", ts.URL+"/api/auth/me", "", bearerHdr(t1)) - var me map[string]any - json.NewDecoder(r.Body).Decode(&me) - if r.StatusCode != 200 || me["username"] != "alice" { - t.Fatalf("me: %d %v", r.StatusCode, me) - } - if r := do(t, "POST", ts.URL+"/api/auth/login", creds("alice", "wrongpass"), nil); r.StatusCode != 401 { - t.Fatalf("wrong password: %d", r.StatusCode) - } - if r := do(t, "POST", ts.URL+"/api/auth/login", creds("nobody", "whatever1"), nil); r.StatusCode != 401 { - t.Fatalf("unknown user: %d", r.StatusCode) - } - r = do(t, "POST", ts.URL+"/api/auth/login", creds("alice", "supersecret"), nil) - var a authResp - json.NewDecoder(r.Body).Decode(&a) - if r.StatusCode != 200 || a.Token == "" || a.Token == t1 { - t.Fatalf("login: %d", r.StatusCode) - } - if r := do(t, "POST", ts.URL+"/api/auth/logout", "", bearerHdr(a.Token)); r.StatusCode != 204 { - t.Fatalf("logout: %d", r.StatusCode) - } - if r := do(t, "GET", ts.URL+"/api/auth/me", "", bearerHdr(a.Token)); r.StatusCode != 401 { - t.Fatalf("revoked token must be 401, got %d", r.StatusCode) - } - if r := do(t, "GET", ts.URL+"/api/auth/me", "", bearerHdr(t1)); r.StatusCode != 200 { - t.Fatalf("other token must stay valid, got %d", r.StatusCode) - } -} - -func TestRegisterValidation(t *testing.T) { - ts, _, _ := authSetup(t, true) - for _, c := range []struct{ u, p string }{{"ab", "longenough"}, {strings.Repeat("a", 33), "longenough"}, {"bad name", "longenough"}, {"bob", "short"}} { - if r := do(t, "POST", ts.URL+"/api/auth/register", creds(c.u, c.p), nil); r.StatusCode != 400 { - t.Fatalf("%q/%q: want 400 got %d", c.u, c.p, r.StatusCode) - } - } -} - -func TestRegistrationDisabled(t *testing.T) { - ts, _, _ := authSetup(t, false) - if r := do(t, "POST", ts.URL+"/api/auth/register", creds("alice", "supersecret"), nil); r.StatusCode != 403 { - t.Fatalf("want 403 got %d", r.StatusCode) - } -} - -func TestAuthRequired(t *testing.T) { - ts, _, _ := authSetup(t, true) - for _, p := range []string{"/api/sessions", "/api/info", "/api/auth/me", "/api/providers", "/api/ws"} { - if r := do(t, "GET", ts.URL+p, "", nil); r.StatusCode != 401 { - t.Fatalf("%s without token: %d", p, r.StatusCode) - } - if r := do(t, "GET", ts.URL+p, "", bearerHdr("bc_invalid")); r.StatusCode != 401 { - t.Fatalf("%s bad token: %d", p, r.StatusCode) - } - } - if r := do(t, "GET", ts.URL+"/api/sessions?token="+tok, "", nil); r.StatusCode != 401 { - t.Fatalf("query token must only work for ws, got %d", r.StatusCode) - } - // static admin token still works - if r := do(t, "GET", ts.URL+"/api/sessions", "", bearerHdr(tok)); r.StatusCode != 200 { - t.Fatalf("static token: %d", r.StatusCode) - } -} - -func TestPerUserIsolation(t *testing.T) { - ts, st, _ := authSetup(t, true) - legacy, _ := st.CreateSession(t.Context(), "legacy") - ta := registerUser(t, ts.URL, "alice", "supersecret") - tb := registerUser(t, ts.URL, "bob_2", "supersecret") - r := do(t, "POST", ts.URL+"/api/sessions", `{"title":"bob's"}`, bearerHdr(tb)) - var bs store.Session - json.NewDecoder(r.Body).Decode(&bs) - - list := func(tk string) []store.Session { - var l []store.Session - json.NewDecoder(do(t, "GET", ts.URL+"/api/sessions", "", bearerHdr(tk)).Body).Decode(&l) - return l - } - if l := list(ta); len(l) != 1 || l[0].ID != legacy.ID { - t.Fatalf("first user must adopt legacy session, got %+v", l) - } - if l := list(tb); len(l) != 1 || l[0].ID != bs.ID { - t.Fatalf("bob sees %+v", l) - } - for _, m := range []struct{ method, path string }{{"GET", ""}, {"GET", "/messages"}, {"DELETE", ""}, {"POST", "/cancel"}, {"GET", "/summary"}} { - if r := do(t, m.method, ts.URL+"/api/sessions/"+bs.ID+m.path, "", bearerHdr(ta)); r.StatusCode != 404 { - t.Fatalf("alice %s %s on bob's session: %d", m.method, m.path, r.StatusCode) - } - } - if r := do(t, "PATCH", ts.URL+"/api/sessions/"+bs.ID, `{"title":"x"}`, bearerHdr(ta)); r.StatusCode != 404 { - t.Fatalf("rename foreign: %d", r.StatusCode) - } - if r := do(t, "POST", ts.URL+"/api/chat", `{"session_id":"`+bs.ID+`","text":"hi"}`, bearerHdr(ta)); r.StatusCode != 404 { - t.Fatalf("chat into foreign session: %d", r.StatusCode) - } - if r := do(t, "POST", ts.URL+"/api/chat", `{"text":"hi"}`, bearerHdr(ta)); r.StatusCode != 200 { - t.Fatalf("own chat: %d", r.StatusCode) - } - if l := list(tb); len(l) != 1 { - t.Fatalf("alice's new chat leaked to bob: %+v", l) - } - if l := list(tok); len(l) != 3 { - t.Fatalf("admin must see all sessions, got %d", len(l)) - } -} - -func TestLoginRateLimit(t *testing.T) { - ts, _, s := authSetup(t, true) - registerUser(t, ts.URL, "alice", "supersecret") - s.limiter.Reset("127.0.0.1") - for i := 0; i < loginMaxFails; i++ { - if r := do(t, "POST", ts.URL+"/api/auth/login", creds("alice", "wrong-pass"), nil); r.StatusCode != 401 { - t.Fatalf("attempt %d: %d", i, r.StatusCode) - } - } - if r := do(t, "POST", ts.URL+"/api/auth/login", creds("alice", "supersecret"), nil); r.StatusCode != 429 { - t.Fatalf("want 429 after %d failures, got %d", loginMaxFails, r.StatusCode) - } - // window expiry unblocks - s.limiter.now = func() time.Time { return time.Now().Add(loginWindow + time.Second) } - if r := do(t, "POST", ts.URL+"/api/auth/login", creds("alice", "supersecret"), nil); r.StatusCode != 200 { - t.Fatalf("after window: %d", r.StatusCode) - } -} - -func TestWSQueryToken(t *testing.T) { - ts, _, _ := authSetup(t, true) - tk := registerUser(t, ts.URL, "alice", "supersecret") - r := do(t, "GET", ts.URL+"/api/ws?token="+tk, "", map[string]string{"Connection": "Upgrade", "Upgrade": "websocket", "Sec-WebSocket-Version": "13", "Sec-WebSocket-Key": "dGhlIHNhbXBsZSBub25jZQ=="}) - if r.StatusCode != http.StatusSwitchingProtocols { - t.Fatalf("ws with query token: %d", r.StatusCode) - } -} diff --git a/internal/server/clientip_test.go b/internal/server/clientip_test.go deleted file mode 100644 index ae6379e..0000000 --- a/internal/server/clientip_test.go +++ /dev/null @@ -1,26 +0,0 @@ -package server - -import ( - "net/http/httptest" - "testing" -) - -func TestClientIPTrustedProxy(t *testing.T) { - s := &Server{} - r := httptest.NewRequest("GET", "/", nil) - r.RemoteAddr = "127.0.0.1:1234" - r.Header.Set("X-Forwarded-For", "6.6.6.6, 1.2.3.4") - if got := s.clientIP(r); got != "1.2.3.4" { - t.Fatalf("loopback proxy: got %s", got) - } - r.RemoteAddr = "9.9.9.9:1" - if got := s.clientIP(r); got != "9.9.9.9" { - t.Fatalf("untrusted peer: got %s", got) - } - n, _ := ParseCIDRs([]string{"10.0.1.0/24"}) - s.TrustedProxies = n - r.RemoteAddr = "10.0.1.6:1" - if got := s.clientIP(r); got != "1.2.3.4" { - t.Fatalf("cidr proxy: got %s", got) - } -} diff --git a/internal/server/extra.go b/internal/server/extra.go index 8da25f3..7dcb852 100644 --- a/internal/server/extra.go +++ b/internal/server/extra.go @@ -48,9 +48,6 @@ func (s *Server) listProviders(w http.ResponseWriter, _ *http.Request) { } func (s *Server) setActiveProvider(w http.ResponseWriter, r *http.Request) { - if !requireAdmin(w, r) { - return - } if !s.needProviders(w) { return } @@ -85,9 +82,6 @@ func (s *Server) listModels(w http.ResponseWriter, r *http.Request) { // PUT /api/sessions/{id}/provider: per-session override; empty provider clears it. func (s *Server) setSessionProvider(w http.ResponseWriter, r *http.Request) { - if !requireAdmin(w, r) { - return - } if !s.needProviders(w) { return } @@ -96,7 +90,8 @@ func (s *Server) setSessionProvider(w http.ResponseWriter, r *http.Request) { return } id := r.PathValue("id") - if !s.owns(w, r, id) { + if _, err := s.Store.GetSession(r.Context(), id); err != nil { + s.storeErr(w, err) return } var a providers.Active @@ -115,15 +110,13 @@ func (s *Server) setSessionProvider(w http.ResponseWriter, r *http.Request) { } func (s *Server) cancel(w http.ResponseWriter, r *http.Request) { - if !s.owns(w, r, r.PathValue("id")) { - return - } writeJSON(w, 200, map[string]bool{"cancelled": s.Agent.Cancel(r.PathValue("id"))}) } func (s *Server) summarize(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") - if !s.owns(w, r, id) { + if _, err := s.Store.GetSession(r.Context(), id); err != nil { + s.storeErr(w, err) return } sm, err := s.Agent.SummarizeNow(r.Context(), id) @@ -140,9 +133,6 @@ func (s *Server) summarize(w http.ResponseWriter, r *http.Request) { } func (s *Server) getSummary(w http.ResponseWriter, r *http.Request) { - if !s.owns(w, r, r.PathValue("id")) { - return - } sm, err := s.Store.LatestSummary(r.Context(), r.PathValue("id")) if err != nil { if errors.Is(err, store.ErrNotFound) { @@ -154,15 +144,3 @@ func (s *Server) getSummary(w http.ResponseWriter, r *http.Request) { } writeJSON(w, 200, sm) } - -// errAdminOnly is returned when a non-admin tries to change provider settings. -var errAdminOnly = errors.New("admin only: provider settings can only be changed by the server admin") - -// requireAdmin writes 403 unless the caller is the admin (static auth_token). -func requireAdmin(w http.ResponseWriter, r *http.Request) bool { - if principal(r.Context()).Admin { - return true - } - writeErr(w, http.StatusForbidden, errAdminOnly.Error()) - return false -} diff --git a/internal/server/extra_test.go b/internal/server/extra_test.go index bf9fdc3..37ad78a 100644 --- a/internal/server/extra_test.go +++ b/internal/server/extra_test.go @@ -50,7 +50,7 @@ func setupFull(t *testing.T, p llm.Provider) (*httptest.Server, *store.Store) { pr, _, err := pm.ForSession(ctx, sid) return pr, err } - s := &Server{Store: st, Token: tok, AllowRegistration: true, Log: slog.New(slog.NewTextHandler(io.Discard, nil)), RunTimeout: 5 * time.Second, Agent: ag, Providers: pm} + s := &Server{Store: st, Token: tok, Log: slog.New(slog.NewTextHandler(io.Discard, nil)), RunTimeout: 5 * time.Second, Agent: ag, Providers: pm} ts := httptest.NewServer(s.Handler()) t.Cleanup(ts.Close) return ts, st 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..435b167 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -3,10 +3,10 @@ package server import ( "context" + "crypto/subtle" "encoding/json" "errors" "log/slog" - "net" "net/http" "net/url" "strings" @@ -35,17 +35,6 @@ type Server struct { Info Info // Providers enables runtime provider switching (optional). 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). - TrustedProxies []*net.IPNet - - authOnce sync.Once - limiter *limiter } type Info struct { @@ -58,19 +47,8 @@ type Info struct { func (s *Server) Handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { writeJSON(w, 200, map[string]string{"status": "ok"}) }) - 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) @@ -86,10 +64,8 @@ func (s *Server) Handler() http.Handler { api.HandleFunc("GET /api/sessions/{id}/messages", s.listMessages) api.HandleFunc("POST /api/chat", s.chat) api.HandleFunc("GET /api/ws", s.ws) - mux.Handle("/api/", s.auth(api)) - h := http.Handler(mux) - h = s.originCheck(h) - return securityHeaders(h) + mux.Handle("/api/", s.originCheck(s.auth(api))) + return securityHeaders(mux) } func securityHeaders(h http.Handler) http.Handler { @@ -100,6 +76,18 @@ func securityHeaders(h http.Handler) http.Handler { }) } +// auth requires "Authorization: Bearer " (constant-time compare). +func (s *Server) auth(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ") + if !ok || subtle.ConstantTimeCompare([]byte(got), []byte(s.Token)) != 1 { + writeErr(w, http.StatusUnauthorized, "unauthorized") + return + } + h.ServeHTTP(w, r) + }) +} + // OriginAllowed: requests without Origin (native apps, curl) pass; browser // requests must come from the same host or an explicitly configured origin. func (s *Server) OriginAllowed(r *http.Request) bool { @@ -170,7 +158,7 @@ func (s *Server) storeErr(w http.ResponseWriter, err error) { } func (s *Server) listSessions(w http.ResponseWriter, r *http.Request) { - list, err := s.Store.ListSessionsFor(r.Context(), principal(r.Context()).Scope(), 200) + list, err := s.Store.ListSessions(r.Context(), 200) if err != nil { s.storeErr(w, err) return @@ -185,7 +173,7 @@ func (s *Server) createSession(w http.ResponseWriter, r *http.Request) { if r.ContentLength != 0 && !decode(w, r, &b) { return } - ss, err := s.Store.CreateSessionFor(r.Context(), principal(r.Context()).OwnerID(), b.Title) + ss, err := s.Store.CreateSession(r.Context(), b.Title) if err != nil { s.storeErr(w, err) return @@ -194,9 +182,6 @@ func (s *Server) createSession(w http.ResponseWriter, r *http.Request) { } func (s *Server) getSession(w http.ResponseWriter, r *http.Request) { - if !s.owns(w, r, r.PathValue("id")) { - return - } ss, err := s.Store.GetSession(r.Context(), r.PathValue("id")) if err != nil { s.storeErr(w, err) @@ -212,9 +197,6 @@ func (s *Server) renameSession(w http.ResponseWriter, r *http.Request) { if !decode(w, r, &b) { return } - if !s.owns(w, r, r.PathValue("id")) { - return - } if err := s.Store.RenameSession(r.Context(), r.PathValue("id"), b.Title); err != nil { s.storeErr(w, err) return @@ -223,9 +205,6 @@ func (s *Server) renameSession(w http.ResponseWriter, r *http.Request) { } func (s *Server) deleteSession(w http.ResponseWriter, r *http.Request) { - if !s.owns(w, r, r.PathValue("id")) { - return - } if err := s.Store.DeleteSession(r.Context(), r.PathValue("id")); err != nil { s.storeErr(w, err) return @@ -235,7 +214,8 @@ func (s *Server) deleteSession(w http.ResponseWriter, r *http.Request) { func (s *Server) listMessages(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") - if !s.owns(w, r, id) { + if _, err := s.Store.GetSession(r.Context(), id); err != nil { + s.storeErr(w, err) return } msgs, err := s.Store.Messages(r.Context(), id, 0) @@ -261,9 +241,6 @@ type chatReq struct { func (s *Server) startSession(ctx context.Context, req chatReq) (string, error) { var a providers.Active if req.SessionID == "" && req.Provider != "" && s.Providers != nil { - if !principal(ctx).Admin { - return "", errAdminOnly - } var err error if a, err = s.Providers.Validate(req.Provider, req.Model); err != nil { return "", err @@ -279,13 +256,14 @@ func (s *Server) startSession(ctx context.Context, req chatReq) (string, error) // ensureSession returns an existing session id or creates one titled after the message. func (s *Server) ensureSession(ctx context.Context, id, text string) (string, error) { if id != "" { - return id, s.Store.SessionOwnedBy(ctx, principal(ctx).Scope(), id) + _, err := s.Store.GetSession(ctx, id) + return id, err } title := strings.TrimSpace(strings.ReplaceAll(text, "\n", " ")) if utf8.RuneCountInString(title) > 48 { title = string([]rune(title)[:48]) + "…" } - ss, err := s.Store.CreateSessionFor(ctx, principal(ctx).OwnerID(), title) + ss, err := s.Store.CreateSession(ctx, title) return ss.ID, err } @@ -299,10 +277,6 @@ func (s *Server) chat(w http.ResponseWriter, r *http.Request) { return } id, err := s.startSession(r.Context(), req) - if errors.Is(err, errAdminOnly) { - writeErr(w, http.StatusForbidden, err.Error()) - return - } if err != nil { if req.SessionID == "" && req.Provider != "" { providerErr(w, err) @@ -363,7 +337,7 @@ func (s *Server) ws(w http.ResponseWriter, r *http.Request) { send(agent.Event{Type: "pong"}) continue case "cancel": - if req.SessionID == "" || s.Store.SessionOwnedBy(ctx, principal(ctx).Scope(), req.SessionID) != nil || !s.Agent.Cancel(req.SessionID) { + if req.SessionID == "" || !s.Agent.Cancel(req.SessionID) { send(agent.Event{Type: "error", Text: "nothing to cancel", Session: req.SessionID}) } continue 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 uses?`, hashToken(tok), time.Now().UTC().UnixMilli()).Scan(&u.ID, &u.Username, &c) - if errors.Is(err, sql.ErrNoRows) { - return u, ErrNotFound - } - u.CreatedAt = time.UnixMilli(c).UTC() - return u, err -} - -func (s *Store) RevokeToken(ctx context.Context, tok string) error { - _, err := s.db.ExecContext(ctx, `DELETE FROM auth_tokens WHERE token_hash=?`, hashToken(tok)) - return err -} - -// SessionOwnedBy returns ErrNotFound unless the session exists and belongs to userID (AllUsers = any). -func (s *Store) SessionOwnedBy(ctx context.Context, userID int64, id string) error { - var n int - err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM sessions WHERE id=? AND (?<0 OR user_id=?)`, id, userID, userID).Scan(&n) - if err == nil && n == 0 { - return ErrNotFound - } - return err -} diff --git a/preview/invites_dark.png b/preview/invites_dark.png deleted file mode 100644 index 0afab55..0000000 Binary files a/preview/invites_dark.png and /dev/null differ diff --git a/preview/invites_light.png b/preview/invites_light.png deleted file mode 100644 index 174997e..0000000 Binary files a/preview/invites_light.png and /dev/null differ diff --git a/preview/login_dark.png b/preview/login_dark.png deleted file mode 100644 index c8bc193..0000000 Binary files a/preview/login_dark.png and /dev/null differ diff --git a/preview/login_light.png b/preview/login_light.png deleted file mode 100644 index 78976a9..0000000 Binary files a/preview/login_light.png and /dev/null differ diff --git a/preview/login_register_dark.png b/preview/login_register_dark.png deleted file mode 100644 index 54b8da2..0000000 Binary files a/preview/login_register_dark.png and /dev/null differ diff --git a/preview/login_register_light.png b/preview/login_register_light.png deleted file mode 100644 index 86c8705..0000000 Binary files a/preview/login_register_light.png and /dev/null differ diff --git a/preview/providers_dark.png b/preview/providers_dark.png index b8a72bf..3e99d6c 100644 Binary files a/preview/providers_dark.png and b/preview/providers_dark.png differ diff --git a/preview/providers_light.png b/preview/providers_light.png index 98f70db..f50dc3c 100644 Binary files a/preview/providers_light.png and b/preview/providers_light.png differ diff --git a/preview/settings_dark.png b/preview/settings_dark.png index cce9ae3..d6e7320 100644 Binary files a/preview/settings_dark.png and b/preview/settings_dark.png differ diff --git a/preview/settings_light.png b/preview/settings_light.png index fdaadd9..e93b138 100644 Binary files a/preview/settings_light.png and b/preview/settings_light.png differ diff --git a/site-preview/desktop.png b/site-preview/desktop.png index 30c5fae..98e038a 100644 Binary files a/site-preview/desktop.png and b/site-preview/desktop.png differ diff --git a/site-preview/mobile.png b/site-preview/mobile.png index 10e30fa..3b4d844 100644 Binary files a/site-preview/mobile.png and b/site-preview/mobile.png differ diff --git a/site/_headers b/site/_headers index dd00f81..6a7ba43 100644 --- a/site/_headers +++ b/site/_headers @@ -1,3 +1,3 @@ /downloads/*.apk Content-Type: application/vnd.android.package-archive - Content-Disposition: attachment; filename="byteclaw-0.3.2.apk" + Content-Disposition: attachment; filename="byteclaw-0.2.0.apk" diff --git a/site/assets/chat_dark.png b/site/assets/chat_dark.png index 280082a..5f6be42 100644 Binary files a/site/assets/chat_dark.png and b/site/assets/chat_dark.png differ diff --git a/site/assets/chat_dark.webp b/site/assets/chat_dark.webp index 79ac3a2..010aa2f 100644 Binary files a/site/assets/chat_dark.webp and b/site/assets/chat_dark.webp differ diff --git a/site/assets/chat_markdown_dark.png b/site/assets/chat_markdown_dark.png index bee7d6a..6bcd98b 100644 Binary files a/site/assets/chat_markdown_dark.png and b/site/assets/chat_markdown_dark.png differ diff --git a/site/assets/chat_markdown_light.png b/site/assets/chat_markdown_light.png index 3fdb79c..5bf92c1 100644 Binary files a/site/assets/chat_markdown_light.png and b/site/assets/chat_markdown_light.png differ diff --git a/site/assets/chat_provider_dark.png b/site/assets/chat_provider_dark.png index 21bdf62..1cb605a 100644 Binary files a/site/assets/chat_provider_dark.png and b/site/assets/chat_provider_dark.png differ diff --git a/site/assets/chat_provider_dark.webp b/site/assets/chat_provider_dark.webp index 52b3174..f5472da 100644 Binary files a/site/assets/chat_provider_dark.webp and b/site/assets/chat_provider_dark.webp differ diff --git a/site/assets/login_dark.png b/site/assets/login_dark.png deleted file mode 100644 index 0839452..0000000 Binary files a/site/assets/login_dark.png and /dev/null differ diff --git a/site/assets/login_dark.webp b/site/assets/login_dark.webp deleted file mode 100644 index 776b890..0000000 Binary files a/site/assets/login_dark.webp and /dev/null differ diff --git a/site/assets/login_light.png b/site/assets/login_light.png deleted file mode 100644 index 18ab6cc..0000000 Binary files a/site/assets/login_light.png and /dev/null differ diff --git a/site/assets/login_light.webp b/site/assets/login_light.webp deleted file mode 100644 index 54b4253..0000000 Binary files a/site/assets/login_light.webp and /dev/null differ diff --git a/site/assets/providers_dark.png b/site/assets/providers_dark.png index 2068c73..289189f 100644 Binary files a/site/assets/providers_dark.png and b/site/assets/providers_dark.png differ diff --git a/site/assets/providers_dark.webp b/site/assets/providers_dark.webp index f85b677..f987e61 100644 Binary files a/site/assets/providers_dark.webp and b/site/assets/providers_dark.webp differ diff --git a/site/assets/providers_light.png b/site/assets/providers_light.png index 3f751ef..24b4e58 100644 Binary files a/site/assets/providers_light.png and b/site/assets/providers_light.png differ diff --git a/site/assets/providers_light.webp b/site/assets/providers_light.webp index 15b3a34..9df04e3 100644 Binary files a/site/assets/providers_light.webp and b/site/assets/providers_light.webp differ diff --git a/site/assets/sessions_search_dark.png b/site/assets/sessions_search_dark.png index 634fc9b..6e353c4 100644 Binary files a/site/assets/sessions_search_dark.png and b/site/assets/sessions_search_dark.png differ diff --git a/site/assets/settings_dark.png b/site/assets/settings_dark.png index b2cc692..17be56e 100644 Binary files a/site/assets/settings_dark.png and b/site/assets/settings_dark.png differ diff --git a/site/assets/settings_dark.webp b/site/assets/settings_dark.webp index 0d60af9..fa732da 100644 Binary files a/site/assets/settings_dark.webp and b/site/assets/settings_dark.webp differ diff --git a/site/assets/settings_light.png b/site/assets/settings_light.png index 890ffa6..1e0d74b 100644 Binary files a/site/assets/settings_light.png and b/site/assets/settings_light.png differ diff --git a/site/assets/settings_light.webp b/site/assets/settings_light.webp index 6335698..00e750e 100644 Binary files a/site/assets/settings_light.webp and b/site/assets/settings_light.webp differ diff --git a/site/index.html b/site/index.html index 48cf64d..c4f2595 100644 --- a/site/index.html +++ b/site/index.html @@ -9,7 +9,7 @@ - + @@ -32,7 +32,7 @@

Selbst 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.

@@ -51,8 +51,7 @@

Abbrechen

Stopp-Button in der App: Der Abbruch reicht bis zum Anbieter, die Teilantwort bleibt gespeichert.

Session-Zusammenfassung

Lange Chats werden automatisch per LLM zusammengefasst – die Originalnachrichten bleiben erhalten.

Anbieter pro Session

Global umschalten oder für einzelne Chats ein anderes Modell wählen – direkt aus der App.

-

Konten & Login

Registrierung und Anmeldung in der App – jeder Nutzer hat eigene, getrennte Chats. Passwörter als bcrypt-Hash, Tokens widerrufbar.

-

Sichere Defaults

Login-Pflicht, Rate-Limit, nur localhost, Origin-Prüfung, TLS immer an, Keys nie in der App, Release nur HTTPS.

+

Sichere Defaults

Token-Pflicht, nur localhost, Origin-Prüfung, TLS immer an, Keys nie in der App, Release nur HTTPS.

ARM & Termux

Statische Builds für Linux amd64, arm64 und armv7 sowie Termux auf Android.

@@ -65,7 +64,7 @@
Chat mit Anbieter-Auswahl pro Session im Header, dunkles Design
Anbieter pro Session
Einstellungen mit Anbieter- und Modellauswahl, dunkles Design
Anbieter & Modell
Sessions durchsuchen, dunkles Design
Sessions suchen & umbenennen
-
Anmelde- und Registrierungsbildschirm, helles Design
Anmelden & Registrieren
+
Einstellungen mit Server-URL und Token, helles Design
Verbindung
Chat, dunkles Design
Dunkles Design
@@ -89,7 +88,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.2.0 · Android 8+ · signiert.