diff --git a/android/.kotlin/errors/errors-1793502564485.log b/android/.kotlin/errors/errors-1793502564485.log new file mode 100644 index 0000000..1219b50 --- /dev/null +++ b/android/.kotlin/errors/errors-1793502564485.log @@ -0,0 +1,4 @@ +kotlin version: 2.0.21 +error message: The daemon has terminated unexpectedly on startup attempt #1 with error code: 0. The daemon process output: + 1. Kotlin compile daemon is ready + diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 39e2a6b..a0b2532 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -31,8 +31,8 @@ android { applicationId = "dev.byteclaw.client" minSdk = 26 targetSdk = 35 - versionCode = 3 - versionName = "0.3.0" + versionCode = 4 + versionName = "0.3.1" } signingConfigs { diff --git a/android/app/src/main/java/dev/byteclaw/client/ui/ChatViewModel.kt b/android/app/src/main/java/dev/byteclaw/client/ui/ChatViewModel.kt index 780339a..feb1bd4 100644 --- a/android/app/src/main/java/dev/byteclaw/client/ui/ChatViewModel.kt +++ b/android/app/src/main/java/dev/byteclaw/client/ui/ChatViewModel.kt @@ -44,6 +44,8 @@ data class UiState( /** 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, ) { /** What the current chat effectively uses: session override, else the global active provider. */ val effectiveProvider: ActiveProvider? get() = sessionProvider ?: provider.active @@ -266,6 +268,7 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) { } fun loadProviders() = viewModelScope.launch { + runCatching { api().me() }.onSuccess { u -> _state.update { it.copy(isAdmin = u.admin) } } runCatching { api().providers() } .onSuccess { r -> _state.update { it.copy(provider = it.provider.copy(providers = r.providers, active = r.active, status = null)) } } .onFailure { e -> if (e is UnauthorizedException) fail(e) else _state.update { it.copy(provider = it.provider.copy(status = "Provider laden fehlgeschlagen: ${e.message}")) } } diff --git a/android/app/src/main/java/dev/byteclaw/client/ui/Screens.kt b/android/app/src/main/java/dev/byteclaw/client/ui/Screens.kt index 74e7c73..6021bfb 100644 --- a/android/app/src/main/java/dev/byteclaw/client/ui/Screens.kt +++ b/android/app/src/main/java/dev/byteclaw/client/ui/Screens.kt @@ -296,7 +296,14 @@ fun SettingsScreen( } status?.let { item { Text(it) } } if (state.settings.isConfigured) { - item { ProviderSection(state.provider, onLoadProviders, onLoadModels, onSelectProvider) } + if (state.isAdmin) { + item { ProviderSection(state.provider, onLoadProviders, onLoadModels, onSelectProvider) } + } 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) + } + } } } } @@ -370,7 +377,7 @@ private fun SessionProviderChip(state: UiState, onPick: (String) -> Unit, initia Box { AssistChip( onClick = { open = true }, label = { Text(label, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.labelSmall) }, - enabled = state.settings.isConfigured && !state.busy, + enabled = state.settings.isConfigured && !state.busy && state.isAdmin, modifier = Modifier.height(28.dp).widthIn(max = 230.dp), ) DropdownMenu(expanded = open, onDismissRequest = { open = false }) { diff --git a/android/app/src/test/java/dev/byteclaw/client/preview/PreviewScreenshots.kt b/android/app/src/test/java/dev/byteclaw/client/preview/PreviewScreenshots.kt index 65a1d81..6cbe174 100644 --- a/android/app/src/test/java/dev/byteclaw/client/preview/PreviewScreenshots.kt +++ b/android/app/src/test/java/dev/byteclaw/client/preview/PreviewScreenshots.kt @@ -43,7 +43,7 @@ class PreviewScreenshots(private val dark: Boolean) { @Test fun chat() = snap("chat") { ChatScreen( - state = UiState( + state = UiState(isAdmin = true, settings = settings, sessionId = "demo", busy = true, lines = listOf( ChatLine("user", "[DEMO] Wie spät ist es in Berlin, und was steht auf golang.org?"), @@ -68,7 +68,7 @@ class PreviewScreenshots(private val dark: Boolean) { @Test fun chatProvider() = snap("chat_provider") { ChatScreen( - state = UiState( + state = UiState(isAdmin = true, settings = settings, sessionId = "demo", provider = demoProviders, sessionProvider = ActiveProvider("gonkarouter", "MiniMaxAI/MiniMax-M2.7"), lines = listOf( @@ -83,7 +83,7 @@ class PreviewScreenshots(private val dark: Boolean) { @Test fun chatMarkdown() = snap("chat_markdown") { ChatScreen( - state = UiState( + state = UiState(isAdmin = true, settings = settings, sessionId = "demo", online = true, lines = listOf( ChatLine("user", "[DEMO] Wie lese ich in Go eine Datei zeilenweise?"), @@ -96,7 +96,7 @@ class PreviewScreenshots(private val dark: Boolean) { @Test fun sessionsSearch() = snap("sessions_search") { SessionsScreen( - state = UiState( + state = UiState(isAdmin = true, settings = settings, sessions = listOf( Session("1", "[DEMO] Go-Datei zeilenweise lesen", "2026-10-25T03:32:00Z"), @@ -110,7 +110,7 @@ class PreviewScreenshots(private val dark: Boolean) { @Test fun sessions() = snap("sessions") { SessionsScreen( - state = UiState( + state = UiState(isAdmin = true, settings = settings, sessions = listOf( Session("1", "[DEMO] Wie spät ist es in Berlin?", "2026-10-25T03:32:00Z"), @@ -126,14 +126,14 @@ class PreviewScreenshots(private val dark: Boolean) { @Test fun settings() = snap("settings") { SettingsScreen( - state = UiState(settings = settings), onSave = {}, onTest = { _, _ -> }, onBack = {}, + state = UiState(isAdmin = true, settings = settings), onSave = {}, onTest = { _, _ -> }, onBack = {}, initialStatus = "Verbindung OK · Provider: Dahl (dahl.global) · Modell: MiniMaxAI/MiniMax-M2.7 [DEMO]", ) } @Test fun providers() = snap("providers") { SettingsScreen( - state = UiState( + state = UiState(isAdmin = true, settings = settings, provider = ProviderUi( active = ActiveProvider("dahl", "MiniMaxAI/MiniMax-M2.7"), @@ -152,12 +152,12 @@ class PreviewScreenshots(private val dark: Boolean) { } @Test fun login() = snap("login") { - LoginScreen(state = UiState(settings = ServerSettings("https://demo.byteclaw.example", "", "crypto")), onAuthenticate = { _, _, _, _ -> }) + LoginScreen(state = UiState(isAdmin = true, settings = ServerSettings("https://demo.byteclaw.example", "", "crypto")), onAuthenticate = { _, _, _, _ -> }) } @Test fun loginRegister() = snap("login_register") { LoginScreen( - state = UiState(settings = ServerSettings("https://demo.byteclaw.example"), authError = "Benutzername ist bereits vergeben"), + state = UiState(isAdmin = true, settings = ServerSettings("https://demo.byteclaw.example"), authError = "Benutzername ist bereits vergeben"), onAuthenticate = { _, _, _, _ -> }, initialRegister = true, initialUser = "crypto", ) } diff --git a/internal/server/admin_test.go b/internal/server/admin_test.go new file mode 100644 index 0000000..544e89a --- /dev/null +++ b/internal/server/admin_test.go @@ -0,0 +1,43 @@ +package server + +import ( + "encoding/json" + "testing" +) + +func TestProviderChangesAdminOnly(t *testing.T) { + ts, _ := setupFull(t, echo{}) + ut := registerUser(t, ts.URL, "alice", "correct-horse-1") + uh := bearerHdr(ut) + if r := do(t, "GET", ts.URL+"/api/providers", "", uh); r.StatusCode != 200 { + t.Fatal("users may list providers", r.StatusCode) + } + if r := do(t, "PUT", ts.URL+"/api/providers/active", `{"provider":"dahl"}`, uh); r.StatusCode != 403 { + t.Fatal("user must not change global provider", r.StatusCode) + } + var me map[string]any + json.NewDecoder(do(t, "GET", ts.URL+"/api/auth/me", "", uh).Body).Decode(&me) + if me["admin"] != false { + t.Fatalf("%v", me) + } + // own session: chat ok, provider change forbidden + r := do(t, "POST", ts.URL+"/api/chat", `{"text":"hi"}`, uh) + if r.StatusCode != 200 { + t.Fatal("user chat", r.StatusCode) + } + var cr struct { + SessionID string `json:"session_id"` + } + json.NewDecoder(r.Body).Decode(&cr) + if cr.SessionID != "" { + if r := do(t, "PUT", ts.URL+"/api/sessions/"+cr.SessionID+"/provider", `{"provider":"openai"}`, uh); r.StatusCode != 403 { + t.Fatal("session provider", r.StatusCode) + } + } + if r := do(t, "POST", ts.URL+"/api/chat", `{"text":"hi","provider":"dahl"}`, uh); r.StatusCode != 403 { + t.Fatal("chat with provider", r.StatusCode) + } + if r := do(t, "PUT", ts.URL+"/api/providers/active", `{"provider":"dahl"}`, authH); r.StatusCode != 200 { + t.Fatal("admin allowed", r.StatusCode) + } +} diff --git a/internal/server/extra.go b/internal/server/extra.go index 1594c14..8da25f3 100644 --- a/internal/server/extra.go +++ b/internal/server/extra.go @@ -48,6 +48,9 @@ func (s *Server) listProviders(w http.ResponseWriter, _ *http.Request) { } func (s *Server) setActiveProvider(w http.ResponseWriter, r *http.Request) { + if !requireAdmin(w, r) { + return + } if !s.needProviders(w) { return } @@ -82,6 +85,9 @@ func (s *Server) listModels(w http.ResponseWriter, r *http.Request) { // PUT /api/sessions/{id}/provider: per-session override; empty provider clears it. func (s *Server) setSessionProvider(w http.ResponseWriter, r *http.Request) { + if !requireAdmin(w, r) { + return + } if !s.needProviders(w) { return } @@ -148,3 +154,15 @@ func (s *Server) getSummary(w http.ResponseWriter, r *http.Request) { } writeJSON(w, 200, sm) } + +// errAdminOnly is returned when a non-admin tries to change provider settings. +var errAdminOnly = errors.New("admin only: provider settings can only be changed by the server admin") + +// requireAdmin writes 403 unless the caller is the admin (static auth_token). +func requireAdmin(w http.ResponseWriter, r *http.Request) bool { + if principal(r.Context()).Admin { + return true + } + writeErr(w, http.StatusForbidden, errAdminOnly.Error()) + return false +} diff --git a/internal/server/extra_test.go b/internal/server/extra_test.go index 37ad78a..bf9fdc3 100644 --- a/internal/server/extra_test.go +++ b/internal/server/extra_test.go @@ -50,7 +50,7 @@ func setupFull(t *testing.T, p llm.Provider) (*httptest.Server, *store.Store) { pr, _, err := pm.ForSession(ctx, sid) return pr, err } - s := &Server{Store: st, Token: tok, Log: slog.New(slog.NewTextHandler(io.Discard, nil)), RunTimeout: 5 * time.Second, Agent: ag, Providers: pm} + s := &Server{Store: st, Token: tok, AllowRegistration: true, Log: slog.New(slog.NewTextHandler(io.Discard, nil)), RunTimeout: 5 * time.Second, Agent: ag, Providers: pm} ts := httptest.NewServer(s.Handler()) t.Cleanup(ts.Close) return ts, st diff --git a/internal/server/server.go b/internal/server/server.go index 3868a19..6357b84 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -253,6 +253,9 @@ type chatReq struct { func (s *Server) startSession(ctx context.Context, req chatReq) (string, error) { var a providers.Active if req.SessionID == "" && req.Provider != "" && s.Providers != nil { + if !principal(ctx).Admin { + return "", errAdminOnly + } var err error if a, err = s.Providers.Validate(req.Provider, req.Model); err != nil { return "", err @@ -288,6 +291,10 @@ func (s *Server) chat(w http.ResponseWriter, r *http.Request) { return } id, err := s.startSession(r.Context(), req) + if errors.Is(err, errAdminOnly) { + writeErr(w, http.StatusForbidden, err.Error()) + return + } if err != nil { if req.SessionID == "" && req.Provider != "" { providerErr(w, err) diff --git a/site/.netlify/state.json b/site/.netlify/state.json new file mode 100644 index 0000000..d1ce675 --- /dev/null +++ b/site/.netlify/state.json @@ -0,0 +1,3 @@ +{ + "siteId": "eda745c3-d798-4dab-907e-aac0a7d94433" +} \ No newline at end of file diff --git a/site/_headers b/site/_headers index cc11c9f..b935310 100644 --- a/site/_headers +++ b/site/_headers @@ -1,3 +1,3 @@ /downloads/*.apk Content-Type: application/vnd.android.package-archive - Content-Disposition: attachment; filename="byteclaw-0.3.0.apk" + Content-Disposition: attachment; filename="byteclaw-0.3.1.apk" diff --git a/site/index.html b/site/index.html index d3677b0..ab9d553 100644 --- a/site/index.html +++ b/site/index.html @@ -32,7 +32,7 @@

Selbst gehosteter, schlanker KI-Assistent – Go-Backend + Android-App.

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

- Android-App laden · v0.3.0 (1,9 MB) + Android-App laden · v0.3.1 (1,9 MB) Quickstart ansehen
@@ -89,7 +89,7 @@ curl -H "Authorization: Bearer $BYTECLAW_AUTH_TOKEN" \