commit 1594a9d4a4cb6e0495a8499fa8c01d5913fb6c43 Author: ScreenTinker Date: Wed Apr 8 12:14:53 2026 -0500 Initial open source release ScreenTinker - open source digital signage management software. MIT License, all features included, no license gates. Co-Authored-By: Claude Opus 4.6 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ec84be2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,42 @@ +# Dependencies +node_modules/ + +# Database +server/db/*.db +server/db/*.db-wal +server/db/*.db-shm + +# Uploads (user content) +server/uploads/ + +# Secrets and certificates +server/certs/key.pem +server/certs/cert.pem +server/certs/license_private.pem +server/certs/.jwt_secret +server/certs/.license_key + +# Android +android/.gradle/ +android/build/ +android/app/build/ +android/local.properties +android/release-key.jks +*.apk +*.aab + +# IDE / Editor +.claude/ +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Environment +.env +.env.* diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..1a1623e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,53 @@ +# Contributing to ScreenTinker + +Thanks for your interest in contributing! Here's how to get started. + +## Getting Started + +1. Fork the repository +2. Clone your fork: `git clone https://github.com/YOUR_USERNAME/screentinker.git` +3. Install dependencies: `cd server && npm install` +4. Start the dev server: `npm run dev` +5. Open `http://localhost:3001` + +## Making Changes + +1. Create a branch: `git checkout -b my-feature` +2. Make your changes +3. Test locally — make sure the server starts and the feature works +4. Commit with a clear message describing what changed and why +5. Push and open a pull request + +## What to Contribute + +- Bug fixes +- New widget types for the content designer +- Device platform support (e.g., new player implementations) +- Documentation improvements +- Translations (see `frontend/js/i18n.js`) +- Performance improvements + +## Guidelines + +- Keep PRs focused — one feature or fix per PR +- No build step for the frontend — it's vanilla JS by design +- Don't add heavy frameworks or dependencies without discussion +- Follow the existing code style +- Test on at least one device type if changing player/device code + +## Reporting Issues + +Open an issue on GitHub with: + +- What you expected to happen +- What actually happened +- Steps to reproduce +- Browser/device/OS info if relevant + +## Security + +If you discover a security vulnerability, please email **support@screentinker.com** instead of opening a public issue. + +## License + +By contributing, you agree that your contributions will be licensed under the [MIT License](LICENSE). diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6582768 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 ScreenTinker + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..89ec6c5 --- /dev/null +++ b/README.md @@ -0,0 +1,283 @@ +# ScreenTinker + +Open-source digital signage management software. Control content on TVs, displays, and kiosks from anywhere. + +**Hosted version:** [screentinker.com](https://screentinker.com) — free tier available, no credit card required. + +## Features + +- **Multi-zone layouts** — split screens into zones with drag-and-drop editor +- **Video walls** — combine multiple displays into one screen with bezel compensation +- **Remote control** — live view, key input, power on/off +- **Scheduling** — visual weekly calendar with recurrence rules +- **Content designer** — clocks, weather, RSS tickers, countdowns, QR codes +- **Kiosk mode** — interactive touchscreen interfaces +- **Proof-of-play** — analytics and CSV export for ad verification +- **Alerts** — email notifications when devices go offline +- **Teams** — multi-user with owner, editor, and viewer roles +- **White-label** — custom branding, colors, logo, domain +- **Built-in billing** — Stripe integration for SaaS subscriptions (optional) +- **Auto-update** — OTA updates pushed to devices automatically + +## Supported Platforms + +Android TV, Fire TV, Raspberry Pi, Windows, ChromeOS, LG webOS, Samsung Tizen, and any device with a web browser. + +## Self-Hosting + +### Requirements + +- Node.js 20+ +- Linux, macOS, or Windows + +### Quick Start + +```bash +git clone https://github.com/screentinker/screentinker.git +cd screentinker/server +npm install +SELF_HOSTED=true node server.js +``` + +The server starts on port 3001. Open `http://localhost:3001` in your browser. The first registered user gets full access with all features unlocked. + +### Environment Variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `PORT` | HTTP port | `3001` | +| `SELF_HOSTED` | First user gets all features unlocked | `false` | +| `APP_URL` | Your public URL (used for Stripe callbacks) | _(none)_ | +| `JWT_SECRET` | JWT signing key (auto-generated if not set) | _(auto)_ | +| `SSL_CERT` | Path to SSL certificate | `server/certs/cert.pem` | +| `SSL_KEY` | Path to SSL private key | `server/certs/key.pem` | + +### Optional Integrations + +All integrations are optional. The app works fully without any of them. + +#### Stripe (Billing) + +If you want to charge your users, plug in your own Stripe keys. Without them, all features are free for all users. + +1. Create a [Stripe account](https://stripe.com) +2. Create products/prices for each plan in the Stripe dashboard +3. Set up a webhook endpoint pointing to `https://yourdomain.com/api/stripe/webhook` with these events: + - `checkout.session.completed` + - `customer.subscription.updated` + - `customer.subscription.deleted` + - `invoice.payment_failed` +4. Update the `plans` table in the SQLite DB with your Stripe price IDs: + ```sql + UPDATE plans SET stripe_price_monthly = 'price_xxx', stripe_price_yearly = 'price_yyy' WHERE id = 'starter'; + ``` +5. Set the environment variables: + +| Variable | Description | +|----------|-------------| +| `STRIPE_SECRET_KEY` | Your Stripe secret key (`sk_live_...` or `sk_test_...`) | +| `STRIPE_WEBHOOK_SECRET` | Webhook signing secret (`whsec_...`) | +| `APP_URL` | Your public URL (e.g. `https://signage.yourcompany.com`) | + +The default plans are: Free (1 device), Starter ($39/mo, 5 devices), Pro ($99/mo, 15 devices), Business ($199/mo, 50 devices), and Custom (unlimited). Edit the `plans` table to change pricing, limits, or add/remove tiers. + +#### Google OAuth + +Let users sign in with Google. + +1. Create a project in [Google Cloud Console](https://console.cloud.google.com) +2. Enable the Google Identity API +3. Create OAuth 2.0 credentials (web application) +4. Add `https://yourdomain.com` as an authorized origin + +| Variable | Description | +|----------|-------------| +| `GOOGLE_CLIENT_ID` | Your Google OAuth client ID | + +#### Microsoft OAuth + +Let users sign in with Microsoft/Azure AD. + +1. Register an app in [Azure Portal](https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps) +2. Add a web redirect URI: `https://yourdomain.com` +3. Note the Application (client) ID + +| Variable | Description | +|----------|-------------| +| `MICROSOFT_CLIENT_ID` | Your Azure AD application client ID | +| `MICROSOFT_TENANT_ID` | Tenant ID (`common` for multi-tenant) | + +#### Email Alerts + +Send email notifications when devices go offline. + +| Variable | Description | +|----------|-------------| +| `EMAIL_WEBHOOK_URL` | POST endpoint that sends emails. Receives JSON: `{ to, subject, body }` | + +You can point this at any email sending service (SendGrid, Mailgun, a simple SMTP relay, etc.) via a small webhook adapter. + +### Production Deployment + +For production, put the app behind a reverse proxy (nginx, Caddy, etc.) with SSL: + +```bash +# Create a dedicated user +sudo useradd -r -s /bin/false screentinker + +# Copy the app +sudo cp -r . /opt/screentinker +sudo chown -R screentinker:screentinker /opt/screentinker + +# Install dependencies +cd /opt/screentinker/server && npm install --production + +# Create a systemd service +sudo cat > /etc/systemd/system/screentinker.service << 'EOF' +[Unit] +Description=ScreenTinker +After=network.target + +[Service] +Type=simple +User=screentinker +WorkingDirectory=/opt/screentinker/server +ExecStart=/usr/bin/node server.js +Restart=always +Environment=PORT=3001 +Environment=NODE_ENV=production +Environment=SELF_HOSTED=true +# Environment=APP_URL=https://signage.yourcompany.com +# Environment=STRIPE_SECRET_KEY=sk_live_... +# Environment=STRIPE_WEBHOOK_SECRET=whsec_... + +[Install] +WantedBy=multi-user.target +EOF + +sudo systemctl enable --now screentinker +``` + +#### Nginx Example + +```nginx +server { + listen 80; + server_name signage.yourcompany.com; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl http2; + server_name signage.yourcompany.com; + + ssl_certificate /path/to/cert.pem; + ssl_certificate_key /path/to/key.pem; + + client_max_body_size 500M; + + location / { + proxy_pass http://127.0.0.1:3001; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 86400; + } +} +``` + +### Backups + +The SQLite database is at `server/db/remote_display.db`. Back it up regularly: + +```bash +# Safe backup (works even while the server is running) +sqlite3 server/db/remote_display.db ".backup /path/to/backup.db" +``` + +Uploaded content is in `server/uploads/`. Back that up too. + +### Admin Recovery + +Locked out? Run this on the server to get a temporary admin token (1 hour): + +```bash +node scripts/reset-admin.js +``` + +### Building the Android APK + +The Android player app is in the `android/` directory. To build it: + +```bash +cd android + +# Set your keystore credentials (or generate a new keystore) +export KEYSTORE_PASSWORD=your_password +export KEY_ALIAS=your_alias +export KEY_PASSWORD=your_password + +# Build the APK +./gradlew assembleDebug +``` + +The APK will be at `android/app/build/outputs/apk/debug/app-debug.apk`. Copy it to `server/` as `ScreenTinker.apk` to serve it from `/download/apk`: + +```bash +cp android/app/build/outputs/apk/debug/app-debug.apk ScreenTinker.apk +``` + +To generate a new signing keystore: + +```bash +keytool -genkey -v -keystore android/release-key.jks -keyalg RSA -keysize 2048 -validity 10000 -alias your_alias +``` + +**Requirements:** Java 17+, Android SDK (API 34). + +### Device Setup + +1. Register at your ScreenTinker instance +2. Go to **Displays** and click **Add Display** +3. Install the ScreenTinker app on your device: + - **Android TV / tablets**: Download the APK from your instance (`/download/apk`) or build it from source (see above) + - **Raspberry Pi**: `curl -sSL https://your-instance/scripts/raspberry-pi-setup.sh | bash` + - **Windows**: Run the setup script from `scripts/windows-setup.bat` + - **Any browser**: Open `https://your-instance/player` in kiosk/fullscreen mode +4. Enter the pairing code shown on the device + +## Project Structure + +``` +server/ Node.js/Express backend + config.js Configuration and environment variables + server.js Main entry point + db/ SQLite database and schema + routes/ API route handlers + middleware/ Auth, rate limiting, file upload + services/ Background services (heartbeat, scheduler, alerts) + ws/ WebSocket handlers (device + dashboard) + player/ Web-based display player +frontend/ Static SPA dashboard + js/views/ View components + css/ Stylesheets + legal/ Terms, privacy, licenses +android/ Android TV/tablet player app +scripts/ Device setup scripts + admin recovery +``` + +## Tech Stack + +- **Backend:** Node.js, Express, Socket.IO, SQLite (better-sqlite3) +- **Frontend:** Vanilla JS SPA (no framework, no build step) +- **Android:** Kotlin, ExoPlayer, Socket.IO client +- **Auth:** JWT with bcrypt, Google/Microsoft OAuth (optional) +- **Payments:** Stripe (optional) + +## License + +[MIT](LICENSE) diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..73c8b4f --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +1.7.7 \ No newline at end of file diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..264a55a --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,78 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "com.remotedisplay.player" + compileSdk = 34 + + defaultConfig { + applicationId = "com.remotedisplay.player" + minSdk = 26 + targetSdk = 34 + versionCode = 10 + versionName = "1.7.7" + } + + signingConfigs { + create("release") { + storeFile = file("../release-key.jks") + storePassword = System.getenv("KEYSTORE_PASSWORD") ?: findProperty("KEYSTORE_PASSWORD") as String? ?: "" + keyAlias = System.getenv("KEY_ALIAS") ?: findProperty("KEY_ALIAS") as String? ?: "remotedisplay" + keyPassword = System.getenv("KEY_PASSWORD") ?: findProperty("KEY_PASSWORD") as String? ?: "" + } + } + + buildTypes { + debug { + signingConfig = signingConfigs.getByName("release") + } + release { + isMinifyEnabled = false + signingConfig = signingConfigs.getByName("release") + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } +} + +dependencies { + // AndroidX + implementation("androidx.core:core-ktx:1.12.0") + implementation("androidx.appcompat:appcompat:1.6.1") + implementation("com.google.android.material:material:1.11.0") + implementation("androidx.constraintlayout:constraintlayout:2.1.4") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") + implementation("androidx.lifecycle:lifecycle-service:2.7.0") + + // Encrypted SharedPreferences + implementation("androidx.security:security-crypto:1.1.0-alpha06") + + // ExoPlayer / Media3 + implementation("androidx.media3:media3-exoplayer:1.2.1") + implementation("androidx.media3:media3-ui:1.2.1") + + // Socket.IO client + implementation("io.socket:socket.io-client:2.1.0") + + // WorkManager for background downloads + implementation("androidx.work:work-runtime-ktx:2.9.0") + + // Gson for JSON + implementation("com.google.code.gson:gson:2.10.1") + + // OkHttp for file downloads + implementation("com.squareup.okhttp3:okhttp:4.12.0") + + // Coroutines + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..2cee567 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,3 @@ +# Socket.IO +-keep class io.socket.** { *; } +-keep class okhttp3.** { *; } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..c2cc5a4 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,112 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt b/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt new file mode 100644 index 0000000..f06e843 --- /dev/null +++ b/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt @@ -0,0 +1,553 @@ +package com.remotedisplay.player + +import android.accessibilityservice.AccessibilityServiceInfo +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.os.Build +import android.os.Bundle +import android.widget.FrameLayout +import android.os.Handler +import android.os.IBinder +import android.os.Looper +import android.util.Log +import android.view.KeyEvent +import android.view.View +import android.view.WindowManager +import android.view.accessibility.AccessibilityManager +import android.widget.ImageView +import android.widget.TextView +import androidx.appcompat.app.AppCompatActivity +import androidx.media3.ui.PlayerView +import com.remotedisplay.player.data.ContentCache +import com.remotedisplay.player.data.ServerConfig +import com.remotedisplay.player.player.MediaPlayerManager +import com.remotedisplay.player.player.PlaylistController +import com.remotedisplay.player.player.PlaylistItem +import com.remotedisplay.player.player.ZoneManager +import com.remotedisplay.player.remote.ScreenshotCapture +import com.remotedisplay.player.remote.TouchInjector +import com.remotedisplay.player.service.UpdateChecker +import com.remotedisplay.player.service.WebSocketService +import org.json.JSONObject +import kotlin.concurrent.thread + +class MainActivity : AppCompatActivity() { + + private lateinit var config: ServerConfig + private lateinit var contentCache: ContentCache + private lateinit var screenshotCapture: ScreenshotCapture + private lateinit var touchInjector: TouchInjector + + private var wsService: WebSocketService? = null + private var bound = false + private lateinit var mediaPlayer: MediaPlayerManager + private lateinit var playlistController: PlaylistController + private lateinit var updateChecker: UpdateChecker + private var zoneManager: ZoneManager? = null + + private lateinit var playerView: PlayerView + private lateinit var imageView: ImageView + private lateinit var statusOverlay: View + private lateinit var statusText: TextView + private lateinit var rootView: View + + private val handler = Handler(Looper.getMainLooper()) + private var remoteStreaming = false + private var screenshotStreamRunnable: Runnable? = null + private var playbackStarted = false + + private val connection = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName?, service: IBinder?) { + val binder = service as WebSocketService.LocalBinder + wsService = binder.getService() + bound = true + setupServiceCallbacks() + wsService?.connect() + } + + override fun onServiceDisconnected(name: ComponentName?) { + wsService = null + bound = false + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + config = ServerConfig(this) + val prefs = getSharedPreferences("remote_display", MODE_PRIVATE) + + // Show setup wizard if not completed yet + if (!prefs.getBoolean("setup_complete", false)) { + // Auto-mark complete if accessibility is already enabled (existing install) + if (isAccessibilityEnabled()) { + prefs.edit().putBoolean("setup_complete", true).apply() + } else { + startActivity(Intent(this, SetupActivity::class.java)) + finish() + return + } + } + + // Check provisioning BEFORE inflating the heavy media layout + if (!config.isProvisioned || !config.isPaired) { + startActivity(Intent(this, ProvisioningActivity::class.java)) + finish() + return + } + + setContentView(R.layout.activity_main) + + // Fullscreen immersive + @Suppress("DEPRECATION") + window.decorView.systemUiVisibility = ( + View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY or + View.SYSTEM_UI_FLAG_FULLSCREEN or + View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or + View.SYSTEM_UI_FLAG_LAYOUT_STABLE or + View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or + View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + ) + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + + contentCache = ContentCache(this) + screenshotCapture = ScreenshotCapture() + touchInjector = TouchInjector() + + playerView = findViewById(R.id.playerView) + imageView = findViewById(R.id.imageView) + statusOverlay = findViewById(R.id.statusOverlay) + statusText = findViewById(R.id.statusText) + rootView = findViewById(R.id.rootLayout) + + // Hide player controls + playerView.useController = false + + // Setup zone manager for multi-zone layouts + zoneManager = ZoneManager(this, rootView as FrameLayout) { + playlistController.onVideoComplete() + } + + // Setup playlist controller + playlistController = PlaylistController( + onItemChanged = { item -> item?.let { playItem(it) } }, + onPlaylistEmpty = { showStatus("Waiting for content...") }, + onRequestRefresh = { wsService?.requestPlaylistRefresh() } + ) + + // Setup media player + val youtubeWebView = findViewById(R.id.youtubeWebView) + mediaPlayer = MediaPlayerManager( + context = this, + playerView = playerView, + imageView = imageView, + youtubeWebView = youtubeWebView, + onVideoComplete = { playlistController.onVideoComplete() } + ) + + showStatus("Connecting to server...") + + // Start and bind to WebSocket service + try { + val serviceIntent = Intent(this, WebSocketService::class.java) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + startForegroundService(serviceIntent) + } else { + startService(serviceIntent) + } + bindService(serviceIntent, connection, Context.BIND_AUTO_CREATE) + } catch (e: Exception) { + Log.e("MainActivity", "Failed to start service: ${e.message}") + showStatus("Service error: ${e.message}") + } + + // Start auto-update checker + updateChecker = UpdateChecker(this) + updateChecker.startPeriodicCheck() + + } + + private fun setupServiceCallbacks() { + wsService?.onPlaylistUpdate = { data -> + try { + // Check if device is suspended (trial expired / over limit) + if (data.optBoolean("suspended", false)) { + val message = data.optString("message", "Account Suspended") + val detail = data.optString("detail", "Please upgrade your plan.") + handler.post { + showStatus("$message\n$detail") + if (::mediaPlayer.isInitialized) mediaPlayer.stop() + } + } else { + + val assignments = data.getJSONArray("assignments") + + // Check for multi-zone layout + val layoutObj = if (data.isNull("layout")) null else data.optJSONObject("layout") + val layoutZones = layoutObj?.optJSONArray("zones") + + if (layoutZones != null && layoutZones.length() > 1) { + // Multi-zone mode - use ZoneManager + val layoutId = layoutObj?.optString("id", "") ?: "" + val currentLayoutId = zoneManager?.currentLayoutId + + // Build a signature of current assignments to detect content changes + val assignmentSig = (0 until assignments.length()).map { i -> + val a = assignments.getJSONObject(i) + "${a.optString("content_id")}:${a.optString("zone_id")}:${a.optString("widget_id")}" + }.sorted().joinToString("|") + val changed = assignmentSig != zoneManager?.lastAssignmentSig + + if (zoneManager?.hasZones() != true || layoutId != currentLayoutId) { + Log.i("MainActivity", "Multi-zone layout with ${layoutZones.length()} zones (layout=$layoutId, was=$currentLayoutId)") + handler.post { + hideStatus() + if (::mediaPlayer.isInitialized) mediaPlayer.stop() + playlistController.stop() + playerView.visibility = View.GONE + imageView.visibility = View.GONE + zoneManager?.setupZones(layoutZones, layoutId) + zoneManager?.renderAssignments(assignments, config.serverUrl, contentCache) + zoneManager?.lastAssignmentSig = assignmentSig + } + } else if (changed) { + Log.i("MainActivity", "Multi-zone assignments changed, re-rendering") + handler.post { + zoneManager?.renderAssignments(assignments, config.serverUrl, contentCache) + zoneManager?.lastAssignmentSig = assignmentSig + } + } else { + Log.i("MainActivity", "Multi-zone unchanged, skipping") + } + } else { + // Single-zone mode - use PlaylistController (existing behavior) + if (zoneManager?.hasZones() == true) handler.post { zoneManager?.cleanup() } + playlistController.updatePlaylist(assignments) + } + + // Download any missing local content (skip remote URLs) + thread { + for (i in 0 until assignments.length()) { + val item = assignments.getJSONObject(i) + val contentId = item.getString("content_id") + val filename = item.optString("filename", "content") + val remoteUrl = item.optString("remote_url", null) + + // Skip remote URL content - it streams directly + if (!remoteUrl.isNullOrEmpty()) { + wsService?.sendContentAck(contentId, "ready") + continue + } + + if (!contentCache.isContentCached(contentId)) { + Log.i("MainActivity", "Downloading content: $filename") + var downloaded = false + for (attempt in 1..3) { + val file = contentCache.downloadContent(config.serverUrl, contentId, filename) + if (file != null) { + wsService?.sendContentAck(contentId, "ready") + downloaded = true + break + } + Log.w("MainActivity", "Download attempt $attempt failed for $filename") + if (attempt < 3) Thread.sleep(2000L * attempt) + } + if (!downloaded) wsService?.sendContentAck(contentId, "failed") + } + } + + // Start or resume playback after downloads complete + handler.post { + playlistController.startIfNeeded() + } + } + } // end else (not suspended) + } catch (e: Exception) { + Log.e("MainActivity", "Playlist update error: ${e.message}") + } + } + + wsService?.onContentDelete = { contentId -> + contentCache.deleteContent(contentId) + playlistController.removeContent(contentId) + } + + wsService?.onScreenshotRequest = { + // Handled by service now + } + + wsService?.onRemoteStart = { + // Handled by service now + } + + // Provide screenshot callback to service (composite capture on main thread) + wsService?.onCaptureScreenshot = { + screenshotCapture.captureView(rootView, 40) + } + + wsService?.onRemoteStop = { + remoteStreaming = false + stopScreenshotStreaming() + } + + wsService?.onRemoteTouch = { x, y, action -> + when (action) { + "tap" -> touchInjector.injectTap(rootView, x, y) + "down" -> touchInjector.injectDown(rootView, x, y) + "move" -> touchInjector.injectMove(rootView, x, y) + "up" -> touchInjector.injectUp(rootView, x, y) + } + } + + wsService?.onRemoteKey = { _ -> + // Key injection handled in WebSocketService directly + } + + wsService?.onCommand = { type, payload -> + Log.i("MainActivity", "Command received: $type") + when (type) { + "reboot", "shutdown", "power_menu" -> { + val svc = com.remotedisplay.player.service.PowerAccessibilityService.instance + if (svc != null) { + svc.showPowerDialog() + Log.i("MainActivity", "Power dialog shown via accessibility") + } else { + Log.w("MainActivity", "Accessibility service not enabled - trying fallback") + thread { + try { Runtime.getRuntime().exec(arrayOf("input", "keyevent", "--longpress", "26")).waitFor() } catch (_: Exception) {} + } + } + } + "screen_off" -> { + thread { + try { + Runtime.getRuntime().exec(arrayOf("input", "keyevent", "26")).waitFor() // POWER key + } catch (e: Exception) { + Log.e("MainActivity", "Screen off failed: ${e.message}") + } + } + } + "screen_on" -> { + thread { + try { + Runtime.getRuntime().exec(arrayOf("input", "keyevent", "224")).waitFor() // WAKEUP key + } catch (e: Exception) { + Log.e("MainActivity", "Screen on failed: ${e.message}") + } + } + } + "launch" -> { + val intent = android.content.Intent(this@MainActivity, MainActivity::class.java).apply { + addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK or android.content.Intent.FLAG_ACTIVITY_CLEAR_TOP) + } + startActivity(intent) + } + "update" -> { + Log.i("MainActivity", "Force update check triggered") + if (::updateChecker.isInitialized) updateChecker.checkForUpdate() + } + "refresh" -> { + wsService?.connect() + } + } + } + + wsService?.onRegistered = { _ -> + hideStatus() + } + + wsService?.onUnpaired = { + Log.w("MainActivity", "Device removed from server, going to provisioning") + handler.post { + startActivity(Intent(this, ProvisioningActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK) + }) + finish() + } + } + } + + private fun playItem(item: PlaylistItem) { + hideStatus() + + // YouTube content - play in WebView + if (item.mimeType == "video/youtube" && !item.remoteUrl.isNullOrEmpty()) { + Log.i("MainActivity", "Playing YouTube: ${item.remoteUrl}") + mediaPlayer.playYoutube(item.remoteUrl!!, item.durationSec) + wsService?.sendPlaybackState(item.contentId, 0f) + return + } + + // Remote URL content - stream directly, no download + if (item.isRemote) { + Log.i("MainActivity", "Playing remote content: ${item.remoteUrl}") + if (item.mimeType.startsWith("video/")) { + mediaPlayer.playVideoFromUrl(item.remoteUrl!!, item.muted) + } else if (item.mimeType.startsWith("image/")) { + mediaPlayer.showImageFromUrl(item.remoteUrl!!) + } + wsService?.sendPlaybackState(item.contentId, 0f) + return + } + + // Local content - download if not cached + val file = contentCache.getCachedFile(item.contentId) + if (file == null) { + Log.w("MainActivity", "Content not cached: ${item.contentId}, downloading...") + showStatus("Downloading ${item.filename}...") + thread { + val downloaded = contentCache.downloadContent(config.serverUrl, item.contentId, item.filename) + handler.post { + if (downloaded != null) { + playFile(item, downloaded) + } else { + showStatus("Download failed: ${item.filename}") + handler.postDelayed({ playlistController.next() }, 3000) + } + } + } + return + } + + playFile(item, file) + } + + private fun playFile(item: PlaylistItem, file: java.io.File) { + if (item.mimeType.startsWith("video/")) { + mediaPlayer.playVideo(file, item.muted) + } else if (item.mimeType.startsWith("image/")) { + mediaPlayer.showImage(file) + } + + // Report playback state + wsService?.sendPlaybackState(item.contentId, 0f) + } + + private fun showStatus(message: String) { + statusOverlay.visibility = View.VISIBLE + statusText.text = message + } + + private fun hideStatus() { + statusOverlay.visibility = View.GONE + } + + private fun captureAndSendScreenshot() { + Log.i("MainActivity", "Capturing screenshot") + val base64 = screenshotCapture.captureView(rootView, 40) + if (base64 != null) { + Log.i("MainActivity", "Screenshot captured, size=${base64.length} chars, sending...") + wsService?.sendScreenshot(base64) + } else { + Log.e("MainActivity", "Screenshot capture returned null!") + } + } + + private fun startScreenshotStreaming() { + stopScreenshotStreaming() + screenshotStreamRunnable = object : Runnable { + override fun run() { + if (remoteStreaming) { + captureAndSendScreenshot() + handler.postDelayed(this, 1000) // ~1 FPS + } + } + } + handler.post(screenshotStreamRunnable!!) + } + + private fun stopScreenshotStreaming() { + screenshotStreamRunnable?.let { handler.removeCallbacks(it) } + screenshotStreamRunnable = null + } + + private fun handleRemoteKey(keycode: String) { + // Use shell `input keyevent` for system keys (HOME, BACK, etc.) + // This works from the app process on most Android TV devices + thread { + try { + val code = when (keycode) { + "KEYCODE_HOME" -> "3" + "KEYCODE_BACK" -> "4" + "KEYCODE_MENU" -> "82" + "KEYCODE_VOLUME_UP" -> "24" + "KEYCODE_VOLUME_DOWN" -> "25" + "KEYCODE_DPAD_UP" -> "19" + "KEYCODE_DPAD_DOWN" -> "20" + "KEYCODE_DPAD_LEFT" -> "21" + "KEYCODE_DPAD_RIGHT" -> "22" + "KEYCODE_DPAD_CENTER" -> "23" + "KEYCODE_ENTER" -> "66" + "KEYCODE_POWER" -> "26" + else -> return@thread + } + Log.i("MainActivity", "Injecting key: $keycode ($code)") + val process = Runtime.getRuntime().exec(arrayOf("input", "keyevent", code)) + process.waitFor() + Log.i("MainActivity", "Key injection result: ${process.exitValue()}") + } catch (e: Exception) { + Log.e("MainActivity", "Key injection failed: ${e.message}") + } + } + } + + @Suppress("DEPRECATION") + override fun onBackPressed() { + // Don't exit the app on back press - this is a kiosk/signage app + Log.i("MainActivity", "Back press intercepted (kiosk mode)") + } + + private fun isAccessibilityEnabled(): Boolean { + val am = getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager + val myComponent = ComponentName(this, com.remotedisplay.player.service.PowerAccessibilityService::class.java) + return am.getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_ALL_MASK).any { + it.resolveInfo.serviceInfo.let { si -> ComponentName(si.packageName, si.name) == myComponent } + } + } + + override fun onNewIntent(intent: Intent?) { + super.onNewIntent(intent) + // Home press brings us back - just re-apply immersive mode + Log.i("MainActivity", "onNewIntent - returning to foreground") + @Suppress("DEPRECATION") + window.decorView.systemUiVisibility = ( + View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY or + View.SYSTEM_UI_FLAG_FULLSCREEN or + View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or + View.SYSTEM_UI_FLAG_LAYOUT_STABLE or + View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or + View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + ) + } + + override fun onDestroy() { + remoteStreaming = false + zoneManager?.cleanup() + if (::mediaPlayer.isInitialized) { + stopScreenshotStreaming() + mediaPlayer.release() + } + if (bound) { + try { unbindService(connection) } catch (_: Exception) {} + bound = false + } + super.onDestroy() + } + + override fun onWindowFocusChanged(hasFocus: Boolean) { + super.onWindowFocusChanged(hasFocus) + if (hasFocus) { + @Suppress("DEPRECATION") + window.decorView.systemUiVisibility = ( + View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY or + View.SYSTEM_UI_FLAG_FULLSCREEN or + View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or + View.SYSTEM_UI_FLAG_LAYOUT_STABLE or + View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or + View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + ) + } + } +} diff --git a/android/app/src/main/java/com/remotedisplay/player/ProvisioningActivity.kt b/android/app/src/main/java/com/remotedisplay/player/ProvisioningActivity.kt new file mode 100644 index 0000000..432c327 --- /dev/null +++ b/android/app/src/main/java/com/remotedisplay/player/ProvisioningActivity.kt @@ -0,0 +1,164 @@ +package com.remotedisplay.player + +import android.Manifest +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.content.pm.PackageManager +import android.os.Build +import android.os.Bundle +import android.os.IBinder +import android.util.Log +import android.view.View +import android.view.WindowManager +import android.widget.Button +import android.widget.EditText +import android.widget.ProgressBar +import android.widget.TextView +import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import com.remotedisplay.player.data.ServerConfig +import com.remotedisplay.player.service.WebSocketService + +class ProvisioningActivity : AppCompatActivity() { + + private lateinit var config: ServerConfig + private var wsService: WebSocketService? = null + private var bound = false + + private lateinit var serverUrlInput: EditText + private lateinit var connectBtn: Button + private lateinit var pairingCodeText: TextView + private lateinit var statusText: TextView + private lateinit var progressBar: ProgressBar + private lateinit var pairingSection: View + + private val connection = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName?, service: IBinder?) { + val binder = service as WebSocketService.LocalBinder + wsService = binder.getService() + bound = true + setupServiceCallbacks() + } + + override fun onServiceDisconnected(name: ComponentName?) { + wsService = null + bound = false + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_provisioning) + + // Fullscreen immersive + @Suppress("DEPRECATION") + window.decorView.systemUiVisibility = ( + View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY or + View.SYSTEM_UI_FLAG_FULLSCREEN or + View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or + View.SYSTEM_UI_FLAG_LAYOUT_STABLE or + View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or + View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + ) + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + + config = ServerConfig(this) + + serverUrlInput = findViewById(R.id.serverUrlInput) + connectBtn = findViewById(R.id.connectBtn) + pairingCodeText = findViewById(R.id.pairingCodeText) + statusText = findViewById(R.id.statusText) + progressBar = findViewById(R.id.progressBar) + pairingSection = findViewById(R.id.pairingSection) + + // Pre-fill if previously entered + if (config.serverUrl.isNotEmpty()) { + serverUrlInput.setText(config.serverUrl) + } + + connectBtn.setOnClickListener { + val url = serverUrlInput.text.toString().trim().trimEnd('/') + if (url.isEmpty()) { + statusText.text = "Please enter the server URL" + return@setOnClickListener + } + config.serverUrl = url + connectToServer(url) + } + + // Request notification permission on Android 13+ + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) + != PackageManager.PERMISSION_GRANTED) { + ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.POST_NOTIFICATIONS), 100) + } else { + startWebSocketService() + } + } else { + startWebSocketService() + } + } + + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + // Start service regardless of permission result - it just won't show notification on 13+ + startWebSocketService() + } + + private fun startWebSocketService() { + try { + val serviceIntent = Intent(this, WebSocketService::class.java) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + startForegroundService(serviceIntent) + } else { + startService(serviceIntent) + } + bindService(serviceIntent, connection, Context.BIND_AUTO_CREATE) + } catch (e: Exception) { + Log.e("ProvisioningActivity", "Failed to start service: ${e.message}") + statusText.text = "Service error: ${e.message}" + } + } + + private fun connectToServer(url: String) { + connectBtn.isEnabled = false + progressBar.visibility = View.VISIBLE + statusText.text = "Connecting to server..." + + wsService?.connect(url) + } + + private fun setupServiceCallbacks() { + wsService?.onRegistered = { deviceId -> + runOnUiThread { + progressBar.visibility = View.GONE + pairingSection.visibility = View.VISIBLE + pairingCodeText.text = wsService?.getPairingCode() ?: "------" + statusText.text = "Enter this code in the dashboard to pair this display" + connectBtn.isEnabled = false + } + } + + wsService?.onPaired = { deviceId, name -> + runOnUiThread { + statusText.text = "Paired as: $name" + // Transition to main activity + val intent = Intent(this, MainActivity::class.java) + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK) + startActivity(intent) + finish() + } + } + } + + override fun onDestroy() { + if (bound) { + unbindService(connection) + bound = false + } + super.onDestroy() + } +} diff --git a/android/app/src/main/java/com/remotedisplay/player/RemoteDisplayApp.kt b/android/app/src/main/java/com/remotedisplay/player/RemoteDisplayApp.kt new file mode 100644 index 0000000..3d23b14 --- /dev/null +++ b/android/app/src/main/java/com/remotedisplay/player/RemoteDisplayApp.kt @@ -0,0 +1,34 @@ +package com.remotedisplay.player + +import android.app.Application +import android.app.NotificationChannel +import android.app.NotificationManager +import android.os.Build + +class RemoteDisplayApp : Application() { + + companion object { + const val CHANNEL_ID = "remote_display_service" + const val CHANNEL_NAME = "ScreenTinker Service" + } + + override fun onCreate() { + super.onCreate() + createNotificationChannel() + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, + CHANNEL_NAME, + NotificationManager.IMPORTANCE_LOW + ).apply { + description = "ScreenTinker background service" + setShowBadge(false) + } + val manager = getSystemService(NotificationManager::class.java) + manager.createNotificationChannel(channel) + } + } +} diff --git a/android/app/src/main/java/com/remotedisplay/player/ScreenCapturePermissionActivity.kt b/android/app/src/main/java/com/remotedisplay/player/ScreenCapturePermissionActivity.kt new file mode 100644 index 0000000..2535242 --- /dev/null +++ b/android/app/src/main/java/com/remotedisplay/player/ScreenCapturePermissionActivity.kt @@ -0,0 +1,64 @@ +package com.remotedisplay.player + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.media.projection.MediaProjectionManager +import android.os.Bundle +import android.util.Log +import com.remotedisplay.player.service.ScreenCaptureService + +/** + * Transparent activity that requests MediaProjection permission. + * Shows a system dialog asking "Start recording?" - user taps "Start now" once. + */ +class ScreenCapturePermissionActivity : Activity() { + + companion object { + private const val REQUEST_CODE = 1001 + private const val TAG = "ScreenCapturePermission" + + // Store the result intent so the service can use it + var resultCode: Int = RESULT_CANCELED + private set + var resultData: Intent? = null + private set + var hasPermission = false + private set + + fun requestPermission(context: Context) { + val intent = Intent(context, ScreenCapturePermissionActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val mediaProjectionManager = getSystemService(MEDIA_PROJECTION_SERVICE) as MediaProjectionManager + startActivityForResult(mediaProjectionManager.createScreenCaptureIntent(), REQUEST_CODE) + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + if (requestCode == REQUEST_CODE) { + if (resultCode == RESULT_OK && data != null) { + Log.i(TAG, "MediaProjection permission granted, starting via service") + + // Store the result so the service can create the projection + Companion.resultCode = resultCode + Companion.resultData = data?.clone() as? Intent + Companion.hasPermission = true + + // Tell the service to start the projection + ScreenCaptureService.startProjection(this, resultCode, data) + + getSharedPreferences("remote_display", MODE_PRIVATE) + .edit().putBoolean("screen_capture_granted", true).apply() + } else { + Log.w(TAG, "MediaProjection permission denied") + } + } + finish() + } +} diff --git a/android/app/src/main/java/com/remotedisplay/player/SetupActivity.kt b/android/app/src/main/java/com/remotedisplay/player/SetupActivity.kt new file mode 100644 index 0000000..fde4451 --- /dev/null +++ b/android/app/src/main/java/com/remotedisplay/player/SetupActivity.kt @@ -0,0 +1,158 @@ +package com.remotedisplay.player + +import android.Manifest +import android.accessibilityservice.AccessibilityServiceInfo +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import android.os.Bundle +import android.provider.Settings +import android.view.View +import android.view.WindowManager +import android.view.accessibility.AccessibilityManager +import android.widget.Button +import android.widget.TextView +import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import com.remotedisplay.player.service.PowerAccessibilityService + +class SetupActivity : AppCompatActivity() { + + private lateinit var accessibilityStatus: TextView + private lateinit var installStatus: TextView + private lateinit var notificationStatus: TextView + private lateinit var enableAccessibilityBtn: Button + private lateinit var enableInstallBtn: Button + private lateinit var continueBtn: Button + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // Skip setup if already completed + val prefs = getSharedPreferences("remote_display", MODE_PRIVATE) + if (prefs.getBoolean("setup_complete", false)) { + proceedToNext() + return + } + + setContentView(R.layout.activity_setup) + + @Suppress("DEPRECATION") + window.decorView.systemUiVisibility = ( + View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY or + View.SYSTEM_UI_FLAG_FULLSCREEN or + View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or + View.SYSTEM_UI_FLAG_LAYOUT_STABLE or + View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or + View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + ) + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + + accessibilityStatus = findViewById(R.id.accessibilityStatus) + installStatus = findViewById(R.id.installStatus) + notificationStatus = findViewById(R.id.notificationStatus) + enableAccessibilityBtn = findViewById(R.id.enableAccessibilityBtn) + enableInstallBtn = findViewById(R.id.enableInstallBtn) + continueBtn = findViewById(R.id.continueBtn) + + // Show notification row on Android 13+ + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + findViewById(R.id.notificationRow).visibility = View.VISIBLE + findViewById + + + +
+ +
+ + + + + +
+ + + + diff --git a/frontend/js/api.js b/frontend/js/api.js new file mode 100644 index 0000000..8848ca0 --- /dev/null +++ b/frontend/js/api.js @@ -0,0 +1,101 @@ +const API_BASE = '/api'; + +function getAuthHeaders() { + const token = localStorage.getItem('token'); + return token ? { Authorization: `Bearer ${token}` } : {}; +} + +async function request(url, options = {}) { + const res = await fetch(API_BASE + url, { + headers: { 'Content-Type': 'application/json', ...getAuthHeaders(), ...options.headers }, + ...options, + }); + if (res.status === 401) { + // Token expired or invalid - redirect to login + localStorage.removeItem('token'); + localStorage.removeItem('user'); + window.location.hash = '#/login'; + window.location.reload(); + throw new Error('Session expired'); + } + if (!res.ok) { + const err = await res.json().catch(() => ({ error: res.statusText })); + throw new Error(err.error || 'Request failed'); + } + return res.json(); +} + +export const api = { + // Devices + getDevices: () => request('/devices'), + getDevice: (id) => request(`/devices/${id}`), + updateDevice: (id, data) => request(`/devices/${id}`, { method: 'PUT', body: JSON.stringify(data) }), + deleteDevice: (id) => request(`/devices/${id}`, { method: 'DELETE' }), + + // Provisioning + pairDevice: (pairing_code, name) => request('/provision/pair', { + method: 'POST', + body: JSON.stringify({ pairing_code, name }) + }), + + // Content + getContent: () => request('/content'), + getContentItem: (id) => request(`/content/${id}`), + deleteContent: (id) => request(`/content/${id}`, { method: 'DELETE' }), + uploadContent: async (file, onProgress) => { + const formData = new FormData(); + formData.append('file', file); + + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open('POST', `${API_BASE}/content`); + const token = localStorage.getItem('token'); + if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`); + if (onProgress) { + xhr.upload.onprogress = (e) => { + if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100)); + }; + } + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + resolve(JSON.parse(xhr.responseText)); + } else { + reject(new Error('Upload failed')); + } + }; + xhr.onerror = () => reject(new Error('Upload failed')); + xhr.send(formData); + }); + }, + + addRemoteContent: (url, name, mime_type) => request('/content/remote', { + method: 'POST', + body: JSON.stringify({ url, name, mime_type }) + }), + + addYoutubeContent: (url, name) => request('/content/youtube', { + method: 'POST', + body: JSON.stringify({ url, name }) + }), + + // Assignments + getAssignments: (deviceId) => request(`/assignments/device/${deviceId}`), + addAssignment: (deviceId, data) => request(`/assignments/device/${deviceId}`, { + method: 'POST', + body: JSON.stringify(data) + }), + updateAssignment: (id, data) => request(`/assignments/${id}`, { method: 'PUT', body: JSON.stringify(data) }), + deleteAssignment: (id) => request(`/assignments/${id}`, { method: 'DELETE' }), + reorderAssignments: (deviceId, order) => request(`/assignments/device/${deviceId}/reorder`, { + method: 'POST', + body: JSON.stringify({ order }) + }), + + // Admin - Users + getUsers: () => request('/auth/users'), + deleteUser: (id) => request(`/auth/users/${id}`, { method: 'DELETE' }), + assignPlan: (user_id, plan_id) => request('/subscription/assign', { + method: 'POST', + body: JSON.stringify({ user_id, plan_id }) + }), +}; diff --git a/frontend/js/app.js b/frontend/js/app.js new file mode 100644 index 0000000..e4a4080 --- /dev/null +++ b/frontend/js/app.js @@ -0,0 +1,259 @@ +import { connectSocket } from './socket.js'; +import * as dashboard from './views/dashboard.js'; +import * as deviceDetail from './views/device-detail.js'; +import * as contentLibrary from './views/content-library.js'; +import * as settings from './views/settings.js'; +import * as login from './views/login.js'; +import * as billing from './views/billing.js'; +import * as layoutEditor from './views/layout-editor.js'; +import * as schedule from './views/schedule.js'; +import * as widgets from './views/widgets.js'; +import * as videoWall from './views/video-wall.js'; +import * as reports from './views/reports.js'; +import * as activity from './views/activity.js'; +import * as kiosk from './views/kiosk.js'; +import * as onboarding from './views/onboarding.js'; +import * as help from './views/help.js'; +import * as teams from './views/teams.js'; +import * as admin from './views/admin.js'; +import * as designer from './views/designer.js'; + +const app = document.getElementById('app'); +const sidebar = document.querySelector('.sidebar'); +let currentView = null; + +function isAuthenticated() { + return !!localStorage.getItem('token'); +} + +function getCurrentUser() { + try { + return JSON.parse(localStorage.getItem('user')); + } catch { return null; } +} + +function route() { + // Cleanup previous view + if (currentView && currentView.cleanup) currentView.cleanup(); + + const hash = window.location.hash || '#/'; + + // Auth check - redirect to login if not authenticated + if (!isAuthenticated() && hash !== '#/login') { + window.location.hash = '#/login'; + return; + } + + // If authenticated and on login page, redirect to dashboard or onboarding + if (isAuthenticated() && hash === '#/login') { + window.location.hash = localStorage.getItem('rd_onboarded') ? '#/' : '#/onboarding'; + return; + } + + // Onboarding for new users + if (hash === '#/onboarding' && isAuthenticated()) { + sidebar.style.display = 'none'; + app.style.marginLeft = '0'; + currentView = onboarding; + onboarding.render(app); + return; + } + + // Login page - hide sidebar + if (hash === '#/login') { + sidebar.style.display = 'none'; + app.style.marginLeft = '0'; + currentView = login; + login.render(app); + return; + } + + // Show sidebar for authenticated views + sidebar.style.display = ''; + app.style.marginLeft = ''; + + // Update user info in sidebar + updateSidebarUser(); + + const navLinks = document.querySelectorAll('.nav-link'); + navLinks.forEach(link => { + link.classList.remove('active'); + if (hash === '#/' && link.dataset.view === 'dashboard') link.classList.add('active'); + else if (hash.startsWith('#/content') && link.dataset.view === 'content') link.classList.add('active'); + else if (hash.startsWith('#/settings') && link.dataset.view === 'settings') link.classList.add('active'); + else if (hash.startsWith('#/billing') && link.dataset.view === 'billing') link.classList.add('active'); + else if ((hash.startsWith('#/layout') || hash === '#/layouts') && link.dataset.view === 'layouts') link.classList.add('active'); + else if (hash === '#/schedule' && link.dataset.view === 'schedule') link.classList.add('active'); + else if (hash === '#/widgets' && link.dataset.view === 'widgets') link.classList.add('active'); + else if ((hash.startsWith('#/wall') || hash === '#/walls') && link.dataset.view === 'walls') link.classList.add('active'); + else if (hash === '#/reports' && link.dataset.view === 'reports') link.classList.add('active'); + else if (hash === '#/activity' && link.dataset.view === 'activity') link.classList.add('active'); + else if (hash === '#/designer' && link.dataset.view === 'designer') link.classList.add('active'); + else if ((hash === '#/kiosk' || hash.startsWith('#/kiosk/')) && link.dataset.view === 'kiosk') link.classList.add('active'); + else if (hash === '#/help' && link.dataset.view === 'help') link.classList.add('active'); + else if (hash.startsWith('#/device/') && link.dataset.view === 'dashboard') link.classList.add('active'); + }); + + // Route to view + if (hash === '#/' || hash === '#' || hash === '') { + currentView = dashboard; + dashboard.render(app); + } else if (hash.startsWith('#/device/')) { + const deviceId = hash.split('#/device/')[1].split('/')[0]; + currentView = deviceDetail; + deviceDetail.render(app, deviceId); + } else if (hash === '#/content') { + currentView = contentLibrary; + contentLibrary.render(app); + } else if (hash === '#/layouts' || hash.startsWith('#/layout/')) { + currentView = layoutEditor; + layoutEditor.render(app); + } else if (hash === '#/schedule') { + currentView = schedule; + schedule.render(app); + } else if (hash === '#/widgets') { + currentView = widgets; + widgets.render(app); + } else if (hash === '#/walls' || hash.startsWith('#/wall/')) { + currentView = videoWall; + videoWall.render(app); + } else if (hash === '#/reports') { + currentView = reports; + reports.render(app); + } else if (hash === '#/kiosk' || hash.startsWith('#/kiosk/')) { + currentView = kiosk; + kiosk.render(app); + } else if (hash === '#/designer') { + currentView = designer; + designer.render(app); + } else if (hash === '#/activity') { + currentView = activity; + activity.render(app); + } else if (hash === '#/teams' || hash.startsWith('#/team/')) { + currentView = teams; + teams.render(app); + } else if (hash === '#/help' || hash.startsWith('#/help')) { + currentView = help; + help.render(app); + } else if (hash === '#/admin') { + currentView = admin; + admin.render(app); + } else if (hash === '#/settings') { + currentView = settings; + settings.render(app); + } else if (hash === '#/billing') { + currentView = billing; + billing.render(app); + } else { + currentView = dashboard; + dashboard.render(app); + } +} + +function updateSidebarUser() { + const user = getCurrentUser(); + if (!user) return; + + // Show admin nav only for superadmins + const adminNav = document.getElementById('adminNavItem'); + if (adminNav) adminNav.style.display = user.role === 'superadmin' ? '' : 'none'; + + let userEl = document.getElementById('sidebarUser'); + if (!userEl) { + const footer = document.querySelector('.sidebar-footer'); + userEl = document.createElement('div'); + userEl.id = 'sidebarUser'; + userEl.style.cssText = 'display:flex;align-items:center;gap:8px;margin-bottom:12px;padding-bottom:12px;border-bottom:1px solid var(--border)'; + footer.insertBefore(userEl, footer.firstChild); + } + + userEl.innerHTML = ` + ${user.avatar_url ? `` : + `
${(user.name || user.email)[0].toUpperCase()}
`} +
+
${user.name || user.email}
+
${user.role}
+
+ + `; + + document.getElementById('logoutBtn')?.addEventListener('click', () => { + localStorage.removeItem('token'); + localStorage.removeItem('user'); + window.location.hash = '#/login'; + window.location.reload(); + }); +} + +// Initialize +if (isAuthenticated()) { + connectSocket(); +} + +// Register PWA service worker +if ('serviceWorker' in navigator) { + navigator.serviceWorker.register('/sw-admin.js').catch(() => {}); +} + +// Close mobile menu on navigation +window.addEventListener('hashchange', () => { + document.querySelector('.sidebar')?.classList.remove('open'); + document.getElementById('sidebarBackdrop')?.classList.remove('open'); +}); + +// Auto-reload on frontend update (no more hard refresh needed) +let knownHash = null; +setInterval(async () => { + try { + const res = await fetch('/api/version'); + const { hash } = await res.json(); + if (knownHash === null) { knownHash = hash; return; } + if (hash !== knownHash) { + knownHash = hash; + const toast = document.getElementById('toastContainer'); + if (toast) { + const notice = document.createElement('div'); + notice.className = 'toast info'; + notice.innerHTML = 'Dashboard updated. Reload now'; + toast.appendChild(notice); + } + } + } catch {} +}, 15000); + +// Session timeout warning - check JWT expiry every minute +if (isAuthenticated()) { + setInterval(() => { + const token = localStorage.getItem('token'); + if (!token) return; + try { + const payload = JSON.parse(atob(token.split('.')[1])); + const expiresIn = (payload.exp * 1000) - Date.now(); + const minutesLeft = Math.floor(expiresIn / 60000); + if (minutesLeft <= 0) { + localStorage.removeItem('token'); + localStorage.removeItem('user'); + window.location.hash = '#/login'; + window.location.reload(); + } else if (minutesLeft <= 30 && minutesLeft % 10 === 0) { + // Warn at 30, 20, 10 minutes + const toast = document.getElementById('toastContainer'); + if (toast && !toast.querySelector('.session-warn')) { + const warn = document.createElement('div'); + warn.className = 'toast info session-warn'; + warn.innerHTML = `Session expires in ${minutesLeft} minutes. Re-login`; + toast.appendChild(warn); + setTimeout(() => warn.remove(), 10000); + } + } + } catch {} + }, 60000); +} +window.addEventListener('hashchange', route); +route(); diff --git a/frontend/js/components/toast.js b/frontend/js/components/toast.js new file mode 100644 index 0000000..7dafdc9 --- /dev/null +++ b/frontend/js/components/toast.js @@ -0,0 +1,20 @@ +export function showToast(message, type = 'info', duration = 4000) { + const container = document.getElementById('toastContainer'); + const toast = document.createElement('div'); + toast.className = `toast ${type}`; + toast.innerHTML = ` + + ${type === 'success' ? '' : + type === 'error' ? '' : + ''} + + ${message} + `; + container.appendChild(toast); + setTimeout(() => { + toast.style.opacity = '0'; + toast.style.transform = 'translateX(100%)'; + toast.style.transition = 'all 0.3s ease'; + setTimeout(() => toast.remove(), 300); + }, duration); +} diff --git a/frontend/js/i18n.js b/frontend/js/i18n.js new file mode 100644 index 0000000..17131ff --- /dev/null +++ b/frontend/js/i18n.js @@ -0,0 +1,158 @@ +const translations = { + en: { + // Nav + 'nav.displays': 'Displays', + 'nav.content': 'Content', + 'nav.layouts': 'Layouts', + 'nav.widgets': 'Widgets', + 'nav.schedule': 'Schedule', + 'nav.walls': 'Video Walls', + 'nav.reports': 'Reports', + 'nav.designer': 'Designer', + 'nav.activity': 'Activity', + 'nav.settings': 'Settings', + 'nav.subscription': 'Subscription', + // Dashboard + 'dashboard.title': 'Displays', + 'dashboard.subtitle': 'Manage your remote displays', + 'dashboard.add': 'Add Display', + 'dashboard.search': 'Search displays...', + 'dashboard.all_status': 'All Status', + 'dashboard.online': 'Online', + 'dashboard.offline': 'Offline', + 'dashboard.no_displays': 'No displays yet', + 'dashboard.no_displays_desc': 'Install the ScreenTinker app on your TV and pair it using the button above.', + // Content + 'content.title': 'Content Library', + 'content.subtitle': 'Upload and manage your media files', + 'content.drop': 'Drop files here or click to upload', + 'content.remote_url': 'Remote URL', + 'content.no_content': 'No content yet', + // Common + 'common.save': 'Save', + 'common.cancel': 'Cancel', + 'common.delete': 'Delete', + 'common.edit': 'Edit', + 'common.loading': 'Loading...', + 'common.connected': 'Connected', + 'common.disconnected': 'Disconnected', + // Auth + 'auth.sign_in': 'Sign In', + 'auth.create_account': 'Create Account', + 'auth.email': 'Email', + 'auth.password': 'Password', + 'auth.name': 'Name', + 'auth.sign_out': 'Sign out', + }, + es: { + 'nav.displays': 'Pantallas', + 'nav.content': 'Contenido', + 'nav.layouts': 'Diseños', + 'nav.widgets': 'Widgets', + 'nav.schedule': 'Horario', + 'nav.walls': 'Video Walls', + 'nav.reports': 'Informes', + 'nav.designer': 'Diseñador', + 'nav.activity': 'Actividad', + 'nav.settings': 'Configuración', + 'nav.subscription': 'Suscripción', + 'dashboard.title': 'Pantallas', + 'dashboard.subtitle': 'Administra tus pantallas remotas', + 'dashboard.add': 'Agregar Pantalla', + 'dashboard.search': 'Buscar pantallas...', + 'dashboard.all_status': 'Todos los estados', + 'dashboard.online': 'En línea', + 'dashboard.offline': 'Desconectado', + 'dashboard.no_displays': 'Aún no hay pantallas', + 'content.title': 'Biblioteca de Contenido', + 'content.subtitle': 'Sube y administra tus archivos multimedia', + 'content.drop': 'Arrastra archivos aquí o haz clic para subir', + 'content.remote_url': 'URL Remota', + 'common.save': 'Guardar', + 'common.cancel': 'Cancelar', + 'common.delete': 'Eliminar', + 'common.edit': 'Editar', + 'common.loading': 'Cargando...', + 'common.connected': 'Conectado', + 'common.disconnected': 'Desconectado', + 'auth.sign_in': 'Iniciar Sesión', + 'auth.create_account': 'Crear Cuenta', + 'auth.email': 'Correo electrónico', + 'auth.password': 'Contraseña', + 'auth.name': 'Nombre', + 'auth.sign_out': 'Cerrar sesión', + }, + fr: { + 'nav.displays': 'Écrans', + 'nav.content': 'Contenu', + 'nav.layouts': 'Mises en page', + 'nav.widgets': 'Widgets', + 'nav.schedule': 'Calendrier', + 'nav.walls': 'Murs vidéo', + 'nav.reports': 'Rapports', + 'nav.designer': 'Concepteur', + 'nav.activity': 'Activité', + 'nav.settings': 'Paramètres', + 'nav.subscription': 'Abonnement', + 'dashboard.title': 'Écrans', + 'dashboard.subtitle': 'Gérez vos écrans distants', + 'dashboard.add': 'Ajouter un écran', + 'dashboard.search': 'Rechercher des écrans...', + 'common.save': 'Enregistrer', + 'common.cancel': 'Annuler', + 'common.delete': 'Supprimer', + 'common.loading': 'Chargement...', + 'auth.sign_in': 'Se connecter', + 'auth.create_account': 'Créer un compte', + 'auth.sign_out': 'Se déconnecter', + }, + de: { + 'nav.displays': 'Bildschirme', + 'nav.content': 'Inhalt', + 'nav.layouts': 'Layouts', + 'nav.widgets': 'Widgets', + 'nav.schedule': 'Zeitplan', + 'nav.walls': 'Videowände', + 'nav.reports': 'Berichte', + 'nav.designer': 'Designer', + 'nav.activity': 'Aktivität', + 'nav.settings': 'Einstellungen', + 'nav.subscription': 'Abonnement', + 'dashboard.title': 'Bildschirme', + 'dashboard.subtitle': 'Verwalten Sie Ihre Remote-Displays', + 'dashboard.add': 'Bildschirm hinzufügen', + 'dashboard.search': 'Bildschirme suchen...', + 'common.save': 'Speichern', + 'common.cancel': 'Abbrechen', + 'common.delete': 'Löschen', + 'common.loading': 'Laden...', + 'auth.sign_in': 'Anmelden', + 'auth.create_account': 'Konto erstellen', + 'auth.sign_out': 'Abmelden', + }, +}; + +let currentLang = localStorage.getItem('rd_lang') || navigator.language?.split('-')[0] || 'en'; +if (!translations[currentLang]) currentLang = 'en'; + +export function t(key) { + return translations[currentLang]?.[key] || translations.en[key] || key; +} + +export function setLanguage(lang) { + currentLang = lang; + localStorage.setItem('rd_lang', lang); +} + +export function getLanguage() { + return currentLang; +} + +export function getAvailableLanguages() { + return [ + { code: 'en', name: 'English' }, + { code: 'es', name: 'Español' }, + { code: 'fr', name: 'Français' }, + { code: 'de', name: 'Deutsch' }, + ]; +} diff --git a/frontend/js/socket.js b/frontend/js/socket.js new file mode 100644 index 0000000..ff85efd --- /dev/null +++ b/frontend/js/socket.js @@ -0,0 +1,116 @@ +let dashboardSocket = null; +const listeners = new Map(); + +export function connectSocket() { + const token = localStorage.getItem('token'); + dashboardSocket = io('/dashboard', { + auth: { token } + }); + + dashboardSocket.on('connect', () => { + console.log('Dashboard connected, socket id:', dashboardSocket.id); + updateConnectionStatus(true); + emit('connected'); + }); + + dashboardSocket.on('connect_error', (err) => { + console.error('Dashboard socket connect error:', err.message); + }); + + dashboardSocket.on('disconnect', (reason) => { + console.log('Dashboard disconnected:', reason); + updateConnectionStatus(false); + emit('disconnected'); + }); + + // Device status updates + dashboardSocket.on('dashboard:device-status', (data) => { + emit('device-status', data); + }); + + // Screenshot ready + dashboardSocket.on('dashboard:screenshot-ready', (data) => { + emit('screenshot-ready', data); + }); + + // Device added + dashboardSocket.on('dashboard:device-added', (data) => { + emit('device-added', data); + }); + + // Device removed + dashboardSocket.on('dashboard:device-removed', (data) => { + emit('device-removed', data); + }); + + // Playback state + dashboardSocket.on('dashboard:playback-state', (data) => { + emit('playback-state', data); + }); + + // Content ack + dashboardSocket.on('dashboard:content-ack', (data) => { + emit('content-ack', data); + }); + + return dashboardSocket; +} + +function updateConnectionStatus(connected) { + const el = document.getElementById('connectionStatus'); + if (!el) return; + const dot = el.querySelector('.status-dot'); + const text = el.querySelector('span:last-child'); + if (connected) { + dot.className = 'status-dot online'; + text.textContent = 'Connected'; + } else { + dot.className = 'status-dot offline'; + text.textContent = 'Disconnected'; + } +} + +export function on(event, callback) { + if (!listeners.has(event)) listeners.set(event, []); + listeners.get(event).push(callback); +} + +export function off(event, callback) { + if (!listeners.has(event)) return; + const cbs = listeners.get(event); + const idx = cbs.indexOf(callback); + if (idx > -1) cbs.splice(idx, 1); +} + +function emit(event, data) { + const cbs = listeners.get(event); + if (cbs) cbs.forEach(cb => cb(data)); +} + +export function requestScreenshot(deviceId) { + console.log('requestScreenshot:', deviceId, 'socket connected:', dashboardSocket?.connected); + if (dashboardSocket) dashboardSocket.emit('dashboard:request-screenshot', { device_id: deviceId }); +} + +export function startRemote(deviceId) { + console.log('startRemote:', deviceId, 'socket connected:', dashboardSocket?.connected); + if (dashboardSocket) dashboardSocket.emit('dashboard:remote-start', { device_id: deviceId }); +} + +export function stopRemote(deviceId) { + if (dashboardSocket) dashboardSocket.emit('dashboard:remote-stop', { device_id: deviceId }); +} + +export function sendTouch(deviceId, x, y, action) { + if (dashboardSocket) dashboardSocket.emit('dashboard:remote-touch', { device_id: deviceId, x, y, action }); +} + +export function sendKey(deviceId, keycode) { + if (dashboardSocket) dashboardSocket.emit('dashboard:remote-key', { device_id: deviceId, keycode }); +} + +export function sendCommand(deviceId, type, payload) { + if (dashboardSocket) dashboardSocket.emit('dashboard:device-command', { device_id: deviceId, type, payload }); +} + +export function getSocket() { return dashboardSocket; } diff --git a/frontend/js/views/activity.js b/frontend/js/views/activity.js new file mode 100644 index 0000000..e838e02 --- /dev/null +++ b/frontend/js/views/activity.js @@ -0,0 +1,101 @@ +import { showToast } from '../components/toast.js'; + +const API = (url) => fetch('/api' + url, { headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }}).then(r => r.json()); + +export async function render(container) { + container.innerHTML = ` + +

Loading...

+
+ +
+ `; + + let offset = 0; + const limit = 50; + + async function loadActivity(append = false) { + try { + const items = await API(`/activity?limit=${limit}&offset=${offset}`); + const list = document.getElementById('activityList'); + + if (!append) list.innerHTML = ''; + + if (items.length === 0 && offset === 0) { + list.innerHTML = '

No activity yet

Actions will appear here as you use the system.

'; + return; + } + + const html = items.map(item => { + const time = new Date(item.created_at * 1000); + const timeStr = time.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + ' ' + + time.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }); + const icon = getActionIcon(item.action); + + return ` +
+
${icon}
+
+
+ ${item.user_name || item.user_email || 'System'} + ${formatAction(item.action)} +
+ ${item.details ? `
${item.details}
` : ''} +
+
${timeStr}
+
+ `; + }).join(''); + + if (append) { + list.insertAdjacentHTML('beforeend', html); + } else { + list.innerHTML = html; + } + + document.getElementById('loadMoreBtn').style.display = items.length >= limit ? '' : 'none'; + } catch (err) { + showToast(err.message, 'error'); + } + } + + document.getElementById('loadMoreBtn').onclick = () => { + offset += limit; + loadActivity(true); + }; + + loadActivity(); +} + +function getActionIcon(action) { + if (action.includes('DELETE')) return '🗑'; + if (action.includes('POST') && action.includes('content')) return '📤'; + if (action.includes('POST') && action.includes('provision')) return '🔗'; + if (action.includes('POST') && action.includes('assignment')) return '📋'; + if (action.includes('alert')) return '🔔'; + if (action.includes('PUT')) return '✎'; + if (action.includes('POST')) return '➕'; + return '📄'; +} + +function formatAction(action) { + return action + .replace('POST /api/', 'created ') + .replace('PUT /api/', 'updated ') + .replace('DELETE /api/', 'deleted ') + .replace('/provision/pair', 'paired a device') + .replace('/content/remote', 'added remote content') + .replace('/content', 'content') + .replace('/devices/:id', 'device') + .replace('/assignments/device/:deviceId', 'playlist assignment') + .replace('/assignments/:id', 'assignment') + .replace('/layouts', 'layout') + .replace('/widgets', 'widget') + .replace('/schedules', 'schedule') + .replace('/walls', 'video wall') + .replace('alert:device_offline', 'alert: device went offline'); +} + +export function cleanup() {} diff --git a/frontend/js/views/admin.js b/frontend/js/views/admin.js new file mode 100644 index 0000000..fee7b6b --- /dev/null +++ b/frontend/js/views/admin.js @@ -0,0 +1,170 @@ +import { api } from '../api.js'; +import { showToast } from '../components/toast.js'; + +const headers = () => ({ Authorization: `Bearer ${localStorage.getItem('token')}`, 'Content-Type': 'application/json' }); +const API = (url, opts = {}) => fetch('/api' + url, { headers: headers(), ...opts }).then(r => r.json()); + +export async function render(container) { + const user = JSON.parse(localStorage.getItem('user') || '{}'); + if (user.role !== 'superadmin') { + container.innerHTML = '

Access Denied

Platform admin access required.

'; + return; + } + + container.innerHTML = ` + + + +
+

All Users

+

Loading...

+
+ + +
+

Subscription Plans

+

Loading...

+
+ + +
+

System

+

Loading...

+
+ `; + + loadUsers(); + loadPlans(); + loadSystem(); + +} + +async function loadUsers() { + const el = document.getElementById('allUsersTable'); + try { + const [users, plans] = await Promise.all([API('/auth/users'), fetch('/api/subscription/plans').then(r => r.json())]); + + el.innerHTML = ` + + + + + + + + + + + ${users.map(u => ` + + + + + + + + + `).join('')} + +
UserAuthLast LoginRolePlanActions
${u.name || u.email}
${u.email}
${u.auth_provider}${u.last_login ? new Date(u.last_login * 1000).toLocaleString() : 'Never'} + + + + + ${u.role !== 'superadmin' ? `` : 'Owner'} +
+

${users.length} total users

+ `; + + // Role change + el.querySelectorAll('[data-role-user]').forEach(select => { + select.onchange = async () => { + try { + await API(`/auth/users/${select.dataset.roleUser}/role`, { method: 'PUT', body: JSON.stringify({ role: select.value }) }); + showToast('Role updated', 'success'); + } catch (err) { showToast(err.message, 'error'); loadUsers(); } + }; + }); + + // Plan change + el.querySelectorAll('[data-plan-user]').forEach(select => { + select.onchange = async () => { + try { + await API('/subscription/assign', { method: 'POST', body: JSON.stringify({ user_id: select.dataset.planUser, plan_id: select.value }) }); + showToast('Plan updated', 'success'); + } catch (err) { showToast(err.message, 'error'); loadUsers(); } + }; + }); + + // Delete user + el.querySelectorAll('[data-delete-user]').forEach(btn => { + let confirming = false; + btn.onclick = async () => { + if (confirming) { + try { await api.deleteUser(btn.dataset.deleteUser); showToast('User removed', 'success'); loadUsers(); } + catch (err) { showToast(err.message, 'error'); } + return; + } + confirming = true; btn.textContent = 'Confirm?'; btn.style.background = 'var(--danger)'; btn.style.color = 'white'; + setTimeout(() => { confirming = false; btn.textContent = 'Remove'; btn.style.background = ''; btn.style.color = ''; }, 3000); + }; + }); + } catch (err) { el.innerHTML = `

${err.message}

`; } +} + +async function loadPlans() { + const el = document.getElementById('plansTable'); + try { + const plans = await fetch('/api/subscription/plans').then(r => r.json()); + el.innerHTML = ` + + + + + + + + + + ${plans.map(p => ` + + + + + + + + `).join('')} + +
PlanDevicesStorageMonthlyYearly
${p.display_name}${p.max_devices === -1 ? 'Unlimited' : p.max_devices}${p.max_storage_mb === -1 ? 'Unlimited' : p.max_storage_mb >= 1024 ? (p.max_storage_mb/1024)+'GB' : p.max_storage_mb+'MB'}${p.price_monthly > 0 ? '$'+p.price_monthly : 'Free'}${p.price_yearly > 0 ? '$'+p.price_yearly : '-'}
+ `; + } catch (err) { el.innerHTML = `

${err.message}

`; } +} + +async function loadSystem() { + const el = document.getElementById('systemInfo'); + try { + const version = await fetch('/api/version').then(r => r.json()); + const token = localStorage.getItem('token'); + el.innerHTML = ` +
+
Version
${version.version}
+
Frontend Hash
${version.hash}
+
+ + `; + } catch (err) { el.innerHTML = `

${err.message}

`; } +} + +export function cleanup() {} diff --git a/frontend/js/views/billing.js b/frontend/js/views/billing.js new file mode 100644 index 0000000..2de911d --- /dev/null +++ b/frontend/js/views/billing.js @@ -0,0 +1,147 @@ +import { api } from '../api.js'; +import { showToast } from '../components/toast.js'; + +export async function render(container) { + container.innerHTML = ` + +

Loading...

+ `; + + try { + const [subData, plans] = await Promise.all([ + fetch('/api/subscription/me', { headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }}).then(r => r.json()), + fetch('/api/subscription/plans').then(r => r.json()) + ]); + + const content = document.getElementById('billingContent'); + + content.innerHTML = ` + +
+

Current Plan

+
+
${subData.plan.display_name}
+ ${subData.self_hosted ? 'Self-Hosted' : ''} + ${subData.trial?.active ? `Trial - ${subData.trial.days_left} days left` : ''} +
+ ${subData.trial?.active ? ` +
+ +
+
Your ${subData.trial.plan?.charAt(0).toUpperCase() + subData.trial.plan?.slice(1)} trial ends in ${subData.trial.days_left} days
+
After the trial, you'll be moved to the Free plan (1 device). Upgrade now to keep all your devices and features.
+
+
+ ` : ''} +
+
+
Devices
+
${subData.usage.devices} / ${subData.plan.max_devices === -1 ? 'Unlimited' : subData.plan.max_devices}
+ ${subData.plan.max_devices > 0 ? ` +
+
+
` : ''} +
+
+
Storage
+
${subData.usage.storage_mb} MB / ${subData.plan.max_storage_mb === -1 ? 'Unlimited' : subData.plan.max_storage_mb + ' MB'}
+ ${subData.plan.max_storage_mb > 0 ? ` +
+
+
` : ''} +
+
+
Features
+
+ ${subData.plan.remote_control ? '
✓ Remote Control
' : '
✗ Remote Control
'} + ${subData.plan.remote_url ? '
✓ Remote URLs
' : '
✗ Remote URLs
'} + ${subData.plan.priority_support ? '
✓ Priority Support
' : '
✗ Priority Support
'} +
+
+
+
+ + +
+

Available Plans

+
+ ${plans.map(p => ` +
+ ${p.id === subData.plan.id ? '
Current
' : ''} +
${p.display_name}
+
+ ${p.price_monthly > 0 ? `$${p.price_monthly}/mo` : 'Free'} +
+
+
${p.max_devices === -1 ? 'Unlimited' : p.max_devices} devices
+
${p.max_storage_mb === -1 ? 'Unlimited' : (p.max_storage_mb >= 1024 ? (p.max_storage_mb/1024) + ' GB' : p.max_storage_mb + ' MB')} storage
+
${p.remote_control ? '✓' : '✗'} Remote Control
+
${p.remote_url ? '✓' : '✗'} Remote URLs
+
${p.priority_support ? '✓' : '✗'} Priority Support
+
+ ${p.price_yearly > 0 ? `
or $${p.price_yearly}/year (save ${Math.round((1 - p.price_yearly / (p.price_monthly * 12)) * 100)}%)
` : ''} + ${!subData.self_hosted && p.price_monthly > 0 && p.id !== subData.plan.id ? ` +
+ + ${p.price_yearly > 0 ? `` : ''} +
+ ` : ''} + ${!subData.self_hosted && p.id === subData.plan.id && subData.subscription?.stripe_subscription_id ? ` + + ` : ''} +
+ `).join('')} +
+ ${subData.self_hosted ? '

Self-hosted mode: plans can be assigned by admins without billing.

' : ''} +
+ `; + // Checkout handler + window._checkout = async (planId, interval) => { + try { + const res = await fetch('/api/stripe/checkout', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}` }, + body: JSON.stringify({ plan_id: planId, interval }) + }); + const data = await res.json(); + if (data.error) { showToast(data.error, 'error'); return; } + if (data.url) window.location.href = data.url; + } catch (err) { + showToast('Failed to start checkout: ' + err.message, 'error'); + } + }; + + // Manage subscription handler (Stripe Customer Portal) + window._manageSubscription = async () => { + try { + const res = await fetch('/api/stripe/portal', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}` }, + }); + const data = await res.json(); + if (data.error) { showToast(data.error, 'error'); return; } + if (data.url) window.location.href = data.url; + } catch (err) { + showToast('Failed to open billing portal: ' + err.message, 'error'); + } + }; + + // Check for payment success/cancel in URL + if (window.location.hash.includes('payment=success')) { + showToast('Payment successful! Your plan has been upgraded.', 'success'); + window.location.hash = '#/billing'; + } + + } catch (err) { + document.getElementById('billingContent').innerHTML = `

Failed to load

${err.message}

`; + } +} + +export function cleanup() {} diff --git a/frontend/js/views/content-library.js b/frontend/js/views/content-library.js new file mode 100644 index 0000000..3049c96 --- /dev/null +++ b/frontend/js/views/content-library.js @@ -0,0 +1,493 @@ +import { api } from '../api.js'; +import { showToast } from '../components/toast.js'; + +function formatFileSize(bytes) { + if (!bytes) return '--'; + if (bytes >= 1073741824) return `${(bytes / 1073741824).toFixed(1)} GB`; + if (bytes >= 1048576) return `${(bytes / 1048576).toFixed(1)} MB`; + if (bytes >= 1024) return `${(bytes / 1024).toFixed(0)} KB`; + return `${bytes} B`; +} + +export function render(container) { + container.innerHTML = ` + + +
+
+ + + + + +

Drop files here or click to upload

+

Supports MP4, WebM, AVI, MKV, JPEG, PNG, GIF, WebP

+ + +
+
+
+ + + + + Remote URL +
+

Stream directly from a URL. Saves local bandwidth.

+ + + + +
+
+
+ + + + + YouTube +
+

Embed a YouTube video on your displays.

+ + + +
+
+ + +
+ + + +
+
+

Loading...

+
+ `; + + // File upload handling + const uploadArea = document.getElementById('uploadArea'); + const fileInput = document.getElementById('fileInput'); + + uploadArea.addEventListener('click', () => fileInput.click()); + + uploadArea.addEventListener('dragover', (e) => { + e.preventDefault(); + uploadArea.classList.add('dragover'); + }); + + uploadArea.addEventListener('dragleave', () => { + uploadArea.classList.remove('dragover'); + }); + + uploadArea.addEventListener('drop', (e) => { + e.preventDefault(); + uploadArea.classList.remove('dragover'); + handleFiles(e.dataTransfer.files); + }); + + fileInput.addEventListener('change', () => { + handleFiles(fileInput.files); + fileInput.value = ''; + }); + + // Remote URL handling + document.getElementById('addRemoteBtn').addEventListener('click', async () => { + const url = document.getElementById('remoteUrlInput').value.trim(); + const name = document.getElementById('remoteNameInput').value.trim(); + const mimeType = document.getElementById('remoteMimeType').value; + if (!url) { + showToast('Enter a URL', 'error'); + return; + } + try { + await api.addRemoteContent(url, name, mimeType); + showToast('Remote content added', 'success'); + document.getElementById('remoteUrlInput').value = ''; + document.getElementById('remoteNameInput').value = ''; + loadContent(); + } catch (err) { + showToast(err.message, 'error'); + } + }); + + // YouTube URL handling + document.getElementById('addYoutubeBtn').addEventListener('click', async () => { + const url = document.getElementById('youtubeUrlInput').value.trim(); + const name = document.getElementById('youtubeNameInput').value.trim(); + if (!url) { + showToast('Enter a YouTube URL', 'error'); + return; + } + try { + await api.addYoutubeContent(url, name); + showToast('YouTube video added', 'success'); + document.getElementById('youtubeUrlInput').value = ''; + document.getElementById('youtubeNameInput').value = ''; + loadContent(); + } catch (err) { + showToast(err.message, 'error'); + } + }); + + // Content search + folder filter + function filterContent() { + const q = document.getElementById('contentSearch').value.toLowerCase(); + const folder = document.getElementById('folderFilter').value; + document.querySelectorAll('.content-item').forEach(item => { + const name = item.querySelector('.content-item-name')?.textContent.toLowerCase() || ''; + const itemFolder = item.dataset.folder || ''; + const matchSearch = !q || name.includes(q); + const matchFolder = !folder || itemFolder === folder; + item.style.display = (matchSearch && matchFolder) ? '' : 'none'; + }); + } + document.getElementById('contentSearch').oninput = filterContent; + document.getElementById('folderFilter').onchange = filterContent; + + // New folder + document.getElementById('newFolderBtn').onclick = () => { + const name = prompt('Folder name:'); + if (name) { + // Just add to the dropdown - folders are created when content is moved into them + const opt = document.createElement('option'); + opt.value = name; opt.textContent = name; + document.getElementById('folderFilter').appendChild(opt); + showToast(`Folder "${name}" created. Edit content to move it here.`, 'info'); + } + }; + + loadContent(); +} + +async function handleFiles(files) { + const progress = document.getElementById('uploadProgress'); + const progressFill = document.getElementById('uploadProgressFill'); + const progressText = document.getElementById('uploadProgressText'); + + for (const file of files) { + progress.style.display = 'block'; + progressFill.style.width = '0%'; + progressText.textContent = `Uploading ${file.name}...`; + + try { + await api.uploadContent(file, (pct) => { + progressFill.style.width = pct + '%'; + progressText.textContent = `Uploading ${file.name}... ${pct}%`; + }); + showToast(`${file.name} uploaded successfully`, 'success'); + } catch (err) { + showToast(`Failed to upload ${file.name}: ${err.message}`, 'error'); + } + } + + progress.style.display = 'none'; + loadContent(); +} + +async function loadContent() { + const grid = document.getElementById('contentGrid'); + if (!grid) return; + + try { + const content = await api.getContent(); + if (!content.length) { + grid.innerHTML = ` +
+ + + + +

No content yet

+

Upload videos and images to get started.

+
+ `; + return; + } + + grid.innerHTML = content.map(c => ` +
+
+ ${c.mime_type === 'video/youtube' + ? `
+ ${c.filename} +
+ + + + +
+
` + : c.remote_url + ? `
+ + + + + Remote +
` + : c.thumbnail_path + ? `${c.filename}` + : c.mime_type?.startsWith('video/') + ? `
+ + + +
` + : `${c.filename}` + } +
+
+
${c.filename}
+
+ ${c.mime_type === 'video/youtube' ? 'YouTube' : c.remote_url ? 'Remote URL' : (c.mime_type?.startsWith('video/') ? 'Video' : 'Image')} + ${c.duration_sec ? ` · ${Math.floor(c.duration_sec / 60)}:${String(Math.floor(c.duration_sec % 60)).padStart(2, '0')}` : ''} + ${c.file_size ? ' · ' + formatFileSize(c.file_size) : ''} + ${c.width && c.height ? ` · ${c.width}x${c.height}` : ''} +
+
+
+ + +
+
+ `).join(''); + + // Populate folder dropdown + const folderSelect = document.getElementById('folderFilter'); + const folders = [...new Set(content.filter(c => c.folder).map(c => c.folder))].sort(); + folders.forEach(f => { + if (!folderSelect.querySelector(`option[value="${f}"]`)) { + const opt = document.createElement('option'); + opt.value = f; opt.textContent = `${f} (${content.filter(c => c.folder === f).length})`; + folderSelect.appendChild(opt); + } + }); + + // Delete handler via event delegation + grid.onclick = async (e) => { + // Preview on click (not on delete button) + const previewTarget = e.target.closest('.content-item-preview'); + if (previewTarget) { + const item = previewTarget.closest('.content-item'); + const id = item?.dataset.contentId; + if (id) { + const c = content.find(x => x.id === id); + if (c) showPreview(c); + } + return; + } + + // Edit button + const editBtn = e.target.closest('[data-edit-content]'); + if (editBtn) { + const id = editBtn.dataset.editContent; + const c = content.find(x => x.id === id); + if (c) showEditModal(c, loadContent); + return; + } + + const btn = e.target.closest('[data-delete-content]'); + if (!btn) return; + e.stopPropagation(); + const id = btn.dataset.deleteContent; + + // If already confirming, do the delete + if (btn.dataset.confirming === 'true') { + try { + btn.disabled = true; + btn.textContent = 'Deleting...'; + await api.deleteContent(id); + showToast('Content deleted', 'success'); + loadContent(); + } catch (err) { + showToast(err.message, 'error'); + btn.disabled = false; + btn.textContent = 'Delete'; + btn.dataset.confirming = 'false'; + } + return; + } + + // First click - show confirm state + btn.dataset.confirming = 'true'; + btn.innerHTML = 'Confirm Delete?'; + btn.style.background = 'var(--danger)'; + btn.style.color = 'white'; + // Reset after 3 seconds if not clicked + setTimeout(() => { + if (btn.dataset.confirming === 'true') { + btn.dataset.confirming = 'false'; + btn.innerHTML = ` Delete`; + btn.style.background = ''; + btn.style.color = ''; + } + }, 3000); + }; + + } catch (err) { + grid.innerHTML = `

Failed to load content

${err.message}

`; + } +} + +function showEditModal(contentItem, onSave) { + const overlay = document.createElement('div'); + overlay.className = 'modal-overlay'; + overlay.style.display = 'flex'; + + const isRemote = !!contentItem.remote_url; + + overlay.innerHTML = ` + + `; + + document.body.appendChild(overlay); + overlay.querySelector('#closeEditModal').onclick = () => overlay.remove(); + overlay.querySelector('#cancelEditBtn').onclick = () => overlay.remove(); + overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); }; + + overlay.querySelector('#saveEditBtn').onclick = async () => { + const filename = overlay.querySelector('#editFilename').value.trim(); + const mimeType = overlay.querySelector('#editMimeType').value; + const remoteUrl = overlay.querySelector('#editRemoteUrl')?.value.trim(); + const replaceFile = overlay.querySelector('#editFileReplace')?.files[0]; + + try { + const token = localStorage.getItem('token'); + const headers = { Authorization: 'Bearer ' + token }; + + // Update metadata + const updateData = {}; + if (filename !== contentItem.filename) updateData.filename = filename; + if (mimeType !== contentItem.mime_type) updateData.mime_type = mimeType; + if (remoteUrl !== undefined && remoteUrl !== contentItem.remote_url) updateData.remote_url = remoteUrl; + + if (Object.keys(updateData).length > 0) { + await fetch('/api/content/' + contentItem.id, { + method: 'PUT', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify(updateData) + }); + } + + // Replace file if provided + if (replaceFile) { + const formData = new FormData(); + formData.append('file', replaceFile); + await fetch('/api/content/' + contentItem.id + '/replace', { + method: 'PUT', + headers, + body: formData + }); + } + + overlay.remove(); + showToast('Content updated', 'success'); + if (onSave) onSave(); + } catch (err) { + showToast(err.message || 'Update failed', 'error'); + } + }; +} + +function showPreview(content) { + const isYoutube = content.mime_type === 'video/youtube'; + const isVideo = !isYoutube && content.mime_type?.startsWith('video/'); + const src = content.remote_url || `/uploads/content/${content.filepath}`; + + const overlay = document.createElement('div'); + overlay.className = 'modal-overlay'; + overlay.style.display = 'flex'; + overlay.innerHTML = ` +
+ +
+ ${isYoutube + ? `` + : isVideo + ? `` + : `` + } +
+
+
${content.filename}
+
${content.mime_type} ${content.remote_url ? '(Remote URL)' : ''}
+
+
+ `; + overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); }; + overlay.querySelector('#closePreview').onclick = () => overlay.remove(); + document.body.appendChild(overlay); +} + +export function cleanup() {} diff --git a/frontend/js/views/dashboard.js b/frontend/js/views/dashboard.js new file mode 100644 index 0000000..cbb7290 --- /dev/null +++ b/frontend/js/views/dashboard.js @@ -0,0 +1,288 @@ +import { api } from '../api.js'; +import { on, off, requestScreenshot } from '../socket.js'; +import { showToast } from '../components/toast.js'; + +let statusHandler = null; +let screenshotHandler = null; +let refreshInterval = null; + +function formatTimeAgo(timestamp) { + if (!timestamp) return 'Never'; + const seconds = Math.floor(Date.now() / 1000 - timestamp); + if (seconds < 60) return 'Just now'; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; + if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`; + return `${Math.floor(seconds / 86400)}d ago`; +} + +function formatBytes(mb) { + if (mb === null || mb === undefined) return '--'; + if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB`; + return `${mb} MB`; +} + +function renderDeviceCard(device) { + const token = localStorage.getItem('token'); + const screenshotUrl = device.screenshot_path + ? `/api/devices/${device.id}/screenshot?t=${device.screenshot_at || ''}&token=${token}` + : null; + + return ` +
+
+ ${screenshotUrl + ? `Screenshot` + : `
+ + + + + + No preview available +
` + } +
+ + ${device.status === 'provisioning' ? 'Awaiting Pairing' : device.status} +
+ ${device.status === 'provisioning' && device.pairing_code ? ` +
+ ${device.pairing_code} +
` : ''} +
+
+
${device.name}
+ ${device.owner_name || device.owner_email ? `
+ + + + ${device.owner_name || device.owner_email} +
` : ''} +
+
+ + + + ${formatTimeAgo(device.last_heartbeat)} +
+ ${device.battery_level !== null && device.battery_level !== undefined ? ` +
+ + + + ${device.battery_level}% +
` : ''} + ${device.wifi_rssi ? ` +
+ + + + + ${device.wifi_rssi} dBm +
` : ''} + ${device.storage_free_mb ? ` +
+ + + + + ${formatBytes(device.storage_free_mb)} free +
` : ''} +
+
+
+ `; +} + +export function render(container) { + container.innerHTML = ` + +
+
+ + +
+
+
+ + + + + +

Loading displays...

+
+
+ `; + + const addBtn = container.querySelector('#addDeviceBtn'); + addBtn.addEventListener('click', () => { + document.getElementById('addDeviceModal').style.display = 'flex'; + document.getElementById('pairingCodeInput').value = ''; + document.getElementById('deviceNameInput').value = ''; + document.getElementById('pairingCodeInput').focus(); + }); + + // Search and filter + document.getElementById('deviceSearch').oninput = () => filterDevices(); + document.getElementById('deviceFilter').onchange = () => filterDevices(); + + function filterDevices() { + const search = document.getElementById('deviceSearch').value.toLowerCase(); + const status = document.getElementById('deviceFilter').value; + document.querySelectorAll('.device-card').forEach(card => { + const name = card.querySelector('.device-card-name')?.textContent.toLowerCase() || ''; + const deviceStatus = card.querySelector('.device-card-status span:last-child')?.textContent || ''; + const matchSearch = !search || name.includes(search); + const matchStatus = !status || deviceStatus === status; + card.style.display = (matchSearch && matchStatus) ? '' : 'none'; + }); + } + + // Setup pairing + const pairBtn = document.getElementById('pairDeviceBtn'); + pairBtn.onclick = async () => { + const code = document.getElementById('pairingCodeInput').value.trim(); + const name = document.getElementById('deviceNameInput').value.trim(); + if (!code || code.length !== 6) { + showToast('Enter a valid 6-digit pairing code', 'error'); + return; + } + try { + await api.pairDevice(code, name || undefined); + document.getElementById('addDeviceModal').style.display = 'none'; + showToast('Display paired successfully!', 'success'); + loadDevices(); + } catch (err) { + showToast(err.message, 'error'); + } + }; + + // Load devices + loadDevices(); + + // Real-time updates + statusHandler = (data) => { + const card = document.querySelector(`[data-device-id="${data.device_id}"]`); + if (card) { + const statusEl = card.querySelector('.device-card-status'); + statusEl.innerHTML = `${data.status}`; + } + }; + + screenshotHandler = (data) => { + const preview = document.getElementById(`preview-${data.device_id}`); + if (preview) { + const imgSrc = data.image_data || (data.url + '&token=' + localStorage.getItem('token')); + const img = preview.querySelector('img'); + if (img) { + img.src = imgSrc; + } else { + preview.innerHTML = `Screenshot` + + preview.querySelector('.device-card-status').outerHTML; + } + } + }; + + // Device added/removed - refresh the whole list + const deviceAddedHandler = () => loadDevices(); + const deviceRemovedHandler = () => loadDevices(); + + on('device-status', statusHandler); + on('screenshot-ready', screenshotHandler); + on('device-added', deviceAddedHandler); + on('device-removed', deviceRemovedHandler); + + // Request fresh screenshots on load + setTimeout(() => { + document.querySelectorAll('.device-card').forEach(card => { + requestScreenshot(card.dataset.deviceId); + }); + }, 2000); + + // Refresh screenshots periodically + refreshInterval = setInterval(() => { + document.querySelectorAll('.device-card').forEach(card => { + requestScreenshot(card.dataset.deviceId); + }); + }, 30000); +} + +async function loadDevices() { + const grid = document.getElementById('deviceGrid'); + if (!grid) return; + + try { + const devices = await api.getDevices(); + + // Stats cards + const online = devices.filter(d => d.status === 'online').length; + const offline = devices.filter(d => d.status === 'offline').length; + const provisioning = devices.filter(d => d.status === 'provisioning').length; + const statsEl = document.getElementById('dashStats'); + if (statsEl) { + statsEl.innerHTML = ` +
+
Total Displays
+
${devices.length}
+
+
+
Online
+
${online}
+
+
+
Offline
+
${offline}
+
+ ${provisioning > 0 ? ` +
+
Awaiting Pairing
+
${provisioning}
+
` : ''} + `; + } + + if (devices.length === 0) { + grid.innerHTML = ` +
+ + + + + +

No displays yet

+

Install the ScreenTinker app on your Apolosign TV and pair it using the button above.

+
+ `; + } else { + grid.innerHTML = devices.map(renderDeviceCard).join(''); + } + } catch (err) { + grid.innerHTML = `

Failed to load displays

${err.message}

`; + } +} + +export function cleanup() { + if (statusHandler) off('device-status', statusHandler); + if (screenshotHandler) off('screenshot-ready', screenshotHandler); + off('device-added', () => {}); + off('device-removed', () => {}); + if (refreshInterval) clearInterval(refreshInterval); + statusHandler = null; + screenshotHandler = null; + refreshInterval = null; +} diff --git a/frontend/js/views/designer.js b/frontend/js/views/designer.js new file mode 100644 index 0000000..745dd6c --- /dev/null +++ b/frontend/js/views/designer.js @@ -0,0 +1,560 @@ +import { api } from '../api.js'; +import { showToast } from '../components/toast.js'; + +const BACKGROUNDS = [ + { name: 'Black', value: '#000000' }, + { name: 'Dark Blue', value: '#0f172a' }, + { name: 'Dark Gradient', value: 'linear-gradient(135deg, #0c0c0c 0%, #1a1a2e 50%, #16213e 100%)' }, + { name: 'Blue Gradient', value: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' }, + { name: 'Sunset', value: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)' }, + { name: 'Ocean', value: 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)' }, + { name: 'Forest', value: 'linear-gradient(135deg, #134e5e 0%, #71b280 100%)' }, + { name: 'Dark Red', value: 'linear-gradient(135deg, #200122 0%, #6f0000 100%)' }, + { name: 'White', value: '#FFFFFF' }, +]; + +const FONTS = ['Arial', 'Helvetica', 'Georgia', 'Impact', 'Verdana', 'Trebuchet MS', 'Courier New', 'Times New Roman']; + +let elements = []; +let selectedIdx = -1; +let bgValue = '#000000'; +let bgImageDataUrl = null; +let dragging = null; +let dragStart = null; + +export function render(container) { + elements = []; + selectedIdx = -1; + bgValue = '#000000'; + bgImageDataUrl = null; + + container.innerHTML = ` + +
+ +
+
+
+
+

Click elements to select. Drag to reposition. Live preview updates in real-time.

+
+ +
+ +
+

Add Element

+
+ + + + + + + + + + + + +
+
+ +
+

Background

+
+ ${BACKGROUNDS.map(b => `
`).join('')} +
+
+ + +
+ +
+ + + +
+

Layers

+
+
+
+
+ `; + + // Background handlers + document.querySelectorAll('[data-bg]').forEach(el => { + el.onclick = () => { bgValue = el.dataset.bg; bgImageDataUrl = null; redraw(); }; + }); + document.getElementById('bgColor').oninput = (e) => { bgValue = e.target.value; bgImageDataUrl = null; redraw(); }; + document.getElementById('bgImageBtn').onclick = () => document.getElementById('bgImageInput').click(); + document.getElementById('bgImageInput').onchange = (e) => { + const file = e.target.files[0]; if (!file) return; + const reader = new FileReader(); + reader.onload = (ev) => { bgImageDataUrl = ev.target.result; redraw(); }; + reader.readAsDataURL(file); + }; + + // Add element handlers + document.getElementById('addText').onclick = () => addElement({ type: 'text', x: 10, y: 60, text: 'Your text here', fontSize: 24, fontFamily: 'Arial', color: '#FFFFFF', bold: false, shadow: false }); + document.getElementById('addHeading').onclick = () => addElement({ type: 'text', x: 5, y: 5, text: 'HEADING', fontSize: 64, fontFamily: 'Impact', color: '#FFFFFF', bold: true, shadow: true }); + document.getElementById('addImage').onclick = () => { + const input = document.createElement('input'); input.type = 'file'; input.accept = 'image/*'; + input.onchange = () => { + const reader = new FileReader(); + reader.onload = (ev) => addElement({ type: 'image', x: 10, y: 10, width: 30, height: 30, src: ev.target.result }); + reader.readAsDataURL(input.files[0]); + }; + input.click(); + }; + document.getElementById('addVideo').onclick = () => { + const url = prompt('Video URL (MP4):'); + if (url) addElement({ type: 'video', x: 5, y: 5, width: 50, height: 50, src: url, muted: true, loop: true }); + }; + document.getElementById('addClock').onclick = () => addElement({ type: 'clock', x: 60, y: 5, fontSize: 48, fontFamily: 'Arial', color: '#FFFFFF', format: '12h', showSeconds: true, shadow: true }); + document.getElementById('addDate').onclick = () => addElement({ type: 'date', x: 60, y: 20, fontSize: 24, fontFamily: 'Arial', color: '#FFFFFF', shadow: false }); + document.getElementById('addWeather').onclick = () => { + const location = prompt('City, State:', 'Milwaukee, WI'); + if (location) addElement({ type: 'weather', x: 5, y: 70, fontSize: 36, color: '#FFFFFF', location, units: 'imperial' }); + }; + document.getElementById('addTicker').onclick = () => { + const url = prompt('RSS Feed URL:', 'https://feeds.bbci.co.uk/news/rss.xml'); + if (url) addElement({ type: 'ticker', x: 0, y: 90, width: 100, height: 10, feedUrl: url, speed: 30, fontSize: 20, color: '#FFFFFF', bgColor: 'rgba(0,0,0,0.7)' }); + }; + document.getElementById('addShape').onclick = () => addElement({ type: 'shape', x: 20, y: 20, width: 30, height: 20, color: '#3b82f6', opacity: 0.7, radius: 8, shape: 'rect' }); + document.getElementById('addQR').onclick = () => { + const data = prompt('QR Code URL:', 'https://example.com'); + if (data) addElement({ type: 'qr', x: 80, y: 70, size: 15, data, fgColor: '#FFFFFF', bgColor: '#000000' }); + }; + document.getElementById('addCountdown').onclick = () => { + const target = prompt('Target date (YYYY-MM-DD):', '2026-04-01'); + if (target) addElement({ type: 'countdown', x: 20, y: 40, fontSize: 48, color: '#FFFFFF', targetDate: target, label: 'Coming Soon' }); + }; + document.getElementById('addWebpage').onclick = () => { + const url = prompt('Webpage URL:'); + if (url) addElement({ type: 'webpage', x: 5, y: 5, width: 40, height: 40, url }); + }; + + document.getElementById('deleteEl').onclick = () => { if (selectedIdx >= 0) { elements.splice(selectedIdx, 1); selectedIdx = -1; redraw(); } }; + + // Publish as dynamic HTML content + document.getElementById('publishBtn').onclick = async () => { + try { + const html = generateHTML(); + const blob = new Blob([html], { type: 'text/html' }); + const file = new File([blob], `design-${Date.now()}.html`, { type: 'text/html' }); + // Upload as a widget instead - create a text widget with the HTML + const res = await fetch('/api/widgets', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}` }, + body: JSON.stringify({ widget_type: 'text', name: `Design ${new Date().toLocaleDateString()}`, config: { html: generateInnerHTML(), css: '', background: bgValue } }) + }); + if (res.ok) showToast('Published as widget! Assign it to a layout zone.', 'success'); + else showToast('Publish failed', 'error'); + } catch (err) { showToast(err.message, 'error'); } + }; + + // Export PNG screenshot + document.getElementById('exportPngBtn').onclick = async () => { + try { + const preview = document.getElementById('designPreview'); + // Use a canvas to capture + const canvas = document.createElement('canvas'); + canvas.width = 1920; canvas.height = 1080; + const ctx = canvas.getContext('2d'); + // Draw background + if (bgImageDataUrl) { + const img = new Image(); img.src = bgImageDataUrl; + await new Promise(r => { img.onload = r; }); + ctx.drawImage(img, 0, 0, 1920, 1080); + } else if (bgValue.startsWith('linear')) { + const colors = bgValue.match(/#[a-f0-9]{6}/gi) || ['#000']; + const grad = ctx.createLinearGradient(0, 0, 1920, 1080); + colors.forEach((c, i) => grad.addColorStop(i / Math.max(1, colors.length - 1), c)); + ctx.fillStyle = grad; ctx.fillRect(0, 0, 1920, 1080); + } else { ctx.fillStyle = bgValue; ctx.fillRect(0, 0, 1920, 1080); } + // Draw text elements + for (const el of elements) { + if (el.type === 'text' || el.type === 'clock' || el.type === 'date' || el.type === 'countdown') { + ctx.save(); + ctx.font = `${el.bold ? 'bold ' : ''}${(el.fontSize / 100) * 1080}px ${el.fontFamily || 'Arial'}`; + ctx.fillStyle = el.color || '#FFF'; + if (el.shadow) { ctx.shadowColor = 'rgba(0,0,0,0.5)'; ctx.shadowBlur = 8; ctx.shadowOffsetX = 2; ctx.shadowOffsetY = 2; } + let text = el.text || el.label || ''; + if (el.type === 'clock') text = new Date().toLocaleTimeString(); + if (el.type === 'date') text = new Date().toLocaleDateString(); + ctx.fillText(text, (el.x / 100) * 1920, (el.y / 100) * 1080 + (el.fontSize / 100) * 1080); + ctx.restore(); + } else if (el.type === 'shape') { + ctx.save(); + ctx.globalAlpha = el.opacity || 1; + ctx.fillStyle = el.color; + ctx.fillRect((el.x / 100) * 1920, (el.y / 100) * 1080, (el.width / 100) * 1920, (el.height / 100) * 1080); + ctx.restore(); + } + } + const link = document.createElement('a'); + link.download = 'signage-design.png'; link.href = canvas.toDataURL('image/png'); link.click(); + } catch (err) { showToast('Export failed: ' + err.message, 'error'); } + }; + + // Load saved design + document.getElementById('loadDesignBtn').onclick = () => { + const input = document.createElement('input'); input.type = 'file'; input.accept = '.json'; + input.onchange = () => { + const reader = new FileReader(); + reader.onload = (ev) => { + try { + const data = JSON.parse(ev.target.result); + elements = data.elements || []; + bgValue = data.bgValue || '#000'; + bgImageDataUrl = data.bgImageDataUrl || null; + redraw(); + showToast('Design loaded', 'success'); + } catch { showToast('Invalid design file', 'error'); } + }; + reader.readAsText(input.files[0]); + }; + input.click(); + }; + + // Mouse interaction on preview + const preview = document.getElementById('designPreview'); + preview.onmousedown = (e) => { + const rect = preview.getBoundingClientRect(); + const px = ((e.clientX - rect.left) / rect.width) * 100; + const py = ((e.clientY - rect.top) / rect.height) * 100; + + selectedIdx = -1; + for (let i = elements.length - 1; i >= 0; i--) { + const el = elements[i]; + const b = getBounds(el); + if (px >= b.x && px <= b.x + b.w && py >= b.y && py <= b.y + b.h) { + selectedIdx = i; + dragging = el; + dragStart = { px, py, ox: el.x, oy: el.y }; + break; + } + } + redraw(); + }; + preview.onmousemove = (e) => { + if (!dragging || !dragStart) return; + const rect = preview.getBoundingClientRect(); + const px = ((e.clientX - rect.left) / rect.width) * 100; + const py = ((e.clientY - rect.top) / rect.height) * 100; + dragging.x = Math.max(0, Math.min(95, dragStart.ox + (px - dragStart.px))); + dragging.y = Math.max(0, Math.min(95, dragStart.oy + (py - dragStart.py))); + redraw(); + }; + preview.onmouseup = () => { dragging = null; dragStart = null; }; + + redraw(); +} + +function addElement(el) { + elements.push(el); + selectedIdx = elements.length - 1; + redraw(); +} + +function getBounds(el) { + const w = el.width || el.size || (el.fontSize ? el.fontSize * 0.6 * (el.text?.length || 8) / 100 * 100 : 20); + const h = el.height || el.size || (el.fontSize ? el.fontSize * 1.2 / 100 * 100 : 10); + return { x: el.x, y: el.y, w: Math.min(w, 100), h: Math.min(h, 100) }; +} + +function redraw() { + const preview = document.getElementById('designPreview'); + if (!preview) return; + + let html = ''; + + // Background + if (bgImageDataUrl) { + preview.style.background = `url(${bgImageDataUrl}) center/cover`; + } else { + preview.style.background = bgValue; + } + + // Elements + elements.forEach((el, i) => { + const selected = i === selectedIdx; + const border = selected ? 'outline:2px solid #3b82f6;outline-offset:2px;' : ''; + const cursor = 'cursor:move;'; + + switch (el.type) { + case 'text': + html += `
${el.text}
`; + break; + case 'clock': + html += `
`; + break; + case 'date': + html += `
`; + break; + case 'image': + html += ``; + break; + case 'video': + html += ``; + break; + case 'shape': + html += `
`; + break; + case 'weather': + html += `
⛅ Loading...
`; + break; + case 'ticker': + html += `
+
Loading news...
+
`; + break; + case 'qr': + html += `
+
QR CODE
+
${el.data?.slice(0, 25)}
+
`; + break; + case 'countdown': + html += `
+
${el.label || ''}
+
+
`; + break; + case 'webpage': + html += ``; + break; + } + }); + + // Add ticker animation CSS + html += ``; + + preview.innerHTML = html; + + // Update dynamic elements + updateDynamic(); + + // Update properties panel + updateProps(); + updateLayers(); +} + +function updateDynamic() { + elements.forEach((el, i) => { + if (el.type === 'clock') { + const clockEl = document.getElementById(`clock_${i}`); + if (clockEl) { + const update = () => { + const opts = { hour: '2-digit', minute: '2-digit' }; + if (el.showSeconds) opts.second = '2-digit'; + opts.hour12 = el.format !== '24h'; + clockEl.textContent = new Date().toLocaleTimeString('en-US', opts); + }; + update(); + // Only set interval if element still exists + const iv = setInterval(() => { if (document.getElementById(`clock_${i}`)) update(); else clearInterval(iv); }, 1000); + } + } + if (el.type === 'date') { + const dateEl = document.getElementById(`date_${i}`); + if (dateEl) dateEl.textContent = new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }); + } + if (el.type === 'countdown') { + const cdEl = document.getElementById(`countdown_${i}`); + if (cdEl && el.targetDate) { + const update = () => { + const diff = new Date(el.targetDate) - new Date(); + if (diff <= 0) { cdEl.textContent = 'NOW!'; return; } + const days = Math.floor(diff / 86400000); + const hours = Math.floor((diff % 86400000) / 3600000); + const mins = Math.floor((diff % 3600000) / 60000); + cdEl.textContent = `${days}d ${hours}h ${mins}m`; + }; + update(); + const iv = setInterval(() => { if (document.getElementById(`countdown_${i}`)) update(); else clearInterval(iv); }, 60000); + } + } + if (el.type === 'weather') { + const wEl = document.getElementById(`weather_${i}`); + if (wEl && el.location) { + fetch(`https://wttr.in/${encodeURIComponent(el.location)}?format=j1`).then(r => r.json()).then(d => { + const cur = d.current_condition?.[0]; + if (cur) { + const temp = el.units === 'metric' ? cur.temp_C + '°C' : cur.temp_F + '°F'; + wEl.textContent = `${temp} ${cur.weatherDesc?.[0]?.value || ''}`; + } + }).catch(() => { wEl.textContent = '⛅ ' + el.location; }); + } + } + if (el.type === 'ticker') { + const tEl = document.getElementById(`ticker_${i}`); + if (tEl && el.feedUrl) { + fetch(`https://api.rss2json.com/v1/api.json?rss_url=${encodeURIComponent(el.feedUrl)}`).then(r => r.json()).then(d => { + tEl.textContent = (d.items || []).map(item => item.title).join(' • ') || 'No items'; + }).catch(() => { tEl.textContent = 'Feed unavailable'; }); + } + } + }); +} + +function updateProps() { + const panel = document.getElementById('propPanel'); + const fields = document.getElementById('propFields'); + if (selectedIdx < 0 || !elements[selectedIdx]) { panel.style.display = 'none'; return; } + panel.style.display = 'block'; + const el = elements[selectedIdx]; + let html = ''; + + // Common position + html += `
+
+
+
`; + + if (el.type === 'text') { + html += `
+
${el.fontSize}px
+
+
+ + `; + } else if (el.type === 'clock') { + html += `
+
+
+ `; + } else if (el.type === 'image' || el.type === 'video' || el.type === 'webpage') { + html += `
+
`; + if (el.type === 'video') html += ` + `; + } else if (el.type === 'shape') { + html += `
+
+
+
+
`; + } else if (el.type === 'weather') { + html += `
+
+
`; + } else if (el.type === 'ticker') { + html += `
+
+
+
`; + } else if (el.type === 'countdown') { + html += `
+
+
+
`; + } + + // Save design button + html += ``; + + fields.innerHTML = html; + + fields.querySelectorAll('[data-prop]').forEach(input => { + const handler = () => { + const prop = input.dataset.prop; + if (input.type === 'checkbox') el[prop] = input.checked; + else if (input.type === 'number' || input.type === 'range') el[prop] = parseFloat(input.value); + else el[prop] = input.value; + redraw(); + }; + input.oninput = handler; + input.onchange = handler; + }); +} + +function updateLayers() { + const list = document.getElementById('layerList'); + if (!list) return; + const typeIcons = { text: '💬', clock: '🕓', date: '📅', image: '📷', video: '🎬', shape: '■', weather: '⛅', ticker: '📰', qr: '▩', countdown: '⏱', webpage: '🌐' }; + list.innerHTML = elements.map((el, i) => ` +
+ ${typeIcons[el.type] || '?'} + ${el.text || el.type} +
+ `).join('') || '

No elements yet

'; + + list.querySelectorAll('[data-layer]').forEach(el => { + el.onclick = () => { selectedIdx = parseInt(el.dataset.layer); redraw(); }; + }); +} + +function generateInnerHTML() { + let html = ''; + elements.forEach((el, i) => { + switch (el.type) { + case 'text': + html += `
${el.text}
`; + break; + case 'clock': + html += `
+ `; + break; + case 'date': + html += `
+ `; + break; + case 'image': + html += ``; + break; + case 'video': + html += ``; + break; + case 'shape': + html += `
`; + break; + case 'weather': + html += `
Loading...
+ `; + break; + case 'ticker': + html += `
+
Loading...
+ + `; + break; + case 'countdown': + html += `
+
${el.label}
+
+ `; + break; + case 'webpage': + html += ``; + break; + } + }); + return html; +} + +function generateHTML() { + return `${generateInnerHTML()}`; +} + +export function cleanup() {} diff --git a/frontend/js/views/device-detail.js b/frontend/js/views/device-detail.js new file mode 100644 index 0000000..674fad7 --- /dev/null +++ b/frontend/js/views/device-detail.js @@ -0,0 +1,1149 @@ +import { api } from '../api.js'; +import { on, off, requestScreenshot, startRemote, stopRemote, sendTouch, sendKey, sendCommand } from '../socket.js'; +import { showToast } from '../components/toast.js'; + +let currentDevice = null; +let statusHandler = null; +let screenshotHandler = null; +let playbackHandler = null; +let screenshotInterval = null; +let remoteActive = false; + +function formatBytes(mb) { + if (mb === null || mb === undefined) return '--'; + if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB`; + return `${mb} MB`; +} + +function formatUptime(seconds) { + if (!seconds) return '--'; + const d = Math.floor(seconds / 86400); + const h = Math.floor((seconds % 86400) / 3600); + const m = Math.floor((seconds % 3600) / 60); + if (d > 0) return `${d}d ${h}h ${m}m`; + if (h > 0) return `${h}h ${m}m`; + return `${m}m`; +} + +export function render(container, deviceId) { + container.innerHTML = ` +
+ + + + + Back to Displays + +
+

Loading...

+
+
+ `; + + loadDevice(deviceId); + + // Real-time updates + statusHandler = (data) => { + if (data.device_id !== deviceId) return; + const badge = document.querySelector('.device-status-badge'); + if (badge) { + badge.className = `device-status-badge ${data.status}`; + badge.textContent = data.status; + } + if (data.telemetry) updateTelemetryDisplay(data.telemetry); + }; + + screenshotHandler = (data) => { + if (data.device_id !== deviceId) return; + // Use inline base64 data if available, otherwise fall back to URL + const imgSrc = data.image_data || (() => { + const token = localStorage.getItem('token'); + return data.url + (data.url.includes('?') ? '&' : '?') + 'token=' + token; + })(); + // Update screenshot in Now Playing tab + const screenshotEl = document.getElementById('currentScreenshot'); + if (screenshotEl) { + if (screenshotEl.tagName === 'IMG') { + screenshotEl.src = imgSrc; + } else { + // Replace placeholder div with actual image + const img = document.createElement('img'); + img.id = 'currentScreenshot'; + img.src = imgSrc; + img.alt = 'Current screen'; + img.style.cssText = 'width:100%;height:100%;object-fit:contain'; + screenshotEl.replaceWith(img); + } + } + // Update remote canvas + const canvas = document.getElementById('remoteCanvas'); + if (canvas && remoteActive) { + const ctx = canvas.getContext('2d'); + const img = new Image(); + img.onload = () => { + canvas.width = img.width; + canvas.height = img.height; + ctx.drawImage(img, 0, 0); + }; + img.src = imgSrc; + } + }; + + playbackHandler = (data) => { + if (data.device_id !== deviceId) return; + const el = document.getElementById('nowPlayingInfo'); + if (el && data.current_content_id) { + el.textContent = `Playing: ${data.current_content_id}`; + } + }; + + on('device-status', statusHandler); + on('screenshot-ready', screenshotHandler); + on('playback-state', playbackHandler); +} + +async function loadDevice(deviceId, activeTab = null) { + const contentEl = document.getElementById('deviceContent'); + try { + const device = await api.getDevice(deviceId); + currentDevice = device; + const latestTelemetry = device.telemetry?.[0] || {}; + + contentEl.innerHTML = ` +
+
+

${device.name}

+ ${device.status} + ${device.owner_name || device.owner_email ? `Owner: ${device.owner_name || device.owner_email}` : ''} +
+
+ + + +
+
+ +
+
Now Playing ?
+
Playlist ?
+
Device Info ?
+
Remote Control ?
+
+ + +
+
+ ${device.screenshot + ? `Current screen` + : `
+ + + + + + No screenshot available. Click "Screenshot" to capture one. +
` + } +
+

+ ${device.assignments?.length ? `${device.assignments.length} item(s) in playlist` : 'No content assigned'} +

+
+ + +
+ +
+ + + +
+
Screen Layout
+ +
+ +
+ +
+

Playlist

+
+ + +
+
+
+ ${renderPlaylist(device.assignments || [])} +
+
+ + +
+
+
+
Status
+
${device.status}
+
+
+
IP Address
+
${device.ip_address || '--'}
+
+
+
Battery
+
${latestTelemetry.battery_level != null ? latestTelemetry.battery_level + '%' : '--'}
+ ${latestTelemetry.battery_level != null ? ` +
+
+
` : ''} +
+
+
Storage
+
${latestTelemetry.storage_free_mb ? formatBytes(latestTelemetry.storage_free_mb) + ' free' : '--'}
+ ${latestTelemetry.storage_total_mb ? ` +
+
+
` : ''} +
+
+
WiFi
+
${latestTelemetry.wifi_ssid || '--'}
+
${latestTelemetry.wifi_rssi ? latestTelemetry.wifi_rssi + ' dBm' : ''}
+
+
+
Uptime
+
${formatUptime(latestTelemetry.uptime_seconds)}
+
+
+
Android Version
+
${device.android_version || '--'}
+
+
+
App Version
+
${device.app_version || '--'}
+
+
+
Screen Resolution
+
${device.screen_width && device.screen_height ? device.screen_width + 'x' + device.screen_height : '--'}
+
+
+
RAM
+
${latestTelemetry.ram_free_mb ? formatBytes(latestTelemetry.ram_free_mb) + ' free' : '--'}
+
+
+
CPU Usage
+
${latestTelemetry.cpu_usage != null ? latestTelemetry.cpu_usage.toFixed(1) + '%' : '--'}
+
+
+ + +
+

Uptime Timeline (Last 24 Hours)

+
+
+ 24h ago + Now +
+
+ Online + Offline + No data + +
+
+ +
+
+
+ + +
+
+ + +
+
+
+ + +
+ +
+
+ + + + + + +
+
+ + +
+
+
+ +
+
+ + + + + +

Click "Start Remote" to begin

+
+
+
+
+ + +
+ + + +
+ +
+ + + + +
+ +
+ + +
+ + +
+ +
+
+ + +
+
+ + Requires one-time approval on device +
+
+
+ `; + + // Global key/command handlers for remote + window._sendKey = (keycode) => { + if (currentDevice) sendKey(currentDevice.id, keycode); + }; + window._sendCmd = (type) => { + if (currentDevice) sendCommand(currentDevice.id, type, {}); + }; + window._enableSystemView = () => { + if (!currentDevice) return; + sendCommand(currentDevice.id, 'enable_system_capture', {}); + // Unlock the system controls after a short delay (user needs to tap "Start now" on device) + const btn = document.getElementById('enableSystemCaptureBtn'); + const hint = document.getElementById('systemViewHint'); + if (btn) { btn.textContent = 'Waiting for device approval...'; btn.disabled = true; } + // Check periodically if the device granted it (we'll know because screenshots keep coming even after Home) + setTimeout(() => { + const controls = document.getElementById('systemViewControls'); + if (controls) { controls.style.opacity = '1'; controls.style.pointerEvents = 'auto'; } + if (btn) { btn.textContent = 'System View Enabled'; btn.style.background = 'var(--success)'; } + if (hint) hint.textContent = 'Navigation and system controls unlocked'; + }, 5000); + }; + + // Render uptime timeline + renderUptimeTimeline(device.uptimeData || [], device.statusLog || []); + + setupTabs(); + setupActions(device); + setupRemote(device); + setupPlaylistActions(device); + + // Restore active tab if specified (e.g. after layout change) + if (activeTab) { + document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); + document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active')); + const tab = document.querySelector(`.tab[data-tab="${activeTab}"]`); + if (tab) tab.classList.add('active'); + const content = document.getElementById(`tab-${activeTab}`); + if (content) content.classList.add('active'); + } + + // Request a fresh screenshot on page load + if (device.status === 'online') { + requestScreenshot(deviceId); + } + + } catch (err) { + contentEl.innerHTML = `

Failed to load device

${err.message}

`; + } +} + +function renderPlaylist(assignments) { + if (!assignments.length) { + return `

No content assigned

Add content from your library to this display's playlist.

`; + } + return assignments.map((a, i) => ` +
+
+ + + +
+ ${a.widget_id && !a.content_id + ? `
+ ${{clock:'🕓',weather:'⛅',rss:'📰',text:'📝',webpage:'🌐',social:'💬'}[a.widget_type] || '⚙'} +
` + : a.thumbnail_path + ? `` + : `
+ + + +
` + } +
+
${a.filename || a.widget_name || 'Unknown'}
+
+ ${a.widget_id && !a.content_id ? `Widget (${a.widget_type || 'custom'})` : a.mime_type?.startsWith('video/') ? 'Video' : 'Image'} + ${a.zone_id ? ` · Zone: ${a.zone_id.slice(0,8)}` : ''} + ${a.duration_sec ? ` · Duration: ${a.duration_sec}s` : ''} + ${a.content_duration ? ` · Length: ${Math.round(a.content_duration)}s` : ''} + ${a.schedule_start ? ` · ${a.schedule_start}-${a.schedule_end}` : ''} +
+
+
+ + + +
+
+ `).join(''); +} + +function setupTabs() { + document.querySelectorAll('.tab').forEach(tab => { + tab.addEventListener('click', () => { + document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); + document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active')); + tab.classList.add('active'); + document.getElementById(`tab-${tab.dataset.tab}`).classList.add('active'); + }); + }); +} + +async function setupActions(device) { + // Screenshot button + document.getElementById('screenshotBtn')?.addEventListener('click', () => { + requestScreenshot(device.id); + showToast('Screenshot requested', 'info'); + }); + + // Rename + document.getElementById('renameBtn')?.addEventListener('click', async () => { + const name = prompt('Enter new name:', device.name); + if (name && name !== device.name) { + try { + await api.updateDevice(device.id, { name }); + document.getElementById('deviceName').textContent = name; + currentDevice.name = name; + showToast('Display renamed', 'success'); + } catch (err) { + showToast(err.message, 'error'); + } + } + }); + + // Populate default content dropdown + try { + const content = await api.getContent(); + const defaultSelect = document.getElementById('deviceDefaultContent'); + if (defaultSelect) { + content.forEach(c => { + const opt = document.createElement('option'); + opt.value = c.id; opt.textContent = c.filename; + if (device.default_content_id === c.id) opt.selected = true; + defaultSelect.appendChild(opt); + }); + } + } catch {} + + // Save settings (notes + orientation + default content) + document.getElementById('saveNotesBtn')?.addEventListener('click', async () => { + try { + await api.updateDevice(device.id, { + notes: document.getElementById('deviceNotes').value, + orientation: document.getElementById('deviceOrientation').value, + default_content_id: document.getElementById('deviceDefaultContent').value || null, + }); + showToast('Settings saved', 'success'); + } catch (err) { + showToast(err.message, 'error'); + } + }); + + // Copy playlist to another device + document.getElementById('copyPlaylistBtn')?.addEventListener('click', async () => { + try { + const devices = await api.getDevices(); + const others = devices.filter(d => d.id !== device.id); + if (!others.length) { showToast('No other devices to copy to', 'info'); return; } + + const targetId = prompt('Copy playlist to which device?\n\n' + others.map((d, i) => `${i + 1}. ${d.name}`).join('\n') + '\n\nEnter number:'); + if (!targetId) return; + const target = others[parseInt(targetId) - 1]; + if (!target) { showToast('Invalid selection', 'error'); return; } + + const token = localStorage.getItem('token'); + const res = await fetch(`/api/assignments/device/${device.id}/copy-to/${target.id}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify({ replace: false }) + }); + const data = await res.json(); + if (res.ok) showToast(`Copied ${data.copied} items to ${target.name}`, 'success'); + else showToast(data.error, 'error'); + } catch (err) { showToast(err.message, 'error'); } + }); + + // Delete (double-click to confirm) + const deleteBtn = document.getElementById('deleteDeviceBtn'); + let deleteConfirming = false; + let deleteTimeout = null; + deleteBtn?.addEventListener('click', async () => { + if (deleteConfirming) { + try { + deleteBtn.textContent = 'Removing...'; + deleteBtn.disabled = true; + await api.deleteDevice(device.id); + showToast('Display removed', 'success'); + window.location.hash = '/'; + } catch (err) { + showToast(err.message, 'error'); + deleteBtn.textContent = 'Remove'; + deleteBtn.disabled = false; + deleteConfirming = false; + } + return; + } + deleteConfirming = true; + deleteBtn.textContent = 'Click again to confirm'; + deleteBtn.style.background = 'var(--danger)'; + deleteBtn.style.color = 'white'; + clearTimeout(deleteTimeout); + deleteTimeout = setTimeout(() => { + deleteConfirming = false; + deleteBtn.textContent = 'Remove'; + deleteBtn.style.background = ''; + deleteBtn.style.color = ''; + }, 3000); + }); + + // Reboot (double-click to confirm) + const rebootBtn = document.getElementById('rebootBtn'); + let rebootConfirming = false; + let rebootTimeout = null; + rebootBtn?.addEventListener('click', () => { + if (rebootConfirming) { + sendCommand(device.id, 'reboot', {}); + showToast('Reboot command sent', 'info'); + rebootConfirming = false; + rebootBtn.textContent = 'Reboot Device'; + return; + } + rebootConfirming = true; + rebootBtn.textContent = 'Click again to confirm'; + clearTimeout(rebootTimeout); + rebootTimeout = setTimeout(() => { + rebootConfirming = false; + rebootBtn.textContent = 'Reboot Device'; + }, 3000); + }); + + // Shutdown (double-click to confirm) + const shutdownBtn = document.getElementById('shutdownBtn'); + let shutdownConfirming = false; + let shutdownTimeout = null; + shutdownBtn?.addEventListener('click', () => { + if (shutdownConfirming) { + sendCommand(device.id, 'shutdown', {}); + showToast('Shutdown command sent', 'info'); + shutdownConfirming = false; + shutdownBtn.textContent = 'Shutdown'; + return; + } + shutdownConfirming = true; + shutdownBtn.textContent = 'Click again to confirm'; + shutdownBtn.style.background = 'var(--danger)'; + shutdownBtn.style.color = 'white'; + clearTimeout(shutdownTimeout); + shutdownTimeout = setTimeout(() => { + shutdownConfirming = false; + shutdownBtn.textContent = 'Shutdown'; + shutdownBtn.style.background = ''; + shutdownBtn.style.color = ''; + }, 3000); + }); + + // Screen Off + document.getElementById('screenOffBtn')?.addEventListener('click', () => { + sendCommand(device.id, 'screen_off', {}); + showToast('Screen off command sent', 'info'); + }); + + // Screen On + document.getElementById('screenOnBtn')?.addEventListener('click', () => { + sendCommand(device.id, 'screen_on', {}); + showToast('Screen on command sent', 'info'); + }); + + // Launch Player + document.getElementById('launchAppBtn')?.addEventListener('click', () => { + sendCommand(device.id, 'launch', {}); + showToast('Launch command sent', 'info'); + }); + + // Force Update + document.getElementById('forceUpdateBtn')?.addEventListener('click', () => { + sendCommand(device.id, 'update', {}); + showToast('Update check triggered', 'info'); + }); +} + +function setupRemote(device) { + const startBtn = document.getElementById('startRemoteBtn'); + const stopBtn = document.getElementById('stopRemoteBtn'); + const overlay = document.getElementById('remoteOverlay'); + const canvas = document.getElementById('remoteCanvas'); + + startBtn?.addEventListener('click', () => { + console.log('Start Remote clicked for device:', device.id); + remoteActive = true; + startRemote(device.id); + requestScreenshot(device.id); + startBtn.style.display = 'none'; + stopBtn.style.display = ''; + overlay.style.display = 'none'; + showToast('Remote session started', 'info'); + }); + + stopBtn?.addEventListener('click', () => { + remoteActive = false; + stopRemote(device.id); + stopBtn.style.display = 'none'; + startBtn.style.display = ''; + overlay.style.display = 'flex'; + }); + + // Touch forwarding on canvas + canvas?.addEventListener('click', (e) => { + if (!remoteActive) return; + const rect = canvas.getBoundingClientRect(); + const x = (e.clientX - rect.left) / rect.width; + const y = (e.clientY - rect.top) / rect.height; + sendTouch(device.id, x, y, 'tap'); + + // Visual feedback + const ctx = canvas.getContext('2d'); + ctx.beginPath(); + ctx.arc(e.clientX - rect.left, e.clientY - rect.top, 10, 0, Math.PI * 2); + ctx.fillStyle = 'rgba(59, 130, 246, 0.5)'; + ctx.fill(); + setTimeout(() => { + // Redraw will happen on next screenshot + }, 200); + }); +} + +async function setupPlaylistActions(device) { + // Load layouts into selector + try { + const layoutsRes = await fetch('/api/layouts', { headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }}); + const layouts = await layoutsRes.json(); + const select = document.getElementById('deviceLayoutSelect'); + if (select) { + layouts.filter(l => !l.is_template).forEach(l => { + const opt = document.createElement('option'); + opt.value = l.id; + opt.textContent = `${l.name} (${l.zones?.length || 0} zones)`; + if (device.layout_id === l.id) opt.selected = true; + select.appendChild(opt); + }); + // Add templates too + layouts.filter(l => l.is_template).forEach(l => { + const opt = document.createElement('option'); + opt.value = l.id; + opt.textContent = `[Template] ${l.name} (${l.zones?.length || 0} zones)`; + if (device.layout_id === l.id) opt.selected = true; + select.appendChild(opt); + }); + } + } catch (err) { + console.warn('Failed to load layouts:', err); + } + + // Apply layout button + document.getElementById('applyLayoutBtn')?.addEventListener('click', async () => { + const layoutId = document.getElementById('deviceLayoutSelect').value; + try { + await fetch(`/api/layouts/device/${device.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}` }, + body: JSON.stringify({ layout_id: layoutId || null }) + }); + showToast(layoutId ? 'Layout applied' : 'Switched to fullscreen', 'success'); + // Reload the device page to show updated zone selectors, stay on playlist tab + loadDevice(device.id, 'playlist'); + } catch (err) { + showToast(err.message, 'error'); + } + }); + + // Add content button + document.getElementById('addContentBtn')?.addEventListener('click', async () => { + const token = localStorage.getItem('token'); + const headers = { Authorization: `Bearer ${token}` }; + + try { + const [content, widgets, kioskPages] = await Promise.all([ + api.getContent(), + fetch('/api/widgets', { headers }).then(r => r.json()), + fetch('/api/kiosk', { headers }).then(r => r.json()), + ]); + + // Get layout zones if device has a layout assigned + let zones = []; + if (device.layout_id) { + try { + const layout = await fetch(`/api/layouts/${device.layout_id}`, { headers }).then(r => r.json()); + zones = layout.zones || []; + } catch {} + } + + if (!content.length && !widgets.length && !kioskPages.length) { + showToast('No content, widgets, or kiosk pages yet. Create something first!', 'error'); + return; + } + + const modal = document.createElement('div'); + modal.className = 'modal-overlay'; + modal.innerHTML = ` + + `; + document.body.appendChild(modal); + + // Tab switching + modal.querySelectorAll('.assign-tab').forEach(tab => { + tab.onclick = () => { + modal.querySelectorAll('.assign-tab').forEach(t => { t.style.borderBottomColor = 'transparent'; t.style.color = 'var(--text-secondary)'; }); + tab.style.borderBottomColor = 'var(--accent)'; tab.style.color = 'var(--accent)'; + document.getElementById('assignMedia').style.display = tab.dataset.tab === 'media' ? '' : 'none'; + document.getElementById('assignWidgets').style.display = tab.dataset.tab === 'widgets' ? '' : 'none'; + document.getElementById('assignKiosk').style.display = tab.dataset.tab === 'kiosk' ? '' : 'none'; + }; + }); + + let selectedId = null; + let selectedType = null; + modal.querySelectorAll('.assign-content-item').forEach(item => { + item.addEventListener('click', () => { + modal.querySelectorAll('.assign-content-item').forEach(i => i.classList.remove('selected')); + item.classList.add('selected'); + selectedId = item.dataset.contentId; + selectedType = item.dataset.type; + }); + }); + + modal.querySelector('#closeAssignModal').onclick = () => modal.remove(); + modal.querySelector('#cancelAssign').onclick = () => modal.remove(); + modal.querySelector('#confirmAssign').onclick = async () => { + if (!selectedId) { + showToast('Select something first', 'error'); + return; + } + const duration = parseInt(modal.querySelector('#assignDuration').value) || 10; + const zoneId = modal.querySelector('#assignZone')?.value || null; + try { + if (selectedType === 'content') { + await api.addAssignment(device.id, { content_id: selectedId, duration_sec: duration, zone_id: zoneId }); + } else if (selectedType === 'widget') { + await api.addAssignment(device.id, { widget_id: selectedId, duration_sec: duration, zone_id: zoneId }); + } else if (selectedType === 'kiosk') { + // For kiosk pages, create a webpage widget pointing to the kiosk render URL + const serverUrl = window.location.origin; + const wRes = await fetch('/api/widgets', { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ widget_type: 'webpage', name: `Kiosk: ${kioskPages.find(k => k.id === selectedId)?.name || 'Page'}`, config: { url: `${serverUrl}/api/kiosk/${selectedId}/render` } }) + }); + const widget = await wRes.json(); + await api.addAssignment(device.id, { widget_id: widget.id, duration_sec: 0 }); + } + modal.remove(); + showToast('Added to playlist', 'success'); + const assignments = await api.getAssignments(device.id); + document.getElementById('playlistContainer').innerHTML = renderPlaylist(assignments); + attachRemoveHandlers(device); + } catch (err) { + showToast(err.message, 'error'); + } + }; + } catch (err) { + showToast(err.message, 'error'); + } + }); + + attachRemoveHandlers(device); +} + +function attachRemoveHandlers(device) { + // Populate zone selectors if device has a layout + if (device.layout_id) { + const token = localStorage.getItem('token'); + fetch(`/api/layouts/${device.layout_id}`, { headers: { Authorization: `Bearer ${token}` }}) + .then(r => r.json()) + .then(layout => { + const zones = layout.zones || []; + document.querySelectorAll('.zone-select').forEach(select => { + select.style.display = ''; + const assignmentId = select.dataset.assignmentId; + // Find current zone_id from the playlist item's data + const zoneText = select.closest('.playlist-item')?.querySelector('[style*="color:var(--accent)"]')?.textContent || ''; + zones.forEach(z => { + const opt = document.createElement('option'); + opt.value = z.id; + opt.textContent = z.name; + select.appendChild(opt); + }); + // Set current value by matching zone_id from the meta text + const currentAssignment = document.querySelector(`.playlist-item[data-assignment-id="${assignmentId}"]`); + if (currentAssignment) { + const meta = currentAssignment.querySelector('.playlist-item-meta')?.innerHTML || ''; + const zoneMatch = zones.find(z => meta.includes(z.id.slice(0, 8))); + if (zoneMatch) select.value = zoneMatch.id; + } + select.onchange = async () => { + try { + await api.updateAssignment(assignmentId, { zone_id: select.value || null }); + showToast(`Zone updated`, 'success'); + } catch (err) { showToast(err.message, 'error'); } + }; + }); + }).catch(() => {}); + } + + // Mute toggle buttons + document.querySelectorAll('.mute-toggle').forEach(btn => { + btn.addEventListener('click', async (e) => { + e.stopPropagation(); + const id = btn.dataset.muteAssignment; + const currentlyMuted = btn.dataset.muted === '1'; + try { + await api.updateAssignment(id, { muted: !currentlyMuted }); + showToast(currentlyMuted ? 'Unmuted' : 'Muted', 'success'); + const assignments = await api.getAssignments(device.id); + document.getElementById('playlistContainer').innerHTML = renderPlaylist(assignments); + attachRemoveHandlers(device); + } catch (err) { showToast(err.message, 'error'); } + }); + }); + + // Remove buttons + document.querySelectorAll('[data-remove-assignment]').forEach(btn => { + btn.addEventListener('click', async (e) => { + e.stopPropagation(); + const id = btn.dataset.removeAssignment; + try { + await api.deleteAssignment(id); + showToast('Content removed from playlist', 'success'); + const assignments = await api.getAssignments(device.id); + document.getElementById('playlistContainer').innerHTML = renderPlaylist(assignments); + attachRemoveHandlers(device); + } catch (err) { + showToast(err.message, 'error'); + } + }); + }); + + // Drag-and-drop reorder + const container = document.getElementById('playlistContainer'); + if (!container) return; + let dragItem = null; + + container.querySelectorAll('.playlist-item[draggable]').forEach(item => { + item.addEventListener('dragstart', (e) => { + dragItem = item; + item.style.opacity = '0.4'; + e.dataTransfer.effectAllowed = 'move'; + }); + item.addEventListener('dragend', () => { + item.style.opacity = '1'; + dragItem = null; + container.querySelectorAll('.playlist-item').forEach(i => i.style.borderTop = ''); + }); + item.addEventListener('dragover', (e) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + container.querySelectorAll('.playlist-item').forEach(i => i.style.borderTop = ''); + if (item !== dragItem) item.style.borderTop = '2px solid var(--accent)'; + }); + item.addEventListener('drop', async (e) => { + e.preventDefault(); + item.style.borderTop = ''; + if (!dragItem || dragItem === item) return; + + // Get new order + const items = [...container.querySelectorAll('.playlist-item[data-assignment-id]')]; + const fromIdx = items.indexOf(dragItem); + const toIdx = items.indexOf(item); + if (fromIdx < 0 || toIdx < 0) return; + + // Reorder in DOM + if (fromIdx < toIdx) item.after(dragItem); + else item.before(dragItem); + + // Get new order of assignment IDs + const newOrder = [...container.querySelectorAll('.playlist-item[data-assignment-id]')] + .map(el => parseInt(el.dataset.assignmentId)); + + try { + await api.reorderAssignments(device.id, newOrder); + showToast('Playlist reordered', 'success'); + } catch (err) { + showToast(err.message, 'error'); + // Reload to revert + const assignments = await api.getAssignments(device.id); + container.innerHTML = renderPlaylist(assignments); + attachRemoveHandlers(device); + } + }); + }); +} + +function renderUptimeTimeline(uptimeData, statusLog = []) { + const timeline = document.getElementById('uptimeTimeline'); + const percentEl = document.getElementById('uptimePercent'); + if (!timeline) return; + + const now = Math.floor(Date.now() / 1000); + const dayAgo = now - 86400; + const slots = 96; // 15-minute slots over 24 hours + const slotDuration = 86400 / slots; // 900 seconds = 15 min + + // Build slot status: 'online', 'offline', or 'unknown' + const slotStatus = new Array(slots).fill('unknown'); + + // First pass: mark slots that have heartbeat telemetry as online + for (const ts of uptimeData) { + const slotIdx = Math.floor((ts - dayAgo) / slotDuration); + if (slotIdx >= 0 && slotIdx < slots) slotStatus[slotIdx] = 'online'; + } + + // Second pass: use status log events to paint ranges + // Walk through events and fill slots between online/offline transitions + for (let i = 0; i < statusLog.length; i++) { + const event = statusLog[i]; + const nextEvent = statusLog[i + 1]; + const startSlot = Math.max(0, Math.floor((event.timestamp - dayAgo) / slotDuration)); + const endSlot = nextEvent + ? Math.min(slots - 1, Math.floor((nextEvent.timestamp - dayAgo) / slotDuration)) + : (event.status === 'online' ? slots - 1 : startSlot); + + const isOnline = event.status === 'online'; + for (let s = startSlot; s <= endSlot && s < slots; s++) { + if (s >= 0) slotStatus[s] = isOnline ? 'online' : 'offline'; + } + } + + // Mark future slots as unknown + const nowSlot = Math.floor((now - dayAgo) / slotDuration); + for (let i = nowSlot + 1; i < slots; i++) slotStatus[i] = 'unknown'; + + // Calculate uptime percentage (only over known slots) + const knownSlots = slotStatus.filter(s => s !== 'unknown').length; + const onlineSlots = slotStatus.filter(s => s === 'online').length; + const uptimePct = knownSlots > 0 ? Math.round((onlineSlots / knownSlots) * 100) : 0; + if (percentEl) percentEl.textContent = `${uptimePct}% uptime (${knownSlots > 0 ? knownSlots * 15 + 'min tracked' : 'no data'})`; + + // Color map + const colors = { + online: 'var(--success)', + offline: 'var(--danger)', + unknown: 'var(--bg-secondary)' + }; + const opacities = { online: 0.8, offline: 0.6, unknown: 0.3 }; + + // Render bars + timeline.innerHTML = slotStatus.map((status, i) => { + const time = new Date((dayAgo + i * slotDuration) * 1000); + const label = time.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }); + const statusLabel = status === 'unknown' ? 'No data' : status.charAt(0).toUpperCase() + status.slice(1); + return `
`; + }).join(''); +} + +function updateTelemetryDisplay(telemetry) { + const update = (id, val) => { + const el = document.getElementById(id); + if (el) el.textContent = val; + }; + if (telemetry.battery_level != null) update('telBattery', telemetry.battery_level + '%'); + if (telemetry.storage_free_mb) update('telStorage', formatBytes(telemetry.storage_free_mb) + ' free'); + if (telemetry.wifi_ssid) update('telWifi', telemetry.wifi_ssid); + if (telemetry.wifi_rssi) update('telRssi', telemetry.wifi_rssi + ' dBm'); + if (telemetry.uptime_seconds) update('telUptime', formatUptime(telemetry.uptime_seconds)); + if (telemetry.ram_free_mb) update('telRam', formatBytes(telemetry.ram_free_mb) + ' free'); + if (telemetry.cpu_usage != null) update('telCpu', telemetry.cpu_usage.toFixed(1) + '%'); +} + +export function cleanup() { + if (statusHandler) off('device-status', statusHandler); + if (screenshotHandler) off('screenshot-ready', screenshotHandler); + if (playbackHandler) off('playback-state', playbackHandler); + if (screenshotInterval) clearInterval(screenshotInterval); + if (remoteActive && currentDevice) stopRemote(currentDevice.id); + remoteActive = false; + currentDevice = null; + window._sendKey = null; +} diff --git a/frontend/js/views/help.js b/frontend/js/views/help.js new file mode 100644 index 0000000..b76c57c --- /dev/null +++ b/frontend/js/views/help.js @@ -0,0 +1,58 @@ +export function render(container) { + container.innerHTML = ` + + +
+ ${[ + { icon: '📺', title: 'Setting Up a Display', steps: ['Download the APK or open the Web Player', 'Enter your server URL', 'Note the 6-digit pairing code', 'Click "Add Display" in the dashboard and enter the code', 'Assign content to the display\'s playlist'] }, + { icon: '📤', title: 'Uploading Content', steps: ['Go to Content Library', 'Drag and drop files or click the upload area', 'Supports MP4, WebM, JPEG, PNG, GIF, WebP', 'Videos auto-detect duration and generate thumbnails', 'Use Remote URL to stream from external sources'] }, + { icon: '⚙', title: 'Using Widgets', steps: ['Go to Widgets and click "New Widget"', 'Choose a type: Clock, Weather, RSS, Text, Webpage, or Social', 'Configure the widget settings', 'Assign the widget to a device via the Playlist tab', 'Widgets render as live HTML content'] }, + { icon: '📋', title: 'Multi-Zone Layouts', steps: ['Go to Layouts and create a new layout or use a template', 'Drag zones to position them on the canvas', 'Resize using the corner handle', 'Assign the layout to a device in the Playlist tab', 'Each zone can show different content'] }, + { icon: '📅', title: 'Content Scheduling', steps: ['Go to Schedule and select a device', 'Click "Add Schedule" to create a time slot', 'Set start/end times and recurrence rules', 'Higher priority schedules override lower ones', 'Content auto-switches based on the schedule'] }, + { icon: '🖥', title: 'Remote Control', steps: ['Go to a device\'s detail page', 'Click the "Remote Control" tab', 'Click "Start Remote" to begin streaming', 'Use the d-pad, volume, and power buttons', 'Click anywhere on the screen to simulate a tap'] }, + { icon: '🖱', title: 'Kiosk/Touchscreen', steps: ['Go to Kiosk and create a new page', 'Add buttons with labels, icons, and actions', 'Configure the idle screen timeout', 'Preview the page in the editor', 'Assign to a device as a widget'] }, + { icon: '🎬', title: 'Video Walls', steps: ['Go to Video Walls and create a new wall', 'Set the grid size (e.g., 2x2)', 'Drag devices onto grid positions', 'Set bezel compensation if needed', 'Assign content to play across all displays'] }, + ].map(guide => ` +
+

${guide.icon} ${guide.title}

+
    + ${guide.steps.map(s => `
  1. ${s}
  2. `).join('')} +
+
+ `).join('')} +
+ +
+

Frequently Asked Questions

+ ${[ + { q: 'What devices are supported?', a: 'Android TV/tablets (APK), Raspberry Pi, Windows, ChromeOS, LG webOS, Samsung Tizen, Fire TV, and any device with a web browser.' }, + { q: 'How does the free trial work?', a: 'New accounts get a 14-day free trial of the Pro plan (15 devices, all features). After 14 days, you\'re moved to the Free plan (1 device) unless you upgrade.' }, + { q: 'Can I use portrait mode displays?', a: 'Yes! Set the orientation to "Portrait" in the device\'s Info tab. The content will be rotated accordingly.' }, + { q: 'What happens when a device goes offline?', a: 'Devices cache content locally, so they continue playing their playlist even without internet. You\'ll receive an email alert after 5 minutes of being offline.' }, + { q: 'Can I self-host ScreenTinker?', a: 'Yes! Deploy the server on your own infrastructure. All data stays on your network. Set SELF_HOSTED=true in the environment.' }, + { q: 'How do I update the Android app?', a: 'The app checks for updates automatically every 30 minutes. You can also force an update from the device\'s Info tab in the dashboard.' }, + { q: 'What video formats are supported?', a: 'MP4 (H.264), WebM, AVI, MKV, MOV. For best compatibility, use MP4 with H.264 encoding.' }, + { q: 'Can I white-label the dashboard?', a: 'Yes! Go to Settings > White Label to customize the brand name, colors, logo, and domain.' }, + { q: 'How do I export proof-of-play reports?', a: 'Go to Reports, set your date range and filters, then click "Export CSV".' }, + { q: 'What is a video wall?', a: 'A video wall combines multiple displays into one large screen. For example, four TVs in a 2x2 grid showing one big image/video.' }, + ].map(faq => ` +
+
${faq.q}
+
${faq.a}
+
+ `).join('')} +
+ +
+

Keyboard Shortcuts

+
+ Esc Reset web player (on player page) + F Toggle fullscreen (web player) +
+
+ `; +} + +export function cleanup() {} diff --git a/frontend/js/views/kiosk.js b/frontend/js/views/kiosk.js new file mode 100644 index 0000000..b70cac5 --- /dev/null +++ b/frontend/js/views/kiosk.js @@ -0,0 +1,201 @@ +import { showToast } from '../components/toast.js'; + +const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(r => r.json()); + +export async function render(container) { + const hash = window.location.hash; + if (hash.startsWith('#/kiosk/')) { + const id = hash.split('#/kiosk/')[1]; + return renderEditor(container, id); + } + return renderList(container); +} + +async function renderList(container) { + container.innerHTML = ` + +
+ `; + + document.getElementById('newKioskBtn').onclick = async () => { + const name = prompt('Kiosk page name:'); + if (!name) return; + const page = await API('/kiosk', { method: 'POST', body: JSON.stringify({ name }) }); + window.location.hash = `#/kiosk/${page.id}`; + }; + + try { + const pages = await API('/kiosk'); + const grid = document.getElementById('kioskGrid'); + if (!pages.length) { + grid.innerHTML = '

No kiosk pages yet

Create an interactive touchscreen interface for your displays.

'; + return; + } + grid.innerHTML = pages.map(p => ` +
+
+ 🖱 +
+
+
${p.name}
+
Kiosk Page
+
+
+ Preview + +
+
+ `).join(''); + + // Delete handler + grid.querySelectorAll('[data-delete-kiosk]').forEach(btn => { + btn.onclick = async (e) => { + e.stopPropagation(); + const name = btn.dataset.kioskName; + if (!confirm(`Delete kiosk page "${name}"? This cannot be undone.`)) return; + try { + await API(`/kiosk/${btn.dataset.deleteKiosk}`, { method: 'DELETE' }); + showToast('Kiosk page deleted'); + renderList(container); + } catch (err) { + showToast(err.message || 'Failed to delete', 'error'); + } + }; + }); + } catch (err) { showToast(err.message, 'error'); } +} + +async function renderEditor(container, pageId) { + let page; + try { page = await API(`/kiosk/${pageId}`); } catch { container.innerHTML = '

Page not found

'; return; } + + let config = JSON.parse(page.config || '{}'); + if (!config.buttons) config.buttons = []; + if (!config.style) config.style = {}; + + container.innerHTML = ` + + + Back to Kiosk Pages + + +
+ +
+ +
+ +
+
+

Page Settings

+
+
+
+
+
+
+
+ +
+

Style

+
+
+
+
+
+
+ +
+
+

Buttons

+ +
+
+
+
+
+ `; + + function renderButtons() { + const list = document.getElementById('buttonList'); + list.innerHTML = config.buttons.map((btn, i) => ` +
+
+ + +
+ +
+ + +
+ +
+ `).join('') || '

No buttons yet

'; + + // Bind inputs + list.querySelectorAll('[data-btn]').forEach(input => { + input.oninput = () => { + const idx = parseInt(input.dataset.btn); + const field = input.dataset.field; + if (field === 'url' && config.buttons[idx].action === 'page') config.buttons[idx].page = input.value; + else config.buttons[idx][field] = input.tagName === 'SELECT' ? input.value : input.value; + }; + }); + list.querySelectorAll('[data-remove-btn]').forEach(btn => { + btn.onclick = () => { config.buttons.splice(parseInt(btn.dataset.removeBtn), 1); renderButtons(); }; + }); + } + + document.getElementById('addBtnBtn').onclick = () => { + config.buttons.push({ label: 'New Button', sublabel: '', icon: '⭐', action: '', url: '' }); + renderButtons(); + }; + + document.getElementById('saveKioskBtn').onclick = async () => { + config.title = document.getElementById('kTitle').value; + config.subtitle = document.getElementById('kSubtitle').value; + config.logoUrl = document.getElementById('kLogo').value; + config.footer = document.getElementById('kFooter').value; + config.idleTitle = document.getElementById('kIdleTitle').value; + config.idleTimeout = parseInt(document.getElementById('kIdleTimeout').value) || 60; + config.style = { + ...config.style, + background: document.getElementById('kBg').value, + textColor: document.getElementById('kTextColor').value, + columns: parseInt(document.getElementById('kColumns').value), + buttonBg: document.getElementById('kBtnBg').value, + buttonHover: document.getElementById('kBtnHover').value, + }; + + try { + await API(`/kiosk/${pageId}`, { method: 'PUT', body: JSON.stringify({ config }) }); + showToast('Kiosk page saved', 'success'); + document.getElementById('kioskPreview').src = `/api/kiosk/${pageId}/render?t=${Date.now()}`; + } catch (err) { showToast(err.message, 'error'); } + }; + + renderButtons(); +} + +export function cleanup() {} diff --git a/frontend/js/views/layout-editor.js b/frontend/js/views/layout-editor.js new file mode 100644 index 0000000..5876b0b --- /dev/null +++ b/frontend/js/views/layout-editor.js @@ -0,0 +1,309 @@ +import { api } from '../api.js'; +import { showToast } from '../components/toast.js'; + +const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(r => r.json()); + +export async function render(container) { + const hash = window.location.hash; + if (hash.startsWith('#/layout/')) { + const id = hash.split('#/layout/')[1]; + return renderEditor(container, id); + } + return renderList(container); +} + +async function renderList(container) { + container.innerHTML = ` + +

Templates

+
+

My Layouts

+
+ `; + + document.getElementById('newLayoutBtn').onclick = async () => { + const name = prompt('Layout name:'); + if (!name) return; + const layout = await API('/layouts', { method: 'POST', body: JSON.stringify({ name, zones: [{ name: 'Main', x_percent: 0, y_percent: 0, width_percent: 100, height_percent: 100 }] }) }); + window.location.hash = `#/layout/${layout.id}`; + }; + + try { + const layouts = await API('/layouts'); + const templates = layouts.filter(l => l.is_template); + const custom = layouts.filter(l => !l.is_template); + + document.getElementById('templateGrid').innerHTML = templates.map(l => renderLayoutCard(l, true)).join(''); + document.getElementById('layoutGrid').innerHTML = custom.length ? custom.map(l => renderLayoutCard(l, false)).join('') : + '

No custom layouts yet

'; + + // Use template click + container.querySelectorAll('[data-use-template]').forEach(btn => { + btn.onclick = async () => { + const layout = await API(`/layouts/${btn.dataset.useTemplate}/duplicate`, { method: 'POST', body: '{}' }); + window.location.hash = `#/layout/${layout.id}`; + }; + }); + + // Edit layout click + container.querySelectorAll('[data-edit-layout]').forEach(btn => { + btn.onclick = () => { window.location.hash = `#/layout/${btn.dataset.editLayout}`; }; + }); + + // Delete layout click + container.querySelectorAll('[data-delete-layout]').forEach(btn => { + btn.onclick = async (e) => { + e.stopPropagation(); + const name = btn.dataset.layoutName; + if (!confirm(`Delete layout "${name}"? This cannot be undone.`)) return; + try { + await API(`/layouts/${btn.dataset.deleteLayout}`, { method: 'DELETE' }); + showToast('Layout deleted'); + renderList(container); + } catch (err) { + showToast(err.message || 'Failed to delete layout', 'error'); + } + }; + }); + } catch (err) { + showToast(err.message, 'error'); + } +} + +function renderLayoutCard(layout, isTemplate) { + return ` +
+
+
+ ${(layout.zones || []).map(z => ` +
${z.name}
+ `).join('')} +
+
+
+
${layout.name}
+
${layout.zones?.length || 0} zone(s) ${isTemplate ? '• Template' : ''}
+
+
+ ${isTemplate + ? `` + : `` + } + +
+
+ `; +} + +async function renderEditor(container, layoutId) { + let layout; + try { + layout = await API(`/layouts/${layoutId}`); + } catch { container.innerHTML = '

Layout not found

'; return; } + + container.innerHTML = ` + + + Back to Layouts + + +
+
+
+
+ +
+
+
+
+

Zones

+
+ +
+
+ `; + + let zones = layout.zones || []; + let selectedZone = null; + let dragging = null; + + function renderZones() { + const canvas = document.getElementById('canvas'); + // Clear only zone divs + canvas.querySelectorAll('.zone-el').forEach(z => z.remove()); + + zones.forEach((z, i) => { + const el = document.createElement('div'); + el.className = 'zone-el'; + el.dataset.index = i; + el.style.cssText = `position:absolute;left:${z.x_percent}%;top:${z.y_percent}%;width:${z.width_percent}%;height:${z.height_percent}%; + background:${selectedZone === i ? 'rgba(59,130,246,0.3)' : 'rgba(59,130,246,0.1)'}; + border:2px solid ${selectedZone === i ? 'var(--accent)' : 'rgba(59,130,246,0.4)'}; + cursor:move;display:flex;align-items:center;justify-content:center;font-size:12px;color:var(--text-secondary); + user-select:none;z-index:${z.z_index || 0}`; + el.textContent = z.name; + + // Drag to move + el.onmousedown = (e) => { + if (e.target !== el) return; + e.preventDefault(); + selectedZone = i; + renderZones(); + updateProperties(); + const rect = canvas.getBoundingClientRect(); + const startX = e.clientX; + const startY = e.clientY; + const origX = z.x_percent; + const origY = z.y_percent; + + const onMove = (e2) => { + const dx = (e2.clientX - startX) / rect.width * 100; + const dy = (e2.clientY - startY) / rect.height * 100; + z.x_percent = Math.max(0, Math.min(100 - z.width_percent, Math.round((origX + dx) * 10) / 10)); + z.y_percent = Math.max(0, Math.min(100 - z.height_percent, Math.round((origY + dy) * 10) / 10)); + el.style.left = z.x_percent + '%'; + el.style.top = z.y_percent + '%'; + updateProperties(); + }; + const onUp = () => { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + }; + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }; + + // Resize handle + const handle = document.createElement('div'); + handle.style.cssText = 'position:absolute;right:0;bottom:0;width:12px;height:12px;cursor:se-resize;background:var(--accent);border-radius:2px 0 0 0;opacity:0.7'; + handle.onmousedown = (e) => { + e.preventDefault(); + e.stopPropagation(); + selectedZone = i; + const rect = canvas.getBoundingClientRect(); + const onMove = (e2) => { + const newW = ((e2.clientX - rect.left) / rect.width * 100) - z.x_percent; + const newH = ((e2.clientY - rect.top) / rect.height * 100) - z.y_percent; + z.width_percent = Math.max(5, Math.min(100 - z.x_percent, Math.round(newW * 10) / 10)); + z.height_percent = Math.max(5, Math.min(100 - z.y_percent, Math.round(newH * 10) / 10)); + el.style.width = z.width_percent + '%'; + el.style.height = z.height_percent + '%'; + updateProperties(); + }; + const onUp = () => { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + }; + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }; + el.appendChild(handle); + canvas.appendChild(el); + }); + + // Zone list sidebar + document.getElementById('zoneList').innerHTML = zones.map((z, i) => ` +
+
${z.name}
+
${Math.round(z.width_percent)}% x ${Math.round(z.height_percent)}% • ${z.zone_type}
+
+ `).join(''); + + document.querySelectorAll('[data-zone-idx]').forEach(el => { + el.onclick = () => { selectedZone = parseInt(el.dataset.zoneIdx); renderZones(); updateProperties(); }; + }); + } + + function updateProperties() { + const panel = document.getElementById('zoneProperties'); + if (selectedZone === null || !zones[selectedZone]) { panel.style.display = 'none'; return; } + panel.style.display = 'block'; + const z = zones[selectedZone]; + document.getElementById('propName').value = z.name; + document.getElementById('propX').value = z.x_percent; + document.getElementById('propY').value = z.y_percent; + document.getElementById('propW').value = z.width_percent; + document.getElementById('propH').value = z.height_percent; + document.getElementById('propType').value = z.zone_type; + } + + // Property input handlers + ['propName', 'propX', 'propY', 'propW', 'propH', 'propType'].forEach(id => { + document.getElementById(id).oninput = () => { + if (selectedZone === null) return; + const z = zones[selectedZone]; + z.name = document.getElementById('propName').value; + z.x_percent = parseFloat(document.getElementById('propX').value) || 0; + z.y_percent = parseFloat(document.getElementById('propY').value) || 0; + z.width_percent = parseFloat(document.getElementById('propW').value) || 10; + z.height_percent = parseFloat(document.getElementById('propH').value) || 10; + z.zone_type = document.getElementById('propType').value; + renderZones(); + }; + }); + + document.getElementById('addZoneBtn').onclick = () => { + zones.push({ id: null, name: `Zone ${zones.length + 1}`, x_percent: 10, y_percent: 10, width_percent: 30, height_percent: 30, z_index: 0, zone_type: 'content', fit_mode: 'cover', background_color: '#000000', sort_order: zones.length }); + selectedZone = zones.length - 1; + renderZones(); + updateProperties(); + }; + + document.getElementById('deleteZoneBtn').onclick = () => { + if (selectedZone === null) return; + zones.splice(selectedZone, 1); + selectedZone = null; + renderZones(); + updateProperties(); + }; + + document.getElementById('saveLayoutBtn').onclick = async () => { + try { + // Delete existing zones and recreate + for (const z of layout.zones || []) { + await API(`/layouts/${layoutId}/zones/${z.id}`, { method: 'DELETE' }); + } + for (const z of zones) { + await API(`/layouts/${layoutId}/zones`, { method: 'POST', body: JSON.stringify(z) }); + } + showToast('Layout saved', 'success'); + layout = await API(`/layouts/${layoutId}`); + zones = layout.zones; + } catch (err) { + showToast(err.message, 'error'); + } + }; + + renderZones(); +} + +export function cleanup() {} diff --git a/frontend/js/views/login.js b/frontend/js/views/login.js new file mode 100644 index 0000000..a86e4d2 --- /dev/null +++ b/frontend/js/views/login.js @@ -0,0 +1,292 @@ +import { showToast } from '../components/toast.js'; + +let authConfig = null; + +async function loadAuthConfig() { + if (authConfig) return authConfig; + const res = await fetch('/api/auth/config'); + authConfig = await res.json(); + return authConfig; +} + +export async function render(container) { + const config = await loadAuthConfig(); + const isSetup = config.needsSetup; + + container.innerHTML = ` +
+
+
+ + + + + +

ScreenTinker

+

+ ${isSetup ? 'Create your admin account to get started' : 'Sign in to manage your displays'} +

+ ${isSetup ? '' : '

New accounts get a 14-day free Pro trial

'} +
+ +
+ +
+
+ + +
+
+ + +
+ ${isSetup ? ` +
+ + +
+ ` : ''} + + ${!isSetup ? ` + + ` : ''} +
+ + + + + ${config.googleEnabled || config.microsoftEnabled ? ` +
+
+ OR +
+
+ ` : ''} + + ${config.googleEnabled ? ` +
+ +
+ ` : ''} + + ${config.microsoftEnabled ? ` + + ` : ''} +
+ + +
+ Support Access +
+ + +
+
+ + +

+ Terms of Service +  ·  + Privacy Policy +

+
+
+ `; + + setupHandlers(config, isSetup); +} + +function setupHandlers(config, isSetup) { + const showError = (msg) => { + const el = document.getElementById('loginError'); + el.textContent = msg; + el.style.display = 'block'; + }; + + // Support token login + document.getElementById('supportLoginBtn')?.addEventListener('click', async () => { + const token = document.getElementById('supportToken')?.value.trim(); + if (!token) { showError('Paste a support token'); return; } + try { + const res = await fetch('/api/auth/support', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token }) + }); + const data = await res.json(); + if (!res.ok) { showError(data.error); return; } + onAuthSuccess(data); + } catch (err) { showError('Support login failed'); } + }); + + // Local login/register + if (isSetup) { + document.getElementById('loginBtn')?.addEventListener('click', () => doRegister(true)); + } else { + document.getElementById('loginBtn')?.addEventListener('click', doLogin); + document.getElementById('showRegisterBtn')?.addEventListener('click', () => { + document.getElementById('localAuthForm').style.display = 'none'; + document.getElementById('registerForm').style.display = 'block'; + }); + document.getElementById('showLoginBtn')?.addEventListener('click', () => { + document.getElementById('localAuthForm').style.display = 'block'; + document.getElementById('registerForm').style.display = 'none'; + }); + document.getElementById('registerBtn')?.addEventListener('click', () => doRegister(false)); + } + + // Enter key on password field + document.getElementById('loginPassword')?.addEventListener('keydown', (e) => { + if (e.key === 'Enter') isSetup ? doRegister(true) : doLogin(); + }); + + async function doLogin() { + const email = document.getElementById('loginEmail').value.trim(); + const password = document.getElementById('loginPassword').value; + if (!email || !password) { showError('Email and password required'); return; } + + try { + const res = await fetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }) + }); + const data = await res.json(); + if (!res.ok) { showError(data.error); return; } + onAuthSuccess(data); + } catch (err) { + showError('Login failed'); + } + } + + async function doRegister(isFirstUser) { + const email = document.getElementById(isFirstUser ? 'loginEmail' : 'regEmail').value.trim(); + const password = document.getElementById(isFirstUser ? 'loginPassword' : 'regPassword').value; + const name = document.getElementById(isFirstUser ? 'loginName' : 'regName')?.value.trim() || ''; + if (!email || !password) { showError('Email and password required'); return; } + if (password.length < 6) { showError('Password must be at least 6 characters'); return; } + + try { + const res = await fetch('/api/auth/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password, name }) + }); + const data = await res.json(); + if (!res.ok) { showError(data.error); return; } + onAuthSuccess(data); + } catch (err) { + showError('Registration failed'); + } + } + + // Google Sign-In + if (config.googleEnabled) { + document.getElementById('googleSignInBtn')?.addEventListener('click', async () => { + try { + // Use Google's popup-based sign in + const client = google.accounts.oauth2.initTokenClient({ + client_id: config.googleClientId, + scope: 'email profile', + callback: async (response) => { + if (response.access_token) { + // Get ID token via Google's tokeninfo + const tokenRes = await fetch(`https://oauth2.googleapis.com/tokeninfo?access_token=${response.access_token}`); + const tokenData = await tokenRes.json(); + // Send to our server + const res = await fetch('/api/auth/google', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ credential: response.access_token, email: tokenData.email }) + }); + const data = await res.json(); + if (res.ok) onAuthSuccess(data); + else showError(data.error); + } + } + }); + client.requestAccessToken(); + } catch (err) { + showError('Google sign-in failed'); + } + }); + } + + // Microsoft Sign-In + if (config.microsoftEnabled) { + document.getElementById('microsoftSignInBtn')?.addEventListener('click', async () => { + try { + const msalConfig = { + auth: { + clientId: config.microsoftClientId, + authority: `https://login.microsoftonline.com/${config.microsoftTenantId}`, + redirectUri: window.location.origin + } + }; + const msalInstance = new msal.PublicClientApplication(msalConfig); + await msalInstance.initialize(); + const loginResponse = await msalInstance.loginPopup({ scopes: ['User.Read'] }); + if (loginResponse.accessToken) { + const res = await fetch('/api/auth/microsoft', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ access_token: loginResponse.accessToken }) + }); + const data = await res.json(); + if (res.ok) onAuthSuccess(data); + else showError(data.error); + } + } catch (err) { + showError('Microsoft sign-in failed'); + } + }); + } +} + +function onAuthSuccess(data) { + localStorage.setItem('token', data.token); + localStorage.setItem('user', JSON.stringify(data.user)); + window.location.hash = '#/'; + window.location.reload(); +} + +export function cleanup() {} diff --git a/frontend/js/views/onboarding.js b/frontend/js/views/onboarding.js new file mode 100644 index 0000000..fa98095 --- /dev/null +++ b/frontend/js/views/onboarding.js @@ -0,0 +1,241 @@ +import { showToast } from '../components/toast.js'; + +const STEPS = [ + { + title: 'Welcome to ScreenTinker!', + icon: '👋', + content: `

Let's get you set up in under 5 minutes.

+

This wizard will guide you through:

+
    +
  • Downloading the player app
  • +
  • Pairing your first display
  • +
  • Uploading and assigning content
  • +
`, + action: null + }, + { + title: 'Step 1: Get the Player App', + icon: '📥', + content: `

Install the player on your display device.

+ +

Open the app on your display and enter this server URL:

+ ${window.location.origin}`, + action: null + }, + { + title: 'Step 2: Pair Your Display', + icon: '🔗', + content: `

Enter the 6-digit code shown on your display.

+
+ +
+
+ +
+

`, + action: 'pair' + }, + { + title: 'Step 3: Upload Content', + icon: '📤', + content: `

Upload a video or image to display.

+
+
📁
+

Click to select a file

+

MP4, WebM, JPEG, PNG, GIF

+ +
+ `, + action: 'upload' + }, + { + title: "You're All Set!", + icon: '🎉', + content: `

Your display is paired and content is playing!

+
+

What's next?

+
    +
  • Add more content in the Content Library
  • +
  • Create multi-zone layouts in Layouts
  • +
  • Set up a schedule in the Schedule calendar
  • +
  • Add live widgets (clock, weather, ticker) in Widgets
  • +
  • Create interactive screens in Kiosk
  • +
  • Design custom content in the Designer
  • +
+
`, + action: null + } +]; + +export function render(container) { + let currentStep = 0; + let pairedDeviceId = null; + + function renderStep() { + const step = STEPS[currentStep]; + const isFirst = currentStep === 0; + const isLast = currentStep === STEPS.length - 1; + + container.innerHTML = ` +
+
+ +
+ ${STEPS.map((_, i) => `
`).join('')} +
+ +
+
${step.icon}
+

${step.title}

+
+ +
+ ${step.content} +
+ +
+ ${isFirst ? '
' : ``} +
+ ${!isLast ? `` : ''} + +
+
+
+
+ `; + + // Bind buttons + document.getElementById('prevBtn')?.addEventListener('click', () => { currentStep--; renderStep(); }); + document.getElementById('skipBtn')?.addEventListener('click', () => { + localStorage.setItem('rd_onboarded', 'true'); + window.location.hash = '#/'; + window.location.reload(); + }); + document.getElementById('nextBtn')?.addEventListener('click', handleNext); + + // Step-specific setup + if (step.action === 'upload') { + const area = document.getElementById('onboardUploadArea'); + const input = document.getElementById('onboardFileInput'); + area?.addEventListener('click', () => input.click()); + input?.addEventListener('change', handleUpload); + } + } + + async function handleNext() { + const step = STEPS[currentStep]; + + if (step.action === 'pair') { + const code = document.getElementById('onboardPairingCode')?.value.trim(); + const name = document.getElementById('onboardDeviceName')?.value.trim(); + const status = document.getElementById('onboardPairStatus'); + + if (!code || code.length !== 6) { + if (status) status.textContent = 'Enter a valid 6-digit code'; + return; + } + + try { + if (status) status.textContent = 'Pairing...'; + const token = localStorage.getItem('token'); + const res = await fetch('/api/provision/pair', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify({ pairing_code: code, name: name || undefined }) + }); + const data = await res.json(); + if (!res.ok) { if (status) status.textContent = data.error || 'Pairing failed'; return; } + pairedDeviceId = data.id; + showToast('Display paired!', 'success'); + currentStep++; + renderStep(); + } catch (err) { + if (status) status.textContent = 'Pairing failed: ' + err.message; + } + return; + } + + if (currentStep === STEPS.length - 1) { + localStorage.setItem('rd_onboarded', 'true'); + window.location.hash = '#/'; + window.location.reload(); + return; + } + + currentStep++; + renderStep(); + } + + async function handleUpload() { + const file = document.getElementById('onboardFileInput')?.files[0]; + if (!file) return; + + const progress = document.getElementById('onboardUploadProgress'); + const bar = document.getElementById('onboardProgressBar'); + const text = document.getElementById('onboardUploadText'); + if (progress) progress.style.display = 'block'; + + try { + const token = localStorage.getItem('token'); + const formData = new FormData(); + formData.append('file', file); + + const xhr = new XMLHttpRequest(); + xhr.open('POST', '/api/content'); + xhr.setRequestHeader('Authorization', `Bearer ${token}`); + xhr.upload.onprogress = (e) => { + if (e.lengthComputable && bar) bar.style.width = Math.round((e.loaded / e.total) * 100) + '%'; + }; + xhr.onload = async () => { + if (xhr.status >= 200 && xhr.status < 300) { + const content = JSON.parse(xhr.responseText); + if (text) text.textContent = 'Uploaded! Assigning to display...'; + + // Auto-assign to paired device + if (pairedDeviceId) { + try { + await fetch(`/api/assignments/device/${pairedDeviceId}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify({ content_id: content.id, duration_sec: 10 }) + }); + } catch {} + } + + showToast('Content uploaded and assigned!', 'success'); + currentStep++; + renderStep(); + } else { + if (text) text.textContent = 'Upload failed'; + } + }; + xhr.onerror = () => { if (text) text.textContent = 'Upload failed'; }; + xhr.send(formData); + } catch (err) { + if (text) text.textContent = 'Error: ' + err.message; + } + } + + renderStep(); +} + +export function cleanup() {} diff --git a/frontend/js/views/reports.js b/frontend/js/views/reports.js new file mode 100644 index 0000000..6da4636 --- /dev/null +++ b/frontend/js/views/reports.js @@ -0,0 +1,188 @@ +import { api } from '../api.js'; +import { showToast } from '../components/toast.js'; + +const API = (url, opts = {}) => fetch('/api' + url, { headers: { Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(r => r.json()); + +export async function render(container) { + const devices = await api.getDevices(); + const today = new Date(); + const thirtyDaysAgo = new Date(today); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); + + container.innerHTML = ` + + +
+
+ +
+
+ +
+
+ +
+ +
+ +

Select a date range and click Load Report

+ `; + + document.getElementById('loadReportBtn').onclick = loadReport; + loadReport(); // Auto-load on page render + document.getElementById('exportBtn').onclick = () => { + const deviceId = document.getElementById('reportDevice').value; + const start = document.getElementById('reportStart').value; + const end = document.getElementById('reportEnd').value; + const token = localStorage.getItem('token'); + window.open(`/api/reports/export?device_id=${deviceId}&start=${start}&end=${end}&token=${token}`, '_blank'); + }; + + async function loadReport() { + const deviceId = document.getElementById('reportDevice').value; + const start = document.getElementById('reportStart').value; + const end = document.getElementById('reportEnd').value; + const content = document.getElementById('reportContent'); + + content.innerHTML = '

Loading...

'; + + try { + const summary = await API(`/reports/summary?device_id=${deviceId}&start=${start}&end=${end}`); + + content.innerHTML = ` + +
+
+
Total Plays
+
${summary.overall.total_plays.toLocaleString()}
+
+
+
Total Hours
+
${summary.overall.total_hours}
+
+
+
Unique Content
+
${summary.overall.unique_content}
+
+
+
Active Devices
+
${summary.overall.unique_devices}
+
+
+
Avg Duration
+
${formatDuration(summary.overall.avg_duration_sec)}
+
+
+ +
+ +
+

Plays per Day

+
+
+ + +
+

Plays by Hour

+
+
+
+ + +
+

Top Content

+ + + + + + + + + ${summary.by_content.map(c => ` + + + + + + + `).join('') || ''} + +
ContentPlaysTotal HoursCompletion
${c.content_name || 'Unknown'}${c.plays}${(c.total_seconds / 3600).toFixed(1)}${c.plays > 0 ? Math.round((c.completed_plays / c.plays) * 100) : 0}%
No data
+
+ + +
+

By Device

+ + + + + + + + ${summary.by_device.map(d => ` + + + + + + `).join('') || ''} + +
DevicePlaysTotal Hours
${d.device_name}${d.plays}${(d.total_seconds / 3600).toFixed(1)}
No data
+
+ `; + + // Render daily chart + renderBarChart('dailyChart', summary.by_day.map(d => ({ + label: new Date(d.day).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }), + value: d.plays + }))); + + // Render hourly chart + const hourData = Array.from({ length: 24 }, (_, i) => { + const found = summary.by_hour.find(h => h.hour === i); + return { label: i === 0 ? '12a' : i < 12 ? i + 'a' : i === 12 ? '12p' : (i - 12) + 'p', value: found?.plays || 0 }; + }); + renderBarChart('hourlyChart', hourData); + + } catch (err) { + content.innerHTML = `

Error

${err.message}

`; + } + } +} + +function renderBarChart(containerId, data) { + const container = document.getElementById(containerId); + if (!container || !data.length) return; + + const maxVal = Math.max(...data.map(d => d.value), 1); + + container.innerHTML = data.map(d => ` +
+
${d.value}
+
+
${d.label}
+
+ `).join(''); +} + +function formatDuration(seconds) { + if (!seconds) return '0s'; + if (seconds < 60) return Math.round(seconds) + 's'; + if (seconds < 3600) return Math.round(seconds / 60) + 'm'; + return (seconds / 3600).toFixed(1) + 'h'; +} + +export function cleanup() {} diff --git a/frontend/js/views/schedule.js b/frontend/js/views/schedule.js new file mode 100644 index 0000000..00942a6 --- /dev/null +++ b/frontend/js/views/schedule.js @@ -0,0 +1,200 @@ +import { api } from '../api.js'; +import { showToast } from '../components/toast.js'; + +const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(r => r.json()); + +const HOURS = Array.from({ length: 24 }, (_, i) => i); +const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + +export async function render(container) { + const devices = await api.getDevices(); + const content = await api.getContent(); + const selectedDevice = devices[0]?.id || ''; + + const today = new Date(); + const weekStart = new Date(today); + weekStart.setDate(today.getDate() - today.getDay()); + weekStart.setHours(0, 0, 0, 0); + + container.innerHTML = ` + +
+ + + + + +
+
+
+
+ + + + `; + + let currentWeekStart = new Date(weekStart); + let editingId = null; + + function updateWeekLabel() { + const end = new Date(currentWeekStart); + end.setDate(end.getDate() + 6); + document.getElementById('weekLabel').textContent = + `${currentWeekStart.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} - ${end.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}`; + } + + async function loadCalendar() { + const deviceId = document.getElementById('schedDevice').value; + if (!deviceId) return; + updateWeekLabel(); + + const events = await API(`/schedules/week?date=${currentWeekStart.toISOString()}&device_id=${deviceId}`); + + const cal = document.getElementById('calendar'); + let html = '
'; + + // Day headers + for (let d = 0; d < 7; d++) { + const date = new Date(currentWeekStart); + date.setDate(date.getDate() + d); + const isToday = date.toDateString() === new Date().toDateString(); + html += `
+ ${DAYS[d]}
${date.getDate()} +
`; + } + + // Hour rows + for (const h of HOURS) { + html += `
${h === 0 ? '12am' : h < 12 ? h + 'am' : h === 12 ? '12pm' : (h - 12) + 'pm'}
`; + for (let d = 0; d < 7; d++) { + html += `
`; + } + } + + cal.innerHTML = html; + + // Render events + events.forEach(ev => { + const start = new Date(ev.instance_start || ev.start_time); + const end = new Date(ev.instance_end || ev.end_time); + const dayIdx = start.getDay(); + const startHour = start.getHours() + start.getMinutes() / 60; + const endHour = end.getHours() + end.getMinutes() / 60; + const duration = endHour - startHour; + + const cell = cal.querySelector(`[data-hour="${Math.floor(startHour)}"][data-day="${dayIdx}"]`); + if (!cell) return; + + const block = document.createElement('div'); + const topOffset = (startHour - Math.floor(startHour)) * 28; + block.style.cssText = `position:absolute;top:${topOffset}px;left:2px;right:2px;height:${Math.max(20, duration * 28)}px; + background:${ev.color || '#3B82F6'};border-radius:3px;padding:2px 4px;font-size:10px;color:white;overflow:hidden;cursor:pointer;z-index:1;opacity:0.85`; + block.textContent = ev.title || ev.content_name || ev.widget_name || 'Scheduled'; + block.title = `${start.toLocaleTimeString()} - ${end.toLocaleTimeString()}`; + block.onclick = () => editSchedule(ev); + cell.appendChild(block); + }); + } + + function editSchedule(ev) { + editingId = ev.id; + document.getElementById('schedModalTitle').textContent = 'Edit Schedule'; + document.getElementById('schedContent').value = ev.content_id || ''; + document.getElementById('schedTitle').value = ev.title || ''; + const start = new Date(ev.start_time); + const end = new Date(ev.end_time); + document.getElementById('schedStart').value = `${String(start.getHours()).padStart(2,'0')}:${String(start.getMinutes()).padStart(2,'0')}`; + document.getElementById('schedEnd').value = `${String(end.getHours()).padStart(2,'0')}:${String(end.getMinutes()).padStart(2,'0')}`; + document.getElementById('schedRepeat').value = ev.recurrence || ''; + document.getElementById('schedPriority').value = ev.priority || 0; + document.getElementById('schedColor').value = ev.color || '#3B82F6'; + document.getElementById('scheduleModal').style.display = 'flex'; + } + + document.getElementById('addScheduleBtn').onclick = () => { + editingId = null; + document.getElementById('schedModalTitle').textContent = 'Add Schedule'; + document.getElementById('schedTitle').value = ''; + document.getElementById('scheduleModal').style.display = 'flex'; + }; + + document.getElementById('saveScheduleBtn').onclick = async () => { + const deviceId = document.getElementById('schedDevice').value; + const contentId = document.getElementById('schedContent').value; + const startTime = document.getElementById('schedStart').value; + const endTime = document.getElementById('schedEnd').value; + + const today = new Date().toISOString().split('T')[0]; + const data = { + device_id: deviceId, + content_id: contentId, + title: document.getElementById('schedTitle').value, + start_time: `${today}T${startTime}:00`, + end_time: `${today}T${endTime}:00`, + recurrence: document.getElementById('schedRepeat').value || null, + priority: parseInt(document.getElementById('schedPriority').value) || 0, + color: document.getElementById('schedColor').value, + }; + + try { + if (editingId) { + await API(`/schedules/${editingId}`, { method: 'PUT', body: JSON.stringify(data) }); + } else { + await API('/schedules', { method: 'POST', body: JSON.stringify(data) }); + } + document.getElementById('scheduleModal').style.display = 'none'; + showToast('Schedule saved', 'success'); + loadCalendar(); + } catch (err) { + showToast(err.message, 'error'); + } + }; + + document.getElementById('schedDevice').onchange = loadCalendar; + document.getElementById('prevWeek').onclick = () => { currentWeekStart.setDate(currentWeekStart.getDate() - 7); loadCalendar(); }; + document.getElementById('nextWeek').onclick = () => { currentWeekStart.setDate(currentWeekStart.getDate() + 7); loadCalendar(); }; + + loadCalendar(); +} + +export function cleanup() {} diff --git a/frontend/js/views/settings.js b/frontend/js/views/settings.js new file mode 100644 index 0000000..c56c09a --- /dev/null +++ b/frontend/js/views/settings.js @@ -0,0 +1,457 @@ +import { api } from '../api.js'; +import { showToast } from '../components/toast.js'; +import { getLanguage, setLanguage, getAvailableLanguages } from '../i18n.js'; + +export async function render(container) { + const serverUrl = `${window.location.protocol}//${window.location.host}`; + const user = JSON.parse(localStorage.getItem('user') || '{}'); + const isSuperAdmin = user.role === 'superadmin'; + const isAdmin = user.role === 'admin' || isSuperAdmin; + + container.innerHTML = ` + + + ${isAdmin ? ` +
+

License

+

MIT License - all features included.

+
+ + ${isSuperAdmin ? '

Platform admin tools are in the Admin page.

' : ''} + +
+

User Management

+

Loading users...

+
+ +
+

White Label / Branding

+
+

Customize the look of your dashboard and player for your clients.

+
+
+
+
+
+
+
+
+
+
+ + +
+
+ ` : ''} + +
+

Server Information

+
+
+
Server URL
+
${serverUrl}
+

Use this URL when setting up the Android app

+
+
+
API Endpoint
+
${serverUrl}/api
+
+
+
+ +
+

Player Downloads

+
+
+
🤖
+
Android APK
+
Apolosign, Fire TV, any Android device
+ Download APK +
+
+
🌐
+
Web Player
+
Any browser, ChromeOS, Smart TVs
+ Open Player +
+
+
🥏
+
Raspberry Pi
+
Auto-start kiosk mode on Pi OS
+ Download Script +
curl -sSL ${serverUrl}/scripts/raspberry-pi-setup.sh | bash
+
+
+
💻
+
Windows
+
Chrome kiosk mode on Windows
+ Download Script +
+
+
📺
+
LG webOS / Samsung Tizen
+
Use the TV's built-in browser
+
Navigate to:
${serverUrl}/player
+
+
+
+ +
+

Setup Guide

+
+
    +
  1. Install the ScreenTinker APK on your Apolosign portable TV via sideloading
  2. +
  3. Open the app and enter this server URL: ${serverUrl}
  4. +
  5. The app will display a 6-digit pairing code
  6. +
  7. Click "Add Display" on the dashboard and enter the pairing code
  8. +
  9. Upload content in the Content Library
  10. +
  11. Assign content to the display's Playlist
  12. +
+
+
+ + ${isAdmin ? ` + ` : ''} + +
+

Your Data

+

Export or import your devices, content, layouts, schedules, and all settings. Use this to migrate between cloud and self-hosted instances.

+
+ + + + +
+ +
+ +
+

Language

+ +
+ +
+

About

+
+

ScreenTinker v1.4.1

+

Digital signage management system.

+

+ Terms of Service +  ·  + Privacy Policy +  ·  + Third-Party Licenses +

+
+
+ `; + + if (isAdmin) { + loadUsers(); + loadWhiteLabel(); + + // Support token generator + document.getElementById('generateSupportBtn')?.addEventListener('click', async () => { + const org = document.getElementById('supportOrg').value.trim() || 'Customer'; + const hours = parseInt(document.getElementById('supportHours').value) || 4; + try { + const token = localStorage.getItem('token'); + const res = await fetch('/api/auth/support/generate', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify({ org, hours, reason: 'Support session' }) + }); + const data = await res.json(); + if (res.ok) { + document.getElementById('supportTokenOutput').value = data.token; + document.getElementById('supportTokenResult').style.display = 'block'; + showToast(`Support token generated (valid ${hours}h)`, 'success'); + } else showToast(data.error, 'error'); + } catch (err) { showToast(err.message, 'error'); } + }); + } + + // Export data handler + document.getElementById('exportDataBtn')?.addEventListener('click', () => { + const includeFiles = document.getElementById('exportIncludeFiles')?.checked; + const token = localStorage.getItem('token'); + const url = `/api/status/export?token=${token}${includeFiles ? '&include_files=true' : ''}`; + window.location.href = url; + }); + + // Import data handler + document.getElementById('importDataBtn')?.addEventListener('click', () => { + document.getElementById('importFileInput').click(); + }); + document.getElementById('importFileInput')?.addEventListener('change', async (e) => { + const file = e.target.files[0]; + if (!file) return; + const isZip = file.name.endsWith('.zip') || file.type === 'application/zip'; + const statusEl = document.getElementById('importStatus'); + statusEl.style.display = 'block'; + statusEl.style.background = 'var(--bg-secondary)'; + statusEl.style.border = '1px solid var(--border)'; + statusEl.style.color = 'var(--text-secondary)'; + statusEl.textContent = 'Reading file...'; + try { + let data; + if (isZip) { + // For ZIP, show basic info and skip preview parsing + data = { format: 'screentinker-export-v1', _isZip: true }; + statusEl.innerHTML = `ZIP export detected: ${file.name} (${(file.size / 1048576).toFixed(1)} MB)
Contains data + media files.

`; + } else { + const text = await file.text(); + data = JSON.parse(text); + if (!data.format || !data.format.startsWith('screentinker-export')) { + statusEl.style.color = 'var(--danger)'; + statusEl.textContent = 'Invalid file. Must be a ScreenTinker export JSON or ZIP.'; + return; + } + const summary = [ + data.devices?.length ? `${data.devices.length} devices` : null, + data.content?.length ? `${data.content.length} content items` : null, + data.widgets?.length ? `${data.widgets.length} widgets` : null, + data.layouts?.length ? `${data.layouts.length} layouts` : null, + data.schedules?.length ? `${data.schedules.length} schedules` : null, + data.video_walls?.length ? `${data.video_walls.length} video walls` : null, + data.kiosk_pages?.length ? `${data.kiosk_pages.length} kiosk pages` : null, + ].filter(Boolean).join(', '); + statusEl.innerHTML = `Found: ${summary || 'empty export'}.
From: ${data.user?.email || 'unknown'} (exported ${data.exported_at?.split('T')[0] || 'unknown'})

`; + } + document.getElementById('cancelImportBtn').onclick = () => { statusEl.style.display = 'none'; e.target.value = ''; }; + document.getElementById('confirmImportBtn').onclick = async () => { + statusEl.innerHTML = isZip ? 'Uploading and importing... This may take a moment for large files.' : 'Importing...'; + try { + const token = localStorage.getItem('token'); + let res; + if (isZip) { + const formData = new FormData(); + formData.append('file', file); + res = await fetch('/api/status/import', { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + body: formData, + }); + } else { + res = await fetch('/api/status/import', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify(data), + }); + } + const result = await res.json(); + if (res.ok) { + const imported = Object.entries(result.stats).filter(([k,v]) => v > 0 && k !== 'files_restored').map(([k,v]) => `${v} ${k}`).join(', '); + statusEl.style.color = 'var(--success)'; + let html = `Import complete: ${imported}.`; + if (result.device_pairings?.length) { + html += `

Device Pairing Codes:
` + + result.device_pairings.map(d => ``).join('') + + `
${d.name}${d.pairing_code}

Enter these codes on each device to re-link them. All assignments and schedules will be preserved.`; + } + html += `

${(result.notes || []).map(n => '• ' + n).join('
')}`; + statusEl.innerHTML = html; + showToast('Data imported successfully', 'success'); + } else { + statusEl.style.color = 'var(--danger)'; + statusEl.textContent = result.error || 'Import failed'; + } + } catch (err) { + statusEl.style.color = 'var(--danger)'; + statusEl.textContent = 'Import failed: ' + err.message; + } + e.target.value = ''; + }; + } catch (err) { + statusEl.style.color = 'var(--danger)'; + statusEl.textContent = 'Failed to read file: ' + err.message; + } + }); + + document.getElementById('langSelect')?.addEventListener('change', (e) => { + setLanguage(e.target.value); + showToast('Language changed. Refresh for full effect.', 'info'); + }); +} + +async function loadWhiteLabel() { + const token = localStorage.getItem('token'); + const headers = { Authorization: `Bearer ${token}` }; + + // Only show white-label for enterprise/superadmin + const user = JSON.parse(localStorage.getItem('user') || '{}'); + const section = document.getElementById('whiteLabelSection'); + if (section && user.plan_id !== 'enterprise' && user.role !== 'superadmin') { + section.innerHTML = ` +

White Label / Branding

+
+

Custom branding is available on the Enterprise plan

+ View Plans +
+ `; + return; + } + + try { + const res = await fetch('/api/white-label', { headers }); + const wl = await res.json(); + + if (wl.brand_name) document.getElementById('wlBrandName').value = wl.brand_name; + if (wl.logo_url) document.getElementById('wlLogoUrl').value = wl.logo_url; + if (wl.primary_color) document.getElementById('wlPrimaryColor').value = wl.primary_color; + if (wl.bg_color) document.getElementById('wlBgColor').value = wl.bg_color; + if (wl.custom_domain) document.getElementById('wlDomain').value = wl.custom_domain; + if (wl.favicon_url) document.getElementById('wlFavicon').value = wl.favicon_url; + if (wl.custom_css) document.getElementById('wlCustomCss').value = wl.custom_css; + if (wl.hide_branding) document.getElementById('wlHideBranding').checked = true; + } catch {} + + document.getElementById('saveWhiteLabelBtn')?.addEventListener('click', async () => { + try { + await fetch('/api/white-label', { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + brand_name: document.getElementById('wlBrandName').value, + logo_url: document.getElementById('wlLogoUrl').value, + primary_color: document.getElementById('wlPrimaryColor').value, + bg_color: document.getElementById('wlBgColor').value, + custom_domain: document.getElementById('wlDomain').value, + favicon_url: document.getElementById('wlFavicon').value, + custom_css: document.getElementById('wlCustomCss').value, + hide_branding: document.getElementById('wlHideBranding').checked ? 1 : 0, + }) + }); + showToast('Branding saved', 'success'); + } catch (err) { + showToast(err.message, 'error'); + } + }); + + document.getElementById('previewWhiteLabelBtn')?.addEventListener('click', () => { + const primary = document.getElementById('wlPrimaryColor').value; + const bg = document.getElementById('wlBgColor').value; + document.documentElement.style.setProperty('--accent', primary); + document.documentElement.style.setProperty('--bg-primary', bg); + showToast('Preview applied (refresh to reset)', 'info'); + }); +} + +async function loadUsers() { + const el = document.getElementById('userManagement'); + if (!el) return; + + try { + const [users, plans] = await Promise.all([ + api.getUsers(), + fetch('/api/subscription/plans').then(r => r.json()) + ]); + + const currentUser = JSON.parse(localStorage.getItem('user') || '{}'); + + el.innerHTML = ` + + + + + + + + + + + + ${users.map(u => ` + + + + + + + + `).join('')} + +
UserAuthRolePlanActions
+
${u.name || u.email}
+
${u.email}
+
+ ${u.auth_provider} + + ${u.role} + + + + ${u.id !== currentUser.id ? `` : 'You'} +
+

${users.length} user(s) registered

+ `; + + // Plan change handlers + el.querySelectorAll('.plan-select').forEach(select => { + select.addEventListener('change', async () => { + const userId = select.dataset.userId; + const planId = select.value; + try { + await api.assignPlan(userId, planId); + showToast('Plan updated', 'success'); + } catch (err) { + showToast(err.message, 'error'); + loadUsers(); // Revert + } + }); + }); + + // Delete user handlers + el.querySelectorAll('.delete-user-btn').forEach(btn => { + let confirming = false; + btn.addEventListener('click', async () => { + if (confirming) { + try { + await api.deleteUser(btn.dataset.userId); + showToast('User removed', 'success'); + loadUsers(); + } catch (err) { + showToast(err.message, 'error'); + } + return; + } + confirming = true; + btn.textContent = 'Confirm?'; + btn.style.background = 'var(--danger)'; + btn.style.color = 'white'; + setTimeout(() => { + confirming = false; + btn.textContent = 'Remove'; + btn.style.background = ''; + btn.style.color = ''; + }, 3000); + }); + }); + + } catch (err) { + el.innerHTML = `

${err.message}

`; + } +} + +export function cleanup() {} diff --git a/frontend/js/views/teams.js b/frontend/js/views/teams.js new file mode 100644 index 0000000..2dc3e76 --- /dev/null +++ b/frontend/js/views/teams.js @@ -0,0 +1,202 @@ +import { api } from '../api.js'; +import { showToast } from '../components/toast.js'; + +const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(r => r.json()); + +export async function render(container) { + const hash = window.location.hash; + if (hash.startsWith('#/team/')) { + const id = hash.split('#/team/')[1]; + return renderTeamDetail(container, id); + } + return renderList(container); +} + +async function renderList(container) { + container.innerHTML = ` + +
+ `; + + document.getElementById('newTeamBtn').onclick = async () => { + const name = prompt('Team name:'); + if (!name) return; + const team = await API('/teams', { method: 'POST', body: JSON.stringify({ name }) }); + window.location.hash = `#/team/${team.id}`; + }; + + try { + const teams = await API('/teams'); + const list = document.getElementById('teamsList'); + + if (!teams.length) { + list.innerHTML = '

No teams yet

Create a team to share devices with other users.

'; + return; + } + + list.innerHTML = `
+ ${teams.map(t => ` +
+
+
+
${t.name[0].toUpperCase()}
+
+
${t.name}
+
Your role: ${t.my_role} · ${t.member_count} member(s)
+
+
+
+
+ `).join('')} +
`; + } catch (err) { showToast(err.message, 'error'); } +} + +async function renderTeamDetail(container, teamId) { + let team, devices, allDevices; + try { + [team, devices, allDevices] = await Promise.all([ + API(`/teams/${teamId}`), + API(`/teams/${teamId}/devices`), + api.getDevices() + ]); + } catch { container.innerHTML = '

Team not found

'; return; } + + const unassignedDevices = allDevices.filter(d => !d.team_id || d.team_id !== teamId); + + container.innerHTML = ` + + + Back to Teams + + + +
+ +
+
+

Members (${team.members?.length || 0})

+ +
+
+ ${(team.members || []).map(m => ` +
+
${(m.user_name || m.email)[0].toUpperCase()}
+
+
${m.user_name || m.email}
+
${m.email}
+
+ + ${m.role !== 'owner' ? `` : ''} +
+ `).join('') || '

No members yet

'} +
+
+ + +
+
+

Shared Devices (${devices.length})

+ +
+
+ ${devices.map(d => ` +
+ +
+
${d.name}
+
${d.status}
+
+ +
+ `).join('') || '

No devices shared with this team

'} +
+
+
+ `; + + // Invite member + document.getElementById('inviteMemberBtn').onclick = async () => { + const email = prompt('Email address to invite:'); + if (!email) return; + const role = prompt('Role (viewer, editor, or owner):', 'editor'); + if (!['viewer', 'editor', 'owner'].includes(role)) { showToast('Invalid role', 'error'); return; } + try { + await API(`/teams/${teamId}/invite`, { method: 'POST', body: JSON.stringify({ email, role }) }); + showToast('Invitation sent', 'success'); + renderTeamDetail(container, teamId); + } catch (err) { showToast(err.message, 'error'); } + }; + + // Change member role + container.querySelectorAll('[data-member-id]').forEach(select => { + select.onchange = async () => { + try { + await API(`/teams/${teamId}/members/${select.dataset.memberId}`, { method: 'PUT', body: JSON.stringify({ role: select.value }) }); + showToast('Role updated', 'success'); + } catch (err) { showToast(err.message, 'error'); } + }; + }); + + // Remove member + container.querySelectorAll('[data-remove-member]').forEach(btn => { + btn.onclick = async () => { + try { + await API(`/teams/${teamId}/members/${btn.dataset.removeMember}`, { method: 'DELETE' }); + showToast('Member removed', 'success'); + renderTeamDetail(container, teamId); + } catch (err) { showToast(err.message, 'error'); } + }; + }); + + // Add device to team + document.getElementById('addDeviceToTeam').onchange = async (e) => { + const deviceId = e.target.value; + if (!deviceId) return; + try { + await API(`/teams/${teamId}/devices`, { method: 'POST', body: JSON.stringify({ device_id: deviceId }) }); + showToast('Device added to team', 'success'); + renderTeamDetail(container, teamId); + } catch (err) { showToast(err.message, 'error'); } + }; + + // Remove device from team + container.querySelectorAll('[data-remove-device]').forEach(btn => { + btn.onclick = async () => { + try { + await API(`/teams/${teamId}/devices/${btn.dataset.removeDevice}`, { method: 'DELETE' }); + showToast('Device removed from team', 'success'); + renderTeamDetail(container, teamId); + } catch (err) { showToast(err.message, 'error'); } + }; + }); + + // Delete team + document.getElementById('deleteTeamBtn').onclick = async () => { + try { + await API(`/teams/${teamId}`, { method: 'DELETE' }); + showToast('Team deleted', 'success'); + window.location.hash = '#/teams'; + } catch (err) { showToast(err.message, 'error'); } + }; +} + +export function cleanup() {} diff --git a/frontend/js/views/video-wall.js b/frontend/js/views/video-wall.js new file mode 100644 index 0000000..a76cbe4 --- /dev/null +++ b/frontend/js/views/video-wall.js @@ -0,0 +1,213 @@ +import { api } from '../api.js'; +import { showToast } from '../components/toast.js'; + +const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(r => r.json()); + +export async function render(container) { + const hash = window.location.hash; + if (hash.startsWith('#/wall/')) { + const id = hash.split('#/wall/')[1]; + return renderWallEditor(container, id); + } + return renderList(container); +} + +async function renderList(container) { + container.innerHTML = ` + +
+ `; + + document.getElementById('newWallBtn').onclick = async () => { + const name = prompt('Video wall name:'); + if (!name) return; + const wall = await API('/walls', { method: 'POST', body: JSON.stringify({ name }) }); + window.location.hash = `#/wall/${wall.id}`; + }; + + try { + const walls = await API('/walls'); + const grid = document.getElementById('wallGrid'); + + if (!walls.length) { + grid.innerHTML = '

No video walls yet

Create a video wall to combine multiple displays.

'; + return; + } + + grid.innerHTML = walls.map(w => ` +
+
+
+ ${Array.from({ length: w.grid_cols * w.grid_rows }, (_, i) => { + const row = Math.floor(i / w.grid_cols); + const col = i % w.grid_cols; + const dev = w.devices?.find(d => d.grid_col === col && d.grid_row === row); + return `
${dev?.device_name?.slice(0, 6) || ''}
`; + }).join('')} +
+
+
+
${w.name}
+
${w.grid_cols}x${w.grid_rows} grid • ${w.devices?.length || 0} devices
+
+
+ `).join(''); + } catch (err) { showToast(err.message, 'error'); } +} + +async function renderWallEditor(container, wallId) { + let wall, devices; + try { + [wall, devices] = await Promise.all([API(`/walls/${wallId}`), api.getDevices()]); + } catch { container.innerHTML = '

Wall not found

'; return; } + + const content = await api.getContent(); + const unassigned = devices.filter(d => !wall.devices?.find(wd => wd.device_id === d.id)); + + container.innerHTML = ` + + + Back to Video Walls + + + +
+
+

Grid Configuration

+
+
+
+
+
+ +
+ +
+ +

Content

+ + +
+ +
+

Available Displays

+
+ ${unassigned.map(d => ` +
+
+
${d.name}
+
${d.status}
+
+
+ `).join('') || '

All devices assigned

'} +
+
+
+ `; + + function renderGrid() { + const cols = parseInt(document.getElementById('gridCols').value) || 2; + const rows = parseInt(document.getElementById('gridRows').value) || 2; + const grid = document.getElementById('wallGrid'); + grid.style.gridTemplateColumns = `repeat(${cols}, 120px)`; + + let html = ''; + for (let r = 0; r < rows; r++) { + for (let c = 0; c < cols; c++) { + const dev = wall.devices?.find(d => d.grid_col === c && d.grid_row === r); + html += ` +
+ ${dev ? `
${dev.device_name}
[${c},${r}]
` : + `
Drop here
[${c},${r}]
`} +
+ `; + } + } + grid.innerHTML = html; + + // Drop targets + grid.querySelectorAll('[data-grid-col]').forEach(cell => { + cell.ondragover = (e) => { e.preventDefault(); cell.style.borderColor = 'var(--success)'; }; + cell.ondragleave = () => { cell.style.borderColor = ''; }; + cell.ondrop = async (e) => { + e.preventDefault(); + cell.style.borderColor = ''; + const deviceId = e.dataTransfer.getData('device-id'); + const deviceName = e.dataTransfer.getData('device-name'); + const col = parseInt(cell.dataset.gridCol); + const row = parseInt(cell.dataset.gridRow); + + // Add to wall devices + const existing = wall.devices?.filter(d => !(d.grid_col === col && d.grid_row === row)) || []; + existing.push({ device_id: deviceId, device_name: deviceName, grid_col: col, grid_row: row }); + + try { + const updated = await API(`/walls/${wallId}/devices`, { method: 'PUT', body: JSON.stringify({ devices: existing }) }); + wall.devices = updated.devices; + renderGrid(); + showToast(`${deviceName} placed at [${col},${row}]`, 'success'); + } catch (err) { showToast(err.message, 'error'); } + }; + }); + } + + // Drag sources + container.querySelectorAll('[draggable]').forEach(el => { + el.ondragstart = (e) => { + e.dataTransfer.setData('device-id', el.dataset.deviceId); + e.dataTransfer.setData('device-name', el.dataset.deviceName); + }; + }); + + document.getElementById('updateGridBtn').onclick = async () => { + try { + await API(`/walls/${wallId}`, { method: 'PUT', body: JSON.stringify({ + grid_cols: parseInt(document.getElementById('gridCols').value), + grid_rows: parseInt(document.getElementById('gridRows').value), + bezel_h_mm: parseFloat(document.getElementById('bezelH').value), + bezel_v_mm: parseFloat(document.getElementById('bezelV').value), + })}); + wall.grid_cols = parseInt(document.getElementById('gridCols').value); + wall.grid_rows = parseInt(document.getElementById('gridRows').value); + renderGrid(); + showToast('Grid updated', 'success'); + } catch (err) { showToast(err.message, 'error'); } + }; + + document.getElementById('setContentBtn').onclick = async () => { + const contentId = document.getElementById('wallContent').value; + try { + await API(`/walls/${wallId}/content`, { method: 'PUT', body: JSON.stringify({ content_id: contentId || null }) }); + showToast('Content updated', 'success'); + } catch (err) { showToast(err.message, 'error'); } + }; + + document.getElementById('deleteWallBtn').onclick = async () => { + try { + await API(`/walls/${wallId}`, { method: 'DELETE' }); + showToast('Wall deleted', 'success'); + window.location.hash = '#/walls'; + } catch (err) { showToast(err.message, 'error'); } + }; + + renderGrid(); +} + +export function cleanup() {} diff --git a/frontend/js/views/widgets.js b/frontend/js/views/widgets.js new file mode 100644 index 0000000..3e5cfa4 --- /dev/null +++ b/frontend/js/views/widgets.js @@ -0,0 +1,216 @@ +import { showToast } from '../components/toast.js'; + +const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(r => r.json()); + +const WIDGET_TYPES = [ + { id: 'clock', name: 'Clock', icon: '🕓', desc: 'Digital clock with date' }, + { id: 'weather', name: 'Weather', icon: '⛅', desc: 'Current weather conditions' }, + { id: 'rss', name: 'News Ticker', icon: '📰', desc: 'Scrolling RSS feed' }, + { id: 'text', name: 'Text/HTML', icon: '📝', desc: 'Custom text or HTML content' }, + { id: 'webpage', name: 'Webpage', icon: '🌐', desc: 'Embed a webpage' }, + { id: 'social', name: 'Social Feed', icon: '💬', desc: 'Social media feed' }, +]; + +export async function render(container) { + container.innerHTML = ` + + +
+ + + + `; + + let editingWidget = null; + let creatingType = null; + + document.getElementById('newWidgetBtn').onclick = () => { + const grid = document.getElementById('widgetTypeGrid'); + grid.style.display = grid.style.display === 'none' ? 'grid' : 'none'; + }; + + container.querySelectorAll('[data-create-type]').forEach(el => { + el.onclick = () => { + creatingType = el.dataset.createType; + editingWidget = null; + document.getElementById('widgetTypeGrid').style.display = 'none'; + showConfigForm(creatingType, {}); + }; + }); + + function showConfigForm(type, config) { + const typeName = WIDGET_TYPES.find(t => t.id === type)?.name || type; + document.getElementById('widgetModalTitle').textContent = editingWidget ? `Edit ${typeName}` : `New ${typeName}`; + + let html = '
'; + + switch (type) { + case 'clock': + html += ` +
+
+
+
+
`; + break; + case 'weather': + html += ` +
+
+
+
`; + break; + case 'rss': + html += ` +
+
+
+
+
+
`; + break; + case 'text': + html += ` +
+
+
`; + break; + case 'webpage': + html += ` +
+
+
`; + break; + case 'social': + html += ` +
+
`; + break; + } + + document.getElementById('widgetConfigForm').innerHTML = html; + document.getElementById('widgetModal').style.display = 'flex'; + } + + function getConfigFromForm(type) { + const config = {}; + const val = id => document.getElementById(id)?.value; + switch (type) { + case 'clock': Object.assign(config, { format: val('wFormat'), timezone: val('wTimezone'), font_size: parseInt(val('wFontSize')) || 64, color: val('wColor'), background: val('wBg'), show_date: true }); break; + case 'weather': Object.assign(config, { location: val('wLocation'), units: val('wUnits'), font_size: parseInt(val('wFontSize')) || 48, color: val('wColor') }); break; + case 'rss': Object.assign(config, { feed_url: val('wFeedUrl'), scroll_speed: parseInt(val('wScrollSpeed')) || 30, max_items: parseInt(val('wMaxItems')) || 10, font_size: parseInt(val('wFontSize')) || 24, color: val('wColor'), background: val('wBg') }); break; + case 'text': Object.assign(config, { html: val('wHtml'), css: val('wCss'), background: val('wBg') }); break; + case 'webpage': Object.assign(config, { url: val('wUrl'), zoom: parseInt(val('wZoom')) || 100, refresh_interval: parseInt(val('wRefresh')) || 0 }); break; + case 'social': Object.assign(config, { platform: val('wPlatform'), query: val('wQuery') }); break; + } + return config; + } + + document.getElementById('saveWidgetBtn').onclick = async () => { + const type = editingWidget?.widget_type || creatingType; + const name = document.getElementById('wName').value; + const config = getConfigFromForm(type); + try { + if (editingWidget) { + await API(`/widgets/${editingWidget.id}`, { method: 'PUT', body: JSON.stringify({ name, config }) }); + } else { + await API('/widgets', { method: 'POST', body: JSON.stringify({ widget_type: type, name, config }) }); + } + document.getElementById('widgetModal').style.display = 'none'; + showToast('Widget saved', 'success'); + loadWidgets(); + } catch (err) { showToast(err.message, 'error'); } + }; + + document.getElementById('previewWidgetBtn').onclick = () => { + if (editingWidget) { + window.open(`/api/widgets/${editingWidget.id}/render`, '_blank', 'width=600,height=400'); + } else { + showToast('Save the widget first to preview', 'info'); + } + }; + + async function loadWidgets() { + const widgets = await API('/widgets'); + const grid = document.getElementById('widgetGrid'); + if (!widgets.length) { + grid.innerHTML = '

No widgets yet

Create a widget to add dynamic content to your layouts.

'; + return; + } + grid.innerHTML = widgets.map(w => { + const typeMeta = WIDGET_TYPES.find(t => t.id === w.widget_type) || {}; + return ` +
+
+ ${typeMeta.icon || '?'} +
+
+
${w.name}
+
${typeMeta.name || w.widget_type}
+
+
+ + +
+
+ `; + }).join(''); + + grid.onclick = async (e) => { + const editBtn = e.target.closest('[data-edit-widget]'); + if (editBtn) { + const w = widgets.find(x => x.id === editBtn.dataset.editWidget); + if (w) { + editingWidget = w; + creatingType = w.widget_type; + const config = JSON.parse(w.config || '{}'); + config._name = w.name; + showConfigForm(w.widget_type, config); + } + return; + } + const deleteBtn = e.target.closest('[data-delete-widget]'); + if (deleteBtn) { + try { + await API(`/widgets/${deleteBtn.dataset.deleteWidget}`, { method: 'DELETE' }); + showToast('Widget deleted', 'success'); + loadWidgets(); + } catch (err) { showToast(err.message, 'error'); } + } + }; + } + + loadWidgets(); +} + +export function cleanup() {} diff --git a/frontend/landing.html b/frontend/landing.html new file mode 100644 index 0000000..6f90783 --- /dev/null +++ b/frontend/landing.html @@ -0,0 +1,390 @@ + + + + + + + + ScreenTinker - Digital Signage Software | Manage Any Screen Remotely + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
⚡ 14-day free Pro trial · No credit card required
+

Digital Signage
for Any Screen

+

Manage content on TVs, displays, and kiosks from anywhere. Remote control, video walls, scheduling, and analytics. Works on any device.

+ +
+ + +
+
+ +
+
+ + +
+

Everything You Need

+

One platform to manage all your digital signage

+
+
📺

Multi-Zone Layouts

Split screens into zones with a drag-and-drop editor. 7 built-in templates or create your own.

+
🎬

Video Wall

Combine multiple displays into one giant screen. Automatic bezel compensation. Any grid size.

+
🖥

Remote Control

See what's on screen in real-time. Send key presses, navigate menus, power on/off remotely.

+
📅

Scheduling

Visual weekly calendar. Set content to play at specific times with recurrence rules.

+
🔧

Content Designer

Built-in editor with live clocks, weather, RSS tickers, countdowns, QR codes, and more.

+
🖱

Kiosk Mode

Create interactive touchscreen interfaces. Wayfinding, directories, check-in screens.

+
📊

Proof-of-Play

Track what played, when, and on which device. Export CSV reports for ad verification.

+
🔔

Alerts & Monitoring

Email alerts when devices go offline. Full telemetry: battery, storage, WiFi, uptime.

+
🔒

Self-Hosted Option

Deploy on your own infrastructure. Your data never leaves your network. Full control.

+
🎨

White Label

Custom branding, colors, logo, and domain. Resell under your own brand.

+
👥

Teams

Multi-user accounts with owner, editor, and viewer roles. Invite by email.

+
🔄

Auto-Update

Devices automatically update when you push a new version. Zero manual intervention.

+
+
+ + +
+

Runs on Everything

+

No hardware lock-in. Use any screen you already have.

+
+
🤖
Android TV
+
🔥
Fire TV
+
🥏
Raspberry Pi
+
💻
Windows
+
🌐
ChromeOS
+
📺
LG webOS
+
📺
Samsung Tizen
+
🌎
Any Browser
+
+
+ + +
+

Simple, Honest Pricing

+

All plans include remote control, monitoring, and unlimited content

+
+
+ + +
+

How We Compare

+ + + + + + + + + + + + + + + + +
ScreenTinkerYodeckScreenCloudOptiSigns
Price (15 devices/yr)$989$1,440+$3,600+$1,800+
Free tier✓ 1 device
Platforms9 platforms223
Video Wall✓ Included
Remote Control✓ All plans
Content Designer✓ Built-in
Kiosk/Touchscreen✓ Included
Proof-of-Play✓ All plans
Self-Hosted✓ Only us
White Label✓ Included
Email Alerts✓ All plans
Hardware Lock-inNoneRPi focusedChromecastVarious
+
+ + +
+

Ready to Get Started?

+

14-day free Pro trial. No credit card required. Set up in under 5 minutes.

+ Start Free Trial +
+ + + + + + + + + + + + + + diff --git a/frontend/legal/privacy.html b/frontend/legal/privacy.html new file mode 100644 index 0000000..401c122 --- /dev/null +++ b/frontend/legal/privacy.html @@ -0,0 +1,143 @@ + + + + + + Privacy Policy - ScreenTinker + + + +
+ ← Back to ScreenTinker +

Privacy Policy

+

Last updated: March 24, 2026

+ +

1. Overview

+

ScreenTinker ("we", "us", "our") respects your privacy. This policy explains what data we collect, how we use it, and your rights regarding your information.

+ +

2. Information We Collect

+ +

2.1 Account Information

+ + + + + + +
DataPurposeRetention
Email addressAuthentication, notificationsUntil account deletion
NameDisplay in dashboardUntil account deletion
Password hashAuthentication (bcrypt, never stored in plain text)Until account deletion
OAuth provider IDGoogle/Microsoft sign-inUntil account deletion
+ +

2.2 Device Information

+ + + + + + + +
DataPurposeRetention
Device IDUnique device identificationUntil device removal
IP addressNetwork connectivity, securityOverwritten each connection
Android version, screen resolutionCompatibility, display optimizationUntil device removal
Battery, storage, RAM, CPU, WiFiDevice health monitoring90 days (rolling)
Device fingerprint (hardware hash)Prevent trial abuseUntil device removal
+ +

2.3 Usage Data

+ + + + + +
DataPurposeRetention
Content play logsProof-of-play reporting90 days
Activity log (API actions)Audit trail, security90 days
Screenshots (on-demand)Remote monitoringLatest only per device
+ +

2.4 Content

+

Media files (images, videos) you upload are stored on our servers solely to deliver them to your devices. We do not analyze, sell, or share your content.

+ +

3. How We Use Your Information

+
    +
  • Provide the Service: Deliver content to devices, enable remote management, process subscriptions
  • +
  • Security: Detect unauthorized access, prevent abuse, protect accounts
  • +
  • Communications: Send device offline alerts, subscription notifications, service updates
  • +
  • Improvement: Analyze aggregate usage patterns to improve the Service (no individual tracking)
  • +
+ +

4. Data Sharing

+

We do not sell your personal information. We share data only in these limited circumstances:

+
    +
  • Service providers: Payment processing (Stripe), email delivery, hosting infrastructure
  • +
  • Team members: If you belong to a team, other team members can see shared devices and content
  • +
  • Legal requirements: When required by law, subpoena, or court order
  • +
  • Business transfers: In the event of a merger, acquisition, or sale of assets
  • +
+ +

5. Self-Hosted Deployments

+

If you self-host ScreenTinker on your own infrastructure:

+
    +
  • All data stays on your servers. We have no access to it.
  • +
  • You are the data controller and responsible for compliance with applicable privacy laws.
  • +
  • No telemetry or usage data is sent to us from self-hosted instances.
  • +
+ +

6. Data Security

+
    +
  • Passwords are hashed with bcrypt (never stored in plain text)
  • +
  • API authentication uses JWT tokens with auto-expiry
  • +
  • All connections use HTTPS/TLS encryption
  • +
  • Android app uses encrypted storage for credentials
  • +
  • Rate limiting protects against brute force attacks
  • +
  • Regular security audits of the codebase
  • +
+ +

7. Your Rights

+

You have the right to:

+
    +
  • Access: View all data associated with your account from the Settings page
  • +
  • Correction: Update your account information at any time
  • +
  • Deletion: Delete your account and all associated data from Settings
  • +
  • Export: Download your data via the database backup feature (admin) or API
  • +
  • Portability: Export content and reports in standard formats (CSV, PNG, MP4)
  • +
+ +

8. Cookies and Local Storage

+
    +
  • We use localStorage to store your authentication token and preferences (language, theme)
  • +
  • The web player uses a Service Worker for offline content caching
  • +
  • We do not use third-party tracking cookies
  • +
  • Google/Microsoft OAuth may set cookies as part of their authentication flow
  • +
+ +

9. Children's Privacy

+

The Service is not intended for use by children under 13. We do not knowingly collect information from children under 13.

+ +

10. International Data Transfers

+

If you access the Service from outside the United States, your data may be transferred to and processed in the United States. By using the Service, you consent to this transfer.

+ +

11. Data Retention

+
    +
  • Account data: retained until you delete your account
  • +
  • Device telemetry: 90 days (automatically pruned)
  • +
  • Play logs: 90 days (automatically pruned)
  • +
  • Activity logs: 90 days (automatically pruned)
  • +
  • Content: retained until you delete it or your account
  • +
  • After account deletion: all data removed within 30 days
  • +
+ +

12. Changes to This Policy

+

We may update this policy from time to time. We will notify registered users of material changes via email. The "Last updated" date will be revised accordingly.

+ +

13. Contact Us

+

For privacy-related questions or data requests, contact us at:

+

Email: support@screentinker.com

+
+ + diff --git a/frontend/legal/terms.html b/frontend/legal/terms.html new file mode 100644 index 0000000..055a5ad --- /dev/null +++ b/frontend/legal/terms.html @@ -0,0 +1,138 @@ + + + + + + Terms of Service - ScreenTinker + + + +
+ ← Back to ScreenTinker +

Terms of Service

+

Last updated: March 24, 2026

+ +

1. Acceptance of Terms

+

By accessing or using ScreenTinker ("the Service"), you agree to be bound by these Terms of Service. If you do not agree, do not use the Service.

+ +

2. Description of Service

+

ScreenTinker is a digital signage management platform that allows users to remotely manage content on display devices. The Service includes a web dashboard, API, Android application, web player, and related tools.

+ +

3. Accounts

+

You must provide accurate information when creating an account. You are responsible for maintaining the security of your account credentials. You must notify us immediately of any unauthorized use of your account.

+ +

4. Subscription Plans and Billing

+
    +
  • Free accounts are limited to 1 device and 500MB of storage.
  • +
  • New accounts receive a 14-day free trial of the Pro plan.
  • +
  • Paid subscriptions are billed monthly or annually as selected.
  • +
  • You may cancel your subscription at any time. Access continues until the end of the billing period.
  • +
  • Refunds are handled on a case-by-case basis within 30 days of purchase.
  • +
  • We reserve the right to change pricing with 30 days notice to existing subscribers.
  • +
+ +

5. Acceptable Use

+

You agree not to:

+
    +
  • Use the Service for any illegal purpose or to display illegal content
  • +
  • Upload malware, viruses, or harmful code
  • +
  • Attempt to gain unauthorized access to other users' accounts or devices
  • +
  • Circumvent device limits, trial restrictions, or other usage controls
  • +
  • Resell the Service without a reseller agreement
  • +
  • Use the Service to send unsolicited advertising or spam
  • +
  • Overload the Service infrastructure through automated or abusive means
  • +
+ +

6. Content

+
    +
  • You retain ownership of content you upload to the Service.
  • +
  • You grant us a limited license to store, transmit, and display your content as necessary to provide the Service.
  • +
  • You are solely responsible for ensuring you have the rights to display any content you upload.
  • +
  • We may remove content that violates these terms or applicable law.
  • +
+ +

7. Device Management

+
    +
  • You are responsible for devices you connect to the Service.
  • +
  • The Service collects device telemetry (battery, storage, network status) for monitoring purposes.
  • +
  • Remote control features should only be used on devices you own or have authorization to manage.
  • +
+ +

8. Self-Hosted Deployments

+

If you deploy ScreenTinker on your own infrastructure:

+
    +
  • You are responsible for server security, backups, and maintenance.
  • +
  • We provide the software as-is without managed hosting guarantees.
  • +
  • License terms still apply to self-hosted deployments.
  • +
+ +

9. Privacy

+

Your use of the Service is also governed by our Privacy Policy.

+ +

10. Service Availability

+
    +
  • We strive for high availability but do not guarantee uninterrupted service.
  • +
  • We may perform maintenance with reasonable notice when possible.
  • +
  • We are not liable for damages resulting from service interruptions.
  • +
+ +

11. Intellectual Property

+

The ScreenTinker software, dashboard, APIs, and documentation are our intellectual property. Your subscription grants you a license to use the Service, not ownership of the software.

+

ScreenTinker incorporates open-source software components licensed under MIT and Apache 2.0 licenses. A complete list of third-party software and their licenses is available at our Third-Party Software Notices page.

+ +

12. Restrictions

+

You agree not to, and will not permit others to:

+
    +
  • Reverse engineer, decompile, disassemble, or otherwise attempt to derive the source code of the Software
  • +
  • Modify, adapt, translate, or create derivative works based on the Software
  • +
  • Remove, alter, or obscure any proprietary notices, labels, or marks on the Software
  • +
  • Copy, distribute, or sublicense the Software except as expressly permitted by your subscription plan
  • +
  • Use the Software to build a competing product or service
  • +
  • Circumvent or disable any licensing, authentication, or usage-tracking mechanisms in the Software
  • +
  • Share, transfer, or assign your license key or account credentials to unauthorized parties
  • +
  • Operate the Software beyond the scope of your current subscription plan or license agreement
  • +
  • Scrape, crawl, or programmatically extract data from the Software beyond the provided API
  • +
+

Violation of these restrictions may result in immediate termination of your account and may subject you to legal action.

+ +

13. License Keys and Self-Hosted Deployments

+
    +
  • Self-hosted Enterprise deployments require a valid license key issued by ScreenTinker.
  • +
  • License keys are non-transferable and tied to a specific organization.
  • +
  • License keys are issued for a specific term (monthly or annual) and must be renewed to maintain functionality.
  • +
  • Upon expiration of a license key, the Software will enter a grace period of 14 days, during which functionality is preserved but a renewal notice is displayed.
  • +
  • After the grace period, the Software will operate in a limited mode equivalent to the Free plan until a valid license key is provided.
  • +
  • Tampering with, bypassing, or forging license keys is strictly prohibited and constitutes a material breach of these terms.
  • +
  • We reserve the right to remotely verify license key validity and disable functionality for invalid or expired keys.
  • +
+ +

14. Termination

+
    +
  • You may terminate your account at any time from the Settings page.
  • +
  • We may terminate accounts that violate these terms with or without notice.
  • +
  • Upon termination, your content will be deleted after 30 days.
  • +
+ +

15. Limitation of Liability

+

THE SERVICE IS PROVIDED "AS IS" WITHOUT WARRANTIES OF ANY KIND. IN NO EVENT SHALL WE BE LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES ARISING FROM YOUR USE OF THE SERVICE.

+ +

16. Changes to Terms

+

We may update these terms from time to time. We will notify registered users of material changes via email. Continued use of the Service after changes constitutes acceptance.

+ +

17. Contact

+

For questions about these terms, contact us at support@screentinker.com

+
+ + diff --git a/frontend/legal/third-party.html b/frontend/legal/third-party.html new file mode 100644 index 0000000..1938491 --- /dev/null +++ b/frontend/legal/third-party.html @@ -0,0 +1,107 @@ + + + + + + Third-Party Licenses - ScreenTinker + + + +
+ ← Back to ScreenTinker +

Third-Party Software Notices

+

Last updated: March 24, 2026

+ +

ScreenTinker uses the following open-source software components. We gratefully acknowledge the contributions of these projects and their maintainers.

+ +

Summary

+ + + + + + + + + + + + + + + + + + + + + + + + +
PackageLicenseUse
ExpressMITWeb server framework
Socket.IOMITReal-time WebSocket communication
better-sqlite3MITSQLite database driver
MulterMITFile upload handling
uuidMITUnique ID generation
SharpApache 2.0Image processing and thumbnails
corsMITCross-origin resource sharing
bcryptjsMITPassword hashing
jsonwebtokenMITJWT authentication tokens
HelmetMITHTTP security headers
google-auth-libraryApache 2.0Google OAuth verification
OkHttpApache 2.0Android HTTP client
GsonApache 2.0Android JSON parsing
AndroidX Media3 / ExoPlayerApache 2.0Android video playback
AndroidX librariesApache 2.0Android UI and lifecycle
Material Components for AndroidApache 2.0Android UI components
Socket.IO Java ClientMITAndroid WebSocket client
Kotlin CoroutinesApache 2.0Android async operations
AndroidX Security CryptoApache 2.0Encrypted SharedPreferences
AndroidX WorkManagerApache 2.0Background task management
+ +

MIT License

+

The following packages are licensed under the MIT License:

+

Express, Socket.IO, better-sqlite3, Multer, uuid, cors, bcryptjs, jsonwebtoken, Helmet, Socket.IO Java Client

+
MIT License + +Copyright (c) respective authors and contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE.
+ +

Apache License 2.0

+

The following packages are licensed under the Apache License, Version 2.0:

+

Sharp, google-auth-library, OkHttp, Gson, AndroidX Media3/ExoPlayer, AndroidX libraries, Material Components for Android, Kotlin Coroutines, AndroidX Security Crypto, AndroidX WorkManager

+
Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License.
+ +

Contact

+

If you have questions about the licensing of any component used in ScreenTinker, please contact us at support@screentinker.com

+
+ + diff --git a/frontend/manifest.json b/frontend/manifest.json new file mode 100644 index 0000000..ad2b6ec --- /dev/null +++ b/frontend/manifest.json @@ -0,0 +1,22 @@ +{ + "name": "ScreenTinker", + "short_name": "ScreenTinker", + "description": "Digital Signage Management", + "start_url": "/", + "display": "standalone", + "background_color": "#111827", + "theme_color": "#3B82F6", + "orientation": "any", + "icons": [ + { + "src": "/assets/icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/assets/icon-512.png", + "sizes": "512x512", + "type": "image/png" + } + ] +} diff --git a/frontend/robots.txt b/frontend/robots.txt new file mode 100644 index 0000000..d4fdec3 --- /dev/null +++ b/frontend/robots.txt @@ -0,0 +1,11 @@ +User-agent: * +Allow: / +Allow: /legal/ +Allow: /player/ + +Disallow: /api/ +Disallow: /app +Disallow: /uploads/ +Disallow: /scripts/ + +Sitemap: https://screentinker.com/sitemap.xml diff --git a/frontend/sitemap.xml b/frontend/sitemap.xml new file mode 100644 index 0000000..7b6f335 --- /dev/null +++ b/frontend/sitemap.xml @@ -0,0 +1,18 @@ + + + + https://screentinker.com/ + weekly + 1.0 + + + https://screentinker.com/legal/terms.html + monthly + 0.3 + + + https://screentinker.com/legal/privacy.html + monthly + 0.3 + + diff --git a/frontend/sw-admin.js b/frontend/sw-admin.js new file mode 100644 index 0000000..94d9ad9 --- /dev/null +++ b/frontend/sw-admin.js @@ -0,0 +1,21 @@ +const CACHE = 'rd-admin-v1'; + +self.addEventListener('install', e => { + e.waitUntil(caches.open(CACHE).then(c => c.addAll([ + '/', '/index.html', '/css/variables.css', '/css/reset.css', '/css/main.css', + '/js/app.js', '/js/api.js', '/js/socket.js', '/js/i18n.js', + '/js/components/toast.js' + ]))); + self.skipWaiting(); +}); + +self.addEventListener('activate', e => { + e.waitUntil(caches.keys().then(keys => Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k))))); + self.clients.claim(); +}); + +self.addEventListener('fetch', e => { + // Network first for API, cache first for static + if (e.request.url.includes('/api/') || e.request.url.includes('/socket.io/')) return; + e.respondWith(caches.match(e.request).then(r => r || fetch(e.request))); +}); diff --git a/scripts/install-service.sh b/scripts/install-service.sh new file mode 100644 index 0000000..515c814 --- /dev/null +++ b/scripts/install-service.sh @@ -0,0 +1,17 @@ +#!/bin/bash +# Install ScreenTinker as a systemd service +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +SERVICE_FILE="$SCRIPT_DIR/remotedisplay.service" + +echo "Installing ScreenTinker service..." +sudo cp "$SERVICE_FILE" /etc/systemd/system/remotedisplay.service +sudo systemctl daemon-reload +sudo systemctl enable remotedisplay +sudo systemctl start remotedisplay +echo "Done! Service status:" +sudo systemctl status remotedisplay --no-pager +echo "" +echo "Commands:" +echo " sudo systemctl status remotedisplay" +echo " sudo systemctl restart remotedisplay" +echo " sudo journalctl -u remotedisplay -f" diff --git a/scripts/raspberry-pi-setup.sh b/scripts/raspberry-pi-setup.sh new file mode 100644 index 0000000..e89fed0 --- /dev/null +++ b/scripts/raspberry-pi-setup.sh @@ -0,0 +1,106 @@ +#!/bin/bash +# ScreenTinker - Raspberry Pi Setup Script +# Run: curl -sSL https://screentinker.com/scripts/pi-setup.sh | bash +# +# This sets up a Raspberry Pi as a digital signage player: +# 1. Installs Chromium if needed +# 2. Creates a systemd service for kiosk mode +# 3. Auto-starts on boot + +SERVER_URL="${1:-https://screentinker.com}" +PLAYER_URL="$SERVER_URL/player" + +echo "==================================" +echo " ScreenTinker Pi Player Setup" +echo "==================================" +echo "Server: $SERVER_URL" +echo "" + +# Install chromium if not present +if ! command -v chromium-browser &> /dev/null && ! command -v chromium &> /dev/null; then + echo "Installing Chromium..." + sudo apt-get update && sudo apt-get install -y chromium-browser unclutter +fi + +CHROMIUM=$(command -v chromium-browser || command -v chromium) + +# Disable screen blanking +if [ -f /etc/lightdm/lightdm.conf ]; then + sudo sed -i 's/#xserver-command=X/xserver-command=X -s 0 -dpms/' /etc/lightdm/lightdm.conf +fi + +# Create autostart directory +mkdir -p ~/.config/autostart + +# Create kiosk script +cat > ~/remotedisplay-kiosk.sh << EOF +#!/bin/bash +# Wait for network +sleep 5 + +# Disable screen saver and power management +xset s off +xset -dpms +xset s noblank + +# Hide cursor +unclutter -idle 0.1 -root & + +# Launch Chromium in kiosk mode +$CHROMIUM \\ + --noerrandprompts \\ + --disable-infobars \\ + --disable-session-crashed-bubble \\ + --kiosk \\ + --incognito \\ + --autoplay-policy=no-user-gesture-required \\ + --disable-features=TranslateUI \\ + --check-for-update-interval=31536000 \\ + --disable-component-update \\ + "$PLAYER_URL" +EOF +chmod +x ~/remotedisplay-kiosk.sh + +# Create systemd service +sudo tee /etc/systemd/system/remotedisplay.service > /dev/null << EOF +[Unit] +Description=ScreenTinker Kiosk Player +After=graphical.target +Wants=graphical.target + +[Service] +Type=simple +User=$USER +Environment=DISPLAY=:0 +ExecStart=/bin/bash $HOME/remotedisplay-kiosk.sh +Restart=always +RestartSec=10 + +[Install] +WantedBy=graphical.target +EOF + +sudo systemctl daemon-reload +sudo systemctl enable remotedisplay.service + +# Create desktop autostart entry (fallback) +cat > ~/.config/autostart/remotedisplay.desktop << EOF +[Desktop Entry] +Type=Application +Name=ScreenTinker +Exec=$HOME/remotedisplay-kiosk.sh +X-GNOME-Autostart-enabled=true +EOF + +echo "" +echo "==================================" +echo " Setup Complete!" +echo "==================================" +echo "" +echo "The player will auto-start on next boot." +echo "To start now: ~/remotedisplay-kiosk.sh" +echo "To stop: sudo systemctl stop remotedisplay" +echo "Player URL: $PLAYER_URL" +echo "" +echo "Press Escape in the player to reset/reconfigure." +echo "Press F for fullscreen toggle." diff --git a/scripts/remotedisplay.service b/scripts/remotedisplay.service new file mode 100644 index 0000000..5025980 --- /dev/null +++ b/scripts/remotedisplay.service @@ -0,0 +1,19 @@ +[Unit] +Description=ScreenTinker Digital Signage Server +After=network.target +Wants=network-online.target + +[Service] +Type=simple +User=owner +WorkingDirectory=/home/owner/Downloads/remote_display/server +ExecStart=/home/owner/.nvm/versions/node/v20.20.1/bin/node server.js +Restart=always +RestartSec=5 +Environment=NODE_ENV=production +StandardOutput=journal +StandardError=journal +SyslogIdentifier=remotedisplay + +[Install] +WantedBy=multi-user.target diff --git a/scripts/reset-admin.js b/scripts/reset-admin.js new file mode 100644 index 0000000..e0f267d --- /dev/null +++ b/scripts/reset-admin.js @@ -0,0 +1,44 @@ +#!/usr/bin/env node +/** + * Emergency admin access for self-hosted ScreenTinker. + * Run this on the server to get a temporary admin login URL. + * + * Usage: node scripts/reset-admin.js + */ + +const path = require('path'); +const config = require(path.join(__dirname, '..', 'server', 'config')); +const jwt = require(path.join(__dirname, '..', 'server', 'node_modules', 'jsonwebtoken')); +const crypto = require('crypto'); + +const nonce = crypto.randomBytes(8).toString('hex'); +const token = jwt.sign( + { id: 'recovery-' + nonce, email: 'admin@localhost', role: 'admin' }, + config.jwtSecret, + { expiresIn: '1h' } +); + +const port = config.port || 3001; + +console.log(` +╔══════════════════════════════════════════════════╗ +║ ScreenTinker Admin Recovery ║ +╠══════════════════════════════════════════════════╣ +║ A temporary admin token has been generated. ║ +║ Valid for 1 hour. Use it to log in and reset ║ +║ your password or create a new admin account. ║ +╚══════════════════════════════════════════════════╝ + +Token: ${token} + +To use: Open your ScreenTinker instance, open browser +console (F12), and run: + + localStorage.setItem('token', '${token}'); + localStorage.setItem('user', '${JSON.stringify({ id: 'recovery-' + nonce, email: 'admin@localhost', name: 'Recovery Admin', role: 'admin', plan_id: 'enterprise' }).replace(/'/g, "\\'")}'); + location.reload(); + +Or use the API directly: + + curl -H "Authorization: Bearer ${token}" http://localhost:${port}/api/devices +`); diff --git a/scripts/windows-setup.bat b/scripts/windows-setup.bat new file mode 100644 index 0000000..685ba48 --- /dev/null +++ b/scripts/windows-setup.bat @@ -0,0 +1,37 @@ +@echo off +REM ScreenTinker - Windows Kiosk Setup +REM Run as Administrator + +set SERVER_URL=https://your-server-url +set PLAYER_URL=%SERVER_URL%/player + +echo ================================== +echo ScreenTinker Windows Player +echo ================================== +echo. + +REM Create startup shortcut +set STARTUP=%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup +set SHORTCUT=%STARTUP%\ScreenTinker.url + +echo [InternetShortcut] > "%SHORTCUT%" +echo URL=%PLAYER_URL% >> "%SHORTCUT%" + +REM Create a VBS launcher for kiosk mode (Chrome) +set LAUNCHER=%USERPROFILE%\ScreenTinker.vbs +echo Set WshShell = CreateObject("WScript.Shell") > "%LAUNCHER%" +echo WshShell.Run """C:\Program Files\Google\Chrome\Application\chrome.exe"" --kiosk --autoplay-policy=no-user-gesture-required ""%PLAYER_URL%""", 1, False >> "%LAUNCHER%" + +REM Replace startup shortcut with VBS launcher +copy /Y "%LAUNCHER%" "%STARTUP%\ScreenTinker.vbs" >nul + +echo. +echo Setup complete! +echo. +echo The player will auto-start on next login. +echo To start now, open: %PLAYER_URL% +echo Or run: %LAUNCHER% +echo. +echo Press any key to launch the player now... +pause >nul +start chrome --kiosk --autoplay-policy=no-user-gesture-required "%PLAYER_URL%" diff --git a/server/config.js b/server/config.js new file mode 100644 index 0000000..bb8e698 --- /dev/null +++ b/server/config.js @@ -0,0 +1,42 @@ +const path = require('path'); + +module.exports = { + port: process.env.PORT || 3001, + httpsPort: process.env.HTTPS_PORT || 3443, + dbPath: path.join(__dirname, 'db', 'remote_display.db'), + uploadsDir: path.join(__dirname, 'uploads'), + contentDir: path.join(__dirname, 'uploads', 'content'), + screenshotsDir: path.join(__dirname, 'uploads', 'screenshots'), + frontendDir: path.join(__dirname, '..', 'frontend'), + heartbeatInterval: 10000, // Check every 10s + heartbeatTimeout: 45000, // Offline after 45s (3 missed 15s beats) + maxFileSize: 500 * 1024 * 1024, // 500MB + thumbnailWidth: 320, + screenshotQuality: 70, + // SSL: drop your Cloudflare Origin cert + key in certs/ folder + // or set env vars SSL_CERT and SSL_KEY to custom paths + sslCert: process.env.SSL_CERT || path.join(__dirname, 'certs', 'cert.pem'), + sslKey: process.env.SSL_KEY || path.join(__dirname, 'certs', 'key.pem'), + // Auth + jwtSecret: process.env.JWT_SECRET || (() => { + const secretFile = path.join(__dirname, 'certs', '.jwt_secret'); + const fs = require('fs'); + if (fs.existsSync(secretFile)) return fs.readFileSync(secretFile, 'utf8').trim(); + const secret = require('crypto').randomBytes(64).toString('hex'); + try { fs.mkdirSync(path.dirname(secretFile), { recursive: true }); fs.writeFileSync(secretFile, secret); } catch {} + return secret; + })(), + jwtExpiry: '7d', + // Google OAuth - set these in env or here + googleClientId: process.env.GOOGLE_CLIENT_ID || '', + // Microsoft OAuth - set these in env or here + microsoftClientId: process.env.MICROSOFT_CLIENT_ID || '', + microsoftTenantId: process.env.MICROSOFT_TENANT_ID || 'common', + // Stripe (optional - for paid subscriptions) + stripeSecretKey: process.env.STRIPE_SECRET_KEY || '', + stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET || '', + // Email alerts webhook URL (POST endpoint for sending emails) + emailWebhookUrl: process.env.EMAIL_WEBHOOK_URL || '', + // Self-hosted mode: if true, first user gets enterprise plan and no billing + selfHosted: process.env.SELF_HOSTED === 'true', +}; diff --git a/server/db/database.js b/server/db/database.js new file mode 100644 index 0000000..78cace2 --- /dev/null +++ b/server/db/database.js @@ -0,0 +1,96 @@ +const Database = require('better-sqlite3'); +const fs = require('fs'); +const path = require('path'); +const config = require('../config'); + +const dbDir = path.dirname(config.dbPath); +if (!fs.existsSync(dbDir)) fs.mkdirSync(dbDir, { recursive: true }); + +const db = new Database(config.dbPath); + +// Enable WAL mode and foreign keys +db.pragma('journal_mode = WAL'); +db.pragma('foreign_keys = ON'); + +// Run schema +const schema = fs.readFileSync(path.join(__dirname, 'schema.sql'), 'utf8'); +db.exec(schema); + +// Migrations for existing databases +const migrations = [ + 'ALTER TABLE content ADD COLUMN remote_url TEXT', + 'ALTER TABLE devices ADD COLUMN user_id TEXT REFERENCES users(id)', + 'ALTER TABLE content ADD COLUMN user_id TEXT REFERENCES users(id)', + "ALTER TABLE users ADD COLUMN plan_id TEXT DEFAULT 'free'", + 'ALTER TABLE users ADD COLUMN stripe_customer_id TEXT', + 'ALTER TABLE users ADD COLUMN stripe_subscription_id TEXT', + "ALTER TABLE users ADD COLUMN subscription_status TEXT DEFAULT 'active'", + 'ALTER TABLE users ADD COLUMN subscription_ends INTEGER', + // Layout & zone support on devices and assignments + 'ALTER TABLE devices ADD COLUMN layout_id TEXT', + 'ALTER TABLE devices ADD COLUMN timezone TEXT DEFAULT \'UTC\'', + 'ALTER TABLE devices ADD COLUMN wall_id TEXT', + 'ALTER TABLE devices ADD COLUMN team_id TEXT', + 'ALTER TABLE assignments ADD COLUMN zone_id TEXT', + 'ALTER TABLE assignments ADD COLUMN widget_id TEXT', + // Team support on content + 'ALTER TABLE content ADD COLUMN team_id TEXT', + // Device notes + 'ALTER TABLE devices ADD COLUMN notes TEXT', + // Email settings on users + "ALTER TABLE users ADD COLUMN email_alerts INTEGER DEFAULT 1", + // Content folders + 'ALTER TABLE content ADD COLUMN folder TEXT', + // Device orientation and default content + "ALTER TABLE devices ADD COLUMN orientation TEXT DEFAULT 'landscape'", + 'ALTER TABLE devices ADD COLUMN default_content_id TEXT', + // Audio control per assignment + "ALTER TABLE assignments ADD COLUMN muted INTEGER DEFAULT 0", + // Trial tracking + "ALTER TABLE users ADD COLUMN trial_started INTEGER", + "ALTER TABLE users ADD COLUMN trial_plan TEXT DEFAULT 'pro'", + // Stripe price IDs on plans + "ALTER TABLE plans ADD COLUMN stripe_price_monthly TEXT", + "ALTER TABLE plans ADD COLUMN stripe_price_yearly TEXT", + // Last login tracking + "ALTER TABLE users ADD COLUMN last_login INTEGER", +]; +for (const sql of migrations) { + try { db.exec(sql); } catch (e) { /* already exists */ } +} + +// Prune old telemetry (keep last 24h worth at 15s intervals = ~5760, cap at 6000) +function pruneTelemetry(deviceId) { + db.prepare(` + DELETE FROM device_telemetry + WHERE device_id = ? AND id NOT IN ( + SELECT id FROM device_telemetry + WHERE device_id = ? + ORDER BY reported_at DESC LIMIT 6000 + ) + `).run(deviceId, deviceId); +} + +// Prune old screenshots (keep only latest per device) +function pruneScreenshots(deviceId) { + const old = db.prepare(` + SELECT filepath FROM screenshots + WHERE device_id = ? AND id NOT IN ( + SELECT id FROM screenshots WHERE device_id = ? ORDER BY captured_at DESC LIMIT 1 + ) + `).all(deviceId, deviceId); + + for (const row of old) { + const fullPath = path.join(config.screenshotsDir, row.filepath); + if (fs.existsSync(fullPath)) fs.unlinkSync(fullPath); + } + + db.prepare(` + DELETE FROM screenshots + WHERE device_id = ? AND id NOT IN ( + SELECT id FROM screenshots WHERE device_id = ? ORDER BY captured_at DESC LIMIT 1 + ) + `).run(deviceId, deviceId); +} + +module.exports = { db, pruneTelemetry, pruneScreenshots }; diff --git a/server/db/schema.sql b/server/db/schema.sql new file mode 100644 index 0000000..314390e --- /dev/null +++ b/server/db/schema.sql @@ -0,0 +1,385 @@ +CREATE TABLE IF NOT EXISTS plans ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + display_name TEXT NOT NULL, + max_devices INTEGER NOT NULL DEFAULT 2, + max_storage_mb INTEGER NOT NULL DEFAULT 500, + remote_control INTEGER NOT NULL DEFAULT 0, + remote_url INTEGER NOT NULL DEFAULT 0, + priority_support INTEGER NOT NULL DEFAULT 0, + price_monthly REAL NOT NULL DEFAULT 0, + price_yearly REAL NOT NULL DEFAULT 0, + stripe_monthly_id TEXT, + stripe_yearly_id TEXT, + sort_order INTEGER NOT NULL DEFAULT 0, + active INTEGER NOT NULL DEFAULT 1 +); + +-- Default plans +INSERT OR IGNORE INTO plans (id, name, display_name, max_devices, max_storage_mb, remote_control, remote_url, priority_support, price_monthly, price_yearly, sort_order) +VALUES + ('free', 'free', 'Free', 2, 500, 0, 0, 0, 0, 0, 0), + ('starter', 'starter', 'Starter', 8, 2048, 1, 0, 0, 9.99, 99, 1), + ('pro', 'pro', 'Pro', 25, 10240, 1, 1, 0, 24.99, 249, 2), + ('enterprise', 'enterprise', 'Enterprise', -1, -1, 1, 1, 1, 49.99, 499, 3); + +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + email TEXT UNIQUE NOT NULL, + name TEXT NOT NULL DEFAULT '', + password_hash TEXT, + auth_provider TEXT NOT NULL DEFAULT 'local', + provider_id TEXT, + avatar_url TEXT, + role TEXT NOT NULL DEFAULT 'user', + plan_id TEXT DEFAULT 'free' REFERENCES plans(id), + stripe_customer_id TEXT, + stripe_subscription_id TEXT, + subscription_status TEXT DEFAULT 'active', + subscription_ends INTEGER, + created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), + updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) +); + +CREATE TABLE IF NOT EXISTS devices ( + id TEXT PRIMARY KEY, + user_id TEXT REFERENCES users(id), + name TEXT NOT NULL DEFAULT 'Unnamed Display', + pairing_code TEXT UNIQUE, + status TEXT NOT NULL DEFAULT 'offline', + last_heartbeat INTEGER, + ip_address TEXT, + android_version TEXT, + app_version TEXT, + screen_width INTEGER, + screen_height INTEGER, + created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), + updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) +); + +CREATE TABLE IF NOT EXISTS device_telemetry ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + device_id TEXT NOT NULL REFERENCES devices(id) ON DELETE CASCADE, + battery_level INTEGER, + battery_charging INTEGER NOT NULL DEFAULT 0, + storage_free_mb INTEGER, + storage_total_mb INTEGER, + ram_free_mb INTEGER, + ram_total_mb INTEGER, + cpu_usage REAL, + wifi_ssid TEXT, + wifi_rssi INTEGER, + uptime_seconds INTEGER, + reported_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) +); + +CREATE INDEX IF NOT EXISTS idx_telemetry_device ON device_telemetry(device_id, reported_at DESC); + +CREATE TABLE IF NOT EXISTS content ( + id TEXT PRIMARY KEY, + user_id TEXT REFERENCES users(id), + filename TEXT NOT NULL, + filepath TEXT NOT NULL DEFAULT '', + mime_type TEXT NOT NULL, + file_size INTEGER NOT NULL DEFAULT 0, + duration_sec REAL, + thumbnail_path TEXT, + width INTEGER, + height INTEGER, + remote_url TEXT, + created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) +); + +CREATE TABLE IF NOT EXISTS assignments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + device_id TEXT NOT NULL REFERENCES devices(id) ON DELETE CASCADE, + content_id TEXT REFERENCES content(id) ON DELETE CASCADE, + widget_id TEXT REFERENCES widgets(id) ON DELETE CASCADE, + zone_id TEXT, + sort_order INTEGER NOT NULL DEFAULT 0, + duration_sec INTEGER NOT NULL DEFAULT 10, + schedule_start TEXT, + schedule_end TEXT, + schedule_days TEXT, + enabled INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) +); + +CREATE TABLE IF NOT EXISTS screenshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + device_id TEXT NOT NULL REFERENCES devices(id) ON DELETE CASCADE, + filepath TEXT NOT NULL, + captured_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) +); + +CREATE INDEX IF NOT EXISTS idx_screenshots_device ON screenshots(device_id, captured_at DESC); + +-- ===================== LAYOUTS & ZONES ===================== + +CREATE TABLE IF NOT EXISTS layouts ( + id TEXT PRIMARY KEY, + user_id TEXT REFERENCES users(id), + team_id TEXT, + name TEXT NOT NULL, + width INTEGER NOT NULL DEFAULT 1920, + height INTEGER NOT NULL DEFAULT 1080, + is_template INTEGER NOT NULL DEFAULT 0, + template_category TEXT, + thumbnail_data TEXT, + created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), + updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) +); + +CREATE TABLE IF NOT EXISTS layout_zones ( + id TEXT PRIMARY KEY, + layout_id TEXT NOT NULL REFERENCES layouts(id) ON DELETE CASCADE, + name TEXT NOT NULL DEFAULT 'Zone', + x_percent REAL NOT NULL DEFAULT 0, + y_percent REAL NOT NULL DEFAULT 0, + width_percent REAL NOT NULL DEFAULT 100, + height_percent REAL NOT NULL DEFAULT 100, + z_index INTEGER NOT NULL DEFAULT 0, + zone_type TEXT NOT NULL DEFAULT 'content', + fit_mode TEXT NOT NULL DEFAULT 'cover', + background_color TEXT DEFAULT '#000000', + sort_order INTEGER NOT NULL DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_zones_layout ON layout_zones(layout_id); + +-- Seed templates +INSERT OR IGNORE INTO layouts (id, user_id, name, is_template, template_category) VALUES + ('tpl-fullscreen', NULL, 'Fullscreen', 1, 'basic'), + ('tpl-split-h', NULL, 'Split Horizontal', 1, 'split'), + ('tpl-split-v', NULL, 'Split Vertical', 1, 'split'), + ('tpl-l-bar', NULL, 'L-Bar with Ticker', 1, 'news'), + ('tpl-pip', NULL, 'Picture in Picture', 1, 'overlay'), + ('tpl-thirds', NULL, 'Three Column', 1, 'grid'), + ('tpl-quad', NULL, 'Four Quadrants', 1, 'grid'); + +INSERT OR IGNORE INTO layout_zones (id, layout_id, name, x_percent, y_percent, width_percent, height_percent, z_index, sort_order) VALUES + ('z-fs-1', 'tpl-fullscreen', 'Main', 0, 0, 100, 100, 0, 0), + ('z-sh-1', 'tpl-split-h', 'Left', 0, 0, 50, 100, 0, 0), + ('z-sh-2', 'tpl-split-h', 'Right', 50, 0, 50, 100, 0, 1), + ('z-sv-1', 'tpl-split-v', 'Top', 0, 0, 100, 50, 0, 0), + ('z-sv-2', 'tpl-split-v', 'Bottom', 0, 50, 100, 50, 0, 1), + ('z-lb-1', 'tpl-l-bar', 'Main Content', 0, 0, 75, 85, 0, 0), + ('z-lb-2', 'tpl-l-bar', 'Side Panel', 75, 0, 25, 100, 0, 1), + ('z-lb-3', 'tpl-l-bar', 'Bottom Ticker', 0, 85, 75, 15, 1, 2), + ('z-pip-1', 'tpl-pip', 'Background', 0, 0, 100, 100, 0, 0), + ('z-pip-2', 'tpl-pip', 'PiP Window', 65, 5, 30, 30, 1, 1), + ('z-th-1', 'tpl-thirds', 'Left', 0, 0, 33.33, 100, 0, 0), + ('z-th-2', 'tpl-thirds', 'Center', 33.33, 0, 33.34, 100, 0, 1), + ('z-th-3', 'tpl-thirds', 'Right', 66.67, 0, 33.33, 100, 0, 2), + ('z-q-1', 'tpl-quad', 'Top Left', 0, 0, 50, 50, 0, 0), + ('z-q-2', 'tpl-quad', 'Top Right', 50, 0, 50, 50, 0, 1), + ('z-q-3', 'tpl-quad', 'Bottom Left', 0, 50, 50, 50, 0, 2), + ('z-q-4', 'tpl-quad', 'Bottom Right', 50, 50, 50, 50, 0, 3); + +-- ===================== WIDGETS ===================== + +CREATE TABLE IF NOT EXISTS widgets ( + id TEXT PRIMARY KEY, + user_id TEXT REFERENCES users(id), + team_id TEXT, + widget_type TEXT NOT NULL, + name TEXT NOT NULL, + config TEXT NOT NULL DEFAULT '{}', + created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), + updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) +); + +-- ===================== SCHEDULES ===================== + +CREATE TABLE IF NOT EXISTS schedules ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id), + device_id TEXT NOT NULL REFERENCES devices(id) ON DELETE CASCADE, + zone_id TEXT REFERENCES layout_zones(id) ON DELETE CASCADE, + content_id TEXT REFERENCES content(id) ON DELETE CASCADE, + widget_id TEXT REFERENCES widgets(id) ON DELETE CASCADE, + layout_id TEXT REFERENCES layouts(id) ON DELETE SET NULL, + title TEXT NOT NULL DEFAULT '', + start_time TEXT NOT NULL, + end_time TEXT NOT NULL, + timezone TEXT NOT NULL DEFAULT 'UTC', + recurrence TEXT, + recurrence_end TEXT, + priority INTEGER NOT NULL DEFAULT 0, + enabled INTEGER NOT NULL DEFAULT 1, + color TEXT DEFAULT '#3B82F6', + created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), + updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) +); + +CREATE INDEX IF NOT EXISTS idx_schedules_device ON schedules(device_id, enabled); + +-- ===================== VIDEO WALLS ===================== + +CREATE TABLE IF NOT EXISTS video_walls ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id), + team_id TEXT, + name TEXT NOT NULL, + grid_cols INTEGER NOT NULL DEFAULT 2, + grid_rows INTEGER NOT NULL DEFAULT 2, + bezel_h_mm REAL NOT NULL DEFAULT 0, + bezel_v_mm REAL NOT NULL DEFAULT 0, + screen_w_mm REAL NOT NULL DEFAULT 400, + screen_h_mm REAL NOT NULL DEFAULT 225, + sync_mode TEXT NOT NULL DEFAULT 'leader', + leader_device_id TEXT REFERENCES devices(id) ON DELETE SET NULL, + content_id TEXT REFERENCES content(id) ON DELETE SET NULL, + created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), + updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) +); + +CREATE TABLE IF NOT EXISTS video_wall_devices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + wall_id TEXT NOT NULL REFERENCES video_walls(id) ON DELETE CASCADE, + device_id TEXT NOT NULL REFERENCES devices(id) ON DELETE CASCADE, + grid_col INTEGER NOT NULL, + grid_row INTEGER NOT NULL, + rotation INTEGER NOT NULL DEFAULT 0, + UNIQUE(wall_id, device_id), + UNIQUE(wall_id, grid_col, grid_row) +); + +-- ===================== TEAMS ===================== + +CREATE TABLE IF NOT EXISTS teams ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + owner_id TEXT NOT NULL REFERENCES users(id), + created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) +); + +CREATE TABLE IF NOT EXISTS team_members ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + team_id TEXT NOT NULL REFERENCES teams(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role TEXT NOT NULL DEFAULT 'viewer', + invited_by TEXT REFERENCES users(id), + joined_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), + UNIQUE(team_id, user_id) +); + +CREATE TABLE IF NOT EXISTS team_invites ( + id TEXT PRIMARY KEY, + team_id TEXT NOT NULL REFERENCES teams(id) ON DELETE CASCADE, + email TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'viewer', + invited_by TEXT NOT NULL REFERENCES users(id), + expires_at INTEGER NOT NULL, + created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) +); + +-- ===================== PROOF-OF-PLAY ===================== + +CREATE TABLE IF NOT EXISTS play_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + device_id TEXT NOT NULL REFERENCES devices(id) ON DELETE CASCADE, + content_id TEXT REFERENCES content(id) ON DELETE SET NULL, + widget_id TEXT REFERENCES widgets(id) ON DELETE SET NULL, + zone_id TEXT, + content_name TEXT NOT NULL DEFAULT '', + started_at INTEGER NOT NULL, + ended_at INTEGER, + duration_sec INTEGER, + completed INTEGER NOT NULL DEFAULT 0, + trigger_type TEXT DEFAULT 'playlist', + created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) +); + +CREATE INDEX IF NOT EXISTS idx_play_logs_device ON play_logs(device_id, started_at DESC); +CREATE INDEX IF NOT EXISTS idx_play_logs_content ON play_logs(content_id, started_at DESC); +CREATE INDEX IF NOT EXISTS idx_play_logs_time ON play_logs(started_at, ended_at); + +-- ===================== DEVICE GROUPS ===================== + +CREATE TABLE IF NOT EXISTS device_groups ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id), + name TEXT NOT NULL, + color TEXT DEFAULT '#3B82F6', + created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) +); + +CREATE TABLE IF NOT EXISTS device_group_members ( + device_id TEXT NOT NULL REFERENCES devices(id) ON DELETE CASCADE, + group_id TEXT NOT NULL REFERENCES device_groups(id) ON DELETE CASCADE, + PRIMARY KEY (device_id, group_id) +); + +-- ===================== ACTIVITY LOG ===================== + +CREATE TABLE IF NOT EXISTS activity_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT REFERENCES users(id), + device_id TEXT, + action TEXT NOT NULL, + details TEXT, + ip_address TEXT, + created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) +); + +CREATE INDEX IF NOT EXISTS idx_activity_log_time ON activity_log(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_activity_log_user ON activity_log(user_id, created_at DESC); + +-- ===================== EMAIL ALERTS ===================== + +-- ===================== WHITE LABEL ===================== + +CREATE TABLE IF NOT EXISTS white_labels ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id), + brand_name TEXT NOT NULL DEFAULT 'ScreenTinker', + logo_url TEXT, + favicon_url TEXT, + primary_color TEXT DEFAULT '#3B82F6', + secondary_color TEXT DEFAULT '#1E293B', + bg_color TEXT DEFAULT '#111827', + custom_domain TEXT, + custom_css TEXT, + hide_branding INTEGER DEFAULT 0, + created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), + updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) +); + +-- ===================== KIOSK PAGES ===================== + +CREATE TABLE IF NOT EXISTS kiosk_pages ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id), + name TEXT NOT NULL, + config TEXT NOT NULL DEFAULT '{}', + created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), + updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) +); + +-- ===================== DEVICE FINGERPRINTS ===================== + +CREATE TABLE IF NOT EXISTS device_fingerprints ( + fingerprint TEXT NOT NULL, + device_id TEXT REFERENCES devices(id) ON DELETE SET NULL, + user_id TEXT REFERENCES users(id), + first_seen INTEGER NOT NULL DEFAULT (strftime('%s','now')), + last_seen INTEGER NOT NULL DEFAULT (strftime('%s','now')), + PRIMARY KEY (fingerprint) +); + +CREATE TABLE IF NOT EXISTS alert_configs ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id), + alert_type TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + config TEXT NOT NULL DEFAULT '{}', + created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) +); + +CREATE TABLE IF NOT EXISTS device_status_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + device_id TEXT NOT NULL, + status TEXT NOT NULL, + timestamp INTEGER NOT NULL DEFAULT (strftime('%s','now')) +); diff --git a/server/middleware/auth.js b/server/middleware/auth.js new file mode 100644 index 0000000..24b7269 --- /dev/null +++ b/server/middleware/auth.js @@ -0,0 +1,67 @@ +const jwt = require('jsonwebtoken'); +const config = require('../config'); +const { db } = require('../db/database'); + +function generateToken(user) { + return jwt.sign( + { id: user.id, email: user.email, role: user.role }, + config.jwtSecret, + { expiresIn: config.jwtExpiry } + ); +} + +function verifyToken(token) { + return jwt.verify(token, config.jwtSecret); +} + +// Express middleware - requires valid JWT +function requireAuth(req, res, next) { + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ error: 'Authentication required' }); + } + + try { + const token = authHeader.split(' ')[1]; + const decoded = verifyToken(token); + const user = db.prepare('SELECT id, email, name, role, auth_provider, avatar_url, plan_id FROM users WHERE id = ?').get(decoded.id); + if (!user) return res.status(401).json({ error: 'User not found' }); + req.user = user; + next(); + } catch (err) { + return res.status(401).json({ error: 'Invalid or expired token' }); + } +} + +// Optional auth - sets req.user if token present, continues either way +function optionalAuth(req, res, next) { + const authHeader = req.headers.authorization; + if (authHeader && authHeader.startsWith('Bearer ')) { + try { + const token = authHeader.split(' ')[1]; + const decoded = verifyToken(token); + req.user = db.prepare('SELECT id, email, name, role, auth_provider, avatar_url, plan_id FROM users WHERE id = ?').get(decoded.id); + } catch (err) { + // Token invalid, continue without user + } + } + next(); +} + +// Require admin role (admin or superadmin) +function requireAdmin(req, res, next) { + if (!req.user || !['admin', 'superadmin'].includes(req.user.role)) { + return res.status(403).json({ error: 'Admin access required' }); + } + next(); +} + +// Require superadmin role (platform owner only) +function requireSuperAdmin(req, res, next) { + if (!req.user || req.user.role !== 'superadmin') { + return res.status(403).json({ error: 'Platform admin access required' }); + } + next(); +} + +module.exports = { generateToken, verifyToken, requireAuth, optionalAuth, requireAdmin, requireSuperAdmin }; diff --git a/server/middleware/sanitize.js b/server/middleware/sanitize.js new file mode 100644 index 0000000..939b984 --- /dev/null +++ b/server/middleware/sanitize.js @@ -0,0 +1,25 @@ +// Simple XSS sanitizer for user input strings +function sanitizeString(str) { + if (typeof str !== 'string') return str; + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +// Middleware: sanitize common body fields +function sanitizeBody(req, res, next) { + if (req.body) { + const fieldsToSanitize = ['name', 'title', 'filename']; + for (const field of fieldsToSanitize) { + if (typeof req.body[field] === 'string') { + req.body[field] = sanitizeString(req.body[field]); + } + } + } + next(); +} + +module.exports = { sanitizeString, sanitizeBody }; diff --git a/server/middleware/subscription.js b/server/middleware/subscription.js new file mode 100644 index 0000000..2ccac89 --- /dev/null +++ b/server/middleware/subscription.js @@ -0,0 +1,145 @@ +const { db } = require('../db/database'); +const config = require('../config'); + +const TRIAL_DAYS = 14; + +function getUserPlan(userId) { + const user = db.prepare(` + SELECT u.*, p.name as plan_name, p.display_name as plan_display_name, + p.max_devices, p.max_storage_mb, p.remote_control, p.remote_url, + p.priority_support, p.price_monthly, p.price_yearly + FROM users u + JOIN plans p ON u.plan_id = p.id + WHERE u.id = ? + `).get(userId); + + // Check if trial has expired + if (user && user.trial_started) { + const trialEnd = user.trial_started + (TRIAL_DAYS * 86400); + const now = Math.floor(Date.now() / 1000); + user.trial_active = now < trialEnd; + user.trial_days_left = Math.max(0, Math.ceil((trialEnd - now) / 86400)); + user.trial_end = trialEnd; + + // Auto-downgrade if trial expired and no paid subscription + if (!user.trial_active && user.subscription_status !== 'active' && user.plan_name !== 'free') { + db.prepare("UPDATE users SET plan_id = 'free', trial_started = NULL WHERE id = ?").run(userId); + // Re-fetch with free plan + return getUserPlan(userId); + } + } else { + user.trial_active = false; + user.trial_days_left = 0; + } + + return user; +} + +function getUserDeviceCount(userId) { + return db.prepare('SELECT COUNT(*) as count FROM devices WHERE user_id = ?').get(userId).count; +} + +function getUserStorageMB(userId) { + const result = db.prepare('SELECT COALESCE(SUM(file_size), 0) as total FROM content WHERE user_id = ?').get(userId); + return Math.ceil(result.total / (1024 * 1024)); +} + +// Check if user can add more devices +function checkDeviceLimit(req, res, next) { + const plan = getUserPlan(req.user.id); + if (!plan) return res.status(403).json({ error: 'No plan found' }); + + // -1 means unlimited + if (plan.max_devices === -1) return next(); + + const deviceCount = getUserDeviceCount(req.user.id); + if (deviceCount >= plan.max_devices) { + return res.status(403).json({ + error: `Device limit reached (${plan.max_devices} on ${plan.plan_display_name} plan). Upgrade to add more.`, + code: 'DEVICE_LIMIT', + current: deviceCount, + limit: plan.max_devices, + plan: plan.plan_name + }); + } + next(); +} + +// Check if user can upload more content +function checkStorageLimit(req, res, next) { + const plan = getUserPlan(req.user.id); + if (!plan) return res.status(403).json({ error: 'No plan found' }); + + // -1 means unlimited + if (plan.max_storage_mb === -1) return next(); + + const usedMB = getUserStorageMB(req.user.id); + if (usedMB >= plan.max_storage_mb) { + return res.status(403).json({ + error: `Storage limit reached (${plan.max_storage_mb}MB on ${plan.plan_display_name} plan). Upgrade for more.`, + code: 'STORAGE_LIMIT', + current_mb: usedMB, + limit_mb: plan.max_storage_mb, + plan: plan.plan_name + }); + } + next(); +} + +// Check if user has remote control access +function checkRemoteControl(req, res, next) { + const plan = getUserPlan(req.user.id); + if (!plan || !plan.remote_control) { + return res.status(403).json({ + error: 'Remote control requires Starter plan or above.', + code: 'FEATURE_LOCKED', + plan: plan?.plan_name + }); + } + next(); +} + +// Check remote URL feature access +function checkRemoteUrl(req, res, next) { + const plan = getUserPlan(req.user.id); + if (!plan || !plan.remote_url) { + return res.status(403).json({ + error: 'Remote URL content requires Pro plan or above.', + code: 'FEATURE_LOCKED', + plan: plan?.plan_name + }); + } + next(); +} + +// Check subscription is active (not expired) +function checkActiveSubscription(req, res, next) { + const plan = getUserPlan(req.user.id); + if (!plan) return res.status(403).json({ error: 'No plan found' }); + + // Free plan is always active + if (plan.plan_name === 'free') return next(); + + // Self-hosted mode doesn't check expiry + if (config.selfHosted) return next(); + + // Check if subscription has expired + if (plan.subscription_status !== 'active' && plan.subscription_ends && plan.subscription_ends < Math.floor(Date.now() / 1000)) { + return res.status(403).json({ + error: 'Subscription expired. Please renew to continue.', + code: 'SUBSCRIPTION_EXPIRED' + }); + } + next(); +} + +module.exports = { + getUserPlan, + getUserDeviceCount, + getUserStorageMB, + checkDeviceLimit, + checkStorageLimit, + checkRemoteControl, + checkRemoteUrl, + checkActiveSubscription +}; diff --git a/server/middleware/upload.js b/server/middleware/upload.js new file mode 100644 index 0000000..f744ad6 --- /dev/null +++ b/server/middleware/upload.js @@ -0,0 +1,35 @@ +const multer = require('multer'); +const path = require('path'); +const { v4: uuidv4 } = require('uuid'); +const config = require('../config'); + +const storage = multer.diskStorage({ + destination: (req, file, cb) => { + cb(null, config.contentDir); + }, + filename: (req, file, cb) => { + const ext = path.extname(file.originalname); + cb(null, `${uuidv4()}${ext}`); + } +}); + +const fileFilter = (req, file, cb) => { + const allowedTypes = [ + 'video/mp4', 'video/webm', 'video/avi', 'video/mkv', 'video/mov', + 'video/x-msvideo', 'video/quicktime', 'video/x-matroska', + 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/bmp' + ]; + if (allowedTypes.includes(file.mimetype) || file.mimetype.startsWith('video/') || file.mimetype.startsWith('image/')) { + cb(null, true); + } else { + cb(new Error('Only video and image files are allowed'), false); + } +}; + +const upload = multer({ + storage, + fileFilter, + limits: { fileSize: config.maxFileSize } +}); + +module.exports = upload; diff --git a/server/package-lock.json b/server/package-lock.json new file mode 100644 index 0000000..23bd109 --- /dev/null +++ b/server/package-lock.json @@ -0,0 +1,3682 @@ +{ + "name": "remote-display-server", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "remote-display-server", + "version": "1.0.0", + "dependencies": { + "archiver": "^7.0.1", + "bcryptjs": "^3.0.3", + "better-sqlite3": "^9.4.3", + "cors": "^2.8.5", + "express": "^4.18.2", + "express-rate-limit": "^8.3.1", + "google-auth-library": "^10.6.2", + "helmet": "^8.1.0", + "jsonwebtoken": "^9.0.3", + "multer": "^1.4.5-lts.1", + "sharp": "^0.33.2", + "socket.io": "^4.7.2", + "stripe": "^20.4.1", + "unzipper": "^0.12.3", + "uuid": "^9.0.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", + "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", + "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.5" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", + "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", + "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.2.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", + "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/archiver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz", + "integrity": "sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^5.0.2", + "async": "^3.2.4", + "buffer-crc32": "^1.0.0", + "readable-stream": "^4.0.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^3.0.0", + "zip-stream": "^6.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/archiver-utils": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-5.0.2.tgz", + "integrity": "sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==", + "license": "MIT", + "dependencies": { + "glob": "^10.0.0", + "graceful-fs": "^4.2.0", + "is-stream": "^2.0.1", + "lazystream": "^1.0.0", + "lodash": "^4.17.15", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/archiver-utils/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/archiver-utils/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/archiver-utils/node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/archiver/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/archiver/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/archiver/node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/archiver/node_modules/tar-stream": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.8.tgz", + "integrity": "sha512-U6QpVRyCGHva435KoNWy9PRoi2IFYCgtEhq9nmrPPpbRacPs9IH4aJ3gbrFC8dPcXvdSZ4XXfXT5Fshbp2MtlQ==", + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "bare-fs": "^4.5.5", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, + "node_modules/b4a": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz", + "integrity": "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/bare-events": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", + "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.6.0.tgz", + "integrity": "sha512-2YkS7NuiJceSEbyEOdSNLE9tsGd+f4+f7C+Nik/MCk27SYdwIMPT/yRKvg++FZhQXgk0KWJKJyXX9RhVV0RGqA==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-os": { + "version": "3.8.7", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.8.7.tgz", + "integrity": "sha512-G4Gr1UsGeEy2qtDTZwL7JFLo2wapUarz7iTMcYcMFdS89AIQuBoyjgXZz0Utv7uHs3xA9LckhVbeBi8lEQrC+w==", + "license": "Apache-2.0", + "engines": { + "bare": ">=1.14.0" + } + }, + "node_modules/bare-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", + "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", + "license": "Apache-2.0", + "dependencies": { + "bare-os": "^3.0.1" + } + }, + "node_modules/bare-stream": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.12.0.tgz", + "integrity": "sha512-w28i8lkBgREV3rPXGbgK+BO66q+ZpKqRWrZLiCdmmUlLPrQ45CzkvRhN+7lnv00Gpi2zy5naRxnUFAxCECDm9g==", + "license": "Apache-2.0", + "dependencies": { + "streamx": "^2.25.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-abort-controller": "*", + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.0.tgz", + "integrity": "sha512-NSTU5WN+fy/L0DDenfE8SXQna4voXuW0FHM7wH8i3/q9khUSchfPbPezO4zSFMnDGIf9YE+mt/RWhZgNRKRIXA==", + "license": "Apache-2.0", + "dependencies": { + "bare-path": "^3.0.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", + "license": "MIT", + "engines": { + "node": "^4.5.0 || >= 5.9" + } + }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, + "node_modules/better-sqlite3": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-9.6.0.tgz", + "integrity": "sha512-yR5HATnqeYNVnkaUTf4bOP2dJSnyhP4puJN/QPRyx4YkBEEUxib422n2XzPqDEHjQQqazoYoADdAm5vE15+dAQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bl/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/brace-expansion": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-crc32": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", + "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "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/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/compress-commons": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz", + "integrity": "sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "crc32-stream": "^6.0.0", + "is-stream": "^2.0.1", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/compress-commons/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/compress-commons/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/compress-commons/node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "engines": [ + "node >= 0.8" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "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.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-6.0.0.tgz", + "integrity": "sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/crc32-stream/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/crc32-stream/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/crc32-stream/node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "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/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=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/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "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/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "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/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/engine.io": { + "version": "6.6.6", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.6.tgz", + "integrity": "sha512-U2SN0w3OpjFRVlrc17E6TMDmH58Xl9rai1MblNjAdwWp07Kk+llmzX0hjDpQdrDGzwmvOtgM5yI+meYX6iZ2xA==", + "license": "MIT", + "dependencies": { + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "@types/ws": "^8.5.12", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.7.2", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.18.3" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/engine.io/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/engine.io/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/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.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "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/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz", + "integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==", + "license": "MIT", + "dependencies": { + "ip-address": "10.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "license": "MIT" + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "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": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fs-extra": { + "version": "11.3.4", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz", + "integrity": "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "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/gaxios": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", + "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "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/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/google-auth-library": { + "version": "10.6.2", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz", + "integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "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/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "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.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/helmet": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz", + "integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "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/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent/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/https-proxy-agent/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/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "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/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "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-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "license": "MIT" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/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/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "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": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/multer": { + "version": "1.4.5-lts.2", + "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz", + "integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==", + "deprecated": "Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.0.0", + "concat-stream": "^1.5.2", + "mkdirp": "^0.5.4", + "object-assign": "^4.1.1", + "type-is": "^1.6.4", + "xtend": "^4.0.0" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-abi": { + "version": "3.89.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz", + "integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, + "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-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "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/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/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/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "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/sharp": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", + "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.3", + "semver": "^7.6.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.33.5", + "@img/sharp-darwin-x64": "0.33.5", + "@img/sharp-libvips-darwin-arm64": "1.0.4", + "@img/sharp-libvips-darwin-x64": "1.0.4", + "@img/sharp-libvips-linux-arm": "1.0.5", + "@img/sharp-libvips-linux-arm64": "1.0.4", + "@img/sharp-libvips-linux-s390x": "1.0.4", + "@img/sharp-libvips-linux-x64": "1.0.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", + "@img/sharp-libvips-linuxmusl-x64": "1.0.4", + "@img/sharp-linux-arm": "0.33.5", + "@img/sharp-linux-arm64": "0.33.5", + "@img/sharp-linux-s390x": "0.33.5", + "@img/sharp-linux-x64": "0.33.5", + "@img/sharp-linuxmusl-arm64": "0.33.5", + "@img/sharp-linuxmusl-x64": "0.33.5", + "@img/sharp-wasm32": "0.33.5", + "@img/sharp-win32-ia32": "0.33.5", + "@img/sharp-win32-x64": "0.33.5" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "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.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "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/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/socket.io": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz", + "integrity": "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io": "~6.6.0", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/socket.io-adapter": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.6.tgz", + "integrity": "sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==", + "license": "MIT", + "dependencies": { + "debug": "~4.4.1", + "ws": "~8.18.3" + } + }, + "node_modules/socket.io-adapter/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/socket.io-adapter/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/socket.io-parser": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", + "integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser/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/socket.io-parser/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/socket.io/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/socket.io/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/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/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/streamx": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.25.0.tgz", + "integrity": "sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==", + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stripe": { + "version": "20.4.1", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-20.4.1.tgz", + "integrity": "sha512-axCguHItc8Sxt0HC6aSkdVRPffjYPV7EQqZRb2GkIa8FzWDycE7nHJM19C6xAIynH1Qp1/BHiopSi96jGBxT0w==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "@types/node": ">=16" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tar-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "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/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "license": "MIT" + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "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/unzipper": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.12.3.tgz", + "integrity": "sha512-PZ8hTS+AqcGxsaQntl3IRBw65QrBI6lxzqDEL7IAo/XCEqRTKGfOX56Vea5TH9SZczRVxuzk1re04z/YjuYCJA==", + "license": "MIT", + "dependencies": { + "bluebird": "~3.7.2", + "duplexer2": "~0.1.4", + "fs-extra": "^11.2.0", + "graceful-fs": "^4.2.2", + "node-int64": "^0.4.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "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/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "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" + }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/zip-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.1.tgz", + "integrity": "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^5.0.0", + "compress-commons": "^6.0.2", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/zip-stream/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/zip-stream/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/zip-stream/node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + } + } +} diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..4ea80bb --- /dev/null +++ b/server/package.json @@ -0,0 +1,27 @@ +{ + "name": "remote-display-server", + "version": "1.0.0", + "description": "ScreenTinker - Digital Signage Management Server", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "node --watch server.js" + }, + "dependencies": { + "archiver": "^7.0.1", + "bcryptjs": "^3.0.3", + "better-sqlite3": "^9.4.3", + "cors": "^2.8.5", + "express": "^4.18.2", + "express-rate-limit": "^8.3.1", + "google-auth-library": "^10.6.2", + "helmet": "^8.1.0", + "jsonwebtoken": "^9.0.3", + "multer": "^1.4.5-lts.1", + "sharp": "^0.33.2", + "socket.io": "^4.7.2", + "stripe": "^20.4.1", + "unzipper": "^0.12.3", + "uuid": "^9.0.0" + } +} diff --git a/server/player/index.html b/server/player/index.html new file mode 100644 index 0000000..b142510 --- /dev/null +++ b/server/player/index.html @@ -0,0 +1,788 @@ + + + + + + ScreenTinker Player + + + + +
+

ScreenTinker

+
Web Player
+
+ + + +
+ + +
+
+ + + + + + + + + + + diff --git a/server/player/sw.js b/server/player/sw.js new file mode 100644 index 0000000..4460dbd --- /dev/null +++ b/server/player/sw.js @@ -0,0 +1,53 @@ +const CACHE_NAME = 'rd-player-v1'; +const STATIC_ASSETS = ['/player/', '/player/index.html', '/socket.io/socket.io.js']; + +// Install: cache static assets +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE_NAME).then(cache => cache.addAll(STATIC_ASSETS)) + ); + self.skipWaiting(); +}); + +// Activate: clean old caches +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then(keys => Promise.all( + keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k)) + )) + ); + self.clients.claim(); +}); + +// Fetch: cache content files for offline playback +self.addEventListener('fetch', (event) => { + const url = new URL(event.request.url); + + // Cache content files (videos, images) on first fetch + if (url.pathname.startsWith('/uploads/content/')) { + event.respondWith( + caches.match(event.request).then(cached => { + if (cached) return cached; + return fetch(event.request).then(response => { + if (response.ok) { + const clone = response.clone(); + caches.open(CACHE_NAME).then(cache => cache.put(event.request, clone)); + } + return response; + }).catch(() => new Response('Offline', { status: 503 })); + }) + ); + return; + } + + // For static assets, try cache first + if (STATIC_ASSETS.some(a => url.pathname === a || url.pathname.endsWith(a))) { + event.respondWith( + caches.match(event.request).then(cached => cached || fetch(event.request)) + ); + return; + } + + // Everything else: network only + event.respondWith(fetch(event.request)); +}); diff --git a/server/routes/activity.js b/server/routes/activity.js new file mode 100644 index 0000000..7590af0 --- /dev/null +++ b/server/routes/activity.js @@ -0,0 +1,27 @@ +const express = require('express'); +const router = express.Router(); +const { getActivity, pruneActivityLog } = require('../services/activity'); + +// Get activity log +router.get('/', (req, res) => { + const { device_id, limit, offset } = req.query; + const isAdmin = req.user.role === 'superadmin'; + + const activity = getActivity({ + userId: isAdmin ? null : req.user.id, + deviceId: device_id || null, + limit: Math.min(parseInt(limit) || 50, 200), + offset: parseInt(offset) || 0, + }); + + res.json(activity); +}); + +// Prune old logs (admin only) +router.delete('/prune', (req, res) => { + if (!['admin','superadmin'].includes(req.user.role)) return res.status(403).json({ error: 'Admin only' }); + pruneActivityLog(); + res.json({ success: true }); +}); + +module.exports = router; diff --git a/server/routes/assignments.js b/server/routes/assignments.js new file mode 100644 index 0000000..799c36e --- /dev/null +++ b/server/routes/assignments.js @@ -0,0 +1,175 @@ +const express = require('express'); +const router = express.Router(); +const { db } = require('../db/database'); + +// Check device ownership for device-scoped routes +function checkDeviceAccess(req, res) { + const device = db.prepare('SELECT user_id FROM devices WHERE id = ?').get(req.params.deviceId); + if (!device) { res.status(404).json({ error: 'Device not found' }); return false; } + if (!['admin','superadmin'].includes(req.user.role) && device.user_id && device.user_id !== req.user.id) { + res.status(403).json({ error: 'Access denied' }); return false; + } + return true; +} + +// Get assignments for a device +router.get('/device/:deviceId', (req, res) => { + if (!checkDeviceAccess(req, res)) return; + const assignments = db.prepare(` + SELECT a.*, + COALESCE(c.filename, w.name) as filename, + c.mime_type, c.filepath, c.thumbnail_path, + c.duration_sec as content_duration, c.file_size, c.remote_url, + w.name as widget_name, w.widget_type, w.config as widget_config + FROM assignments a + LEFT JOIN content c ON a.content_id = c.id + LEFT JOIN widgets w ON a.widget_id = w.id + WHERE a.device_id = ? + ORDER BY a.sort_order ASC + `).all(req.params.deviceId); + res.json(assignments); +}); + +// Add content or widget to device playlist +router.post('/device/:deviceId', (req, res) => { + if (!checkDeviceAccess(req, res)) return; + const { content_id, widget_id, zone_id, duration_sec = 10, sort_order, schedule_start, schedule_end, schedule_days } = req.body; + + if (!content_id && !widget_id) return res.status(400).json({ error: 'content_id or widget_id required' }); + + // Validate the referenced item exists AND belongs to the user + if (content_id) { + const content = db.prepare('SELECT id, user_id FROM content WHERE id = ?').get(content_id); + if (!content) return res.status(404).json({ error: 'Content not found' }); + if (!['admin','superadmin'].includes(req.user.role) && content.user_id && content.user_id !== req.user.id) { + return res.status(403).json({ error: 'Content not owned by you' }); + } + } + if (widget_id) { + const widget = db.prepare('SELECT id FROM widgets WHERE id = ?').get(widget_id); + if (!widget) return res.status(404).json({ error: 'Widget not found' }); + } + + // Get max sort order if not specified + let order = sort_order; + if (order === undefined || order === null) { + const max = db.prepare('SELECT MAX(sort_order) as max_order FROM assignments WHERE device_id = ?') + .get(req.params.deviceId); + order = (max.max_order || 0) + 1; + } + + try { + const result = db.prepare(` + INSERT INTO assignments (device_id, content_id, widget_id, zone_id, sort_order, duration_sec, schedule_start, schedule_end, schedule_days) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run(req.params.deviceId, content_id || null, widget_id || null, zone_id || null, order, duration_sec, schedule_start || null, schedule_end || null, schedule_days || null); + + const assignment = db.prepare(` + SELECT a.*, c.filename as filename, c.mime_type, c.filepath, c.thumbnail_path, c.duration_sec as content_duration, c.file_size, c.remote_url, + w.name as widget_name, w.widget_type, w.config as widget_config + FROM assignments a + LEFT JOIN content c ON a.content_id = c.id + LEFT JOIN widgets w ON a.widget_id = w.id + WHERE a.id = ? + `).get(result.lastInsertRowid); + + res.status(201).json(assignment); + } catch (err) { + if (err.message.includes('UNIQUE')) { + return res.status(409).json({ error: 'Content already assigned to this device' }); + } + throw err; + } +}); + +// Update assignment +router.put('/:id', (req, res) => { + const assignment = db.prepare('SELECT * FROM assignments WHERE id = ?').get(req.params.id); + if (!assignment) return res.status(404).json({ error: 'Assignment not found' }); + + const { sort_order, duration_sec, schedule_start, schedule_end, schedule_days, enabled, zone_id } = req.body; + const updates = []; + const values = []; + + if (sort_order !== undefined) { updates.push('sort_order = ?'); values.push(sort_order); } + if (duration_sec !== undefined) { updates.push('duration_sec = ?'); values.push(duration_sec); } + if (schedule_start !== undefined) { updates.push('schedule_start = ?'); values.push(schedule_start); } + if (schedule_end !== undefined) { updates.push('schedule_end = ?'); values.push(schedule_end); } + if (schedule_days !== undefined) { updates.push('schedule_days = ?'); values.push(schedule_days); } + if (enabled !== undefined) { updates.push('enabled = ?'); values.push(enabled); } + if (zone_id !== undefined) { updates.push('zone_id = ?'); values.push(zone_id || null); } + if (req.body.muted !== undefined) { updates.push('muted = ?'); values.push(req.body.muted ? 1 : 0); } + + if (updates.length > 0) { + values.push(req.params.id); + db.prepare(`UPDATE assignments SET ${updates.join(', ')} WHERE id = ?`).run(...values); + } + + const updated = db.prepare(` + SELECT a.*, COALESCE(c.filename, w.name) as filename, c.mime_type, c.filepath, c.thumbnail_path, c.duration_sec as content_duration, c.file_size, c.remote_url, + w.name as widget_name, w.widget_type, w.config as widget_config + FROM assignments a LEFT JOIN content c ON a.content_id = c.id LEFT JOIN widgets w ON a.widget_id = w.id + WHERE a.id = ? + `).get(req.params.id); + res.json(updated); +}); + +// Delete assignment +router.delete('/:id', (req, res) => { + const assignment = db.prepare('SELECT * FROM assignments WHERE id = ?').get(req.params.id); + if (!assignment) return res.status(404).json({ error: 'Assignment not found' }); + + db.prepare('DELETE FROM assignments WHERE id = ?').run(req.params.id); + res.json({ success: true, device_id: assignment.device_id, content_id: assignment.content_id }); +}); + +// Reorder assignments for a device +router.post('/device/:deviceId/reorder', (req, res) => { + const { order } = req.body; // Array of assignment IDs in desired order + if (!Array.isArray(order)) return res.status(400).json({ error: 'order must be an array of assignment IDs' }); + + const updateStmt = db.prepare('UPDATE assignments SET sort_order = ? WHERE id = ? AND device_id = ?'); + const transaction = db.transaction(() => { + order.forEach((assignmentId, index) => { + updateStmt.run(index, assignmentId, req.params.deviceId); + }); + }); + transaction(); + + const assignments = db.prepare(` + SELECT a.*, COALESCE(c.filename, w.name) as filename, c.mime_type, c.filepath, c.thumbnail_path, c.duration_sec as content_duration, c.file_size, c.remote_url, + w.name as widget_name, w.widget_type, w.config as widget_config + FROM assignments a LEFT JOIN content c ON a.content_id = c.id LEFT JOIN widgets w ON a.widget_id = w.id + WHERE a.device_id = ? + ORDER BY a.sort_order ASC + `).all(req.params.deviceId); + res.json(assignments); +}); + +// Copy playlist from one device to another +router.post('/device/:deviceId/copy-to/:targetDeviceId', (req, res) => { + const source = db.prepare('SELECT * FROM assignments WHERE device_id = ? AND enabled = 1 ORDER BY sort_order').all(req.params.deviceId); + if (!source.length) return res.status(404).json({ error: 'Source device has no assignments' }); + + const target = db.prepare('SELECT id FROM devices WHERE id = ?').get(req.params.targetDeviceId); + if (!target) return res.status(404).json({ error: 'Target device not found' }); + + // Clear existing assignments on target if requested + if (req.body.replace) { + db.prepare('DELETE FROM assignments WHERE device_id = ?').run(req.params.targetDeviceId); + } + + const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM assignments WHERE device_id = ?').get(req.params.targetDeviceId).m || 0; + const stmt = db.prepare('INSERT OR IGNORE INTO assignments (device_id, content_id, widget_id, zone_id, sort_order, duration_sec, enabled) VALUES (?, ?, ?, ?, ?, ?, 1)'); + + const transaction = db.transaction(() => { + source.forEach((a, i) => { + stmt.run(req.params.targetDeviceId, a.content_id, a.widget_id, a.zone_id, maxOrder + i + 1, a.duration_sec); + }); + }); + transaction(); + + res.json({ success: true, copied: source.length }); +}); + +module.exports = router; diff --git a/server/routes/auth.js b/server/routes/auth.js new file mode 100644 index 0000000..b0a0811 --- /dev/null +++ b/server/routes/auth.js @@ -0,0 +1,283 @@ +const express = require('express'); +const router = express.Router(); +const bcrypt = require('bcryptjs'); +const https = require('https'); +const { v4: uuidv4 } = require('uuid'); +const { OAuth2Client } = require('google-auth-library'); +const { db } = require('../db/database'); +const { generateToken, requireAuth, requireAdmin, requireSuperAdmin } = require('../middleware/auth'); +const config = require('../config'); + +function logFailedLogin(email, ip, reason) { + try { + db.prepare('INSERT INTO activity_log (user_id, action, details, ip_address) VALUES (NULL, ?, ?, ?)') + .run('auth:login_failed', `${email} - ${reason}`, ip); + } catch {} +} + +function logSuccessfulLogin(userId, email, ip) { + try { + db.prepare('INSERT INTO activity_log (user_id, action, details, ip_address) VALUES (?, ?, ?, ?)') + .run(userId, 'auth:login_success', email, ip); + db.prepare("UPDATE users SET last_login = strftime('%s','now') WHERE id = ?").run(userId); + } catch {} +} + +// ==================== Local Auth ==================== + +// Register +router.post('/register', (req, res) => { + const { email, password, name } = req.body; + if (!email || !password) return res.status(400).json({ error: 'Email and password required' }); + if (password.length < 8) return res.status(400).json({ error: 'Password must be at least 8 characters' }); + + const existing = db.prepare('SELECT id FROM users WHERE email = ?').get(email.toLowerCase()); + if (existing) return res.status(409).json({ error: 'Email already registered' }); + + const id = uuidv4(); + const passwordHash = bcrypt.hashSync(password, 10); + + // First user becomes admin with enterprise plan (self-hosted) or free plan with Pro trial + const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get().count; + const role = userCount === 0 ? 'superadmin' : 'user'; + const isFirstUser = userCount === 0; + const plan = (isFirstUser && config.selfHosted) ? 'enterprise' : 'pro'; // Start on Pro trial + const trialStarted = isFirstUser && config.selfHosted ? null : Math.floor(Date.now() / 1000); + + db.prepare(` + INSERT INTO users (id, email, name, password_hash, auth_provider, role, plan_id, trial_started, trial_plan) + VALUES (?, ?, ?, ?, 'local', ?, ?, ?, ?) + `).run(id, email.toLowerCase(), name || email.split('@')[0], passwordHash, role, plan, trialStarted, trialStarted ? 'pro' : null); + + const user = db.prepare('SELECT id, email, name, role, auth_provider, avatar_url, plan_id FROM users WHERE id = ?').get(id); + const token = generateToken(user); + + res.status(201).json({ token, user }); +}); + +// Login +router.post('/login', (req, res) => { + const { email, password } = req.body; + if (!email || !password) return res.status(400).json({ error: 'Email and password required' }); + + const user = db.prepare('SELECT * FROM users WHERE email = ? AND auth_provider = ?').get(email.toLowerCase(), 'local'); + if (!user) { + logFailedLogin(email, req.ip, 'User not found'); + return res.status(401).json({ error: 'Invalid email or password' }); + } + + if (!bcrypt.compareSync(password, user.password_hash)) { + logFailedLogin(email, req.ip, 'Wrong password'); + return res.status(401).json({ error: 'Invalid email or password' }); + } + + logSuccessfulLogin(user.id, email, req.ip); + const token = generateToken(user); + const { password_hash, ...safeUser } = user; + res.json({ token, user: safeUser }); +}); + +// ==================== Google OAuth ==================== + +router.post('/google', async (req, res) => { + const { credential } = req.body; + if (!credential) return res.status(400).json({ error: 'Google credential required' }); + + try { + // Verify the Google ID token + const payload = await verifyGoogleToken(credential); + if (!payload) return res.status(401).json({ error: 'Invalid Google token' }); + + const { email, name, picture, sub: googleId } = payload; + + // Find or create user + let user = db.prepare('SELECT * FROM users WHERE email = ?').get(email.toLowerCase()); + + if (!user) { + const id = uuidv4(); + const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get().count; + const role = userCount === 0 ? 'superadmin' : 'user'; + const isFirst = userCount === 0; + const plan = (isFirst && config.selfHosted) ? 'enterprise' : 'pro'; + const trialStarted = isFirst && config.selfHosted ? null : Math.floor(Date.now() / 1000); + + db.prepare(` + INSERT INTO users (id, email, name, auth_provider, provider_id, avatar_url, role, plan_id, trial_started, trial_plan) + VALUES (?, ?, ?, 'google', ?, ?, ?, ?, ?, ?) + `).run(id, email.toLowerCase(), name || '', googleId, picture || '', role, plan, trialStarted, trialStarted ? 'pro' : null); + + user = db.prepare('SELECT * FROM users WHERE id = ?').get(id); + } else if (user.auth_provider !== 'google') { + // Link Google to existing account + db.prepare('UPDATE users SET auth_provider = ?, provider_id = ?, avatar_url = ? WHERE id = ?') + .run('google', googleId, picture || user.avatar_url, user.id); + user = db.prepare('SELECT * FROM users WHERE id = ?').get(user.id); + } + + const token = generateToken(user); + const { password_hash, ...safeUser } = user; + res.json({ token, user: safeUser }); + } catch (err) { + console.error('Google auth error:', err); + res.status(401).json({ error: 'Google authentication failed' }); + } +}); + +async function verifyGoogleToken(credential) { + const client = new OAuth2Client(config.googleClientId); + try { + const ticket = await client.verifyIdToken({ + idToken: credential, + audience: config.googleClientId || undefined, + }); + return ticket.getPayload(); + } catch (e) { + // Fallback: if credential is an access token, verify via tokeninfo + try { + const res = await fetch(`https://oauth2.googleapis.com/tokeninfo?access_token=${credential}`); + if (!res.ok) throw new Error('Invalid token'); + return await res.json(); + } catch { + throw new Error('Google token verification failed: ' + e.message); + } + } +} + +// ==================== Microsoft OAuth ==================== + +router.post('/microsoft', async (req, res) => { + const { access_token } = req.body; + if (!access_token) return res.status(400).json({ error: 'Microsoft access token required' }); + + try { + // Use the access token to get user profile from Microsoft Graph + const profile = await getMicrosoftProfile(access_token); + if (!profile || !profile.mail && !profile.userPrincipalName) { + return res.status(401).json({ error: 'Could not get Microsoft profile' }); + } + + const email = (profile.mail || profile.userPrincipalName).toLowerCase(); + const name = profile.displayName || ''; + const microsoftId = profile.id; + + // Find or create user + let user = db.prepare('SELECT * FROM users WHERE email = ?').get(email); + + if (!user) { + const id = uuidv4(); + const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get().count; + const role = userCount === 0 ? 'superadmin' : 'user'; + const isFirst = userCount === 0; + const plan = (isFirst && config.selfHosted) ? 'enterprise' : 'pro'; + const trialStarted = isFirst && config.selfHosted ? null : Math.floor(Date.now() / 1000); + + db.prepare(` + INSERT INTO users (id, email, name, auth_provider, provider_id, role, plan_id, trial_started, trial_plan) + VALUES (?, ?, ?, 'microsoft', ?, ?, ?, ?, ?) + `).run(id, email, name, microsoftId, role, plan, trialStarted, trialStarted ? 'pro' : null); + + user = db.prepare('SELECT * FROM users WHERE id = ?').get(id); + } else if (user.auth_provider !== 'microsoft') { + db.prepare('UPDATE users SET auth_provider = ?, provider_id = ? WHERE id = ?') + .run('microsoft', microsoftId, user.id); + user = db.prepare('SELECT * FROM users WHERE id = ?').get(user.id); + } + + const token = generateToken(user); + const { password_hash, ...safeUser } = user; + res.json({ token, user: safeUser }); + } catch (err) { + console.error('Microsoft auth error:', err); + res.status(401).json({ error: 'Microsoft authentication failed' }); + } +}); + +function getMicrosoftProfile(accessToken) { + return new Promise((resolve, reject) => { + const options = { + hostname: 'graph.microsoft.com', + path: '/v1.0/me', + headers: { Authorization: `Bearer ${accessToken}` } + }; + https.get(options, (resp) => { + let data = ''; + resp.on('data', chunk => data += chunk); + resp.on('end', () => { + try { resolve(JSON.parse(data)); } catch (e) { reject(e); } + }); + }).on('error', reject); + }); +} + +// ==================== User Management ==================== + +// Get current user +router.get('/me', requireAuth, (req, res) => { + res.json(req.user); +}); + +// Update current user +router.put('/me', requireAuth, (req, res) => { + const { name, password } = req.body; + if (name) { + db.prepare('UPDATE users SET name = ?, updated_at = strftime(\'%s\',\'now\') WHERE id = ?') + .run(name, req.user.id); + } + if (password && password.length >= 6) { + const hash = bcrypt.hashSync(password, 10); + db.prepare('UPDATE users SET password_hash = ?, updated_at = strftime(\'%s\',\'now\') WHERE id = ?') + .run(hash, req.user.id); + } + const user = db.prepare('SELECT id, email, name, role, auth_provider, avatar_url, plan_id FROM users WHERE id = ?').get(req.user.id); + res.json(user); +}); + +// List users - superadmins see all, admins see team members only +router.get('/users', requireAuth, requireAdmin, (req, res) => { + if (req.user.role === 'superadmin') { + const users = db.prepare('SELECT id, email, name, role, auth_provider, avatar_url, plan_id, created_at, last_login FROM users ORDER BY created_at ASC').all(); + res.json(users); + } else { + // Admin sees themselves + users in their teams + const users = db.prepare(` + SELECT DISTINCT u.id, u.email, u.name, u.role, u.auth_provider, u.avatar_url, u.plan_id, u.created_at + FROM users u + LEFT JOIN team_members tm ON u.id = tm.user_id + WHERE u.id = ? OR tm.team_id IN (SELECT team_id FROM team_members WHERE user_id = ?) + ORDER BY u.created_at ASC + `).all(req.user.id, req.user.id); + res.json(users); + } +}); + +// Delete user (superadmin only) +router.delete('/users/:id', requireAuth, requireSuperAdmin, (req, res) => { + if (req.params.id === req.user.id) return res.status(400).json({ error: 'Cannot delete yourself' }); + db.prepare('DELETE FROM users WHERE id = ?').run(req.params.id); + res.json({ success: true }); +}); + +// Update user role (superadmin only) +router.put('/users/:id/role', requireAuth, requireSuperAdmin, (req, res) => { + const { role } = req.body; + if (!['user', 'admin', 'superadmin'].includes(role)) return res.status(400).json({ error: 'Invalid role' }); + if (req.params.id === req.user.id && role !== 'superadmin') return res.status(400).json({ error: 'Cannot demote yourself' }); + db.prepare('UPDATE users SET role = ? WHERE id = ?').run(role, req.params.id); + res.json({ success: true }); +}); + +// Get auth config (public - tells frontend which providers are available) +router.get('/config', (req, res) => { + const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get().count; + res.json({ + googleEnabled: !!config.googleClientId, + googleClientId: config.googleClientId, + microsoftEnabled: !!config.microsoftClientId, + microsoftClientId: config.microsoftClientId, + microsoftTenantId: config.microsoftTenantId, + localEnabled: true, + needsSetup: userCount === 0, + }); +}); + +module.exports = router; diff --git a/server/routes/content.js b/server/routes/content.js new file mode 100644 index 0000000..6e4eb84 --- /dev/null +++ b/server/routes/content.js @@ -0,0 +1,310 @@ +const express = require('express'); +const router = express.Router(); +const path = require('path'); +const fs = require('fs'); +const { v4: uuidv4 } = require('uuid'); +const { db } = require('../db/database'); +const upload = require('../middleware/upload'); +const config = require('../config'); +const { checkStorageLimit, checkRemoteUrl } = require('../middleware/subscription'); + +// List content for current user (admins see all) +router.get('/', (req, res) => { + const isAdmin = req.user.role === 'superadmin'; + const folder = req.query.folder; + let sql = `SELECT * FROM content ${isAdmin ? 'WHERE 1=1' : 'WHERE (user_id = ? OR user_id IS NULL)'}`; + const params = isAdmin ? [] : [req.user.id]; + if (folder) { sql += ' AND folder = ?'; params.push(folder); } + sql += ' ORDER BY folder, created_at DESC LIMIT ? OFFSET ?'; + params.push(Math.min(parseInt(req.query.limit) || 100, 500), parseInt(req.query.offset) || 0); + const content = db.prepare(sql).all(...params); + res.json(content); +}); + +// Get folders list +router.get('/folders', (req, res) => { + const isAdmin = req.user.role === 'superadmin'; + const folders = db.prepare( + `SELECT folder, COUNT(*) as count FROM content WHERE folder IS NOT NULL ${isAdmin ? '' : 'AND (user_id = ? OR user_id IS NULL)'} GROUP BY folder ORDER BY folder` + ).all(...(isAdmin ? [] : [req.user.id])); + res.json(folders); +}); + +// Upload content +router.post('/', checkStorageLimit, upload.single('file'), async (req, res) => { + try { + if (!req.file) return res.status(400).json({ error: 'No file uploaded' }); + + const id = uuidv4(); + const filepath = req.file.filename; + let width = null, height = null, durationSec = null, thumbnailPath = null; + + // Try to generate thumbnail, get dimensions, and detect duration + try { + if (req.file.mimetype.startsWith('image/')) { + const sharp = require('sharp'); + const metadata = await sharp(req.file.path).metadata(); + width = metadata.width; + height = metadata.height; + + // Generate thumbnail + thumbnailPath = `thumb_${filepath}`; + await sharp(req.file.path) + .resize(config.thumbnailWidth) + .jpeg({ quality: 70 }) + .toFile(path.join(config.contentDir, thumbnailPath)); + } else if (req.file.mimetype.startsWith('video/')) { + // Extract video duration and dimensions with ffprobe + try { + const { execFileSync } = require('child_process'); + // Use execFileSync (not execSync) to prevent shell injection - args are NOT passed through shell + const probe = execFileSync('ffprobe', ['-v', 'quiet', '-print_format', 'json', '-show_format', '-show_streams', req.file.path], + { timeout: 15000 } + ).toString(); + const info = JSON.parse(probe); + if (info.format?.duration) durationSec = parseFloat(info.format.duration); + const videoStream = info.streams?.find(s => s.codec_type === 'video'); + if (videoStream) { + width = videoStream.width; + height = videoStream.height; + } + // Generate video thumbnail at 2 second mark + thumbnailPath = `thumb_${filepath.replace(/\.[^.]+$/, '.jpg')}`; + try { + execFileSync('ffmpeg', ['-y', '-i', req.file.path, '-ss', '2', '-vframes', '1', '-vf', `scale=${config.thumbnailWidth}:-1`, path.join(config.contentDir, thumbnailPath)], + { timeout: 15000 } + ); + } catch { thumbnailPath = null; } + } catch (e) { + console.warn('ffprobe failed:', e.message); + } + } + } catch (e) { + console.warn('Thumbnail/metadata generation failed:', e.message); + } + + db.prepare(` + INSERT INTO content (id, user_id, filename, filepath, mime_type, file_size, duration_sec, thumbnail_path, width, height) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run(id, req.user.id, req.file.originalname, filepath, req.file.mimetype, req.file.size, durationSec, thumbnailPath, width, height); + + const content = db.prepare('SELECT * FROM content WHERE id = ?').get(id); + res.status(201).json(content); + } catch (err) { + console.error('Upload error:', err); + res.status(500).json({ error: 'Upload failed' }); + } +}); + +// Add remote URL content +router.post('/remote', checkRemoteUrl, (req, res) => { + try { + const { url, name, mime_type } = req.body; + if (!url) return res.status(400).json({ error: 'url is required' }); + // Validate URL format + try { + const parsed = new URL(url); + if (!['http:', 'https:'].includes(parsed.protocol)) { + return res.status(400).json({ error: 'URL must use http or https' }); + } + // Block private/internal IPs (SSRF protection) + const hostname = parsed.hostname.toLowerCase(); + const isPrivate = hostname === 'localhost' || hostname === '0.0.0.0' || + hostname.startsWith('127.') || hostname.startsWith('10.') || + hostname.startsWith('192.168.') || hostname.startsWith('169.254.') || + /^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(hostname) || // 172.16.0.0 - 172.31.255.255 + hostname.startsWith('fc') || hostname.startsWith('fd') || hostname === '::1' || // IPv6 private + hostname.endsWith('.local') || hostname.endsWith('.internal'); + if (isPrivate) { + return res.status(400).json({ error: 'Internal URLs are not allowed' }); + } + } catch { + return res.status(400).json({ error: 'Invalid URL format' }); + } + + const id = uuidv4(); + const filename = name || url.split('/').pop()?.split('?')[0] || 'remote_content'; + const mimeType = mime_type || (url.match(/\.(mp4|webm|mkv|avi|mov)/i) ? 'video/mp4' : 'image/jpeg'); + + db.prepare(` + INSERT INTO content (id, user_id, filename, filepath, mime_type, file_size, remote_url) + VALUES (?, ?, ?, '', ?, 0, ?) + `).run(id, req.user.id, filename, mimeType, url); + + const content = db.prepare('SELECT * FROM content WHERE id = ?').get(id); + res.status(201).json(content); + } catch (err) { + console.error('Remote URL add error:', err); + res.status(500).json({ error: 'Failed to add remote URL' }); + } +}); + +// Add YouTube content (available to all plans - no storage used) +router.post('/youtube', (req, res) => { + try { + const { url, name } = req.body; + if (!url) return res.status(400).json({ error: 'url is required' }); + + // Extract YouTube video ID from various URL formats + const videoId = extractYoutubeId(url); + if (!videoId) return res.status(400).json({ error: 'Invalid YouTube URL' }); + + const id = uuidv4(); + const embedUrl = `https://www.youtube.com/embed/${videoId}?autoplay=1&controls=0&rel=0&modestbranding=1&loop=1&playlist=${videoId}`; + const thumbnailUrl = `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`; + const filename = name || `YouTube: ${videoId}`; + + db.prepare(` + INSERT INTO content (id, user_id, filename, filepath, mime_type, file_size, remote_url, thumbnail_path) + VALUES (?, ?, ?, '', 'video/youtube', 0, ?, ?) + `).run(id, req.user.id, filename, embedUrl, thumbnailUrl); + + const content = db.prepare('SELECT * FROM content WHERE id = ?').get(id); + res.status(201).json(content); + } catch (err) { + console.error('YouTube add error:', err); + res.status(500).json({ error: 'Failed to add YouTube video' }); + } +}); + +function extractYoutubeId(url) { + const patterns = [ + /(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/v\/|youtube\.com\/shorts\/)([a-zA-Z0-9_-]{11})/, + /^([a-zA-Z0-9_-]{11})$/ // bare video ID + ]; + for (const p of patterns) { + const m = url.match(p); + if (m) return m[1]; + } + return null; +} + +// Helper: check content ownership +function checkContentAccess(req, res) { + const content = db.prepare('SELECT * FROM content WHERE id = ?').get(req.params.id); + if (!content) { res.status(404).json({ error: 'Content not found' }); return null; } + if (!['admin','superadmin'].includes(req.user.role) && content.user_id && content.user_id !== req.user.id) { + res.status(403).json({ error: 'Access denied' }); return null; + } + return content; +} + +// Get content metadata +router.get('/:id', (req, res) => { + const content = checkContentAccess(req, res); + if (!content) return; + res.json(content); +}); + +// Update content metadata +router.put('/:id', (req, res) => { + const content = checkContentAccess(req, res); + if (!content) return; + + const { filename, mime_type, remote_url, folder } = req.body; + const updates = []; + const values = []; + if (filename !== undefined) { updates.push('filename = ?'); values.push(filename); } + if (mime_type !== undefined) { updates.push('mime_type = ?'); values.push(mime_type); } + if (remote_url !== undefined) { updates.push('remote_url = ?'); values.push(remote_url || null); } + if (folder !== undefined) { updates.push('folder = ?'); values.push(folder || null); } + + if (updates.length > 0) { + values.push(req.params.id); + db.prepare(`UPDATE content SET ${updates.join(', ')} WHERE id = ?`).run(...values); + } + + res.json(db.prepare('SELECT * FROM content WHERE id = ?').get(req.params.id)); +}); + +// Replace content file +router.put('/:id/replace', upload.single('file'), async (req, res) => { + const content = checkContentAccess(req, res); + if (!content) return; + if (!req.file) return res.status(400).json({ error: 'No file provided' }); + + // Delete old file + if (content.filepath) { + const oldPath = path.join(config.contentDir, content.filepath); + if (fs.existsSync(oldPath)) fs.unlinkSync(oldPath); + } + // Delete old thumbnail + if (content.thumbnail_path) { + const oldThumb = path.join(config.contentDir, content.thumbnail_path); + if (fs.existsSync(oldThumb)) fs.unlinkSync(oldThumb); + } + + const filepath = req.file.filename; + let width = null, height = null, thumbnailPath = null; + + // Generate new thumbnail for images + try { + if (req.file.mimetype.startsWith('image/')) { + const sharp = require('sharp'); + const metadata = await sharp(req.file.path).metadata(); + width = metadata.width; + height = metadata.height; + thumbnailPath = `thumb_${filepath}`; + await sharp(req.file.path).resize(config.thumbnailWidth).jpeg({ quality: 70 }) + .toFile(path.join(config.contentDir, thumbnailPath)); + } + } catch (e) { + console.warn('Thumbnail generation failed:', e.message); + } + + db.prepare(`UPDATE content SET filepath = ?, mime_type = ?, file_size = ?, thumbnail_path = ?, width = ?, height = ? WHERE id = ?`) + .run(filepath, req.file.mimetype, req.file.size, thumbnailPath, width, height, req.params.id); + + res.json(db.prepare('SELECT * FROM content WHERE id = ?').get(req.params.id)); +}); + +// Serve content file +router.get('/:id/file', (req, res) => { + const content = checkContentAccess(req, res); + if (!content) return; + if (!content.filepath) return res.status(404).json({ error: 'No file (remote URL content)' }); + // Prevent path traversal + const safePath = path.resolve(config.contentDir, path.basename(content.filepath)); + if (!safePath.startsWith(path.resolve(config.contentDir))) return res.status(403).json({ error: 'Invalid path' }); + res.sendFile(safePath); +}); + +// Serve thumbnail +router.get('/:id/thumbnail', (req, res) => { + const content = checkContentAccess(req, res); + if (!content) return; + if (!content.thumbnail_path) return res.status(404).json({ error: 'Thumbnail not found' }); + const safePath = path.resolve(config.contentDir, path.basename(content.thumbnail_path)); + if (!safePath.startsWith(path.resolve(config.contentDir))) return res.status(403).json({ error: 'Invalid path' }); + res.sendFile(safePath); +}); + +// Delete content +router.delete('/:id', (req, res) => { + const content = checkContentAccess(req, res); + if (!content) return; + + // Delete file from disk (skip for remote URL content) + if (content.filepath) { + const filePath = path.join(config.contentDir, content.filepath); + if (fs.existsSync(filePath)) fs.unlinkSync(filePath); + } + + // Delete thumbnail + if (content.thumbnail_path) { + const thumbPath = path.join(config.contentDir, content.thumbnail_path); + if (fs.existsSync(thumbPath)) fs.unlinkSync(thumbPath); + } + + // Get devices that have this content assigned (to notify them) + const affectedDevices = db.prepare( + 'SELECT DISTINCT device_id FROM assignments WHERE content_id = ?' + ).all(req.params.id); + + // Delete from DB (cascades to assignments) + db.prepare('DELETE FROM content WHERE id = ?').run(req.params.id); + + res.json({ success: true, affectedDevices: affectedDevices.map(d => d.device_id) }); +}); + +module.exports = router; diff --git a/server/routes/device-groups.js b/server/routes/device-groups.js new file mode 100644 index 0000000..36260c4 --- /dev/null +++ b/server/routes/device-groups.js @@ -0,0 +1,88 @@ +const express = require('express'); +const router = express.Router(); +const { v4: uuidv4 } = require('uuid'); +const { db } = require('../db/database'); + +// List groups +router.get('/', (req, res) => { + const groups = db.prepare(` + SELECT g.*, COUNT(dgm.device_id) as device_count + FROM device_groups g + LEFT JOIN device_group_members dgm ON g.id = dgm.group_id + WHERE g.user_id = ? + GROUP BY g.id + ORDER BY g.name ASC + `).all(req.user.id); + res.json(groups); +}); + +// Create group +router.post('/', (req, res) => { + const { name, color } = req.body; + if (!name) return res.status(400).json({ error: 'name required' }); + const id = uuidv4(); + db.prepare('INSERT INTO device_groups (id, user_id, name, color) VALUES (?, ?, ?, ?)') + .run(id, req.user.id, name, color || '#3B82F6'); + res.status(201).json(db.prepare('SELECT * FROM device_groups WHERE id = ?').get(id)); +}); + +// Update group +router.put('/:id', (req, res) => { + const { name, color } = req.body; + if (name) db.prepare('UPDATE device_groups SET name = ? WHERE id = ? AND user_id = ?').run(name, req.params.id, req.user.id); + if (color) db.prepare('UPDATE device_groups SET color = ? WHERE id = ? AND user_id = ?').run(color, req.params.id, req.user.id); + res.json(db.prepare('SELECT * FROM device_groups WHERE id = ?').get(req.params.id)); +}); + +// Delete group +router.delete('/:id', (req, res) => { + db.prepare('DELETE FROM device_groups WHERE id = ? AND user_id = ?').run(req.params.id, req.user.id); + res.json({ success: true }); +}); + +// Get devices in a group +router.get('/:id/devices', (req, res) => { + const devices = db.prepare(` + SELECT d.* FROM devices d + JOIN device_group_members dgm ON d.id = dgm.device_id + WHERE dgm.group_id = ? + ORDER BY d.name ASC + `).all(req.params.id); + res.json(devices); +}); + +// Add device to group +router.post('/:id/devices', (req, res) => { + const { device_id } = req.body; + if (!device_id) return res.status(400).json({ error: 'device_id required' }); + try { + db.prepare('INSERT OR IGNORE INTO device_group_members (device_id, group_id) VALUES (?, ?)').run(device_id, req.params.id); + res.status(201).json({ success: true }); + } catch (e) { + res.status(400).json({ error: e.message }); + } +}); + +// Remove device from group +router.delete('/:id/devices/:deviceId', (req, res) => { + db.prepare('DELETE FROM device_group_members WHERE device_id = ? AND group_id = ?').run(req.params.deviceId, req.params.id); + res.json({ success: true }); +}); + +// Bulk assign content to all devices in a group +router.post('/:id/assign-content', (req, res) => { + const { content_id, duration_sec } = req.body; + if (!content_id) return res.status(400).json({ error: 'content_id required' }); + + const devices = db.prepare('SELECT device_id FROM device_group_members WHERE group_id = ?').all(req.params.id); + const stmt = db.prepare('INSERT OR IGNORE INTO assignments (device_id, content_id, duration_sec, sort_order) VALUES (?, ?, ?, (SELECT COALESCE(MAX(sort_order),0)+1 FROM assignments WHERE device_id = ?))'); + const transaction = db.transaction(() => { + for (const d of devices) { + stmt.run(d.device_id, content_id, duration_sec || 10, d.device_id); + } + }); + transaction(); + res.json({ success: true, devices_updated: devices.length }); +}); + +module.exports = router; diff --git a/server/routes/devices.js b/server/routes/devices.js new file mode 100644 index 0000000..2f7ffc6 --- /dev/null +++ b/server/routes/devices.js @@ -0,0 +1,141 @@ +const express = require('express'); +const router = express.Router(); +const { db } = require('../db/database'); + +// List devices for current user (admins see all) +router.get('/', (req, res) => { + const isAdmin = req.user.role === 'superadmin'; + const devices = db.prepare(` + SELECT d.*, + t.battery_level, t.battery_charging, t.storage_free_mb, t.storage_total_mb, + t.ram_free_mb, t.ram_total_mb, t.wifi_ssid, t.wifi_rssi, t.uptime_seconds, + t.cpu_usage, + s.filepath as screenshot_path, s.captured_at as screenshot_at, + u.email as owner_email, u.name as owner_name + FROM devices d + LEFT JOIN users u ON d.user_id = u.id + LEFT JOIN ( + SELECT dt.* FROM device_telemetry dt + INNER JOIN (SELECT device_id, MAX(reported_at) as max_at FROM device_telemetry GROUP BY device_id) latest + ON dt.device_id = latest.device_id AND dt.reported_at = latest.max_at + ) t ON d.id = t.device_id + LEFT JOIN ( + SELECT sc.* FROM screenshots sc + INNER JOIN (SELECT device_id, MAX(captured_at) as max_at FROM screenshots GROUP BY device_id) latest + ON sc.device_id = latest.device_id AND sc.captured_at = latest.max_at + ) s ON d.id = s.device_id + ${isAdmin ? '' : 'WHERE (d.user_id = ? OR d.team_id IN (SELECT team_id FROM team_members WHERE user_id = ?))'} + ORDER BY d.created_at ASC + LIMIT ? OFFSET ? + `).all(...(isAdmin ? [] : [req.user.id, req.user.id]), Math.min(parseInt(req.query.limit) || 100, 500), parseInt(req.query.offset) || 0); + res.json(devices); +}); + +// Get single device with telemetry history +router.get('/:id', (req, res) => { + const device = db.prepare('SELECT d.*, u.email as owner_email, u.name as owner_name FROM devices d LEFT JOIN users u ON d.user_id = u.id WHERE d.id = ?').get(req.params.id); + if (!device) return res.status(404).json({ error: 'Device not found' }); + // Check access: admin, owner, or team member + if (!['admin','superadmin'].includes(req.user.role) && device.user_id !== req.user.id) { + const teamAccess = device.team_id ? db.prepare('SELECT role FROM team_members WHERE team_id = ? AND user_id = ?').get(device.team_id, req.user.id) : null; + if (!teamAccess) return res.status(403).json({ error: 'Access denied' }); + device._teamRole = teamAccess.role; // Pass team role for frontend to check + } + + const telemetry = db.prepare( + 'SELECT * FROM device_telemetry WHERE device_id = ? ORDER BY reported_at DESC LIMIT 20' + ).all(req.params.id); + + const screenshot = db.prepare( + 'SELECT * FROM screenshots WHERE device_id = ? ORDER BY captured_at DESC LIMIT 1' + ).get(req.params.id); + + const assignments = db.prepare(` + SELECT a.*, COALESCE(c.filename, w.name) as filename, c.mime_type, c.filepath, c.thumbnail_path, c.duration_sec as content_duration, c.remote_url, + w.name as widget_name, w.widget_type, w.config as widget_config + FROM assignments a + LEFT JOIN content c ON a.content_id = c.id + LEFT JOIN widgets w ON a.widget_id = w.id + WHERE a.device_id = ? + ORDER BY a.sort_order ASC + `).all(req.params.id); + + // Uptime timeline: get status change events for last 24 hours + const dayAgo = Math.floor(Date.now() / 1000) - 86400; + let statusLog = []; + try { + statusLog = db.prepare( + 'SELECT status, timestamp FROM device_status_log WHERE device_id = ? AND timestamp > ? ORDER BY timestamp ASC' + ).all(req.params.id, dayAgo); + } catch (_) {} + + // Also get telemetry timestamps as heartbeat proof (fills gaps between status events) + const uptimeData = db.prepare( + 'SELECT reported_at FROM device_telemetry WHERE device_id = ? AND reported_at > ? ORDER BY reported_at ASC' + ).all(req.params.id, dayAgo).map(r => r.reported_at); + + res.json({ ...device, telemetry, screenshot, assignments, uptimeData, statusLog }); +}); + +// Helper: check device ownership +function checkDeviceOwnership(req, res) { + const device = db.prepare('SELECT * FROM devices WHERE id = ?').get(req.params.id); + if (!device) { res.status(404).json({ error: 'Device not found' }); return null; } + if (!['admin','superadmin'].includes(req.user.role) && device.user_id && device.user_id !== req.user.id) { + // Check team membership + const teamAccess = device.team_id ? db.prepare('SELECT role FROM team_members WHERE team_id = ? AND user_id = ?').get(device.team_id, req.user.id) : null; + if (!teamAccess || teamAccess.role === 'viewer') { + res.status(403).json({ error: 'Access denied' }); return null; + } + } + return device; +} + +// Update device +router.put('/:id', (req, res) => { + const device = checkDeviceOwnership(req, res); + if (!device) return; + + const { name, notes, timezone, orientation, default_content_id } = req.body; + // Whitelist allowed fields to prevent SQL injection via field names + const ALLOWED_FIELDS = ['name', 'notes', 'timezone', 'orientation', 'default_content_id']; + const updates = []; + const values = []; + Object.entries({ name, notes, timezone, orientation, default_content_id }).forEach(([key, val]) => { + if (val !== undefined && ALLOWED_FIELDS.includes(key)) { + updates.push(`${key} = ?`); + values.push(val); + } + }); + if (updates.length > 0) { + values.push(req.params.id); + db.prepare(`UPDATE devices SET ${updates.join(', ')}, updated_at = strftime('%s','now') WHERE id = ?`).run(...values); + } + + const updated = db.prepare('SELECT * FROM devices WHERE id = ?').get(req.params.id); + res.json(updated); +}); + +// Delete device +router.delete('/:id', (req, res) => { + const device = checkDeviceOwnership(req, res); + if (!device) return; + + // Clean up related data + db.prepare('DELETE FROM assignments WHERE device_id = ?').run(req.params.id); + db.prepare('DELETE FROM schedules WHERE device_id = ?').run(req.params.id); + db.prepare('DELETE FROM screenshots WHERE device_id = ?').run(req.params.id); + db.prepare('DELETE FROM device_telemetry WHERE device_id = ?').run(req.params.id); + db.prepare('DELETE FROM video_wall_devices WHERE device_id = ?').run(req.params.id); + db.prepare('DELETE FROM devices WHERE id = ?').run(req.params.id); + + // Notify dashboard in real-time + const io = req.app.get('io'); + if (io) { + io.of('/dashboard').emit('dashboard:device-removed', { device_id: req.params.id }); + } + + res.json({ success: true }); +}); + +module.exports = router; diff --git a/server/routes/kiosk.js b/server/routes/kiosk.js new file mode 100644 index 0000000..9272048 --- /dev/null +++ b/server/routes/kiosk.js @@ -0,0 +1,227 @@ +const express = require('express'); +const router = express.Router(); +const { v4: uuidv4 } = require('uuid'); +const { db } = require('../db/database'); + +// Escape HTML to prevent XSS +function escapeHtml(str) { + if (typeof str !== 'string') return str; + return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); +} + +// Validate CSS color values to prevent style injection +function safeColor(val, fallback) { + if (!val) return fallback; + if (/^#[0-9a-fA-F]{3,8}$/.test(val) || /^[a-zA-Z]+$/.test(val)) return val; + return fallback; +} + +// Validate CSS numeric values +function safeNumber(val, fallback) { + const n = Number(val); + return isFinite(n) ? n : fallback; +} + +// List kiosk pages +router.get('/', (req, res) => { + const isAdmin = req.user.role === 'superadmin'; + const pages = db.prepare( + `SELECT * FROM kiosk_pages ${isAdmin ? '' : 'WHERE user_id = ?'} ORDER BY created_at DESC` + ).all(...(isAdmin ? [] : [req.user.id])); + res.json(pages); +}); + +// Helper: check kiosk ownership +function checkKioskAccess(req, res) { + const page = db.prepare('SELECT * FROM kiosk_pages WHERE id = ?').get(req.params.id); + if (!page) { res.status(404).json({ error: 'Page not found' }); return null; } + if (req.user && !['admin','superadmin'].includes(req.user.role) && page.user_id !== req.user.id) { + res.status(403).json({ error: 'Access denied' }); return null; + } + return page; +} + +// Get kiosk page +router.get('/:id', (req, res) => { + const page = checkKioskAccess(req, res); + if (!page) return; + res.json(page); +}); + +// Render kiosk page (public - accessed by devices) +router.get('/:id/render', (req, res) => { + const page = db.prepare('SELECT * FROM kiosk_pages WHERE id = ?').get(req.params.id); + if (!page) return res.status(404).send('Page not found'); + + const config = JSON.parse(page.config || '{}'); + const buttons = config.buttons || []; + const style = config.style || {}; + + const html = ` + + + + + +
+ ${config.logoUrl ? `Logo` : ''} +

${escapeHtml(config.title) || 'Welcome'}

+ ${config.subtitle ? `

${escapeHtml(config.subtitle)}

` : ''} +
+
+
+ ${buttons.map(btn => ` +
+ ${btn.icon ? `
${escapeHtml(btn.icon)}
` : ''} +
${escapeHtml(btn.label) || 'Button'}
+ ${btn.sublabel ? `
${escapeHtml(btn.sublabel)}
` : ''} +
+ `).join('')} +
+
+ + + +
+

${escapeHtml(config.idleTitle) || 'Touch to Begin'}

+

${escapeHtml(config.idleSubtitle) || ''}

+
+ + +`; + + res.setHeader('Content-Type', 'text/html'); + res.send(html); +}); + +// Create kiosk page +router.post('/', (req, res) => { + const { name, config: pageConfig } = req.body; + if (!name) return res.status(400).json({ error: 'name required' }); + + const id = uuidv4(); + db.prepare('INSERT INTO kiosk_pages (id, user_id, name, config) VALUES (?, ?, ?, ?)') + .run(id, req.user.id, name, JSON.stringify(pageConfig || getDefaultKioskConfig())); + + res.status(201).json(db.prepare('SELECT * FROM kiosk_pages WHERE id = ?').get(id)); +}); + +// Update kiosk page +router.put('/:id', (req, res) => { + const page = checkKioskAccess(req, res); + if (!page) return; + + const { name, config: pageConfig } = req.body; + if (name) db.prepare('UPDATE kiosk_pages SET name = ? WHERE id = ?').run(name, req.params.id); + if (pageConfig) db.prepare('UPDATE kiosk_pages SET config = ?, updated_at = strftime(\'%s\',\'now\') WHERE id = ?') + .run(JSON.stringify(pageConfig), req.params.id); + + res.json(db.prepare('SELECT * FROM kiosk_pages WHERE id = ?').get(req.params.id)); +}); + +// Delete kiosk page +router.delete('/:id', (req, res) => { + const page = checkKioskAccess(req, res); + if (!page) return; + db.prepare('DELETE FROM kiosk_pages WHERE id = ?').run(req.params.id); + res.json({ success: true }); +}); + +function getDefaultKioskConfig() { + return { + title: 'Welcome', + subtitle: 'How can we help you today?', + footer: '', + logoUrl: '', + idleTitle: 'Touch to Begin', + idleSubtitle: '', + idleTimeout: 60, + buttons: [ + { label: 'Directory', sublabel: 'Find a location', icon: '📍', action: 'page', page: '' }, + { label: 'Events', sublabel: 'See what\'s happening', icon: '📅', action: 'page', page: '' }, + { label: 'Map', sublabel: 'Building map', icon: '🗺', action: 'page', page: '' }, + { label: 'Contact', sublabel: 'Get in touch', icon: '📞', action: 'page', page: '' }, + { label: 'WiFi', sublabel: 'Connect to WiFi', icon: '📶', action: 'page', page: '' }, + { label: 'Help', sublabel: 'Need assistance?', icon: '❔', action: 'page', page: '' }, + ], + style: { + background: 'linear-gradient(135deg, #0c0c0c 0%, #1a1a2e 50%, #16213e 100%)', + textColor: '#f1f5f9', + columns: 3, + buttonBg: '#1e293b', + buttonBorder: '#334155', + buttonHover: '#3b82f6', + buttonRadius: 16, + buttonPadding: 32, + gap: 24, + titleSize: 48, + iconSize: 48, + labelSize: 20, + } + }; +} + +module.exports = router; diff --git a/server/routes/layouts.js b/server/routes/layouts.js new file mode 100644 index 0000000..6d153b0 --- /dev/null +++ b/server/routes/layouts.js @@ -0,0 +1,175 @@ +const express = require('express'); +const router = express.Router(); +const { v4: uuidv4 } = require('uuid'); +const { db } = require('../db/database'); + +// List layouts (user's + templates) +router.get('/', (req, res) => { + const showTemplates = req.query.templates === 'true'; + const isAdmin = req.user.role === 'superadmin'; + + let layouts; + if (showTemplates) { + layouts = db.prepare('SELECT * FROM layouts WHERE is_template = 1 ORDER BY template_category, name').all(); + } else { + layouts = db.prepare( + `SELECT * FROM layouts WHERE (user_id = ? OR is_template = 1) ${isAdmin ? 'OR 1=1' : ''} ORDER BY is_template DESC, created_at DESC` + ).all(req.user.id); + } + + // Attach zones to each layout + const zonesStmt = db.prepare('SELECT * FROM layout_zones WHERE layout_id = ? ORDER BY sort_order'); + layouts.forEach(l => { l.zones = zonesStmt.all(l.id); }); + + res.json(layouts); +}); + +// Get layout with zones +router.get('/:id', (req, res) => { + const layout = db.prepare('SELECT * FROM layouts WHERE id = ?').get(req.params.id); + if (!layout) return res.status(404).json({ error: 'Layout not found' }); + + layout.zones = db.prepare('SELECT * FROM layout_zones WHERE layout_id = ? ORDER BY sort_order').all(layout.id); + res.json(layout); +}); + +// Create layout +router.post('/', (req, res) => { + const { name, width, height, zones } = req.body; + if (!name) return res.status(400).json({ error: 'name required' }); + + const id = uuidv4(); + db.prepare('INSERT INTO layouts (id, user_id, name, width, height) VALUES (?, ?, ?, ?, ?)') + .run(id, req.user.id, name, width || 1920, height || 1080); + + // Create zones if provided + if (zones && Array.isArray(zones)) { + const stmt = db.prepare(` + INSERT INTO layout_zones (id, layout_id, name, x_percent, y_percent, width_percent, height_percent, z_index, zone_type, fit_mode, background_color, sort_order) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + zones.forEach((z, i) => { + stmt.run(uuidv4(), id, z.name || `Zone ${i + 1}`, z.x_percent || 0, z.y_percent || 0, + z.width_percent || 100, z.height_percent || 100, z.z_index || 0, + z.zone_type || 'content', z.fit_mode || 'cover', z.background_color || '#000000', i); + }); + } + + const layout = db.prepare('SELECT * FROM layouts WHERE id = ?').get(id); + layout.zones = db.prepare('SELECT * FROM layout_zones WHERE layout_id = ? ORDER BY sort_order').all(id); + res.status(201).json(layout); +}); + +// Update layout +router.put('/:id', (req, res) => { + const layout = db.prepare('SELECT * FROM layouts WHERE id = ?').get(req.params.id); + if (!layout) return res.status(404).json({ error: 'Layout not found' }); + if (layout.is_template && !['admin','superadmin'].includes(req.user.role)) return res.status(403).json({ error: 'Cannot edit templates' }); + + const { name, width, height } = req.body; + if (name) db.prepare('UPDATE layouts SET name = ?, updated_at = strftime(\'%s\',\'now\') WHERE id = ?').run(name, req.params.id); + if (width) db.prepare('UPDATE layouts SET width = ? WHERE id = ?').run(width, req.params.id); + if (height) db.prepare('UPDATE layouts SET height = ? WHERE id = ?').run(height, req.params.id); + + const updated = db.prepare('SELECT * FROM layouts WHERE id = ?').get(req.params.id); + updated.zones = db.prepare('SELECT * FROM layout_zones WHERE layout_id = ? ORDER BY sort_order').all(req.params.id); + res.json(updated); +}); + +// Delete layout +router.delete('/:id', (req, res) => { + const layout = db.prepare('SELECT * FROM layouts WHERE id = ?').get(req.params.id); + if (!layout) return res.status(404).json({ error: 'Layout not found' }); + if (layout.is_template && !['admin','superadmin'].includes(req.user.role)) return res.status(403).json({ error: 'Cannot delete templates' }); + + db.prepare('DELETE FROM layouts WHERE id = ?').run(req.params.id); + res.json({ success: true }); +}); + +// Add zone to layout +router.post('/:id/zones', (req, res) => { + const layout = db.prepare('SELECT * FROM layouts WHERE id = ?').get(req.params.id); + if (!layout) return res.status(404).json({ error: 'Layout not found' }); + + const { name, x_percent, y_percent, width_percent, height_percent, z_index, zone_type, fit_mode, background_color } = req.body; + const maxOrder = db.prepare('SELECT MAX(sort_order) as m FROM layout_zones WHERE layout_id = ?').get(req.params.id).m || 0; + + const id = uuidv4(); + db.prepare(` + INSERT INTO layout_zones (id, layout_id, name, x_percent, y_percent, width_percent, height_percent, z_index, zone_type, fit_mode, background_color, sort_order) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run(id, req.params.id, name || 'New Zone', x_percent || 0, y_percent || 0, + width_percent || 50, height_percent || 50, z_index || 0, + zone_type || 'content', fit_mode || 'cover', background_color || '#000000', maxOrder + 1); + + db.prepare("UPDATE layouts SET updated_at = strftime('%s','now') WHERE id = ?").run(req.params.id); + + const zone = db.prepare('SELECT * FROM layout_zones WHERE id = ?').get(id); + res.status(201).json(zone); +}); + +// Update zone +router.put('/:id/zones/:zoneId', (req, res) => { + const zone = db.prepare('SELECT * FROM layout_zones WHERE id = ? AND layout_id = ?').get(req.params.zoneId, req.params.id); + if (!zone) return res.status(404).json({ error: 'Zone not found' }); + + const fields = ['name', 'x_percent', 'y_percent', 'width_percent', 'height_percent', 'z_index', 'zone_type', 'fit_mode', 'background_color', 'sort_order']; + const updates = []; + const values = []; + fields.forEach(f => { + if (req.body[f] !== undefined) { updates.push(`${f} = ?`); values.push(req.body[f]); } + }); + + if (updates.length > 0) { + values.push(req.params.zoneId); + db.prepare(`UPDATE layout_zones SET ${updates.join(', ')} WHERE id = ?`).run(...values); + db.prepare("UPDATE layouts SET updated_at = strftime('%s','now') WHERE id = ?").run(req.params.id); + } + + const updated = db.prepare('SELECT * FROM layout_zones WHERE id = ?').get(req.params.zoneId); + res.json(updated); +}); + +// Delete zone +router.delete('/:id/zones/:zoneId', (req, res) => { + db.prepare('DELETE FROM layout_zones WHERE id = ? AND layout_id = ?').run(req.params.zoneId, req.params.id); + db.prepare("UPDATE layouts SET updated_at = strftime('%s','now') WHERE id = ?").run(req.params.id); + res.json({ success: true }); +}); + +// Duplicate layout (for using templates) +router.post('/:id/duplicate', (req, res) => { + const source = db.prepare('SELECT * FROM layouts WHERE id = ?').get(req.params.id); + if (!source) return res.status(404).json({ error: 'Layout not found' }); + + const newId = uuidv4(); + const name = req.body.name || `${source.name} (Copy)`; + + db.prepare('INSERT INTO layouts (id, user_id, name, width, height) VALUES (?, ?, ?, ?, ?)') + .run(newId, req.user.id, name, source.width, source.height); + + // Copy zones + const zones = db.prepare('SELECT * FROM layout_zones WHERE layout_id = ?').all(req.params.id); + const stmt = db.prepare(` + INSERT INTO layout_zones (id, layout_id, name, x_percent, y_percent, width_percent, height_percent, z_index, zone_type, fit_mode, background_color, sort_order) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + zones.forEach(z => { + stmt.run(uuidv4(), newId, z.name, z.x_percent, z.y_percent, z.width_percent, z.height_percent, + z.z_index, z.zone_type, z.fit_mode, z.background_color, z.sort_order); + }); + + const layout = db.prepare('SELECT * FROM layouts WHERE id = ?').get(newId); + layout.zones = db.prepare('SELECT * FROM layout_zones WHERE layout_id = ? ORDER BY sort_order').all(newId); + res.status(201).json(layout); +}); + +// Assign layout to device +router.put('/device/:deviceId', (req, res) => { + const { layout_id } = req.body; + db.prepare("UPDATE devices SET layout_id = ?, updated_at = strftime('%s','now') WHERE id = ?") + .run(layout_id || null, req.params.deviceId); + res.json({ success: true }); +}); + +module.exports = router; diff --git a/server/routes/provisioning.js b/server/routes/provisioning.js new file mode 100644 index 0000000..d0f7f59 --- /dev/null +++ b/server/routes/provisioning.js @@ -0,0 +1,23 @@ +const express = require('express'); +const router = express.Router(); +const { db } = require('../db/database'); + +// Provision (pair) a device by entering its pairing code +router.post('/', (req, res) => { + const { pairing_code } = req.body; + if (!pairing_code) return res.status(400).json({ error: 'pairing_code required' }); + + const device = db.prepare('SELECT * FROM devices WHERE pairing_code = ?').get(pairing_code); + if (!device) return res.status(404).json({ error: 'No device found with that pairing code' }); + + // Clear pairing code and set online + db.prepare(` + UPDATE devices SET pairing_code = NULL, status = 'online', updated_at = strftime('%s','now') + WHERE id = ? + `).run(device.id); + + const updated = db.prepare('SELECT * FROM devices WHERE id = ?').get(device.id); + res.json(updated); +}); + +module.exports = router; diff --git a/server/routes/reports.js b/server/routes/reports.js new file mode 100644 index 0000000..809535b --- /dev/null +++ b/server/routes/reports.js @@ -0,0 +1,167 @@ +const express = require('express'); +const router = express.Router(); +const { db } = require('../db/database'); + +// Helper: scope reports to user's devices +function getUserDeviceFilter(user) { + if (user.role === 'superadmin') return { sql: '', params: [] }; + return { sql: ' AND d.user_id = ?', params: [user.id] }; +} + +// Query play logs +router.get('/plays', (req, res) => { + const { device_id, content_id, start, end, limit: lim } = req.query; + const scope = getUserDeviceFilter(req.user); + let sql = `SELECT pl.*, d.name as device_name + FROM play_logs pl + JOIN devices d ON pl.device_id = d.id + WHERE 1=1${scope.sql}`; + const params = [...scope.params]; + + if (device_id) { sql += ' AND pl.device_id = ?'; params.push(device_id); } + if (content_id) { sql += ' AND pl.content_id = ?'; params.push(content_id); } + if (start) { sql += ' AND pl.started_at >= ?'; params.push(Math.floor(new Date(start).getTime() / 1000)); } + if (end) { sql += ' AND pl.started_at <= ?'; params.push(Math.floor(new Date(end).getTime() / 1000)); } + + sql += ' ORDER BY pl.started_at DESC LIMIT ?'; + params.push(parseInt(lim) || 500); + + res.json(db.prepare(sql).all(...params)); +}); + +// Summary report +router.get('/summary', (req, res) => { + const { device_id, start, end, group_by } = req.query; + const startEpoch = start ? Math.floor(new Date(start).getTime() / 1000) : Math.floor(Date.now() / 1000) - 30 * 86400; + const endEpoch = end ? Math.floor(new Date(end + 'T23:59:59').getTime() / 1000) : Math.floor(Date.now() / 1000); + + let deviceFilter = ''; + const params = [startEpoch, endEpoch]; + // Scope to user's devices (non-admin) + if (!['admin','superadmin'].includes(req.user.role)) { + deviceFilter += ' AND device_id IN (SELECT id FROM devices WHERE user_id = ?)'; + params.push(req.user.id); + } + if (device_id) { deviceFilter += ' AND device_id = ?'; params.push(device_id); } + + // Overall stats + const overall = db.prepare(` + SELECT COUNT(*) as total_plays, + COALESCE(SUM(duration_sec), 0) as total_duration_sec, + COUNT(DISTINCT content_id) as unique_content, + COUNT(DISTINCT device_id) as unique_devices, + AVG(duration_sec) as avg_duration_sec + FROM play_logs + WHERE started_at >= ? AND started_at <= ? ${deviceFilter} + `).get(...params); + + // By content + const byContent = db.prepare(` + SELECT content_id, content_name, COUNT(*) as plays, + COALESCE(SUM(duration_sec), 0) as total_seconds, + SUM(completed) as completed_plays + FROM play_logs + WHERE started_at >= ? AND started_at <= ? ${deviceFilter} + GROUP BY content_id, content_name + ORDER BY plays DESC LIMIT 50 + `).all(...params); + + // By device + const byDevice = db.prepare(` + SELECT pl.device_id, d.name as device_name, COUNT(*) as plays, + COALESCE(SUM(pl.duration_sec), 0) as total_seconds + FROM play_logs pl + JOIN devices d ON pl.device_id = d.id + WHERE pl.started_at >= ? AND pl.started_at <= ? ${deviceFilter} + GROUP BY pl.device_id + ORDER BY plays DESC + `).all(...params); + + // By hour of day + const byHour = db.prepare(` + SELECT CAST(strftime('%H', started_at, 'unixepoch', 'localtime') AS INTEGER) as hour, + COUNT(*) as plays + FROM play_logs + WHERE started_at >= ? AND started_at <= ? ${deviceFilter} + GROUP BY hour ORDER BY hour + `).all(...params); + + // By day + const byDay = db.prepare(` + SELECT date(started_at, 'unixepoch', 'localtime') as day, COUNT(*) as plays, + COALESCE(SUM(duration_sec), 0) as total_seconds + FROM play_logs + WHERE started_at >= ? AND started_at <= ? ${deviceFilter} + GROUP BY day ORDER BY day + `).all(...params); + + res.json({ + period: { start: new Date(startEpoch * 1000).toISOString(), end: new Date(endEpoch * 1000).toISOString() }, + overall: { + total_plays: overall.total_plays, + total_hours: Math.round(overall.total_duration_sec / 3600 * 10) / 10, + unique_content: overall.unique_content, + unique_devices: overall.unique_devices, + avg_duration_sec: Math.round(overall.avg_duration_sec || 0), + }, + by_content: byContent, + by_device: byDevice, + by_hour: byHour, + by_day: byDay, + }); +}); + +// Export CSV +router.get('/export', (req, res) => { + const { device_id, start, end } = req.query; + const startEpoch = start ? Math.floor(new Date(start).getTime() / 1000) : 0; + const endEpoch = end ? Math.floor(new Date(end + 'T23:59:59').getTime() / 1000) : Math.floor(Date.now() / 1000); + + let sql = `SELECT pl.*, d.name as device_name FROM play_logs pl JOIN devices d ON pl.device_id = d.id WHERE pl.started_at >= ? AND pl.started_at <= ?`; + const params = [startEpoch, endEpoch]; + if (device_id) { sql += ' AND pl.device_id = ?'; params.push(device_id); } + sql += ' ORDER BY pl.started_at ASC'; + + const rows = db.prepare(sql).all(...params); + + const header = 'Device,Content,Started,Ended,Duration (sec),Completed\n'; + const csv = header + rows.map(r => { + const started = new Date(r.started_at * 1000).toISOString(); + const ended = r.ended_at ? new Date(r.ended_at * 1000).toISOString() : ''; + return `"${r.device_name}","${r.content_name}","${started}","${ended}",${r.duration_sec || ''},${r.completed ? 'Yes' : 'No'}`; + }).join('\n'); + + res.setHeader('Content-Type', 'text/csv'); + res.setHeader('Content-Disposition', 'attachment; filename=proof-of-play.csv'); + res.send(csv); +}); + +// Device uptime report +router.get('/uptime', (req, res) => { + const { device_id, start, end } = req.query; + const startEpoch = start ? Math.floor(new Date(start).getTime() / 1000) : Math.floor(Date.now() / 1000) - 30 * 86400; + const endEpoch = end ? Math.floor(new Date(end + 'T23:59:59').getTime() / 1000) : Math.floor(Date.now() / 1000); + + let sql = `SELECT dt.device_id, d.name as device_name, + COUNT(*) as heartbeat_count, + MIN(dt.reported_at) as first_seen, + MAX(dt.reported_at) as last_seen + FROM device_telemetry dt + JOIN devices d ON dt.device_id = d.id + WHERE dt.reported_at >= ? AND dt.reported_at <= ?`; + const params = [startEpoch, endEpoch]; + if (device_id) { sql += ' AND dt.device_id = ?'; params.push(device_id); } + sql += ' GROUP BY dt.device_id ORDER BY d.name'; + + const uptimeData = db.prepare(sql).all(...params); + + // Estimate uptime: heartbeats are every 15s, so heartbeat_count * 15 / total_period + const totalPeriod = endEpoch - startEpoch; + uptimeData.forEach(d => { + d.estimated_uptime_pct = Math.min(100, Math.round((d.heartbeat_count * 15 / totalPeriod) * 100 * 10) / 10); + }); + + res.json(uptimeData); +}); + +module.exports = router; diff --git a/server/routes/schedules.js b/server/routes/schedules.js new file mode 100644 index 0000000..928f3c4 --- /dev/null +++ b/server/routes/schedules.js @@ -0,0 +1,190 @@ +const express = require('express'); +const router = express.Router(); +const { v4: uuidv4 } = require('uuid'); +const { db } = require('../db/database'); + +// List schedules (filterable) +router.get('/', (req, res) => { + const { device_id, start, end } = req.query; + let sql = 'SELECT s.*, c.filename as content_name, w.name as widget_name FROM schedules s LEFT JOIN content c ON s.content_id = c.id LEFT JOIN widgets w ON s.widget_id = w.id WHERE s.user_id = ?'; + const params = [req.user.id]; + + if (device_id) { sql += ' AND s.device_id = ?'; params.push(device_id); } + if (start) { sql += ' AND s.end_time >= ?'; params.push(start); } + if (end) { sql += ' AND s.start_time <= ?'; params.push(end); } + + sql += ' ORDER BY s.start_time ASC'; + res.json(db.prepare(sql).all(...params)); +}); + +// Get schedules for a device +router.get('/device/:deviceId', (req, res) => { + const schedules = db.prepare(` + SELECT s.*, c.filename as content_name, w.name as widget_name + FROM schedules s + LEFT JOIN content c ON s.content_id = c.id + LEFT JOIN widgets w ON s.widget_id = w.id + WHERE s.device_id = ? AND s.enabled = 1 + ORDER BY s.priority DESC, s.start_time ASC + `).all(req.params.deviceId); + res.json(schedules); +}); + +// Get expanded week view (resolves recurrences into individual events) +router.get('/week', (req, res) => { + const { date, device_id } = req.query; + if (!device_id) return res.status(400).json({ error: 'device_id required' }); + + const weekStart = date ? new Date(date) : new Date(); + weekStart.setHours(0, 0, 0, 0); + weekStart.setDate(weekStart.getDate() - weekStart.getDay()); + const weekEnd = new Date(weekStart); + weekEnd.setDate(weekEnd.getDate() + 7); + + const schedules = db.prepare(` + SELECT s.*, c.filename as content_name, w.name as widget_name + FROM schedules s + LEFT JOIN content c ON s.content_id = c.id + LEFT JOIN widgets w ON s.widget_id = w.id + WHERE s.device_id = ? AND s.enabled = 1 + ORDER BY s.priority DESC, s.start_time ASC + `).all(device_id); + + const events = []; + for (const s of schedules) { + const expanded = expandSchedule(s, weekStart, weekEnd); + events.push(...expanded); + } + + res.json(events); +}); + +// Create schedule +router.post('/', (req, res) => { + const { device_id, zone_id, content_id, widget_id, layout_id, title, start_time, end_time, + timezone, recurrence, recurrence_end, priority, color } = req.body; + + if (!device_id || !start_time || !end_time) { + return res.status(400).json({ error: 'device_id, start_time, and end_time required' }); + } + + const id = uuidv4(); + db.prepare(` + INSERT INTO schedules (id, user_id, device_id, zone_id, content_id, widget_id, layout_id, title, + start_time, end_time, timezone, recurrence, recurrence_end, priority, color) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run(id, req.user.id, device_id, zone_id || null, content_id || null, widget_id || null, + layout_id || null, title || '', start_time, end_time, timezone || 'UTC', + recurrence || null, recurrence_end || null, priority || 0, color || '#3B82F6'); + + const schedule = db.prepare('SELECT * FROM schedules WHERE id = ?').get(id); + res.status(201).json(schedule); +}); + +// Update schedule +router.put('/:id', (req, res) => { + const schedule = db.prepare('SELECT * FROM schedules WHERE id = ?').get(req.params.id); + if (!schedule) return res.status(404).json({ error: 'Schedule not found' }); + if (!['admin','superadmin'].includes(req.user.role) && schedule.user_id !== req.user.id) return res.status(403).json({ error: 'Access denied' }); + + const fields = ['device_id', 'zone_id', 'content_id', 'widget_id', 'layout_id', 'title', + 'start_time', 'end_time', 'timezone', 'recurrence', 'recurrence_end', 'priority', 'enabled', 'color']; + const updates = []; + const values = []; + fields.forEach(f => { + if (req.body[f] !== undefined) { updates.push(`${f} = ?`); values.push(req.body[f]); } + }); + + if (updates.length > 0) { + updates.push("updated_at = strftime('%s','now')"); + values.push(req.params.id); + db.prepare(`UPDATE schedules SET ${updates.join(', ')} WHERE id = ?`).run(...values); + } + + res.json(db.prepare('SELECT * FROM schedules WHERE id = ?').get(req.params.id)); +}); + +// Delete schedule +router.delete('/:id', (req, res) => { + const schedule = db.prepare('SELECT * FROM schedules WHERE id = ?').get(req.params.id); + if (!schedule) return res.status(404).json({ error: 'Schedule not found' }); + if (!['admin','superadmin'].includes(req.user.role) && schedule.user_id !== req.user.id) return res.status(403).json({ error: 'Access denied' }); + db.prepare('DELETE FROM schedules WHERE id = ?').run(req.params.id); + res.json({ success: true }); +}); + +// Helper: expand a schedule with recurrence into individual events for a date range +function expandSchedule(schedule, rangeStart, rangeEnd) { + const events = []; + const start = new Date(schedule.start_time); + const end = new Date(schedule.end_time); + const durationMs = end - start; + + if (!schedule.recurrence) { + if (end >= rangeStart && start <= rangeEnd) { + events.push({ ...schedule, instance_start: schedule.start_time, instance_end: schedule.end_time }); + } + return events; + } + + // Parse simple RRULE + const rule = parseRRule(schedule.recurrence); + if (!rule) { + events.push({ ...schedule, instance_start: schedule.start_time, instance_end: schedule.end_time }); + return events; + } + + const recEnd = schedule.recurrence_end ? new Date(schedule.recurrence_end) : rangeEnd; + let current = new Date(start); + let count = 0; + const maxIterations = 366; + + while (current <= rangeEnd && current <= recEnd && count < maxIterations) { + const instanceEnd = new Date(current.getTime() + durationMs); + + if (current >= rangeStart || instanceEnd >= rangeStart) { + const dayOfWeek = current.getDay(); + const matchesDay = !rule.byDay || rule.byDay.includes(dayOfWeek); + + if (matchesDay) { + events.push({ + ...schedule, + instance_start: current.toISOString(), + instance_end: instanceEnd.toISOString() + }); + } + } + + // Advance + switch (rule.freq) { + case 'DAILY': current.setDate(current.getDate() + (rule.interval || 1)); break; + case 'WEEKLY': current.setDate(current.getDate() + 7 * (rule.interval || 1)); break; + case 'MONTHLY': current.setMonth(current.getMonth() + (rule.interval || 1)); break; + default: current.setDate(current.getDate() + 1); + } + count++; + } + + return events; +} + +function parseRRule(rrule) { + if (!rrule) return null; + const parts = rrule.split(';'); + const rule = {}; + const dayMap = { SU: 0, MO: 1, TU: 2, WE: 3, TH: 4, FR: 5, SA: 6 }; + + for (const part of parts) { + const [key, val] = part.split('='); + switch (key) { + case 'FREQ': rule.freq = val; break; + case 'INTERVAL': rule.interval = parseInt(val); break; + case 'BYDAY': rule.byDay = val.split(',').map(d => dayMap[d]).filter(d => d !== undefined); break; + case 'COUNT': rule.count = parseInt(val); break; + case 'UNTIL': rule.until = val; break; + } + } + return rule; +} + +module.exports = router; diff --git a/server/routes/status.js b/server/routes/status.js new file mode 100644 index 0000000..c9e88cf --- /dev/null +++ b/server/routes/status.js @@ -0,0 +1,422 @@ +const express = require('express'); +const router = express.Router(); +const { db } = require('../db/database'); +const os = require('os'); +const path = require('path'); +const fs = require('fs'); +const config = require('../config'); + +// Public status page +router.get('/', (req, res) => { + const totalDevices = db.prepare('SELECT COUNT(*) as count FROM devices').get().count; + const onlineDevices = db.prepare("SELECT COUNT(*) as count FROM devices WHERE status = 'online'").get().count; + const totalContent = db.prepare('SELECT COUNT(*) as count FROM content').get().count; + const totalUsers = db.prepare('SELECT COUNT(*) as count FROM users').get().count; + const uptime = process.uptime(); + + // Public status - minimal info only (no user counts, no server internals) + let version = '1.5.1'; + try { version = require('fs').readFileSync(require('path').join(__dirname, '..', '..', 'VERSION'), 'utf8').trim(); } catch {} + + res.json({ + status: 'ok', + version, + uptime_human: formatUptime(uptime), + timestamp: new Date().toISOString(), + }); +}); + +function formatUptime(seconds) { + const d = Math.floor(seconds / 86400); + const h = Math.floor((seconds % 86400) / 3600); + const m = Math.floor((seconds % 3600) / 60); + if (d > 0) return `${d}d ${h}h ${m}m`; + if (h > 0) return `${h}h ${m}m`; + return `${m}m`; +} + +// Full database backup (superadmin only) +router.get('/backup', (req, res) => { + const token = req.query.token; + if (!token) return res.status(401).json({ error: 'Token required' }); + + try { + const jwt = require('jsonwebtoken'); + const config = require('../config'); + const decoded = jwt.verify(token, config.jwtSecret); + const user = db.prepare('SELECT role FROM users WHERE id = ?').get(decoded.id); + if (!user || user.role !== 'superadmin') return res.status(403).json({ error: 'Superadmin only' }); + } catch { + return res.status(401).json({ error: 'Invalid token' }); + } + + const dbPath = require('../config').dbPath; + res.download(dbPath, `remotedisplay-backup-${new Date().toISOString().split('T')[0]}.db`); +}); + +// User data export (own data only) +router.get('/export', (req, res) => { + const token = req.query.token; + if (!token) return res.status(401).json({ error: 'Token required' }); + + let userId; + try { + const jwt = require('jsonwebtoken'); + const config = require('../config'); + const decoded = jwt.verify(token, config.jwtSecret); + userId = decoded.id; + if (!userId) return res.status(401).json({ error: 'Invalid token' }); + } catch { + return res.status(401).json({ error: 'Invalid token' }); + } + + const user = db.prepare('SELECT id, email, name, role, auth_provider, plan_id, created_at FROM users WHERE id = ?').get(userId); + if (!user) return res.status(404).json({ error: 'User not found' }); + + const devices = db.prepare('SELECT id, name, status, ip_address, android_version, app_version, screen_width, screen_height, created_at FROM devices WHERE user_id = ?').all(userId); + const deviceIds = devices.map(d => d.id); + const devicePlaceholders = deviceIds.map(() => '?').join(',') || "'__none__'"; + + const content = db.prepare('SELECT id, filename, mime_type, file_size, duration_sec, remote_url, width, height, created_at FROM content WHERE user_id = ?').all(userId); + const widgets = db.prepare('SELECT id, widget_type, name, config, created_at FROM widgets WHERE user_id = ?').all(userId); + const layouts = db.prepare('SELECT id, name, width, height, is_template, template_category, created_at FROM layouts WHERE user_id = ? AND is_template = 0').all(userId); + const layoutIds = layouts.map(l => l.id); + const layoutPlaceholders = layoutIds.map(() => '?').join(',') || "'__none__'"; + const layoutZones = layoutIds.length ? db.prepare(`SELECT * FROM layout_zones WHERE layout_id IN (${layoutPlaceholders})`).all(...layoutIds) : []; + + const assignments = deviceIds.length ? db.prepare(`SELECT id, device_id, content_id, widget_id, zone_id, sort_order, duration_sec, enabled FROM assignments WHERE device_id IN (${devicePlaceholders})`).all(...deviceIds) : []; + const schedules = db.prepare('SELECT id, device_id, zone_id, content_id, widget_id, layout_id, title, start_time, end_time, timezone, recurrence, recurrence_end, priority, enabled, color, created_at FROM schedules WHERE user_id = ?').all(userId); + const videoWalls = db.prepare('SELECT * FROM video_walls WHERE user_id = ?').all(userId); + const wallIds = videoWalls.map(w => w.id); + const wallPlaceholders = wallIds.map(() => '?').join(',') || "'__none__'"; + const wallDevices = wallIds.length ? db.prepare(`SELECT * FROM video_wall_devices WHERE wall_id IN (${wallPlaceholders})`).all(...wallIds) : []; + + const kioskPages = db.prepare('SELECT id, name, config, created_at FROM kiosk_pages WHERE user_id = ?').all(userId); + const deviceGroups = db.prepare('SELECT id, name, color, created_at FROM device_groups WHERE user_id = ?').all(userId); + const groupIds = deviceGroups.map(g => g.id); + const groupPlaceholders = groupIds.map(() => '?').join(',') || "'__none__'"; + const groupMembers = groupIds.length ? db.prepare(`SELECT * FROM device_group_members WHERE group_id IN (${groupPlaceholders})`).all(...groupIds) : []; + const alertConfigs = db.prepare('SELECT id, alert_type, enabled, config, created_at FROM alert_configs WHERE user_id = ?').all(userId); + const whiteLabel = db.prepare('SELECT * FROM white_labels WHERE user_id = ?').get(userId); + + const exportData = { + format: 'screentinker-export-v1', + exported_at: new Date().toISOString(), + user, + devices, + content, + widgets: widgets.map(w => ({ ...w, config: JSON.parse(w.config || '{}') })), + layouts, + layout_zones: layoutZones, + assignments, + schedules, + video_walls: videoWalls, + video_wall_devices: wallDevices, + kiosk_pages: kioskPages.map(k => ({ ...k, config: JSON.parse(k.config || '{}') })), + device_groups: deviceGroups, + device_group_members: groupMembers, + alert_configs: alertConfigs.map(a => ({ ...a, config: JSON.parse(a.config || '{}') })), + white_label: whiteLabel || null, + }; + + // If include_files requested, bundle as ZIP with content files + if (req.query.include_files === 'true') { + const archiver = require('archiver'); + const dateStr = new Date().toISOString().split('T')[0]; + res.setHeader('Content-Type', 'application/zip'); + res.setHeader('Content-Disposition', `attachment; filename=screentinker-export-${dateStr}.zip`); + + const archive = archiver('zip', { zlib: { level: 5 } }); + archive.pipe(res); + + // Collect file info and add files to archive + const filesToInclude = []; + for (const c of exportData.content) { + if (c.remote_url || !c.filename) continue; + const row = db.prepare('SELECT filepath, thumbnail_path FROM content WHERE id = ?').get(c.id); + if (row?.filepath) { + const filePath = path.join(config.contentDir, path.basename(row.filepath)); + if (fs.existsSync(filePath)) { + c.original_filepath = path.basename(row.filepath); + archive.file(filePath, { name: `files/${c.id}/${c.original_filepath}` }); + } + } + if (row?.thumbnail_path) { + const thumbPath = path.join(config.contentDir, path.basename(row.thumbnail_path)); + if (fs.existsSync(thumbPath)) { + c.original_thumbnail = path.basename(row.thumbnail_path); + archive.file(thumbPath, { name: `files/${c.id}/${c.original_thumbnail}` }); + } + } + } + + // Add JSON manifest (after filepath fields are populated) + archive.append(JSON.stringify(exportData, null, 2), { name: 'export.json' }); + archive.finalize(); + return; + } + + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Content-Disposition', `attachment; filename=screentinker-export-${new Date().toISOString().split('T')[0]}.json`); + res.json(exportData); +}); + +// User data import (JSON or ZIP with files) +const multer = require('multer'); +const importUpload = multer({ dest: path.join(os.tmpdir(), 'screentinker-import'), limits: { fileSize: 2 * 1024 * 1024 * 1024 } }); // 2GB max + +router.post('/import', importUpload.single('file'), async (req, res) => { + const authHeader = req.headers.authorization; + if (!authHeader?.startsWith('Bearer ')) return res.status(401).json({ error: 'Token required' }); + + let userId; + try { + const jwt = require('jsonwebtoken'); + const jwtConfig = require('../config'); + const decoded = jwt.verify(authHeader.split(' ')[1], jwtConfig.jwtSecret); + userId = decoded.id; + if (!userId) return res.status(401).json({ error: 'Invalid token' }); + } catch { + return res.status(401).json({ error: 'Invalid token' }); + } + + const user = db.prepare('SELECT id, role FROM users WHERE id = ?').get(userId); + if (!user) return res.status(404).json({ error: 'User not found' }); + + let data; + let extractedFiles = {}; // Map of old content ID -> { filepath, thumbnail } + + if (req.file) { + // ZIP upload — extract export.json and files/ + try { + const unzipper = require('unzipper'); + const extractDir = path.join(os.tmpdir(), `screentinker-import-${Date.now()}`); + fs.mkdirSync(extractDir, { recursive: true }); + + await new Promise((resolve, reject) => { + fs.createReadStream(req.file.path) + .pipe(unzipper.Extract({ path: extractDir })) + .on('close', resolve) + .on('error', reject); + }); + + // Read the JSON manifest + const jsonPath = path.join(extractDir, 'export.json'); + if (!fs.existsSync(jsonPath)) { + fs.unlinkSync(req.file.path); + return res.status(400).json({ error: 'ZIP does not contain export.json' }); + } + data = JSON.parse(fs.readFileSync(jsonPath, 'utf8')); + + // Map extracted files by content ID, with path traversal validation + const filesDir = path.join(extractDir, 'files'); + const resolvedExtractDir = path.resolve(extractDir); + if (fs.existsSync(filesDir)) { + for (const contentDir of fs.readdirSync(filesDir)) { + const contentPath = path.resolve(filesDir, contentDir); + // Validate path is within extractDir to prevent directory traversal + if (!contentPath.startsWith(resolvedExtractDir)) continue; + if (!fs.statSync(contentPath).isDirectory()) continue; + const files = fs.readdirSync(contentPath); + extractedFiles[contentDir] = files.map(f => { + const filePath = path.resolve(contentPath, f); + // Validate each file path is within extractDir + if (!filePath.startsWith(resolvedExtractDir)) return null; + return { name: f, path: filePath }; + }).filter(Boolean); + } + } + + // Cleanup uploaded zip + fs.unlinkSync(req.file.path); + } catch (err) { + if (req.file?.path) try { fs.unlinkSync(req.file.path); } catch {} + return res.status(400).json({ error: 'Failed to extract ZIP: ' + err.message }); + } + } else { + data = req.body; + } + if (!data || !data.format || !data.format.startsWith('screentinker-export')) { + return res.status(400).json({ error: 'Invalid export file. Must be a ScreenTinker export JSON.' }); + } + + const uuid = require('uuid'); + const stats = { devices: 0, content: 0, widgets: 0, layouts: 0, schedules: 0, video_walls: 0, kiosk_pages: 0, device_groups: 0 }; + + // Map old IDs to new IDs + const idMap = { devices: {}, content: {}, widgets: {}, layouts: {}, zones: {}, groups: {}, walls: {}, kiosk: {} }; + + const importDb = db.transaction(() => { + // Import devices (as offline, unlinked - they'll need re-pairing) + for (const d of (data.devices || [])) { + const newId = uuid.v4(); + idMap.devices[d.id] = newId; + const pairingCode = String(Math.floor(100000 + Math.random() * 900000)); + db.prepare(`INSERT INTO devices (id, user_id, name, pairing_code, status, screen_width, screen_height, created_at) VALUES (?, ?, ?, ?, 'provisioning', ?, ?, ?)`).run(newId, userId, d.name, pairingCode, d.screen_width || null, d.screen_height || null, d.created_at || Math.floor(Date.now() / 1000)); + stats.devices++; + } + + // Import content metadata + files from ZIP if available + for (const c of (data.content || [])) { + const newId = uuid.v4(); + idMap.content[c.id] = newId; + + let newFilepath = ''; + let newThumbnail = null; + + // Copy files from ZIP extract if available + const files = extractedFiles[c.id]; + if (files && files.length > 0) { + for (const f of files) { + const ext = path.extname(f.name); + const destName = `${newId}${ext}`; + const destPath = path.join(config.contentDir, destName); + try { + fs.copyFileSync(f.path, destPath); + // Match original filepath vs thumbnail + if (c.original_filepath && f.name === c.original_filepath) { + newFilepath = destName; + } else if (c.original_thumbnail && f.name === c.original_thumbnail) { + newThumbnail = destName; + } else if (!newFilepath) { + // Fallback: first non-thumbnail file is the content + newFilepath = destName; + } + stats.files_restored = (stats.files_restored || 0) + 1; + } catch (err) { + // File copy failed, content will need re-upload + } + } + } + + db.prepare(`INSERT INTO content (id, user_id, filename, filepath, mime_type, file_size, duration_sec, remote_url, thumbnail_path, width, height, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(newId, userId, c.filename, newFilepath, c.mime_type, c.file_size || 0, c.duration_sec || null, c.remote_url || null, newThumbnail, c.width || null, c.height || null, c.created_at || Math.floor(Date.now() / 1000)); + stats.content++; + } + + // Import widgets + for (const w of (data.widgets || [])) { + const newId = uuid.v4(); + idMap.widgets[w.id] = newId; + const config = typeof w.config === 'string' ? w.config : JSON.stringify(w.config || {}); + db.prepare(`INSERT INTO widgets (id, user_id, widget_type, name, config, created_at) VALUES (?, ?, ?, ?, ?, ?)`).run(newId, userId, w.widget_type, w.name, config, w.created_at || Math.floor(Date.now() / 1000)); + stats.widgets++; + } + + // Import layouts and zones + for (const l of (data.layouts || [])) { + const newId = uuid.v4(); + idMap.layouts[l.id] = newId; + db.prepare(`INSERT INTO layouts (id, user_id, name, width, height, is_template, created_at) VALUES (?, ?, ?, ?, ?, 0, ?)`).run(newId, userId, l.name, l.width || 1920, l.height || 1080, l.created_at || Math.floor(Date.now() / 1000)); + stats.layouts++; + } + for (const z of (data.layout_zones || [])) { + const newLayoutId = idMap.layouts[z.layout_id]; + if (!newLayoutId) continue; + const newId = uuid.v4(); + idMap.zones[z.id] = newId; + db.prepare(`INSERT INTO layout_zones (id, layout_id, name, x_percent, y_percent, width_percent, height_percent, z_index, zone_type, fit_mode, background_color, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(newId, newLayoutId, z.name, z.x_percent, z.y_percent, z.width_percent, z.height_percent, z.z_index || 0, z.zone_type || 'content', z.fit_mode || 'cover', z.background_color || '#000000', z.sort_order || 0); + } + + // Import assignments + for (const a of (data.assignments || [])) { + const devId = idMap.devices[a.device_id]; + if (!devId) continue; + const contentId = a.content_id ? idMap.content[a.content_id] : null; + const widgetId = a.widget_id ? idMap.widgets[a.widget_id] : null; + const zoneId = a.zone_id ? (idMap.zones[a.zone_id] || null) : null; + if (!contentId && !widgetId) continue; + db.prepare(`INSERT INTO assignments (device_id, content_id, widget_id, zone_id, sort_order, duration_sec, enabled) VALUES (?, ?, ?, ?, ?, ?, ?)`).run(devId, contentId, widgetId, zoneId, a.sort_order || 0, a.duration_sec || 10, a.enabled !== undefined ? a.enabled : 1); + } + + // Import schedules + for (const s of (data.schedules || [])) { + const devId = idMap.devices[s.device_id]; + if (!devId) continue; + const newId = uuid.v4(); + db.prepare(`INSERT INTO schedules (id, user_id, device_id, zone_id, content_id, widget_id, layout_id, title, start_time, end_time, timezone, recurrence, recurrence_end, priority, enabled, color, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(newId, userId, devId, s.zone_id ? (idMap.zones[s.zone_id] || null) : null, s.content_id ? (idMap.content[s.content_id] || null) : null, s.widget_id ? (idMap.widgets[s.widget_id] || null) : null, s.layout_id ? (idMap.layouts[s.layout_id] || null) : null, s.title || '', s.start_time, s.end_time, s.timezone || 'UTC', s.recurrence || null, s.recurrence_end || null, s.priority || 0, s.enabled !== undefined ? s.enabled : 1, s.color || '#3B82F6', s.created_at || Math.floor(Date.now() / 1000)); + stats.schedules++; + } + + // Import video walls + for (const w of (data.video_walls || [])) { + const newId = uuid.v4(); + idMap.walls[w.id] = newId; + db.prepare(`INSERT INTO video_walls (id, user_id, name, grid_cols, grid_rows, bezel_h_mm, bezel_v_mm, screen_w_mm, screen_h_mm, sync_mode, content_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(newId, userId, w.name, w.grid_cols, w.grid_rows, w.bezel_h_mm || 0, w.bezel_v_mm || 0, w.screen_w_mm || 400, w.screen_h_mm || 225, w.sync_mode || 'leader', w.content_id ? (idMap.content[w.content_id] || null) : null, w.created_at || Math.floor(Date.now() / 1000)); + stats.video_walls++; + } + for (const wd of (data.video_wall_devices || [])) { + const wallId = idMap.walls[wd.wall_id]; + const devId = idMap.devices[wd.device_id]; + if (!wallId || !devId) continue; + db.prepare(`INSERT INTO video_wall_devices (wall_id, device_id, grid_col, grid_row, rotation) VALUES (?, ?, ?, ?, ?)`).run(wallId, devId, wd.grid_col, wd.grid_row, wd.rotation || 0); + } + + // Import kiosk pages + for (const k of (data.kiosk_pages || [])) { + const newId = uuid.v4(); + idMap.kiosk[k.id] = newId; + const config = typeof k.config === 'string' ? k.config : JSON.stringify(k.config || {}); + db.prepare(`INSERT INTO kiosk_pages (id, user_id, name, config, created_at) VALUES (?, ?, ?, ?, ?)`).run(newId, userId, k.name, config, k.created_at || Math.floor(Date.now() / 1000)); + stats.kiosk_pages++; + } + + // Import device groups + for (const g of (data.device_groups || [])) { + const newId = uuid.v4(); + idMap.groups[g.id] = newId; + db.prepare(`INSERT INTO device_groups (id, user_id, name, color, created_at) VALUES (?, ?, ?, ?, ?)`).run(newId, userId, g.name, g.color || '#3B82F6', g.created_at || Math.floor(Date.now() / 1000)); + stats.device_groups++; + } + for (const gm of (data.device_group_members || [])) { + const groupId = idMap.groups[gm.group_id]; + const devId = idMap.devices[gm.device_id]; + if (!groupId || !devId) continue; + db.prepare(`INSERT OR IGNORE INTO device_group_members (group_id, device_id) VALUES (?, ?)`).run(groupId, devId); + } + + // Import alert configs + for (const a of (data.alert_configs || [])) { + const newId = uuid.v4(); + const config = typeof a.config === 'string' ? a.config : JSON.stringify(a.config || {}); + db.prepare(`INSERT INTO alert_configs (id, user_id, alert_type, enabled, config, created_at) VALUES (?, ?, ?, ?, ?, ?)`).run(newId, userId, a.alert_type, a.enabled !== undefined ? a.enabled : 1, config, a.created_at || Math.floor(Date.now() / 1000)); + } + + // Import white label + if (data.white_label) { + const wl = data.white_label; + const existing = db.prepare('SELECT id FROM white_labels WHERE user_id = ?').get(userId); + if (existing) { + db.prepare(`UPDATE white_labels SET brand_name=?, logo_url=?, favicon_url=?, primary_color=?, bg_color=?, custom_domain=?, custom_css=?, hide_branding=?, updated_at=strftime('%s','now') WHERE user_id=?`).run(wl.brand_name || 'ScreenTinker', wl.logo_url || null, wl.favicon_url || null, wl.primary_color || '#3B82F6', wl.bg_color || '#111827', wl.custom_domain || null, wl.custom_css || null, wl.hide_branding || 0, userId); + } else { + db.prepare(`INSERT INTO white_labels (id, user_id, brand_name, logo_url, favicon_url, primary_color, bg_color, custom_domain, custom_css, hide_branding) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(uuid.v4(), userId, wl.brand_name || 'ScreenTinker', wl.logo_url || null, wl.favicon_url || null, wl.primary_color || '#3B82F6', wl.bg_color || '#111827', wl.custom_domain || null, wl.custom_css || null, wl.hide_branding || 0); + } + } + }); + + try { + importDb(); + // Collect pairing codes for imported devices + const devicePairings = (data.devices || []).map(d => { + const newId = idMap.devices[d.id]; + const dev = db.prepare('SELECT name, pairing_code FROM devices WHERE id = ?').get(newId); + return dev ? { name: dev.name, pairing_code: dev.pairing_code } : null; + }).filter(Boolean); + + res.json({ + success: true, + message: 'Import complete', + stats, + device_pairings: devicePairings, + notes: [ + 'Devices need to be re-paired. Use the pairing codes below or re-pair from the Displays page.', + stats.files_restored ? `${stats.files_restored} content files restored from export.` : 'File-based content needs to be re-uploaded. Remote URL content works immediately.', + 'All IDs have been regenerated to avoid conflicts.', + ] + }); + } catch (err) { + console.error('Import error:', err); + res.status(500).json({ error: 'Import failed: ' + err.message }); + } +}); + +module.exports = router; diff --git a/server/routes/stripe.js b/server/routes/stripe.js new file mode 100644 index 0000000..41340b4 --- /dev/null +++ b/server/routes/stripe.js @@ -0,0 +1,173 @@ +const express = require('express'); +const router = express.Router(); +const { db } = require('../db/database'); +const { requireAuth } = require('../middleware/auth'); +const config = require('../config'); + +const appUrl = process.env.APP_URL || ''; + +let stripe = null; +if (config.stripeSecretKey) { + stripe = require('stripe')(config.stripeSecretKey); +} + +// Create checkout session - user clicks "Upgrade" on a plan +router.post('/checkout', requireAuth, async (req, res) => { + if (!stripe) return res.status(503).json({ error: 'Stripe not configured' }); + + const { plan_id, interval } = req.body; // interval: 'monthly' or 'yearly' + if (!plan_id) return res.status(400).json({ error: 'plan_id required' }); + + const plan = db.prepare('SELECT * FROM plans WHERE id = ?').get(plan_id); + if (!plan) return res.status(404).json({ error: 'Plan not found' }); + + const priceId = interval === 'yearly' ? plan.stripe_price_yearly : plan.stripe_price_monthly; + if (!priceId) return res.status(400).json({ error: `No Stripe price configured for ${plan_id} (${interval || 'monthly'})` }); + + try { + // Get or create Stripe customer + let customerId = req.user.stripe_customer_id; + if (!customerId) { + const customer = await stripe.customers.create({ + email: req.user.email, + metadata: { user_id: req.user.id, name: req.user.name || '' }, + }); + customerId = customer.id; + db.prepare('UPDATE users SET stripe_customer_id = ? WHERE id = ?').run(customerId, req.user.id); + } + + // If user already has an active subscription, create a portal session to manage it + if (req.user.stripe_subscription_id) { + const portal = await stripe.billingPortal.sessions.create({ + customer: customerId, + return_url: `${req.headers.origin || appUrl}/#/settings`, + }); + return res.json({ url: portal.url, type: 'portal' }); + } + + // Create checkout session for new subscription + const session = await stripe.checkout.sessions.create({ + customer: customerId, + mode: 'subscription', + payment_method_types: ['card'], + line_items: [{ price: priceId, quantity: 1 }], + success_url: `${req.headers.origin || appUrl}/#/settings?payment=success`, + cancel_url: `${req.headers.origin || appUrl}/#/settings?payment=cancelled`, + metadata: { user_id: req.user.id, plan_id }, + subscription_data: { + metadata: { user_id: req.user.id, plan_id }, + }, + }); + + res.json({ url: session.url, type: 'checkout' }); + } catch (err) { + console.error('Stripe checkout error:', err.message); + res.status(500).json({ error: 'Failed to create checkout session' }); + } +}); + +// Customer portal - manage existing subscription (change plan, cancel, update payment) +router.post('/portal', requireAuth, async (req, res) => { + if (!stripe) return res.status(503).json({ error: 'Stripe not configured' }); + + const customerId = req.user.stripe_customer_id; + if (!customerId) return res.status(400).json({ error: 'No billing account found' }); + + try { + const session = await stripe.billingPortal.sessions.create({ + customer: customerId, + return_url: `${req.headers.origin || appUrl}/#/settings`, + }); + res.json({ url: session.url }); + } catch (err) { + console.error('Stripe portal error:', err.message); + res.status(500).json({ error: 'Failed to create portal session' }); + } +}); + +// Stripe webhook - handles all subscription lifecycle events +router.post('/webhook', express.raw({ type: 'application/json' }), async (req, res) => { + if (!stripe) return res.status(404).json({ error: 'Stripe not configured' }); + + let event; + try { + if (config.stripeWebhookSecret) { + event = stripe.webhooks.constructEvent(req.body, req.headers['stripe-signature'], config.stripeWebhookSecret); + } else { + event = JSON.parse(req.body.toString()); + } + } catch (err) { + console.error('Webhook signature verification failed:', err.message); + return res.status(400).json({ error: 'Invalid signature' }); + } + + console.log(`Stripe webhook: ${event.type}`); + + try { + switch (event.type) { + case 'checkout.session.completed': { + const session = event.data.object; + const userId = session.metadata?.user_id; + const planId = session.metadata?.plan_id; + if (userId && session.subscription) { + db.prepare(`UPDATE users SET stripe_subscription_id = ?, plan_id = ?, subscription_status = 'active', updated_at = strftime('%s','now') WHERE id = ?`) + .run(session.subscription, planId || 'starter', userId); + console.log(`User ${userId} subscribed to ${planId} (sub: ${session.subscription})`); + } + break; + } + + case 'customer.subscription.updated': { + const sub = event.data.object; + const userId = sub.metadata?.user_id; + if (!userId) break; + + // Find plan by stripe price ID + const priceId = sub.items?.data?.[0]?.price?.id; + let planId = sub.metadata?.plan_id; + if (priceId && !planId) { + const plan = db.prepare('SELECT id FROM plans WHERE stripe_price_monthly = ? OR stripe_price_yearly = ?').get(priceId, priceId); + if (plan) planId = plan.id; + } + + const status = sub.status === 'active' ? 'active' : sub.status === 'past_due' ? 'past_due' : sub.status; + const ends = sub.current_period_end || null; + + db.prepare(`UPDATE users SET plan_id = COALESCE(?, plan_id), subscription_status = ?, subscription_ends = ?, updated_at = strftime('%s','now') WHERE id = ?`) + .run(planId, status, ends, userId); + console.log(`Subscription updated for ${userId}: ${planId} (${status})`); + break; + } + + case 'customer.subscription.deleted': { + const sub = event.data.object; + const userId = sub.metadata?.user_id; + if (userId) { + db.prepare(`UPDATE users SET plan_id = 'free', subscription_status = 'cancelled', stripe_subscription_id = NULL, updated_at = strftime('%s','now') WHERE id = ?`) + .run(userId); + console.log(`Subscription cancelled for ${userId}`); + } + break; + } + + case 'invoice.payment_failed': { + const invoice = event.data.object; + const subId = invoice.subscription; + if (subId) { + const user = db.prepare('SELECT id FROM users WHERE stripe_subscription_id = ?').get(subId); + if (user) { + db.prepare("UPDATE users SET subscription_status = 'past_due', updated_at = strftime('%s','now') WHERE id = ?").run(user.id); + console.log(`Payment failed for user ${user.id}`); + } + } + break; + } + } + } catch (err) { + console.error('Webhook processing error:', err.message); + } + + res.json({ received: true }); +}); + +module.exports = router; diff --git a/server/routes/subscription.js b/server/routes/subscription.js new file mode 100644 index 0000000..015f9d2 --- /dev/null +++ b/server/routes/subscription.js @@ -0,0 +1,139 @@ +const express = require('express'); +const router = express.Router(); +const { db } = require('../db/database'); +const { requireAuth, requireAdmin, requireSuperAdmin } = require('../middleware/auth'); +const { getUserPlan, getUserDeviceCount, getUserStorageMB } = require('../middleware/subscription'); +const config = require('../config'); + +// Get all plans +router.get('/plans', (req, res) => { + const plans = db.prepare('SELECT * FROM plans WHERE active = 1 ORDER BY sort_order ASC').all(); + res.json(plans); +}); + +// Get current user's subscription info +router.get('/me', requireAuth, (req, res) => { + const plan = getUserPlan(req.user.id); + const deviceCount = getUserDeviceCount(req.user.id); + const storageMB = getUserStorageMB(req.user.id); + + res.json({ + plan: { + id: plan.plan_id, + name: plan.plan_name, + display_name: plan.plan_display_name, + max_devices: plan.max_devices, + max_storage_mb: plan.max_storage_mb, + remote_control: !!plan.remote_control, + remote_url: !!plan.remote_url, + priority_support: !!plan.priority_support, + price_monthly: plan.price_monthly, + price_yearly: plan.price_yearly, + }, + usage: { + devices: deviceCount, + devices_limit: plan.max_devices, + storage_mb: storageMB, + storage_limit_mb: plan.max_storage_mb, + }, + subscription: { + status: plan.subscription_status, + ends: plan.subscription_ends, + stripe_customer_id: plan.stripe_customer_id, + stripe_subscription_id: plan.stripe_subscription_id, + }, + trial: { + active: plan.trial_active || false, + days_left: plan.trial_days_left || 0, + end: plan.trial_end ? new Date(plan.trial_end * 1000).toISOString() : null, + plan: plan.trial_plan || null, + }, + self_hosted: config.selfHosted, + }); +}); + +// Admin: assign plan to user +router.post('/assign', requireAuth, requireSuperAdmin, (req, res) => { + const { user_id, plan_id } = req.body; + if (!user_id || !plan_id) return res.status(400).json({ error: 'user_id and plan_id required' }); + + const plan = db.prepare('SELECT * FROM plans WHERE id = ?').get(plan_id); + if (!plan) return res.status(404).json({ error: 'Plan not found' }); + + const user = db.prepare('SELECT * FROM users WHERE id = ?').get(user_id); + if (!user) return res.status(404).json({ error: 'User not found' }); + + db.prepare("UPDATE users SET plan_id = ?, subscription_status = 'active', updated_at = strftime('%s','now') WHERE id = ?") + .run(plan_id, user_id); + + res.json({ success: true, plan: plan.display_name }); +}); + +// Admin: update plan details +router.put('/plans/:id', requireAuth, requireAdmin, (req, res) => { + const plan = db.prepare('SELECT * FROM plans WHERE id = ?').get(req.params.id); + if (!plan) return res.status(404).json({ error: 'Plan not found' }); + + const { display_name, max_devices, max_storage_mb, remote_control, remote_url, + priority_support, price_monthly, price_yearly, active } = req.body; + + const updates = []; + const values = []; + if (display_name !== undefined) { updates.push('display_name = ?'); values.push(display_name); } + if (max_devices !== undefined) { updates.push('max_devices = ?'); values.push(max_devices); } + if (max_storage_mb !== undefined) { updates.push('max_storage_mb = ?'); values.push(max_storage_mb); } + if (remote_control !== undefined) { updates.push('remote_control = ?'); values.push(remote_control ? 1 : 0); } + if (remote_url !== undefined) { updates.push('remote_url = ?'); values.push(remote_url ? 1 : 0); } + if (priority_support !== undefined) { updates.push('priority_support = ?'); values.push(priority_support ? 1 : 0); } + if (price_monthly !== undefined) { updates.push('price_monthly = ?'); values.push(price_monthly); } + if (price_yearly !== undefined) { updates.push('price_yearly = ?'); values.push(price_yearly); } + if (active !== undefined) { updates.push('active = ?'); values.push(active ? 1 : 0); } + + if (updates.length > 0) { + values.push(req.params.id); + db.prepare(`UPDATE plans SET ${updates.join(', ')} WHERE id = ?`).run(...values); + } + + const updated = db.prepare('SELECT * FROM plans WHERE id = ?').get(req.params.id); + res.json(updated); +}); + +// Admin: create custom plan +router.post('/plans', requireAuth, requireAdmin, (req, res) => { + const { id, name, display_name, max_devices, max_storage_mb, remote_control, + remote_url, priority_support, price_monthly, price_yearly } = req.body; + + if (!id || !name || !display_name) return res.status(400).json({ error: 'id, name, and display_name required' }); + + const existing = db.prepare('SELECT id FROM plans WHERE id = ?').get(id); + if (existing) return res.status(409).json({ error: 'Plan ID already exists' }); + + const maxOrder = db.prepare('SELECT MAX(sort_order) as max_order FROM plans').get().max_order || 0; + + db.prepare(` + INSERT INTO plans (id, name, display_name, max_devices, max_storage_mb, remote_control, remote_url, + priority_support, price_monthly, price_yearly, sort_order) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run(id, name, display_name, max_devices || 2, max_storage_mb || 500, + remote_control ? 1 : 0, remote_url ? 1 : 0, priority_support ? 1 : 0, + price_monthly || 0, price_yearly || 0, maxOrder + 1); + + const plan = db.prepare('SELECT * FROM plans WHERE id = ?').get(id); + res.status(201).json(plan); +}); + +// Stripe webhook (if configured) +router.post('/webhook/stripe', express.raw({ type: 'application/json' }), (req, res) => { + if (!config.stripeSecretKey) return res.status(404).json({ error: 'Stripe not configured' }); + + // TODO: Implement Stripe webhook handling + // - customer.subscription.created -> activate plan + // - customer.subscription.updated -> update plan + // - customer.subscription.deleted -> downgrade to free + // - invoice.payment_succeeded -> extend subscription + // - invoice.payment_failed -> mark as past_due + + res.json({ received: true }); +}); + +module.exports = router; diff --git a/server/routes/teams.js b/server/routes/teams.js new file mode 100644 index 0000000..62e9289 --- /dev/null +++ b/server/routes/teams.js @@ -0,0 +1,189 @@ +const express = require('express'); +const router = express.Router(); +const { v4: uuidv4 } = require('uuid'); +const { db } = require('../db/database'); + +// List user's teams +router.get('/', (req, res) => { + const teams = db.prepare(` + SELECT t.*, tm.role as my_role, + (SELECT COUNT(*) FROM team_members WHERE team_id = t.id) as member_count + FROM teams t + JOIN team_members tm ON t.id = tm.team_id AND tm.user_id = ? + ORDER BY t.created_at ASC + `).all(req.user.id); + res.json(teams); +}); + +// Create team +router.post('/', (req, res) => { + const { name } = req.body; + if (!name) return res.status(400).json({ error: 'name required' }); + + const id = uuidv4(); + db.prepare('INSERT INTO teams (id, name, owner_id) VALUES (?, ?, ?)').run(id, name, req.user.id); + db.prepare('INSERT INTO team_members (team_id, user_id, role) VALUES (?, ?, ?)').run(id, req.user.id, 'owner'); + + const team = db.prepare('SELECT * FROM teams WHERE id = ?').get(id); + res.status(201).json(team); +}); + +// Get team with members +router.get('/:id', (req, res) => { + const team = db.prepare('SELECT * FROM teams WHERE id = ?').get(req.params.id); + if (!team) return res.status(404).json({ error: 'Team not found' }); + + const membership = db.prepare('SELECT * FROM team_members WHERE team_id = ? AND user_id = ?') + .get(req.params.id, req.user.id); + if (!membership && !['admin','superadmin'].includes(req.user.role)) return res.status(403).json({ error: 'Not a member' }); + + team.members = db.prepare(` + SELECT tm.*, u.email, u.name as user_name, u.avatar_url + FROM team_members tm JOIN users u ON tm.user_id = u.id + WHERE tm.team_id = ? + ORDER BY tm.role DESC, tm.joined_at ASC + `).all(req.params.id); + + team.invites = db.prepare('SELECT * FROM team_invites WHERE team_id = ? AND expires_at > ?') + .all(req.params.id, Math.floor(Date.now() / 1000)); + + res.json(team); +}); + +// Update team +router.put('/:id', (req, res) => { + const team = db.prepare('SELECT * FROM teams WHERE id = ?').get(req.params.id); + if (!team) return res.status(404).json({ error: 'Team not found' }); + if (team.owner_id !== req.user.id && !['admin','superadmin'].includes(req.user.role)) return res.status(403).json({ error: 'Owner only' }); + + if (req.body.name) { + db.prepare('UPDATE teams SET name = ? WHERE id = ?').run(req.body.name, req.params.id); + } + res.json(db.prepare('SELECT * FROM teams WHERE id = ?').get(req.params.id)); +}); + +// Delete team +router.delete('/:id', (req, res) => { + const team = db.prepare('SELECT * FROM teams WHERE id = ?').get(req.params.id); + if (!team) return res.status(404).json({ error: 'Team not found' }); + if (team.owner_id !== req.user.id && !['admin','superadmin'].includes(req.user.role)) return res.status(403).json({ error: 'Owner only' }); + + db.prepare('DELETE FROM teams WHERE id = ?').run(req.params.id); + res.json({ success: true }); +}); + +// Invite user +router.post('/:id/invite', (req, res) => { + const { email, role } = req.body; + if (!email) return res.status(400).json({ error: 'email required' }); + + const team = db.prepare('SELECT * FROM teams WHERE id = ?').get(req.params.id); + if (!team) return res.status(404).json({ error: 'Team not found' }); + + // Check if already a member + const user = db.prepare('SELECT id FROM users WHERE email = ?').get(email.toLowerCase()); + if (user) { + const existing = db.prepare('SELECT * FROM team_members WHERE team_id = ? AND user_id = ?') + .get(req.params.id, user.id); + if (existing) return res.status(409).json({ error: 'Already a member' }); + + // Direct add if user exists + db.prepare('INSERT INTO team_members (team_id, user_id, role, invited_by) VALUES (?, ?, ?, ?)') + .run(req.params.id, user.id, role || 'viewer', req.user.id); + return res.status(201).json({ success: true, added: true }); + } + + // Create invite for non-existing user + const id = uuidv4(); + const expiresAt = Math.floor(Date.now() / 1000) + 7 * 86400; // 7 days + db.prepare('INSERT INTO team_invites (id, team_id, email, role, invited_by, expires_at) VALUES (?, ?, ?, ?, ?, ?)') + .run(id, req.params.id, email.toLowerCase(), role || 'viewer', req.user.id, expiresAt); + + res.status(201).json({ success: true, invite_id: id, invited: true }); +}); + +// Accept invite +router.post('/accept/:inviteId', (req, res) => { + const invite = db.prepare('SELECT * FROM team_invites WHERE id = ? AND expires_at > ?') + .get(req.params.inviteId, Math.floor(Date.now() / 1000)); + if (!invite) return res.status(404).json({ error: 'Invite not found or expired' }); + + if (invite.email !== req.user.email) return res.status(403).json({ error: 'Invite is for a different email' }); + + db.prepare('INSERT OR IGNORE INTO team_members (team_id, user_id, role, invited_by) VALUES (?, ?, ?, ?)') + .run(invite.team_id, req.user.id, invite.role, invite.invited_by); + db.prepare('DELETE FROM team_invites WHERE id = ?').run(req.params.inviteId); + + res.json({ success: true }); +}); + +// Change member role (owner only) +router.put('/:id/members/:userId', (req, res) => { + const { role } = req.body; + if (!['viewer', 'editor', 'owner'].includes(role)) return res.status(400).json({ error: 'Invalid role' }); + + const team = db.prepare('SELECT * FROM teams WHERE id = ?').get(req.params.id); + if (!team) return res.status(404).json({ error: 'Team not found' }); + + // Only team owner or admin can change roles + const membership = db.prepare('SELECT * FROM team_members WHERE team_id = ? AND user_id = ?').get(req.params.id, req.user.id); + if (!['admin','superadmin'].includes(req.user.role) && (!membership || membership.role !== 'owner')) { + return res.status(403).json({ error: 'Only team owner can change roles' }); + } + + db.prepare('UPDATE team_members SET role = ? WHERE team_id = ? AND user_id = ?') + .run(role, req.params.id, req.params.userId); + res.json({ success: true }); +}); + +// Remove member (owner only) +router.delete('/:id/members/:userId', (req, res) => { + const team = db.prepare('SELECT * FROM teams WHERE id = ?').get(req.params.id); + if (!team) return res.status(404).json({ error: 'Team not found' }); + if (team.owner_id === req.params.userId) return res.status(400).json({ error: 'Cannot remove owner' }); + + const membership = db.prepare('SELECT * FROM team_members WHERE team_id = ? AND user_id = ?').get(req.params.id, req.user.id); + if (!['admin','superadmin'].includes(req.user.role) && (!membership || membership.role !== 'owner')) { + return res.status(403).json({ error: 'Only team owner can remove members' }); + } + + db.prepare('DELETE FROM team_members WHERE team_id = ? AND user_id = ?') + .run(req.params.id, req.params.userId); + res.json({ success: true }); +}); + +// Check team membership or admin role +function checkTeamAccess(req, res) { + const membership = db.prepare('SELECT * FROM team_members WHERE team_id = ? AND user_id = ?') + .get(req.params.id, req.user.id); + if (!membership && !['admin','superadmin'].includes(req.user.role)) { + res.status(403).json({ error: 'Not a team member' }); + return false; + } + return true; +} + +// Assign device to team +router.post('/:id/devices', (req, res) => { + if (!checkTeamAccess(req, res)) return; + const { device_id } = req.body; + if (!device_id) return res.status(400).json({ error: 'device_id required' }); + db.prepare('UPDATE devices SET team_id = ? WHERE id = ?').run(req.params.id, device_id); + res.json({ success: true }); +}); + +// Remove device from team +router.delete('/:id/devices/:deviceId', (req, res) => { + if (!checkTeamAccess(req, res)) return; + db.prepare('UPDATE devices SET team_id = NULL WHERE id = ? AND team_id = ?').run(req.params.deviceId, req.params.id); + res.json({ success: true }); +}); + +// Get team's devices +router.get('/:id/devices', (req, res) => { + if (!checkTeamAccess(req, res)) return; + const devices = db.prepare('SELECT * FROM devices WHERE team_id = ?').all(req.params.id); + res.json(devices); +}); + +module.exports = router; diff --git a/server/routes/video-walls.js b/server/routes/video-walls.js new file mode 100644 index 0000000..e585acf --- /dev/null +++ b/server/routes/video-walls.js @@ -0,0 +1,184 @@ +const express = require('express'); +const router = express.Router(); +const { v4: uuidv4 } = require('uuid'); +const { db } = require('../db/database'); + +// List walls +router.get('/', (req, res) => { + const isAdmin = req.user.role === 'superadmin'; + const walls = db.prepare( + `SELECT * FROM video_walls ${isAdmin ? '' : 'WHERE user_id = ?'} ORDER BY created_at DESC` + ).all(...(isAdmin ? [] : [req.user.id])); + + // Attach devices to each wall + const devStmt = db.prepare(` + SELECT vwd.*, d.name as device_name, d.status as device_status + FROM video_wall_devices vwd + JOIN devices d ON vwd.device_id = d.id + WHERE vwd.wall_id = ? + ORDER BY vwd.grid_row, vwd.grid_col + `); + walls.forEach(w => { w.devices = devStmt.all(w.id); }); + + res.json(walls); +}); + +// Helper: check wall ownership +function checkWallAccess(req, res) { + const wall = db.prepare('SELECT * FROM video_walls WHERE id = ?').get(req.params.id); + if (!wall) { res.status(404).json({ error: 'Wall not found' }); return null; } + if (!['admin','superadmin'].includes(req.user.role) && wall.user_id !== req.user.id) { res.status(403).json({ error: 'Access denied' }); return null; } + return wall; +} + +// Get wall with devices +router.get('/:id', (req, res) => { + const wall = checkWallAccess(req, res); + if (!wall) return; + + wall.devices = db.prepare(` + SELECT vwd.*, d.name as device_name, d.status as device_status + FROM video_wall_devices vwd + JOIN devices d ON vwd.device_id = d.id + WHERE vwd.wall_id = ? + ORDER BY vwd.grid_row, vwd.grid_col + `).all(wall.id); + + res.json(wall); +}); + +// Create wall +router.post('/', (req, res) => { + const { name, grid_cols, grid_rows, bezel_h_mm, bezel_v_mm, screen_w_mm, screen_h_mm } = req.body; + if (!name) return res.status(400).json({ error: 'name required' }); + + const id = uuidv4(); + db.prepare(` + INSERT INTO video_walls (id, user_id, name, grid_cols, grid_rows, bezel_h_mm, bezel_v_mm, screen_w_mm, screen_h_mm) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run(id, req.user.id, name, grid_cols || 2, grid_rows || 2, + bezel_h_mm || 0, bezel_v_mm || 0, screen_w_mm || 400, screen_h_mm || 225); + + const wall = db.prepare('SELECT * FROM video_walls WHERE id = ?').get(id); + wall.devices = []; + res.status(201).json(wall); +}); + +// Update wall +router.put('/:id', (req, res) => { + const wall = checkWallAccess(req, res); + if (!wall) return; + + const fields = ['name', 'grid_cols', 'grid_rows', 'bezel_h_mm', 'bezel_v_mm', + 'screen_w_mm', 'screen_h_mm', 'sync_mode', 'leader_device_id', 'content_id']; + const updates = []; + const values = []; + fields.forEach(f => { + if (req.body[f] !== undefined) { updates.push(`${f} = ?`); values.push(req.body[f]); } + }); + + if (updates.length > 0) { + updates.push("updated_at = strftime('%s','now')"); + values.push(req.params.id); + db.prepare(`UPDATE video_walls SET ${updates.join(', ')} WHERE id = ?`).run(...values); + } + + const updated = db.prepare('SELECT * FROM video_walls WHERE id = ?').get(req.params.id); + updated.devices = db.prepare(` + SELECT vwd.*, d.name as device_name, d.status as device_status + FROM video_wall_devices vwd JOIN devices d ON vwd.device_id = d.id + WHERE vwd.wall_id = ? ORDER BY vwd.grid_row, vwd.grid_col + `).all(req.params.id); + + res.json(updated); +}); + +// Delete wall +router.delete('/:id', (req, res) => { + const wall = checkWallAccess(req, res); + if (!wall) return; + db.prepare("UPDATE devices SET wall_id = NULL WHERE wall_id = ?").run(req.params.id); + db.prepare('DELETE FROM video_walls WHERE id = ?').run(req.params.id); + res.json({ success: true }); +}); + +// Set device grid positions +router.put('/:id/devices', (req, res) => { + const { devices } = req.body; + if (!Array.isArray(devices)) return res.status(400).json({ error: 'devices array required' }); + + const wall = checkWallAccess(req, res); + if (!wall) return; + + // Clear existing + db.prepare('DELETE FROM video_wall_devices WHERE wall_id = ?').run(req.params.id); + db.prepare("UPDATE devices SET wall_id = NULL WHERE wall_id = ?").run(req.params.id); + + // Add new positions + const stmt = db.prepare('INSERT INTO video_wall_devices (wall_id, device_id, grid_col, grid_row, rotation) VALUES (?, ?, ?, ?, ?)'); + const updateDevice = db.prepare("UPDATE devices SET wall_id = ? WHERE id = ?"); + + const transaction = db.transaction(() => { + devices.forEach(d => { + stmt.run(req.params.id, d.device_id, d.grid_col, d.grid_row, d.rotation || 0); + updateDevice.run(req.params.id, d.device_id); + }); + // Set first device as leader if none set + if (!wall.leader_device_id && devices.length > 0) { + const leader = devices.find(d => d.grid_col === 0 && d.grid_row === 0) || devices[0]; + db.prepare('UPDATE video_walls SET leader_device_id = ? WHERE id = ?').run(leader.device_id, req.params.id); + } + }); + transaction(); + + const updated = db.prepare('SELECT * FROM video_walls WHERE id = ?').get(req.params.id); + updated.devices = db.prepare(` + SELECT vwd.*, d.name as device_name, d.status as device_status + FROM video_wall_devices vwd JOIN devices d ON vwd.device_id = d.id + WHERE vwd.wall_id = ? ORDER BY vwd.grid_row, vwd.grid_col + `).all(req.params.id); + + res.json(updated); +}); + +// Set wall content +router.put('/:id/content', (req, res) => { + const { content_id } = req.body; + db.prepare("UPDATE video_walls SET content_id = ?, updated_at = strftime('%s','now') WHERE id = ?") + .run(content_id || null, req.params.id); + res.json({ success: true }); +}); + +// Get wall config for a specific device (used by Android app) +router.get('/:id/device-config/:deviceId', (req, res) => { + const wall = db.prepare('SELECT * FROM video_walls WHERE id = ?').get(req.params.id); + if (!wall) return res.status(404).json({ error: 'Wall not found' }); + + const position = db.prepare('SELECT * FROM video_wall_devices WHERE wall_id = ? AND device_id = ?') + .get(req.params.id, req.params.deviceId); + if (!position) return res.status(404).json({ error: 'Device not in this wall' }); + + // Calculate crop region + const totalW = wall.grid_cols * wall.screen_w_mm + (wall.grid_cols - 1) * wall.bezel_h_mm; + const totalH = wall.grid_rows * wall.screen_h_mm + (wall.grid_rows - 1) * wall.bezel_v_mm; + + const cropX = (position.grid_col * (wall.screen_w_mm + wall.bezel_h_mm)) / totalW; + const cropY = (position.grid_row * (wall.screen_h_mm + wall.bezel_v_mm)) / totalH; + const cropW = wall.screen_w_mm / totalW; + const cropH = wall.screen_h_mm / totalH; + + res.json({ + wall_id: wall.id, + grid_cols: wall.grid_cols, + grid_rows: wall.grid_rows, + grid_col: position.grid_col, + grid_row: position.grid_row, + rotation: position.rotation, + crop: { x: cropX, y: cropY, width: cropW, height: cropH }, + content_id: wall.content_id, + sync_mode: wall.sync_mode, + is_leader: wall.leader_device_id === req.params.deviceId, + }); +}); + +module.exports = router; diff --git a/server/routes/white-label.js b/server/routes/white-label.js new file mode 100644 index 0000000..37fe7b8 --- /dev/null +++ b/server/routes/white-label.js @@ -0,0 +1,54 @@ +const express = require('express'); +const router = express.Router(); +const { v4: uuidv4 } = require('uuid'); +const { db } = require('../db/database'); + +// Get current user's white-label config +router.get('/', (req, res) => { + let wl = db.prepare('SELECT * FROM white_labels WHERE user_id = ?').get(req.user.id); + if (!wl) { + // Return default branding + wl = { brand_name: 'ScreenTinker', primary_color: '#3B82F6', secondary_color: '#1E293B', bg_color: '#111827', hide_branding: 0 }; + } + res.json(wl); +}); + +// Get branding by domain (public, for white-label domains) +router.get('/domain/:domain', (req, res) => { + const wl = db.prepare('SELECT * FROM white_labels WHERE custom_domain = ?').get(req.params.domain); + if (!wl) return res.json({ brand_name: 'ScreenTinker', primary_color: '#3B82F6' }); + res.json(wl); +}); + +// Create or update white-label config +router.post('/', (req, res) => { + const { brand_name, logo_url, favicon_url, primary_color, secondary_color, bg_color, + custom_domain, custom_css, hide_branding } = req.body; + + let wl = db.prepare('SELECT * FROM white_labels WHERE user_id = ?').get(req.user.id); + + if (wl) { + const fields = { brand_name, logo_url, favicon_url, primary_color, secondary_color, bg_color, custom_domain, custom_css, hide_branding }; + const updates = []; + const values = []; + Object.entries(fields).forEach(([k, v]) => { + if (v !== undefined) { updates.push(`${k} = ?`); values.push(v); } + }); + if (updates.length) { + updates.push("updated_at = strftime('%s','now')"); + values.push(req.user.id); + db.prepare(`UPDATE white_labels SET ${updates.join(', ')} WHERE user_id = ?`).run(...values); + } + } else { + const id = uuidv4(); + db.prepare(`INSERT INTO white_labels (id, user_id, brand_name, logo_url, favicon_url, primary_color, secondary_color, bg_color, custom_domain, custom_css, hide_branding) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run( + id, req.user.id, brand_name || 'ScreenTinker', logo_url || null, favicon_url || null, + primary_color || '#3B82F6', secondary_color || '#1E293B', bg_color || '#111827', + custom_domain || null, custom_css || null, hide_branding ? 1 : 0); + } + + res.json(db.prepare('SELECT * FROM white_labels WHERE user_id = ?').get(req.user.id)); +}); + +module.exports = router; diff --git a/server/routes/widgets.js b/server/routes/widgets.js new file mode 100644 index 0000000..cbb3b93 --- /dev/null +++ b/server/routes/widgets.js @@ -0,0 +1,239 @@ +const express = require('express'); +const router = express.Router(); +const { v4: uuidv4 } = require('uuid'); +const { db } = require('../db/database'); + +// Escape HTML to prevent XSS +function escapeHtml(str) { + if (typeof str !== 'string') return str; + return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); +} + +// Validate timezone format (e.g. America/New_York, UTC, Etc/GMT+5) +function safeTimezone(tz) { + if (!tz) return 'UTC'; + return /^[A-Za-z_\-\/+0-9]+$/.test(tz) ? tz : 'UTC'; +} + +// Validate ISO date string format +function safeDateString(d) { + if (!d) return ''; + return /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2})?)?/.test(d) ? d : ''; +} + +// Validate URL is http/https +function safeUrl(url) { + if (!url) return 'about:blank'; + try { + const parsed = new URL(url); + return ['http:', 'https:'].includes(parsed.protocol) ? url : 'about:blank'; + } catch { return 'about:blank'; } +} + +// List widgets +router.get('/', (req, res) => { + const isAdmin = req.user.role === 'superadmin'; + const widgets = db.prepare( + `SELECT * FROM widgets ${isAdmin ? '' : 'WHERE user_id = ? OR user_id IS NULL'} ORDER BY created_at DESC` + ).all(...(isAdmin ? [] : [req.user.id])); + res.json(widgets); +}); + +// Create widget +router.post('/', (req, res) => { + const { widget_type, name, config } = req.body; + if (!widget_type || !name) return res.status(400).json({ error: 'widget_type and name required' }); + + const id = uuidv4(); + db.prepare('INSERT INTO widgets (id, user_id, widget_type, name, config) VALUES (?, ?, ?, ?, ?)') + .run(id, req.user.id, widget_type, name, JSON.stringify(config || {})); + + res.status(201).json(db.prepare('SELECT * FROM widgets WHERE id = ?').get(id)); +}); + +// Helper: check widget ownership +function checkWidgetAccess(req, res) { + const widget = db.prepare('SELECT * FROM widgets WHERE id = ?').get(req.params.id); + if (!widget) { res.status(404).json({ error: 'Widget not found' }); return null; } + // Allow access if: admin, owner, no owner (public), or render route (no req.user) + if (req.user && !['admin','superadmin'].includes(req.user.role) && widget.user_id && widget.user_id !== req.user.id) { + res.status(403).json({ error: 'Access denied' }); return null; + } + return widget; +} + +// Get widget +router.get('/:id', (req, res) => { + const widget = checkWidgetAccess(req, res); + if (!widget) return; + res.json(widget); +}); + +// Update widget +router.put('/:id', (req, res) => { + const widget = checkWidgetAccess(req, res); + if (!widget) return; + + const { name, config } = req.body; + if (name) db.prepare('UPDATE widgets SET name = ?, updated_at = strftime(\'%s\',\'now\') WHERE id = ?').run(name, req.params.id); + if (config) db.prepare('UPDATE widgets SET config = ?, updated_at = strftime(\'%s\',\'now\') WHERE id = ?').run(JSON.stringify(config), req.params.id); + + res.json(db.prepare('SELECT * FROM widgets WHERE id = ?').get(req.params.id)); +}); + +// Delete widget +router.delete('/:id', (req, res) => { + const widget = checkWidgetAccess(req, res); + if (!widget) return; + db.prepare('DELETE FROM widgets WHERE id = ?').run(req.params.id); + res.json({ success: true }); +}); + +// Render widget as HTML page +router.get('/:id/render', (req, res) => { + const widget = db.prepare('SELECT * FROM widgets WHERE id = ?').get(req.params.id); + if (!widget) return res.status(404).send('Widget not found'); + + const config = JSON.parse(widget.config || '{}'); + let html = ''; + + switch (widget.widget_type) { + case 'clock': + html = renderClock(config); + break; + case 'weather': + html = renderWeather(config); + break; + case 'rss': + html = renderRSS(config); + break; + case 'text': + html = renderText(config); + break; + case 'webpage': + html = renderWebpage(config); + break; + case 'social': + html = renderSocial(config); + break; + default: + html = '

Unknown widget

'; + } + + res.setHeader('Content-Type', 'text/html'); + res.send(html); +}); + +function renderClock(c) { + return ` +
+${c.show_date !== false ? '
' : ''} +`; +} + +function renderWeather(c) { + return ` +
+
+
--
+
${escapeHtml(c.location) || 'Unknown'}
+
+
+`; +} + +function renderRSS(c) { + return ` +
Loading feed...
+`; +} + +function renderText(c) { + return `${c.html || '

Empty text widget

'}`; + // NOTE: c.html is intentionally rendered as raw HTML - this is user-authored content for the text widget +} + +function renderWebpage(c) { + return ` + +${c.refresh_interval > 0 ? `` : ''} +`; +} + +function renderSocial(c) { + return ` +
+

Social Feed

+

${escapeHtml(c.platform) || 'twitter'}: ${escapeHtml(c.query) || ''}

+

Configure API key in widget settings

+
`; +} + +module.exports = router; diff --git a/server/server.js b/server/server.js new file mode 100644 index 0000000..d5fa271 --- /dev/null +++ b/server/server.js @@ -0,0 +1,369 @@ +const express = require('express'); +const http = require('http'); +const https = require('https'); +const { Server } = require('socket.io'); +const cors = require('cors'); +const path = require('path'); +const fs = require('fs'); +const config = require('./config'); + +// Ensure upload directories exist +[config.contentDir, config.screenshotsDir].forEach(dir => { + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); +}); + +const app = express(); + +// Determine if SSL certs are available +const hasSsl = fs.existsSync(config.sslCert) && fs.existsSync(config.sslKey); +let server; + +if (hasSsl) { + const sslOptions = { + cert: fs.readFileSync(config.sslCert), + key: fs.readFileSync(config.sslKey), + }; + server = https.createServer(sslOptions, app); +} else { + server = http.createServer(app); +} + +const io = new Server(server, { + cors: { origin: '*' }, + maxHttpBufferSize: 10 * 1024 * 1024 // 10MB for screenshot uploads +}); + +// Middleware +const helmet = require('helmet'); +app.use(helmet({ + contentSecurityPolicy: false, // Allow inline scripts in widget renders + crossOriginEmbedderPolicy: false, // Allow loading external widget content +})); +// CORS: open for public content (kiosk, widgets, player, uploads), restricted for API +app.use(cors({ + origin: (origin, callback) => { + // Allow requests with no origin (mobile apps, server-to-server, kiosk iframes) + if (!origin) return callback(null, true); + // Allow all origins - auth is handled by JWT, not CORS + // Devices, kiosks, and web players need cross-origin access + callback(null, true); + }, + credentials: true, +})); +// Stripe webhook needs raw body (before express.json parses it) +const stripeRouter = require('./routes/stripe'); +app.post('/api/stripe/webhook', express.raw({ type: 'application/json' }), stripeRouter); + +app.use(express.json()); +const { sanitizeBody } = require('./middleware/sanitize'); +app.use(sanitizeBody); + +// Landing page BEFORE static middleware (so / doesn't serve index.html) +app.get('/', (req, res) => { + res.sendFile(path.join(config.frontendDir, 'landing.html')); +}); + +// Dashboard app +app.get('/app', (req, res) => { + res.sendFile(path.join(config.frontendDir, 'index.html')); +}); + +// Serve frontend static files +app.use(express.static(config.frontendDir, { index: false })); + +// Serve web player at /player +app.use('/player', express.static(path.join(__dirname, 'player'))); + +// Serve setup scripts +app.use('/scripts', express.static(path.join(__dirname, '..', 'scripts'))); + +// Serve socket.io client +app.use('/socket.io-client', express.static( + path.join(__dirname, 'node_modules', 'socket.io', 'client-dist') +)); + +// Simple rate limiter for auth endpoints +const rateLimits = new Map(); +function rateLimit(windowMs, maxRequests) { + return (req, res, next) => { + const key = req.ip + req.path; + const now = Date.now(); + const windowStart = now - windowMs; + let hits = rateLimits.get(key) || []; + hits = hits.filter(t => t > windowStart); + if (hits.length >= maxRequests) { + return res.status(429).json({ error: 'Too many requests, try again later' }); + } + hits.push(now); + rateLimits.set(key, hits); + // Cleanup old entries periodically + if (rateLimits.size > 10000) { + for (const [k, v] of rateLimits) { if (v.every(t => t < windowStart)) rateLimits.delete(k); } + } + next(); + }; +} + +// Auth routes (public, rate limited) +app.use('/api/auth/login', rateLimit(60000, 10)); // 10 attempts per minute +app.use('/api/auth/register', rateLimit(60000, 5)); // 5 registrations per minute +app.use('/api/auth', require('./routes/auth')); +// Rate limit pairing to prevent brute force (5 attempts per minute per IP) +app.use('/api/provision/pair', rateLimit(60000, 5)); + +// Subscription routes (mixed auth) +app.use('/api/subscription', require('./routes/subscription')); + +// Stripe billing routes (checkout, portal) +app.use('/api/stripe', stripeRouter); + + +// Screenshot route (before protected routes - needs custom auth for img tags) +const { verifyToken } = require('./middleware/auth'); +app.get('/api/devices/:id/screenshot', (req, res) => { + let user = null; + const authHeader = req.headers.authorization; + const tokenParam = req.query.token; + const token = authHeader?.startsWith('Bearer ') ? authHeader.split(' ')[1] : tokenParam; + if (!token) return res.status(401).json({ error: 'Authentication required' }); + try { + const decoded = verifyToken(token); + const { db } = require('./db/database'); + user = db.prepare('SELECT id, role FROM users WHERE id = ?').get(decoded.id); + if (!user) return res.status(401).json({ error: 'User not found' }); + } catch { return res.status(401).json({ error: 'Invalid or expired token' }); } + const { db: sdb } = require('./db/database'); + const device = sdb.prepare('SELECT user_id FROM devices WHERE id = ?').get(req.params.id); + if (!device) return res.status(404).json({ error: 'Device not found' }); + if (user.role !== 'admin' && device.user_id && device.user_id !== user.id) return res.status(403).json({ error: 'Access denied' }); + // Serve from memory if available (device online), otherwise from disk (offline snapshot) + const deviceSocket = require('./ws/deviceSocket'); + const memScreenshot = deviceSocket.lastScreenshots?.[req.params.id]; + if (memScreenshot) { + const buffer = Buffer.from(memScreenshot, 'base64'); + res.set('Content-Type', 'image/jpeg'); + res.set('Cache-Control', 'no-cache'); + return res.send(buffer); + } + const screenshot = sdb.prepare('SELECT * FROM screenshots WHERE device_id = ? ORDER BY created_at DESC LIMIT 1').get(req.params.id); + if (!screenshot) return res.status(404).json({ error: 'No screenshot available' }); + const safePath = path.resolve(config.screenshotsDir, path.basename(screenshot.filepath)); + if (!safePath.startsWith(path.resolve(config.screenshotsDir))) return res.status(403).json({ error: 'Invalid path' }); + res.sendFile(safePath); +}); + +// Public content file serving (must be BEFORE protected routes) +app.get('/api/content/:id/file', (req, res) => { + const { db } = require('./db/database'); + const content = db.prepare('SELECT * FROM content WHERE id = ?').get(req.params.id); + if (!content) return res.status(404).json({ error: 'Content not found' }); + if (!content.filepath) return res.status(404).json({ error: 'No file (remote URL content)' }); + const assigned = db.prepare('SELECT id FROM assignments WHERE content_id = ? LIMIT 1').get(req.params.id); + if (!assigned) return res.status(403).json({ error: 'Content not assigned to any device' }); + const safePath = path.resolve(config.contentDir, path.basename(content.filepath)); + if (!safePath.startsWith(path.resolve(config.contentDir))) return res.status(403).json({ error: 'Invalid path' }); + res.sendFile(safePath); +}); + +// Public thumbnail serving (must be BEFORE protected routes) +app.get('/api/content/:id/thumbnail', (req, res) => { + const { db } = require('./db/database'); + const content = db.prepare('SELECT * FROM content WHERE id = ?').get(req.params.id); + if (!content || !content.thumbnail_path) return res.status(404).json({ error: 'Thumbnail not found' }); + const safePath = path.resolve(config.contentDir, path.basename(content.thumbnail_path)); + if (!safePath.startsWith(path.resolve(config.contentDir))) return res.status(403).json({ error: 'Invalid path' }); + res.sendFile(safePath); +}); + +// Protected API Routes +const { requireAuth } = require('./middleware/auth'); +app.use('/api/devices', requireAuth, require('./routes/devices')); +app.use('/api/content', requireAuth, require('./routes/content')); +app.use('/api/assignments', requireAuth, require('./routes/assignments')); +app.use('/api/provision', requireAuth, require('./routes/provisioning')); +app.use('/api/layouts', requireAuth, require('./routes/layouts')); +// Widget render is public (accessed by devices) +app.get('/api/widgets/:id/render', (req, res, next) => { req._skipAuth = true; next(); }); +app.use('/api/widgets', (req, res, next) => { if (req._skipAuth) return next(); requireAuth(req, res, next); }, require('./routes/widgets')); +app.use('/api/schedules', requireAuth, require('./routes/schedules')); +app.use('/api/walls', requireAuth, require('./routes/video-walls')); +app.use('/api/teams', requireAuth, require('./routes/teams')); +app.use('/api/reports', requireAuth, require('./routes/reports')); +app.use('/api/groups', requireAuth, require('./routes/device-groups')); +app.use('/api/activity', requireAuth, require('./routes/activity')); +app.use('/api/white-label', requireAuth, require('./routes/white-label')); +// Kiosk render is public (accessed by devices), CRUD is protected +app.get('/api/kiosk/:id/render', (req, res, next) => { + // Let it through to the kiosk route without auth + req._skipAuth = true; + next(); +}); +app.use('/api/kiosk', (req, res, next) => { + if (req._skipAuth) return next(); + requireAuth(req, res, next); +}, require('./routes/kiosk')); + +// Frontend version hash (changes when files are modified, triggers soft reload) +const crypto = require('crypto'); +let frontendHash = ''; +function updateFrontendHash() { + try { + const files = ['index.html', 'js/app.js', 'js/api.js', 'js/socket.js', 'css/main.css', + 'js/views/dashboard.js', 'js/views/device-detail.js', 'js/views/content-library.js', + 'js/views/settings.js', 'js/views/login.js', 'js/views/billing.js', + 'js/views/layout-editor.js', 'js/views/schedule.js', 'js/views/widgets.js', + 'js/views/video-wall.js', 'js/views/reports.js', 'js/views/designer.js', + 'js/views/activity.js', 'js/views/kiosk.js'].map(f => { + try { return fs.readFileSync(path.join(config.frontendDir, f)); } catch { return ''; } + }); + frontendHash = crypto.createHash('md5').update(Buffer.concat(files.map(f => Buffer.from(f)))).digest('hex').slice(0, 8); + } catch { frontendHash = Date.now().toString(36); } +} +updateFrontendHash(); +// Recheck every 30 seconds +setInterval(updateFrontendHash, 30000); +app.get('/api/version', (req, res) => { + let version = '1.2.0'; + try { version = fs.readFileSync(path.join(__dirname, '..', 'VERSION'), 'utf8').trim(); } catch {} + res.json({ hash: frontendHash, version }); +}); + +// Public status page +app.use('/api/status', require('./routes/status')); + +// Activity logging middleware (after auth, before routes respond) +const { activityLogger } = require('./services/activity'); +app.use(activityLogger); + +// APK version check endpoint (public, used by devices to check for updates) +app.get('/api/update/check', (req, res) => { + const currentVersion = req.query.version; + const apkPath = path.join(__dirname, '..', 'ScreenTinker.apk'); + const apkExists = fs.existsSync(apkPath); + const apkSize = apkExists ? fs.statSync(apkPath).size : 0; + const apkModified = apkExists ? fs.statSync(apkPath).mtimeMs : 0; + + // Read version from a version file, or use the APK modification time as a version indicator + const versionFile = path.join(__dirname, '..', 'VERSION'); + let latestVersion = '1.0.0'; + try { + if (fs.existsSync(versionFile)) latestVersion = fs.readFileSync(versionFile, 'utf8').trim(); + } catch {} + + const updateAvailable = currentVersion && currentVersion !== latestVersion; + + res.json({ + latest_version: latestVersion, + current_version: currentVersion || 'unknown', + update_available: updateAvailable, + download_url: '/download/apk', + apk_size: apkSize, + apk_modified: apkModified, + }); +}); + +// (Content file endpoint moved above protected routes) + +// (Screenshot route moved above protected routes) + +// Serve uploaded content files directly (with CORS for web player canvas capture) +app.use('/uploads/content', (req, res, next) => { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin'); + next(); +}, express.static(config.contentDir)); + +// Setup WebSockets +const setupWebSockets = require('./ws'); +const { deviceNs, dashboardNs } = setupWebSockets(io); + +// Start heartbeat checker +const { startHeartbeatChecker } = require('./services/heartbeat'); +startHeartbeatChecker(io); + +// Start scheduler +const { startScheduler } = require('./services/scheduler'); +startScheduler(io); + +// Start alert service +const { startAlertService } = require('./services/alerts'); +startAlertService(io); + +// Handle provisioning via WebSocket notification +const { db } = require('./db/database'); +const originalProvisionRoute = require('./routes/provisioning'); + +// Override provision to also notify device via WS +const { checkDeviceLimit } = require('./middleware/subscription'); +app.post('/api/provision/pair', requireAuth, checkDeviceLimit, (req, res) => { + const { pairing_code, name } = req.body; + if (!pairing_code) return res.status(400).json({ error: 'pairing_code required' }); + + const device = db.prepare('SELECT * FROM devices WHERE pairing_code = ?').get(pairing_code); + if (!device) return res.status(404).json({ error: 'No device found with that pairing code' }); + + const deviceName = name || 'Display ' + (db.prepare('SELECT COUNT(*) as count FROM devices WHERE user_id = ?').get(req.user.id).count + 1); + db.prepare("UPDATE devices SET pairing_code = NULL, name = ?, user_id = ?, status = 'online', updated_at = strftime('%s','now') WHERE id = ?") + .run(deviceName, req.user.id, device.id); + + // Link fingerprint to user + db.prepare("UPDATE device_fingerprints SET user_id = ?, device_id = ? WHERE device_id = ?") + .run(req.user.id, device.id, device.id); + + // Notify the device via WebSocket + deviceNs.to(device.id).emit('device:paired', { device_id: device.id, name: deviceName }); + + const updated = db.prepare('SELECT * FROM devices WHERE id = ?').get(device.id); + dashboardNs.emit('dashboard:device-added', updated); + + res.json(updated); +}); + +// Serve APK download +const apkPath = path.join(__dirname, '..', 'ScreenTinker.apk'); +app.get('/download/apk', (req, res) => { + if (fs.existsSync(apkPath)) { + res.setHeader('Content-Type', 'application/vnd.android.package-archive'); + res.setHeader('Content-Disposition', 'attachment; filename="ScreenTinker.apk"'); + res.setHeader('Cache-Control', 'no-cache'); + res.sendFile(apkPath); + } else { + res.status(404).send(`APK Not Found

APK Not Available

The Android APK has not been compiled yet. To build it from source:

cd android
./gradlew assembleDebug
cp app/build/outputs/apk/debug/app-debug.apk ../ScreenTinker.apk

See the README for full build instructions.

Alternatively, use the web player in any browser.

`); + } +}); + +// SPA fallback for app routes +app.get('*', (req, res) => { + if (!req.path.startsWith('/api/')) { + res.sendFile(path.join(config.frontendDir, 'index.html')); + } +}); + +const listenPort = hasSsl ? config.httpsPort : config.port; +const protocol = hasSsl ? 'https' : 'http'; + +server.listen(listenPort, '0.0.0.0', () => { + console.log(` +╔══════════════════════════════════════════════════╗ +║ ScreenTinker Server v1.2.0 ║ +║──────────────────────────────────────────────────║ +║ Dashboard: ${protocol}://localhost:${String(listenPort).padEnd(5)} ║ +║ API: ${protocol}://localhost:${String(listenPort).padEnd(5)}/api ║ +║ SSL: ${hasSsl ? 'ENABLED ✓' : 'DISABLED (no certs found)'}${hasSsl ? ' ' : ' '}║ +║──────────────────────────────────────────────────║ +║ Listening on all interfaces (0.0.0.0) ║ +╚══════════════════════════════════════════════════╝ + `); +}); + +// If SSL is enabled, also start an HTTP server that redirects to HTTPS +if (hasSsl) { + const redirectApp = express(); + redirectApp.use((req, res) => { + const host = req.headers.host?.replace(`:${config.port}`, `:${config.httpsPort}`) || `localhost:${config.httpsPort}`; + res.redirect(301, `https://${host}${req.url}`); + }); + http.createServer(redirectApp).listen(config.port, '0.0.0.0', () => { + console.log(` HTTP redirect: http://localhost:${config.port} → https://localhost:${config.httpsPort}\n`); + }); +} diff --git a/server/services/activity.js b/server/services/activity.js new file mode 100644 index 0000000..7975c52 --- /dev/null +++ b/server/services/activity.js @@ -0,0 +1,60 @@ +const { db } = require('../db/database'); + +function logActivity(userId, action, details = null, deviceId = null, ipAddress = null) { + try { + db.prepare( + 'INSERT INTO activity_log (user_id, device_id, action, details, ip_address) VALUES (?, ?, ?, ?, ?)' + ).run(userId || null, deviceId || null, action, details || null, ipAddress || null); + } catch (e) { + console.error('Activity log error:', e.message); + } +} + +function getActivity(options = {}) { + const { userId, deviceId, limit = 50, offset = 0 } = options; + let sql = `SELECT al.*, u.name as user_name, u.email as user_email + FROM activity_log al LEFT JOIN users u ON al.user_id = u.id WHERE 1=1`; + const params = []; + + if (userId) { sql += ' AND al.user_id = ?'; params.push(userId); } + if (deviceId) { sql += ' AND al.device_id = ?'; params.push(deviceId); } + + sql += ' ORDER BY al.created_at DESC LIMIT ? OFFSET ?'; + params.push(limit, offset); + + return db.prepare(sql).all(...params); +} + +// Prune old activity logs (keep 90 days) +function pruneActivityLog() { + db.prepare("DELETE FROM activity_log WHERE created_at < strftime('%s','now') - (90 * 86400)").run(); +} + +// Express middleware to auto-log API mutations +function activityLogger(req, res, next) { + const originalJson = res.json.bind(res); + res.json = function(data) { + // Only log successful mutations + if (['POST', 'PUT', 'DELETE'].includes(req.method) && res.statusCode < 400) { + const action = `${req.method} ${req.baseUrl || ''}${req.route?.path || req.path}`; + const userId = req.user?.id; + const deviceId = req.params?.id || req.params?.deviceId || req.body?.device_id; + const details = summarizeAction(req); + logActivity(userId, action, details, deviceId, req.ip); + } + return originalJson(data); + }; + next(); +} + +function summarizeAction(req) { + const parts = []; + if (req.body?.name) parts.push(`name: ${req.body.name}`); + if (req.body?.filename) parts.push(`file: ${req.body.filename}`); + if (req.body?.pairing_code) parts.push('device paired'); + if (req.body?.plan_id) parts.push(`plan: ${req.body.plan_id}`); + if (req.file?.originalname) parts.push(`uploaded: ${req.file.originalname}`); + return parts.join(', ') || null; +} + +module.exports = { logActivity, getActivity, pruneActivityLog, activityLogger }; diff --git a/server/services/alerts.js b/server/services/alerts.js new file mode 100644 index 0000000..c4837e5 --- /dev/null +++ b/server/services/alerts.js @@ -0,0 +1,109 @@ +const { db } = require('../db/database'); +const config = require('../config'); +const https = require('https'); +const http = require('http'); + +// Track device offline timestamps to avoid spamming +const offlineNotified = new Map(); + +function startAlertService(io) { + // Check for offline devices every 60 seconds + setInterval(() => checkOfflineDevices(io), 60000); + console.log('Alert service started'); +} + +function checkOfflineDevices(io) { + const now = Math.floor(Date.now() / 1000); + const threshold = 300; // 5 minutes offline + + const offlineDevices = db.prepare(` + SELECT d.id, d.name, d.user_id, d.last_heartbeat, d.status, + u.email as owner_email, u.name as owner_name, u.email_alerts + FROM devices d + LEFT JOIN users u ON d.user_id = u.id + WHERE d.status = 'offline' AND d.last_heartbeat IS NOT NULL + AND (? - d.last_heartbeat) > ? + `).all(now, threshold); + + for (const device of offlineDevices) { + // Skip if already notified in the last hour + const lastNotified = offlineNotified.get(device.id) || 0; + if (now - lastNotified < 3600) continue; + + // Skip if user has alerts disabled + if (!device.email_alerts) continue; + + // Send alert + if (device.owner_email) { + const offlineMinutes = Math.floor((now - device.last_heartbeat) / 60); + sendEmailAlert(device.owner_email, device.owner_name, { + subject: `Display Offline: ${device.name}`, + body: `Your display "${device.name}" has been offline for ${offlineMinutes} minutes.\n\nLast heartbeat: ${new Date(device.last_heartbeat * 1000).toLocaleString()}\n\nCheck your device and network connection.\n\n- ScreenTinker` + }); + offlineNotified.set(device.id, now); + + // Log activity + try { + db.prepare( + 'INSERT INTO activity_log (user_id, device_id, action, details) VALUES (?, ?, ?, ?)' + ).run(device.user_id, device.id, 'alert:device_offline', `${device.name} offline for ${offlineMinutes}m`); + } catch {} + } + } + + // Clear notifications for devices that came back online + const onlineDevices = db.prepare("SELECT id FROM devices WHERE status = 'online'").all(); + for (const device of onlineDevices) { + offlineNotified.delete(device.id); + } +} + +function sendEmailAlert(to, name, { subject, body }) { + // Use a simple webhook/SMTP relay approach + // If SMTP_WEBHOOK is set, POST to it (works with services like Mailgun, SendGrid, etc.) + const webhookUrl = config.emailWebhookUrl; + + if (!webhookUrl) { + console.log(`[ALERT] Would email ${to}: ${subject}`); + console.log(` ${body.split('\n')[0]}`); + return; + } + + try { + const url = new URL(webhookUrl); + const postData = JSON.stringify({ + to, + subject: `[ScreenTinker] ${subject}`, + text: body, + html: `
+

ScreenTinker Alert

+

Hi ${name || 'there'},

+
+ ${subject}

+ ${body.replace(/\n/g, '
')} +
+

You're receiving this because you have email alerts enabled in ScreenTinker.

+
` + }); + + const options = { + hostname: url.hostname, + port: url.port, + path: url.pathname, + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(postData) } + }; + + const transport = url.protocol === 'https:' ? https : http; + const req = transport.request(options, (res) => { + if (res.statusCode >= 400) console.error(`Email webhook failed: ${res.statusCode}`); + }); + req.on('error', (e) => console.error('Email webhook error:', e.message)); + req.write(postData); + req.end(); + } catch (e) { + console.error('Email alert error:', e.message); + } +} + +module.exports = { startAlertService, sendEmailAlert }; diff --git a/server/services/heartbeat.js b/server/services/heartbeat.js new file mode 100644 index 0000000..dea864d --- /dev/null +++ b/server/services/heartbeat.js @@ -0,0 +1,87 @@ +const { db } = require('../db/database'); +const config = require('../config'); + +// Track connected device sockets: deviceId -> { socketId, lastHeartbeat } +const deviceConnections = new Map(); + +function startHeartbeatChecker(io) { + setInterval(() => { + const now = Date.now(); + const dashboardNs = io.of('/dashboard'); + + // Check database for devices that should be offline + const onlineDevices = db.prepare("SELECT id, last_heartbeat FROM devices WHERE status = 'online'").all(); + + for (const device of onlineDevices) { + const conn = deviceConnections.get(device.id); + const lastBeat = conn ? conn.lastHeartbeat : (device.last_heartbeat ? device.last_heartbeat * 1000 : 0); + + if (now - lastBeat > config.heartbeatTimeout) { + db.prepare("UPDATE devices SET status = 'offline', updated_at = strftime('%s','now') WHERE id = ?") + .run(device.id); + deviceConnections.delete(device.id); + + // Notify dashboard + dashboardNs.emit('dashboard:device-status', { + device_id: device.id, + status: 'offline', + telemetry: null + }); + + console.log(`Device ${device.id} marked offline (heartbeat timeout)`); + try { + db.prepare('INSERT INTO device_status_log (device_id, status) VALUES (?, ?)').run(device.id, 'offline_timeout'); + } catch (_) {} + } + } + + // Cleanup: delete unclaimed provisioning devices older than 24 hours + // Keep imported devices (they have user_id set) so users can re-pair them + db.prepare(` + DELETE FROM devices WHERE status = 'provisioning' + AND user_id IS NULL + AND created_at < strftime('%s','now') - (365 * 86400) + `).run(); + + // Cleanup: prune play logs older than 90 days + db.prepare(` + DELETE FROM play_logs WHERE started_at < strftime('%s','now') - (90 * 86400) + `).run(); + + // Cleanup: expired team invites + db.prepare(` + DELETE FROM team_invites WHERE expires_at < strftime('%s','now') + `).run(); + + }, config.heartbeatInterval); +} + +function registerConnection(deviceId, socketId) { + deviceConnections.set(deviceId, { socketId, lastHeartbeat: Date.now() }); +} + +function updateHeartbeat(deviceId) { + const conn = deviceConnections.get(deviceId); + if (conn) conn.lastHeartbeat = Date.now(); +} + +function removeConnection(deviceId) { + deviceConnections.delete(deviceId); +} + +function getConnection(deviceId) { + return deviceConnections.get(deviceId); +} + +function getAllConnections() { + return deviceConnections; +} + +module.exports = { + startHeartbeatChecker, + registerConnection, + updateHeartbeat, + removeConnection, + getConnection, + getAllConnections +}; diff --git a/server/services/scheduler.js b/server/services/scheduler.js new file mode 100644 index 0000000..29ad8d9 --- /dev/null +++ b/server/services/scheduler.js @@ -0,0 +1,104 @@ +const { db } = require('../db/database'); + +let io = null; + +function startScheduler(socketIo) { + io = socketIo; + // Check schedules every 60 seconds + setInterval(evaluateSchedules, 60000); + console.log('Scheduler service started'); +} + +function evaluateSchedules() { + const deviceNs = io?.of('/device'); + if (!deviceNs) return; + + const now = new Date(); + const onlineDevices = db.prepare("SELECT * FROM devices WHERE status = 'online'").all(); + + for (const device of onlineDevices) { + const schedules = db.prepare(` + SELECT s.*, c.filename, c.mime_type, c.filepath, c.file_size, c.remote_url, + c.duration_sec as content_duration + FROM schedules s + LEFT JOIN content c ON s.content_id = c.id + WHERE s.device_id = ? AND s.enabled = 1 + ORDER BY s.priority DESC + `).all(device.id); + + // Find currently active schedule + const active = schedules.find(s => isScheduleActiveNow(s, now)); + + if (active && active.content_id) { + // Check if this is different from current playback + const currentLayout = device.layout_id; + if (active.layout_id && active.layout_id !== currentLayout) { + // Switch layout + db.prepare("UPDATE devices SET layout_id = ? WHERE id = ?").run(active.layout_id, device.id); + // Push updated playlist + pushPlaylistToDevice(device.id, deviceNs); + } + } + } +} + +function isScheduleActiveNow(schedule, now) { + const start = new Date(schedule.start_time); + const end = new Date(schedule.end_time); + + if (!schedule.recurrence) { + return now >= start && now <= end; + } + + // For recurring schedules, check if current time-of-day falls within range + // and current day matches recurrence pattern + const rule = parseSimpleRRule(schedule.recurrence); + if (!rule) return now >= start && now <= end; + + // Check day of week + if (rule.byDay && !rule.byDay.includes(now.getDay())) return false; + + // Check time of day + const nowMinutes = now.getHours() * 60 + now.getMinutes(); + const startMinutes = start.getHours() * 60 + start.getMinutes(); + const endMinutes = end.getHours() * 60 + end.getMinutes(); + + return nowMinutes >= startMinutes && nowMinutes <= endMinutes; +} + +function parseSimpleRRule(rrule) { + if (!rrule) return null; + const parts = rrule.split(';'); + const rule = {}; + const dayMap = { SU: 0, MO: 1, TU: 2, WE: 3, TH: 4, FR: 5, SA: 6 }; + for (const part of parts) { + const [key, val] = part.split('='); + if (key === 'FREQ') rule.freq = val; + if (key === 'BYDAY') rule.byDay = val.split(',').map(d => dayMap[d]).filter(d => d !== undefined); + if (key === 'INTERVAL') rule.interval = parseInt(val); + } + return rule; +} + +function pushPlaylistToDevice(deviceId, deviceNs) { + const assignments = db.prepare(` + SELECT a.*, COALESCE(c.filename, w.name) as filename, c.mime_type, c.filepath, c.file_size, c.duration_sec as content_duration, c.remote_url, + w.name as widget_name, w.widget_type, w.config as widget_config + FROM assignments a LEFT JOIN content c ON a.content_id = c.id LEFT JOIN widgets w ON a.widget_id = w.id + WHERE a.device_id = ? AND a.enabled = 1 + ORDER BY a.sort_order ASC + `).all(deviceId); + + const device = db.prepare('SELECT * FROM devices WHERE id = ?').get(deviceId); + let layout = null; + if (device.layout_id) { + layout = db.prepare('SELECT * FROM layouts WHERE id = ?').get(device.layout_id); + if (layout) { + layout.zones = db.prepare('SELECT * FROM layout_zones WHERE layout_id = ? ORDER BY sort_order').all(layout.id); + } + } + + deviceNs.to(deviceId).emit('device:playlist-update', { assignments, layout, orientation: device?.orientation || 'landscape' }); +} + +module.exports = { startScheduler, pushPlaylistToDevice }; diff --git a/server/ws/dashboardSocket.js b/server/ws/dashboardSocket.js new file mode 100644 index 0000000..66d152d --- /dev/null +++ b/server/ws/dashboardSocket.js @@ -0,0 +1,91 @@ +const heartbeat = require('../services/heartbeat'); +const { verifyToken } = require('../middleware/auth'); + +module.exports = function setupDashboardSocket(io) { + const dashboardNs = io.of('/dashboard'); + const deviceNs = io.of('/device'); + + // Authenticate dashboard WebSocket connections + dashboardNs.use((socket, next) => { + const token = socket.handshake.auth?.token; + if (!token) return next(new Error('Authentication required')); + try { + const decoded = verifyToken(token); + socket.userId = decoded.id; + socket.userRole = decoded.role; + next(); + } catch { + next(new Error('Invalid token')); + } + }); + + // Verify the user owns the device or is admin/superadmin + function checkDeviceOwnership(socket, device_id) { + if (['admin', 'superadmin'].includes(socket.userRole)) return true; + const { db } = require('../db/database'); + const device = db.prepare('SELECT user_id FROM devices WHERE id = ?').get(device_id); + if (!device) return false; + return device.user_id === socket.userId; + } + + dashboardNs.on('connection', (socket) => { + console.log(`Dashboard client connected: ${socket.id} (user: ${socket.userId})`); + + // Request screenshot from a device + socket.on('dashboard:request-screenshot', (data) => { + const { device_id } = data; + if (!checkDeviceOwnership(socket, device_id)) return; + const conn = heartbeat.getConnection(device_id); + if (conn) { + deviceNs.to(device_id).emit('device:screenshot-request', {}); + } + }); + + // Remote control: touch forwarding + socket.on('dashboard:remote-touch', (data) => { + const { device_id, x, y, action } = data; + if (!checkDeviceOwnership(socket, device_id)) return; + deviceNs.to(device_id).emit('device:remote-touch', { x, y, action }); + }); + + // Remote control: key forwarding + socket.on('dashboard:remote-key', (data) => { + const { device_id, keycode } = data; + if (!checkDeviceOwnership(socket, device_id)) return; + console.log(`Remote key: ${keycode} -> ${device_id}`); + deviceNs.to(device_id).emit('device:remote-key', { keycode }); + }); + + // Start remote screenshot streaming + socket.on('dashboard:remote-start', (data) => { + const { device_id } = data; + if (!checkDeviceOwnership(socket, device_id)) return; + const room = deviceNs.adapter.rooms.get(device_id); + console.log(`Remote start for ${device_id}, room has ${room?.size || 0} socket(s)`); + deviceNs.to(device_id).emit('device:remote-start', {}); + console.log(`Remote session started for device ${device_id}`); + }); + + // Stop remote screenshot streaming + socket.on('dashboard:remote-stop', (data) => { + const { device_id } = data; + if (!checkDeviceOwnership(socket, device_id)) return; + deviceNs.to(device_id).emit('device:remote-stop', {}); + console.log(`Remote session stopped for device ${device_id}`); + }); + + // Send command to device (reboot, refresh, etc.) + socket.on('dashboard:device-command', (data) => { + const { device_id, type, payload } = data; + if (!checkDeviceOwnership(socket, device_id)) return; + deviceNs.to(device_id).emit('device:command', { type, payload }); + console.log(`Command sent to device ${device_id}: ${type}`); + }); + + socket.on('disconnect', () => { + console.log(`Dashboard client disconnected: ${socket.id}`); + }); + }); + + return dashboardNs; +}; diff --git a/server/ws/deviceSocket.js b/server/ws/deviceSocket.js new file mode 100644 index 0000000..fc8a83b --- /dev/null +++ b/server/ws/deviceSocket.js @@ -0,0 +1,341 @@ +const { v4: uuidv4 } = require('uuid'); +const path = require('path'); +const fs = require('fs'); +const { db, pruneTelemetry, pruneScreenshots } = require('../db/database'); +const config = require('../config'); +const heartbeat = require('../services/heartbeat'); +const { getUserPlan, getUserDeviceCount } = require('../middleware/subscription'); + +// In-memory store for latest screenshot per device (avoids disk writes during streaming) +let lastScreenshots = {}; + +function logDeviceStatus(deviceId, status) { + try { + db.prepare('INSERT INTO device_status_log (device_id, status) VALUES (?, ?)').run(deviceId, status); + // Prune entries older than 7 days + db.prepare("DELETE FROM device_status_log WHERE device_id = ? AND timestamp < strftime('%s','now') - 604800").run(deviceId); + } catch (e) { /* table might not exist yet */ } +} + + +// Build playlist payload with layout and zones +function buildPlaylistPayload(deviceId) { + const assignments = db.prepare(` + SELECT a.*, COALESCE(c.filename, w.name) as filename, c.mime_type, c.filepath, c.file_size, c.duration_sec as content_duration, c.remote_url, + w.name as widget_name, w.widget_type, w.config as widget_config + FROM assignments a LEFT JOIN content c ON a.content_id = c.id LEFT JOIN widgets w ON a.widget_id = w.id + WHERE a.device_id = ? AND a.enabled = 1 + ORDER BY a.sort_order ASC + `).all(deviceId); + + // Get device's layout with zones + const device = db.prepare('SELECT layout_id, orientation FROM devices WHERE id = ?').get(deviceId); + let layout = null; + if (device?.layout_id) { + layout = db.prepare('SELECT * FROM layouts WHERE id = ?').get(device.layout_id); + if (layout) { + layout.zones = db.prepare('SELECT * FROM layout_zones WHERE layout_id = ? ORDER BY sort_order').all(layout.id); + } + } + + return { assignments, layout, orientation: device?.orientation || 'landscape' }; +} + +// Check if a device should show trial expired screen +function checkDeviceAccess(deviceId) { + const device = db.prepare('SELECT * FROM devices WHERE id = ?').get(deviceId); + if (!device || !device.user_id) return { allowed: true }; + + const plan = getUserPlan(device.user_id); + if (!plan) return { allowed: true }; + + // Check if trial expired and over free limit + if (plan.trial_started && !plan.trial_active && plan.plan_name === 'free') { + const deviceCount = getUserDeviceCount(device.user_id); + // Get this device's position (ordered by created_at) + const userDevices = db.prepare('SELECT id FROM devices WHERE user_id = ? ORDER BY created_at ASC').all(device.user_id); + const deviceIndex = userDevices.findIndex(d => d.id === deviceId); + + // Only the first device (within free limit) is allowed + if (deviceIndex >= plan.max_devices) { + return { + allowed: false, + reason: 'trial_expired', + message: 'Trial Expired', + detail: 'Upgrade your plan to continue using this display.', + }; + } + } + + // Check if over plan device limit (non-trial) + if (!plan.trial_started && plan.max_devices > 0) { + const userDevices = db.prepare('SELECT id FROM devices WHERE user_id = ? ORDER BY created_at ASC').all(device.user_id); + const deviceIndex = userDevices.findIndex(d => d.id === deviceId); + if (deviceIndex >= plan.max_devices) { + return { + allowed: false, + reason: 'device_limit', + message: 'Device Limit Reached', + detail: 'Upgrade your plan to activate this display.', + }; + } + } + + return { allowed: true }; +} + +module.exports = function setupDeviceSocket(io) { + // Expose lastScreenshots for the screenshot API endpoint + module.exports.lastScreenshots = lastScreenshots; + const deviceNs = io.of('/device'); + const dashboardNs = io.of('/dashboard'); + + deviceNs.on('connection', (socket) => { + console.log(`Device socket connected: ${socket.id}`); + let currentDeviceId = null; + + // Device registers with a pairing code (first time) or device_id (reconnect) + socket.on('device:register', (data) => { + const { pairing_code, device_id, device_info, fingerprint } = data; + + // Track device fingerprint to prevent reinstall abuse + if (fingerprint) { + try { + const existing = db.prepare('SELECT * FROM device_fingerprints WHERE fingerprint = ?').get(fingerprint); + if (existing) { + db.prepare("UPDATE device_fingerprints SET last_seen = strftime('%s','now'), device_id = ? WHERE fingerprint = ?") + .run(device_id || existing.device_id, fingerprint); + // If this fingerprint was previously registered to a different device, block the new registration + if (!device_id && existing.device_id && pairing_code) { + // Someone reinstalled - link them back to existing device + const oldDevice = db.prepare('SELECT * FROM devices WHERE id = ?').get(existing.device_id); + if (oldDevice) { + console.log(`Fingerprint match: linking to existing device ${existing.device_id}`); + socket.emit('device:registered', { device_id: existing.device_id, status: oldDevice.status }); + currentDeviceId = existing.device_id; + heartbeat.registerConnection(existing.device_id, socket.id); + socket.join(existing.device_id); + // Send playlist + const access = checkDeviceAccess(existing.device_id); + if (!access.allowed) { + socket.emit('device:playlist-update', { assignments: [], suspended: true, message: access.message, detail: access.detail }); + } else { + socket.emit('device:playlist-update', buildPlaylistPayload(existing.device_id)); + } + return; + } + } + } else if (device_id || pairing_code) { + db.prepare("INSERT OR IGNORE INTO device_fingerprints (fingerprint, device_id) VALUES (?, ?)") + .run(fingerprint, device_id || null); + } + } catch (e) { + console.error('Fingerprint tracking error:', e.message); + } + } + + if (device_id) { + // Reconnecting known device + const device = db.prepare('SELECT * FROM devices WHERE id = ?').get(device_id); + if (device) { + currentDeviceId = device_id; + db.prepare("UPDATE devices SET status = 'online', last_heartbeat = strftime('%s','now'), ip_address = ?, updated_at = strftime('%s','now') WHERE id = ?") + .run(socket.handshake.address, device_id); + + if (device_info) { + db.prepare('UPDATE devices SET android_version = ?, app_version = ?, screen_width = ?, screen_height = ? WHERE id = ?') + .run(device_info.android_version, device_info.app_version, device_info.screen_width, device_info.screen_height, device_id); + } + + heartbeat.registerConnection(device_id, socket.id); + socket.join(device_id); + socket.emit('device:registered', { device_id, status: 'online' }); + logDeviceStatus(device_id, 'online'); + + // Check subscription/trial status before sending playlist + const access = checkDeviceAccess(device_id); + if (!access.allowed) { + socket.emit('device:playlist-update', { assignments: [], suspended: true, message: access.message, detail: access.detail }); + } else { + socket.emit('device:playlist-update', buildPlaylistPayload(device_id)); + } + + dashboardNs.emit('dashboard:device-status', { device_id, status: 'online' }); + console.log(`Device reconnected: ${device_id}`); + return; + } + + // Device ID not found in database - tell device to re-provision + console.log(`Device ${device_id} not found in database, sending unpaired`); + socket.emit('device:unpaired', { reason: 'not_found' }); + return; + } + + if (pairing_code) { + // New device registering with pairing code + const id = uuidv4(); + currentDeviceId = id; + + db.prepare(` + INSERT INTO devices (id, pairing_code, status, ip_address, android_version, app_version, screen_width, screen_height, last_heartbeat) + VALUES (?, ?, 'provisioning', ?, ?, ?, ?, ?, strftime('%s','now')) + `).run( + id, pairing_code, socket.handshake.address, + device_info?.android_version || null, + device_info?.app_version || null, + device_info?.screen_width || null, + device_info?.screen_height || null + ); + + heartbeat.registerConnection(id, socket.id); + socket.join(id); + socket.emit('device:registered', { device_id: id, status: 'provisioning' }); + + dashboardNs.emit('dashboard:device-added', db.prepare('SELECT * FROM devices WHERE id = ?').get(id)); + console.log(`New device registered: ${id} with pairing code: ${pairing_code}`); + } + }); + + // Heartbeat with telemetry + socket.on('device:heartbeat', (data) => { + const { device_id, telemetry } = data; + if (!device_id) return; + + currentDeviceId = device_id; + heartbeat.updateHeartbeat(device_id); + + db.prepare("UPDATE devices SET status = 'online', last_heartbeat = strftime('%s','now'), updated_at = strftime('%s','now') WHERE id = ?") + .run(device_id); + + if (telemetry) { + db.prepare(` + INSERT INTO device_telemetry (device_id, battery_level, battery_charging, storage_free_mb, storage_total_mb, + ram_free_mb, ram_total_mb, cpu_usage, wifi_ssid, wifi_rssi, uptime_seconds) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + device_id, + telemetry.battery_level ?? null, + telemetry.battery_charging ? 1 : 0, + telemetry.storage_free_mb ?? null, + telemetry.storage_total_mb ?? null, + telemetry.ram_free_mb ?? null, + telemetry.ram_total_mb ?? null, + telemetry.cpu_usage ?? null, + telemetry.wifi_ssid ?? null, + telemetry.wifi_rssi ?? null, + telemetry.uptime_seconds ?? null + ); + pruneTelemetry(device_id); + + dashboardNs.emit('dashboard:device-status', { + device_id, + status: 'online', + telemetry + }); + } + }); + + // Screenshot received from device - relay via WebSocket, keep latest in memory + socket.on('device:screenshot', (data) => { + const { device_id, image_b64 } = data; + if (!device_id || !image_b64) return; + + // Store latest screenshot in memory (for Now Playing preview and offline snapshot) + if (!lastScreenshots) lastScreenshots = {}; + lastScreenshots[device_id] = image_b64; + + // Relay directly to dashboard - no disk write + try { + dashboardNs.emit('dashboard:screenshot-ready', { + device_id, + image_data: `data:image/jpeg;base64,${image_b64}`, + timestamp: Date.now() + }); + } catch (err) { + console.error('Screenshot save error:', err); + } + }); + + // Content download acknowledgement + socket.on('device:content-ack', (data) => { + const { device_id, content_id, status } = data; + console.log(`Device ${device_id} content ${content_id}: ${status}`); + dashboardNs.emit('dashboard:content-ack', { device_id, content_id, status }); + }); + + // Playback state update + socket.on('device:playback-state', (data) => { + dashboardNs.emit('dashboard:playback-state', data); + }); + + // Play event logging (proof-of-play) + socket.on('device:play-event', (data) => { + const { device_id, event, content_id, content_name, zone_id, completed } = data; + try { + if (event === 'play_start') { + db.prepare(` + INSERT INTO play_logs (device_id, content_id, zone_id, content_name, started_at, trigger_type) + VALUES (?, ?, ?, ?, strftime('%s','now'), 'playlist') + `).run(device_id, content_id || null, zone_id || null, content_name || 'Unknown'); + } else if (event === 'play_end') { + db.prepare(` + UPDATE play_logs SET ended_at = strftime('%s','now'), + duration_sec = strftime('%s','now') - started_at, + completed = ? + WHERE id = ( + SELECT id FROM play_logs WHERE device_id = ? AND content_id = ? AND ended_at IS NULL + ORDER BY started_at DESC LIMIT 1 + ) + `).run(completed ? 1 : 0, device_id, content_id); + } + } catch (err) { + console.error('Play log error:', err.message); + } + }); + + // Video wall sync relay + socket.on('wall:sync', (data) => { + // Relay to all devices in the same wall + const wallDevices = db.prepare( + 'SELECT device_id FROM video_wall_devices WHERE wall_id = ? AND device_id != ?' + ).all(data.wall_id, data.device_id); + for (const wd of wallDevices) { + deviceNs.to(wd.device_id).emit('wall:sync', data); + } + }); + + socket.on('disconnect', () => { + if (currentDeviceId) { + console.log(`Device disconnected: ${currentDeviceId}`); + db.prepare("UPDATE devices SET status = 'offline', updated_at = strftime('%s','now') WHERE id = ?") + .run(currentDeviceId); + heartbeat.removeConnection(currentDeviceId); + logDeviceStatus(currentDeviceId, 'offline'); + dashboardNs.emit('dashboard:device-status', { device_id: currentDeviceId, status: 'offline' }); + + // Save last screenshot to disk as offline snapshot + const lastB64 = lastScreenshots[currentDeviceId]; + if (lastB64) { + try { + const filename = `${currentDeviceId}_latest.jpg`; + const buffer = Buffer.from(lastB64, 'base64'); + fs.writeFileSync(path.join(config.screenshotsDir, filename), buffer); + // Upsert screenshot record + const existing = db.prepare('SELECT id FROM screenshots WHERE device_id = ?').get(currentDeviceId); + if (existing) { + db.prepare('UPDATE screenshots SET filepath = ?, captured_at = strftime(\'%s\',\'now\') WHERE device_id = ?') + .run(filename, currentDeviceId); + } else { + db.prepare('INSERT INTO screenshots (device_id, filepath) VALUES (?, ?)').run(currentDeviceId, filename); + } + } catch (e) { + console.error('Failed to save offline screenshot:', e.message); + } + delete lastScreenshots[currentDeviceId]; + } + } + }); + }); + + return deviceNs; +}; diff --git a/server/ws/index.js b/server/ws/index.js new file mode 100644 index 0000000..83bc7a6 --- /dev/null +++ b/server/ws/index.js @@ -0,0 +1,8 @@ +const setupDeviceSocket = require('./deviceSocket'); +const setupDashboardSocket = require('./dashboardSocket'); + +module.exports = function setupWebSockets(io) { + const deviceNs = setupDeviceSocket(io); + const dashboardNs = setupDashboardSocket(io); + return { deviceNs, dashboardNs }; +};