Compare commits
3 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf16eb5a31 | ||
|
|
cf5e0b1ca7 | ||
|
|
d2658de099 |
4
.gitignore
vendored
|
|
@ -4,3 +4,7 @@
|
||||||
*.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. `allow_registration` (Standard an). Optionaler statischer `auth_token` (mind. 16 Zeichen) = Admin, sieht alle Sessions; Vergleich in konstanter Zeit.
|
- Konten: `POST /api/auth/register|login|logout`, `GET /api/auth/me`; Passwörter als bcrypt-Hash, Tokens zufällig + gehasht gespeichert, 30 Tage gültig, Logout widerruft. Sessions sind pro Nutzer getrennt. Login-Rate-Limit pro IP. `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.
|
||||||
- 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.
|
||||||
|
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
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
|
|
||||||
|
|
||||||
|
|
@ -31,8 +31,8 @@ android {
|
||||||
applicationId = "dev.byteclaw.client"
|
applicationId = "dev.byteclaw.client"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 4
|
versionCode = 5
|
||||||
versionName = "0.3.1"
|
versionName = "0.3.2"
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ class MainActivity : ComponentActivity() {
|
||||||
}
|
}
|
||||||
NavHost(nav, startDestination = if (loggedIn) "chat" else "login") {
|
NavHost(nav, startDestination = if (loggedIn) "chat" else "login") {
|
||||||
composable("login") {
|
composable("login") {
|
||||||
LoginScreen(state, onAuthenticate = { u, n, p, r -> vm.authenticate(u, n, p, r) },
|
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) })
|
onAdminToken = { u, t -> vm.useAdminToken(u, t) })
|
||||||
}
|
}
|
||||||
composable("chat") {
|
composable("chat") {
|
||||||
|
|
@ -93,7 +93,8 @@ 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) }, onLogout = vm::logout,
|
||||||
|
onLoadInvites = { vm.loadInvites() }, onCreateInvite = { n, h -> vm.createInvite(n, h) }, onRevokeInvite = { vm.revokeInvite(it) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,18 @@ 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())
|
||||||
}
|
}
|
||||||
|
|
@ -132,10 +144,11 @@ class ByteClawApi(private val settings: ServerSettings) {
|
||||||
private val authJson = Json { ignoreUnknownKeys = true; explicitNulls = false }
|
private val authJson = Json { ignoreUnknownKeys = true; explicitNulls = false }
|
||||||
|
|
||||||
/** POST /api/auth/login or /register without a bearer token. Errors carry German text. */
|
/** 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): AuthResponse =
|
suspend fun authenticate(serverUrl: String, username: String, password: String, register: Boolean, inviteCode: String = ""): AuthResponse =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val path = if (register) "/api/auth/register" else "/api/auth/login"
|
val path = if (register) "/api/auth/register" else "/api/auth/login"
|
||||||
val body = authJson.encodeToString(Credentials.serializer(), Credentials(username.trim(), password)).toRequestBody(JSON)
|
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()
|
val req = Request.Builder().url((serverUrl.trim().trimEnd('/') + path).toHttpUrl()).post(body).build()
|
||||||
shared.newCall(req).execute().use { r ->
|
shared.newCall(req).execute().use { r ->
|
||||||
val text = r.body?.string().orEmpty()
|
val text = r.body?.string().orEmpty()
|
||||||
|
|
@ -144,6 +157,18 @@ class ByteClawApi(private val settings: ServerSettings) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Registration mode of a server; older servers without the endpoint count as "open". */
|
||||||
|
suspend fun registrationMode(serverUrl: String): String = withContext(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
val req = Request.Builder().url((serverUrl.trim().trimEnd('/') + "/api/auth/config").toHttpUrl()).build()
|
||||||
|
shared.newCall(req).execute().use { r ->
|
||||||
|
if (r.code == 404) "open"
|
||||||
|
else if (!r.isSuccessful) null
|
||||||
|
else authJson.decodeFromString(AuthConfig.serializer(), r.body?.string().orEmpty()).registrationMode
|
||||||
|
}
|
||||||
|
}.getOrNull() ?: "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
private val JSON = "application/json".toMediaType()
|
private val JSON = "application/json".toMediaType()
|
||||||
private val shared = OkHttpClient.Builder()
|
private val shared = OkHttpClient.Builder()
|
||||||
.connectTimeout(15, TimeUnit.SECONDS)
|
.connectTimeout(15, TimeUnit.SECONDS)
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,29 @@ object ProviderPresets {
|
||||||
data class RenameRequest(val title: String)
|
data class RenameRequest(val title: String)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Credentials(val username: String, val password: String)
|
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
|
@Serializable
|
||||||
data class AuthUser(val id: Long = 0, val username: String = "", val admin: Boolean = false)
|
data class AuthUser(val id: Long = 0, val username: String = "", val admin: Boolean = false)
|
||||||
|
|
@ -107,6 +129,10 @@ object AuthErrors {
|
||||||
fun german(code: Int, body: String): String = when {
|
fun german(code: Int, body: String): String = when {
|
||||||
code == 401 -> "Benutzername oder Passwort falsch"
|
code == 401 -> "Benutzername oder Passwort falsch"
|
||||||
code == 409 -> "Benutzername ist bereits vergeben"
|
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 == 403 -> "Registrierung ist auf diesem Server deaktiviert"
|
||||||
code == 429 -> "Zu viele Versuche – bitte später erneut probieren"
|
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("username") -> "Benutzername: 3–32 Zeichen (Buchstaben, Ziffern, . _ -)"
|
||||||
|
|
|
||||||
|
|
@ -4,6 +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.AuthErrors
|
||||||
import dev.byteclaw.client.data.UnauthorizedException
|
import dev.byteclaw.client.data.UnauthorizedException
|
||||||
|
|
@ -46,6 +47,11 @@ data class UiState(
|
||||||
val authError: String? = null,
|
val authError: String? = null,
|
||||||
/** From /api/auth/me: only the admin may change providers. */
|
/** From /api/auth/me: only the admin may change providers. */
|
||||||
val isAdmin: Boolean = false,
|
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
|
||||||
|
|
@ -81,13 +87,40 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Login or registration; on success the token is stored encrypted and the chat opens. */
|
/** Login or registration; on success the token is stored encrypted and the chat opens. */
|
||||||
fun authenticate(serverUrl: String, username: String, password: String, register: Boolean) {
|
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 urlErr = ByteClawApi.validateUrl(serverUrl)
|
||||||
val err = urlErr ?: AuthErrors.validate(username, password)
|
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 }
|
if (err != null) { _state.update { it.copy(authError = err) }; return }
|
||||||
_state.update { it.copy(authBusy = true, authError = null) }
|
_state.update { it.copy(authBusy = true, authError = null) }
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runCatching { ByteClawApi.authenticate(serverUrl, username, password, register) }
|
runCatching { ByteClawApi.authenticate(serverUrl, username, password, register, inviteCode) }
|
||||||
.onSuccess { r ->
|
.onSuccess { r ->
|
||||||
store.save(ServerSettings(serverUrl, r.token, r.user.username))
|
store.save(ServerSettings(serverUrl, r.token, r.user.username))
|
||||||
_state.update { UiState(settings = store.load()) }
|
_state.update { UiState(settings = store.load()) }
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
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") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,7 +18,8 @@ import dev.byteclaw.client.data.ByteClawApi
|
||||||
@Composable
|
@Composable
|
||||||
fun LoginScreen(
|
fun LoginScreen(
|
||||||
state: UiState,
|
state: UiState,
|
||||||
onAuthenticate: (url: String, user: String, pass: String, register: Boolean) -> Unit,
|
onAuthenticate: (url: String, user: String, pass: String, register: Boolean, invite: String) -> Unit,
|
||||||
|
onCheckMode: (url: String) -> Unit = {},
|
||||||
onAdminToken: (url: String, token: String) -> Unit = { _, _ -> },
|
onAdminToken: (url: String, token: String) -> Unit = { _, _ -> },
|
||||||
initialRegister: Boolean = false,
|
initialRegister: Boolean = false,
|
||||||
initialUser: String? = null,
|
initialUser: String? = null,
|
||||||
|
|
@ -30,6 +31,9 @@ fun LoginScreen(
|
||||||
var pass2 by remember { mutableStateOf("") }
|
var pass2 by remember { mutableStateOf("") }
|
||||||
var adminMode by remember { mutableStateOf(false) }
|
var adminMode by remember { mutableStateOf(false) }
|
||||||
var token by remember { mutableStateOf("") }
|
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 urlError = if (url.isBlank()) null else ByteClawApi.validateUrl(url)
|
||||||
val mismatch = register && pass2.isNotEmpty() && pass != pass2
|
val mismatch = register && pass2.isNotEmpty() && pass != pass2
|
||||||
|
|
||||||
|
|
@ -65,14 +69,22 @@ fun LoginScreen(
|
||||||
OutlinedTextField(pass2, { pass2 = it }, Modifier.fillMaxWidth(), label = { Text("Passwort wiederholen") }, singleLine = true,
|
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,
|
isError = mismatch, supportingText = if (mismatch) { { Text("Passwörter stimmen nicht überein") } } else null,
|
||||||
visualTransformation = PasswordVisualTransformation(), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password))
|
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) }
|
state.authError?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||||
Button(
|
Button(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
enabled = !state.authBusy && url.isNotBlank() && urlError == null &&
|
enabled = !state.authBusy && url.isNotBlank() && urlError == null &&
|
||||||
(if (adminMode) token.isNotBlank() else user.isNotBlank() && pass.isNotEmpty() && (!register || pass == pass2)),
|
(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) },
|
onClick = { if (adminMode) onAdminToken(url, token) else onAuthenticate(url, user, pass, register, invite) },
|
||||||
) {
|
) {
|
||||||
if (state.authBusy) CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
|
if (state.authBusy) CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
|
||||||
else Text(when { adminMode -> "Mit Token verbinden"; register -> "Konto erstellen"; else -> "Anmelden" })
|
else Text(when { adminMode -> "Mit Token verbinden"; register -> "Konto erstellen"; else -> "Anmelden" })
|
||||||
|
|
|
||||||
|
|
@ -271,6 +271,9 @@ fun SettingsScreen(
|
||||||
onSelectProvider: (String, String) -> Unit = { _, _ -> },
|
onSelectProvider: (String, String) -> Unit = { _, _ -> },
|
||||||
initialStatus: String? = null,
|
initialStatus: String? = null,
|
||||||
onLogout: () -> Unit = {},
|
onLogout: () -> Unit = {},
|
||||||
|
onLoadInvites: () -> Unit = {},
|
||||||
|
onCreateInvite: (Int, Int) -> Unit = { _, _ -> },
|
||||||
|
onRevokeInvite: (String) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
var status by remember { mutableStateOf(initialStatus) }
|
var status by remember { mutableStateOf(initialStatus) }
|
||||||
LaunchedEffect(state.settings) { if (state.settings.isConfigured) onLoadProviders() }
|
LaunchedEffect(state.settings) { if (state.settings.isConfigured) onLoadProviders() }
|
||||||
|
|
@ -298,6 +301,7 @@ fun SettingsScreen(
|
||||||
if (state.settings.isConfigured) {
|
if (state.settings.isConfigured) {
|
||||||
if (state.isAdmin) {
|
if (state.isAdmin) {
|
||||||
item { ProviderSection(state.provider, onLoadProviders, onLoadModels, onSelectProvider) }
|
item { ProviderSection(state.provider, onLoadProviders, onLoadModels, onSelectProvider) }
|
||||||
|
item { InviteSection(state, onLoadInvites, onCreateInvite, onRevokeInvite) }
|
||||||
} else {
|
} else {
|
||||||
item {
|
item {
|
||||||
Text("LLM-Provider: " + (state.provider.active?.let { ProviderPresets.label(it.provider) + " · " + it.model } ?: "–") +
|
Text("LLM-Provider: " + (state.provider.active?.let { ProviderPresets.label(it.provider) + " · " + it.model } ?: "–") +
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ 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.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
|
||||||
|
|
@ -152,13 +153,25 @@ class PreviewScreenshots(private val dark: Boolean) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun login() = snap("login") {
|
@Test fun login() = snap("login") {
|
||||||
LoginScreen(state = UiState(isAdmin = true, settings = ServerSettings("https://demo.byteclaw.example", "", "crypto")), onAuthenticate = { _, _, _, _ -> })
|
LoginScreen(state = UiState(isAdmin = true, settings = ServerSettings("https://demo.byteclaw.example", "", "crypto")), onAuthenticate = { _, _, _, _, _ -> })
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun loginRegister() = snap("login_register") {
|
@Test fun loginRegister() = snap("login_register") {
|
||||||
LoginScreen(
|
LoginScreen(
|
||||||
state = UiState(isAdmin = true, settings = ServerSettings("https://demo.byteclaw.example"), authError = "Benutzername ist bereits vergeben"),
|
state = UiState(isAdmin = true, settings = ServerSettings("https://demo.byteclaw.example"), registrationMode = "invite", authError = "Einladungscode wurde bereits verwendet"),
|
||||||
onAuthenticate = { _, _, _, _ -> }, initialRegister = true, initialUser = "crypto",
|
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 = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 60 KiB |
|
|
@ -86,7 +86,7 @@ func run(cfgPath string, log *slog.Logger) error {
|
||||||
log.Error("invalid trusted_proxies", "err", err)
|
log.Error("invalid trusted_proxies", "err", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
srv := &server.Server{TrustedProxies: trusted, Agent: ag, Store: st, Token: cfg.AuthToken, AllowRegistration: cfg.RegistrationAllowed(), TokenTTL: time.Duration(cfg.TokenTTLHours) * time.Hour, AllowedOrigins: cfg.AllowedOrigins, Log: log, Providers: pm,
|
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}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,8 @@
|
||||||
# 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: "" # optional static admin token (>= 16 chars, sees all sessions). Generate: openssl rand -hex 32
|
||||||
allow_registration: true # users sign up via POST /api/auth/register; set false after creating your account
|
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
|
token_ttl_hours: 720 # login token lifetime
|
||||||
trusted_proxies: [] # reverse proxy IPs/CIDRs whose X-Forwarded-For is trusted (empty = loopback only)
|
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
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,8 @@ type Config struct {
|
||||||
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 enables self-service sign-up (default true).
|
||||||
AllowRegistration *bool `yaml:"allow_registration" json:"allow_registration"`
|
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 is the lifetime of login tokens (default 720 = 30 days).
|
||||||
TokenTTLHours int `yaml:"token_ttl_hours" json:"token_ttl_hours"`
|
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.
|
||||||
|
|
@ -164,6 +166,7 @@ func applyEnv(c *Config, get func(string) string) {
|
||||||
b := v == "1" || strings.EqualFold(v, "true") || strings.EqualFold(v, "yes")
|
b := v == "1" || strings.EqualFold(v, "true") || strings.EqualFold(v, "yes")
|
||||||
c.AllowRegistration = &b
|
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,12 +180,29 @@ func applyEnv(c *Config, get func(string) string) {
|
||||||
// RegistrationAllowed reports allow_registration (default true).
|
// RegistrationAllowed reports allow_registration (default true).
|
||||||
func (c Config) RegistrationAllowed() bool { return c.AllowRegistration == nil || *c.AllowRegistration }
|
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 c.AuthToken != "" && 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 at least 16 characters when set (BYTECLAW_AUTH_TOKEN)"))
|
||||||
}
|
}
|
||||||
|
if m := c.Registration(); m != "open" && m != "invite" && m != "closed" {
|
||||||
|
errs = append(errs, errors.New("registration_mode must be open, invite or closed"))
|
||||||
|
}
|
||||||
|
if c.Registration() == "invite" && c.AuthToken == "" {
|
||||||
|
errs = append(errs, errors.New("registration_mode invite needs auth_token (only the admin can create invite codes)"))
|
||||||
|
}
|
||||||
if c.LLM.BaseURL == "" || c.LLM.Model == "" {
|
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"))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -217,6 +217,7 @@ func (s *Server) auth(h http.Handler) http.Handler {
|
||||||
type credentials struct {
|
type credentials struct {
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
|
InviteCode string `json:"invite_code,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func validUsername(u string) bool {
|
func validUsername(u string) bool {
|
||||||
|
|
@ -247,7 +248,8 @@ func (s *Server) issue(w http.ResponseWriter, r *http.Request, u store.User, cod
|
||||||
|
|
||||||
func (s *Server) register(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) register(w http.ResponseWriter, r *http.Request) {
|
||||||
s.initAuth()
|
s.initAuth()
|
||||||
if !s.AllowRegistration {
|
mode := s.regMode()
|
||||||
|
if mode == "closed" {
|
||||||
writeErr(w, http.StatusForbidden, "registration disabled")
|
writeErr(w, http.StatusForbidden, "registration disabled")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -269,13 +271,24 @@ func (s *Server) register(w http.ResponseWriter, r *http.Request) {
|
||||||
writeErr(w, http.StatusBadRequest, "password must be 8-72 characters")
|
writeErr(w, http.StatusBadRequest, "password must be 8-72 characters")
|
||||||
return
|
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
|
s.limiter.Fail(ip) // every registration counts against the IP budget
|
||||||
hash, err := bcrypt.GenerateFromPassword([]byte(c.Password), bcrypt.DefaultCost)
|
hash, err := bcrypt.GenerateFromPassword([]byte(c.Password), bcrypt.DefaultCost)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.storeErr(w, err)
|
s.storeErr(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
u, err := s.Store.CreateUser(r.Context(), c.Username, string(hash))
|
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) {
|
if errors.Is(err, store.ErrUserExists) {
|
||||||
writeErr(w, http.StatusConflict, "username already taken")
|
writeErr(w, http.StatusConflict, "username already taken")
|
||||||
return
|
return
|
||||||
|
|
@ -339,3 +352,15 @@ func (s *Server) owns(w http.ResponseWriter, r *http.Request, id string) bool {
|
||||||
}
|
}
|
||||||
return true
|
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"
|
||||||
|
}
|
||||||
|
|
|
||||||
54
internal/server/invites.go
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
145
internal/server/invites_test.go
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -37,6 +37,8 @@ type Server struct {
|
||||||
Providers *providers.Manager
|
Providers *providers.Manager
|
||||||
// AllowRegistration enables POST /api/auth/register.
|
// AllowRegistration enables POST /api/auth/register.
|
||||||
AllowRegistration bool
|
AllowRegistration bool
|
||||||
|
// RegistrationMode overrides AllowRegistration: "open", "invite" or "closed".
|
||||||
|
RegistrationMode string
|
||||||
// TokenTTL is the lifetime of user tokens (default 30 days).
|
// TokenTTL is the lifetime of user tokens (default 30 days).
|
||||||
TokenTTL time.Duration
|
TokenTTL time.Duration
|
||||||
// TrustedProxies whose X-Forwarded-For is honoured (empty = loopback only).
|
// TrustedProxies whose X-Forwarded-For is honoured (empty = loopback only).
|
||||||
|
|
@ -59,10 +61,16 @@ func (s *Server) Handler() http.Handler {
|
||||||
s.initAuth()
|
s.initAuth()
|
||||||
mux.HandleFunc("POST /api/auth/register", s.register)
|
mux.HandleFunc("POST /api/auth/register", s.register)
|
||||||
mux.HandleFunc("POST /api/auth/login", s.login)
|
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("POST /api/auth/logout", s.logout)
|
||||||
api.HandleFunc("GET /api/auth/me", s.me)
|
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)
|
||||||
|
|
|
||||||
163
internal/store/invites.go
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
@ -63,6 +63,14 @@ CREATE TABLE IF NOT EXISTS auth_tokens(
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL);
|
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 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.
|
||||||
|
|
|
||||||
|
|
@ -26,12 +26,23 @@ func (u User) PasswordHash() string { return u.hash }
|
||||||
// CreateUser inserts a user. The first user ever created adopts all sessions
|
// CreateUser inserts a user. The first user ever created adopts all sessions
|
||||||
// that existed before accounts were introduced (user_id 0).
|
// that existed before accounts were introduced (user_id 0).
|
||||||
func (s *Store) CreateUser(ctx context.Context, username, passwordHash string) (User, error) {
|
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()
|
now := time.Now().UTC()
|
||||||
tx, err := s.db.BeginTx(ctx, nil)
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return User{}, err
|
return User{}, err
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
|
if code != "" {
|
||||||
|
if err := consumeInvite(ctx, tx, code, now); err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
var n int
|
var n int
|
||||||
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&n); err != nil {
|
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&n); err != nil {
|
||||||
return User{}, err
|
return User{}, err
|
||||||
|
|
@ -44,6 +55,11 @@ func (s *Store) CreateUser(ctx context.Context, username, passwordHash string) (
|
||||||
return User{}, err
|
return User{}, err
|
||||||
}
|
}
|
||||||
id, _ := res.LastInsertId()
|
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 n == 0 {
|
||||||
if _, err := tx.ExecContext(ctx, `UPDATE sessions SET user_id=? WHERE user_id=0`, id); err != nil {
|
if _, err := tx.ExecContext(ctx, `UPDATE sessions SET user_id=? WHERE user_id=0`, id); err != nil {
|
||||||
return User{}, err
|
return User{}, err
|
||||||
|
|
|
||||||
BIN
preview/invites_dark.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
preview/invites_light.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 60 KiB |
|
|
@ -1,3 +0,0 @@
|
||||||
{
|
|
||||||
"siteId": "eda745c3-d798-4dab-907e-aac0a7d94433"
|
|
||||||
}
|
|
||||||
|
|
@ -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.1.apk"
|
Content-Disposition: attachment; filename="byteclaw-0.3.2.apk"
|
||||||
|
|
|
||||||
|
|
@ -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.1/byteclaw-0.3.1-release.apk">Android-App laden · v0.3.1 (1,9 MB)</a>
|
<a class="btn btn-primary" href="https://git.jodabreit-ai.de/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-ghost" href="#quickstart">Quickstart ansehen</a>
|
<a class="btn btn-ghost" href="#quickstart">Quickstart ansehen</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -89,7 +89,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.1 · Android 8+ · signiert.</p>
|
<p class="muted">Screenshots zeigen Demo-Daten. APK v0.3.2 · 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',()=>{
|
||||||
|
|
|
||||||