add: header-auth with UI

This commit is contained in:
Tobias 2025-09-22 21:55:57 +02:00
parent e3317dd592
commit c1bb7b8971
15 changed files with 356 additions and 13 deletions

View file

@ -11,6 +11,8 @@ import io.heckel.ntfy.util.Log
import io.heckel.ntfy.util.validUrl
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicLong
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
class Repository(private val sharedPrefs: SharedPreferences, private val database: Database) {
private val subscriptionDao = database.subscriptionDao()
@ -481,6 +483,35 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas
}
}
fun getCustomHeaders(): Map<String, String> {
val json = sharedPrefs.getString(SHARED_PREFS_CUSTOM_HEADERS, null)
return if (json != null) {
try {
val type = object : TypeToken<Map<String, String>>() {}.type
Gson().fromJson(json, type) ?: emptyMap()
} catch (e: Exception) {
Log.w("Repository", "Failed to parse custom headers", e)
emptyMap()
}
} else {
emptyMap()
}
}
fun setCustomHeaders(headers: Map<String, String>) {
val json = if (headers.isEmpty()) {
null
} else {
Gson().toJson(headers)
}
if (json == null) {
sharedPrefs.edit().remove(SHARED_PREFS_CUSTOM_HEADERS).apply()
} else {
sharedPrefs.edit().putString(SHARED_PREFS_CUSTOM_HEADERS, json).apply()
}
}
private fun getState(subscriptionId: Long): ConnectionState {
return connectionStatesLiveData.value!!.getOrElse(subscriptionId) { ConnectionState.NOT_APPLICABLE }
}
@ -506,6 +537,7 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas
const val SHARED_PREFS_UNIFIED_PUSH_BASE_URL = "UnifiedPushBaseURL" // Legacy key required for migration to DefaultBaseURL
const val SHARED_PREFS_DEFAULT_BASE_URL = "DefaultBaseURL"
const val SHARED_PREFS_LAST_TOPICS = "LastTopics"
const val SHARED_PREFS_CUSTOM_HEADERS = "CustomHeaders"
private const val LAST_TOPICS_COUNT = 3

View file

@ -1,8 +1,10 @@
package io.heckel.ntfy.msg
import android.content.Context
import android.os.Build
import io.heckel.ntfy.BuildConfig
import io.heckel.ntfy.db.Notification
import io.heckel.ntfy.db.Repository
import io.heckel.ntfy.db.User
import io.heckel.ntfy.util.*
import okhttp3.*
@ -13,21 +15,26 @@ import java.nio.charset.StandardCharsets.UTF_8
import java.util.concurrent.TimeUnit
import kotlin.random.Random
class ApiService {
class ApiService(private val context: Context) {
private val repository = Repository.getInstance(context)
private val client = OkHttpClient.Builder()
.callTimeout(15, TimeUnit.SECONDS) // Total timeout for entire request
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.writeTimeout(15, TimeUnit.SECONDS)
.addInterceptor(CustomHeadersInterceptor(repository))
.build()
private val publishClient = OkHttpClient.Builder()
.callTimeout(5, TimeUnit.MINUTES) // Total timeout for entire request
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.writeTimeout(15, TimeUnit.SECONDS)
.addInterceptor(CustomHeadersInterceptor(repository))
.build()
private val subscriberClient = OkHttpClient.Builder()
.readTimeout(77, TimeUnit.SECONDS) // Assuming that keepalive messages are more frequent than this
.addInterceptor(CustomHeadersInterceptor(repository))
.build()
private val parser = NotificationParser()
@ -165,6 +172,29 @@ class ApiService {
}
}
/**
* Interceptor that adds custom headers to all HTTP requests
*/
private class CustomHeadersInterceptor(private val repository: Repository) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
val customHeaders = repository.getCustomHeaders()
// If no custom headers, proceed with original request
if (customHeaders.isEmpty()) {
return chain.proceed(originalRequest)
}
// Add custom headers to the request
val requestBuilder = originalRequest.newBuilder()
customHeaders.forEach { (name, value) ->
requestBuilder.addHeader(name, value)
}
return chain.proceed(requestBuilder.build())
}
}
class UnauthorizedException(val user: User?) : Exception()
class EntityTooLargeException : Exception()
@ -188,4 +218,4 @@ class ApiService {
return builder
}
}
}
}

View file

@ -68,7 +68,7 @@ class BroadcastService(private val ctx: Context) {
}
private fun send(ctx: Context, intent: Intent) {
val api = ApiService()
val api = ApiService(ctx)
val baseUrl = getStringExtra(intent, "base_url") ?: ctx.getString(R.string.app_base_url)
val topic = getStringExtra(intent, "topic") ?: return
val message = getStringExtra(intent, "message") ?: return
@ -128,4 +128,4 @@ class BroadcastService(private val ctx: Context) {
private const val MESSAGE_SEND_ACTION = "io.heckel.ntfy.SEND_MESSAGE"
private const val USER_ACTION_ACTION = "io.heckel.ntfy.USER_ACTION"
}
}
}

View file

@ -0,0 +1,198 @@
package io.heckel.ntfy.ui
import android.os.Bundle
import android.view.LayoutInflater
import android.widget.EditText
import androidx.appcompat.app.AlertDialog
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import com.google.android.material.textfield.TextInputEditText
import io.heckel.ntfy.R
import io.heckel.ntfy.db.Repository
import io.heckel.ntfy.util.Log
class CustomHeadersFragment : PreferenceFragmentCompat() {
private lateinit var repository: Repository
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.custom_headers_preferences, rootKey)
repository = Repository.getInstance(requireContext())
setupAddHeaderPreference()
loadCustomHeaders()
}
private fun setupAddHeaderPreference() {
findPreference<Preference>("add_custom_header")?.setOnPreferenceClickListener {
showAddHeaderDialog()
true
}
}
private fun loadCustomHeaders() {
val customHeaders = repository.getCustomHeaders()
val preferenceScreen = preferenceScreen
// Remove existing header preferences (keep only the "Add Header" preference)
val preferencesToRemove = mutableListOf<Preference>()
for (i in 0 until preferenceScreen.preferenceCount) {
val preference = preferenceScreen.getPreference(i)
if (preference.key != "add_custom_header") {
preferencesToRemove.add(preference)
}
}
preferencesToRemove.forEach { preferenceScreen.removePreference(it) }
// Add preferences for each custom header
customHeaders.forEach { (name, value) ->
val headerPreference = Preference(requireContext()).apply {
key = "header_$name"
title = name
summary = redactHeaderValue(value) // Show redacted value
setOnPreferenceClickListener {
showEditHeaderDialog(name, value)
true
}
}
preferenceScreen.addPreference(headerPreference)
}
}
private fun showAddHeaderDialog() {
val dialogView = LayoutInflater.from(requireContext())
.inflate(R.layout.dialog_custom_header, null)
val headerNameEdit = dialogView.findViewById<TextInputEditText>(R.id.header_name)
val headerValueEdit = dialogView.findViewById<TextInputEditText>(R.id.header_value)
AlertDialog.Builder(requireContext())
.setTitle(R.string.custom_headers_add_title)
.setView(dialogView)
.setPositiveButton(R.string.custom_headers_add) { _, _ ->
val name = headerNameEdit.text.toString().trim()
val value = headerValueEdit.text.toString().trim()
if (validateHeaderName(name)) {
addCustomHeader(name, value)
} else {
showInvalidHeaderDialog()
}
}
.setNegativeButton(R.string.user_dialog_button_cancel, null)
.show()
}
private fun showEditHeaderDialog(originalName: String, originalValue: String) {
val dialogView = LayoutInflater.from(requireContext())
.inflate(R.layout.dialog_custom_header, null)
val headerNameEdit = dialogView.findViewById<TextInputEditText>(R.id.header_name)
val headerValueEdit = dialogView.findViewById<TextInputEditText>(R.id.header_value)
headerNameEdit.setText(originalName)
headerValueEdit.setText(originalValue)
AlertDialog.Builder(requireContext())
.setTitle(R.string.custom_headers_edit_title)
.setView(dialogView)
.setPositiveButton(R.string.custom_headers_save) { _, _ ->
val name = headerNameEdit.text.toString().trim()
val value = headerValueEdit.text.toString().trim()
if (validateHeaderName(name)) {
updateCustomHeader(originalName, name, value)
} else {
showInvalidHeaderDialog()
}
}
.setNeutralButton(R.string.custom_headers_delete) { _, _ ->
showDeleteHeaderDialog(originalName)
}
.setNegativeButton(R.string.user_dialog_button_cancel, null)
.show()
}
private fun showDeleteHeaderDialog(headerName: String) {
AlertDialog.Builder(requireContext())
.setTitle(R.string.custom_headers_delete_title)
.setMessage(getString(R.string.custom_headers_delete_message, headerName))
.setPositiveButton(R.string.custom_headers_delete) { _, _ ->
deleteCustomHeader(headerName)
}
.setNegativeButton(R.string.user_dialog_button_cancel, null)
.show()
}
private fun showInvalidHeaderDialog() {
AlertDialog.Builder(requireContext())
.setTitle(R.string.custom_headers_error_title)
.setMessage(R.string.custom_headers_invalid_name)
.setPositiveButton(android.R.string.ok, null)
.show()
}
private fun validateHeaderName(name: String): Boolean {
if (name.isEmpty()) return false
// HTTP header names should only contain ASCII letters, digits, and hyphens
// and must not start or end with hyphens
val regex = Regex("^[A-Za-z0-9][A-Za-z0-9\\-]*[A-Za-z0-9]$|^[A-Za-z0-9]$")
return regex.matches(name)
}
private fun addCustomHeader(name: String, value: String) {
try {
val currentHeaders = repository.getCustomHeaders().toMutableMap()
currentHeaders[name] = value
repository.setCustomHeaders(currentHeaders)
loadCustomHeaders() // Refresh the UI
Log.d(TAG, "Added custom header: $name")
} catch (e: Exception) {
Log.w(TAG, "Failed to add custom header", e)
}
}
private fun updateCustomHeader(originalName: String, newName: String, newValue: String) {
try {
val currentHeaders = repository.getCustomHeaders().toMutableMap()
// Remove the old header if name changed
if (originalName != newName) {
currentHeaders.remove(originalName)
}
// Add/update the header
currentHeaders[newName] = newValue
repository.setCustomHeaders(currentHeaders)
loadCustomHeaders() // Refresh the UI
Log.d(TAG, "Updated custom header: $originalName -> $newName")
} catch (e: Exception) {
Log.w(TAG, "Failed to update custom header", e)
}
}
private fun deleteCustomHeader(name: String) {
try {
val currentHeaders = repository.getCustomHeaders().toMutableMap()
currentHeaders.remove(name)
repository.setCustomHeaders(currentHeaders)
loadCustomHeaders() // Refresh the UI
Log.d(TAG, "Deleted custom header: $name")
} catch (e: Exception) {
Log.w(TAG, "Failed to delete custom header", e)
}
}
private fun redactHeaderValue(value: String): String {
return when {
value.isEmpty() -> "(empty)"
value.length <= 3 -> "".repeat(value.length)
else -> "".repeat(8) // Always show 8 dots for longer values
}
}
companion object {
private const val TAG = "CustomHeadersFragment"
}
}

View file

@ -64,7 +64,7 @@ class SubscriberService : Service() {
private val repository by lazy { (application as Application).repository }
private val dispatcher by lazy { NotificationDispatcher(this, repository) }
private val connections = ConcurrentHashMap<ConnectionId, Connection>()
private val api = ApiService()
private val api by lazy { ApiService(this) }
private var notificationManager: NotificationManager? = null
private var serviceNotification: Notification? = null
private val refreshMutex = Mutex() // Ensure refreshConnections() is only run one at a time
@ -377,4 +377,4 @@ class SubscriberService : Service() {
return ServiceState.valueOf(value!!)
}
}
}
}

View file

@ -23,7 +23,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class AddFragment : DialogFragment() {
private val api = ApiService()
private val api by lazy { ApiService(requireContext()) }
private lateinit var repository: Repository
private lateinit var subscribeListener: SubscribeListener
@ -435,4 +435,4 @@ class AddFragment : DialogFragment() {
const val TAG = "NtfyAddFragment"
private val DISALLOWED_TOPICS = listOf("docs", "static", "file") // If updated, also update in server
}
}
}

View file

@ -46,7 +46,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra
DetailViewModelFactory((application as Application).repository)
}
private val repository by lazy { (application as Application).repository }
private val api = ApiService()
private val api by lazy { ApiService(this) }
private val messenger = FirebaseMessenger()
private var notifier: NotificationService? = null // Context-dependent
private var appBaseUrl: String? = null // Context-dependent

View file

@ -58,7 +58,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
SubscriptionsViewModelFactory((application as Application).repository)
}
private val repository by lazy { (application as Application).repository }
private val api = ApiService()
private val api by lazy { ApiService(this) }
private val messenger = FirebaseMessenger()
// UI elements

View file

@ -21,7 +21,7 @@ import kotlinx.coroutines.launch
class ShareActivity : AppCompatActivity() {
private val repository by lazy { (application as Application).repository }
private val api = ApiService()
private val api by lazy { ApiService(this) }
// File to share
private var fileUri: Uri? = null

View file

@ -26,7 +26,7 @@ class PollWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx,
Log.d(TAG, "Polling for new notifications")
val repository = Repository.getInstance(applicationContext)
val dispatcher = NotificationDispatcher(applicationContext, repository)
val api = ApiService()
val api = ApiService(applicationContext) // FIXED: Pass context parameter
val baseUrl = inputData.getString(INPUT_DATA_BASE_URL)
val topic = inputData.getString(INPUT_DATA_TOPIC)
@ -72,4 +72,4 @@ class PollWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx,
const val INPUT_DATA_BASE_URL = "baseUrl"
const val INPUT_DATA_TOPIC = "topic"
}
}
}

View file

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/custom_headers_name_hint">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/header_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text"
android:singleLine="true" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:hint="@string/custom_headers_value_hint">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/header_value"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text"
android:singleLine="true" />
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>

View file

@ -350,4 +350,20 @@
<string name="settings_advanced_exact_alarms_title">Genaue Alarme</string>
<string name="settings_advanced_exact_alarms_true">ntfy kann genaue Alarme planen. Um WebSockets im Hintergrund wieder zu verbinden, sind genaue Alarme erforderlich. Hier tippen, um die Berechtigung zu widerrufen.</string>
<string name="settings_advanced_exact_alarms_false">ntfy kann keine genauen Alarme planen. Um WebSockets im Hintergrund wieder zu verbinden, sind genaue Alarme erforderlich. Tippe hier, um die Berechtigung zu erteilen.</string>
<string name="settings_advanced_custom_headers_key">BenutzerdefinierteHeader</string>
<string name="settings_advanced_custom_headers_title">Benutzerdefinierte Header</string>
<string name="settings_advanced_custom_headers_summary">Benutzerdefinierte HTTP-Header zu allen Anfragen hinzufügen</string>
<string name="custom_headers_add_header">Header hinzufügen</string>
<string name="custom_headers_add_summary">Neuen benutzerdefinierten HTTP-Header hinzufügen</string>
<string name="custom_headers_add_title">Neuen Header hinzufügen</string>
<string name="custom_headers_edit_title">Header bearbeiten</string>
<string name="custom_headers_delete_title">Header löschen</string>
<string name="custom_headers_delete_message">Sind Sie sicher, dass Sie den Header „%1$s“ löschen möchten?</string>
<string name="custom_headers_add">Hinzufügen</string>
<string name="custom_headers_save">Speichern</string>
<string name="custom_headers_delete">Löschen</string>
<string name="custom_headers_error_title">Ungültiger Header</string>
<string name="custom_headers_invalid_name">Ungültige Zeichen im Header-Namen</string>
<string name="custom_headers_name_hint">Header-Name (z. B. CF-Access-Client-Id)</string>
<string name="custom_headers_value_hint">Header-Wert</string>
</resources>

View file

@ -356,6 +356,9 @@
<string name="settings_advanced_exact_alarms_title">Exact alarms</string>
<string name="settings_advanced_exact_alarms_true">ntfy can schedule exact alarms. Exact alarms are required to reconnect WebSockets in the background. Click to revoke the permission.</string>
<string name="settings_advanced_exact_alarms_false">ntfy cannot schedule exact alarms. Exact alarms are required to reconnect WebSockets in the background. Click to grant the permission.</string>
<string name="settings_advanced_custom_headers_key">CustomHeaders</string>
<string name="settings_advanced_custom_headers_title">Custom Headers</string>
<string name="settings_advanced_custom_headers_summary">Add custom HTTP headers to all requests</string>
<string name="settings_about_header">About</string>
<string name="settings_about_version_title">Version</string>
<string name="settings_about_version_format">ntfy %1$s (%2$s)</string>
@ -400,4 +403,19 @@
<string name="user_dialog_button_cancel">Cancel</string>
<string name="user_dialog_button_delete">Delete user</string>
<string name="user_dialog_button_save">Save</string>
<!-- Custom Headers dialog -->
<string name="custom_headers_add_header">Add Header</string>
<string name="custom_headers_add_summary">Add a new custom HTTP header</string>
<string name="custom_headers_add_title">Add Custom Header</string>
<string name="custom_headers_edit_title">Edit Custom Header</string>
<string name="custom_headers_delete_title">Delete Header</string>
<string name="custom_headers_delete_message">Are you sure you want to delete the header "%1$s"?</string>
<string name="custom_headers_add">Add</string>
<string name="custom_headers_save">Save</string>
<string name="custom_headers_delete">Delete</string>
<string name="custom_headers_error_title">Invalid Header</string>
<string name="custom_headers_invalid_name">Header name contains invalid characters</string>
<string name="custom_headers_name_hint">Header name (e.g., CF-Access-Client-Id)</string>
<string name="custom_headers_value_hint">Header value</string>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:app="http://schemas.android.com/apk/res-auto">
<Preference
app:key="add_custom_header"
app:title="@string/custom_headers_add_header"
app:summary="@string/custom_headers_add_summary" />
</PreferenceScreen>

View file

@ -98,6 +98,11 @@
app:key="@string/settings_advanced_clear_logs_key"
app:title="@string/settings_advanced_clear_logs_title"
app:summary="@string/settings_advanced_clear_logs_summary"/>
<Preference
app:key="@string/settings_advanced_custom_headers_key"
app:title="@string/settings_advanced_custom_headers_title"
app:summary="@string/settings_advanced_custom_headers_summary"
app:fragment="io.heckel.ntfy.ui.CustomHeadersFragment" />
</PreferenceCategory>
<PreferenceCategory app:title="@string/settings_about_header">
<Preference