From 9a6c121d095ac55ba9925ea16268ca96c683275e Mon Sep 17 00:00:00 2001 From: TCFDONMEZ Date: Thu, 8 Oct 2026 13:23:00 +0300 Subject: [PATCH] Rename app and persist all-time consumption to MongoDB --- .gitignore | 4 + OTA.md | 4 +- README.md | 11 +- app/build.gradle | 11 +- .../com/evsuite/abrp/AbrpUploadService.java | 3 + .../evsuite/abrp/ConsumptionCloudClient.java | 82 ++ .../com/evsuite/abrp/ConsumptionTracker.java | 24 + .../java/com/evsuite/abrp/MainActivity.java | 24 +- app/src/main/res/layout/pane_consumption.xml | 10 +- app/src/main/res/values/strings.xml | 6 +- .../java/com/evsuite/abrp/OtaUpdaterTest.java | 10 +- .../java/com/evsuite/abrp/OtaUpdater.java | 14 +- server/.env.example | 4 + server/.gitignore | 3 + server/Dockerfile | 9 + server/README.md | 21 + server/package-lock.json | 1040 +++++++++++++++++ server/package.json | 17 + server/src/app.js | 100 ++ server/src/server.js | 28 + server/test/app.test.js | 52 + settings.gradle | 2 +- 22 files changed, 1449 insertions(+), 30 deletions(-) create mode 100644 app/src/main/java/com/evsuite/abrp/ConsumptionCloudClient.java create mode 100644 server/.env.example create mode 100644 server/.gitignore create mode 100644 server/Dockerfile create mode 100644 server/README.md create mode 100644 server/package-lock.json create mode 100644 server/package.json create mode 100644 server/src/app.js create mode 100644 server/src/server.js create mode 100644 server/test/app.test.js diff --git a/.gitignore b/.gitignore index b9f8ba9..aab93a6 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,10 @@ local.properties mise.local.toml .mise.local.toml +# Local API configuration and dependencies. +/server/.env +/server/node_modules + # Signed build output. Rebuilt from source by ./gradlew assembleUnstableDebug and # signed locally — a checked-in APK is stale the moment anything above it changes. *.apk diff --git a/OTA.md b/OTA.md index fec7c7b..1e1ad16 100644 --- a/OTA.md +++ b/OTA.md @@ -25,7 +25,7 @@ vehicle's shared UID; do not use that preview APK for the MG4 upgrade. ## What the updater actually looks for -`OtaUpdater.check()` reads `https://api.github.com/repos/fatihdonmezdev/MG4ABRP/releases` +`OtaUpdater.check()` reads `https://api.github.com/repos/fatihdonmezdev/FatihsMG4/releases` and walks **every** entry, keeping the highest version rather than the first match. A release is a candidate only if all of these hold: @@ -99,7 +99,7 @@ The version comes from the **asset filename**, not the git tag. The tag can stay "$BT/apksigner" verify --print-certs FatihsMG4-unstable-2.2.7.apk ``` -5. **Publish.** On github.com/fatihdonmezdev/MG4ABRP → Releases → Draft a new release: +5. **Publish.** On github.com/fatihdonmezdev/FatihsMG4 → Releases → Draft a new release: - tag: `unstable` (reuse it; the rolling tag is the design) - **tick "Set as a pre-release"** ← the whole thing is inert without this - attach the signed APK and its `.sha256` sidecar diff --git a/README.md b/README.md index 5c38800..d723b58 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,8 @@ -# EVABRPUploader +# FatihsMG4 -

EVABRPUploader

+

FatihsMG4

-[![Tests](https://github.com/malys/EVABRPUploader/actions/workflows/tests.yml/badge.svg)](https://github.com/malys/EVABRPUploader/actions/workflows/tests.yml) -[![Security](https://github.com/malys/EVABRPUploader/actions/workflows/security.yml/badge.svg)](https://github.com/malys/EVABRPUploader/actions/workflows/security.yml) -[![Unstable](https://github.com/malys/EVABRPUploader/actions/workflows/unstable.yml/badge.svg)](https://github.com/malys/EVABRPUploader/actions/workflows/unstable.yml) -[![Release](https://img.shields.io/github/v/release/malys/EVABRPUploader?include_prereleases&sort=semver)](https://github.com/malys/EVABRPUploader/releases) +[![Release](https://img.shields.io/github/v/release/fatihdonmezdev/FatihsMG4?include_prereleases&sort=semver)](https://github.com/fatihdonmezdev/FatihsMG4/releases) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) Sends live telemetry from an **MG4 (SAIC eh32)** to @@ -112,7 +109,7 @@ Two channels. Pick one — they install side by side. | **Stable** | No. Contains no updater at all. | You want the car to run what you put on it | | **Unstable** | Yes, from GitHub pre-releases | You are testing and want fixes as they land | -Grab the APK from [Releases](https://github.com/malys/EVABRPUploader/releases). Stable builds are the tagged ones; unstable builds +Grab the APK from [Releases](https://github.com/fatihdonmezdev/FatihsMG4/releases). Stable builds are the tagged ones; unstable builds are marked pre-release. ### Getting the APK onto the car diff --git a/app/build.gradle b/app/build.gradle index 014d6e2..c076913 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -37,6 +37,11 @@ android { versionCode 51 versionName "2.2.34" + // MongoDB credentials never ship in the APK. The app only talks to the small + // authenticated API in server/, which owns the database connection. + buildConfigField "String", "CONSUMPTION_API_URL", "\"${System.getenv('CONSUMPTION_API_URL') ?: ''}\"" + buildConfigField "String", "CONSUMPTION_API_TOKEN", "\"${System.getenv('CONSUMPTION_API_TOKEN') ?: ''}\"" + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } @@ -104,7 +109,7 @@ android { } // Version publiée par la CI pour composer le nom de l'asset APK : ce nom DOIT être -// comparable par l'updater ("EVABRPUploader-unstable-1.0.42.apk"). Le tag de la +// comparable par l'updater ("FatihsMG4-unstable-1.0.42.apk"). Le tag de la // pré-release est la constante "unstable", écrasée à chaque build. tasks.register('printUnstableVersion') { doLast { @@ -120,8 +125,8 @@ androidComponents { // Le canal unstable garde un nom de fichier stable : l'updater identifie // la version par le tag de la pré-release, jamais par le nom d'APK. output.outputFileName.set(variant.flavorName == "unstable" - ? "EVABRPUploader-unstable.apk" - : "EVABRPUploader-${variant.flavorName}-${output.versionName.get()}.apk") + ? "FatihsMG4-unstable.apk" + : "FatihsMG4-${variant.flavorName}-${output.versionName.get()}.apk") } } } diff --git a/app/src/main/java/com/evsuite/abrp/AbrpUploadService.java b/app/src/main/java/com/evsuite/abrp/AbrpUploadService.java index e923985..6ff63c6 100644 --- a/app/src/main/java/com/evsuite/abrp/AbrpUploadService.java +++ b/app/src/main/java/com/evsuite/abrp/AbrpUploadService.java @@ -130,6 +130,7 @@ public class AbrpUploadService extends Service { private EnergyTelemetryReader energyReader; private ConsumptionTracker consumptionTracker; + private ConsumptionCloudClient consumptionCloudClient; /** * Keeps the unit on the phone hotspot without a trip into Settings. Attempted on the * upload tick because that is exactly when being offline costs something, and it is @@ -196,6 +197,7 @@ public class AbrpUploadService extends Service { // One shared, firmware-aware EVHardware snapshot owns every vehicle signal. energyReader = new EnergyTelemetryReader(getApplicationContext()); consumptionTracker = ConsumptionTracker.get(this); + consumptionCloudClient = new ConsumptionCloudClient(getApplicationContext()); wifi = new WifiAutoConnect(getApplicationContext()); // The head unit's own weather service, bound for one reason: ENV_OUTSIDE_TEMPERATURE // is not implemented on this vehicle, so the car cannot say how warm it is outside @@ -324,6 +326,7 @@ public class AbrpUploadService extends Service { long sampleMs = System.currentTimeMillis(); EnergySnapshot vehicle = energyReader.read(sampleMs); consumptionTracker.sample(vehicle); + consumptionCloudClient.syncIfDue(consumptionTracker.cloudSnapshot(), sampleMs); // Keep local consumption history alive while another app owns the ABRP feed. if (!settings.abrpUploadEnabled) { diff --git a/app/src/main/java/com/evsuite/abrp/ConsumptionCloudClient.java b/app/src/main/java/com/evsuite/abrp/ConsumptionCloudClient.java new file mode 100644 index 0000000..2911a5b --- /dev/null +++ b/app/src/main/java/com/evsuite/abrp/ConsumptionCloudClient.java @@ -0,0 +1,82 @@ +package com.evsuite.abrp; + +import android.content.Context; +import android.content.SharedPreferences; +import android.util.Log; + +import org.json.JSONObject; + +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.UUID; + +/** Best-effort MongoDB sync through the API in server/. Never blocks local persistence. */ +final class ConsumptionCloudClient { + private static final String TAG = "FatihsMG4.Mongo"; + private static final long SYNC_INTERVAL_MS = 5 * 60_000L; + private static final int TIMEOUT_MS = 8_000; + private static final String PREFS = "consumption_cloud"; + private static final String INSTALLATION_ID = "installation_id"; + + private final String endpoint; + private final String token; + private final String installationId; + private long lastAttemptMs; + + ConsumptionCloudClient(Context context) { + endpoint = BuildConfig.CONSUMPTION_API_URL.trim(); + token = BuildConfig.CONSUMPTION_API_TOKEN.trim(); + SharedPreferences prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE); + String existing = prefs.getString(INSTALLATION_ID, ""); + if (existing == null || existing.isEmpty()) { + existing = UUID.randomUUID().toString(); + prefs.edit().putString(INSTALLATION_ID, existing).commit(); + } + installationId = existing; + } + + void syncIfDue(ConsumptionTracker.CloudSnapshot snapshot, long nowMs) { + if (endpoint.isEmpty() || token.isEmpty() || nowMs - lastAttemptMs < SYNC_INTERVAL_MS) return; + lastAttemptMs = nowMs; + HttpURLConnection connection = null; + try { + JSONObject body = new JSONObject() + .put("installationId", installationId) + .put("recordedAt", nowMs) + .put("lifetime", totals(snapshot.lifetime)); + if (snapshot.sohPercent != null) body.put("sohPercent", snapshot.sohPercent); + byte[] bytes = body.toString().getBytes(StandardCharsets.UTF_8); + connection = (HttpURLConnection) new URL(trimSlash(endpoint) + "/v1/consumption").openConnection(); + connection.setRequestMethod("PUT"); + connection.setConnectTimeout(TIMEOUT_MS); + connection.setReadTimeout(TIMEOUT_MS); + connection.setDoOutput(true); + connection.setFixedLengthStreamingMode(bytes.length); + connection.setRequestProperty("Content-Type", "application/json"); + connection.setRequestProperty("Authorization", "Bearer " + token); + try (OutputStream out = connection.getOutputStream()) { out.write(bytes); } + int status = connection.getResponseCode(); + try (InputStream ignored = status < 400 + ? connection.getInputStream() : connection.getErrorStream()) { } + if (status < 200 || status >= 300) Log.w(TAG, "Sync rejected with HTTP " + status); + } catch (Exception e) { + Log.w(TAG, "Consumption sync unavailable"); + } finally { + if (connection != null) connection.disconnect(); + } + } + + private static JSONObject totals(ConsumptionTracker.Totals totals) throws Exception { + return new JSONObject().put("km", totals.km).put("kwh", totals.kwh) + .put("hours", totals.hours).put("socDrop", totals.soc); + } + + private static String trimSlash(String value) { + int end = value.length(); + while (end > 0 && value.charAt(end - 1) == '/') end--; + return value.substring(0, end); + } +} diff --git a/app/src/main/java/com/evsuite/abrp/ConsumptionTracker.java b/app/src/main/java/com/evsuite/abrp/ConsumptionTracker.java index 05e0909..0fd8d7a 100644 --- a/app/src/main/java/com/evsuite/abrp/ConsumptionTracker.java +++ b/app/src/main/java/com/evsuite/abrp/ConsumptionTracker.java @@ -34,6 +34,7 @@ final class ConsumptionTracker { */ private static final String PREFS = "consumption_counters_v2"; private static final String LEGACY_PREFS = "consumption_counters_v1"; + private static final String SOH_PERCENT = "manual_soh_percent"; /** How often the in-memory accumulators reach disk. Matches DriveHub_Dort. */ private static final long PERSIST_INTERVAL_MS = 30_000L; @@ -138,6 +139,29 @@ final class ConsumptionTracker { return loadTotals(dayKey(date)); } + synchronized CloudSnapshot cloudSnapshot() { + return new CloudSnapshot(lifetime.snapshot(), sohPercent()); + } + + static final class CloudSnapshot { + final Totals lifetime; + final Float sohPercent; + + CloudSnapshot(Totals lifetime, Float sohPercent) { + this.lifetime = lifetime; + this.sohPercent = sohPercent; + } + } + + synchronized Float sohPercent() { + return prefs.contains(SOH_PERCENT) ? prefs.getFloat(SOH_PERCENT, 0f) : null; + } + + synchronized boolean setSohPercent(float value) { + if (!Float.isFinite(value) || value <= 0f || value > 100f) return false; + return prefs.edit().putFloat(SOH_PERCENT, value).commit(); + } + /** Flushes the day that just ended and opens the new one. */ private void rollDayIfNeeded() { LocalDate date = LocalDate.now(); diff --git a/app/src/main/java/com/evsuite/abrp/MainActivity.java b/app/src/main/java/com/evsuite/abrp/MainActivity.java index d9c8fbf..c7be7d7 100644 --- a/app/src/main/java/com/evsuite/abrp/MainActivity.java +++ b/app/src/main/java/com/evsuite/abrp/MainActivity.java @@ -12,6 +12,7 @@ import android.view.View; import android.widget.AdapterView; import android.widget.ArrayAdapter; import android.widget.Button; +import android.widget.Toast; import android.widget.ImageView; import android.widget.Spinner; import android.widget.TextView; @@ -42,7 +43,7 @@ public class MainActivity extends AppCompatActivity { private static final int LOCATION_PERMISSION_REQUEST = 100; /** Shared with {@link AbrpUploadService}, which reads it on every tick. */ static final String WIFI_AUTO_KEY = "wifi_auto_connect"; - private static final String REPOSITORY_URL = "https://github.com/fatihdonmezdev/MG4ABRP"; + private static final String REPOSITORY_URL = "https://github.com/fatihdonmezdev/FatihsMG4"; /** Navigation destinations in page order. Parallel to {@link #panes}. */ private static final int[] TAB_IDS = @@ -114,6 +115,8 @@ public class MainActivity extends AppCompatActivity { consumptionSpeed, consumptionTime, consumptionSoc, consumptionDailyHistory; private Button consumptionReset; private MaterialButton consumptionDateButton; + private TextInputLayout consumptionSohLayout; + private TextInputEditText consumptionSohInput; private java.time.LocalDate selectedConsumptionDate = java.time.LocalDate.now(); private TextView chargeStatus, chargeSoc, chargeVoltage, chargeCurrent, chargePower, chargeEnergy, chargeDuration, chargeCurveTable; @@ -861,6 +864,12 @@ public class MainActivity extends AppCompatActivity { consumptionDateButton.setText(selectedConsumptionDate.toString()); consumptionDateButton.setOnClickListener(v -> showConsumptionDatePicker()); consumptionReset = consumptionPane.findViewById(R.id.consumption_reset); + consumptionSohLayout = consumptionPane.findViewById(R.id.consumption_soh_layout); + consumptionSohInput = consumptionPane.findViewById(R.id.consumption_soh_input); + Float savedSoh = consumptionTracker.sohPercent(); + if (savedSoh != null) consumptionSohInput.setText(String.format( + java.util.Locale.US, "%.1f", savedSoh)); + consumptionPane.findViewById(R.id.consumption_soh_save).setOnClickListener(v -> saveSoh()); bindPeriodButton(R.id.consumption_lifetime, ConsumptionTracker.Period.LIFETIME); bindPeriodButton(R.id.consumption_week, ConsumptionTracker.Period.WEEK); bindPeriodButton(R.id.consumption_month, ConsumptionTracker.Period.MONTH); @@ -880,6 +889,19 @@ public class MainActivity extends AppCompatActivity { }); } + private void saveSoh() { + String raw = textOf(consumptionSohInput).replace(',', '.'); + try { + float value = Float.parseFloat(raw); + if (!consumptionTracker.setSohPercent(value)) throw new NumberFormatException(); + consumptionSohLayout.setError(null); + consumptionSohInput.setText(String.format(java.util.Locale.US, "%.1f", value)); + Toast.makeText(this, "SoH kaydedildi", Toast.LENGTH_SHORT).show(); + } catch (NumberFormatException e) { + consumptionSohLayout.setError("1 ile 100 arasında bir değer girin"); + } + } + private void bindPeriodButton(int id, ConsumptionTracker.Period period) { View button = consumptionPane.findViewById(id); button.setOnClickListener(v -> { diff --git a/app/src/main/res/layout/pane_consumption.xml b/app/src/main/res/layout/pane_consumption.xml index 6555cff..edee196 100644 --- a/app/src/main/res/layout/pane_consumption.xml +++ b/app/src/main/res/layout/pane_consumption.xml @@ -1,5 +1,5 @@ - + @@ -23,6 +23,14 @@ + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cc4dd0c..f2e4732 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,5 +1,5 @@ - Fatih\'sMG4 + FatihsMG4 ABRP @@ -128,9 +128,9 @@ Fatih\'sMG4 Hakkında Version %1$s Fork of malys / EVABRPUploader\nand Leon Kernan / ABRP_Uploader - github.com/fatihdonmezdev/MG4ABRP + github.com/fatihdonmezdev/FatihsMG4 MG4 EH32 · SWI69 - EVABRPUploader logo + FatihsMG4 logo QR code for the project repository unknown Close diff --git a/app/src/testUnstable/java/com/evsuite/abrp/OtaUpdaterTest.java b/app/src/testUnstable/java/com/evsuite/abrp/OtaUpdaterTest.java index ad9d356..4211434 100644 --- a/app/src/testUnstable/java/com/evsuite/abrp/OtaUpdaterTest.java +++ b/app/src/testUnstable/java/com/evsuite/abrp/OtaUpdaterTest.java @@ -102,13 +102,13 @@ public class OtaUpdaterTest { @Test public void versionIsReadFromTheAssetName() { // The release tag is the constant "unstable", so the asset name carries the build. - assertEquals("1.0.42", OtaUpdater.versionFromAssetName("EVABRPUploader-unstable-1.0.42.apk")); - assertEquals("1.0.100", OtaUpdater.versionFromAssetName("EVABRPUploader-unstable-1.0.100.APK")); + assertEquals("1.0.42", OtaUpdater.versionFromAssetName("FatihsMG4-unstable-1.0.42.apk")); + assertEquals("1.0.100", OtaUpdater.versionFromAssetName("FatihsMG4-unstable-1.0.100.APK")); } @Test public void assetNameWithoutAVersionIsIgnored() { - assertNull(OtaUpdater.versionFromAssetName("EVABRPUploader-unstable.apk")); + assertNull(OtaUpdater.versionFromAssetName("FatihsMG4-unstable.apk")); assertNull(OtaUpdater.versionFromAssetName("unstable")); } @@ -218,7 +218,7 @@ public class OtaUpdaterTest { private static JSONObject asset(String version) throws Exception { String name = "FatihsMG4-unstable-" + version + ".apk"; return new JSONObject().put("name", name).put("browser_download_url", - "https://github.com/fatihdonmezdev/MG4ABRP/releases/download/unstable/" + name); + "https://github.com/fatihdonmezdev/FatihsMG4/releases/download/unstable/" + name); } private static JSONObject release(boolean prerelease, JSONObject... assets) throws Exception { @@ -249,7 +249,7 @@ public class OtaUpdaterTest { boolean disconnected; Response(int status, String body) throws Exception { - super(new URL("https://api.github.com/repos/fatihdonmezdev/MG4ABRP/releases")); + super(new URL("https://api.github.com/repos/fatihdonmezdev/FatihsMG4/releases")); this.status = status; this.body = body; } diff --git a/app/src/unstable/java/com/evsuite/abrp/OtaUpdater.java b/app/src/unstable/java/com/evsuite/abrp/OtaUpdater.java index bf3ad5d..b180804 100644 --- a/app/src/unstable/java/com/evsuite/abrp/OtaUpdater.java +++ b/app/src/unstable/java/com/evsuite/abrp/OtaUpdater.java @@ -53,7 +53,7 @@ import java.security.MessageDigest; final class OtaUpdater { private static final String TAG = "OtaUpdater"; - private static final String CACHE_PREFIX = "EVABRPUploader-ota-"; + private static final String CACHE_PREFIX = "FatihsMG4-ota-"; /** * Pre-releases live here; the unstable channel tracks them. @@ -64,7 +64,7 @@ final class OtaUpdater { * nothing until this repository publishes a release, which is the intended quiet. */ private static final String RELEASES_API = - "https://api.github.com/repos/fatihdonmezdev/MG4ABRP/releases"; + "https://api.github.com/repos/fatihdonmezdev/FatihsMG4/releases"; /** * Hosts an update may come from. The githubusercontent entries are the CDNs GitHub @@ -162,7 +162,7 @@ final class OtaUpdater { /** * Version carried by an unstable asset name: - * "EVABRPUploader-unstable-1.0.42.apk" -> "1.0.42". + * "FatihsMG4-unstable-1.0.42.apk" -> "1.0.42". * * The release tag is the fixed string "unstable" (one rolling pre-release), so the asset * name is what identifies a build. Returns null when the name carries no version. @@ -198,7 +198,7 @@ final class OtaUpdater { try { conn.setInstanceFollowRedirects(false); conn.setRequestProperty("Accept", "application/vnd.github.v3+json"); - conn.setRequestProperty("User-Agent", "EVABRPUploader-Android"); + conn.setRequestProperty("User-Agent", "FatihsMG4-Android"); conn.setConnectTimeout(TIMEOUT_MS); conn.setReadTimeout(TIMEOUT_MS); int status = conn.getResponseCode(); @@ -297,7 +297,7 @@ final class OtaUpdater { connection.setInstanceFollowRedirects(false); connection.setConnectTimeout(TIMEOUT_MS); connection.setReadTimeout(TIMEOUT_MS); - connection.setRequestProperty("User-Agent", "EVABRPUploader-Android"); + connection.setRequestProperty("User-Agent", "FatihsMG4-Android"); try { int status = connection.getResponseCode(); if (status >= 300 && status <= 399) { @@ -358,7 +358,7 @@ final class OtaUpdater { long downloadId = -1L; try { DownloadManager.Request request = new DownloadManager.Request(Uri.parse(update.apkUrl)) - .setTitle("EVABRPUploader " + update.versionName) + .setTitle("FatihsMG4 " + update.versionName) .setDescription("Güncelleme indiriliyor") .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) .setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, publicName) @@ -564,7 +564,7 @@ final class OtaUpdater { Intent install = new Intent(Intent.ACTION_VIEW) .setDataAndType(contentUri, "application/vnd.android.package-archive") .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_GRANT_READ_URI_PERMISSION); - install.setClipData(ClipData.newRawUri("EVABRPUploader update", contentUri)); + install.setClipData(ClipData.newRawUri("FatihsMG4 update", contentUri)); grantToInstallers(context, contentUri); if (install.resolveActivity(context.getPackageManager()) == null) { throw new IllegalStateException("No package installer activity"); diff --git a/server/.env.example b/server/.env.example new file mode 100644 index 0000000..6d69c15 --- /dev/null +++ b/server/.env.example @@ -0,0 +1,4 @@ +MONGODB_URI=mongodb+srv://username:password@cluster.example.mongodb.net/ +MONGODB_DATABASE=fatihsmg4 +API_TOKEN=replace-with-a-long-random-secret +PORT=8080 diff --git a/server/.gitignore b/server/.gitignore new file mode 100644 index 0000000..03c5ccf --- /dev/null +++ b/server/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +.env +npm-debug.log* diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..0f5fb99 --- /dev/null +++ b/server/Dockerfile @@ -0,0 +1,9 @@ +FROM node:22-alpine +WORKDIR /app +COPY package*.json ./ +RUN npm ci --omit=dev +COPY src ./src +ENV NODE_ENV=production +USER node +EXPOSE 8080 +CMD ["node", "src/server.js"] diff --git a/server/README.md b/server/README.md new file mode 100644 index 0000000..15821f0 --- /dev/null +++ b/server/README.md @@ -0,0 +1,21 @@ +# FatihsMG4 consumption API + +The Android app never connects to MongoDB directly and never contains a MongoDB URI. This +service accepts authenticated all-time totals and upserts them into the `consumption` collection. + +## Run + +1. Copy `.env.example` to `.env` and fill in the MongoDB Atlas URI and a random API token. +2. Export the variables (Node does not load `.env` automatically), then run `npm install` and + `npm start`. +3. Build the Android app with the public HTTPS service address and the same token: + + ```sh + CONSUMPTION_API_URL=https://your-api.example.com \ + CONSUMPTION_API_TOKEN=your-random-token \ + ./gradlew assembleUnstableRelease + ``` + +If either Android build variable is missing, cloud sync is disabled and local consumption +tracking continues unchanged. MongoDB receives one continuously updated document per installation, +containing all-time `km`, `kwh`, `hours`, and `socDrop` totals plus the manually entered `sohPercent`. diff --git a/server/package-lock.json b/server/package-lock.json new file mode 100644 index 0000000..72f9a88 --- /dev/null +++ b/server/package-lock.json @@ -0,0 +1,1040 @@ +{ + "name": "fatihsmg4-consumption-api", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "fatihsmg4-consumption-api", + "version": "1.0.0", + "dependencies": { + "express": "^5.1.0", + "mongodb": "^6.20.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@mongodb-js/saslprep": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.6.0.tgz", + "integrity": "sha512-19jGXfpctLDJUELLUZGJ5BllcLT7rHdF+gTTaN2jzqu0bGkUhpdcYtgK2Vux+g3zuBAa0TM/W666QMIjYhYujw==", + "license": "MIT", + "dependencies": { + "sparse-bitfield": "^3.0.3" + } + }, + "node_modules/@types/webidl-conversions": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", + "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==", + "license": "MIT" + }, + "node_modules/@types/whatwg-url": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz", + "integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==", + "license": "MIT", + "dependencies": { + "@types/webidl-conversions": "*" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bson": { + "version": "6.10.4", + "resolved": "https://registry.npmjs.org/bson/-/bson-6.10.4.tgz", + "integrity": "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==", + "license": "Apache-2.0", + "engines": { + "node": ">=16.20.1" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/memory-pager": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", + "license": "MIT" + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mongodb": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.21.0.tgz", + "integrity": "sha512-URyb/VXMjJ4da46OeSXg+puO39XH9DeQpWCslifrRn9JWugy0D+DvvBvkm2WxmHe61O/H19JM66p1z7RHVkZ6A==", + "license": "Apache-2.0", + "dependencies": { + "@mongodb-js/saslprep": "^1.3.0", + "bson": "^6.10.4", + "mongodb-connection-string-url": "^3.0.2" + }, + "engines": { + "node": ">=16.20.1" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.188.0", + "@mongodb-js/zstd": "^1.1.0 || ^2.0.0", + "gcp-metadata": "^5.2.0", + "kerberos": "^2.0.1", + "mongodb-client-encryption": ">=6.0.0 <7", + "snappy": "^7.3.2", + "socks": "^2.7.1" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, + "node_modules/mongodb-connection-string-url": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.2.tgz", + "integrity": "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==", + "license": "Apache-2.0", + "dependencies": { + "@types/whatwg-url": "^11.0.2", + "whatwg-url": "^14.1.0 || ^13.0.0" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/sparse-bitfield": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", + "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", + "license": "MIT", + "dependencies": { + "memory-pager": "^1.0.2" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + } + } +} diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..8ec4047 --- /dev/null +++ b/server/package.json @@ -0,0 +1,17 @@ +{ + "name": "fatihsmg4-consumption-api", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "start": "node src/server.js", + "test": "node --test" + }, + "engines": { + "node": ">=22" + }, + "dependencies": { + "express": "^5.1.0", + "mongodb": "^6.20.0" + } +} diff --git a/server/src/app.js b/server/src/app.js new file mode 100644 index 0000000..7cc4ef4 --- /dev/null +++ b/server/src/app.js @@ -0,0 +1,100 @@ +import crypto from "node:crypto"; +import express from "express"; + +const MAX_BODY_BYTES = "16kb"; + +export function createApp({ collection, apiToken }) { + if (!collection) throw new Error("collection is required"); + if (!apiToken || apiToken.length < 24) throw new Error("API_TOKEN must contain at least 24 characters"); + + const app = express(); + app.disable("x-powered-by"); + app.use(express.json({ limit: MAX_BODY_BYTES, strict: true })); + + app.get("/health", (_request, response) => response.json({ status: "ok" })); + + app.put("/v1/consumption", authenticate(apiToken), async (request, response, next) => { + try { + const record = validateRecord(request.body); + const updatedAt = new Date(); + await collection.updateOne( + { installationId: record.installationId }, + { + $set: { ...record, updatedAt }, + $setOnInsert: { createdAt: updatedAt } + }, + { upsert: true } + ); + response.status(204).end(); + } catch (error) { + if (error instanceof ValidationError) { + response.status(400).json({ error: error.message }); + return; + } + next(error); + } + }); + + app.use((error, _request, response, _next) => { + if (error?.type === "entity.parse.failed" || error?.type === "entity.too.large") { + response.status(400).json({ error: "invalid JSON body" }); + return; + } + console.error("Request failed", error); + response.status(500).json({ error: "internal server error" }); + }); + return app; +} + +function authenticate(expected) { + const expectedBytes = Buffer.from(expected); + return (request, response, next) => { + const header = request.get("authorization") ?? ""; + const supplied = header.startsWith("Bearer ") ? header.slice(7) : ""; + const suppliedBytes = Buffer.from(supplied); + if (suppliedBytes.length !== expectedBytes.length || + !crypto.timingSafeEqual(suppliedBytes, expectedBytes)) { + response.status(401).json({ error: "unauthorized" }); + return; + } + next(); + }; +} + +class ValidationError extends Error {} + +function validateRecord(body) { + if (!body || typeof body !== "object" || Array.isArray(body)) throw new ValidationError("body is required"); + if (!/^[0-9a-f]{8}-[0-9a-f-]{27}$/i.test(body.installationId ?? "")) { + throw new ValidationError("installationId is invalid"); + } + if (!Number.isSafeInteger(body.recordedAt) || body.recordedAt < 0) { + throw new ValidationError("recordedAt is invalid"); + } + return { + installationId: body.installationId, + recordedAt: new Date(body.recordedAt), + lifetime: validateTotals(body.lifetime, "lifetime"), + ...(body.sohPercent === undefined ? {} : { sohPercent: validateSoh(body.sohPercent) }) + }; +} + +function validateSoh(value) { + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0 || value > 100) { + throw new ValidationError("sohPercent is invalid"); + } + return value; +} + +function validateTotals(value, name) { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new ValidationError(`${name} is invalid`); + const result = {}; + for (const field of ["km", "kwh", "hours", "socDrop"]) { + const number = value[field]; + if (typeof number !== "number" || !Number.isFinite(number) || Math.abs(number) > 10_000_000) { + throw new ValidationError(`${name}.${field} is invalid`); + } + result[field] = number; + } + return result; +} diff --git a/server/src/server.js b/server/src/server.js new file mode 100644 index 0000000..bc85f3f --- /dev/null +++ b/server/src/server.js @@ -0,0 +1,28 @@ +import { MongoClient } from "mongodb"; +import { createApp } from "./app.js"; + +const { MONGODB_URI, API_TOKEN } = process.env; +const databaseName = process.env.MONGODB_DATABASE || "fatihsmg4"; +const port = Number(process.env.PORT || 8080); + +if (!MONGODB_URI) throw new Error("MONGODB_URI is required"); +if (!API_TOKEN) throw new Error("API_TOKEN is required"); +if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("PORT is invalid"); + +const client = new MongoClient(MONGODB_URI, { serverSelectionTimeoutMS: 10_000 }); +await client.connect(); +const collection = client.db(databaseName).collection("consumption"); +await collection.createIndex({ installationId: 1 }, { unique: true }); +await collection.createIndex({ updatedAt: -1 }); + +const app = createApp({ collection, apiToken: API_TOKEN }); +const server = app.listen(port, () => console.log(`FatihsMG4 API listening on ${port}`)); + +async function shutdown() { + server.close(async () => { + await client.close(); + process.exit(0); + }); +} +process.on("SIGINT", shutdown); +process.on("SIGTERM", shutdown); diff --git a/server/test/app.test.js b/server/test/app.test.js new file mode 100644 index 0000000..cbc7285 --- /dev/null +++ b/server/test/app.test.js @@ -0,0 +1,52 @@ +import assert from "node:assert/strict"; +import { after, before, test } from "node:test"; +import { createApp } from "../src/app.js"; + +const token = "a-secure-test-token-with-24-chars"; +const writes = []; +const collection = { + async updateOne(filter, update, options) { writes.push({ filter, update, options }); } +}; +let server; +let baseUrl; + +before(async () => { + const app = createApp({ collection, apiToken: token }); + await new Promise((resolve) => { server = app.listen(0, "127.0.0.1", resolve); }); + baseUrl = `http://127.0.0.1:${server.address().port}`; +}); +after(() => new Promise((resolve) => server.close(resolve))); + +const validBody = { + installationId: "123e4567-e89b-12d3-a456-426614174000", + recordedAt: 1791417600000, + lifetime: { km: 1000, kwh: 170, hours: 30, socDrop: 400 }, + sohPercent: 91.5 +}; + +test("rejects requests without the API token", async () => { + const response = await fetch(`${baseUrl}/v1/consumption`, { method: "PUT", body: JSON.stringify(validBody), headers: { "content-type": "application/json" } }); + assert.equal(response.status, 401); +}); + +test("validates malformed all-time totals", async () => { + const response = await fetch(`${baseUrl}/v1/consumption`, { + method: "PUT", + headers: { "content-type": "application/json", authorization: `Bearer ${token}` }, + body: JSON.stringify({ ...validBody, lifetime: { ...validBody.lifetime, km: "12" } }) + }); + assert.equal(response.status, 400); +}); + +test("upserts one all-time record per installation", async () => { + const response = await fetch(`${baseUrl}/v1/consumption`, { + method: "PUT", + headers: { "content-type": "application/json", authorization: `Bearer ${token}` }, + body: JSON.stringify(validBody) + }); + assert.equal(response.status, 204); + assert.deepEqual(writes.at(-1).filter, { installationId: validBody.installationId }); + assert.equal(writes.at(-1).options.upsert, true); + assert.equal(writes.at(-1).update.$set.lifetime.kwh, 170); + assert.equal(writes.at(-1).update.$set.sohPercent, 91.5); +}); diff --git a/settings.gradle b/settings.gradle index 5255aaf..98c922f 100644 --- a/settings.gradle +++ b/settings.gradle @@ -22,7 +22,7 @@ dependencyResolutionManagement { } } -rootProject.name = "EVABRPUploader" +rootProject.name = "FatihsMG4" include ':app' include ':evhardware' project(':evhardware').projectDir = file('EVHardware/lib')