Compare commits
No commits in common. "cf5e0b1ca79f71b95a939c949498349847151bf0" and "a88d5b65e5c7fbe294a547403483b76b761ac940" have entirely different histories.
cf5e0b1ca7
...
a88d5b65e5
|
|
@ -1 +1 @@
|
||||||
{"projects":{"default":"byteclaw-app-65807"}}
|
{"projects":{"default":"byteclaw-app"}}
|
||||||
|
|
|
||||||
4
.gitignore
vendored
|
|
@ -4,7 +4,3 @@
|
||||||
*.db
|
*.db
|
||||||
config.yaml
|
config.yaml
|
||||||
.env
|
.env
|
||||||
|
|
||||||
.kotlin/
|
|
||||||
.netlify/
|
|
||||||
.firebase/
|
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ Abhängigkeiten des Backends: `gopkg.in/yaml.v3`, `modernc.org/sqlite`, `github.
|
||||||
|
|
||||||
## Sichere Defaults
|
## 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`.
|
- 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.
|
- 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.
|
- 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.
|
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
|
## Nächste Schritte
|
||||||
|
|
||||||
- CI (GitHub Actions) für Go + Android
|
- CI (GitHub Actions) für Go + Android
|
||||||
|
|
|
||||||
|
|
@ -31,8 +31,8 @@ android {
|
||||||
applicationId = "dev.byteclaw.client"
|
applicationId = "dev.byteclaw.client"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 5
|
versionCode = 2
|
||||||
versionName = "0.3.2"
|
versionName = "0.2.0"
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,6 @@ import dev.byteclaw.client.ui.ChatScreen
|
||||||
import dev.byteclaw.client.ui.ChatViewModel
|
import dev.byteclaw.client.ui.ChatViewModel
|
||||||
import dev.byteclaw.client.ui.SessionsScreen
|
import dev.byteclaw.client.ui.SessionsScreen
|
||||||
import dev.byteclaw.client.ui.SettingsScreen
|
import dev.byteclaw.client.ui.SettingsScreen
|
||||||
import dev.byteclaw.client.ui.LoginScreen
|
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
private var voiceCallback: ((String) -> Unit)? = null
|
private var voiceCallback: ((String) -> Unit)? = null
|
||||||
|
|
@ -62,16 +61,7 @@ class MainActivity : ComponentActivity() {
|
||||||
val vm: ChatViewModel = viewModel()
|
val vm: ChatViewModel = viewModel()
|
||||||
val state by vm.state.collectAsStateWithLifecycle()
|
val state by vm.state.collectAsStateWithLifecycle()
|
||||||
val nav = rememberNavController()
|
val nav = rememberNavController()
|
||||||
val loggedIn = state.settings.isConfigured
|
NavHost(nav, startDestination = if (state.settings.isConfigured) "chat" else "settings") {
|
||||||
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) })
|
|
||||||
}
|
|
||||||
composable("chat") {
|
composable("chat") {
|
||||||
androidx.compose.runtime.LaunchedEffect(state.settings) { if (state.settings.isConfigured) vm.loadProviders() }
|
androidx.compose.runtime.LaunchedEffect(state.settings) { if (state.settings.isConfigured) vm.loadProviders() }
|
||||||
ChatScreen(
|
ChatScreen(
|
||||||
|
|
@ -93,8 +83,7 @@ class MainActivity : ComponentActivity() {
|
||||||
SettingsScreen(state, onSave = vm::saveSettings, onTest = { s, cb -> vm.testConnection(s, cb) }, onBack = {
|
SettingsScreen(state, onSave = vm::saveSettings, onTest = { s, cb -> vm.testConnection(s, cb) }, onBack = {
|
||||||
if (!nav.popBackStack()) nav.navigate("chat") { popUpTo("settings") { inclusive = true } }
|
if (!nav.popBackStack()) nav.navigate("chat") { popUpTo("settings") { inclusive = true } }
|
||||||
}, onLoadProviders = { vm.loadProviders() }, onLoadModels = { vm.loadModels(it) },
|
}, onLoadProviders = { vm.loadProviders() }, onLoadModels = { vm.loadModels(it) },
|
||||||
onSelectProvider = { p, m -> vm.selectProvider(p, m) }, onLogout = vm::logout,
|
onSelectProvider = { p, m -> vm.selectProvider(p, m) })
|
||||||
onLoadInvites = { vm.loadInvites() }, onCreateInvite = { n, h -> vm.createInvite(n, h) }, onRevokeInvite = { vm.revokeInvite(it) })
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,10 +17,7 @@ import okhttp3.WebSocketListener
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
open class ApiException(msg: String, val code: Int = 0) : IOException(msg)
|
class ApiException(msg: String) : IOException(msg)
|
||||||
|
|
||||||
/** Token missing, expired or revoked: the app returns to the login screen. */
|
|
||||||
class UnauthorizedException : ApiException("Sitzung abgelaufen – bitte erneut anmelden", 401)
|
|
||||||
|
|
||||||
/** REST + WebSocket client for the ByteClaw Go backend. TLS verification uses system defaults. */
|
/** REST + WebSocket client for the ByteClaw Go backend. TLS verification uses system defaults. */
|
||||||
class ByteClawApi(private val settings: ServerSettings) {
|
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) {
|
private suspend fun call(req: Request): String = withContext(Dispatchers.IO) {
|
||||||
http.newCall(req).execute().use { r: Response ->
|
http.newCall(req).execute().use { r: Response ->
|
||||||
val body = r.body?.string().orEmpty()
|
val body = r.body?.string().orEmpty()
|
||||||
if (r.code == 401) throw UnauthorizedException()
|
if (!r.isSuccessful) throw ApiException("HTTP ${r.code}: ${body.take(200)}")
|
||||||
if (!r.isSuccessful) throw ApiException("HTTP ${r.code}: ${body.take(200)}", r.code)
|
|
||||||
body
|
body
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -46,13 +42,6 @@ class ByteClawApi(private val settings: ServerSettings) {
|
||||||
http.newCall(Request.Builder().url(url("/healthz")).build()).execute().use { it.isSuccessful }
|
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 info(): ServerInfo = json.decodeFromString(call(Request.Builder().url(url("/api/info")).build()))
|
||||||
|
|
||||||
suspend fun sessions(): List<Session> = json.decodeFromString(call(Request.Builder().url(url("/api/sessions")).build()))
|
suspend fun sessions(): List<Session> = 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<Message> =
|
suspend fun messages(sessionId: String): List<Message> =
|
||||||
json.decodeFromString(call(Request.Builder().url(url("/api/sessions/$sessionId/messages")).build()))
|
json.decodeFromString(call(Request.Builder().url(url("/api/sessions/$sessionId/messages")).build()))
|
||||||
|
|
||||||
suspend fun invites(): List<Invite> =
|
|
||||||
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) {
|
suspend fun deleteSession(sessionId: String) {
|
||||||
call(Request.Builder().url(url("/api/sessions/$sessionId")).delete().build())
|
call(Request.Builder().url(url("/api/sessions/$sessionId")).delete().build())
|
||||||
}
|
}
|
||||||
|
|
@ -141,34 +118,6 @@ class ByteClawApi(private val settings: ServerSettings) {
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
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 JSON = "application/json".toMediaType()
|
||||||
private val shared = OkHttpClient.Builder()
|
private val shared = OkHttpClient.Builder()
|
||||||
.connectTimeout(15, TimeUnit.SECONDS)
|
.connectTimeout(15, TimeUnit.SECONDS)
|
||||||
|
|
|
||||||
|
|
@ -92,59 +92,3 @@ object ProviderPresets {
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class RenameRequest(val title: String)
|
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<String> = 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import android.content.SharedPreferences
|
||||||
import androidx.security.crypto.EncryptedSharedPreferences
|
import androidx.security.crypto.EncryptedSharedPreferences
|
||||||
import androidx.security.crypto.MasterKey
|
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()
|
val isConfigured get() = serverUrl.isNotBlank() && token.isNotBlank()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -20,21 +20,16 @@ class SettingsStore(context: Context) {
|
||||||
)
|
)
|
||||||
|
|
||||||
fun load() = ServerSettings(
|
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, "") ?: "",
|
token = prefs.getString(KEY_TOKEN, "") ?: "",
|
||||||
username = prefs.getString(KEY_USER, "") ?: "",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
fun save(s: ServerSettings) {
|
fun save(s: ServerSettings) {
|
||||||
prefs.edit().putString(KEY_URL, s.serverUrl.trim().trimEnd('/')).putString(KEY_TOKEN, s.token.trim())
|
prefs.edit().putString(KEY_URL, s.serverUrl.trim().trimEnd('/')).putString(KEY_TOKEN, s.token.trim()).apply()
|
||||||
.putString(KEY_USER, s.username.trim()).apply()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
private companion object {
|
||||||
/** Public ByteClaw server, prefilled on the login screen (user can change it). */
|
const val KEY_URL = "server_url"
|
||||||
const val DEFAULT_SERVER_URL = "https://byteclaw.jodabreit-ai.de"
|
const val KEY_TOKEN = "token"
|
||||||
private const val KEY_URL = "server_url"
|
|
||||||
private const val KEY_TOKEN = "token"
|
|
||||||
private const val KEY_USER = "username"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,7 @@ import android.app.Application
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dev.byteclaw.client.data.ActiveProvider
|
import dev.byteclaw.client.data.ActiveProvider
|
||||||
import dev.byteclaw.client.data.Invite
|
|
||||||
import dev.byteclaw.client.data.ByteClawApi
|
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.ProviderInfo
|
||||||
import dev.byteclaw.client.data.ProviderPresets
|
import dev.byteclaw.client.data.ProviderPresets
|
||||||
import dev.byteclaw.client.data.ServerSettings
|
import dev.byteclaw.client.data.ServerSettings
|
||||||
|
|
@ -42,16 +39,6 @@ data class UiState(
|
||||||
val sessionProvider: ActiveProvider? = null,
|
val sessionProvider: ActiveProvider? = null,
|
||||||
/** null = unknown, true/false = last health check result. */
|
/** null = unknown, true/false = last health check result. */
|
||||||
val online: Boolean? = null,
|
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<Invite> = emptyList(),
|
|
||||||
val inviteStatus: String? = null,
|
|
||||||
) {
|
) {
|
||||||
/** What the current chat effectively uses: session override, else the global active provider. */
|
/** What the current chat effectively uses: session override, else the global active provider. */
|
||||||
val effectiveProvider: ActiveProvider? get() = sessionProvider ?: provider.active
|
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) }
|
_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 {
|
fun testConnection(s: ServerSettings, onResult: (String) -> Unit) = viewModelScope.launch {
|
||||||
val msg = runCatching {
|
val msg = runCatching {
|
||||||
val i = ByteClawApi(s).info()
|
val i = ByteClawApi(s).info()
|
||||||
|
|
@ -179,7 +91,7 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
fun refreshSessions() = viewModelScope.launch {
|
fun refreshSessions() = viewModelScope.launch {
|
||||||
runCatching { api().sessions() }
|
runCatching { api().sessions() }
|
||||||
.onSuccess { list -> _state.update { it.copy(sessions = list) } }
|
.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 {
|
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) }) }
|
_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 {
|
fun deleteSession(id: String) = viewModelScope.launch {
|
||||||
|
|
@ -210,7 +122,7 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
if (_state.value.sessionId == id) newChat()
|
if (_state.value.sessionId == id) newChat()
|
||||||
refreshSessions()
|
refreshSessions()
|
||||||
}
|
}
|
||||||
.onFailure { e -> fail(e) }
|
.onFailure { e -> _state.update { it.copy(error = e.message) } }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun send(text: String) {
|
fun send(text: String) {
|
||||||
|
|
@ -236,7 +148,7 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
System.currentTimeMillis() - started)
|
System.currentTimeMillis() - started)
|
||||||
}
|
}
|
||||||
"cancelled" -> markCancelled()
|
"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() })
|
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
|
if (t.isEmpty()) return@launch
|
||||||
runCatching { api().renameSession(id, t) }
|
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 }) } }
|
.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. */
|
/** 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 {
|
viewModelScope.launch {
|
||||||
runCatching { api().setSessionProvider(sid, name, model) }
|
runCatching { api().setSessionProvider(sid, name, model) }
|
||||||
.onSuccess { a -> _state.update { it.copy(sessionProvider = a.takeIf { x -> x.provider.isNotBlank() }) } }
|
.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 {
|
fun loadProviders() = viewModelScope.launch {
|
||||||
runCatching { api().me() }.onSuccess { u -> _state.update { it.copy(isAdmin = u.admin) } }
|
|
||||||
runCatching { api().providers() }
|
runCatching { api().providers() }
|
||||||
.onSuccess { r -> _state.update { it.copy(provider = it.provider.copy(providers = r.providers, active = r.active, status = null)) } }
|
.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 {
|
fun loadModels(name: String) = viewModelScope.launch {
|
||||||
|
|
|
||||||
|
|
@ -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<String?>(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") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -270,12 +270,11 @@ fun SettingsScreen(
|
||||||
onLoadModels: (String) -> Unit = {},
|
onLoadModels: (String) -> Unit = {},
|
||||||
onSelectProvider: (String, String) -> Unit = { _, _ -> },
|
onSelectProvider: (String, String) -> Unit = { _, _ -> },
|
||||||
initialStatus: String? = null,
|
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) }
|
var status by remember { mutableStateOf(initialStatus) }
|
||||||
|
val urlError = if (url.isBlank()) null else ByteClawApi.validateUrl(url)
|
||||||
LaunchedEffect(state.settings) { if (state.settings.isConfigured) onLoadProviders() }
|
LaunchedEffect(state.settings) { if (state.settings.isConfigured) onLoadProviders() }
|
||||||
Scaffold(topBar = {
|
Scaffold(topBar = {
|
||||||
TopAppBar(title = { Text("Einstellungen") },
|
TopAppBar(title = { Text("Einstellungen") },
|
||||||
|
|
@ -283,31 +282,33 @@ fun SettingsScreen(
|
||||||
}) { pad ->
|
}) { pad ->
|
||||||
LazyColumn(Modifier.padding(pad).fillMaxSize(), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
LazyColumn(Modifier.padding(pad).fillMaxSize(), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
item {
|
item {
|
||||||
Card(Modifier.fillMaxWidth()) {
|
OutlinedTextField(url, { url = it }, Modifier.fillMaxWidth(), label = { Text("Server-URL") },
|
||||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
placeholder = { Text("https://claw.example.org") }, singleLine = true,
|
||||||
Text("Konto", style = MaterialTheme.typography.titleMedium)
|
isError = urlError != null, supportingText = { urlError?.let { Text(it) } },
|
||||||
Text("Angemeldet als ${state.settings.username.ifBlank { "unbekannt" }}")
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri))
|
||||||
Text("Server: ${state.settings.serverUrl}", style = MaterialTheme.typography.bodySmall)
|
}
|
||||||
|
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)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
OutlinedButton(onClick = {
|
Button(enabled = urlError == null && url.isNotBlank() && token.isNotBlank(), onClick = {
|
||||||
status = "Teste…"; onTest(state.settings) { status = it }
|
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") }
|
}) { Text("Verbindung testen") }
|
||||||
Button(onClick = onLogout, colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)) { Text("Abmelden") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
status?.let { item { Text(it) } }
|
status?.let { item { Text(it) } }
|
||||||
if (state.settings.isConfigured) {
|
if (state.settings.isConfigured) {
|
||||||
if (state.isAdmin) {
|
|
||||||
item { ProviderSection(state.provider, onLoadProviders, onLoadModels, onSelectProvider) }
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -381,7 +382,7 @@ private fun SessionProviderChip(state: UiState, onPick: (String) -> Unit, initia
|
||||||
Box {
|
Box {
|
||||||
AssistChip(
|
AssistChip(
|
||||||
onClick = { open = true }, label = { Text(label, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.labelSmall) },
|
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),
|
modifier = Modifier.height(28.dp).widthIn(max = 230.dp),
|
||||||
)
|
)
|
||||||
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
|
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -10,8 +10,6 @@ import dev.byteclaw.client.ui.ChatLine
|
||||||
import dev.byteclaw.client.ui.ChatScreen
|
import dev.byteclaw.client.ui.ChatScreen
|
||||||
import dev.byteclaw.client.ui.SessionsScreen
|
import dev.byteclaw.client.ui.SessionsScreen
|
||||||
import dev.byteclaw.client.ui.SettingsScreen
|
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.UiState
|
||||||
import dev.byteclaw.client.ui.ProviderUi
|
import dev.byteclaw.client.ui.ProviderUi
|
||||||
import dev.byteclaw.client.data.ProviderInfo
|
import dev.byteclaw.client.data.ProviderInfo
|
||||||
|
|
@ -37,14 +35,14 @@ class PreviewScreenshots(private val dark: Boolean) {
|
||||||
maxPercentDifference = 0.1,
|
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) =
|
private fun snap(name: String, content: @Composable () -> Unit) =
|
||||||
paparazzi.snapshot(name + if (dark) "_dark" else "_light") { ByteClawTheme(dark = dark, content = content) }
|
paparazzi.snapshot(name + if (dark) "_dark" else "_light") { ByteClawTheme(dark = dark, content = content) }
|
||||||
|
|
||||||
@Test fun chat() = snap("chat") {
|
@Test fun chat() = snap("chat") {
|
||||||
ChatScreen(
|
ChatScreen(
|
||||||
state = UiState(isAdmin = true,
|
state = UiState(
|
||||||
settings = settings, sessionId = "demo", busy = true,
|
settings = settings, sessionId = "demo", busy = true,
|
||||||
lines = listOf(
|
lines = listOf(
|
||||||
ChatLine("user", "[DEMO] Wie spät ist es in Berlin, und was steht auf golang.org?"),
|
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") {
|
@Test fun chatProvider() = snap("chat_provider") {
|
||||||
ChatScreen(
|
ChatScreen(
|
||||||
state = UiState(isAdmin = true,
|
state = UiState(
|
||||||
settings = settings, sessionId = "demo", provider = demoProviders,
|
settings = settings, sessionId = "demo", provider = demoProviders,
|
||||||
sessionProvider = ActiveProvider("gonkarouter", "MiniMaxAI/MiniMax-M2.7"),
|
sessionProvider = ActiveProvider("gonkarouter", "MiniMaxAI/MiniMax-M2.7"),
|
||||||
lines = listOf(
|
lines = listOf(
|
||||||
|
|
@ -84,7 +82,7 @@ class PreviewScreenshots(private val dark: Boolean) {
|
||||||
|
|
||||||
@Test fun chatMarkdown() = snap("chat_markdown") {
|
@Test fun chatMarkdown() = snap("chat_markdown") {
|
||||||
ChatScreen(
|
ChatScreen(
|
||||||
state = UiState(isAdmin = true,
|
state = UiState(
|
||||||
settings = settings, sessionId = "demo", online = true,
|
settings = settings, sessionId = "demo", online = true,
|
||||||
lines = listOf(
|
lines = listOf(
|
||||||
ChatLine("user", "[DEMO] Wie lese ich in Go eine Datei zeilenweise?"),
|
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") {
|
@Test fun sessionsSearch() = snap("sessions_search") {
|
||||||
SessionsScreen(
|
SessionsScreen(
|
||||||
state = UiState(isAdmin = true,
|
state = UiState(
|
||||||
settings = settings,
|
settings = settings,
|
||||||
sessions = listOf(
|
sessions = listOf(
|
||||||
Session("1", "[DEMO] Go-Datei zeilenweise lesen", "2026-10-25T03:32:00Z"),
|
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") {
|
@Test fun sessions() = snap("sessions") {
|
||||||
SessionsScreen(
|
SessionsScreen(
|
||||||
state = UiState(isAdmin = true,
|
state = UiState(
|
||||||
settings = settings,
|
settings = settings,
|
||||||
sessions = listOf(
|
sessions = listOf(
|
||||||
Session("1", "[DEMO] Wie spät ist es in Berlin?", "2026-10-25T03:32:00Z"),
|
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") {
|
@Test fun settings() = snap("settings") {
|
||||||
SettingsScreen(
|
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]",
|
initialStatus = "Verbindung OK · Provider: Dahl (dahl.global) · Modell: MiniMaxAI/MiniMax-M2.7 [DEMO]",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun providers() = snap("providers") {
|
@Test fun providers() = snap("providers") {
|
||||||
SettingsScreen(
|
SettingsScreen(
|
||||||
state = UiState(isAdmin = true,
|
state = UiState(
|
||||||
settings = settings,
|
settings = settings,
|
||||||
provider = ProviderUi(
|
provider = ProviderUi(
|
||||||
active = ActiveProvider("dahl", "MiniMaxAI/MiniMax-M2.7"),
|
active = ActiveProvider("dahl", "MiniMaxAI/MiniMax-M2.7"),
|
||||||
|
|
@ -151,27 +149,4 @@ class PreviewScreenshots(private val dark: Boolean) {
|
||||||
onSave = {}, onTest = { _, _ -> }, onBack = {},
|
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 = {},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 54 KiB |
|
|
@ -81,12 +81,7 @@ func run(cfgPath string, log *slog.Logger) error {
|
||||||
p, _, err := pm.ForSession(ctx, sid)
|
p, _, err := pm.ForSession(ctx, sid)
|
||||||
return p, err
|
return p, err
|
||||||
}}
|
}}
|
||||||
trusted, err := server.ParseCIDRs(cfg.TrustedProxies)
|
srv := &server.Server{Agent: ag, Store: st, Token: cfg.AuthToken, AllowedOrigins: cfg.AllowedOrigins, Log: log, Providers: pm,
|
||||||
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,
|
|
||||||
Info: server.Info{Version: version, Provider: cfg.LLM.Provider, Model: cfg.LLM.Model, BaseURL: cfg.LLM.BaseURL},
|
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}
|
RunTimeout: time.Duration(cfg.LLM.TimeoutSec*cfg.Agent.MaxIterations) * time.Second}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,7 @@
|
||||||
# BYTECLAW_AUTH_TOKEN, BYTECLAW_LLM_API_KEY, BYTECLAW_LLM_BASE_URL, BYTECLAW_LLM_MODEL,
|
# 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)
|
# 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
|
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
|
auth_token: "" # required, >= 16 chars. 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)
|
|
||||||
allowed_origins: [] # browser origins allowed besides same host; "*" only if you really mean it
|
allowed_origins: [] # browser origins allowed besides same host; "*" only if you really mean it
|
||||||
data_dir: ./data
|
data_dir: ./data
|
||||||
workspace: "" # default: <data_dir>/workspace; file tools cannot leave it
|
workspace: "" # default: <data_dir>/workspace; file tools cannot leave it
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,12 @@
|
||||||
{
|
{
|
||||||
"hosting": {
|
"hosting": {
|
||||||
"public": "site",
|
"public": "site",
|
||||||
"ignore": [
|
"ignore": ["firebase.json", "**/.*", "_headers"],
|
||||||
"firebase.json",
|
|
||||||
"**/.*",
|
|
||||||
"_headers",
|
|
||||||
"**/*.apk"
|
|
||||||
],
|
|
||||||
"headers": [
|
"headers": [
|
||||||
{
|
{"source": "**/*.apk", "headers": [
|
||||||
"source": "**/*.apk",
|
{"key": "Content-Type", "value": "application/vnd.android.package-archive"},
|
||||||
"headers": [
|
{"key": "Content-Disposition", "value": "attachment"}
|
||||||
{
|
]}
|
||||||
"key": "Content-Type",
|
|
||||||
"value": "application/vnd.android.package-archive"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "Content-Disposition",
|
|
||||||
"value": "attachment"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
5
go.mod
|
|
@ -1,6 +1,6 @@
|
||||||
module github.com/crypto/byteclaw
|
module github.com/crypto/byteclaw
|
||||||
|
|
||||||
go 1.26.0
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/coder/websocket v1.8.13
|
github.com/coder/websocket v1.8.13
|
||||||
|
|
@ -14,9 +14,8 @@ require (
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // 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/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/libc v1.66.3 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
|
|
||||||
4
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/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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
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 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/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=
|
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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
|
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.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 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
||||||
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
|
|
||||||
|
|
@ -76,18 +76,9 @@ type Tools struct {
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Listen string `yaml:"listen" json:"listen"`
|
Listen string `yaml:"listen" json:"listen"`
|
||||||
// AuthToken is an optional static admin token (sees all sessions). Users log in via /api/auth.
|
|
||||||
AuthToken string `yaml:"auth_token" json:"auth_token"`
|
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 for browser WebSocket/CORS. Empty = same host only. "*" must be set explicitly.
|
||||||
AllowedOrigins []string `yaml:"allowed_origins" json:"allowed_origins"`
|
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"`
|
DataDir string `yaml:"data_dir" json:"data_dir"`
|
||||||
Workspace string `yaml:"workspace" json:"workspace"`
|
Workspace string `yaml:"workspace" json:"workspace"`
|
||||||
LLM LLM `yaml:"llm" json:"llm"`
|
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_BASE_URL", &c.LLM.BaseURL)
|
||||||
set("BYTECLAW_LLM_API_KEY", &c.LLM.APIKey)
|
set("BYTECLAW_LLM_API_KEY", &c.LLM.APIKey)
|
||||||
set("BYTECLAW_LLM_MODEL", &c.LLM.Model)
|
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 != "" {
|
if v := get("BYTECLAW_ALLOWED_ORIGINS"); v != "" {
|
||||||
c.AllowedOrigins = nil
|
c.AllowedOrigins = nil
|
||||||
for _, o := range strings.Split(v, ",") {
|
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.
|
// Validate enforces required fields and secure settings.
|
||||||
func (c Config) Validate() error {
|
func (c Config) Validate() error {
|
||||||
var errs []error
|
var errs []error
|
||||||
if c.AuthToken != "" && len(c.AuthToken) < 16 {
|
if len(c.AuthToken) < 16 {
|
||||||
errs = append(errs, errors.New("auth_token must be at least 16 characters when set (BYTECLAW_AUTH_TOKEN)"))
|
errs = append(errs, errors.New("auth_token must be set and at least 16 characters (BYTECLAW_AUTH_TOKEN)"))
|
||||||
}
|
|
||||||
if m := c.Registration(); m != "open" && m != "invite" && m != "closed" {
|
|
||||||
errs = append(errs, errors.New("registration_mode must be open, invite or closed"))
|
|
||||||
}
|
|
||||||
if c.Registration() == "invite" && c.AuthToken == "" {
|
|
||||||
errs = append(errs, errors.New("registration_mode invite needs auth_token (only the admin can create invite codes)"))
|
|
||||||
}
|
}
|
||||||
if c.LLM.BaseURL == "" || c.LLM.Model == "" {
|
if c.LLM.BaseURL == "" || c.LLM.Model == "" {
|
||||||
errs = append(errs, errors.New("llm.base_url and llm.model are required"))
|
errs = append(errs, errors.New("llm.base_url and llm.model are required"))
|
||||||
|
|
|
||||||
|
|
@ -36,21 +36,9 @@ func TestLoadJSON(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestValidateShortToken(t *testing.T) {
|
func TestValidateRequiresToken(t *testing.T) {
|
||||||
t.Setenv("BYTECLAW_AUTH_TOKEN", "short")
|
|
||||||
if _, err := Load(""); err == nil {
|
|
||||||
t.Fatal("expected error for short token")
|
|
||||||
}
|
|
||||||
t.Setenv("BYTECLAW_AUTH_TOKEN", "")
|
t.Setenv("BYTECLAW_AUTH_TOKEN", "")
|
||||||
c, err := Load("")
|
if _, err := Load(""); err == nil {
|
||||||
if err != nil {
|
t.Fatal("expected error without token")
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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"
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -48,9 +48,6 @@ func (s *Server) listProviders(w http.ResponseWriter, _ *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) setActiveProvider(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) setActiveProvider(w http.ResponseWriter, r *http.Request) {
|
||||||
if !requireAdmin(w, r) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !s.needProviders(w) {
|
if !s.needProviders(w) {
|
||||||
return
|
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.
|
// PUT /api/sessions/{id}/provider: per-session override; empty provider clears it.
|
||||||
func (s *Server) setSessionProvider(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) setSessionProvider(w http.ResponseWriter, r *http.Request) {
|
||||||
if !requireAdmin(w, r) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !s.needProviders(w) {
|
if !s.needProviders(w) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -96,7 +90,8 @@ func (s *Server) setSessionProvider(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
id := r.PathValue("id")
|
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
|
return
|
||||||
}
|
}
|
||||||
var a providers.Active
|
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) {
|
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"))})
|
writeJSON(w, 200, map[string]bool{"cancelled": s.Agent.Cancel(r.PathValue("id"))})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) summarize(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) summarize(w http.ResponseWriter, r *http.Request) {
|
||||||
id := r.PathValue("id")
|
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
|
return
|
||||||
}
|
}
|
||||||
sm, err := s.Agent.SummarizeNow(r.Context(), id)
|
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) {
|
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"))
|
sm, err := s.Store.LatestSummary(r.Context(), r.PathValue("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
|
|
@ -154,15 +144,3 @@ func (s *Server) getSummary(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
writeJSON(w, 200, sm)
|
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
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ func setupFull(t *testing.T, p llm.Provider) (*httptest.Server, *store.Store) {
|
||||||
pr, _, err := pm.ForSession(ctx, sid)
|
pr, _, err := pm.ForSession(ctx, sid)
|
||||||
return pr, err
|
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())
|
ts := httptest.NewServer(s.Handler())
|
||||||
t.Cleanup(ts.Close)
|
t.Cleanup(ts.Close)
|
||||||
return ts, st
|
return ts, st
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -3,10 +3,10 @@ package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/subtle"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -35,17 +35,6 @@ type Server struct {
|
||||||
Info Info
|
Info Info
|
||||||
// Providers enables runtime provider switching (optional).
|
// Providers enables runtime provider switching (optional).
|
||||||
Providers *providers.Manager
|
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 {
|
type Info struct {
|
||||||
|
|
@ -58,19 +47,8 @@ type Info struct {
|
||||||
func (s *Server) Handler() http.Handler {
|
func (s *Server) Handler() http.Handler {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { writeJSON(w, 200, map[string]string{"status": "ok"}) })
|
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 := 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/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("GET /api/providers", s.listProviders)
|
||||||
api.HandleFunc("PUT /api/providers/active", s.setActiveProvider)
|
api.HandleFunc("PUT /api/providers/active", s.setActiveProvider)
|
||||||
api.HandleFunc("GET /api/providers/{name}/models", s.listModels)
|
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("GET /api/sessions/{id}/messages", s.listMessages)
|
||||||
api.HandleFunc("POST /api/chat", s.chat)
|
api.HandleFunc("POST /api/chat", s.chat)
|
||||||
api.HandleFunc("GET /api/ws", s.ws)
|
api.HandleFunc("GET /api/ws", s.ws)
|
||||||
mux.Handle("/api/", s.auth(api))
|
mux.Handle("/api/", s.originCheck(s.auth(api)))
|
||||||
h := http.Handler(mux)
|
return securityHeaders(mux)
|
||||||
h = s.originCheck(h)
|
|
||||||
return securityHeaders(h)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func securityHeaders(h http.Handler) http.Handler {
|
func securityHeaders(h http.Handler) http.Handler {
|
||||||
|
|
@ -100,6 +76,18 @@ func securityHeaders(h http.Handler) http.Handler {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// auth requires "Authorization: Bearer <token>" (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
|
// OriginAllowed: requests without Origin (native apps, curl) pass; browser
|
||||||
// requests must come from the same host or an explicitly configured origin.
|
// requests must come from the same host or an explicitly configured origin.
|
||||||
func (s *Server) OriginAllowed(r *http.Request) bool {
|
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) {
|
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 {
|
if err != nil {
|
||||||
s.storeErr(w, err)
|
s.storeErr(w, err)
|
||||||
return
|
return
|
||||||
|
|
@ -185,7 +173,7 @@ func (s *Server) createSession(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.ContentLength != 0 && !decode(w, r, &b) {
|
if r.ContentLength != 0 && !decode(w, r, &b) {
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
s.storeErr(w, err)
|
s.storeErr(w, err)
|
||||||
return
|
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) {
|
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"))
|
ss, err := s.Store.GetSession(r.Context(), r.PathValue("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.storeErr(w, err)
|
s.storeErr(w, err)
|
||||||
|
|
@ -212,9 +197,6 @@ func (s *Server) renameSession(w http.ResponseWriter, r *http.Request) {
|
||||||
if !decode(w, r, &b) {
|
if !decode(w, r, &b) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !s.owns(w, r, r.PathValue("id")) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := s.Store.RenameSession(r.Context(), r.PathValue("id"), b.Title); err != nil {
|
if err := s.Store.RenameSession(r.Context(), r.PathValue("id"), b.Title); err != nil {
|
||||||
s.storeErr(w, err)
|
s.storeErr(w, err)
|
||||||
return
|
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) {
|
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 {
|
if err := s.Store.DeleteSession(r.Context(), r.PathValue("id")); err != nil {
|
||||||
s.storeErr(w, err)
|
s.storeErr(w, err)
|
||||||
return
|
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) {
|
func (s *Server) listMessages(w http.ResponseWriter, r *http.Request) {
|
||||||
id := r.PathValue("id")
|
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
|
return
|
||||||
}
|
}
|
||||||
msgs, err := s.Store.Messages(r.Context(), id, 0)
|
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) {
|
func (s *Server) startSession(ctx context.Context, req chatReq) (string, error) {
|
||||||
var a providers.Active
|
var a providers.Active
|
||||||
if req.SessionID == "" && req.Provider != "" && s.Providers != nil {
|
if req.SessionID == "" && req.Provider != "" && s.Providers != nil {
|
||||||
if !principal(ctx).Admin {
|
|
||||||
return "", errAdminOnly
|
|
||||||
}
|
|
||||||
var err error
|
var err error
|
||||||
if a, err = s.Providers.Validate(req.Provider, req.Model); err != nil {
|
if a, err = s.Providers.Validate(req.Provider, req.Model); err != nil {
|
||||||
return "", err
|
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.
|
// 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) {
|
func (s *Server) ensureSession(ctx context.Context, id, text string) (string, error) {
|
||||||
if id != "" {
|
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", " "))
|
title := strings.TrimSpace(strings.ReplaceAll(text, "\n", " "))
|
||||||
if utf8.RuneCountInString(title) > 48 {
|
if utf8.RuneCountInString(title) > 48 {
|
||||||
title = string([]rune(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
|
return ss.ID, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -299,10 +277,6 @@ func (s *Server) chat(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
id, err := s.startSession(r.Context(), req)
|
id, err := s.startSession(r.Context(), req)
|
||||||
if errors.Is(err, errAdminOnly) {
|
|
||||||
writeErr(w, http.StatusForbidden, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if req.SessionID == "" && req.Provider != "" {
|
if req.SessionID == "" && req.Provider != "" {
|
||||||
providerErr(w, err)
|
providerErr(w, err)
|
||||||
|
|
@ -363,7 +337,7 @@ func (s *Server) ws(w http.ResponseWriter, r *http.Request) {
|
||||||
send(agent.Event{Type: "pong"})
|
send(agent.Event{Type: "pong"})
|
||||||
continue
|
continue
|
||||||
case "cancel":
|
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})
|
send(agent.Event{Type: "error", Text: "nothing to cancel", Session: req.SessionID})
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
|
|
|
||||||
|
|
@ -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<max_uses`, code)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if n, _ := res.RowsAffected(); n == 0 {
|
|
||||||
return ErrInviteUsed
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
@ -54,23 +54,6 @@ CREATE TABLE IF NOT EXISTS summaries(
|
||||||
upto_message_id INTEGER NOT NULL, content TEXT NOT NULL, created_at INTEGER NOT NULL);
|
upto_message_id INTEGER NOT NULL, content TEXT NOT NULL, created_at INTEGER NOT NULL);
|
||||||
CREATE INDEX IF NOT EXISTS idx_summaries_session ON summaries(session_id, id);
|
CREATE INDEX IF NOT EXISTS idx_summaries_session ON summaries(session_id, id);
|
||||||
CREATE TABLE IF NOT EXISTS settings(key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
CREATE TABLE IF NOT EXISTS settings(key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
||||||
CREATE TABLE IF NOT EXISTS users(
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
|
||||||
password_hash TEXT NOT NULL, created_at INTEGER NOT NULL);
|
|
||||||
CREATE TABLE IF NOT EXISTS auth_tokens(
|
|
||||||
token_hash TEXT PRIMARY KEY,
|
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_auth_tokens_user ON auth_tokens(user_id);
|
|
||||||
CREATE TABLE IF NOT EXISTS invites(
|
|
||||||
code TEXT PRIMARY KEY, max_uses INTEGER NOT NULL DEFAULT 1, uses INTEGER NOT NULL DEFAULT 0,
|
|
||||||
expires_at INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL);
|
|
||||||
CREATE TABLE IF NOT EXISTS invite_uses(
|
|
||||||
code TEXT NOT NULL REFERENCES invites(code) ON DELETE CASCADE,
|
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
username TEXT NOT NULL, used_at INTEGER NOT NULL);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_invite_uses_code ON invite_uses(code);
|
|
||||||
`
|
`
|
||||||
|
|
||||||
// migrations add columns to databases created by older versions.
|
// migrations add columns to databases created by older versions.
|
||||||
|
|
@ -78,7 +61,6 @@ var migrations = []struct{ table, column, ddl string }{
|
||||||
{"sessions", "provider", "ALTER TABLE sessions ADD COLUMN provider TEXT NOT NULL DEFAULT ''"},
|
{"sessions", "provider", "ALTER TABLE sessions ADD COLUMN provider TEXT NOT NULL DEFAULT ''"},
|
||||||
{"sessions", "model", "ALTER TABLE sessions ADD COLUMN model TEXT NOT NULL DEFAULT ''"},
|
{"sessions", "model", "ALTER TABLE sessions ADD COLUMN model TEXT NOT NULL DEFAULT ''"},
|
||||||
{"messages", "cancelled", "ALTER TABLE messages ADD COLUMN cancelled INTEGER NOT NULL DEFAULT 0"},
|
{"messages", "cancelled", "ALTER TABLE messages ADD COLUMN cancelled INTEGER NOT NULL DEFAULT 0"},
|
||||||
{"sessions", "user_id", "ALTER TABLE sessions ADD COLUMN user_id INTEGER NOT NULL DEFAULT 0"},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func migrate(db *sql.DB) error {
|
func migrate(db *sql.DB) error {
|
||||||
|
|
@ -121,15 +103,10 @@ func newID() string {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) CreateSession(ctx context.Context, title string) (Session, error) {
|
func (s *Store) CreateSession(ctx context.Context, title string) (Session, error) {
|
||||||
return s.CreateSessionFor(ctx, 0, title)
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateSessionFor creates a session owned by userID (0 = legacy/admin).
|
|
||||||
func (s *Store) CreateSessionFor(ctx context.Context, userID int64, title string) (Session, error) {
|
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
ss := Session{ID: newID(), Title: title, CreatedAt: now, UpdatedAt: now}
|
ss := Session{ID: newID(), Title: title, CreatedAt: now, UpdatedAt: now}
|
||||||
_, err := s.db.ExecContext(ctx, `INSERT INTO sessions(id,title,user_id,created_at,updated_at) VALUES(?,?,?,?,?)`,
|
_, err := s.db.ExecContext(ctx, `INSERT INTO sessions(id,title,created_at,updated_at) VALUES(?,?,?,?)`,
|
||||||
ss.ID, ss.Title, userID, now.UnixMilli(), now.UnixMilli())
|
ss.ID, ss.Title, now.UnixMilli(), now.UnixMilli())
|
||||||
return ss, err
|
return ss, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -145,16 +122,7 @@ func (s *Store) GetSession(ctx context.Context, id string) (Session, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) ListSessions(ctx context.Context, limit int) ([]Session, error) {
|
func (s *Store) ListSessions(ctx context.Context, limit int) ([]Session, error) {
|
||||||
return s.ListSessionsFor(ctx, AllUsers, limit)
|
rows, err := s.db.QueryContext(ctx, `SELECT id,title,provider,model,created_at,updated_at FROM sessions ORDER BY updated_at DESC LIMIT ?`, limit)
|
||||||
}
|
|
||||||
|
|
||||||
// AllUsers disables per-user scoping (admin / static token).
|
|
||||||
const AllUsers int64 = -1
|
|
||||||
|
|
||||||
// ListSessionsFor lists the sessions of userID (AllUsers = every session).
|
|
||||||
func (s *Store) ListSessionsFor(ctx context.Context, userID int64, limit int) ([]Session, error) {
|
|
||||||
rows, err := s.db.QueryContext(ctx, `SELECT id,title,provider,model,created_at,updated_at FROM sessions
|
|
||||||
WHERE (?<0 OR user_id=?) ORDER BY updated_at DESC LIMIT ?`, userID, userID, limit)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,128 +0,0 @@
|
||||||
package store
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"crypto/rand"
|
|
||||||
"crypto/sha256"
|
|
||||||
"database/sql"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/hex"
|
|
||||||
"errors"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
var ErrUserExists = errors.New("username already taken")
|
|
||||||
|
|
||||||
type User struct {
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
Username string `json:"username"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
hash string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u User) PasswordHash() string { return u.hash }
|
|
||||||
|
|
||||||
// CreateUser inserts a user. The first user ever created adopts all sessions
|
|
||||||
// that existed before accounts were introduced (user_id 0).
|
|
||||||
func (s *Store) CreateUser(ctx context.Context, username, passwordHash string) (User, error) {
|
|
||||||
return s.CreateUserWithInvite(ctx, username, passwordHash, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateUserWithInvite creates a user and, if code is non-empty, consumes one
|
|
||||||
// use of that invite code in the same transaction (all or nothing).
|
|
||||||
func (s *Store) CreateUserWithInvite(ctx context.Context, username, passwordHash, code string) (User, error) {
|
|
||||||
now := time.Now().UTC()
|
|
||||||
tx, err := s.db.BeginTx(ctx, nil)
|
|
||||||
if err != nil {
|
|
||||||
return User{}, err
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
if code != "" {
|
|
||||||
if err := consumeInvite(ctx, tx, code, now); err != nil {
|
|
||||||
return User{}, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var n int
|
|
||||||
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&n); err != nil {
|
|
||||||
return User{}, err
|
|
||||||
}
|
|
||||||
res, err := tx.ExecContext(ctx, `INSERT INTO users(username,password_hash,created_at) VALUES(?,?,?)`, username, passwordHash, now.UnixMilli())
|
|
||||||
if err != nil {
|
|
||||||
if strings.Contains(err.Error(), "UNIQUE") {
|
|
||||||
return User{}, ErrUserExists
|
|
||||||
}
|
|
||||||
return User{}, err
|
|
||||||
}
|
|
||||||
id, _ := res.LastInsertId()
|
|
||||||
if code != "" {
|
|
||||||
if _, err := tx.ExecContext(ctx, `INSERT INTO invite_uses(code,user_id,username,used_at) VALUES(?,?,?,?)`, code, id, username, now.UnixMilli()); err != nil {
|
|
||||||
return User{}, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if n == 0 {
|
|
||||||
if _, err := tx.ExecContext(ctx, `UPDATE sessions SET user_id=? WHERE user_id=0`, id); err != nil {
|
|
||||||
return User{}, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return User{ID: id, Username: username, CreatedAt: now, hash: passwordHash}, tx.Commit()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) UserByName(ctx context.Context, username string) (User, error) {
|
|
||||||
var u User
|
|
||||||
var c int64
|
|
||||||
err := s.db.QueryRowContext(ctx, `SELECT id,username,password_hash,created_at FROM users WHERE username=?`, username).Scan(&u.ID, &u.Username, &u.hash, &c)
|
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
|
||||||
return u, ErrNotFound
|
|
||||||
}
|
|
||||||
u.CreatedAt = time.UnixMilli(c).UTC()
|
|
||||||
return u, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func hashToken(t string) string {
|
|
||||||
h := sha256.Sum256([]byte(t))
|
|
||||||
return hex.EncodeToString(h[:])
|
|
||||||
}
|
|
||||||
|
|
||||||
// IssueToken creates a random opaque token; only its SHA-256 is stored.
|
|
||||||
func (s *Store) IssueToken(ctx context.Context, userID int64, ttl time.Duration) (string, time.Time, error) {
|
|
||||||
b := make([]byte, 32)
|
|
||||||
if _, err := rand.Read(b); err != nil {
|
|
||||||
return "", time.Time{}, err
|
|
||||||
}
|
|
||||||
tok := "bc_" + base64.RawURLEncoding.EncodeToString(b)
|
|
||||||
now := time.Now().UTC()
|
|
||||||
exp := now.Add(ttl)
|
|
||||||
_, err := s.db.ExecContext(ctx, `INSERT INTO auth_tokens(token_hash,user_id,created_at,expires_at) VALUES(?,?,?,?)`,
|
|
||||||
hashToken(tok), userID, now.UnixMilli(), exp.UnixMilli())
|
|
||||||
s.db.ExecContext(ctx, `DELETE FROM auth_tokens WHERE expires_at<?`, now.UnixMilli())
|
|
||||||
return tok, exp, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// UserByToken resolves a non-expired token (lookup by hash, so no timing leak on the secret).
|
|
||||||
func (s *Store) UserByToken(ctx context.Context, tok string) (User, error) {
|
|
||||||
var u User
|
|
||||||
var c int64
|
|
||||||
err := s.db.QueryRowContext(ctx, `SELECT u.id,u.username,u.created_at FROM auth_tokens t JOIN users u ON u.id=t.user_id
|
|
||||||
WHERE t.token_hash=? AND t.expires_at>?`, 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
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 534 KiB After Width: | Height: | Size: 688 KiB |
|
Before Width: | Height: | Size: 588 KiB After Width: | Height: | Size: 1.2 MiB |
|
|
@ -1,3 +1,3 @@
|
||||||
/downloads/*.apk
|
/downloads/*.apk
|
||||||
Content-Type: application/vnd.android.package-archive
|
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"
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 21 KiB |
|
|
@ -9,7 +9,7 @@
|
||||||
<meta property="og:type" content="website">
|
<meta property="og:type" content="website">
|
||||||
<meta property="og:title" content="ByteClaw – selbst gehosteter KI-Assistent">
|
<meta property="og:title" content="ByteClaw – selbst gehosteter KI-Assistent">
|
||||||
<meta property="og:description" content="Go-Backend + Android-App. OpenAI, OpenRouter, Ollama, Dahl, GonkaRouter oder eigener Endpunkt.">
|
<meta property="og:description" content="Go-Backend + Android-App. OpenAI, OpenRouter, Ollama, Dahl, GonkaRouter oder eigener Endpunkt.">
|
||||||
<meta property="og:image" content="https://byteclaw-app-65807.web.app/assets/og.png">
|
<meta property="og:image" content="https://byteclaw-app.web.app/assets/og.png">
|
||||||
<meta property="og:locale" content="de_DE">
|
<meta property="og:locale" content="de_DE">
|
||||||
<meta name="twitter:card" content="summary_large_image">
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
<link rel="icon" href="assets/icon.svg" type="image/svg+xml">
|
<link rel="icon" href="assets/icon.svg" type="image/svg+xml">
|
||||||
|
|
@ -32,7 +32,7 @@
|
||||||
<p class="claim">Selbst gehosteter, schlanker KI-Assistent – <span>Go-Backend</span> + <span>Android-App</span>.</p>
|
<p class="claim">Selbst gehosteter, schlanker KI-Assistent – <span>Go-Backend</span> + <span>Android-App</span>.</p>
|
||||||
<p class="lead">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.</p>
|
<p class="lead">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.</p>
|
||||||
<div class="cta">
|
<div class="cta">
|
||||||
<a class="btn btn-primary" href="https://git.85.190.126.179.sslip.io/crypto/byteclaw/releases/download/v0.3.2/byteclaw-0.3.2-release.apk">Android-App laden · v0.3.2 (1,9 MB)</a>
|
<a class="btn btn-primary" href="downloads/byteclaw-0.2.0.apk" download>Android-App laden · v0.2.0 (1,9 MB)</a>
|
||||||
<a class="btn btn-ghost" href="#quickstart">Quickstart ansehen</a>
|
<a class="btn btn-ghost" href="#quickstart">Quickstart ansehen</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -51,8 +51,7 @@
|
||||||
<article class="card"><h3>Abbrechen</h3><p>Stopp-Button in der App: Der Abbruch reicht bis zum Anbieter, die Teilantwort bleibt gespeichert.</p></article>
|
<article class="card"><h3>Abbrechen</h3><p>Stopp-Button in der App: Der Abbruch reicht bis zum Anbieter, die Teilantwort bleibt gespeichert.</p></article>
|
||||||
<article class="card"><h3>Session-Zusammenfassung</h3><p>Lange Chats werden automatisch per LLM zusammengefasst – die Originalnachrichten bleiben erhalten.</p></article>
|
<article class="card"><h3>Session-Zusammenfassung</h3><p>Lange Chats werden automatisch per LLM zusammengefasst – die Originalnachrichten bleiben erhalten.</p></article>
|
||||||
<article class="card"><h3>Anbieter pro Session</h3><p>Global umschalten oder für einzelne Chats ein anderes Modell wählen – direkt aus der App.</p></article>
|
<article class="card"><h3>Anbieter pro Session</h3><p>Global umschalten oder für einzelne Chats ein anderes Modell wählen – direkt aus der App.</p></article>
|
||||||
<article class="card"><h3>Konten & Login</h3><p>Registrierung und Anmeldung in der App – jeder Nutzer hat eigene, getrennte Chats. Passwörter als bcrypt-Hash, Tokens widerrufbar.</p></article>
|
<article class="card"><h3>Sichere Defaults</h3><p>Token-Pflicht, nur localhost, Origin-Prüfung, TLS immer an, Keys nie in der App, Release nur HTTPS.</p></article>
|
||||||
<article class="card"><h3>Sichere Defaults</h3><p>Login-Pflicht, Rate-Limit, nur localhost, Origin-Prüfung, TLS immer an, Keys nie in der App, Release nur HTTPS.</p></article>
|
|
||||||
<article class="card"><h3>ARM & Termux</h3><p>Statische Builds für Linux amd64, arm64 und armv7 sowie Termux auf Android.</p></article>
|
<article class="card"><h3>ARM & Termux</h3><p>Statische Builds für Linux amd64, arm64 und armv7 sowie Termux auf Android.</p></article>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -65,7 +64,7 @@
|
||||||
<figure><div class="phone"><picture><source srcset="assets/chat_provider_dark.webp" type="image/webp"><img loading="lazy" src="assets/chat_provider_dark.png" alt="Chat mit Anbieter-Auswahl pro Session im Header, dunkles Design" width="450" height="1000"></picture></div><figcaption>Anbieter pro Session</figcaption></figure>
|
<figure><div class="phone"><picture><source srcset="assets/chat_provider_dark.webp" type="image/webp"><img loading="lazy" src="assets/chat_provider_dark.png" alt="Chat mit Anbieter-Auswahl pro Session im Header, dunkles Design" width="450" height="1000"></picture></div><figcaption>Anbieter pro Session</figcaption></figure>
|
||||||
<figure><div class="phone"><picture><source srcset="assets/providers_dark.webp" type="image/webp"><img loading="lazy" src="assets/providers_dark.png" alt="Einstellungen mit Anbieter- und Modellauswahl, dunkles Design" width="450" height="1000"></picture></div><figcaption>Anbieter & Modell</figcaption></figure>
|
<figure><div class="phone"><picture><source srcset="assets/providers_dark.webp" type="image/webp"><img loading="lazy" src="assets/providers_dark.png" alt="Einstellungen mit Anbieter- und Modellauswahl, dunkles Design" width="450" height="1000"></picture></div><figcaption>Anbieter & Modell</figcaption></figure>
|
||||||
<figure><div class="phone"><picture><source srcset="assets/sessions_search_dark.webp" type="image/webp"><img loading="lazy" src="assets/sessions_search_dark.png" alt="Sessions durchsuchen, dunkles Design" width="450" height="1000"></picture></div><figcaption>Sessions suchen & umbenennen</figcaption></figure>
|
<figure><div class="phone"><picture><source srcset="assets/sessions_search_dark.webp" type="image/webp"><img loading="lazy" src="assets/sessions_search_dark.png" alt="Sessions durchsuchen, dunkles Design" width="450" height="1000"></picture></div><figcaption>Sessions suchen & umbenennen</figcaption></figure>
|
||||||
<figure><div class="phone"><picture><source srcset="assets/login_light.webp" type="image/webp"><img loading="lazy" src="assets/login_light.png" alt="Anmelde- und Registrierungsbildschirm, helles Design" width="450" height="1000"></picture></div><figcaption>Anmelden & Registrieren</figcaption></figure>
|
<figure><div class="phone"><picture><source srcset="assets/settings_light.webp" type="image/webp"><img loading="lazy" src="assets/settings_light.png" alt="Einstellungen mit Server-URL und Token, helles Design" width="450" height="1000"></picture></div><figcaption>Verbindung</figcaption></figure>
|
||||||
<figure><div class="phone"><picture><source srcset="assets/chat_dark.webp" type="image/webp"><img loading="lazy" src="assets/chat_dark.png" alt="Chat, dunkles Design" width="450" height="1000"></picture></div><figcaption>Dunkles Design</figcaption></figure>
|
<figure><div class="phone"><picture><source srcset="assets/chat_dark.webp" type="image/webp"><img loading="lazy" src="assets/chat_dark.png" alt="Chat, dunkles Design" width="450" height="1000"></picture></div><figcaption>Dunkles Design</figcaption></figure>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -89,7 +88,7 @@ curl -H "Authorization: Bearer $BYTECLAW_AUTH_TOKEN" \
|
||||||
|
|
||||||
<footer class="footer">
|
<footer class="footer">
|
||||||
<p><img src="assets/icon.svg" alt="" width="20" height="20"> ByteClaw · selbst gehostet, Go + Android</p>
|
<p><img src="assets/icon.svg" alt="" width="20" height="20"> ByteClaw · selbst gehostet, Go + Android</p>
|
||||||
<p class="muted">Screenshots zeigen Demo-Daten. APK v0.3.2 · Android 8+ · signiert.</p>
|
<p class="muted">Screenshots zeigen Demo-Daten. APK v0.2.0 · Android 8+ · signiert.</p>
|
||||||
</footer>
|
</footer>
|
||||||
<script>
|
<script>
|
||||||
document.querySelectorAll('[data-copy]').forEach(b=>b.addEventListener('click',()=>{
|
document.querySelectorAll('[data-copy]').forEach(b=>b.addEventListener('click',()=>{
|
||||||
|
|
|
||||||