diff --git a/.gitignore b/.gitignore
index cc68ad44b..1e688b208 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,12 +1,35 @@
.idea
-.env*
-!.env.docker
-!.env.production
*serviceAccountKey.json
android/app/debug/
*main.exe*
android/app/release/
+# Kernel core dumps (created on crash, can be hundreds of MB)
+core
+core.[0-9]*
+
+# production
+/build
+
+# misc
+.DS_Store
+*.pem
+
+# debug
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+.pnpm-debug.log*
+.DS_Store
+
+# local env files
+.env
+.env.local
+.env.development.local
+.env.test.local
+.env.production
+.env.production.local
+
tests/firebase-credentials.json
tests/certs/
tests/emulator/emulator.exe
@@ -17,5 +40,46 @@ SECURITY_AUDIT_REPORT.md
.output
.agents/
skills-lock.json
+
+/logs
+/external_services
+
+# --- CUSTOM (radata fork): deployment tooling ---------------------------------
+# THIS REPO IS PUBLIC (fork of NdoleStudio/httpsms). Nothing describing how or
+# where we deploy belongs in it.
+#
+# ops/ is NOT tracked: the build and deploy scripts name the bastion host, the
+# ssh port, the login user, the server paths and the release layout. They live
+# only on disk and on the deploy host. Release tarballs under ops/tmp/ are also
+# ~50MB each, which is reason enough on its own.
+**/ops/
+# files parked during the api/web split
+*.superseded
+# The real api dev stack: names the bastion host, ssh port, login user and the
+# private key path. api/docker-compose.example.yml is the sanitised template
+# that ships instead.
+api/docker-compose.yml
+# Our Firebase config: project id, app id, oauth client and the Android API
+# key. Upstream TRACKS this file, so it was inherited tracked and .gitignore
+# alone could never have held it back — it needed git rm --cached, which is
+# why it reached GitHub twice. Keep it local; the build fails loudly without
+# it and android/scripts/build-release.sh already gates on it.
+android/app/google-services.json
+
+# Working notes — investigation write-ups, open questions, things parked for
+# later. Local thinking, not documentation, and it names this deployment's hosts
+# and Firebase project in passing. It reached a commit once (1d1757f, reset
+# before it was pushed); this is what stops the next one.
+__to_do/
+
+
+# vercel
+.vercel
+
+# typescript
+*.tsbuildinfo
+next-env.d.ts
+
+# upstream
.worktrees/
docs/superpowers
diff --git a/android/.gitignore b/android/.gitignore
index 6c13541ca..5597988d9 100644
--- a/android/.gitignore
+++ b/android/.gitignore
@@ -14,3 +14,16 @@
.cxx
.kotlin/sessions/
local.properties
+
+# Release signing. keystore.properties holds the keystore password in the
+# clear and *.jks is the signing key itself — neither ever goes to git.
+/keystore.properties
+*.jks
+*.keystore
+.DS_Store
+
+# Kernel core dumps (created on crash, can be hundreds of MB).
+# The dev container now runs with `ulimit -c 0`, so these should stop
+# appearing - the pattern stays for anything already on disk elsewhere.
+core
+core.[0-9]*
\ No newline at end of file
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index f670b8bb7..8c141a606 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -1,3 +1,5 @@
+import java.util.Properties
+
plugins {
id("com.android.application")
id("com.google.gms.google-services")
@@ -5,28 +7,77 @@ plugins {
id("org.jetbrains.kotlin.plugin.compose")
}
-val gitHash = providers.exec {
- commandLine("git", "rev-parse", "--short", "HEAD")
-}.standardOutput.asText.map { it.trim() }
+// Release signing credentials. They live outside git, in one of two places:
+// keystore.properties beside this module (see .gitignore) on a machine that
+// keeps the password on disk, or the HW_SMS_* environment variables on one
+// that keeps it in a password manager. The file wins where both exist.
+val keystoreProps = Properties().apply {
+ val f = rootProject.file("keystore.properties")
+ if (f.exists()) f.inputStream().use { load(it) }
+}
+
+fun signingProp(key: String, env: String): String? =
+ keystoreProps.getProperty(key) ?: System.getenv(env)
+
+// App version, hand-bumped in version.properties. Kept out of this file so the
+// number sits somewhere obvious rather than buried in a build script, and so a
+// version bump is a one-line diff that reviews cleanly.
+val appVersion = Properties().apply {
+ rootProject.file("version.properties").inputStream().use { load(it) }
+}.getProperty("versionCode").trim().toInt()
android {
compileSdk = 37
defaultConfig {
- applicationId = "com.httpsms"
+ // CUSTOM: was "com.httpsms" (upstream's). This MUST equal a
+ // package_name in app/google-services.json or the google-services gradle
+ // plugin fails the build with "No matching client found for package
+ // name". The Firebase app registered for this deployment is
+ // nl.hollandworx.sms.
+ //
+ // Only applicationId changes. `namespace` below stays com.httpsms: that
+ // one is the package for the generated R and BuildConfig classes and has
+ // to match the Kotlin source tree (com/httpsms/...), which Firebase does
+ // not care about. Android supports the two differing.
+ applicationId = "nl.hollandworx.sms"
minSdk = 28
targetSdk = 37
- versionCode = 1
- versionName = gitHash.getOrElse("unknown")
+ // One number for both. Play permanently rejects a re-used or lower
+ // versionCode, so bump version.properties before every upload.
+ versionCode = appVersion
+ versionName = appVersion.toString()
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
+ // WHY THIS TOLERATES MISSING CREDENTIALS. A checkout without the keystore
+ // (a fresh clone, CI running unit tests) still has to configure. So an
+ // absent storeFile leaves the config half-built here and unreferenced
+ // below, and assembleRelease then emits an UNSIGNED apk exactly as it did
+ // before this block existed — the case scripts/build-release.sh refuses
+ // for --release and --bundle. Failing the build instead would break `test`
+ // on every machine that has no business holding the signing key.
+ signingConfigs {
+ create("release") {
+ val store = signingProp("storeFile", "HW_SMS_STORE_FILE")
+ if (store != null) {
+ storeFile = file(store)
+ storePassword = signingProp("storePassword", "HW_SMS_STORE_PASSWORD")
+ keyAlias = signingProp("keyAlias", "HW_SMS_KEY_ALIAS")
+ keyPassword = signingProp("keyPassword", "HW_SMS_KEY_PASSWORD")
+ }
+ }
+ }
+
buildTypes {
getByName("debug") {
manifestPlaceholders["sentryEnvironment"] = "development"
}
getByName("release") {
manifestPlaceholders["sentryEnvironment"] = "production"
+ // null when no credentials were found, which is what makes the
+ // unsigned-but-still-configurable case above work.
+ signingConfig = signingConfigs.getByName("release").takeIf { it.storeFile != null }
isMinifyEnabled = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
diff --git a/android/app/google-services.json b/android/app/google-services.json
deleted file mode 100644
index 4db4c0640..000000000
--- a/android/app/google-services.json
+++ /dev/null
@@ -1,47 +0,0 @@
-{
- "project_info": {
- "project_number": "877524083399",
- "project_id": "httpsms-86c51",
- "storage_bucket": "httpsms-86c51.appspot.com"
- },
- "client": [
- {
- "client_info": {
- "mobilesdk_app_id": "1:877524083399:android:5ceb08a909fd24a96514e2",
- "android_client_info": {
- "package_name": "com.httpsms"
- }
- },
- "oauth_client": [
- {
- "client_id": "877524083399-londfmd80qqlb2gebpbhctavlmqtla3b.apps.googleusercontent.com",
- "client_type": 1,
- "android_info": {
- "package_name": "com.httpsms",
- "certificate_hash": "58df9f7489b8bb5e3cd57f01a03d5e0068d8c185"
- }
- },
- {
- "client_id": "877524083399-7pn57hd8k2js6tc93i428ko1m51mj50r.apps.googleusercontent.com",
- "client_type": 3
- }
- ],
- "api_key": [
- {
- "current_key": "AIzaSyBKiokmk5qhE2Zc5F97KWzOvRe8FDDuxDg"
- }
- ],
- "services": {
- "appinvite_service": {
- "other_platform_oauth_client": [
- {
- "client_id": "877524083399-7pn57hd8k2js6tc93i428ko1m51mj50r.apps.googleusercontent.com",
- "client_type": 3
- }
- ]
- }
- }
- }
- ],
- "configuration_version": "1"
-}
diff --git a/android/app/src/main/java/com/httpsms/DeliveredReceiver.kt b/android/app/src/main/java/com/httpsms/DeliveredReceiver.kt
index 5d8ad85b9..547de327b 100644
--- a/android/app/src/main/java/com/httpsms/DeliveredReceiver.kt
+++ b/android/app/src/main/java/com/httpsms/DeliveredReceiver.kt
@@ -39,6 +39,7 @@ internal class DeliveredReceiver : BroadcastReceiver() {
val work = OneTimeWorkRequest
.Builder(DeliveredMessageWorker::class.java)
+ .quietDelayCustom(context, null) // CUSTOM: see QuietHoursCustom.kt
.setConstraints(constraints)
.setInputData(inputData)
.build()
@@ -67,6 +68,7 @@ internal class DeliveredReceiver : BroadcastReceiver() {
val work = OneTimeWorkRequest
.Builder(FailedMessageWorker::class.java)
+ .quietDelayCustom(context, null) // CUSTOM: see QuietHoursCustom.kt
.setConstraints(constraints)
.setInputData(inputData)
.build()
diff --git a/android/app/src/main/java/com/httpsms/FirebaseMessagingService.kt b/android/app/src/main/java/com/httpsms/FirebaseMessagingService.kt
index 244b68cf8..4292f6883 100644
--- a/android/app/src/main/java/com/httpsms/FirebaseMessagingService.kt
+++ b/android/app/src/main/java/com/httpsms/FirebaseMessagingService.kt
@@ -1,5 +1,8 @@
package com.httpsms
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
@@ -73,6 +76,7 @@ class MyFirebaseMessagingService : FirebaseMessagingService() {
HttpSmsApiService.create(applicationContext).storeHeartbeat(phoneNumbers.toTypedArray(), Settings.isCharging(applicationContext))
Settings.setHeartbeatTimestampAsync(applicationContext, System.currentTimeMillis())
+ QuietHoursCustom.refresh(applicationContext) // CUSTOM: server probed us, so our windows may be stale — see QuietHoursCustom.kt
} catch (exception: Exception) {
Timber.e(exception)
}
@@ -91,6 +95,15 @@ class MyFirebaseMessagingService : FirebaseMessagingService() {
.Builder(SendSmsWorker::class.java)
.setConstraints(constraints)
.setInputData(inputData)
+ // The api pushes this notification with Android priority "high"
+ // (pkg/services/phone_notification_service.go), which grants the app a
+ // temporary Doze allowlist window. A plain OneTimeWorkRequest does not
+ // use that window: in Doze it is parked until the next maintenance
+ // window, observed here at 1-2 hours, while the server expires the
+ // message after phones.message_expiration_seconds (600 by default).
+ // The send then never happens and the message expires untouched, with
+ // last_attempted_at still NULL.
+ .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.addTag(messageID)
.build()
@@ -132,6 +145,41 @@ class MyFirebaseMessagingService : FirebaseMessagingService() {
}
internal class SendSmsWorker(appContext: Context, workerParams: WorkerParameters) : Worker(appContext, workerParams) {
+ // Required by setExpedited(). On API 31+ expedited work runs as an
+ // expedited job and this is never called; on API 28-30 (minSdk is 28)
+ // WorkManager runs it as a foreground service and THROWS
+ // IllegalStateException if the worker does not supply ForegroundInfo.
+ // Reuses StickyNotificationService's channel — createNotificationChannel
+ // is idempotent for an existing id, so this is safe whether or not the
+ // sticky service happens to be running.
+ override fun getForegroundInfo(): ForegroundInfo {
+ val channelId = "sticky_notification_channel"
+
+ val notificationManager = applicationContext
+ .getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ notificationManager.createNotificationChannel(
+ NotificationChannel(
+ channelId,
+ channelId,
+ NotificationManager.IMPORTANCE_HIGH
+ ).apply {
+ enableVibration(false)
+ setShowBadge(false)
+ }
+ )
+
+ val notification = Notification.Builder(applicationContext, channelId)
+ .setContentTitle("httpSMS")
+ .setContentText("Sending SMS message")
+ .setSmallIcon(R.drawable.ic_stat_name)
+ .setOngoing(true)
+ .build()
+
+ // Notification id 2: StickyNotificationService owns id 1 and must not
+ // be displaced while this runs.
+ return ForegroundInfo(2, notification)
+ }
+
override fun doWork(): Result {
if (!Settings.isLoggedIn(applicationContext)) {
Timber.w("user is not logged in, stopping processing")
diff --git a/android/app/src/main/java/com/httpsms/MainActivity.kt b/android/app/src/main/java/com/httpsms/MainActivity.kt
index ff7587db2..8d1d7d7c4 100644
--- a/android/app/src/main/java/com/httpsms/MainActivity.kt
+++ b/android/app/src/main/java/com/httpsms/MainActivity.kt
@@ -15,6 +15,7 @@ import android.widget.Toast
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
+import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.work.Constraints
import androidx.work.ExistingPeriodicWorkPolicy
@@ -80,7 +81,41 @@ class MainActivity : AppCompatActivity() {
override fun onStart() {
super.onStart()
- requestPermissions(this)
+ // The system permission dialog does not satisfy Play's Prominent
+ // Disclosure Requirement on its own, and neither does a privacy policy:
+ // the app itself has to say what SMS and call data it collects and that
+ // the data leaves the device, BEFORE the permission request. So the
+ // request is gated behind this acknowledgement rather than fired here.
+ if (Settings.hasAcceptedDataDisclosure(this)) {
+ requestPermissions(this)
+ } else {
+ showDataDisclosure(this)
+ }
+ }
+
+ private fun showDataDisclosure(context: Context) {
+ AlertDialog.Builder(this)
+ .setTitle(R.string.disclosure_title)
+ .setMessage(R.string.disclosure_message)
+ // Not cancelable: a disclosure dismissed by a stray tap outside the
+ // dialog would not be the affirmative acknowledgement Play requires.
+ .setCancelable(false)
+ .setPositiveButton(R.string.disclosure_continue) { dialog, _ ->
+ Settings.setDataDisclosureAccepted(context, true)
+ dialog.dismiss()
+ // NOT requestPermissions() here. It calls registerForActivityResult,
+ // which Android only allows before the activity is STARTED; a button
+ // tap happens while RESUMED, so it threw IllegalStateException and
+ // crashed the app on "Continue". recreate() goes back through
+ // onStart, which now sees the acceptance and requests from there —
+ // the same path that already worked after a manual restart.
+ recreate()
+ }
+ .setNegativeButton(R.string.disclosure_decline) { dialog, _ ->
+ Timber.w("data disclosure declined, not requesting SMS permissions")
+ dialog.dismiss()
+ }
+ .show()
}
override fun onResume() {
diff --git a/android/app/src/main/java/com/httpsms/QuietHoursCustom.kt b/android/app/src/main/java/com/httpsms/QuietHoursCustom.kt
new file mode 100644
index 000000000..649233d29
--- /dev/null
+++ b/android/app/src/main/java/com/httpsms/QuietHoursCustom.kt
@@ -0,0 +1,185 @@
+package com.httpsms
+
+import android.content.Context
+import androidx.preference.PreferenceManager
+import androidx.work.Constraints
+import androidx.work.ExistingWorkPolicy
+import androidx.work.NetworkType
+import androidx.work.OneTimeWorkRequest
+import androidx.work.WorkManager
+import com.httpsms.worker.HeartbeatWorker
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import org.json.JSONObject
+import timber.log.Timber
+import java.net.URLEncoder
+import java.time.ZoneId
+import java.time.ZonedDateTime
+import java.util.concurrent.TimeUnit
+
+// CUSTOM FILE — not upstream. Quiet hours, app side.
+//
+// THE AIM: during quiet hours httpSMS causes NO network traffic. Every request
+// wakes the data radio, and on this phone a radio wake brings the VPN up and
+// fires the connectivity event a pile of other apps react to. The app is not
+// killed — it stays installed and running, it just keeps quiet.
+//
+// Quiet hours are the hours OUTSIDE the send windows of the message send
+// schedule attached to the phone on the server (web → Settings → phone). The
+// server holds outgoing messages and its own heartbeat pushes for exactly those
+// hours (api/pkg/services/quiet_hours_custom.go). This file makes the app match:
+//
+// - HeartbeatWorker skips its network call while quiet and instead books one
+// check-in for the moment the window opens (deferHeartbeat).
+// - Received SMS, missed calls and delivery reports are still captured as they
+// happen, but their upload waits for the window (quietDelayCustom). Nothing
+// is dropped: WorkManager persists the work across reboots.
+// - After every successful heartbeat the app re-reads the windows from
+// GET /v1/phones/quiet-hours (refresh), so a schedule edited on the server
+// reaches the phone on its next heartbeat inside a window.
+// - The server's heartbeat push also triggers a refresh. That push only comes
+// when the server thinks the phone is awake, so receiving it while our copy
+// says quiet means our copy is stale (quiet hours shortened overnight).
+// Without this the server would re-probe every 16 minutes until our old
+// window opened. The radio is already up for the reply, so it costs nothing.
+//
+// No cached schedule, or one with no windows, means never quiet — upstream
+// behaviour.
+object QuietHoursCustom {
+ private const val PREF_PREFIX = "quiet_hours_custom_"
+ private const val CHECK_IN_WORK = "quiet_hours_custom_check_in"
+
+ /** Re-reads each active SIM's windows from the server. Call only while the network is in use anyway. */
+ fun refresh(context: Context) {
+ val client = OkHttpClient()
+ val baseURL = Settings.getServerUrlOrDefault(context)
+ for (owner in activeOwners(context)) {
+ try {
+ val path = baseURL.path + "/v1/phones/quiet-hours?owner=" + URLEncoder.encode(owner, "UTF-8")
+ val request = Request.Builder()
+ .url(baseURL.resolve(path).toURL())
+ .header("x-api-key", Settings.getApiKeyOrDefault(context))
+ .header("X-Client-Version", BuildConfig.VERSION_NAME)
+ .build()
+ client.newCall(request).execute().use { response ->
+ if (!response.isSuccessful) {
+ Timber.w("cannot fetch quiet hours for [$owner]: HTTP ${response.code}")
+ return@use
+ }
+ val data = JSONObject(response.body!!.string()).getJSONObject("data")
+ PreferenceManager.getDefaultSharedPreferences(context).edit()
+ .putString(PREF_PREFIX + owner, data.toString())
+ .apply()
+ Timber.d("quiet hours for [$owner] refreshed")
+ }
+ } catch (exception: Exception) {
+ Timber.w(exception, "cannot refresh quiet hours for [$owner]")
+ }
+ }
+ }
+
+ /**
+ * True when every active SIM is quiet. The caller must then skip its
+ * heartbeat; one check-in has been booked for when the first window opens.
+ */
+ fun deferHeartbeat(context: Context): Boolean {
+ val delay = uploadDelayMillis(context, null)
+ if (delay <= 0) {
+ return false
+ }
+
+ val work = OneTimeWorkRequest.Builder(HeartbeatWorker::class.java)
+ .setInitialDelay(delay, TimeUnit.MILLISECONDS)
+ .setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
+ .build()
+ WorkManager.getInstance(context).enqueueUniqueWork(CHECK_IN_WORK, ExistingWorkPolicy.REPLACE, work)
+ Timber.i("quiet hours: heartbeat skipped, check-in booked in [${delay / 60000}] minutes")
+ return true
+ }
+
+ /**
+ * Milliseconds until [owner]'s window opens; 0 when it is open now. With a
+ * null owner it answers for the whole phone: 0 as soon as any active SIM is
+ * open, otherwise the soonest opening.
+ */
+ fun uploadDelayMillis(context: Context, owner: String?, now: ZonedDateTime = ZonedDateTime.now()): Long {
+ val owners = if (owner != null) listOf(owner) else activeOwners(context)
+ if (owners.isEmpty()) {
+ return 0
+ }
+ return owners.minOf { millisUntilOpen(cached(context, it), now) }
+ }
+
+ private fun activeOwners(context: Context): List {
+ val owners = mutableListOf()
+ if (Settings.getActiveStatus(context, Constants.SIM1)) {
+ owners.add(Settings.getSIM1PhoneNumber(context))
+ }
+ if (Settings.getActiveStatus(context, Constants.SIM2)) {
+ owners.add(Settings.getSIM2PhoneNumber(context))
+ }
+ return owners
+ }
+
+ private fun cached(context: Context, owner: String): JSONObject? {
+ val raw = PreferenceManager.getDefaultSharedPreferences(context).getString(PREF_PREFIX + owner, null)
+ ?: return null
+ return try {
+ JSONObject(raw)
+ } catch (exception: Exception) {
+ null
+ }
+ }
+
+ /**
+ * Same arithmetic as MessageSendSchedule.ResolveScheduledAt on the server,
+ * so both ends agree on when the window opens. Go numbers weekdays
+ * Sunday=0..Saturday=6; java.time is Monday=1..Sunday=7, hence `% 7`.
+ */
+ internal fun millisUntilOpen(schedule: JSONObject?, now: ZonedDateTime): Long {
+ if (schedule == null) {
+ return 0
+ }
+ val windows = schedule.optJSONArray("windows") ?: return 0
+ if (windows.length() == 0) {
+ return 0
+ }
+ val zone = try {
+ ZoneId.of(schedule.optString("timezone"))
+ } catch (exception: Exception) {
+ return 0
+ }
+
+ val base = now.withZoneSameInstant(zone)
+ var best: ZonedDateTime? = null
+ for (dayOffset in 0..7) {
+ val midnight = base.toLocalDate().plusDays(dayOffset.toLong()).atStartOfDay(zone)
+ val weekday = midnight.dayOfWeek.value % 7
+ for (i in 0 until windows.length()) {
+ val window = windows.getJSONObject(i)
+ if (window.optInt("day_of_week", -1) != weekday) {
+ continue
+ }
+ val start = midnight.plusMinutes(window.optLong("start_minute"))
+ val end = midnight.plusMinutes(window.optLong("end_minute"))
+ val candidate = when {
+ dayOffset == 0 && base.isBefore(start) -> start
+ dayOffset == 0 && !base.isBefore(start) && base.isBefore(end) -> return 0
+ dayOffset > 0 -> start
+ else -> null
+ }
+ if (candidate != null && (best == null || candidate.isBefore(best))) {
+ best = candidate
+ }
+ }
+ if (best != null) {
+ break
+ }
+ }
+ return best?.let { java.time.Duration.between(base, it).toMillis().coerceAtLeast(0) } ?: 0
+ }
+}
+
+/** Hook for upstream work builders: hold an upload until the quiet period ends. */
+fun OneTimeWorkRequest.Builder.quietDelayCustom(context: Context, owner: String?): OneTimeWorkRequest.Builder =
+ setInitialDelay(QuietHoursCustom.uploadDelayMillis(context, owner), TimeUnit.MILLISECONDS)
diff --git a/android/app/src/main/java/com/httpsms/ReceivedReceiver.kt b/android/app/src/main/java/com/httpsms/ReceivedReceiver.kt
index 3edc30e2d..e36d1c25e 100644
--- a/android/app/src/main/java/com/httpsms/ReceivedReceiver.kt
+++ b/android/app/src/main/java/com/httpsms/ReceivedReceiver.kt
@@ -159,6 +159,7 @@ class ReceivedReceiver: BroadcastReceiver()
val work = OneTimeWorkRequest
.Builder(ReceivedSmsWorker::class.java)
+ .quietDelayCustom(context, to) // CUSTOM: see QuietHoursCustom.kt
.setConstraints(constraints)
.setInputData(inputData)
.build()
diff --git a/android/app/src/main/java/com/httpsms/Settings.kt b/android/app/src/main/java/com/httpsms/Settings.kt
index 8cda79da2..fd73de543 100644
--- a/android/app/src/main/java/com/httpsms/Settings.kt
+++ b/android/app/src/main/java/com/httpsms/Settings.kt
@@ -1,380 +1,399 @@
-package com.httpsms
-
-import android.content.Context
-import android.os.BatteryManager
-import androidx.preference.PreferenceManager
-import timber.log.Timber
-import java.net.URI
-import java.time.ZoneOffset
-import java.time.ZonedDateTime
-import java.time.format.DateTimeFormatter
-
-object Settings {
- private const val SETTINGS_SIM1_PHONE_NUMBER = "SETTINGS_SIM1_PHONE_NUMBER"
- private const val SETTINGS_SIM2_PHONE_NUMBER = "SETTINGS_SIM2_PHONE_NUMBER"
- private const val SETTINGS_SIM1_ACTIVE = "SETTINGS_SIM1_ACTIVE_STATUS"
- private const val SETTINGS_SIM2_ACTIVE = "SETTINGS_SIM2_ACTIVE_STATUS"
- private const val SETTINGS_SIM1_INCOMING_ACTIVE = "SETTINGS_SIM1_INCOMING_ACTIVE"
- private const val SETTINGS_SIM2_INCOMING_ACTIVE = "SETTINGS_SIM2_INCOMING_ACTIVE"
- private const val SETTINGS_SIM1_INCOMING_CALL_ACTIVE = "SETTINGS_SIM1_INCOMING_CALL_ACTIVE"
- private const val SETTINGS_SIM2_INCOMING_CALL_ACTIVE = "SETTINGS_SIM2_INCOMING_CALL_ACTIVE"
- private const val SETTINGS_DEBUG_LOG_ENABLED = "SETTINGS_DEBUG_LOG_ENABLED"
- private const val SETTINGS_API_KEY = "SETTINGS_API_KEY"
- private const val SETTINGS_SERVER_URL = "SETTINGS_SERVER_URL"
- private const val SETTINGS_FCM_TOKEN = "SETTINGS_FCM_TOKEN"
- private const val SETTINGS_USER_ID = "SETTINGS_USER_ID"
- private const val SETTINGS_FCM_TOKEN_UPDATE_TIMESTAMP = "SETTINGS_FCM_TOKEN_UPDATE_TIMESTAMP"
- private const val SETTINGS_HEARTBEAT_TIMESTAMP = "SETTINGS_HEARTBEAT_TIMESTAMP"
- private const val SETTINGS_ENCRYPTION_KEY = "SETTINGS_ENCRYPTION_KEY"
- private const val SETTINGS_ENCRYPT_RECEIVED_MESSAGES = "SETTINGS_ENCRYPT_RECEIVED_MESSAGES"
-
- fun getPhoneNumber(context:Context, sim: String): String {
- if (sim == Constants.SIM2) {
- return getSIM2PhoneNumber(context)
- }
- return getSIM1PhoneNumber(context)
- }
-
- fun getSIM1PhoneNumber(context: Context): String {
- Timber.d(Settings::getSIM1PhoneNumber.name)
-
- val owner = PreferenceManager
- .getDefaultSharedPreferences(context)
- .getString(this.SETTINGS_SIM1_PHONE_NUMBER, null)
-
- if (owner == null) {
- Timber.i("cannot get owner from preference [${this.SETTINGS_SIM1_PHONE_NUMBER}]")
- return ""
- }
-
- Timber.d("SETTINGS_SIM1_PHONE_NUMBER: [$owner]")
- return owner
- }
-
- fun getSIM2PhoneNumber(context: Context): String {
- Timber.d(Settings::getSIM2PhoneNumber.name)
-
- val owner = PreferenceManager
- .getDefaultSharedPreferences(context)
- .getString(this.SETTINGS_SIM2_PHONE_NUMBER, null)
-
- if (owner == null) {
- Timber.i("cannot get owner from preference [${this.SETTINGS_SIM2_PHONE_NUMBER}]")
- return ""
- }
-
- Timber.d("SETTINGS_SIM2_PHONE_NUMBER: [$owner]")
- return owner
- }
-
- fun hasOwner(context: Context): Boolean {
- return getSIM1PhoneNumber(context) != ""
- }
-
- fun getFcmTokenLastUpdateTimestamp(context: Context): Long {
- Timber.d(Settings::getFcmTokenLastUpdateTimestamp.name)
-
- val timestamp = PreferenceManager
- .getDefaultSharedPreferences(context)
- .getLong(this.SETTINGS_FCM_TOKEN_UPDATE_TIMESTAMP,0)
-
- Timber.d("SETTINGS_FCM_TOKEN_UPDATE_TIMESTAMP: [$timestamp]")
- return timestamp
- }
-
-
- fun setFcmTokenLastUpdateTimestampAsync(context: Context, timestamp: Long) {
- Timber.d(Settings::setFcmTokenLastUpdateTimestampAsync.name)
-
- PreferenceManager.getDefaultSharedPreferences(context)
- .edit()
- .putLong(this.SETTINGS_FCM_TOKEN_UPDATE_TIMESTAMP, timestamp)
- .apply()
- }
-
- fun setSIM1PhoneNumber(context: Context, owner: String?) {
- Timber.d(Settings::setSIM1PhoneNumber.name)
-
- PreferenceManager.getDefaultSharedPreferences(context)
- .edit()
- .putString(this.SETTINGS_SIM1_PHONE_NUMBER, owner)
- .apply()
- }
-
- fun setSIM2PhoneNumber(context: Context, owner: String?) {
- Timber.d(Settings::setSIM2PhoneNumber.name)
-
- PreferenceManager.getDefaultSharedPreferences(context)
- .edit()
- .putString(this.SETTINGS_SIM2_PHONE_NUMBER, owner)
- .apply()
- }
-
- fun isIncomingMessageEnabled(context: Context, sim: String): Boolean {
- var setting = this.SETTINGS_SIM1_INCOMING_ACTIVE
- if (sim == Constants.SIM2) {
- setting = this.SETTINGS_SIM2_INCOMING_ACTIVE
- }
- val activeStatus = PreferenceManager
- .getDefaultSharedPreferences(context)
- .getBoolean(setting,true)
-
- Timber.d("SETTINGS_${sim}_INCOMING_ACTIVE: [$activeStatus]")
- return activeStatus
- }
-
- fun isIncomingCallEventsEnabled(context: Context, sim: String): Boolean {
- var setting = this.SETTINGS_SIM1_INCOMING_CALL_ACTIVE
- if (sim == Constants.SIM2) {
- setting = this.SETTINGS_SIM2_INCOMING_CALL_ACTIVE
- }
- val activeStatus = PreferenceManager
- .getDefaultSharedPreferences(context)
- .getBoolean(setting,false)
-
- Timber.d("SETTINGS_${sim}_INCOMING_CALL_ACTIVE: [$activeStatus]")
- return activeStatus
- }
-
- fun setIncomingCallEventsEnabled(context: Context, sim: String, enabled: Boolean) {
- var setting = this.SETTINGS_SIM1_INCOMING_CALL_ACTIVE
- if (sim == Constants.SIM2) {
- setting = this.SETTINGS_SIM2_INCOMING_CALL_ACTIVE
- }
-
- PreferenceManager.getDefaultSharedPreferences(context)
- .edit()
- .putBoolean(setting, enabled)
- .apply()
- }
-
-
- fun isDebugLogEnabled(context: Context) : Boolean {
- Timber.d(Settings::isDebugLogEnabled.name)
-
- return PreferenceManager
- .getDefaultSharedPreferences(context)
- .getBoolean(this.SETTINGS_DEBUG_LOG_ENABLED, false)
- }
-
- fun setDebugLogEnabled(context: Context, status: Boolean) {
- Timber.d(Settings::setDebugLogEnabled.name)
-
- PreferenceManager.getDefaultSharedPreferences(context)
- .edit()
- .putBoolean(this.SETTINGS_DEBUG_LOG_ENABLED, status)
- .apply()
- }
-
- fun setIncomingActiveSIM1(context: Context, status: Boolean) {
- Timber.d(Settings::setIncomingActiveSIM1.name)
-
- PreferenceManager.getDefaultSharedPreferences(context)
- .edit()
- .putBoolean(this.SETTINGS_SIM1_INCOMING_ACTIVE, status)
- .apply()
- }
-
- fun setEncryptReceivedMessages(context: Context, status: Boolean) {
- Timber.d(Settings::setEncryptReceivedMessages.name)
-
- PreferenceManager.getDefaultSharedPreferences(context)
- .edit()
- .putBoolean(this.SETTINGS_ENCRYPT_RECEIVED_MESSAGES, status)
- .apply()
- }
-
- fun encryptReceivedMessages(context: Context): Boolean {
- Timber.d(Settings::encryptReceivedMessages.name)
-
- val encryptReceivedMessages = PreferenceManager
- .getDefaultSharedPreferences(context)
- .getBoolean(this.SETTINGS_ENCRYPT_RECEIVED_MESSAGES,false)
-
- Timber.d("SETTINGS_ENCRYPT_RECEIVED_MESSAGES: [$encryptReceivedMessages]")
- return encryptReceivedMessages && !getEncryptionKey(context).isNullOrEmpty()
- }
-
- fun setEncryptionKey(context: Context, key: String?) {
- Timber.d(Settings::setEncryptionKey.name)
-
- PreferenceManager.getDefaultSharedPreferences(context)
- .edit()
- .putString(this.SETTINGS_ENCRYPTION_KEY, key)
- .apply()
- }
-
- fun getEncryptionKey(context: Context): String? {
- Timber.d(Settings::getEncryptionKey.name)
-
- return PreferenceManager
- .getDefaultSharedPreferences(context)
- .getString(this.SETTINGS_ENCRYPTION_KEY, "")
- }
-
- fun setIncomingActiveSIM2(context: Context, status: Boolean) {
- Timber.d(Settings::setIncomingActiveSIM2.name)
-
- PreferenceManager.getDefaultSharedPreferences(context)
- .edit()
- .putBoolean(this.SETTINGS_SIM2_INCOMING_ACTIVE, status)
- .apply()
- }
-
-
- fun getActiveStatus(context: Context, sim: String): Boolean {
- var setting = this.SETTINGS_SIM1_ACTIVE
- if (sim == Constants.SIM2) {
- setting = this.SETTINGS_SIM2_ACTIVE
- }
- var activeStatus = PreferenceManager
- .getDefaultSharedPreferences(context)
- .getBoolean(setting,true)
-
- if (sim == Constants.SIM2) {
- activeStatus = activeStatus && isDualSIM(context)
- }
-
- Timber.d("SETTINGS_${sim}_ACTIVE: [$activeStatus]")
- return activeStatus
- }
-
- fun setActiveStatusAsync(context: Context, status: Boolean, sim: String) {
- Timber.d(Settings::setActiveStatusAsync.name)
-
- var setting = this.SETTINGS_SIM1_ACTIVE
- if (sim == Constants.SIM2) {
- setting = this.SETTINGS_SIM2_ACTIVE
- }
-
- PreferenceManager.getDefaultSharedPreferences(context)
- .edit()
- .putBoolean(setting, status)
- .apply()
- }
-
- fun isLoggedIn(context: Context): Boolean {
- return getApiKey(context) != null && hasOwner(context)
- }
-
- fun isDualSIM(context: Context): Boolean {
- return getSIM1PhoneNumber(context) != "" && getSIM2PhoneNumber(context) != ""
- }
-
- private fun getApiKey(context: Context): String?{
- Timber.d(Settings::getApiKey.name)
-
- val apiKey = PreferenceManager
- .getDefaultSharedPreferences(context)
- .getString(this.SETTINGS_API_KEY,null)
-
- Timber.d("SETTINGS_API_KEY: [$apiKey]")
- return apiKey
- }
-
- fun getApiKeyOrDefault(context:Context): String {
- return getApiKey(context) ?: ""
- }
-
- fun isCharging(context: Context): Boolean {
- val myBatteryManager = context.getSystemService(Context.BATTERY_SERVICE) as BatteryManager
- return myBatteryManager.isCharging
- }
-
- fun setUserID(context:Context, userID: String?) {
- Timber.d(Settings::setUserID.name)
- PreferenceManager.getDefaultSharedPreferences(context)
- .edit()
- .putString(this.SETTINGS_USER_ID, userID)
- .apply()
- }
-
- // getUserID don't log here as this will create recursion on the LogTail sink
- fun getUserID(context:Context): String {
- val userID = PreferenceManager
- .getDefaultSharedPreferences(context)
- .getString(this.SETTINGS_USER_ID,null)
- return userID ?: ""
- }
-
- fun getServerUrlOrDefault(context:Context): URI {
- val urlString = getServerUrl(context) ?: "https://api.httpsms.com"
- return URI(urlString)
- }
-
- private fun getServerUrl(context: Context): String? {
- Timber.d(Settings::getServerUrl.name)
-
- val serverUrl = PreferenceManager
- .getDefaultSharedPreferences(context)
- .getString(this.SETTINGS_SERVER_URL,null)
-
- Timber.d("SETTINGS_SERVER_URL: [$serverUrl]")
- return serverUrl
- }
-
- fun setServerUrlAsync(context: Context, serverURL: String?) {
- Timber.d(Settings::SETTINGS_SERVER_URL.name)
-
- PreferenceManager.getDefaultSharedPreferences(context)
- .edit()
- .putString(this.SETTINGS_SERVER_URL, serverURL)
- .apply()
- }
-
- fun setApiKeyAsync(context: Context, apiKey: String?) {
- Timber.d(Settings::setApiKeyAsync.name)
-
- PreferenceManager.getDefaultSharedPreferences(context)
- .edit()
- .putString(this.SETTINGS_API_KEY, apiKey)
- .apply()
- }
-
- fun getFcmToken(context: Context): String?{
- Timber.d(Settings::getFcmToken.name)
-
- val activeStatus = PreferenceManager
- .getDefaultSharedPreferences(context)
- .getString(this.SETTINGS_FCM_TOKEN,null)
-
- Timber.d("SETTINGS_FCM_TOKEN: [$activeStatus]")
- return activeStatus
- }
-
- fun setFcmTokenAsync(context: Context, apiKey: String) {
- Timber.d(Settings::setApiKeyAsync.name)
-
- PreferenceManager.getDefaultSharedPreferences(context)
- .edit()
- .putString(this.SETTINGS_FCM_TOKEN, apiKey)
- .apply()
- }
-
- fun getHeartbeatTimestamp(context: Context): Long {
- Timber.d(Settings::getHeartbeatTimestamp.name)
-
- val timestamp = PreferenceManager
- .getDefaultSharedPreferences(context)
- .getLong(this.SETTINGS_HEARTBEAT_TIMESTAMP,0)
-
- Timber.d("SETTINGS_HEARTBEAT_TIMESTAMP: [$timestamp]")
- return timestamp
- }
-
- fun currentTimestamp(): String {
- return DateTimeFormatter.ofPattern(Constants.TIMESTAMP_PATTERN).format(
- ZonedDateTime.now(ZoneOffset.UTC)
- ).replace("+", "Z")
- }
-
-
- fun setHeartbeatTimestampAsync(context: Context, timestamp: Long) {
- Timber.d(Settings::setHeartbeatTimestampAsync.name)
-
- PreferenceManager.getDefaultSharedPreferences(context)
- .edit()
- .putLong(this.SETTINGS_HEARTBEAT_TIMESTAMP, timestamp)
- .apply()
- }
-}
+package com.httpsms
+
+import android.content.Context
+import android.os.BatteryManager
+import androidx.preference.PreferenceManager
+import timber.log.Timber
+import java.net.URI
+import java.time.ZoneOffset
+import java.time.ZonedDateTime
+import java.time.format.DateTimeFormatter
+
+object Settings {
+ private const val SETTINGS_SIM1_PHONE_NUMBER = "SETTINGS_SIM1_PHONE_NUMBER"
+ private const val SETTINGS_SIM2_PHONE_NUMBER = "SETTINGS_SIM2_PHONE_NUMBER"
+ private const val SETTINGS_SIM1_ACTIVE = "SETTINGS_SIM1_ACTIVE_STATUS"
+ private const val SETTINGS_SIM2_ACTIVE = "SETTINGS_SIM2_ACTIVE_STATUS"
+ private const val SETTINGS_SIM1_INCOMING_ACTIVE = "SETTINGS_SIM1_INCOMING_ACTIVE"
+ private const val SETTINGS_SIM2_INCOMING_ACTIVE = "SETTINGS_SIM2_INCOMING_ACTIVE"
+ private const val SETTINGS_SIM1_INCOMING_CALL_ACTIVE = "SETTINGS_SIM1_INCOMING_CALL_ACTIVE"
+ private const val SETTINGS_SIM2_INCOMING_CALL_ACTIVE = "SETTINGS_SIM2_INCOMING_CALL_ACTIVE"
+ private const val SETTINGS_DEBUG_LOG_ENABLED = "SETTINGS_DEBUG_LOG_ENABLED"
+ private const val SETTINGS_API_KEY = "SETTINGS_API_KEY"
+ private const val SETTINGS_DATA_DISCLOSURE_ACCEPTED = "SETTINGS_DATA_DISCLOSURE_ACCEPTED"
+ private const val SETTINGS_SERVER_URL = "SETTINGS_SERVER_URL"
+ private const val SETTINGS_FCM_TOKEN = "SETTINGS_FCM_TOKEN"
+ private const val SETTINGS_USER_ID = "SETTINGS_USER_ID"
+ private const val SETTINGS_FCM_TOKEN_UPDATE_TIMESTAMP = "SETTINGS_FCM_TOKEN_UPDATE_TIMESTAMP"
+ private const val SETTINGS_HEARTBEAT_TIMESTAMP = "SETTINGS_HEARTBEAT_TIMESTAMP"
+ private const val SETTINGS_ENCRYPTION_KEY = "SETTINGS_ENCRYPTION_KEY"
+ private const val SETTINGS_ENCRYPT_RECEIVED_MESSAGES = "SETTINGS_ENCRYPT_RECEIVED_MESSAGES"
+
+ fun getPhoneNumber(context:Context, sim: String): String {
+ if (sim == Constants.SIM2) {
+ return getSIM2PhoneNumber(context)
+ }
+ return getSIM1PhoneNumber(context)
+ }
+
+ fun getSIM1PhoneNumber(context: Context): String {
+ Timber.d(Settings::getSIM1PhoneNumber.name)
+
+ val owner = PreferenceManager
+ .getDefaultSharedPreferences(context)
+ .getString(this.SETTINGS_SIM1_PHONE_NUMBER, null)
+
+ if (owner == null) {
+ Timber.i("cannot get owner from preference [${this.SETTINGS_SIM1_PHONE_NUMBER}]")
+ return ""
+ }
+
+ Timber.d("SETTINGS_SIM1_PHONE_NUMBER: [$owner]")
+ return owner
+ }
+
+ fun getSIM2PhoneNumber(context: Context): String {
+ Timber.d(Settings::getSIM2PhoneNumber.name)
+
+ val owner = PreferenceManager
+ .getDefaultSharedPreferences(context)
+ .getString(this.SETTINGS_SIM2_PHONE_NUMBER, null)
+
+ if (owner == null) {
+ Timber.i("cannot get owner from preference [${this.SETTINGS_SIM2_PHONE_NUMBER}]")
+ return ""
+ }
+
+ Timber.d("SETTINGS_SIM2_PHONE_NUMBER: [$owner]")
+ return owner
+ }
+
+ fun hasOwner(context: Context): Boolean {
+ return getSIM1PhoneNumber(context) != ""
+ }
+
+ fun getFcmTokenLastUpdateTimestamp(context: Context): Long {
+ Timber.d(Settings::getFcmTokenLastUpdateTimestamp.name)
+
+ val timestamp = PreferenceManager
+ .getDefaultSharedPreferences(context)
+ .getLong(this.SETTINGS_FCM_TOKEN_UPDATE_TIMESTAMP,0)
+
+ Timber.d("SETTINGS_FCM_TOKEN_UPDATE_TIMESTAMP: [$timestamp]")
+ return timestamp
+ }
+
+
+ fun setFcmTokenLastUpdateTimestampAsync(context: Context, timestamp: Long) {
+ Timber.d(Settings::setFcmTokenLastUpdateTimestampAsync.name)
+
+ PreferenceManager.getDefaultSharedPreferences(context)
+ .edit()
+ .putLong(this.SETTINGS_FCM_TOKEN_UPDATE_TIMESTAMP, timestamp)
+ .apply()
+ }
+
+ fun setSIM1PhoneNumber(context: Context, owner: String?) {
+ Timber.d(Settings::setSIM1PhoneNumber.name)
+
+ PreferenceManager.getDefaultSharedPreferences(context)
+ .edit()
+ .putString(this.SETTINGS_SIM1_PHONE_NUMBER, owner)
+ .apply()
+ }
+
+ fun setSIM2PhoneNumber(context: Context, owner: String?) {
+ Timber.d(Settings::setSIM2PhoneNumber.name)
+
+ PreferenceManager.getDefaultSharedPreferences(context)
+ .edit()
+ .putString(this.SETTINGS_SIM2_PHONE_NUMBER, owner)
+ .apply()
+ }
+
+ fun isIncomingMessageEnabled(context: Context, sim: String): Boolean {
+ var setting = this.SETTINGS_SIM1_INCOMING_ACTIVE
+ if (sim == Constants.SIM2) {
+ setting = this.SETTINGS_SIM2_INCOMING_ACTIVE
+ }
+ val activeStatus = PreferenceManager
+ .getDefaultSharedPreferences(context)
+ .getBoolean(setting,true)
+
+ Timber.d("SETTINGS_${sim}_INCOMING_ACTIVE: [$activeStatus]")
+ return activeStatus
+ }
+
+ // Google Play's Prominent Disclosure Requirement: the user must be told
+ // in-app, before the system permission dialog, what SMS and call data is
+ // collected and that it leaves the device. Persisted so the disclosure is
+ // shown once rather than on every launch; a decline is deliberately NOT
+ // persisted, so the app asks again next time instead of silently giving up.
+ fun hasAcceptedDataDisclosure(context: Context): Boolean {
+ return PreferenceManager
+ .getDefaultSharedPreferences(context)
+ .getBoolean(this.SETTINGS_DATA_DISCLOSURE_ACCEPTED, false)
+ }
+
+ fun setDataDisclosureAccepted(context: Context, accepted: Boolean) {
+ PreferenceManager.getDefaultSharedPreferences(context)
+ .edit()
+ .putBoolean(this.SETTINGS_DATA_DISCLOSURE_ACCEPTED, accepted)
+ .apply()
+ }
+
+ fun isIncomingCallEventsEnabled(context: Context, sim: String): Boolean {
+ var setting = this.SETTINGS_SIM1_INCOMING_CALL_ACTIVE
+ if (sim == Constants.SIM2) {
+ setting = this.SETTINGS_SIM2_INCOMING_CALL_ACTIVE
+ }
+ val activeStatus = PreferenceManager
+ .getDefaultSharedPreferences(context)
+ .getBoolean(setting,false)
+
+ Timber.d("SETTINGS_${sim}_INCOMING_CALL_ACTIVE: [$activeStatus]")
+ return activeStatus
+ }
+
+ fun setIncomingCallEventsEnabled(context: Context, sim: String, enabled: Boolean) {
+ var setting = this.SETTINGS_SIM1_INCOMING_CALL_ACTIVE
+ if (sim == Constants.SIM2) {
+ setting = this.SETTINGS_SIM2_INCOMING_CALL_ACTIVE
+ }
+
+ PreferenceManager.getDefaultSharedPreferences(context)
+ .edit()
+ .putBoolean(setting, enabled)
+ .apply()
+ }
+
+
+ fun isDebugLogEnabled(context: Context) : Boolean {
+ Timber.d(Settings::isDebugLogEnabled.name)
+
+ return PreferenceManager
+ .getDefaultSharedPreferences(context)
+ .getBoolean(this.SETTINGS_DEBUG_LOG_ENABLED, false)
+ }
+
+ fun setDebugLogEnabled(context: Context, status: Boolean) {
+ Timber.d(Settings::setDebugLogEnabled.name)
+
+ PreferenceManager.getDefaultSharedPreferences(context)
+ .edit()
+ .putBoolean(this.SETTINGS_DEBUG_LOG_ENABLED, status)
+ .apply()
+ }
+
+ fun setIncomingActiveSIM1(context: Context, status: Boolean) {
+ Timber.d(Settings::setIncomingActiveSIM1.name)
+
+ PreferenceManager.getDefaultSharedPreferences(context)
+ .edit()
+ .putBoolean(this.SETTINGS_SIM1_INCOMING_ACTIVE, status)
+ .apply()
+ }
+
+ fun setEncryptReceivedMessages(context: Context, status: Boolean) {
+ Timber.d(Settings::setEncryptReceivedMessages.name)
+
+ PreferenceManager.getDefaultSharedPreferences(context)
+ .edit()
+ .putBoolean(this.SETTINGS_ENCRYPT_RECEIVED_MESSAGES, status)
+ .apply()
+ }
+
+ fun encryptReceivedMessages(context: Context): Boolean {
+ Timber.d(Settings::encryptReceivedMessages.name)
+
+ val encryptReceivedMessages = PreferenceManager
+ .getDefaultSharedPreferences(context)
+ .getBoolean(this.SETTINGS_ENCRYPT_RECEIVED_MESSAGES,false)
+
+ Timber.d("SETTINGS_ENCRYPT_RECEIVED_MESSAGES: [$encryptReceivedMessages]")
+ return encryptReceivedMessages && !getEncryptionKey(context).isNullOrEmpty()
+ }
+
+ fun setEncryptionKey(context: Context, key: String?) {
+ Timber.d(Settings::setEncryptionKey.name)
+
+ PreferenceManager.getDefaultSharedPreferences(context)
+ .edit()
+ .putString(this.SETTINGS_ENCRYPTION_KEY, key)
+ .apply()
+ }
+
+ fun getEncryptionKey(context: Context): String? {
+ Timber.d(Settings::getEncryptionKey.name)
+
+ return PreferenceManager
+ .getDefaultSharedPreferences(context)
+ .getString(this.SETTINGS_ENCRYPTION_KEY, "")
+ }
+
+ fun setIncomingActiveSIM2(context: Context, status: Boolean) {
+ Timber.d(Settings::setIncomingActiveSIM2.name)
+
+ PreferenceManager.getDefaultSharedPreferences(context)
+ .edit()
+ .putBoolean(this.SETTINGS_SIM2_INCOMING_ACTIVE, status)
+ .apply()
+ }
+
+
+ fun getActiveStatus(context: Context, sim: String): Boolean {
+ var setting = this.SETTINGS_SIM1_ACTIVE
+ if (sim == Constants.SIM2) {
+ setting = this.SETTINGS_SIM2_ACTIVE
+ }
+ var activeStatus = PreferenceManager
+ .getDefaultSharedPreferences(context)
+ .getBoolean(setting,true)
+
+ if (sim == Constants.SIM2) {
+ activeStatus = activeStatus && isDualSIM(context)
+ }
+
+ Timber.d("SETTINGS_${sim}_ACTIVE: [$activeStatus]")
+ return activeStatus
+ }
+
+ fun setActiveStatusAsync(context: Context, status: Boolean, sim: String) {
+ Timber.d(Settings::setActiveStatusAsync.name)
+
+ var setting = this.SETTINGS_SIM1_ACTIVE
+ if (sim == Constants.SIM2) {
+ setting = this.SETTINGS_SIM2_ACTIVE
+ }
+
+ PreferenceManager.getDefaultSharedPreferences(context)
+ .edit()
+ .putBoolean(setting, status)
+ .apply()
+ }
+
+ fun isLoggedIn(context: Context): Boolean {
+ return getApiKey(context) != null && hasOwner(context)
+ }
+
+ fun isDualSIM(context: Context): Boolean {
+ return getSIM1PhoneNumber(context) != "" && getSIM2PhoneNumber(context) != ""
+ }
+
+ private fun getApiKey(context: Context): String?{
+ Timber.d(Settings::getApiKey.name)
+
+ val apiKey = PreferenceManager
+ .getDefaultSharedPreferences(context)
+ .getString(this.SETTINGS_API_KEY,null)
+
+ Timber.d("SETTINGS_API_KEY: [$apiKey]")
+ return apiKey
+ }
+
+ fun getApiKeyOrDefault(context:Context): String {
+ return getApiKey(context) ?: ""
+ }
+
+ fun isCharging(context: Context): Boolean {
+ val myBatteryManager = context.getSystemService(Context.BATTERY_SERVICE) as BatteryManager
+ return myBatteryManager.isCharging
+ }
+
+ fun setUserID(context:Context, userID: String?) {
+ Timber.d(Settings::setUserID.name)
+ PreferenceManager.getDefaultSharedPreferences(context)
+ .edit()
+ .putString(this.SETTINGS_USER_ID, userID)
+ .apply()
+ }
+
+ // getUserID don't log here as this will create recursion on the LogTail sink
+ fun getUserID(context:Context): String {
+ val userID = PreferenceManager
+ .getDefaultSharedPreferences(context)
+ .getString(this.SETTINGS_USER_ID,null)
+ return userID ?: ""
+ }
+
+ fun getServerUrlOrDefault(context:Context): URI {
+ val urlString = getServerUrl(context) ?: "https://api.httpsms.com"
+ return URI(urlString)
+ }
+
+ private fun getServerUrl(context: Context): String? {
+ Timber.d(Settings::getServerUrl.name)
+
+ val serverUrl = PreferenceManager
+ .getDefaultSharedPreferences(context)
+ .getString(this.SETTINGS_SERVER_URL,null)
+
+ Timber.d("SETTINGS_SERVER_URL: [$serverUrl]")
+ return serverUrl
+ }
+
+ fun setServerUrlAsync(context: Context, serverURL: String?) {
+ Timber.d(Settings::SETTINGS_SERVER_URL.name)
+
+ PreferenceManager.getDefaultSharedPreferences(context)
+ .edit()
+ .putString(this.SETTINGS_SERVER_URL, serverURL)
+ .apply()
+ }
+
+ fun setApiKeyAsync(context: Context, apiKey: String?) {
+ Timber.d(Settings::setApiKeyAsync.name)
+
+ PreferenceManager.getDefaultSharedPreferences(context)
+ .edit()
+ .putString(this.SETTINGS_API_KEY, apiKey)
+ .apply()
+ }
+
+ fun getFcmToken(context: Context): String?{
+ Timber.d(Settings::getFcmToken.name)
+
+ val activeStatus = PreferenceManager
+ .getDefaultSharedPreferences(context)
+ .getString(this.SETTINGS_FCM_TOKEN,null)
+
+ Timber.d("SETTINGS_FCM_TOKEN: [$activeStatus]")
+ return activeStatus
+ }
+
+ fun setFcmTokenAsync(context: Context, apiKey: String) {
+ Timber.d(Settings::setApiKeyAsync.name)
+
+ PreferenceManager.getDefaultSharedPreferences(context)
+ .edit()
+ .putString(this.SETTINGS_FCM_TOKEN, apiKey)
+ .apply()
+ }
+
+ fun getHeartbeatTimestamp(context: Context): Long {
+ Timber.d(Settings::getHeartbeatTimestamp.name)
+
+ val timestamp = PreferenceManager
+ .getDefaultSharedPreferences(context)
+ .getLong(this.SETTINGS_HEARTBEAT_TIMESTAMP,0)
+
+ Timber.d("SETTINGS_HEARTBEAT_TIMESTAMP: [$timestamp]")
+ return timestamp
+ }
+
+ fun currentTimestamp(): String {
+ return DateTimeFormatter.ofPattern(Constants.TIMESTAMP_PATTERN).format(
+ ZonedDateTime.now(ZoneOffset.UTC)
+ ).replace("+", "Z")
+ }
+
+
+ fun setHeartbeatTimestampAsync(context: Context, timestamp: Long) {
+ Timber.d(Settings::setHeartbeatTimestampAsync.name)
+
+ PreferenceManager.getDefaultSharedPreferences(context)
+ .edit()
+ .putLong(this.SETTINGS_HEARTBEAT_TIMESTAMP, timestamp)
+ .apply()
+ }
+}
diff --git a/android/app/src/main/java/com/httpsms/receivers/PhoneStateReceiver.kt b/android/app/src/main/java/com/httpsms/receivers/PhoneStateReceiver.kt
index 98ea87800..422c18c95 100644
--- a/android/app/src/main/java/com/httpsms/receivers/PhoneStateReceiver.kt
+++ b/android/app/src/main/java/com/httpsms/receivers/PhoneStateReceiver.kt
@@ -19,6 +19,7 @@ import androidx.work.workDataOf
import com.httpsms.Constants
import com.httpsms.HttpSmsApiService
import com.httpsms.Settings
+import com.httpsms.quietDelayCustom
import timber.log.Timber
import java.time.Instant
import java.time.ZoneOffset
@@ -77,6 +78,7 @@ class PhoneStateReceiver : BroadcastReceiver() {
val work = OneTimeWorkRequest
.Builder(MissedCallWorker::class.java)
+ .quietDelayCustom(context, owner) // CUSTOM: see QuietHoursCustom.kt
.setConstraints(constraints)
.setInputData(inputData)
.build()
diff --git a/android/app/src/main/java/com/httpsms/worker/HeartbeatWorker.kt b/android/app/src/main/java/com/httpsms/worker/HeartbeatWorker.kt
index 174f27425..b05892a72 100644
--- a/android/app/src/main/java/com/httpsms/worker/HeartbeatWorker.kt
+++ b/android/app/src/main/java/com/httpsms/worker/HeartbeatWorker.kt
@@ -5,6 +5,7 @@ import androidx.work.Worker
import androidx.work.WorkerParameters
import com.httpsms.Constants
import com.httpsms.HttpSmsApiService
+import com.httpsms.QuietHoursCustom
import com.httpsms.Settings
import timber.log.Timber
@@ -16,6 +17,8 @@ class HeartbeatWorker(appContext: Context, workerParams: WorkerParameters) : Wor
return Result.failure()
}
+ if (QuietHoursCustom.deferHeartbeat(applicationContext)) return Result.success() // CUSTOM: see QuietHoursCustom.kt
+
val phoneNumbers = mutableListOf()
if (Settings.getActiveStatus(applicationContext, Constants.SIM1)) {
phoneNumbers.add(Settings.getSIM1PhoneNumber(applicationContext))
@@ -35,6 +38,7 @@ class HeartbeatWorker(appContext: Context, workerParams: WorkerParameters) : Wor
Settings.setHeartbeatTimestampAsync(applicationContext, System.currentTimeMillis())
Timber.d("Set the heartbeat timestamp")
+ QuietHoursCustom.refresh(applicationContext) // CUSTOM: see QuietHoursCustom.kt
} catch (exception: Exception) {
Timber.e(exception, "Failed to send [${phoneNumbers.joinToString()}] heartbeats to server")
return Result.failure()
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index 02dfa1b63..cdf0aaec1 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -7,15 +7,17 @@
Login With API Key
API Key
HTTP Sms Logo
- Get Your API Key at\nhttpsms.com/settings
+ Get Your API Key at\nsms.hollandworx.nl/settings
Log Out
e.g +18005550199 (international format)
- e.g https://api.httpsms.com
+ e.g https://sms-api.hollandworx.nl
Phone Number
Send Heartbeat
Server URL
- https://api.httpsms.com
- httpsms.com - %s
+
+ https://sms-api.hollandworx.nl
+ sms.hollandworx.nl - %s
Disable Battery Optimization
Enable SMS Permission
App Settings
@@ -32,4 +34,9 @@
Enable debug logs
Enable SIM1 missed call events
Enable SIM2 missed call events
+
+ How httpSMS uses your messages
+ httpSMS relays text messages between this phone and the server you configure.\n\nTo do that it needs access to SMS and call information on this device:\n\n\u2022 Text messages (SMS and MMS) received on this phone are forwarded to your server.\n\u2022 Messages you send from your server are sent out through this phone\'s SIM.\n\u2022 Missed call events are forwarded to your server, if you enable that in Settings.\n\nThis data is sent off this device to the server address you enter when you sign in. It is not sent anywhere else and is not shared with any third party.\n\nAndroid will now ask you to grant these permissions. You can revoke them at any time in Android Settings.
+ Continue
+ Not now
diff --git a/android/build.gradle.kts b/android/build.gradle.kts
index 3be829b64..627a725bc 100644
--- a/android/build.gradle.kts
+++ b/android/build.gradle.kts
@@ -10,8 +10,8 @@ buildscript {
}
plugins {
- id("com.android.application") version "9.2.1" apply false
- id("com.android.library") version "9.2.1" apply false
+ id("com.android.application") version "9.3.2" apply false
+ id("com.android.library") version "9.3.2" apply false
id("org.jetbrains.kotlin.android") version "2.4.0" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.4.0" apply false
}
diff --git a/android/gradlew b/android/gradlew
old mode 100644
new mode 100755
diff --git a/android/version.properties b/android/version.properties
new file mode 100644
index 000000000..2c8bb2c93
--- /dev/null
+++ b/android/version.properties
@@ -0,0 +1,15 @@
+# ONE number. Bump it before every release build / Play upload.
+#
+# It is both the versionCode and the versionName, so 1, 2, 3, 4, 5 ... and
+# nothing else to keep in sync. Play shows it as "2 (2)".
+#
+# versionCode must increase on every Play upload - Play rejects a re-used or
+# lower value permanently - and Android refuses to install an update whose
+# versionCode is lower than the installed one, so this matters for sideloading
+# too. It is also the trailing segment of the tester URL:
+# https://play.google.com/apps/test/nl.hollandworx.sms/
+#
+# versionName reaches the backend as the client-version header on every API
+# call (HttpSmsApiService) and appears in Logz, so bumping it is what makes a
+# release identifiable in server logs.
+versionCode=3
diff --git a/api/.dockerignore b/api/.dockerignore
new file mode 100644
index 000000000..46b6c53c1
--- /dev/null
+++ b/api/.dockerignore
@@ -0,0 +1,14 @@
+# CUSTOM. api/Dockerfile does `COPY . .` with this folder as the build context,
+# so anything here lands in the builder stage. ops/ is ours and must not.
+ops/
+
+# Secrets must never enter an image layer, even one that is later discarded.
+.env
+.env.local
+.env.*.local
+
+# Not inputs to a Go build.
+docker-compose.yml
+docker-compose.example.yml
+docker-compose.override.yml
+*.superseded
diff --git a/api/.env.docker b/api/.env.docker
deleted file mode 100644
index 235972f4d..000000000
--- a/api/.env.docker
+++ /dev/null
@@ -1,75 +0,0 @@
-ENV=local
-
-# This is the project-id of the firebase project you created in the setup instructions
-GCP_PROJECT_ID=httpsms-docker
-
-USE_HTTP_LOGGER=true
-
-# Set to "true" to enable feature entitlement checks (limits for free users).
-# Defaults to "false" for self-hosted deployments (no limits).
-ENTITLEMENT_ENABLED=false
-
-EVENTS_QUEUE_TYPE=emulator
-EVENTS_QUEUE_NAME=events-local
-EVENTS_QUEUE_ENDPOINT=http://localhost:8000/v1/events
-
-# This is the user API key for the system admin user that is used to authenticate requests to the /v1/events endpoint
-# You need to create a system user in the `users` table in your database and put the API key and ID of this user here
-EVENTS_QUEUE_USER_API_KEY=system-user-api-key
-EVENTS_QUEUE_USER_ID=system-user-id
-
-# This is the actual conetnt of your service account firebase-credentials.json file that you downloaded in the setup instructions
-# e.g FIREBASE_CREDENTIALS='{ "type": "service_account", "project_id": "httpsms-docker", "private_key_id":.....
-FIREBASE_CREDENTIALS=
-
-# This is the from name for your emails
-SMTP_FROM_NAME=httpSMS
-# This is the address where your email messages should come from. You should make sure this matches what is configured on your SMTP service
-SMTP_FROM_EMAIL=httpsms@local.com
-
-# These are the credentials for your SMTP email service
-SMTP_USERNAME=
-SMTP_PASSWORD=
-SMTP_HOST=smtp.mailtrap.io
-SMTP_PORT=2525
-
-# This is the URL of the application UI and it's used to generate links in emails
-APP_URL=http://localhost:3000
-
-# The name of the application you can set it to whatever you like
-APP_NAME=httpSMS
-
-# This is the port where the API server will run on
-APP_PORT=8000
-
-# Host for the swagger UI
-SWAGGER_HOST=localhost:8000
-
-# Postgresql Database connection string
-DATABASE_URL=postgresql://dbusername:dbpassword@postgres:5432/httpsms
-DATABASE_URL_DEDICATED=postgresql://dbusername:dbpassword@postgres:5432/httpsms
-
-# Redis connection string
-REDIS_URL=redis://@redis:6379
-
-# Google Cloud Storage bucket for MMS attachments. Leave empty to use in-memory storage.
-GCS_BUCKET_NAME=
-
-# [Optional] Axiom observability configuration
-# API token for Axiom (required for logging, traces, and metrics in production)
-AXIOM_TOKEN=
-# Dataset for logs and traces (e.g. "events")
-AXIOM_DATASET_EVENTS=
-# Dataset for metrics (e.g. "metrics")
-AXIOM_DATASET_METRICS=
-
-
-# [optional] Websocket configuration for https://pusher.com if you will like to frontend to update in real time
-PUSHER_APP_ID=
-PUSHER_KEY=
-PUSHER_SECRET=
-PUSHER_CLUSTER=
-
-# Cloudflare Turnstile secret key for validating captcha tokens on the /v1/messages/search route
-# Get your secret key at https://developers.cloudflare.com/turnstile/get-started/
-CLOUDFLARE_TURNSTILE_SECRET_KEY=
diff --git a/api/.env.production b/api/.env.production
deleted file mode 100644
index 49226775e..000000000
--- a/api/.env.production
+++ /dev/null
@@ -1,32 +0,0 @@
-ENV=production
-
-GCP_PROJECT_ID=
-
-APP_HTTP_LOGGER=
-
-EVENTS_QUEUE_TYPE=
-EVENTS_QUEUE_USER_API_KEY=
-EVENTS_QUEUE_NAME=
-EVENTS_QUEUE_USER_ID=
-EVENTS_QUEUE_ENDPOINT=
-
-FIREBASE_CREDENTIALS=
-
-SMTP_FROM_NAME=
-SMTP_FROM_EMAIL=
-SMTP_USERNAME=
-SMTP_PASSWORD=
-SMTP_HOST=
-SMTP_PORT=
-
-APP_URL=
-APP_NAME=
-APP_PORT=8000
-APP_HOST=
-
-DATABASE_URL=
-
-REDIS_URL=
-
-LEMONSQUEEZY_API_KEY=
-LEMONSQUEEZY_SIGNING_SECRET=
diff --git a/api/.gitignore b/api/.gitignore
index 19bdf4df0..cd6f4ee12 100644
--- a/api/.gitignore
+++ b/api/.gitignore
@@ -9,3 +9,10 @@ $path
!.env.vault
!.env.docker
cmd/experiments/*
+.DS_Store
+
+# Kernel core dumps (created on crash, can be hundreds of MB).
+# The dev container now runs with `ulimit -c 0`, so these should stop
+# appearing - the pattern stays for anything already on disk elsewhere.
+core
+core.[0-9]*
\ No newline at end of file
diff --git a/api/docker-compose.example.yml b/api/docker-compose.example.yml
new file mode 100644
index 000000000..b1c5644dc
--- /dev/null
+++ b/api/docker-compose.example.yml
@@ -0,0 +1,160 @@
+# httpsms_api — PRODUCTION compose (server)
+#
+# Split from the web UI deliberately, matching wapi / wapi_api / wapi_admin:
+# the two deploy, restart and roll back independently. The API is a Go binary
+# that talks to the database; the web UI is a static Nuxt build served by nginx
+# and only needs the API's PUBLIC url, which is baked in at build time. They
+# have no runtime dependency on each other, so there is no depends_on between
+# these two folders — and there could not be, since they are separate compose
+# projects.
+#
+# THE SERVER IS ON doc_net, so postgres and redis are reached DIRECTLY by
+# container name. No tunnel here, ever. The tunnel exists only for local dev
+# (nodejs/httpsms/docker-compose.override.yml), which is why the dev DATABASE_URL
+# names the local tunnel container and this one names the postgres host directly.
+#
+# postgres and redis
+# are SEPARATE compose projects on this host, so they can
+# never be depends_on targets. The API finds them over doc_net by container name.
+#
+# The image tag is FIXED, not interpolated. ops/deploy.sh loads the versioned
+# image from the release tarball and retags it to this name, so this project
+# needs only .env — `docker compose ps|logs|exec` work here exactly as in every
+# other repo. There is no second env file to remember.
+#
+# Rolling back is a retag; versioned tags stay in the local image store:
+# docker image ls 'httpsms-*'
+# docker tag httpsms-:
+# docker compose up -d --force-recreate
+
+networks:
+ doc_net:
+ external: true
+ name: doc_net
+
+services:
+ api:
+ image: httpsms-api:current
+ # NEVER PULL. This tag only ever exists locally — ops/deploy.sh creates it by
+ # retagging the image it just loaded from the release tarball. Without this,
+ # a missing tag makes compose try Docker Hub and fail with "pull access
+ # denied ... may require 'docker login'", which reads like a credentials
+ # problem and is actually "deploy.sh did not run, or is the old version that
+ # wrote ops/.release.env instead of retagging".
+ pull_policy: never
+ container_name: httpsms-api
+ restart: unless-stopped
+
+ # DO NOT publish on 127.0.0.1. Docker here is ROOTLESS, and a loopback-scoped
+ # publish makes rootlesskit bind the IPv4 loopback ONLY — there is no [::1]
+ # listener, so anything that dials the name `localhost` and gets ::1 first is
+ # refused. Every reverse_proxy in the host Caddyfile uses `localhost`. A bare
+ # host port gets both 0.0.0.0 and [::], which is what the rest of the fleet
+ # (wapi_api, php, n8n, vaultwarden) publishes and what Caddy can actually reach.
+ ports:
+ - "${HTTPSMS_API_PORT:-58013}:8000"
+
+ # The image entrypoint runs with --dotenv=false: the binary reads its config
+ # from the PROCESS ENVIRONMENT only, it never opens a .env of its own. Docker
+ # is what injects these. They are listed explicitly — the wapi_api pattern —
+ # so the contract is visible here instead of hiding in an unversioned file.
+ # Values interpolate from .env, which deploy.sh passes as --env-file.
+ environment:
+ ENV: ${ENV:-production}
+ # APP_HOST must stay EMPTY: main.go listens on "$APP_HOST:$APP_PORT", so a
+ # value here would bind inside the container and docker could not reach it.
+ APP_HOST: ${APP_HOST:-}
+ APP_PORT: ${APP_PORT:-8000}
+ # NO DEFAULT ON PURPOSE. This key is absent from .env, so under the old
+ # env_file the binary saw "" — any default here would be a silent
+ # behaviour change on the next deploy (it feeds AppName in
+ # pkg/di/container.go:1137, used in outgoing mail). Set it in .env if you
+ # want a real name; do not fix it here.
+ APP_NAME: ${APP_NAME:-}
+ APP_URL: ${APP_URL}
+ SWAGGER_HOST: ${SWAGGER_HOST}
+ USE_HTTP_LOGGER: ${USE_HTTP_LOGGER:-true}
+ ENTITLEMENT_ENABLED: ${ENTITLEMENT_ENABLED:-false}
+
+ # ─── Datastores (reached over doc_net by container name) ──────────────
+ DATABASE_URL: ${DATABASE_URL}
+ DATABASE_URL_DEDICATED: ${DATABASE_URL_DEDICATED}
+ REDIS_URL: ${REDIS_URL}
+
+ # ─── Events queue ─────────────────────────────────────────────────────
+ EVENTS_QUEUE_TYPE: ${EVENTS_QUEUE_TYPE:-emulator}
+ EVENTS_QUEUE_NAME: ${EVENTS_QUEUE_NAME:-events-local}
+ EVENTS_QUEUE_ENDPOINT: ${EVENTS_QUEUE_ENDPOINT}
+ EVENTS_QUEUE_USER_API_KEY: ${EVENTS_QUEUE_USER_API_KEY}
+ EVENTS_QUEUE_USER_ID: ${EVENTS_QUEUE_USER_ID}
+
+ # ─── Firebase / GCP (push notifications) ──────────────────────────────
+ GCP_PROJECT_ID: ${GCP_PROJECT_ID}
+ FIREBASE_CREDENTIALS: ${FIREBASE_CREDENTIALS}
+
+ # ─── Mail ─────────────────────────────────────────────────────────────
+ SMTP_HOST: ${SMTP_HOST}
+ SMTP_PORT: ${SMTP_PORT:-587}
+ SMTP_USERNAME: ${SMTP_USERNAME}
+ SMTP_PASSWORD: ${SMTP_PASSWORD}
+ SMTP_FROM_NAME: ${SMTP_FROM_NAME}
+ SMTP_FROM_EMAIL: ${SMTP_FROM_EMAIL}
+
+ APP_ACCESS_NOTIFY_EMAIL: ${APP_ACCESS_NOTIFY_EMAIL}
+
+ # ─── Anti-abuse ───────────────────────────────────────────────────────
+ CLOUDFLARE_TURNSTILE_SECRET_KEY: ${CLOUDFLARE_TURNSTILE_SECRET_KEY}
+
+ # ─── Attachment storage ───────────────────────────────────────────────
+ GCS_BUCKET_NAME: ${GCS_BUCKET_NAME:-}
+
+ # ─── Observability — pick ONE, or neither ─────────────────────────────
+ # The backend is chosen by WHICHEVER CREDENTIAL IS SET, in this order:
+ # AXIOM_TOKEN, then POSTHOG_API_KEY, else no-op. See
+ # pkg/di/container_telemetry_custom.go (CUSTOM: upstream always used
+ # Axiom, which 401-spammed the log every ~5s with no token).
+ #
+ # Uncommenting a line with an EMPTY value in .env changes nothing —
+ # selection tests for a non-empty string, not for presence.
+
+ # Axiom
+ # AXIOM_TOKEN: ${AXIOM_TOKEN}
+ # AXIOM_DATASET_EVENTS: ${AXIOM_DATASET_EVENTS}
+ # AXIOM_DATASET_METRICS: ${AXIOM_DATASET_METRICS}
+
+ # PostHog — PLACEHOLDER. The exporter is not implemented yet: setting
+ # POSTHOG_API_KEY logs one warning at boot and falls back to no-op, so it
+ # is safe to fill in early. POSTHOG_HOST is HOST ONLY, no scheme and no
+ # /v1/traces suffix (otlptracehttp.WithEndpoint wants "host:port"), and
+ # differs between PostHog's EU and US regions.
+ # POSTHOG_API_KEY: ${POSTHOG_API_KEY}
+ # POSTHOG_HOST: ${POSTHOG_HOST}
+
+ # ─── Realtime (Pusher) ────────────────────────────────────────────────
+ # PUSHER_APP_ID: ${PUSHER_APP_ID}
+ # PUSHER_KEY: ${PUSHER_KEY}
+ # PUSHER_SECRET: ${PUSHER_SECRET}
+ # PUSHER_CLUSTER: ${PUSHER_CLUSTER}
+
+ # THIS LIST MUST COVER EVERY KEY IN .env. It does not maintain itself:
+ # a key added to .env and not added here is SILENTLY dropped, and the
+ # binary sees an unset value with no error anywhere. ops/deploy.sh runs
+ # an env-parity gate that refuses to deploy when the two drift — that
+ # gate is the only reason this pattern is safe. Do not remove it.
+
+ # curl is present in the api image (api/Dockerfile, alpine stage).
+ healthcheck:
+ test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8000/health"]
+ interval: 10s
+ timeout: 5s
+ retries: 10
+ start_period: 20s
+
+ logging:
+ driver: "json-file"
+ options:
+ max-size: "10m"
+ max-file: "3"
+
+ networks:
+ - doc_net
diff --git a/api/pkg/di/container.go b/api/pkg/di/container.go
index 8d9e778d4..9937b98ed 100644
--- a/api/pkg/di/container.go
+++ b/api/pkg/di/container.go
@@ -1002,6 +1002,9 @@ func (container *Container) BillingService() (service *services.BillingService)
container.InMemoryCache(),
container.Mailer(),
container.UserEmailFactory(),
+ // CUSTOM: same switch as EntitlementService() below — off means no
+ // monthly message ceiling and no "approaching your limit" mail.
+ os.Getenv("ENTITLEMENT_ENABLED") == "true",
container.BillingUsageRepository(),
container.UserRepository(),
)
@@ -1790,12 +1793,14 @@ func (container *Container) RegisterPhoneRoutes() {
container.logger.Debug(fmt.Sprintf("registering %T routes", &handlers.PhoneHandler{}))
container.PhoneHandler().RegisterRoutes(container.App(), container.AuthenticatedMiddleware())
container.PhoneHandler().RegisterPhoneAPIKeyRoutes(container.App(), container.PhoneAPIKeyMiddleware(), container.AuthenticatedMiddleware())
+ container.registerQuietHoursCustom() // CUSTOM: see container_quiet_hours_custom.go
}
// RegisterUserRoutes registers routes for the /users prefix
func (container *Container) RegisterUserRoutes() {
container.logger.Debug(fmt.Sprintf("registering %T routes", &handlers.UserHandler{}))
container.UserHandler().RegisterRoutes(container.App(), container.AuthenticatedMiddleware())
+ container.registerAppAccessCustom() // CUSTOM: see container_app_access_custom.go
}
// RegisterMessageSendScheduleRoutes registers routes for the /send-schedules prefix
@@ -1912,8 +1917,13 @@ func (container *Container) UserRistrettoCache() *ristretto.Cache[string, entiti
}
// InitializeTraceProvider initializes the open telemetry trace provider
+//
+// CUSTOM: upstream calls initializeAxiomTraceProvider unconditionally, which
+// 401-spams the log every batch when AXIOM_TOKEN is empty. The backend choice
+// now lives in container_telemetry_custom.go — keep this delegation one line so
+// upstream merges stay trivial.
func (container *Container) InitializeTraceProvider() func() {
- return container.initializeAxiomTraceProvider(container.version, container.projectID)
+ return container.initializeTraceProviderCustom(container.version, container.projectID)
}
func (container *Container) initializeGoogleTraceProvider(version string, namespace string) func() {
@@ -2044,11 +2054,12 @@ func logger(skipFrameCount int) telemetry.Logger {
)
}
+// CUSTOM: delegated to logDriverCustom in container_telemetry_custom.go.
+// Upstream sends every non-local deployment to axiomLogger, which log.Fatal()s
+// when AXIOM_TOKEN is absent — so a self-hosted production API could not boot at
+// all. Keep this one line so upstream merges stay trivial.
func logDriver(skipFrameCount int) *zerodriver.Logger {
- if isLocal() {
- return consoleLogger(skipFrameCount)
- }
- return axiomLogger(skipFrameCount)
+ return logDriverCustom(skipFrameCount)
}
func axiomLogger(skipFrameCount int) *zerodriver.Logger {
diff --git a/api/pkg/di/container_app_access_custom.go b/api/pkg/di/container_app_access_custom.go
new file mode 100644
index 000000000..0318a317c
--- /dev/null
+++ b/api/pkg/di/container_app_access_custom.go
@@ -0,0 +1,26 @@
+package di
+
+import (
+ "fmt"
+ "os"
+
+ "github.com/NdoleStudio/httpsms/pkg/handlers"
+)
+
+// CUSTOM FILE — not upstream. Wires the app-access request form
+// (handlers/app_access_handler_custom.go) in. container.go calls
+// registerAppAccessCustom from RegisterUserRoutes and nowhere else, so an
+// upstream merge touches one line.
+//
+// Requests go to APP_ACCESS_NOTIFY_EMAIL, falling back to SMTP_FROM_EMAIL (the
+// operator's own sending address) so an install that sets only SMTP still works.
+func (container *Container) registerAppAccessCustom() {
+ notifyEmail := os.Getenv("APP_ACCESS_NOTIFY_EMAIL")
+ if notifyEmail == "" {
+ notifyEmail = os.Getenv("SMTP_FROM_EMAIL")
+ }
+
+ handler := handlers.NewAppAccessHandlerCustom(container.Logger(), container.Tracer(), container.Mailer(), notifyEmail)
+ container.logger.Debug(fmt.Sprintf("registering %T routes", handler))
+ handler.RegisterRoutes(container.App(), container.AuthenticatedMiddleware())
+}
diff --git a/api/pkg/di/container_quiet_hours_custom.go b/api/pkg/di/container_quiet_hours_custom.go
new file mode 100644
index 000000000..db5076b35
--- /dev/null
+++ b/api/pkg/di/container_quiet_hours_custom.go
@@ -0,0 +1,22 @@
+package di
+
+import (
+ "fmt"
+
+ "github.com/NdoleStudio/httpsms/pkg/handlers"
+ "github.com/NdoleStudio/httpsms/pkg/services"
+)
+
+// CUSTOM FILE — not upstream. Wires quiet hours (services/quiet_hours_custom.go)
+// in. container.go calls registerQuietHoursCustom from RegisterPhoneRoutes and
+// nowhere else, so an upstream merge touches one line.
+func (container *Container) registerQuietHoursCustom() {
+ quietHours := services.SetQuietHoursCustom(
+ container.PhoneRepository(),
+ container.MessageSendScheduleRepository(),
+ )
+
+ handler := handlers.NewQuietHoursHandlerCustom(container.Logger(), container.Tracer(), quietHours)
+ container.logger.Debug(fmt.Sprintf("registering %T routes", handler))
+ handler.RegisterPhoneAPIKeyRoutes(container.App(), container.PhoneAPIKeyMiddleware(), container.AuthenticatedMiddleware())
+}
diff --git a/api/pkg/di/container_telemetry_custom.go b/api/pkg/di/container_telemetry_custom.go
new file mode 100644
index 000000000..9e5ad4c46
--- /dev/null
+++ b/api/pkg/di/container_telemetry_custom.go
@@ -0,0 +1,136 @@
+package di
+
+import (
+ "context"
+ "os"
+
+ "go.opentelemetry.io/otel"
+ "go.opentelemetry.io/otel/propagation"
+ "go.opentelemetry.io/otel/sdk/trace"
+
+ "github.com/NdoleStudio/stacktrace"
+ "github.com/hirosassa/zerodriver"
+)
+
+// CUSTOM FILE — not upstream. Keep all local telemetry changes here so that
+// pulling a new httpSMS release touches container.go in one place only: the body
+// of InitializeTraceProvider, which delegates to initializeTraceProviderCustom
+// below.
+//
+// WHY THIS EXISTS
+//
+// Upstream's InitializeTraceProvider calls initializeAxiomTraceProvider
+// unconditionally. That builds an OTLP exporter with the header
+// "Authorization: Bearer " + AXIOM_TOKEN whether or not the token is set, so a
+// self-hosted deployment with no Axiom account emits
+//
+// traces export: failed to send to https://us-east-1.aws.edge.axiom.co/v1/traces:
+// 401 Unauthorized (body: {"code":401,"message":"auth token not provided"})
+//
+// on every batch, roughly every five seconds. Nothing breaks, but it churns the
+// container's json-file log (max-size 10m, max-file 3) and rotates away the
+// startup errors that are actually worth keeping.
+//
+// Note this is NOT reachable through OTEL_SDK_DISABLED: that variable is only
+// honoured by the SDK's autoconfiguration package, and the providers here are
+// constructed by hand.
+
+// logDriverCustom picks the LOG driver. Called from logDriver in container.go.
+//
+// THIS IS WHAT STOPPED PRODUCTION BOOTING, and it is a different code path from
+// the trace provider below — patching one does not fix the other.
+//
+// Upstream:
+//
+// if isLocal() { return consoleLogger(...) }
+// return axiomLogger(...) // log.Fatal on error
+//
+// isLocal() is `ENV == "local"`, so dev took the console branch and looked fine
+// while every non-local deployment went to axiomLogger — which calls log.Fatal
+// with "cannot create axiom zerolog writer / Caused by: missing token" when
+// AXIOM_TOKEN is unset. log.Fatal is os.Exit(1): the container died at boot, the
+// deploy's /health gate waited out its full 60 retries, and the only clue was a
+// message that reads like a warning.
+//
+// Falling back to the console driver is also simply correct for a container:
+// stdout/stderr is where docker collects logs from.
+func logDriverCustom(skipFrameCount int) *zerodriver.Logger {
+ if isLocal() || os.Getenv("AXIOM_TOKEN") == "" {
+ // The LOG LEVEL is deliberately NOT set here. telemetry.NewZerologLogger
+ // calls SetGlobalLevel on every construction and would overwrite anything
+ // chosen at this point — which is what made an earlier version of this
+ // fix look correct while changing nothing. The single source of truth is
+ // telemetry.GlobalLevelCustom, called from there.
+ return consoleLogger(skipFrameCount)
+ }
+ return axiomLogger(skipFrameCount)
+}
+
+// initializeTraceProviderCustom picks a telemetry backend from the environment.
+//
+// The selection is by CREDENTIAL, not by a mode flag: a backend is used when its
+// token is present, which means an unconfigured deployment silently does the
+// right thing instead of needing a switch set correctly.
+func (container *Container) initializeTraceProviderCustom(version string, namespace string) func() {
+ switch {
+ case os.Getenv("AXIOM_TOKEN") != "":
+ return container.initializeAxiomTraceProvider(version, namespace)
+
+ case os.Getenv("POSTHOG_API_KEY") != "":
+ return container.initializePosthogTraceProvider(version, namespace)
+
+ default:
+ container.logger.Info("no telemetry backend configured (AXIOM_TOKEN and POSTHOG_API_KEY are both empty); traces and metrics are disabled")
+ return container.initializeNoopTraceProvider(version, namespace)
+ }
+}
+
+// initializeNoopTraceProvider installs a TracerProvider with NO span processor.
+//
+// Spans are still created, so every tracer.Start call site keeps working and
+// nothing has to become conditional — they are simply never exported, and no
+// network call is ever attempted. This is deliberately the SDK provider rather
+// than trace/noop: the SDK one still carries the resource attributes, so if a
+// backend is configured later the span shape does not change.
+func (container *Container) initializeNoopTraceProvider(version string, namespace string) func() {
+ tp := trace.NewTracerProvider(
+ trace.WithResource(container.OtelResources(version, namespace)),
+ )
+ otel.SetTracerProvider(tp)
+
+ otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
+ propagation.TraceContext{},
+ propagation.Baggage{},
+ ))
+
+ return func() {
+ if err := tp.Shutdown(context.Background()); err != nil {
+ container.logger.Error(stacktrace.Propagatef(err, "cannot shutdown noop trace provider"))
+ }
+ }
+}
+
+// initializePosthogTraceProvider is a PLACEHOLDER — it does not export anything
+// yet, and says so rather than pretending to work.
+//
+// It exists so that turning PostHog on later is a change in ONE function with a
+// wiring point that already resolves, instead of an edit to upstream's
+// container.go. To implement it:
+//
+// 1. Set POSTHOG_HOST to the OTLP ingest endpoint, HOST ONLY, no scheme and no
+// /v1/traces suffix — otlptracehttp.WithEndpoint expects "host:port", and
+// passing a URL is the usual reason a first attempt 404s. EU projects and
+// US projects have different hosts.
+// 2. Swap the body for an otlptracehttp/otlpmetrichttp pair modelled on
+// initializeAxiomTraceProvider, with the project API key as the auth header
+// PostHog documents at the time.
+// 3. Do NOT copy Axiom's Fatal-on-error handling. Telemetry is optional here;
+// a misconfigured observability backend must never stop the API from
+// booting. Log and fall through to noop instead.
+//
+// Until then any POSTHOG_API_KEY value degrades to the no-op provider, so
+// setting it early is harmless.
+func (container *Container) initializePosthogTraceProvider(version string, namespace string) func() {
+ container.logger.Warn(stacktrace.NewError("POSTHOG_API_KEY is set but the PostHog exporter is not implemented; falling back to no-op telemetry. See initializePosthogTraceProvider in container_telemetry_custom.go"))
+ return container.initializeNoopTraceProvider(version, namespace)
+}
diff --git a/api/pkg/handlers/app_access_handler_custom.go b/api/pkg/handlers/app_access_handler_custom.go
new file mode 100644
index 000000000..faa73d907
--- /dev/null
+++ b/api/pkg/handlers/app_access_handler_custom.go
@@ -0,0 +1,156 @@
+package handlers
+
+import (
+ "context"
+ "fmt"
+ "html"
+ "net/mail"
+ "net/url"
+ "strings"
+ "sync"
+ "time"
+ "unicode"
+
+ "github.com/NdoleStudio/httpsms/pkg/emails"
+ "github.com/NdoleStudio/httpsms/pkg/telemetry"
+ "github.com/NdoleStudio/stacktrace"
+ "github.com/gofiber/fiber/v3"
+)
+
+// CUSTOM FILE — not upstream. POST /v1/app-access-requests lets a signed-in
+// user ask for the Android app.
+//
+// WHY
+//
+// Upstream links straight to its own APK (apk.httpsms.com and the GitHub
+// release). That build is tied to upstream's Firebase project, so against this
+// server it can neither sign in nor receive push — it cannot work. Our own
+// build is distributed as a Google Play test app, and Play only lets a tester
+// in once their Google account is added in the Play Console. So instead of a
+// download, the web app shows a form (web/app/pages/app-access_custom.vue) and
+// this handler emails the request to the operator, who adds the tester by hand.
+//
+// Nothing is stored: the email is the whole record.
+
+// appAccessCooldownCustom is how long a user must wait between requests, so a
+// double click or a stuck retry cannot flood the operator's inbox.
+const appAccessCooldownCustom = 10 * time.Minute
+
+// AppAccessHandlerCustom forwards app-access requests to the operator.
+type AppAccessHandlerCustom struct {
+ handler
+ logger telemetry.Logger
+ tracer telemetry.Tracer
+ mailer emails.Mailer
+ notifyEmail string
+ lastSent sync.Map // entities.UserID -> time.Time
+}
+
+// AppAccessRequestCustom is the form the web app posts.
+type AppAccessRequestCustom struct {
+ Name string `json:"name"`
+ GooglePlay string `json:"google_play_email"`
+ Note string `json:"note"`
+}
+
+// NewAppAccessHandlerCustom creates a new AppAccessHandlerCustom. notifyEmail
+// is where requests are sent.
+func NewAppAccessHandlerCustom(logger telemetry.Logger, tracer telemetry.Tracer, mailer emails.Mailer, notifyEmail string) (h *AppAccessHandlerCustom) {
+ return &AppAccessHandlerCustom{
+ logger: logger.WithService(fmt.Sprintf("%T", h)),
+ tracer: tracer,
+ mailer: mailer,
+ notifyEmail: strings.TrimSpace(notifyEmail),
+ }
+}
+
+// RegisterRoutes registers the route for signed-in users.
+func (h *AppAccessHandlerCustom) RegisterRoutes(router fiber.Router, middlewares ...fiber.Handler) {
+ h.register(router, fiber.MethodPost, "/v1/app-access-requests", middlewares, h.Store)
+}
+
+// Store validates the form and emails it to the operator.
+func (h *AppAccessHandlerCustom) Store(c fiber.Ctx) error {
+ ctx, span := h.tracer.StartFromFiberCtx(c)
+ defer span.End()
+
+ ctxLogger := h.tracer.CtxLogger(h.logger, span)
+
+ var request AppAccessRequestCustom
+ if err := c.Bind().Body(&request); err != nil {
+ return h.responseBadRequest(c, stacktrace.Propagatef(err, "cannot parse app access request"))
+ }
+ request.Name = strings.TrimSpace(request.Name)
+ request.GooglePlay = strings.TrimSpace(request.GooglePlay)
+ request.Note = strings.TrimSpace(request.Note)
+
+ if errors := h.validate(request); len(errors) > 0 {
+ return h.responseUnprocessableEntity(c, errors, "validation errors while requesting app access")
+ }
+
+ user := h.userFromContext(c)
+ if last, ok := h.lastSent.Load(user.ID); ok && time.Since(last.(time.Time)) < appAccessCooldownCustom {
+ return h.responseAccepted(c, "your request was already sent, we will be in touch")
+ }
+
+ if h.notifyEmail == "" {
+ ctxLogger.Error(stacktrace.NewError("APP_ACCESS_NOTIFY_EMAIL and SMTP_FROM_EMAIL are both empty, cannot forward app access request"))
+ return h.responseInternalServerError(c)
+ }
+
+ if err := h.send(ctx, user.Email, string(user.ID), request); err != nil {
+ ctxLogger.Error(stacktrace.Propagatef(err, "cannot send app access request for user [%s]", user.ID))
+ return h.responseInternalServerError(c)
+ }
+
+ h.lastSent.Store(user.ID, time.Now())
+ return h.responseAccepted(c, "your request has been sent, we will be in touch")
+}
+
+func (h *AppAccessHandlerCustom) validate(request AppAccessRequestCustom) url.Values {
+ errors := url.Values{}
+ // The name goes into the Subject header, so no control characters (CR/LF
+ // would let a user inject headers).
+ if request.Name == "" || len(request.Name) > 100 || strings.ContainsFunc(request.Name, unicode.IsControl) {
+ errors.Add("name", "The name field is required and must be at most 100 characters")
+ }
+ if address, err := mail.ParseAddress(request.GooglePlay); err != nil || address.Address != request.GooglePlay || len(request.GooglePlay) > 254 {
+ errors.Add("google_play_email", "The google play email field must be a valid email address")
+ }
+ if len(request.Note) > 1000 {
+ errors.Add("note", "The note field must be at most 1000 characters")
+ }
+ return errors
+}
+
+func (h *AppAccessHandlerCustom) send(ctx context.Context, accountEmail, userID string, request AppAccessRequestCustom) error {
+ note := request.Note
+ if note == "" {
+ note = "-"
+ }
+
+ rows := [][2]string{
+ {"Name", request.Name},
+ {"Google Play email", request.GooglePlay},
+ {"Account email", accountEmail},
+ {"User ID", userID},
+ {"Note", note},
+ }
+
+ var text, table strings.Builder
+ for _, row := range rows {
+ text.WriteString(fmt.Sprintf("%s: %s\n", row[0], row[1]))
+ table.WriteString(fmt.Sprintf("| %s | %s |
", row[0], html.EscapeString(row[1])))
+ }
+ text.WriteString("\nAdd the Google Play email as a tester in the Play Console to grant access.\n")
+
+ return h.mailer.Send(ctx, &emails.Email{
+ ToEmail: h.notifyEmail,
+ Subject: fmt.Sprintf("App access request from %s", request.Name),
+ Text: text.String(),
+ HTML: fmt.Sprintf(
+ "A user has asked for access to the Android app.
Add the Google Play email as a tester in the Play Console to grant access.
",
+ table.String(),
+ ),
+ })
+}
diff --git a/api/pkg/handlers/app_access_handler_custom_test.go b/api/pkg/handlers/app_access_handler_custom_test.go
new file mode 100644
index 000000000..188e0ba94
--- /dev/null
+++ b/api/pkg/handlers/app_access_handler_custom_test.go
@@ -0,0 +1,34 @@
+package handlers
+
+import (
+ "testing"
+)
+
+// CUSTOM FILE — not upstream. Tests for app_access_handler_custom.go.
+
+func TestAppAccessValidateCustom(t *testing.T) {
+ h := &AppAccessHandlerCustom{}
+ valid := AppAccessRequestCustom{Name: "Jane", GooglePlay: "jane@gmail.com"}
+
+ if errors := h.validate(valid); len(errors) != 0 {
+ t.Fatalf("expected no errors, got %v", errors)
+ }
+
+ cases := map[string]struct {
+ request AppAccessRequestCustom
+ field string
+ }{
+ "empty name": {AppAccessRequestCustom{GooglePlay: "jane@gmail.com"}, "name"},
+ "header injection": {AppAccessRequestCustom{Name: "Jane\r\nBcc: x@y.z", GooglePlay: "jane@gmail.com"}, "name"},
+ "bad email": {AppAccessRequestCustom{Name: "Jane", GooglePlay: "not-an-email"}, "google_play_email"},
+ "display name in mail": {AppAccessRequestCustom{Name: "Jane", GooglePlay: "Jane "}, "google_play_email"},
+ "long note": {AppAccessRequestCustom{Name: "Jane", GooglePlay: "jane@gmail.com", Note: string(make([]byte, 1001))}, "note"},
+ }
+ for name, tc := range cases {
+ t.Run(name, func(t *testing.T) {
+ if errors := h.validate(tc.request); errors.Get(tc.field) == "" {
+ t.Fatalf("expected an error on %q, got %v", tc.field, errors)
+ }
+ })
+ }
+}
diff --git a/api/pkg/handlers/phone_handler_custom.go b/api/pkg/handlers/phone_handler_custom.go
new file mode 100644
index 000000000..ee0191580
--- /dev/null
+++ b/api/pkg/handlers/phone_handler_custom.go
@@ -0,0 +1,83 @@
+package handlers
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/NdoleStudio/httpsms/pkg/entities"
+ "github.com/NdoleStudio/httpsms/pkg/repositories"
+ "github.com/NdoleStudio/httpsms/pkg/services"
+ "github.com/NdoleStudio/httpsms/pkg/telemetry"
+ "github.com/NdoleStudio/stacktrace"
+ "github.com/gofiber/fiber/v3"
+)
+
+// CUSTOM FILE — not upstream. GET /v1/phones/quiet-hours tells the Android app
+// when its phone is quiet, so the app can stop its own heartbeats and uploads
+// for the same hours the server holds messages. See
+// services/quiet_hours_custom.go for what quiet hours are and why.
+
+// QuietHoursHandlerCustom serves a phone's quiet-hours schedule.
+type QuietHoursHandlerCustom struct {
+ handler
+ logger telemetry.Logger
+ tracer telemetry.Tracer
+ quietHours *services.QuietHoursCustom
+}
+
+// QuietHoursResponseCustom is the send schedule as the app needs it. No
+// windows means the phone is never quiet.
+type QuietHoursResponseCustom struct {
+ Owner string `json:"owner"`
+ Timezone string `json:"timezone"`
+ Windows []entities.MessageSendScheduleWindow `json:"windows"`
+}
+
+// NewQuietHoursHandlerCustom creates a new QuietHoursHandlerCustom
+func NewQuietHoursHandlerCustom(logger telemetry.Logger, tracer telemetry.Tracer, quietHours *services.QuietHoursCustom) (h *QuietHoursHandlerCustom) {
+ return &QuietHoursHandlerCustom{
+ logger: logger.WithService(fmt.Sprintf("%T", h)),
+ tracer: tracer,
+ quietHours: quietHours,
+ }
+}
+
+// RegisterPhoneAPIKeyRoutes registers the route with the same middleware chain
+// as the app's other calls (heartbeats, fcm-token), so both phone API keys and
+// account API keys work.
+func (h *QuietHoursHandlerCustom) RegisterPhoneAPIKeyRoutes(router fiber.Router, middlewares ...fiber.Handler) {
+ h.register(router, fiber.MethodGet, "/v1/phones/quiet-hours", middlewares, h.Show)
+}
+
+// Show returns the quiet-hours schedule for ?owner=.
+func (h *QuietHoursHandlerCustom) Show(c fiber.Ctx) error {
+ ctx, span := h.tracer.StartFromFiberCtx(c)
+ defer span.End()
+
+ ctxLogger := h.tracer.CtxLogger(h.logger, span)
+
+ owner := strings.TrimSpace(c.Query("owner"))
+ if owner == "" {
+ return h.responseBadRequest(c, stacktrace.NewError("the owner query parameter is required"))
+ }
+
+ if !h.authorizePhoneAPIKey(c, owner) {
+ return h.responsePhoneAPIKeyUnauthorized(c, owner, h.userFromContext(c))
+ }
+
+ schedule, err := h.quietHours.ScheduleForOwner(ctx, h.userIDFomContext(c), owner)
+ if stacktrace.GetCode(err) == repositories.ErrCodeNotFound {
+ return h.responseNotFound(c, fmt.Sprintf("cannot find phone with number [%s]", owner))
+ }
+ if err != nil {
+ ctxLogger.Error(stacktrace.Propagatef(err, "cannot load quiet hours for phone [%s]", owner))
+ return h.responseInternalServerError(c)
+ }
+
+ response := QuietHoursResponseCustom{Owner: owner, Windows: []entities.MessageSendScheduleWindow{}}
+ if schedule != nil {
+ response.Timezone = schedule.Timezone
+ response.Windows = schedule.Windows
+ }
+ return h.responseOK(c, "fetched quiet hours", response)
+}
diff --git a/api/pkg/repositories/gorm_user_repository.go b/api/pkg/repositories/gorm_user_repository.go
index f7c74f318..febd3a58b 100644
--- a/api/pkg/repositories/gorm_user_repository.go
+++ b/api/pkg/repositories/gorm_user_repository.go
@@ -225,8 +225,11 @@ func (repository *gormUserRepository) LoadOrStore(ctx context.Context, authUser
Email: authUser.Email,
APIKey: "uk_" + apiKey,
SubscriptionName: entities.SubscriptionNameFree,
- CreatedAt: time.Now().UTC(),
- UpdatedAt: time.Now().UTC(),
+ // CUSTOM: was left unset, which fell through to the column default
+ // `Africa/Accra` on entities.User. See gorm_user_repository_custom.go.
+ Timezone: defaultTimezoneCustom(),
+ CreatedAt: time.Now().UTC(),
+ UpdatedAt: time.Now().UTC(),
}
isNew := false
diff --git a/api/pkg/repositories/gorm_user_repository_custom.go b/api/pkg/repositories/gorm_user_repository_custom.go
new file mode 100644
index 000000000..8bedf0fc1
--- /dev/null
+++ b/api/pkg/repositories/gorm_user_repository_custom.go
@@ -0,0 +1,50 @@
+package repositories
+
+import (
+ "os"
+ "strings"
+ "time"
+)
+
+// CUSTOM FILE — not upstream. Keep local repository changes here so pulling a
+// new httpSMS release touches gorm_user_repository.go in one place only: the
+// Timezone field of the struct literal in LoadOrStore.
+//
+// WHY THIS EXISTS
+//
+// entities.User declares:
+//
+// Timezone string `... gorm:"default:Africa/Accra"`
+//
+// and LoadOrStore never sets the field, so every new account inherits the
+// upstream author's own timezone from the COLUMN DEFAULT. On a Dutch
+// deployment that silently backdates or postdates everything a user reads —
+// message timestamps, the send-schedule window, heartbeat times — by an hour or
+// two depending on DST, with nothing anywhere reporting a problem.
+//
+// The struct tag cannot read the environment, so the value has to be supplied
+// at row-creation time instead. Existing rows are NOT touched: this only fills
+// in accounts created from here on. Change an existing one in Settings →
+// Timezone, which posts to the same field.
+//
+// DEFAULT_TIMEZONE is an IANA name, e.g. Europe/Amsterdam. Resolution works
+// because the api image sets ENV ZONEINFO=/zoneinfo.zip over the zoneinfo.zip
+// copied out of the Go toolchain (see api/Dockerfile) — without that,
+// LoadLocation would fail for every name but UTC.
+func defaultTimezoneCustom() string {
+ tz := strings.TrimSpace(os.Getenv("DEFAULT_TIMEZONE"))
+ if tz == "" {
+ return "UTC"
+ }
+
+ // Validate rather than trust. A typo stored here would be written to every
+ // new row and only surface later as a LoadLocation error deep in an email
+ // or scheduling path, far from the cause. UTC is a deliberate fallback: it
+ // is obviously neutral, so a wrong-looking clock points at this setting
+ // instead of looking like someone's plausible home timezone.
+ if _, err := time.LoadLocation(tz); err != nil {
+ return "UTC"
+ }
+
+ return tz
+}
diff --git a/api/pkg/services/billing_service.go b/api/pkg/services/billing_service.go
index e94002d1d..4f3313cdc 100644
--- a/api/pkg/services/billing_service.go
+++ b/api/pkg/services/billing_service.go
@@ -23,17 +23,21 @@ type BillingService struct {
cache cache.Cache
emailFactory emails.UserEmailFactory
mailer emails.Mailer
+ entitlementEnabled bool
userRepository repositories.UserRepository
billingUsageRepository repositories.BillingUsageRepository
}
-// NewBillingService creates a new BillingService
+// NewBillingService creates a new BillingService.
+// The entitlementEnabled flag should come from the ENTITLEMENT_ENABLED
+// environment variable, the same one that drives the EntitlementService.
func NewBillingService(
logger telemetry.Logger,
tracer telemetry.Tracer,
cache cache.Cache,
mailer emails.Mailer,
emailFactory emails.UserEmailFactory,
+ entitlementEnabled bool,
usageRepository repositories.BillingUsageRepository,
userRepository repositories.UserRepository,
) (s *BillingService) {
@@ -43,6 +47,7 @@ func NewBillingService(
cache: cache,
emailFactory: emailFactory,
mailer: mailer,
+ entitlementEnabled: entitlementEnabled,
userRepository: userRepository,
billingUsageRepository: usageRepository,
}
@@ -53,6 +58,15 @@ func (service *BillingService) IsEntitledWithCount(ctx context.Context, userID e
ctx, span, ctxLogger := service.tracer.StartWithLogger(ctx, service.logger)
defer span.End()
+ // CUSTOM: a self-hosted install that sells nothing has no plan to exceed.
+ // Upstream enforces SubscriptionName.Limit() unconditionally, so every user
+ // stops at 200 messages a month even when ENTITLEMENT_ENABLED is false and
+ // there is no checkout to upgrade through. Usage is still recorded below by
+ // RegisterSentMessage/RegisterReceivedMessage — only the ceiling is gone.
+ if !service.entitlementEnabled {
+ return nil
+ }
+
user, err := service.userRepository.Load(ctx, userID)
if err != nil {
ctxLogger.Error(service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot load user with ID [%s], entitlement successful", userID)))
@@ -182,6 +196,14 @@ func (service *BillingService) sendUsageAlert(ctx context.Context, userID entiti
ctx, span, ctxLogger := service.tracer.StartWithLogger(ctx, service.logger)
defer span.End()
+ // CUSTOM: no ceiling means nothing to warn about — "you have used 160 of
+ // your 200 messages" is a lie when the 200 is never enforced. Returning here
+ // rather than in shouldSendAlert also drops the two loads this does on every
+ // single sent and received message.
+ if !service.entitlementEnabled {
+ return
+ }
+
user, err := service.userRepository.Load(ctx, userID)
if err != nil {
ctxLogger.Error(service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot load user with ID [%s]", userID)))
diff --git a/api/pkg/services/heartbeat_service.go b/api/pkg/services/heartbeat_service.go
index d6c442347..ff7a5078e 100644
--- a/api/pkg/services/heartbeat_service.go
+++ b/api/pkg/services/heartbeat_service.go
@@ -299,6 +299,12 @@ func (service *HeartbeatService) Monitor(ctx context.Context, params *HeartbeatM
return nil
}
+ // CUSTOM: quiet hours — no probe, no offline alarm. See quiet_hours_custom.go.
+ heartbeat, quiet := quietMonitorCustom(ctx, heartbeat)
+ if quiet {
+ return service.scheduleHeartbeatCheck(ctx, heartbeat.Timestamp, params)
+ }
+
// send urgent FCM message if the last heartbeat is late
if time.Now().UTC().Sub(heartbeat.Timestamp) > heartbeatCheckInterval && time.Now().UTC().Sub(heartbeat.Timestamp) < (heartbeatCheckInterval*5) {
ctxLogger.Info(fmt.Sprintf("sending missed heartbeat notification for userID [%s] and owner [%s] and monitor ID [%s]", params.UserID, params.Owner, params.MonitorID))
diff --git a/api/pkg/services/message_service.go b/api/pkg/services/message_service.go
index 89c216817..da88d5027 100644
--- a/api/pkg/services/message_service.go
+++ b/api/pkg/services/message_service.go
@@ -929,6 +929,25 @@ func (service *MessageService) CheckExpired(ctx context.Context, params MessageC
return nil
}
+ // CUSTOM: status "sending" means the phone fetched the message after the
+ // push, which is the last delivery signal the api can rely on — the SENT
+ // report from the phone's radio is not guaranteed to arrive. Expiring it
+ // here would trigger a retry that sends the SMS a second time, so treat
+ // the fetch as the send. Only a message the phone never fetched (push
+ // lost) expires and is retried. A late FAILED event still overrides this.
+ if message.IsSending() {
+ sentAt := time.Now().UTC()
+ if message.LastAttemptedAt != nil {
+ sentAt = *message.LastAttemptedAt
+ }
+
+ ctxLogger.Info(fmt.Sprintf("message [%s] was fetched by the phone at [%s] without a SENT event, marking it as sent instead of expiring it", message.ID, sentAt))
+ if err = service.handleMessageSentEvent(ctx, MessageStoreEventParams{Source: params.Source, Timestamp: sentAt}, message); err != nil {
+ return service.tracer.WrapErrorSpan(span, stacktrace.Propagatef(err, "cannot mark fetched message [%s] as sent", message.ID))
+ }
+ return nil
+ }
+
event, err := service.createMessageSendExpiredEvent(params.Source, events.MessageSendExpiredPayload{
MessageID: message.ID,
Owner: message.Owner,
diff --git a/api/pkg/services/phone_notification_service.go b/api/pkg/services/phone_notification_service.go
index 2c63fcd7c..9220c2488 100644
--- a/api/pkg/services/phone_notification_service.go
+++ b/api/pkg/services/phone_notification_service.go
@@ -92,6 +92,11 @@ func (service *PhoneNotificationService) SendHeartbeatFCM(ctx context.Context, p
return service.tracer.WrapErrorSpan(span, stacktrace.NewErrorf("phone with id [%s] has no notification token", phone.ID))
}
+ // CUSTOM: this push wakes the radio — never during quiet hours. See quiet_hours_custom.go.
+ if quietNowCustom(ctx, phone) {
+ return nil
+ }
+
result, _, err := service.sendPhoneNotification(ctx, phone, &messaging.Message{
Data: map[string]string{
"KEY_HEARTBEAT_ID": time.Now().UTC().Format(time.RFC3339),
@@ -150,7 +155,39 @@ func (service *PhoneNotificationService) Send(ctx context.Context, params *Phone
"KEY_MESSAGE_ID": params.MessageID.String(),
},
Android: &messaging.AndroidConfig{
- Priority: "normal",
+ // CUSTOM: "high", not upstream's "normal".
+ //
+ // NORMAL-PRIORITY FCM IS NOT DELIVERED DURING DOZE. It is held by
+ // Play Services until the next maintenance window, which in deep
+ // Doze are 1h, 2h, 4h, then 6h apart. The phone is never told there
+ // is a message, so it never attempts one.
+ //
+ // This is NOT fixable on the Android side. onMessageReceived is not
+ // called, so no amount of expedited WorkManager, foreground service
+ // or inline sending helps — the push has not arrived yet.
+ //
+ // The battery allowlist does not rescue it either: that exempts the
+ // app from App Standby and Doze's job/network limits, but the FCM
+ // deferral happens in Play Services BEFORE the app is involved.
+ // Measured on an allowlisted, fully-permissioned device held in
+ // forced deep Doze: two messages sat at status=scheduled with
+ // last_attempted_at=null and were never attempted at all.
+ //
+ // It shows up in ordinary use too, as wildly variable latency on the
+ // same device — 1s, 47s, 92s, 10m, 53m — and as messages reaching
+ // max_send_attempts and expiring unsent.
+ //
+ // High priority is the documented, intended mechanism for exactly
+ // this: it wakes the device and grants a brief network window. Each
+ // push here corresponds to one user-initiated send, which is the
+ // time-sensitive/user-visible case Firebase asks for, so this is not
+ // at risk of the throttling that punishes apps using high priority
+ // for background chatter.
+ //
+ // The heartbeat above has always been "high", which is why the phone
+ // looks online and healthy while sends stall — the monitoring path
+ // was the one path never affected.
+ Priority: "high",
TTL: &ttl,
},
})
@@ -314,6 +351,7 @@ func (service *PhoneNotificationService) scheduleExact(
if scheduledAt.Before(time.Now().UTC()) {
scheduledAt = time.Now().UTC()
}
+ scheduledAt = quietResolveCustom(ctx, phone, scheduledAt) // CUSTOM: see quiet_hours_custom.go
notification.ScheduledAt = scheduledAt
if err := service.phoneNotificationRepository.ScheduleExact(ctx, notification); err != nil {
diff --git a/api/pkg/services/phone_notification_service_test.go b/api/pkg/services/phone_notification_service_test.go
index 9f0c42a46..4b004b8ce 100644
--- a/api/pkg/services/phone_notification_service_test.go
+++ b/api/pkg/services/phone_notification_service_test.go
@@ -201,7 +201,8 @@ func TestPhoneNotificationServiceSendUsesHTTPSMessage(t *testing.T) {
assert.Equal(t, endpoint, httpClient.message.Token)
assert.Equal(t, params.MessageID.String(), httpClient.message.Data["KEY_MESSAGE_ID"])
require.NotNil(t, httpClient.message.Android)
- assert.Equal(t, "normal", httpClient.message.Android.Priority)
+ // CUSTOM: "high", not upstream's "normal" — see Send() in phone_notification_service.go.
+ assert.Equal(t, "high", httpClient.message.Android.Priority)
require.NotNil(t, httpClient.message.Android.TTL)
assert.Equal(t, phone.MessageExpirationDuration(), *httpClient.message.Android.TTL)
require.Len(t, eventQueue.events, 1)
diff --git a/api/pkg/services/quiet_hours_custom.go b/api/pkg/services/quiet_hours_custom.go
new file mode 100644
index 000000000..401de92c5
--- /dev/null
+++ b/api/pkg/services/quiet_hours_custom.go
@@ -0,0 +1,193 @@
+package services
+
+import (
+ "context"
+ "time"
+
+ "github.com/NdoleStudio/httpsms/pkg/entities"
+ "github.com/NdoleStudio/httpsms/pkg/repositories"
+ "github.com/NdoleStudio/stacktrace"
+)
+
+// CUSTOM FILE — not upstream. Quiet hours for the gateway phone.
+//
+// WHAT "QUIET" MEANS
+//
+// A phone is quiet whenever it is OUTSIDE the send windows of the message send
+// schedule attached to it (Settings → phone → Message Send Schedule). No
+// schedule, or a schedule with no windows, means never quiet — upstream
+// behaviour, unchanged.
+//
+// WHY
+//
+// Every push the server sends is high priority (see Send in
+// phone_notification_service.go), and every high-priority push wakes the data
+// radio. On this phone a radio wake is not free: it brings the VPN up and fires
+// the "connectivity changed" event that a pile of other apps listen for. During
+// quiet hours httpSMS must cause NO network traffic at all, from either end.
+//
+// Server side that is three things, each a one-line hook in upstream code:
+//
+// 1. Outgoing messages already wait for the next window — upstream's
+// PhoneNotificationRepository.Schedule does that. Explicit send_at requests
+// skipped it; scheduleExact now clamps them too (quietResolveCustom).
+// 2. The missed-heartbeat probe is a high-priority push. SendHeartbeatFCM
+// does not send it while quiet.
+// 3. The phone goes silent on purpose, so HeartbeatService.Monitor must not
+// read that silence as "phone offline" and email about it.
+//
+// Expiry needs nothing: the expiry clock starts when the push is SENT
+// (ScheduleExpirationCheck uses NotificationSentAt), and a held message has not
+// been pushed yet.
+//
+// The app learns the same windows from GET /v1/phones/quiet-hours
+// (handlers/phone_handler_custom.go) and stops its own heartbeats and uploads to
+// match — android/.../QuietHoursCustom.kt.
+
+// QuietHoursCustom resolves a phone's quiet period from its send schedule.
+type QuietHoursCustom struct {
+ phoneRepository repositories.PhoneRepository
+ scheduleRepository repositories.MessageSendScheduleRepository
+}
+
+// quietHoursCustom is package state rather than a field on each service so that
+// no upstream constructor changes shape. Nil means the feature is not wired up,
+// and every check below then answers "not quiet".
+var quietHoursCustom *QuietHoursCustom
+
+// SetQuietHoursCustom wires quiet hours in. Called once from the DI container.
+func SetQuietHoursCustom(phoneRepository repositories.PhoneRepository, scheduleRepository repositories.MessageSendScheduleRepository) *QuietHoursCustom {
+ quietHoursCustom = &QuietHoursCustom{
+ phoneRepository: phoneRepository,
+ scheduleRepository: scheduleRepository,
+ }
+ return quietHoursCustom
+}
+
+// ScheduleForOwner returns the send schedule attached to the phone with this
+// number, or nil when it has none.
+func (q *QuietHoursCustom) ScheduleForOwner(ctx context.Context, userID entities.UserID, owner string) (*entities.MessageSendSchedule, error) {
+ phone, err := q.phoneRepository.Load(ctx, userID, owner)
+ if err != nil {
+ return nil, stacktrace.Propagatef(err, "cannot load phone [%s] for user [%s]", owner, userID)
+ }
+ return q.ScheduleForPhone(ctx, phone)
+}
+
+// ScheduleForPhone returns the send schedule attached to a phone, or nil when
+// it has none.
+func (q *QuietHoursCustom) ScheduleForPhone(ctx context.Context, phone *entities.Phone) (*entities.MessageSendSchedule, error) {
+ if phone == nil || phone.MessageSendScheduleID == nil {
+ return nil, nil
+ }
+ schedule, err := q.scheduleRepository.Load(ctx, phone.UserID, *phone.MessageSendScheduleID)
+ if stacktrace.GetCode(err) == repositories.ErrCodeNotFound {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, stacktrace.Propagatef(err, "cannot load send schedule [%s] for phone [%s]", *phone.MessageSendScheduleID, phone.ID)
+ }
+ return schedule, nil
+}
+
+// quietNowCustom reports whether the phone is outside its send windows right
+// now. Any failure to decide answers false: the fallback is upstream behaviour.
+func quietNowCustom(ctx context.Context, phone *entities.Phone) bool {
+ if quietHoursCustom == nil {
+ return false
+ }
+ schedule, err := quietHoursCustom.ScheduleForPhone(ctx, phone)
+ if err != nil || schedule == nil {
+ return false
+ }
+ now := time.Now().UTC()
+ return schedule.ResolveScheduledAt(now).After(now)
+}
+
+// quietNowByOwnerCustom is quietNowCustom for callers that only hold the
+// phone number.
+func quietNowByOwnerCustom(ctx context.Context, userID entities.UserID, owner string) bool {
+ if quietHoursCustom == nil {
+ return false
+ }
+ phone, err := quietHoursCustom.phoneRepository.Load(ctx, userID, owner)
+ if err != nil {
+ return false
+ }
+ return quietNowCustom(ctx, phone)
+}
+
+// quietMonitorCustom is the heartbeat monitor's view of quiet hours. It returns
+// quiet=true while the phone is inside quiet hours: the caller then skips both
+// the missed-heartbeat push and the offline alarm.
+//
+// Outside quiet hours it returns a heartbeat whose Timestamp is never earlier
+// than the start of the current window. Upstream only reacts while the last
+// heartbeat is 16–80 minutes old, so after a quiet night — last heartbeat hours
+// ago — it would never react again, and a phone that failed to check in when
+// the window opened would go unnoticed. Measuring from the window start gives
+// the phone the normal grace period after quiet hours end, then the normal probe
+// and alarm. The returned value is a copy; the caller's heartbeat is untouched.
+func quietMonitorCustom(ctx context.Context, heartbeat *entities.Heartbeat) (*entities.Heartbeat, bool) {
+ if quietHoursCustom == nil || heartbeat == nil {
+ return heartbeat, false
+ }
+ phone, err := quietHoursCustom.phoneRepository.Load(ctx, heartbeat.UserID, heartbeat.Owner)
+ if err != nil {
+ return heartbeat, false
+ }
+ schedule, err := quietHoursCustom.ScheduleForPhone(ctx, phone)
+ if err != nil || schedule == nil || len(schedule.Windows) == 0 {
+ return heartbeat, false
+ }
+
+ now := time.Now().UTC()
+ if schedule.ResolveScheduledAt(now).After(now) {
+ return heartbeat, true
+ }
+
+ windowStart, ok := quietCurrentWindowStartCustom(schedule, now)
+ if !ok || !heartbeat.Timestamp.Before(windowStart) {
+ return heartbeat, false
+ }
+ adjusted := *heartbeat
+ adjusted.Timestamp = windowStart
+ return &adjusted, false
+}
+
+// quietCurrentWindowStartCustom returns when the send window containing now
+// began. Same window arithmetic as MessageSendSchedule.ResolveScheduledAt.
+func quietCurrentWindowStartCustom(schedule *entities.MessageSendSchedule, now time.Time) (time.Time, bool) {
+ location, err := time.LoadLocation(schedule.Timezone)
+ if err != nil {
+ return time.Time{}, false
+ }
+ local := now.In(location)
+ midnight := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, location)
+ for _, window := range schedule.Windows {
+ if window.DayOfWeek != int(local.Weekday()) {
+ continue
+ }
+ start := midnight.Add(time.Duration(window.StartMinute) * time.Minute)
+ end := midnight.Add(time.Duration(window.EndMinute) * time.Minute)
+ if !local.Before(start) && local.Before(end) {
+ return start.UTC(), true
+ }
+ }
+ return time.Time{}, false
+}
+
+// quietResolveCustom moves an explicit send time out of quiet hours to the
+// start of the next window. Upstream lets send_at bypass the schedule; here a
+// 03:00 send_at would wake the radio at 03:00, which is exactly what quiet
+// hours exist to prevent.
+func quietResolveCustom(ctx context.Context, phone *entities.Phone, scheduledAt time.Time) time.Time {
+ if quietHoursCustom == nil {
+ return scheduledAt
+ }
+ schedule, err := quietHoursCustom.ScheduleForPhone(ctx, phone)
+ if err != nil || schedule == nil {
+ return scheduledAt
+ }
+ return schedule.ResolveScheduledAt(scheduledAt)
+}
diff --git a/api/pkg/services/quiet_hours_custom_test.go b/api/pkg/services/quiet_hours_custom_test.go
new file mode 100644
index 000000000..a713bc675
--- /dev/null
+++ b/api/pkg/services/quiet_hours_custom_test.go
@@ -0,0 +1,60 @@
+package services
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/NdoleStudio/httpsms/pkg/entities"
+ "github.com/stretchr/testify/assert"
+)
+
+// CUSTOM FILE — not upstream. Tests for quiet_hours_custom.go.
+
+func quietTestScheduleCustom() *entities.MessageSendSchedule {
+ // Open 07:00–23:00 Amsterdam time on Wednesday (weekday 3).
+ return &entities.MessageSendSchedule{
+ Timezone: "Europe/Amsterdam",
+ Windows: []entities.MessageSendScheduleWindow{
+ {DayOfWeek: 3, StartMinute: 7 * 60, EndMinute: 23 * 60},
+ },
+ }
+}
+
+func TestQuietCurrentWindowStartCustom(t *testing.T) {
+ schedule := quietTestScheduleCustom()
+ location, _ := time.LoadLocation("Europe/Amsterdam")
+
+ // Wednesday 2026-09-23 10:00 local is inside the window, which began at 07:00.
+ start, ok := quietCurrentWindowStartCustom(schedule, time.Date(2026, 9, 23, 10, 0, 0, 0, location))
+ assert.True(t, ok)
+ assert.Equal(t, time.Date(2026, 9, 23, 7, 0, 0, 0, location).UTC(), start)
+
+ // 23:30 is after the window closed.
+ _, ok = quietCurrentWindowStartCustom(schedule, time.Date(2026, 9, 23, 23, 30, 0, 0, location))
+ assert.False(t, ok)
+}
+
+func TestQuietResolveCustomMovesSendAtOutOfQuietHours(t *testing.T) {
+ schedule := quietTestScheduleCustom()
+ location, _ := time.LoadLocation("Europe/Amsterdam")
+
+ // A 03:00 Wednesday send_at waits for the 07:00 window.
+ resolved := schedule.ResolveScheduledAt(time.Date(2026, 9, 23, 3, 0, 0, 0, location))
+ assert.Equal(t, time.Date(2026, 9, 23, 7, 0, 0, 0, location).UTC(), resolved)
+}
+
+func TestQuietHoursCustomUnwiredIsNeverQuiet(t *testing.T) {
+ previous := quietHoursCustom
+ quietHoursCustom = nil
+ defer func() { quietHoursCustom = previous }()
+
+ now := time.Now().UTC()
+ assert.False(t, quietNowCustom(context.Background(), &entities.Phone{}))
+ assert.Equal(t, now, quietResolveCustom(context.Background(), &entities.Phone{}, now))
+
+ heartbeat := &entities.Heartbeat{Timestamp: now}
+ got, quiet := quietMonitorCustom(context.Background(), heartbeat)
+ assert.False(t, quiet)
+ assert.Same(t, heartbeat, got)
+}
diff --git a/api/pkg/telemetry/zerolog_logger.go b/api/pkg/telemetry/zerolog_logger.go
index c5230bc01..4f5f2b2e9 100644
--- a/api/pkg/telemetry/zerolog_logger.go
+++ b/api/pkg/telemetry/zerolog_logger.go
@@ -26,7 +26,10 @@ func NewZerologLogger(projectID string, fields map[string]string, driver *zerodr
spanContext: span,
}
- zerolog.SetGlobalLevel(zerolog.TraceLevel)
+ // CUSTOM: was hardcoded zerolog.TraceLevel, which re-forced Trace on every
+ // construction and overwrote any level set elsewhere. See GlobalLevelCustom
+ // in zerolog_logger_custom.go.
+ zerolog.SetGlobalLevel(GlobalLevelCustom())
return logger
}
diff --git a/api/pkg/telemetry/zerolog_logger_custom.go b/api/pkg/telemetry/zerolog_logger_custom.go
new file mode 100644
index 000000000..ceea3db86
--- /dev/null
+++ b/api/pkg/telemetry/zerolog_logger_custom.go
@@ -0,0 +1,63 @@
+package telemetry
+
+import (
+ "os"
+ "sync"
+
+ "github.com/rs/zerolog"
+)
+
+// CUSTOM FILE — not upstream. Holds the log-level policy so zerolog_logger.go
+// keeps a single-line change: the argument to SetGlobalLevel in
+// NewZerologLogger.
+//
+// WHY THIS EXISTS
+//
+// NewZerologLogger called
+//
+// zerolog.SetGlobalLevel(zerolog.TraceLevel)
+//
+// unconditionally, on EVERY construction — and WithService builds a new logger
+// each time it is called, so the level was re-forced to Trace repeatedly during
+// startup. Setting a level anywhere else (for example in the di package's log
+// driver) was silently overwritten a moment later, which is exactly what
+// happened: LOG_LEVEL=info was honoured, then immediately undone, and the
+// container still emitted every container.go Debug line.
+//
+// On the server that is hundreds of lines per boot into a json-file capped at
+// 10m x 3, which rotates away the startup errors worth keeping — the same
+// argument as USE_HTTP_LOGGER=false.
+//
+// Resolved ONCE via sync.Once. The value cannot change during a process
+// lifetime, and NewZerologLogger is called many times, so re-reading the
+// environment on every call would be pure waste.
+var (
+ globalLevelOnce sync.Once
+ globalLevelValue zerolog.Level
+)
+
+// GlobalLevelCustom is the zerolog level this process should run at.
+//
+// - local (ENV=local): Trace, unchanged from upstream. A dev box is where you
+// want every line.
+// - anywhere else: LOG_LEVEL if it parses, otherwise Info.
+//
+// An unparseable LOG_LEVEL falls back to Info rather than failing: a typo in an
+// observability setting must never stop the API from booting.
+func GlobalLevelCustom() zerolog.Level {
+ globalLevelOnce.Do(func() {
+ if os.Getenv("ENV") == "local" {
+ globalLevelValue = zerolog.TraceLevel
+ return
+ }
+
+ globalLevelValue = zerolog.InfoLevel
+ if raw := os.Getenv("LOG_LEVEL"); raw != "" {
+ if parsed, err := zerolog.ParseLevel(raw); err == nil {
+ globalLevelValue = parsed
+ }
+ }
+ })
+
+ return globalLevelValue
+}
diff --git a/api/pkg/validators/phone_handler_validator.go b/api/pkg/validators/phone_handler_validator.go
index 9e21d26a9..6db43359c 100644
--- a/api/pkg/validators/phone_handler_validator.go
+++ b/api/pkg/validators/phone_handler_validator.go
@@ -86,17 +86,39 @@ func (validator *PhoneHandlerValidator) ValidateUpsert(ctx context.Context, user
},
"message_expiration_seconds": []string{
"min:60",
- "max:3600",
+ "max:7200",
},
"message_send_schedule_id": []string{
"uuid",
},
},
+ // govalidator's defaults name the raw JSON key and phrase a numeric bound
+ // as "must be maximum 7200 in size", which says neither what the unit is
+ // nor what a sane value looks like. Each message below states the limit and
+ // what it means, because the limit is the one thing the person needs.
+ //
+ // Written as bare PREDICATES, no field name: the client prefixes the field
+ // (see getApiErrorDetails in web/app/utils/api-error.ts), so a message that
+ // names itself gets read twice.
+ Messages: govalidator.MapData{
+ "messages_per_minute": []string{
+ "min:cannot be negative.",
+ "max:must be 60 or less — the gateway phone sends at most one SMS per second.",
+ },
+ "max_send_attempts": []string{
+ "min:cannot be negative.",
+ "max:must be 5 or less.",
+ },
+ "message_expiration_seconds": []string{
+ "min:must be at least 60 (1 minute).",
+ "max:must be at most 7200 (2 hours).",
+ },
+ },
})
result := v.ValidateStruct()
if request.MaxSendAttempts > 0 && request.MessageExpirationSeconds == 0 {
- result.Add("message_expiration_seconds", "message_expiration_seconds cannot be 0 when max_send_attempts is greater than 0")
+ result.Add("message_expiration_seconds", "cannot be 0 when max send attempts is greater than 0 — a retry needs a window to retry inside.")
}
if len(result) > 0 {
diff --git a/docker-compose.yml b/docker-compose.yml
deleted file mode 100644
index ef63287d2..000000000
--- a/docker-compose.yml
+++ /dev/null
@@ -1,53 +0,0 @@
-services:
- postgres:
- image: postgres:alpine
- environment:
- POSTGRES_DB: httpsms
- POSTGRES_PASSWORD: dbpassword
- POSTGRES_USER: dbusername
- volumes:
- - postgres:/var/lib/postgresql/data
- ports:
- - "5435:5432"
- restart: on-failure
- healthcheck:
- test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
- interval: 30s
- timeout: 60s
- retries: 5
- start_period: 5s
-
- redis:
- image: redis:latest
- command: redis-server
- volumes:
- - redis:/var/lib/redis
- ports:
- - "6379:6379"
- restart: on-failure
-
- api:
- build:
- context: ./api
- ports:
- - "8000:8000"
- depends_on:
- postgres:
- condition: service_healthy
- redis:
- condition: service_started
- env_file:
- - ./api/.env
-
- web:
- build:
- context: ./web
- ports:
- - "3000:3000"
- depends_on:
- api:
- condition: service_started
-
-volumes:
- redis:
- postgres:
diff --git a/web/.dockerignore b/web/.dockerignore
new file mode 100644
index 000000000..60c1ef558
--- /dev/null
+++ b/web/.dockerignore
@@ -0,0 +1,30 @@
+# CUSTOM. web/Dockerfile does `COPY . .` with this folder as the build context.
+ops/
+# ...except the container-start config script, which the production stage
+# installs into /docker-entrypoint.d. It is the only thing in ops/ that belongs
+# in an image; everything else there drives builds from the host.
+!ops/runtime-config.sh
+
+# EXCLUDE every host .env. The image is built with __HTTPSMS___ placeholders
+# that the Dockerfile writes itself, and ops/runtime-config.sh substitutes real
+# values at container start — so the build needs no configuration and must not
+# pick any up.
+#
+# Excluding these is what makes the guarantee hold: nothing about this machine
+# can reach the image, one artifact runs in dev and production, and a symlinked
+# .env can no longer arrive dangling and silently bake upstream's
+# api.httpsms.com into the bundle.
+.env
+.env.*
+
+# Reinstalled inside the image; copying the host tree in is slow and can drag
+# glibc-built binaries into an alpine build.
+node_modules/
+.output/
+.nuxt/
+
+# Not inputs to the build.
+docker-compose.yml
+docker-compose.example.yml
+docker-compose.override.yml
+*.superseded
diff --git a/web/.env.docker b/web/.env.docker
deleted file mode 100644
index b1751dfb6..000000000
--- a/web/.env.docker
+++ /dev/null
@@ -1,21 +0,0 @@
-API_BASE_URL=http://localhost:8000
-
-APP_URL=http://localhost:3000
-APP_NAME=httpSMS
-APP_GITHUB_URL=https://github.com/NdoleStudio/httpsms
-APP_DOCUMENTATION_URL=https://docs.httpsms.com
-APP_DOWNLOAD_URL=https://github.com/NdoleStudio/httpsms/releases/latest/download/HttpSms.apk
-APP_ENV=production
-
-# Firebase credentials
-FIREBASE_API_KEY=AIzaSyAKqPvj51igvvNNcRt_gL0A6cgx3ZB-kuQ
-FIREBASE_AUTH_DOMAIN=httpsms-docker.firebaseapp.com
-FIREBASE_PROJECT_ID=httpsms-docker
-FIREBASE_STORAGE_BUCKET=httpsms-docker.appspot.com
-FIREBASE_MESSAGING_SENDER_ID=668063041624
-FIREBASE_APP_ID=668063041624:web:29b9e3b7027965ba08a22d
-FIREBASE_MEASUREMENT_ID=G-18VRYL22PZ
-
-# Cloudflare Turnstile site key for captcha on the search messages page
-# Get your site key at https://developers.cloudflare.com/turnstile/get-started/
-CLOUDFLARE_TURNSTILE_SITE_KEY=
diff --git a/web/.env.production b/web/.env.production
index 7d50c7d57..ed14d40e9 100644
--- a/web/.env.production
+++ b/web/.env.production
@@ -3,7 +3,8 @@ APP_URL=https://httpsms.com
APP_NAME=httpSMS
APP_GITHUB_URL=https://github.com/NdoleStudio/httpsms
APP_DOCUMENTATION_URL=https://docs.httpsms.com
-APP_DOWNLOAD_URL=https://apk.httpsms.com/HttpSms.apk
+# APP_DOWNLOAD_URL=https://github.com/NdoleStudio/httpsms/releases/latest/download/HttpSms.apk
+APP_DOWNLOAD_URL= # Published to PlayStore as Test App (Not read by code)
APP_ENV=production
CHECKOUT_URL=https://httpsms.lemonsqueezy.com/checkout/buy/706c5638-4c8d-40db-a6f2-b6371b7e0af4
diff --git a/web/.gitignore b/web/.gitignore
index 38d6ddc8b..eb8ac4ee8 100644
--- a/web/.gitignore
+++ b/web/.gitignore
@@ -9,6 +9,26 @@ dist
# Node dependencies
node_modules
+# Kernel core dumps (created on crash, can be hundreds of MB).
+# The dev container now runs with `ulimit -c 0`, so these should stop
+# appearing - the pattern stays for anything already on disk elsewhere.
+core
+core.[0-9]*
+
+# pnpm's local content-addressable store. Created when pnpm runs with this
+# directory as its store root — which is what happens when the dev server is
+# run in a container that bind-mounts web/ (see the debugging procedure: the
+# compose `web` service is the final nginx stage and cannot run pnpm). Hundreds
+# of MB of package tarballs; never belongs in the repo.
+.pnpm-store
+.DS_Store
+
+# TypeScript incremental build cache. The root .gitignore already lists
+# *.tsbuildinfo, but this file was committed before that rule existed and so
+# stayed tracked — .gitignore never applies to files git already follows.
+# Untracked with `git rm --cached web/tsconfig.tsbuildinfo`.
+*.tsbuildinfo
+
# Logs
logs
*.log
diff --git a/web/Dockerfile b/web/Dockerfile
index f283fd32a..bd60b00b4 100644
--- a/web/Dockerfile
+++ b/web/Dockerfile
@@ -1,16 +1,79 @@
# build stage
-FROM node:22-alpine AS build
+FROM node:26-alpine AS build
WORKDIR /app
-COPY package.json pnpm-lock.yaml ./
+# pnpm-workspace.yaml IS REQUIRED HERE, not just in the `COPY . .` below.
+# It carries no `packages:` — its only content is pnpm 10's `allowBuilds:` list
+# (esbuild, @parcel/watcher, protobufjs, @firebase/util, unrs-resolver,
+# vue-demi). pnpm 10 refuses to run dependency build scripts unless they are
+# approved there, and reports the refusal as the WARNING ERR_PNPM_IGNORED_BUILDS
+# rather than an error. Omit this file at install time and those packages
+# silently never place their platform binaries; the build then fails much later,
+# inside `nuxt generate`, with an error that looks unrelated to dependencies.
+COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
-# Install pnpm
-RUN npm install -g pnpm
+# package.json's `prepare` script is `cd .. && husky web/.husky`, and husky
+# exits 1 with ".git can't be found" when there is no repo — which there never
+# is inside a build context. HUSKY=0 makes husky skip and exit 0 instead.
+# Git hooks are a developer-machine concern; they have no meaning in an image.
+ENV HUSKY=0
+
+# PIN THE MAJOR. The bare `pnpm` tag moves, and this project is built for 10:
+# `allowBuilds:` is a pnpm 10 key, and pnpm 9 rejects a pnpm-workspace.yaml that
+# has no `packages:` field with "packages field missing or empty".
+RUN npm install -g pnpm@10
RUN pnpm install
COPY . .
+
+# BUILD WITH PLACEHOLDERS — THE IMAGE CARRIES NO CONFIGURATION.
+#
+# `nuxt generate` inlines runtimeConfig.public into the JS payload, so upstream's
+# web is configured at BUILD time. That would make the dev image and the prod
+# image different artifacts, put the web outside the rule every other service
+# here follows (one build, configured by the server's own .env), and hide a wrong
+# value until someone opens a browser after a deploy.
+#
+# So we generate with __HTTPSMS___ markers and substitute them at CONTAINER
+# START from the environment — see ops/runtime-config.sh, installed into
+# /docker-entrypoint.d below. One image runs in dev and in production, exactly
+# like the Go api.
+#
+# This .env is written HERE rather than copied from the build context on purpose:
+# nothing about the host can leak into the image, a symlinked web/.env can no
+# longer arrive dangling and silently bake upstream's api.httpsms.com, and there
+# is no build-time secret to keep out of a layer.
+#
+# KEEP IN SYNC with VARS in ops/runtime-config.sh. A name here with no
+# counterpart there is never substituted and ships the literal placeholder to the
+# browser; the script asserts the two critical ones actually landed.
+RUN printf '%s\n' \
+ 'API_BASE_URL=__HTTPSMS_API_BASE_URL__' \
+ 'APP_URL=__HTTPSMS_APP_URL__' \
+ 'APP_NAME=__HTTPSMS_APP_NAME__' \
+ 'APP_ENV=__HTTPSMS_APP_ENV__' \
+ 'APP_GITHUB_URL=__HTTPSMS_APP_GITHUB_URL__' \
+ 'APP_DOCUMENTATION_URL=__HTTPSMS_APP_DOCUMENTATION_URL__' \
+ 'APP_DOWNLOAD_URL=__HTTPSMS_APP_DOWNLOAD_URL__' \
+ 'CHECKOUT_URL=__HTTPSMS_CHECKOUT_URL__' \
+ 'ENTERPRISE_CHECKOUT_URL=__HTTPSMS_ENTERPRISE_CHECKOUT_URL__' \
+ 'CLOUDFLARE_TURNSTILE_SITE_KEY=__HTTPSMS_CLOUDFLARE_TURNSTILE_SITE_KEY__' \
+ 'PUSHER_KEY=__HTTPSMS_PUSHER_KEY__' \
+ 'PUSHER_CLUSTER=__HTTPSMS_PUSHER_CLUSTER__' \
+ 'DEFAULT_COUNTRY=__HTTPSMS_DEFAULT_COUNTRY__' \
+ 'ENTITLEMENT_ENABLED=__HTTPSMS_ENTITLEMENT_ENABLED__' \
+ 'FIREBASE_API_KEY=__HTTPSMS_FIREBASE_API_KEY__' \
+ 'FIREBASE_AUTH_DOMAIN=__HTTPSMS_FIREBASE_AUTH_DOMAIN__' \
+ 'FIREBASE_PROJECT_ID=__HTTPSMS_FIREBASE_PROJECT_ID__' \
+ 'FIREBASE_STORAGE_BUCKET=__HTTPSMS_FIREBASE_STORAGE_BUCKET__' \
+ 'FIREBASE_MESSAGING_SENDER_ID=__HTTPSMS_FIREBASE_MESSAGING_SENDER_ID__' \
+ 'FIREBASE_APP_ID=__HTTPSMS_FIREBASE_APP_ID__' \
+ 'FIREBASE_MEASUREMENT_ID=__HTTPSMS_FIREBASE_MEASUREMENT_ID__' \
+ 'GITHUB_SHA=__HTTPSMS_GITHUB_SHA__' \
+ > .env
+
RUN pnpm run generate
# production stage
@@ -20,5 +83,10 @@ COPY --from=build /app/.output/public /usr/share/nginx/html
# Copy the nginx configuration file
COPY ./nginx.conf /etc/nginx/conf.d/default.conf
+# Runs before nginx starts — the nginx image executes /docker-entrypoint.d/*.sh
+# in name order, so 40- lands after its own 10/15/20/30 scripts.
+COPY ./ops/runtime-config.sh /docker-entrypoint.d/40-httpsms-runtime-config.sh
+RUN chmod +x /docker-entrypoint.d/40-httpsms-runtime-config.sh
+
EXPOSE 3000
CMD ["nginx", "-g", "daemon off;"]
diff --git a/web/app/components/ContactDialog.vue b/web/app/components/ContactDialog.vue
index 2df329a93..d5f49db95 100644
--- a/web/app/components/ContactDialog.vue
+++ b/web/app/components/ContactDialog.vue
@@ -50,6 +50,20 @@ const emit = defineEmits<{
}>()
const contactsStore = useContactsStore()
+
+// CUSTOM: was hardcoded 'US'. See defaultCountryCustom in ~/composables/useCustom.ts.
+// Read at setup scope because phoneNumberRow also runs from event handlers.
+//
+// MUST STAY ABOVE `form`. That initialiser calls emptyForm() -> phoneNumberRow(),
+// which reads defaultCountry. Both are hoisted function declarations so the call
+// itself works, but a `const` declared below is still in its temporal dead zone,
+// and setup dies with "Cannot access 'defaultCountry' before initialization".
+// In a production build that surfaces as a bare 500 naming a minified variable,
+// on every route that mounts this dialog — including /threads/[id].
+const defaultCountry = defaultCountryCustom(
+ useRuntimeConfig().public.defaultCountry,
+)
+
const saving = ref(false)
const form = ref(emptyForm())
const formErrors = ref(new ErrorMessages())
@@ -61,7 +75,7 @@ const dialogTitle = computed(() =>
function phoneNumberRow(value = ''): PhoneNumberRow {
return {
value,
- country: parsePhoneNumberFromString(value)?.country ?? 'US',
+ country: parsePhoneNumberFromString(value)?.country ?? defaultCountry,
}
}
@@ -327,8 +341,8 @@ watch(
+
- Download App
+ Get the App
+
- Download App
+ Get the App
diff --git a/web/app/composables/useCustom.ts b/web/app/composables/useCustom.ts
new file mode 100644
index 000000000..7d0a8530e
--- /dev/null
+++ b/web/app/composables/useCustom.ts
@@ -0,0 +1,74 @@
+// CUSTOM FILE — not upstream.
+//
+// Local behaviour changes live here so that pulling a new httpSMS release
+// touches upstream .vue files as little as possible. Vue single-file components
+// cannot be split the way Go files can (there is no partial-override mechanism
+// for a template), so the rule for this repo is:
+//
+// * all LOGIC and every default value lives in this file
+// * the upstream component keeps a one-line call, marked `CUSTOM:`
+//
+// That way `git diff` against a fresh upstream checkout shows a handful of
+// single-line hunks instead of rewritten components, and the reasoning is in
+// one place instead of scattered through the page tree.
+//
+// Every helper here is PURE — it takes a value and returns a value, and never
+// calls useRuntimeConfig() itself. That matters: several call sites are event
+// handlers that run outside Vue's setup context, where Nuxt composables are not
+// guaranteed to resolve. The caller reads config at setup scope and passes the
+// value in.
+
+/**
+ * Whether the realtime layer should be wired up at all.
+ *
+ * PUSHER_KEY is empty in a self-hosted install, and pusher-js does NOT throw on
+ * an empty key — it accepts it, then retries a websocket against a malformed
+ * host forever (the endless openStream/tryStrategy frames in the console).
+ *
+ * Worse, in the default layout Pusher's `phone.updated` event was the ONLY
+ * thing that ever set `canPoll`, and `canPoll` gates the whole polling loop.
+ * With no key the dashboard therefore got neither push NOR polling and never
+ * refreshed after first load. Upstream never hit this because httpsms.com
+ * always has Pusher configured.
+ */
+export function pusherEnabledCustom(pusherKey: unknown): boolean {
+ return typeof pusherKey === 'string' && pusherKey.trim() !== ''
+}
+
+/**
+ * Default ISO-3166 alpha-2 country for phone-number entry.
+ *
+ * Upstream hardcodes 'US' in the contact dialog and the send-message form, so
+ * a Dutch number typed without a +31 prefix is parsed as American and silently
+ * stored wrong. Driven by DEFAULT_COUNTRY instead.
+ *
+ * Falls back to 'US' — upstream's value — when unset or malformed, so an
+ * unconfigured deployment behaves exactly as before rather than picking a new
+ * surprise default.
+ */
+export function defaultCountryCustom(value: unknown): string {
+ const country = String(value ?? '')
+ .trim()
+ .toUpperCase()
+ return /^[A-Z]{2}$/.test(country) ? country : 'US'
+}
+
+/**
+ * Whether this install sells plans at all.
+ *
+ * Upstream's billing page always shows `/200 messages`, an Upgrade Plan
+ * button and the Pro/Enterprise cards, because httpsms.com always has a Lemon
+ * Squeezy store behind it. A self-hosted install that charges nobody has
+ * neither, and showing them is worse than noise: the checkout URLs shipped in
+ * .env.production point at NdoleStudio's OWN store, so a user who clicks
+ * Upgrade pays upstream for a plan this server will never see.
+ *
+ * Parsed exactly like the api parses ENTITLEMENT_ENABLED (`== "true"`, see
+ * pkg/di/container.go), so the same value in both .env files means the same
+ * thing on both sides. Anything else — unset, empty, "1", the unsubstituted
+ * placeholder — is off, which is the safe direction: no ceiling is claimed that
+ * the api would not enforce.
+ */
+export function entitlementEnabledCustom(value: unknown): boolean {
+ return String(value ?? '').trim() === 'true'
+}
diff --git a/web/app/layouts/default.vue b/web/app/layouts/default.vue
index 47c901de3..116be1846 100644
--- a/web/app/layouts/default.vue
+++ b/web/app/layouts/default.vue
@@ -15,21 +15,28 @@ const appStore = useAppStore()
let poller: ReturnType | null = null
let canPoll = false
+// CUSTOM: see pusherEnabledCustom in ~/composables/useCustom.ts
+const pusherEnabled = pusherEnabledCustom(config.public.pusherKey)
+
const hasDrawer = computed(() => {
return ['threads', 'threads-id'].includes((route.name as string) ?? '')
})
onMounted(() => {
setTimeout(() => {
- const pusher = new Pusher(config.public.pusherKey as string, {
- cluster: config.public.pusherCluster as string,
- })
-
- if (authStore.authUser) {
- const channel = pusher.subscribe(authStore.authUser.id)
- channel.bind('phone.updated', () => {
- canPoll = true
+ if (pusherEnabled) {
+ const pusher = new Pusher(config.public.pusherKey as string, {
+ cluster: config.public.pusherCluster as string,
})
+
+ if (authStore.authUser) {
+ const channel = pusher.subscribe(authStore.authUser.id)
+ channel.bind('phone.updated', () => {
+ canPoll = true
+ })
+ }
+ } else {
+ canPoll = true
}
startPoller()
@@ -58,7 +65,9 @@ function startPoller() {
])
}
- canPoll = false
+ // Re-arm the latch only when Pusher can actually trip it again. With no
+ // Pusher this stays true so the next tick polls.
+ canPoll = !pusherEnabled
setTimeout(() => appStore.setPolling(false), 1000)
}, 10_000)
}
diff --git a/web/app/layouts/website.vue b/web/app/layouts/website.vue
index 3b6565a5a..d7cb83a15 100644
--- a/web/app/layouts/website.vue
+++ b/web/app/layouts/website.vue
@@ -5,7 +5,6 @@ import {
mdiCircle,
mdiTwitter,
mdiHeart,
- mdiShieldStar,
mdiLightbulbOn50,
mdiCreation,
mdiEyeOffOutline,
@@ -202,15 +201,6 @@ function goToPricing() {
-
-
-
+// CUSTOM FILE — not upstream. Served at /app-access.
+//
+// Upstream's "Download App" links point at its own APK, which is built against
+// upstream's Firebase project and cannot sign in to this server. Our build is a
+// Google Play test app, and Play only admits testers whose Google account the
+// operator has added in the Play Console. So every download link in the app and
+// blog now points here instead, and this form emails the request to the
+// operator (api: handlers/app_access_handler_custom.go).
+//
+// Signed-in only: the `auth` middleware sends a visitor to /login?to=/app-access
+// and back, and the request carries their account email for free.
+import { mdiArrowLeft, mdiCheckCircleOutline, mdiSend } from '@mdi/js'
+import { ErrorMessages } from '~/utils/errors'
+import { getApiErrorMessage, toApiError } from '~/utils/api-error'
+
+definePageMeta({
+ path: '/app-access',
+ middleware: ['auth'],
+})
+
+useHead({
+ title: 'Get the Android App - httpSMS',
+})
+
+const authStore = useAuthStore()
+const notificationsStore = useNotificationsStore()
+const { apiFetch } = useApi()
+
+const form = ref({
+ name: authStore.authUser?.displayName ?? '',
+ google_play_email: authStore.authUser?.email ?? '',
+ note: '',
+})
+const sending = ref(false)
+const sent = ref(false)
+const errorMessages = ref(new ErrorMessages())
+
+async function submit() {
+ sending.value = true
+ errorMessages.value = new ErrorMessages()
+ try {
+ await apiFetch('/v1/app-access-requests', {
+ method: 'POST',
+ body: form.value,
+ })
+ sent.value = true
+ } catch (error) {
+ const bag = new ErrorMessages()
+ const data = toApiError(error).data?.data
+ if (data && typeof data === 'object') {
+ Object.keys(data).forEach((key) => bag.addMany(key, data[key]))
+ }
+ errorMessages.value = bag
+ notificationsStore.addNotification({
+ type: 'error',
+ message: getApiErrorMessage(error, 'Your request could not be sent'),
+ })
+ } finally {
+ sending.value = false
+ }
+}
+
+
+
+
+
+
+
+
+ Get the Android App
+
+
+
+
+
+ Get the Android App
+
+
+
+ Your request has been sent.
+
+ Once approved, Google Play emails an invitation to
+ {{ form.google_play_email }}. Open it on your Android phone
+ to install the app, then sign in with your API key.
+
+
+
+
+
+ The httpSMS Android app is in testing on Google Play. Request
+ access below, and we will add your Google account as a tester.
+
+
+
+
+
+
+ Request access
+
+
+
+
+
+
+
+
diff --git a/web/app/pages/billing/index.vue b/web/app/pages/billing/index.vue
index 8a22aed23..072b8b133 100644
--- a/web/app/pages/billing/index.vue
+++ b/web/app/pages/billing/index.vue
@@ -132,6 +132,18 @@ const subscriptionIsCancelled = computed(
() => authStore.user?.subscription_status === 'cancelled',
)
+// CUSTOM: does this install sell plans at all? Off hides every ceiling and every
+// upgrade path — see ~/composables/useCustom.ts for why that matters.
+const entitlementEnabled = entitlementEnabledCustom(
+ config.public.entitlementEnabled,
+)
+
+// CUSTOM: "FREE" only means something next to a paid tier. With entitlements off
+// there is no tier and no cap, so name it for what it is.
+const planLabel = computed(() =>
+ !entitlementEnabled && isOnFreePlan.value ? 'Unlimited' : plan.value.name,
+)
+
const invoiceStateOptions = computed(() =>
getStateOptions(invoiceFormCountry.value),
)
@@ -145,6 +157,10 @@ const totalMessages = computed(() => {
})
const checkoutURL = computed(() => {
+ // CUSTOM: `new URL('')` THROWS, and an unconfigured self-hosted install has no
+ // CHECKOUT_URL. Upstream never hit this because httpsms.com always sets one;
+ // here it took the whole billing page down with it.
+ if (!config.public.checkoutUrl) return undefined
const url = new URL(config.public.checkoutUrl as string)
const user = authStore.authUser
if (user) {
@@ -160,6 +176,8 @@ const checkoutURL = computed(() => {
})
const enterpriseCheckoutURL = computed(() => {
+ // CUSTOM: same as checkoutURL above — empty is a valid configuration here.
+ if (!config.public.enterpriseCheckoutUrl) return undefined
const url = new URL(config.public.enterpriseCheckoutUrl as string)
const user = authStore.authUser
if (user) {
@@ -295,7 +313,8 @@ onMounted(async () => {
- {{ plan.name }}
+
+ {{ planLabel }}
{{ plan.name }} → Free
@@ -331,12 +350,22 @@ onMounted(async () => {
).toLocaleDateString()
}}
-
+
+
{{ formatDecimal(totalMessages) }}/{{
formatDecimal(plan.messagesPerMonth)
}}
messages
+
+ {{ formatDecimal(totalMessages) }} messages this billing
+ period
+
{
>
Update Plan
+
@@ -420,7 +451,8 @@ onMounted(async () => {
-
+
+
Upgrade Plan
diff --git a/web/app/pages/blog/end-to-end-encryption-to-sms-messages.vue b/web/app/pages/blog/end-to-end-encryption-to-sms-messages.vue
index bda47bca9..1b186cae5 100644
--- a/web/app/pages/blog/end-to-end-encryption-to-sms-messages.vue
+++ b/web/app/pages/blog/end-to-end-encryption-to-sms-messages.vue
@@ -63,7 +63,7 @@ useSeoMeta({
⬇️ Download and install
the httpSMS Android app on your phone and set you encryption key under
diff --git a/web/app/pages/blog/forward-incoming-sms-from-phone-to-webhook.vue b/web/app/pages/blog/forward-incoming-sms-from-phone-to-webhook.vue
index dd0306b6d..c20e91e44 100644
--- a/web/app/pages/blog/forward-incoming-sms-from-phone-to-webhook.vue
+++ b/web/app/pages/blog/forward-incoming-sms-from-phone-to-webhook.vue
@@ -66,7 +66,7 @@ useSeoMeta({
⬇️ Download and install
the httpSMS android app on your phone and sign in using your API KEY
diff --git a/web/app/pages/blog/how-to-send-sms-messages-from-excel.vue b/web/app/pages/blog/how-to-send-sms-messages-from-excel.vue
index f55f922a5..09abba464 100644
--- a/web/app/pages/blog/how-to-send-sms-messages-from-excel.vue
+++ b/web/app/pages/blog/how-to-send-sms-messages-from-excel.vue
@@ -65,7 +65,7 @@ useSeoMeta({
⬇️ Download and install
the httpSMS android app on your phone and sign in using your API KEY
diff --git a/web/app/pages/blog/send-bulk-sms-from-csv-file-with-no-code.vue b/web/app/pages/blog/send-bulk-sms-from-csv-file-with-no-code.vue
index 9166d40ef..c11b5b082 100644
--- a/web/app/pages/blog/send-bulk-sms-from-csv-file-with-no-code.vue
+++ b/web/app/pages/blog/send-bulk-sms-from-csv-file-with-no-code.vue
@@ -74,7 +74,7 @@ useSeoMeta({
⬇️ Download and install
the httpSMS android app on your phone and sign in using your API KEY
diff --git a/web/app/pages/blog/send-sms-from-android-phone-with-python.vue b/web/app/pages/blog/send-sms-from-android-phone-with-python.vue
index 400f9caaa..ab7da5ab5 100644
--- a/web/app/pages/blog/send-sms-from-android-phone-with-python.vue
+++ b/web/app/pages/blog/send-sms-from-android-phone-with-python.vue
@@ -83,7 +83,7 @@ useSeoMeta({
⬇️ Download and install
the httpSMS android app on your phone and sign in using your API KEY
diff --git a/web/app/pages/index.vue b/web/app/pages/index.vue
index be30493f7..462bd4227 100644
--- a/web/app/pages/index.vue
+++ b/web/app/pages/index.vue
@@ -475,10 +475,9 @@ const planYearlyMonthlyPrice = computed(
Step 2
DownloadGet
and install the companion android application on your
phone and sign in using your API Key.
diff --git a/web/app/pages/messages/index.vue b/web/app/pages/messages/index.vue
index 5b6abd53f..983a773ac 100644
--- a/web/app/pages/messages/index.vue
+++ b/web/app/pages/messages/index.vue
@@ -25,7 +25,10 @@ const { formatPhoneNumber } = useFilters()
const sending = ref(false)
const formPhoneNumber = ref('')
-const phoneCountry = ref('US')
+// CUSTOM: was hardcoded 'US'. See defaultCountryCustom in ~/composables/useCustom.ts.
+const phoneCountry = ref(
+ defaultCountryCustom(useRuntimeConfig().public.defaultCountry),
+)
const formContent = ref('')
const formAttachments = ref('')
const errors = ref(new Map())
diff --git a/web/app/pages/phone-api-keys/index.vue b/web/app/pages/phone-api-keys/index.vue
index aded9453d..76d470065 100644
--- a/web/app/pages/phone-api-keys/index.vue
+++ b/web/app/pages/phone-api-keys/index.vue
@@ -18,7 +18,6 @@ useHead({
const config = useRuntimeConfig()
const { lgAndUp } = useVDisplay()
const authStore = useAuthStore()
-const appStore = useAppStore()
const phonesStore = usePhonesStore()
const notificationsStore = useNotificationsStore()
const { formatTimestamp, formatPhoneNumber } = useFilters()
@@ -288,13 +287,13 @@ onBeforeUnmount(() => {
httpSMS API, use the API key under your account settings page instead
https://httpsms.com/settings{{ config.public.appUrl }}/settings.
@@ -413,7 +412,7 @@ onBeforeUnmount(() => {
httpSMS app
on your Android phone to login.
diff --git a/web/app/pages/settings/index.vue b/web/app/pages/settings/index.vue
index f4c312035..d79494b29 100644
--- a/web/app/pages/settings/index.vue
+++ b/web/app/pages/settings/index.vue
@@ -888,7 +888,7 @@ onMounted(async () => {
Use your API Key in the x-api-key HTTP Header
when sending requests to
- https://api.httpsms.com endpoints.
+ {{ config.public.apiBaseUrl }} endpoints.
{
Scan this QR code with the
httpSMS app
on your Android phone to login.
@@ -1651,6 +1651,15 @@ onMounted(async () => {
type="number"
density="compact"
label="Message Expiration (seconds)"
+ min="60"
+ max="7200"
+ hint="How long a message may sit unsent before it counts as expired. 60–7200 seconds (1 minute to 2 hours)."
+ persistent-hint
+ :rules="[
+ (v: number) =>
+ (v >= 60 && v <= 7200) ||
+ 'Message expiration must be between 60 and 7200 seconds (1 minute to 2 hours)',
+ ]"
/>
{
type="number"
density="compact"
label="Messages Per Minute"
+ min="0"
+ max="60"
+ hint="Sending rate for this SIM. 0–60 (the gateway phone sends at most one SMS per second)."
+ persistent-hint
+ :rules="[
+ (v: number) =>
+ (v >= 0 && v <= 60) ||
+ 'Messages per minute must be between 0 and 60',
+ ]"
/>
{
await loadData()
+ // CUSTOM: see pusherEnabledCustom in ~/composables/useCustom.ts.
+ // onBeforeUnmount already null-checks webhookChannel.
+ if (!pusherEnabledCustom(config.public.pusherKey)) return
+
const pusher = new Pusher(config.public.pusherKey as string, {
cluster: config.public.pusherCluster as string,
})
diff --git a/web/app/utils/api-error.ts b/web/app/utils/api-error.ts
index 73934dd74..82c303bda 100644
--- a/web/app/utils/api-error.ts
+++ b/web/app/utils/api-error.ts
@@ -16,6 +16,55 @@ export function toApiError(error: unknown): ApiError {
return {}
}
+/** "message_expiration_seconds" -> "Message expiration seconds" */
+function humanizeField(field: string): string {
+ const words = field.replace(/_/g, ' ').trim()
+ return words.charAt(0).toUpperCase() + words.slice(1)
+}
+
+function replaceAll(text: string, search: string, replacement: string): string {
+ return text.split(search).join(replacement)
+}
+
+/**
+ * The per-field validation errors from a 422, as sentences a person can act on.
+ *
+ * A rejected save answers with a generic top-level message ("validation errors
+ * while updating phones") and puts what actually went wrong in `data.data`,
+ * keyed by field. Only the generic half was ever shown, so a value the API
+ * refused looked like it had saved and silently reverted — with no hint of
+ * which field was wrong or what the limit was.
+ */
+export function getApiErrorDetails(error: unknown): string[] {
+ const fields = toApiError(error).data?.data
+ if (!fields) {
+ return []
+ }
+
+ return Object.entries(fields).flatMap(([field, messages]) =>
+ (messages ?? []).map((message) => {
+ // govalidator's built-in messages embed the raw JSON key, e.g. "The
+ // message_expiration_seconds field must be maximum 7200 in size". Swap the
+ // key for words in place rather than printing it and a prefix.
+ if (message.includes(field)) {
+ return replaceAll(message, field, humanizeField(field).toLowerCase())
+ }
+ // Anything else is a custom message from the validator, written as a bare
+ // predicate ("must be at most 7200 (2 hours)") precisely so the field name
+ // can be prefixed here. Keep them that way — a message that names its own
+ // field will read it twice.
+ return `${humanizeField(field)}: ${message}`
+ }),
+ )
+}
+
export function getApiErrorMessage(error: unknown, fallback: string): string {
+ // Field-level detail names the field AND the limit, so it beats the generic
+ // top-level message whenever the API sent any.
+ const details = getApiErrorDetails(error)
+ if (details.length > 0) {
+ return details.join(' ')
+ }
+
return toApiError(error).data?.message ?? fallback
}
diff --git a/web/docker-compose.example.yml b/web/docker-compose.example.yml
new file mode 100644
index 000000000..1f68b0b34
--- /dev/null
+++ b/web/docker-compose.example.yml
@@ -0,0 +1,137 @@
+# httpsms — PRODUCTION compose (server), web UI only
+#
+# The API is deployed separately. See its compose file for why the two are
+# split and why neither depends_on the other.
+#
+# This container is nginx serving a STATIC Nuxt build, but it is NOT configured
+# at build time — that is the one thing to know about it.
+#
+# `nuxt generate` normally inlines runtimeConfig.public into the JS, which would
+# make the dev image and this image different artifacts and put the web outside
+# the rule everything else here follows. So the image is generated with
+# __HTTPSMS___ placeholders and ops/runtime-config.sh (installed into
+# /docker-entrypoint.d) substitutes them at CONTAINER START from the environment
+# block below. The SAME image runs in dev and here; only this folder's .env
+# differs, exactly like the api.
+#
+# Consequences:
+# • changing a value is `up -d --force-recreate`, NOT `restart` — the
+# substitution already ran against the container filesystem;
+# • the container REFUSES TO START when API_BASE_URL or APP_URL is empty,
+# because a blank value would send every visitor to the wrong origin and
+# nothing downstream would report it;
+# • this .env holds NO secrets. The web needs the Firebase WEB SDK keys and
+# the Turnstile SITE key, all of which ship to the browser anyway. The
+# service account, DB and SMTP passwords belong to httpsms_api only.
+#
+# The image tag is FIXED, not interpolated. ops/deploy.sh loads the versioned
+# image from the release tarball and retags it to this name, so this project
+# needs only .env — `docker compose ps|logs|exec` work here exactly as in every
+# other repo. There is no second env file to remember.
+#
+# Rolling back is a retag; versioned tags stay in the local image store:
+# docker image ls 'httpsms-*'
+# docker tag httpsms-:
+# docker compose up -d --force-recreate
+
+networks:
+ doc_net:
+ external: true
+ name: doc_net
+
+services:
+ web:
+ image: httpsms-web:current
+ # NEVER PULL. This tag only ever exists locally — ops/deploy.sh creates it by
+ # retagging the image it just loaded from the release tarball. Without this,
+ # a missing tag makes compose try Docker Hub and fail with "pull access
+ # denied ... may require 'docker login'", which reads like a credentials
+ # problem and is actually "deploy.sh did not run, or is the old version that
+ # wrote ops/.release.env instead of retagging".
+ pull_policy: never
+ container_name: httpsms-web
+ restart: unless-stopped
+
+ # nginx inside the image listens on 3000, not 80 (web/ nginx conf:
+ # `listen 3000;`).
+ #
+ # DO NOT publish on 127.0.0.1. Docker here is ROOTLESS, and a loopback-scoped
+ # publish makes rootlesskit bind the IPv4 loopback ONLY — no [::1] listener —
+ # so a proxy that dials `localhost` and gets ::1 first is refused. The host
+ # Caddyfile uses `localhost` for every upstream. A bare host port gets both
+ # 0.0.0.0 and [::], matching the rest of the fleet.
+ ports:
+ - "${HTTPSMS_WEB_PORT:-58012}:3000"
+
+ # CUSTOM: the published Android apk, served at /downloads/HttpSms.apk (see
+ # nginx.conf). Mounted, not baked in, for the same reason the config is
+ # substituted at start: one image everywhere, and a new app build must not
+ # require rebuilding and redeploying the web.
+ #
+ # THE ANDROID REPO IS NOT ON THIS HOST. The apk is built on a workstation
+ # (android/scripts/build-release.sh writes android/ops/public/) and copied up:
+ #
+ # scp android/ops/public/HttpSms.apk* :/downloads/
+ #
+ # No redeploy is needed afterwards — nginx serves the file straight off the
+ # mount, and Cache-Control: no-store means the next visitor gets the new
+ # build rather than a remembered old one.
+ #
+ # An absent or empty directory is not an error here: /downloads/ answers 404
+ # (nginx.conf makes sure of that rather than serving the SPA as an apk) and
+ # the rest of the site is unaffected.
+ volumes:
+ - ${APK_DIR:-./downloads}:/usr/share/nginx/html/downloads:ro
+
+ # RUNTIME CONFIG — the image is configuration-free. It is generated with
+ # __HTTPSMS___ placeholders and ops/runtime-config.sh substitutes these
+ # at container start, so the SAME image runs in dev and here; only these
+ # values differ, and they come from this folder's .env like the api's do.
+ #
+ # Changing one needs `up -d --force-recreate`, not `restart`: the
+ # substitution already ran against the container filesystem.
+ #
+ # The container REFUSES TO START when API_BASE_URL or APP_URL is empty —
+ # a blank value would send every visitor's browser to the wrong origin, and
+ # nothing downstream would report it.
+ environment:
+ API_BASE_URL: ${API_BASE_URL:?API_BASE_URL not set}
+ APP_URL: ${APP_URL:?APP_URL not set}
+ APP_NAME: ${APP_NAME:-httpSMS}
+ APP_ENV: ${APP_ENV:-production}
+ APP_GITHUB_URL: ${APP_GITHUB_URL:-}
+ APP_DOCUMENTATION_URL: ${APP_DOCUMENTATION_URL:-}
+ APP_DOWNLOAD_URL: ${APP_DOWNLOAD_URL:-}
+ CHECKOUT_URL: ${CHECKOUT_URL:-}
+ ENTERPRISE_CHECKOUT_URL: ${ENTERPRISE_CHECKOUT_URL:-}
+ # Must match ENTITLEMENT_ENABLED in the api's .env. The api decides whether
+ # messages are capped; this only decides whether the UI shows a cap and an
+ # upgrade path. Default off — same as the api.
+ ENTITLEMENT_ENABLED: ${ENTITLEMENT_ENABLED:-false}
+ CLOUDFLARE_TURNSTILE_SITE_KEY: ${CLOUDFLARE_TURNSTILE_SITE_KEY:-}
+ PUSHER_KEY: ${PUSHER_KEY:-}
+ PUSHER_CLUSTER: ${PUSHER_CLUSTER:-}
+ FIREBASE_API_KEY: ${FIREBASE_API_KEY:-}
+ FIREBASE_AUTH_DOMAIN: ${FIREBASE_AUTH_DOMAIN:-}
+ FIREBASE_PROJECT_ID: ${FIREBASE_PROJECT_ID:-}
+ FIREBASE_STORAGE_BUCKET: ${FIREBASE_STORAGE_BUCKET:-}
+ FIREBASE_MESSAGING_SENDER_ID: ${FIREBASE_MESSAGING_SENDER_ID:-}
+ FIREBASE_APP_ID: ${FIREBASE_APP_ID:-}
+ FIREBASE_MEASUREMENT_ID: ${FIREBASE_MEASUREMENT_ID:-}
+ GITHUB_SHA: ${HTTPSMS_VERSION:-unknown}
+
+ healthcheck:
+ test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:3000/ || exit 1"]
+ interval: 10s
+ timeout: 5s
+ retries: 10
+ start_period: 10s
+
+ logging:
+ driver: "json-file"
+ options:
+ max-size: "10m"
+ max-file: "3"
+
+ networks:
+ - doc_net
diff --git a/web/docker-compose.yml b/web/docker-compose.yml
new file mode 100644
index 000000000..98d2f86b1
--- /dev/null
+++ b/web/docker-compose.yml
@@ -0,0 +1,104 @@
+# httpsms web — LOCAL DEV stack
+#
+# Just the Nuxt build served by nginx. No database, no Redis, no tunnel: the web
+# UI never talks to anything but the API, and it does that from the visitor's
+# browser over the PUBLIC url — not container-to-container.
+#
+# That is also why there is no depends_on the API here or on the server: the
+# two halves are independent releases, deployed separately, and neither waits
+# for the other.
+#
+# RUNTIME CONFIG, NOT BUILD-TIME. This was the other way round until the image
+# was made configuration-free, and the change is the whole point:
+#
+# `nuxt generate` inlines runtimeConfig.public into the JS, so upstream's web is
+# configured when it is BUILT. That made the dev image and the prod image
+# different artifacts, put this service outside the rule everything else follows,
+# and hid a wrong value until someone opened a browser after a deploy — with
+# nuxt.config falling back to upstream's api.httpsms.com when a value was missing.
+#
+# Now the image is generated with __HTTPSMS___ placeholders (written by the
+# Dockerfile itself) and ops/runtime-config.sh substitutes them at CONTAINER
+# START from the environment below. ONE image runs here and on the server; only
+# these values differ, and they come from this folder's .env exactly like the api.
+#
+# Two consequences worth knowing:
+# • changing a value needs `up -d --force-recreate`, not `restart` — the
+# substitution already ran against the container's filesystem;
+# • web/.env is EXCLUDED from the build context (.dockerignore), so nothing
+# about this machine can reach the image.
+
+networks:
+ doc_net:
+ external: true
+ name: doc_net
+
+services:
+ web:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ image: httpsms-web:dev
+ container_name: httpsms-web-dev
+ mem_limit: 1g
+ # restart: unless-stopped
+ # nginx inside the image listens on 3000, not 80 (web/ nginx conf:
+ # `listen 3000;`).
+ #
+ # DO NOT publish on 127.0.0.1. Docker here is ROOTLESS: a loopback-scoped
+ # publish makes rootlesskit bind the IPv4 loopback ONLY, with no [::1]
+ # listener, so anything resolving `localhost` to ::1 first gets refused.
+ ports:
+ - "${HTTPSMS_WEB_PORT:-58012}:3000"
+ # CUSTOM: the published Android apk, served at /downloads/HttpSms.apk (see
+ # nginx.conf). Mounted rather than baked in, for the same reason the config
+ # is: the image stays one artifact, and a new app build does not drag a web
+ # rebuild and redeploy behind it.
+ #
+ # The source is android/ops/public — the directory android/scripts/build-release.sh
+ # publishes into, whose entire contents are meant to be public. It is
+ # deliberately NOT android/ops/tmp: that holds ~50MB release tarballs from
+ # every past build, and none of them belong on the internet.
+ #
+ # read-only, and it is worth keeping that way. Nothing in the web container
+ # has any business writing to a directory on the build host.
+ volumes:
+ - ${APK_DIR:-../android/ops/public}:/usr/share/nginx/html/downloads:ro
+ # RUNTIME CONFIG — the image is built with __HTTPSMS___ placeholders and
+ # ops/runtime-config.sh substitutes these at container start. Same image runs
+ # here and on the server; only these values differ. Change one and use
+ # `up -d --force-recreate`, not `restart`: the substitution already ran.
+ environment:
+ API_BASE_URL: ${API_BASE_URL:?API_BASE_URL not set}
+ APP_URL: ${APP_URL:?APP_URL not set}
+ APP_NAME: ${APP_NAME:-httpSMS}
+ APP_ENV: ${APP_ENV:-local}
+ APP_GITHUB_URL: ${APP_GITHUB_URL:-}
+ APP_DOCUMENTATION_URL: ${APP_DOCUMENTATION_URL:-}
+ APP_DOWNLOAD_URL: ${APP_DOWNLOAD_URL:-}
+ CHECKOUT_URL: ${CHECKOUT_URL:-}
+ ENTERPRISE_CHECKOUT_URL: ${ENTERPRISE_CHECKOUT_URL:-}
+ # Must match ENTITLEMENT_ENABLED in the api's .env. The api decides
+ # whether messages are capped; this only decides whether the UI shows a
+ # cap and an upgrade path. Default off — same as the api.
+ ENTITLEMENT_ENABLED: ${ENTITLEMENT_ENABLED:-false}
+ CLOUDFLARE_TURNSTILE_SITE_KEY: ${CLOUDFLARE_TURNSTILE_SITE_KEY:-}
+ PUSHER_KEY: ${PUSHER_KEY:-}
+ PUSHER_CLUSTER: ${PUSHER_CLUSTER:-}
+ FIREBASE_API_KEY: ${FIREBASE_API_KEY:-}
+ FIREBASE_AUTH_DOMAIN: ${FIREBASE_AUTH_DOMAIN:-}
+ FIREBASE_PROJECT_ID: ${FIREBASE_PROJECT_ID:-}
+ FIREBASE_STORAGE_BUCKET: ${FIREBASE_STORAGE_BUCKET:-}
+ FIREBASE_MESSAGING_SENDER_ID: ${FIREBASE_MESSAGING_SENDER_ID:-}
+ FIREBASE_APP_ID: ${FIREBASE_APP_ID:-}
+ FIREBASE_MEASUREMENT_ID: ${FIREBASE_MEASUREMENT_ID:-}
+ GITHUB_SHA: ${GITHUB_SHA:-dev}
+
+ healthcheck:
+ test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:3000/ || exit 1"]
+ interval: 10s
+ timeout: 5s
+ retries: 10
+ start_period: 10s
+ networks:
+ - doc_net
diff --git a/web/nginx.conf b/web/nginx.conf
index a66ccfd7d..0d6560a5a 100644
--- a/web/nginx.conf
+++ b/web/nginx.conf
@@ -4,7 +4,47 @@ server {
root /usr/share/nginx/html;
index index.html index.htm;
+ # MUST STAY OFF. `nuxt generate` writes every route as a DIRECTORY
+ absolute_redirect off;
+
location / {
try_files $uri $uri/ /index.html;
}
+
+ # CUSTOM: the Android APK, published as a download.
+ #
+ # The directory is BIND-MOUNTED in (docker-compose.yml), never baked into the
+ # image. Same reason the config is substituted at container start: one image
+ # runs in dev and in production, and shipping a new build of the app must not
+ # mean rebuilding and redeploying the web.
+ #
+ # THIS BLOCK EXISTS TO DEFEAT THE FALLBACK ABOVE. `try_files ... /index.html`
+ # answers EVERY unmatched path with the SPA and a 200, so a missing apk is
+ # downloaded as a few KB of HTML named HttpSms.apk. The phone then rejects it
+ # as an invalid package, and nothing anywhere says the file was simply absent
+ # — not the status code, not the log line. `=404` makes it say so.
+ location /downloads/ {
+ # No `alias`. root is inherited from the server block, so this resolves to
+ # /usr/share/nginx/html/downloads/ on its own, and `try_files $uri` behaves
+ # predictably — which it notoriously does not inside an aliased location.
+ try_files $uri =404;
+
+ # nginx's mime.types has no entry for .apk, so it would go out as
+ # application/octet-stream. Android's package installer reads this type when
+ # the file arrives from a browser.
+ #
+ # A `types` block REPLACES the inherited map for this location rather than
+ # extending it, so the checksum has to be named here too or it downloads as
+ # a binary blob instead of showing in the browser.
+ types {
+ application/vnd.android.package-archive apk;
+ text/plain sha256;
+ }
+ default_type application/octet-stream;
+
+ # The apk is REPLACED IN PLACE under a stable name, so a cached copy is a
+ # stale build. That is how a phone installs last week's app and someone
+ # reports a bug that was fixed on Tuesday.
+ add_header Cache-Control 'no-store' always;
+ }
}
diff --git a/web/nuxt.config.ts b/web/nuxt.config.ts
index c574ce308..786437a37 100644
--- a/web/nuxt.config.ts
+++ b/web/nuxt.config.ts
@@ -129,6 +129,19 @@ export default defineNuxtConfig({
process.env.CLOUDFLARE_TURNSTILE_SITE_KEY || '',
pusherKey: process.env.PUSHER_KEY || '',
pusherCluster: process.env.PUSHER_CLUSTER || '',
+ // CUSTOM: default country for phone-number entry. Upstream hardcoded 'US'
+ // in ContactDialog and the send form. See ~/composables/useCustom.ts.
+ defaultCountry: process.env.DEFAULT_COUNTRY || 'NL',
+ // CUSTOM: whether plan limits and upgrade paths exist at all. Kept as a
+ // RAW STRING and parsed in the app, never `=== 'true'` here — at build
+ // time this holds the literal __HTTPSMS_ENTITLEMENT_ENABLED__ placeholder,
+ // so comparing here would bake `false` in and no runtime substitution
+ // could ever change it. See ~/composables/useCustom.ts.
+ //
+ // MUST MATCH ENTITLEMENT_ENABLED in the api's .env: the api decides
+ // whether messages are actually capped, this only decides whether the UI
+ // says so.
+ entitlementEnabled: process.env.ENTITLEMENT_ENABLED || '',
firebaseApiKey: process.env.FIREBASE_API_KEY || '',
firebaseAuthDomain: process.env.FIREBASE_AUTH_DOMAIN || '',
firebaseProjectId: process.env.FIREBASE_PROJECT_ID || '',
diff --git a/web/package.json b/web/package.json
index bfec579c7..1fa3bb7bd 100644
--- a/web/package.json
+++ b/web/package.json
@@ -60,7 +60,7 @@
"stylelint": "^17.13.0",
"stylelint-config-recommended-vue": "^1.6.1",
"stylelint-config-standard": "^40.0.0",
- "typescript": "^5.9.3",
+ "typescript": "6.0.3",
"vuetify-nuxt-module": "^0.19.5"
}
}
diff --git a/web/public/integrations.js b/web/public/integrations.js
index 4fe4b5aeb..35703f943 100644
--- a/web/public/integrations.js
+++ b/web/public/integrations.js
@@ -1,15 +1,19 @@
-;(function (c, l, a, r, i, t, y) {
- c[a] =
- c[a] ||
- function () {
- ;(c[a].q = c[a].q || []).push(arguments)
- }
- t = l.createElement(r)
- t.async = 1
- t.src = 'https://www.clarity.ms/tag/' + i
- y = l.getElementsByTagName(r)[0]
- y.parentNode.insertBefore(t, y)
-})(window, document, 'clarity', 'script', 'f3xyl9wf6t')
-
-// LemonSqueezy
-window.lemonSqueezyAffiliateConfig = { store: 'httpsms' }
+// CUSTOM: emptied for self-hosting. Upstream shipped two third-party
+// integrations here, both wired to the UPSTREAM PROJECT'S OWN accounts:
+//
+// 1. Microsoft Clarity, project id 'f3xyl9wf6t' — session RECORDING, not just
+// page counts. It captures clicks, scroll and DOM snapshots and posts them
+// to https://t.clarity.ms/collect. On a self-hosted install that means the
+// contents of this console — contacts, thread lists, phone numbers on
+// screen — were being recorded and delivered to someone else's analytics
+// dashboard, with a 204 back and nothing visible in the UI.
+//
+// 2. window.lemonSqueezyAffiliateConfig = { store: 'httpsms' } — upstream's
+// billing/affiliate store. Irrelevant here; ENTITLEMENT_ENABLED is false
+// and there is no checkout.
+//
+// The file itself is KEPT rather than deleted: nuxt.config.ts references
+// /integrations.js, so removing it would turn every page load into a 404.
+//
+// If you ever want your OWN analytics, add it here with YOUR id — and prefer
+// something that does not record sessions.