Skip to content

Commit 7281c33

Browse files
authored
feat: optional Quire server URL override for split deployments (#66)
* feat(auth): add optional quireServerUrl to Basic credentials * feat(auth): persist optional quireServerUrl on Basic accounts * feat(opds): allow auth header on Basic.quireServerUrl override host * feat(app): route sync/library/AI clients through quireServerUrl override * feat(settings): expose optional Quire server URL field * chore(settings): drop unused CalibreCredentials import
1 parent 10b8353 commit 7281c33

8 files changed

Lines changed: 250 additions & 14 deletions

File tree

app/src/main/java/io/theficos/ereader/di/AppContainer.kt

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,15 @@ import kotlinx.coroutines.launch
5151
import kotlinx.coroutines.withContext
5252
import io.theficos.ereader.data.local.db.ProgressDao
5353

54+
/**
55+
* URL that sync/library/AI clients should target. For [AccountCredentials.Basic]
56+
* with a [AccountCredentials.Basic.quireServerUrl] override, return the
57+
* override; otherwise fall back to [AccountCredentials.baseUrl]. Bearer
58+
* accounts have no override in tier-1.
59+
*/
60+
private fun io.theficos.ereader.auth.AccountCredentials.quireServerOrPrimaryUrl(): String =
61+
(this as? io.theficos.ereader.auth.AccountCredentials.Basic)?.quireServerUrl ?: baseUrl
62+
5463
class AppContainer(context: Context) {
5564
private val appContext = context.applicationContext
5665

@@ -80,7 +89,7 @@ class AppContainer(context: Context) {
8089
val welcomePreferencesStore = WelcomePreferencesStore(appContext)
8190

8291
val syncClient: SyncClient = SyncClient(
83-
baseUrlProvider = { credentialStore.getAccount()?.baseUrl },
92+
baseUrlProvider = { credentialStore.getAccount()?.quireServerOrPrimaryUrl() },
8493
okHttp = opdsHttp.okHttp,
8594
)
8695
val syncOrchestrator: SyncOrchestrator = SyncOrchestrator(
@@ -92,7 +101,7 @@ class AppContainer(context: Context) {
92101
)
93102

94103
val aiClient: AiClient = AiClient(
95-
baseUrlProvider = { credentialStore.getAccount()?.baseUrl },
104+
baseUrlProvider = { credentialStore.getAccount()?.quireServerOrPrimaryUrl() },
96105
http = opdsHttp.okHttp,
97106
)
98107
val insightDao = db.insightDao()
@@ -117,7 +126,7 @@ class AppContainer(context: Context) {
117126
credentialStore.getAccount()?.subject
118127

119128
val libraryClient: LibraryClient = LibraryClient(
120-
baseUrlProvider = { credentialStore.getAccount()?.baseUrl },
129+
baseUrlProvider = { credentialStore.getAccount()?.quireServerOrPrimaryUrl() },
121130
http = opdsHttp.okHttp,
122131
)
123132

app/src/main/java/io/theficos/ereader/ui/settings/SettingsScreen.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,16 @@ fun SettingsScreen(
119119
visualTransformation = PasswordVisualTransformation(),
120120
modifier = Modifier.fillMaxWidth(),
121121
)
122+
OutlinedTextField(
123+
value = calibre.quireServerUrl,
124+
onValueChange = viewModel::onQuireServerUrlChange,
125+
label = { Text("Quire server URL (optional)") },
126+
placeholder = { Text("Same as calibre-web URL") },
127+
supportingText = {
128+
Text("Only set this if sync / AI run at a different address than your books.")
129+
},
130+
modifier = Modifier.fillMaxWidth(),
131+
)
122132
Button(
123133
onClick = viewModel::saveCalibre,
124134
enabled = calibre.baseUrl.isNotBlank() && calibre.username.isNotBlank() && calibre.password.isNotBlank(),

app/src/main/java/io/theficos/ereader/ui/settings/SettingsViewModel.kt

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import android.content.Context
44
import androidx.lifecycle.ViewModel
55
import androidx.lifecycle.viewModelScope
66
import io.theficos.ereader.auth.CalibreCredentialStore
7-
import io.theficos.ereader.auth.CalibreCredentials
87
import io.theficos.ereader.data.ai.AiConfig
98
import io.theficos.ereader.data.ai.AiHealthResponse
109
import io.theficos.ereader.data.ai.AiPreferences
@@ -126,24 +125,33 @@ class SettingsViewModel(
126125
}
127126

128127
private fun loadInitialCalibre(): CalibreUiState {
129-
val creds = store.get()
128+
val account = store.getAccount() as? io.theficos.ereader.auth.AccountCredentials.Basic
130129
return CalibreUiState(
131-
baseUrl = creds?.baseUrl.orEmpty(),
132-
username = creds?.username.orEmpty(),
133-
password = creds?.password.orEmpty(),
134-
saved = creds != null,
130+
baseUrl = account?.baseUrl.orEmpty(),
131+
username = account?.username.orEmpty(),
132+
password = account?.password.orEmpty(),
133+
quireServerUrl = account?.quireServerUrl.orEmpty(),
134+
saved = account != null,
135135
)
136136
}
137137

138138
fun onBaseUrlChange(value: String) { _calibre.value = _calibre.value.copy(baseUrl = value, saved = false) }
139139
fun onUsernameChange(value: String) { _calibre.value = _calibre.value.copy(username = value, saved = false) }
140140
fun onPasswordChange(value: String) { _calibre.value = _calibre.value.copy(password = value, saved = false) }
141+
fun onQuireServerUrlChange(value: String) {
142+
_calibre.value = _calibre.value.copy(quireServerUrl = value, saved = false)
143+
}
141144

142145
fun saveCalibre() {
143146
val s = _calibre.value
144147
if (s.baseUrl.isBlank() || s.username.isBlank() || s.password.isBlank()) return
145148
viewModelScope.launch {
146-
store.put(CalibreCredentials(s.baseUrl.trim().trimEnd('/'), s.username, s.password))
149+
store.saveBasicAccount(
150+
baseUrl = s.baseUrl.trim().trimEnd('/'),
151+
username = s.username,
152+
password = s.password,
153+
quireServerUrl = s.quireServerUrl.trim().trimEnd('/').takeIf { it.isNotBlank() },
154+
)
147155
_calibre.value = s.copy(saved = true)
148156
_sync.value = _sync.value.copy(hasCredentials = true)
149157
}
@@ -242,5 +250,6 @@ data class CalibreUiState(
242250
val baseUrl: String,
243251
val username: String,
244252
val password: String,
253+
val quireServerUrl: String,
245254
val saved: Boolean,
246255
)

auth/src/main/java/io/theficos/ereader/auth/AccountCredentials.kt

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,15 +28,29 @@ sealed class AccountCredentials {
2828
*/
2929
abstract val subject: String
3030

31+
/**
32+
* Calibre-web style credentials (HTTP Basic).
33+
*
34+
* [quireServerUrl] optionally routes sync/library/AI calls to a different
35+
* host than the OPDS catalog at [baseUrl]. Null means "use [baseUrl] for
36+
* everything" (the common single-URL deployment). When set, both URLs
37+
* share the same Basic credential — the operator is responsible for
38+
* configuring quire-server's `QUIRE_SERVER_CWA_BASE_URL` to validate
39+
* incoming Basic headers against the same calibre-web instance.
40+
*
41+
* See `docs/superpowers/split-server-urls.md` for the tiered scope and
42+
* the deferred BEARER + separate-calibre-web case.
43+
*/
3144
data class Basic(
3245
override val baseUrl: String,
3346
val username: String,
3447
val password: String,
48+
val quireServerUrl: String? = null,
3549
) : AccountCredentials() {
3650
override val scheme: AuthScheme = AuthScheme.BASIC
3751
override val subject: String get() = username.lowercase()
3852
override fun toString(): String =
39-
"AccountCredentials.Basic(baseUrl=$baseUrl, username=$username, password=***)"
53+
"AccountCredentials.Basic(baseUrl=$baseUrl, username=$username, password=***, quireServerUrl=$quireServerUrl)"
4054
}
4155

4256
data class Bearer(

auth/src/main/java/io/theficos/ereader/auth/CalibreCredentialStore.kt

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,15 +91,26 @@ class CalibreCredentialStore(context: Context) {
9191
* Save a calibre-web style account (HTTP Basic). Replaces any
9292
* previously-stored account regardless of scheme. Blank values are
9393
* rejected at the API boundary.
94+
*
95+
* [quireServerUrl] is an optional override that routes sync/library/AI
96+
* calls to a different host than the OPDS catalog at [baseUrl]. Pass
97+
* null (or blank) to use [baseUrl] for everything. Tier-1 scope; see
98+
* `docs/superpowers/split-server-urls.md`.
9499
*/
95-
fun saveBasicAccount(baseUrl: String, username: String, password: String) {
100+
fun saveBasicAccount(
101+
baseUrl: String,
102+
username: String,
103+
password: String,
104+
quireServerUrl: String? = null,
105+
) {
96106
require(baseUrl.isNotBlank()) { "baseUrl must not be blank" }
97107
require(username.isNotBlank()) { "username must not be blank" }
98108
require(password.isNotBlank()) { "password must not be blank" }
99109
val account = AccountCredentials.Basic(
100110
baseUrl = baseUrl.trim().trimEnd('/'),
101111
username = username,
102112
password = password,
113+
quireServerUrl = quireServerUrl?.trim()?.trimEnd('/')?.takeIf { it.isNotBlank() },
103114
)
104115
persistAccount(account)
105116
}
@@ -151,13 +162,19 @@ class CalibreCredentialStore(context: Context) {
151162
.putString(KEY_PASS, account.password)
152163
.remove(KEY_EMAIL)
153164
.remove(KEY_TOKEN)
165+
if (account.quireServerUrl != null) {
166+
editor.putString(KEY_QUIRE_SERVER_URL, account.quireServerUrl)
167+
} else {
168+
editor.remove(KEY_QUIRE_SERVER_URL)
169+
}
154170
}
155171
is AccountCredentials.Bearer -> {
156172
editor
157173
.putString(KEY_EMAIL, account.email)
158174
.putString(KEY_TOKEN, account.token)
159175
.remove(KEY_USER)
160176
.remove(KEY_PASS)
177+
.remove(KEY_QUIRE_SERVER_URL)
161178
}
162179
}
163180
val committed = editor.commit()
@@ -204,7 +221,8 @@ class CalibreCredentialStore(context: Context) {
204221
AuthScheme.BASIC -> {
205222
val user = prefs.getString(KEY_USER, null) ?: return null
206223
val pass = prefs.getString(KEY_PASS, null) ?: return null
207-
AccountCredentials.Basic(baseUrl, user, pass)
224+
val quireServerUrl = prefs.getString(KEY_QUIRE_SERVER_URL, null)
225+
AccountCredentials.Basic(baseUrl, user, pass, quireServerUrl)
208226
}
209227
AuthScheme.BEARER -> {
210228
val email = prefs.getString(KEY_EMAIL, null) ?: return null
@@ -227,5 +245,6 @@ class CalibreCredentialStore(context: Context) {
227245
const val KEY_PASS = "password"
228246
const val KEY_EMAIL = "email"
229247
const val KEY_TOKEN = "token"
248+
const val KEY_QUIRE_SERVER_URL = "quire_server_url"
230249
}
231250
}

auth/src/test/java/io/theficos/ereader/auth/CalibreCredentialStoreTest.kt

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,4 +202,85 @@ class CalibreCredentialStoreTest {
202202
val bearer = AccountCredentials.Bearer("https://cloud", "a@b", "tok_secret_xyz")
203203
assertThat(bearer.toString()).doesNotContain("tok_secret_xyz")
204204
}
205+
206+
// ---------- Tier 1: optional Quire server URL override ----------
207+
208+
@Test fun `saveBasicAccount persists optional quireServerUrl`() {
209+
val store = CalibreCredentialStore(ApplicationProvider.getApplicationContext())
210+
store.clear()
211+
store.saveBasicAccount(
212+
baseUrl = "https://lib.example",
213+
username = "alice",
214+
password = "s3cret",
215+
quireServerUrl = "https://quire.example",
216+
)
217+
val basic = store.getAccount() as AccountCredentials.Basic
218+
assertThat(basic.quireServerUrl).isEqualTo("https://quire.example")
219+
// Survives a fresh load (round-trips through EncryptedSharedPreferences).
220+
val reread = CalibreCredentialStore(ApplicationProvider.getApplicationContext())
221+
val rereadBasic = reread.getAccount() as AccountCredentials.Basic
222+
assertThat(rereadBasic.quireServerUrl).isEqualTo("https://quire.example")
223+
}
224+
225+
@Test fun `saveBasicAccount without quireServerUrl stores null`() {
226+
val store = CalibreCredentialStore(ApplicationProvider.getApplicationContext())
227+
store.clear()
228+
store.saveBasicAccount("https://lib.example", "alice", "s3cret")
229+
val basic = store.getAccount() as AccountCredentials.Basic
230+
assertThat(basic.quireServerUrl).isNull()
231+
}
232+
233+
@Test fun `saveBasicAccount canonicalizes quireServerUrl trailing slash`() {
234+
val store = CalibreCredentialStore(ApplicationProvider.getApplicationContext())
235+
store.clear()
236+
store.saveBasicAccount(
237+
baseUrl = "https://lib.example",
238+
username = "alice",
239+
password = "s3cret",
240+
quireServerUrl = " https://quire.example/ ",
241+
)
242+
val basic = store.getAccount() as AccountCredentials.Basic
243+
assertThat(basic.quireServerUrl).isEqualTo("https://quire.example")
244+
}
245+
246+
@Test fun `blank quireServerUrl persists as null`() {
247+
// Settings will pass "" when the user clears the field; treat that
248+
// as "no override" rather than persisting an empty string.
249+
val store = CalibreCredentialStore(ApplicationProvider.getApplicationContext())
250+
store.clear()
251+
store.saveBasicAccount(
252+
baseUrl = "https://lib.example",
253+
username = "alice",
254+
password = "s3cret",
255+
quireServerUrl = " ",
256+
)
257+
val basic = store.getAccount() as AccountCredentials.Basic
258+
assertThat(basic.quireServerUrl).isNull()
259+
}
260+
261+
@Test fun `switch to bearer removes quireServerUrl key`() {
262+
val store = CalibreCredentialStore(ApplicationProvider.getApplicationContext())
263+
store.clear()
264+
store.saveBasicAccount("https://lib.example", "alice", "s3cret", "https://quire.example")
265+
store.saveBearerAccount("https://cloud.quire.app", "alice@example.com", "tok_abc")
266+
// Re-read from a fresh store. The override key must be gone — a future
267+
// switch back to BASIC without an override must NOT resurrect it.
268+
val reread = CalibreCredentialStore(ApplicationProvider.getApplicationContext())
269+
assertThat(reread.getAccount()).isInstanceOf(AccountCredentials.Bearer::class.java)
270+
reread.saveBasicAccount("https://lib.example", "alice", "new-pw")
271+
val basic = reread.getAccount() as AccountCredentials.Basic
272+
assertThat(basic.quireServerUrl).isNull()
273+
}
274+
275+
@Test fun `pre-tier-1 record without quireServerUrl key loads as null`() {
276+
// Seed a BASIC account via the older 3-arg API (no override key written).
277+
val seed = CalibreCredentialStore(ApplicationProvider.getApplicationContext())
278+
seed.clear()
279+
seed.saveBasicAccount("https://lib.example", "alice", "s3cret")
280+
// Fresh store load — quireServerUrl must surface as null, not as an
281+
// empty string or a default-baseUrl fallback.
282+
val reread = CalibreCredentialStore(ApplicationProvider.getApplicationContext())
283+
val basic = reread.getAccount() as AccountCredentials.Basic
284+
assertThat(basic.quireServerUrl).isNull()
285+
}
205286
}

data/opds/src/main/java/io/theficos/ereader/data/opds/AccountAuthInterceptor.kt

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ class AccountAuthInterceptor(
4545
return chain.proceed(request)
4646
}
4747
val account = accountProvider() ?: return chain.proceed(request)
48-
if (!sameOrigin(account.baseUrl, request.url)) {
48+
if (!isAllowedOrigin(account, request.url)) {
4949
return chain.proceed(request)
5050
}
5151
val headerValue = when (account) {
@@ -57,6 +57,21 @@ class AccountAuthInterceptor(
5757
)
5858
}
5959

60+
/**
61+
* Returns true when [requestUrl] targets either the primary [baseUrl] OR,
62+
* for a [AccountCredentials.Basic] with an override, its [quireServerUrl].
63+
* Bearer accounts have no override in tier-1; their check stays
64+
* single-URL.
65+
*/
66+
private fun isAllowedOrigin(
67+
account: AccountCredentials,
68+
requestUrl: HttpUrl,
69+
): Boolean {
70+
if (sameOrigin(account.baseUrl, requestUrl)) return true
71+
val override = (account as? AccountCredentials.Basic)?.quireServerUrl
72+
return override != null && sameOrigin(override, requestUrl)
73+
}
74+
6075
private fun basicHeader(account: AccountCredentials.Basic): String {
6176
val raw = "${account.username}:${account.password}"
6277
val encoded = Base64.getEncoder().encodeToString(raw.toByteArray())

0 commit comments

Comments
 (0)