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.

%s

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() { -
  • - - Affiliates - - -
  • +// 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 + } +} + + + 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 () => { -