Compare commits

...

10 Commits

Author SHA1 Message Date
Koala 8c899a6469 Update changelog for v2.0.4 2026-06-03 11:06:44 +02:00
Koala 4f4cdcd8c0 Cache health responses server-side 2026-06-03 11:04:45 +02:00
Koala 602be3a724 Tighten health endpoint hardening 2026-06-03 10:59:08 +02:00
Koala bb7bd21102 Update release assets for v2.0.3 2026-06-03 10:51:47 +02:00
GitHub Action 543bfe074d chore(release): update versions to v2.0.3 [skip ci] 2026-06-03 08:49:25 +00:00
Koala 81c50eff16 Harden room discovery and health metrics 2026-06-03 10:48:49 +02:00
Koala bf0fb5741f fix: version.json path for all non-English locales
Changed condition from lang === 'de' to lang === 'en' so that all
non-English language subdirectories (fr, es, pt-BR, ru, de) correctly
resolve version.json via ../version.json instead of only German.
2026-06-02 14:31:13 +02:00
Timo 6d2355f404 Store Assets added 2026-06-02 09:26:20 +02:00
Koala 732d585273 docs: add v2.0.2 changelog and update README badge 2026-06-02 08:23:49 +02:00
GitHub Action 666808f876 chore(release): update versions to v2.0.2 [skip ci] 2026-06-02 06:17:21 +00:00
39 changed files with 544 additions and 89 deletions
+30
View File
@@ -4,6 +4,36 @@ All notable changes to the KoalaSync browser extension and relay server.
---
## [v2.0.4] — 2026-06-03
### Security & Hardening
- Hardened relay health endpoints against simple flood traffic: `GET /` and `GET /health` are now limited to 10 requests per minute per client IP.
- Added lazy 60-second server-side caching for `GET /`, basic `/health`, and admin `/health` JSON responses to reduce repeated health-check work under noisy polling.
- Added stricter brute-force throttling for invalid admin metrics bearer attempts.
- Added startup warning for short `ADMIN_METRICS_TOKEN` values and documented that production Node ports must stay private behind Caddy or another trusted reverse proxy.
- Lowered the default maximum peers per room to 25.
### Added
- Optional privacy-preserving admin metrics on `/health` when `ADMIN_METRICS_TOKEN` is configured and a valid bearer token is supplied. Metrics are aggregate-only and exclude room IDs, peer IDs, usernames, IP addresses, media titles, passwords, and other user-level data.
### Changed
- Removed `bcryptjs`; temporary room passwords continue to use keyed SHA-256/HMAC hashing as documented.
- Public room discovery is now rate-limited server-side to one refresh every 10 seconds per socket, with the extension refresh button locked for 11 seconds.
### Fixed
- Improved Shadow DOM video detection so real embedded players are not hidden by smaller light-DOM preview or placeholder videos.
- Fixed join-button timeout cleanup after join status responses.
---
## [v2.0.2] — 2026-06-02
### Fixed
- Peer identity spoofing in relay server: client-supplied `peerId` could be used to impersonate other peers in PEER_STATUS events. Server now always stamps `peerId` with the authenticated sender's identity.
- Amazon domain detection: replaced broad `includes('amazon.')` substring check with boundary-safe regex that correctly matches all Amazon storefronts (`amazon.com`, `amazon.de`, `amazon.co.uk`, etc.) while rejecting lookalike domains.
---
## [v2.0.1] — 2026-06-01
### Fixed
+1 -1
View File
@@ -7,7 +7,7 @@ KoalaSync is designed with a **Security-First & Volatile** architecture. This me
## 1. Data Processing (In-Memory Only)
KoalaSync does not use a database. All active session data exists only in the server's RAM and is purged immediately when no longer needed.
- **Session Data**: To synchronize playback, the server must temporarily hold your `peerId`, `username`, and the `title` of the video you are watching. Additionally, playback metadata (`mediaTitle`, `playbackState`, `currentTime`, `volume`, `muted`) is held per peer for the duration of the session. All of this is deleted as soon as you leave the room.
- **Room Passwords**: If you set a room password, it is stored only as a secure **bcrypt hash** in RAM. The server never sees or stores your plaintext password.
- **Room Passwords**: If you set a room password, it is stored only as an in-memory **keyed SHA-256 HMAC hash**. The server receives the plaintext password only during join validation, never stores it, and keeps only the hash for the short room lifetime.
- **Routing Maps**: The server maintains ephemeral lookup tables (`socketToRoom`, `peerToSocket`) to route messages between peers. These contain only transport identifiers and are purged on disconnect.
### Data Retention
+1 -1
View File
@@ -12,7 +12,7 @@
<a href="https://chromewebstore.google.com/detail/koalasync/obbnmkmlaaddodakcbdljknjpagklifc"><img src="https://img.shields.io/badge/Chrome-Download-blue?logo=googlechrome&logoColor=white" alt="Chrome Extension"></a>
</p>
<p align="center"><a href="CHANGELOG.md"><b>New v2.0.1 Release!</b> — See what's changed</a></p>
<p align="center"><a href="CHANGELOG.md"><b>New v2.0.2 Release!</b> — See what's changed</a></p>
<p align="center"><i>KoalaSync is a lightweight Browser Extension and Relay Server for synchronized video playback on almost any website with a video element—YouTube, Twitch, Netflix, Emby, Jellyfin, and beyond. Built with a focus on <b>Data Sovereignty</b> and <b>Performance</b>.</i></p>
+3 -2
View File
@@ -73,8 +73,9 @@ Encrypt sensitive findings with our PGP key (available on request).
KoalaSync's security is grounded in its architecture:
- **RAM-only relay**: No database, no persistent logs. All session data evaporates on disconnect.
- **bcrypt room passwords**: Plaintext passwords never stored. Brute-force protection: 5 attempts → 15-minute IP lockout.
- **Rate limiting**: Connection rate (IP-based, 60s window) and event rate (per-socket, 10s window).
- **Keyed SHA-256 room password hashes**: Plaintext passwords are never stored. Room passwords are held only as in-memory HMAC-SHA256 hashes for the short room lifetime, with brute-force protection: 5 attempts → 15-minute IP lockout.
- **Rate limiting**: Connection rate (IP-based, 60s window), health endpoint rate (10 requests/minute/IP), wrong admin-metrics bearer attempts (5 requests/minute/IP), and event rate (per-socket, 10s window). Health-style JSON responses are cached server-side for 60 seconds and refreshed lazily on request.
- **Reverse proxy boundary**: The relay trusts one proxy hop for client IP detection. In production, keep the Node server reachable only through Caddy or another trusted reverse proxy.
- **URL-hash credential isolation**: Invitation credentials live in the URL fragment (`#join:...`) — never sent to the web server.
- **Strict CSP**: `default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'none'`.
- **No third-party requests**: Zero CDNs, fonts, analytics, or external scripts.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 889 KiB

Binary file not shown.
+2 -1
View File
@@ -8,7 +8,8 @@ services:
- PORT=3000 # Port KoalaSync listens on inside the container
- MIN_VERSION=1.0.0 # Minimum client version allowed to connect
- MAX_ROOMS=100 # Maximum number of rooms that can exist
- MAX_PEERS_PER_ROOM=50 # Maximum number of peers allowed per room
- MAX_PEERS_PER_ROOM=25 # Maximum number of peers allowed per room
- ADMIN_METRICS_TOKEN= # Optional: 32+ char random token for aggregate-only /health metrics
pids_limit: 2048 # Limits the container to 2048 process IDs for safety
networks: # Attaches the service to the networks listed below
- caddy_net # Joins the pre-existing Caddy network for reverse proxying
+2 -1
View File
@@ -10,7 +10,8 @@ services:
- PORT=3000 # Port KoalaSync listens on inside the container
- MIN_VERSION=1.0.0 # Minimum client version allowed to connect
- MAX_ROOMS=100 # Maximum number of rooms that can exist
- MAX_PEERS_PER_ROOM=50 # Maximum number of peers allowed per room
- MAX_PEERS_PER_ROOM=25 # Maximum number of peers allowed per room
- ADMIN_METRICS_TOKEN= # Optional: 32+ char random token for aggregate-only /health metrics
pids_limit: 2048 # Limits the container to 2048 process IDs for safety
networks: # Attaches the service to the networks listed below
bond0_network: # Network name as referenced by the service
+1 -1
View File
@@ -58,7 +58,7 @@ KoalaSync uses a megaphone routing approach to minimize server logic:
## 7. Security & Stability
- **Service Worker Lifecycle**: Uses `chrome.alarms` (30s interval) to prevent the Manifest V3 service worker from suspending while in an active room. On wake, runtime state is restored from `chrome.storage.session` via `ensureState()`.
- **Reconnect Visualization**: Badge shows "..." (orange) during reconnect. Popup displays "Reconnecting..." with attempt counter.
- **Rate Limiting**: Server-side per-socket and per-IP rate limits to prevent sync-spamming or DoS. Real client IP extracted via `x-forwarded-for` header behind proxies/CDNs.
- **Rate Limiting**: Server-side per-socket and per-IP rate limits to prevent sync-spamming or simple DoS. Public health endpoints are limited to 10 requests/minute/IP and cached server-side for 60 seconds, wrong admin-metrics bearer attempts to 5 requests/minute/IP, and room discovery to one request every 10 seconds per socket. Real client IP is taken from the trusted reverse proxy hop, so the Node port must stay private behind Caddy or another trusted proxy.
- **Room Creation Lock**: Per-room mutex prevents race conditions when multiple peers join a new room simultaneously.
- **CORS**: Allows `chrome-extension://` origins for WebSocket fallback compatibility.
- **Message Buffer**: `maxHttpBufferSize` set to 4KB to accommodate large `JOIN_ROOM` payloads.
+2 -2
View File
@@ -51,7 +51,7 @@ Click **"Create Room"** in the popup's Room tab:
4. **Server-side processing**:
- All fields are **sanitized**: `roomId` is stripped of invalid characters and clamped to 64 chars; `peerId` clamped to 16 chars; `password` clamped to 128 chars; `username` clamped to 30 chars.
- The server **hashes the password** with bcrypt and stores the hash in RAM (the plaintext is never stored).
- The server **hashes the password** with a keyed SHA-256 HMAC and stores only that hash in RAM (the plaintext is never stored).
- A new room object is created in memory with the peer's data.
- The server responds with `ROOM_DATA` containing the list of peers in the room.
@@ -89,7 +89,7 @@ When your friend opens the link in their browser:
- After 500ms, the page dispatches a `KOALASYNC_JOIN_REQUEST` custom DOM event with `{ roomId, password, useCustomServer, serverUrl }`.
- `bridge.js` catches this event and forwards it to `background.js` via `chrome.runtime.sendMessage`.
- `background.js` stores the credentials in `chrome.storage.sync` and emits `JOIN_ROOM` to the server.
- The server validates the password against the stored bcrypt hash.
- The server validates the password against the stored keyed SHA-256 HMAC hash.
- On success, the server responds with `ROOM_DATA` and broadcasts `PEER_STATUS { status: 'joined' }` to all existing peers.
- The join page updates to show "✅ Successfully joined!".
+5 -2
View File
@@ -482,12 +482,15 @@ async function connect() {
socket.onerror = () => {
broadcastConnectionStatus('disconnected');
addLog('WebSocket Error: Connection failed', 'error');
const logType = reconnectAttempts > 1 ? 'error' : 'warn';
addLog('WebSocket Error: Connection failed', logType);
};
} catch (e) {
isConnecting = false;
addLog(e.message, 'error');
const logType = reconnectAttempts > 1 ? 'error' : 'warn';
const errMsg = (e && e.message) ? e.message : String(e || 'Unknown connection error');
addLog(errMsg, logType);
broadcastConnectionStatus('disconnected');
scheduleReconnect();
}
+13 -14
View File
@@ -80,27 +80,26 @@
// --- Helper: find the best video element on the page ---
// Prefers larger, visible videos over tiny preview/trailer elements.
function findVideo(root = document) {
const allVideos = root.querySelectorAll('video');
if (allVideos.length === 0) {
// Optimize: scan only potential player, video, media, and stream hosts by matching typical keywords (case-insensitive)
// or common custom element tags. This prevents recursive scanning of thousands of standard DOM nodes (div, span, a, etc.)
// while guaranteeing 100% airtight compatibility with all video web components in the wild.
const potentialHosts = root.querySelectorAll('[id*="player" i], [class*="player" i], [id*="video" i], [class*="video" i], [id*="media" i], [class*="media" i], [id*="stream" i], [class*="stream" i], ytd-player, netflix-player, emby-player, jellyfin-player, video-player');
for (const el of potentialHosts) {
if (el.shadowRoot) {
const found = findVideo(el.shadowRoot);
if (found) return found;
}
const candidates = Array.from(root.querySelectorAll('video'));
// Scan likely media hosts even when light-DOM videos exist; many players
// expose a tiny preview/ad video outside Shadow DOM and the real player inside.
const potentialHosts = root.querySelectorAll('[id*="player" i], [class*="player" i], [id*="video" i], [class*="video" i], [id*="media" i], [class*="media" i], [id*="stream" i], [class*="stream" i], ytd-player, netflix-player, emby-player, jellyfin-player, video-player');
for (const el of potentialHosts) {
if (el.shadowRoot) {
const found = findVideo(el.shadowRoot);
if (found) candidates.push(found);
}
return null;
}
if (candidates.length === 0) return null;
// Multiple videos found → pick the best one
if (allVideos.length === 1) return allVideos[0];
if (candidates.length === 1) return candidates[0];
let best = null;
let bestScore = -1;
for (const v of allVideos) {
for (const v of candidates) {
if (v.tagName !== 'VIDEO') continue;
// Score: visible area + bonus for unmuted + bonus for longer duration
const area = (v.videoWidth || v.offsetWidth || 0) * (v.videoHeight || v.offsetHeight || 0);
+1 -1
View File
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "KoalaSync",
"version": "2.0.1",
"version": "2.0.4",
"description": "Synchronize video playback on YouTube, Netflix, Emby, Jellyfin, and any HTML5 site in real-time with friends.",
"permissions": [
"storage",
+85 -6
View File
@@ -66,21 +66,32 @@ let popupIntervals = [];
let populateTabsToken = null;
let errorToken = 0;
let forceSyncDone = false;
let connectionErrorTimer = null;
let pendingConnectionErrorMsg = null;
let roomListRefreshTimer = null;
const ROOM_LIST_REFRESH_COOLDOWN_MS = 11000;
// --- Helpers ---
function clearConnectionErrorTimer() {
if (connectionErrorTimer) {
clearTimeout(connectionErrorTimer);
connectionErrorTimer = null;
}
pendingConnectionErrorMsg = null;
}
// --- Initialization ---
async function init() {
// Load Settings
const data = await chrome.storage.sync.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'filterNoise', 'username', 'autoSyncNextEpisode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite', 'locale']);
let activeLang = data.locale;
if (!activeLang) {
const systemLang = (navigator.language || chrome.i18n.getUILanguage()).split('-')[0];
activeLang = ['en', 'de', 'fr', 'es', 'pt', 'ru'].includes(systemLang) ? (systemLang === 'pt' ? 'pt-BR' : systemLang) : 'en';
chrome.storage.sync.set({ locale: activeLang });
}
await loadLocale(activeLang);
translateDOM();
@@ -148,8 +159,8 @@ async function init() {
// Check for invite link on landing page
checkInviteLink();
// Initial room list fetch
chrome.runtime.sendMessage({ type: 'GET_ROOM_LIST' });
// Initialize public rooms placeholder
renderEmpty(elements.publicRooms, 'rooms');
// Debug Info Refresh
popupIntervals.push(setInterval(refreshDebugInfo, 2000));
@@ -952,7 +963,12 @@ function showToast(message, type = 'info', duration = 3000) {
if (!container) return;
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.textContent = message;
toast.textContent = message || '';
toast.style.whiteSpace = 'pre-wrap';
const delay = Math.max(0, duration - 600);
toast.style.animation = `toastSlideIn 0.3s ease-out, toastFadeOut 0.3s ease-in ${delay}ms forwards`;
container.appendChild(toast);
setTimeout(() => toast.remove(), duration);
}
@@ -981,6 +997,7 @@ elements.roomId.addEventListener('input', () => {
});
elements.joinBtn.addEventListener('click', async () => {
clearConnectionErrorTimer();
if (elements.joinBtn.disabled) return;
const roomIdInput = elements.roomId.value.trim();
const isCreating = !roomIdInput;
@@ -1040,6 +1057,7 @@ elements.joinBtn.addEventListener('click', async () => {
});
elements.leaveBtn.addEventListener('click', async () => {
clearConnectionErrorTimer();
chrome.runtime.sendMessage({ type: 'LEAVE_ROOM' });
await chrome.storage.sync.set({ roomId: '', password: '' });
elements.roomId.value = '';
@@ -1080,6 +1098,13 @@ if (syncTabCreateRoomBtn) syncTabCreateRoomBtn.addEventListener('click', () => {
});
elements.refreshRooms.addEventListener('click', () => {
if (elements.refreshRooms.disabled) return;
elements.refreshRooms.disabled = true;
roomListRefreshTimer = setTimeout(() => {
elements.refreshRooms.disabled = false;
roomListRefreshTimer = null;
}, ROOM_LIST_REFRESH_COOLDOWN_MS);
elements.publicRooms.replaceChildren();
const el = document.createElement('div');
el.style.cssText = 'text-align:center; color: var(--text-muted); font-size: 11px; padding: 10px;';
@@ -1310,7 +1335,25 @@ chrome.runtime.onMessage.addListener((msg) => {
if (msg.type === 'LOG_UPDATE') {
refreshLogs();
if (msg.log && msg.log.type === 'error') {
showError(msg.log.message);
const errMsg = msg.log.message || '';
const isConnErr = typeof errMsg === 'string' && (
errMsg.toLowerCase().includes('connection') ||
errMsg.toLowerCase().includes('websocket')
);
if (isConnErr) {
pendingConnectionErrorMsg = msg.log.message;
if (!connectionErrorTimer) {
connectionErrorTimer = setTimeout(() => {
if (pendingConnectionErrorMsg) {
showError(pendingConnectionErrorMsg);
}
connectionErrorTimer = null;
pendingConnectionErrorMsg = null;
}, 5000);
}
} else {
showError(msg.log.message);
}
}
} else if (msg.type === 'ACTION_UPDATE') {
const state = msg.state;
@@ -1366,6 +1409,9 @@ chrome.runtime.onMessage.addListener((msg) => {
updatePeerList(msg.peers);
if (msg.peers) detectPeerChanges(msg.peers);
} else if (msg.type === 'CONNECTION_STATUS') {
if (msg.status === 'connected') {
clearConnectionErrorTimer();
}
if (msg.status !== 'reconnecting') {
applyConnectionStatus(msg.status);
reconnectSlowMode = false;
@@ -1397,6 +1443,13 @@ chrome.runtime.onMessage.addListener((msg) => {
} else if (msg.type === 'ROOM_LIST') {
updateRoomList(msg.rooms);
} else if (msg.type === 'JOIN_STATUS') {
if (joinBtnTimeout) {
clearTimeout(joinBtnTimeout);
joinBtnTimeout = null;
}
elements.joinBtn.disabled = false;
elements.joinBtn.textContent = getMessage('BTN_JOIN_ROOM');
if (msg.success) {
// Final confirmation of join from background
chrome.storage.sync.get(['roomId', 'password', 'useCustomServer', 'serverUrl'], (data) => {
@@ -1589,6 +1642,28 @@ elements.copyLogs.addEventListener('click', () => {
const original = elements.copyLogs.textContent;
elements.copyLogs.textContent = getMessage('TOAST_LOGS_COPIED');
setTimeout(() => { elements.copyLogs.textContent = original; }, 2000);
// Construct rich multiline toast summary
const verStr = safe(status.version, '?');
const logsCount = logs.length;
const historyCount = history.length;
const peersCount = Array.isArray(status.peers) ? status.peers.length : 0;
let summary = `📋 Debug Report Copied!\n`;
summary += `• Version: v${verStr}\n`;
summary += `• System Logs: ${logsCount} entries\n`;
summary += `• Sync Actions: ${historyCount} captured\n`;
if (status.roomId) {
summary += `• Peers in Room: ${peersCount}\n`;
}
if (rawVideo && vs.found) {
const stateStr = vs.paused === true ? 'Paused' : (vs.paused === false ? 'Playing' : 'Unknown');
const timeStr = typeof vs.currentTime === 'number' ? `${Math.round(vs.currentTime)}s` : '?';
summary += `• Video: ${stateStr} at ${timeStr}\n`;
}
summary += `Paste it in markdown to view!`;
showToast(summary, 'success', 5000);
}).catch(() => {
showToast(getMessage('TOAST_COPY_FAILED'), 'error');
});
@@ -1753,6 +1828,10 @@ window.addEventListener('unload', () => {
clearTimeout(forceSyncResetTimer);
forceSyncResetTimer = null;
}
if (roomListRefreshTimer) {
clearTimeout(roomListRefreshTimer);
roomListRefreshTimer = null;
}
});
// --- Episode Lobby UI ---
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "koalasync",
"version": "2.0.1",
"version": "2.0.4",
"description": "KoalaSync Build Scripts",
"private": true,
"scripts": {
+64
View File
@@ -0,0 +1,64 @@
const fs = require('fs');
const path = require('path');
const assert = require('assert');
const contentPath = path.join(__dirname, '..', 'extension', 'content.js');
const source = fs.readFileSync(contentPath, 'utf8');
function extractFunction(name, text) {
const start = text.indexOf(`function ${name}`);
assert.notStrictEqual(start, -1, `${name} not found`);
const bodyStart = text.indexOf('{', start);
let depth = 0;
for (let i = bodyStart; i < text.length; i++) {
if (text[i] === '{') depth++;
if (text[i] === '}') depth--;
if (depth === 0) return text.slice(start, i + 1);
}
throw new Error(`${name} body did not terminate`);
}
function makeVideo(name, width, height, options = {}) {
return {
name,
tagName: 'VIDEO',
videoWidth: width,
videoHeight: height,
offsetWidth: width,
offsetHeight: height,
muted: options.muted ?? true,
duration: options.duration ?? 0
};
}
const lightPreview = makeVideo('light-preview', 160, 90, { muted: false, duration: 30 });
const shadowPlayer = makeVideo('shadow-player', 1920, 1080, { muted: false, duration: 3600 });
const shadowRoot = {
querySelectorAll(selector) {
if (selector === 'video') return [shadowPlayer];
return [];
}
};
const shadowHost = { shadowRoot };
const fakeDocument = {
querySelectorAll(selector) {
if (selector === 'video') return [lightPreview];
return [shadowHost];
}
};
const fnSource = extractFunction('findVideo', source);
const findVideo = Function('document', `${fnSource}; return findVideo;`)(fakeDocument);
const selected = findVideo(fakeDocument);
assert.strictEqual(
selected,
shadowPlayer,
'findVideo should score Shadow DOM videos together with light DOM videos'
);
console.log('content video finder tests passed');
+27
View File
@@ -0,0 +1,27 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { cwd } from 'node:process';
const popupPath = path.join(cwd(), 'extension', 'popup.js');
const source = fs.readFileSync(popupPath, 'utf8');
assert.match(
source,
/const ROOM_LIST_REFRESH_COOLDOWN_MS\s*=\s*11000;/,
'popup should define an 11 second room-list refresh cooldown'
);
assert.match(
source,
/elements\.refreshRooms\.disabled\s*=\s*true;/,
'refresh button should be disabled while cooldown is active'
);
assert.match(
source,
/setTimeout\(\(\)\s*=>\s*{\s*elements\.refreshRooms\.disabled\s*=\s*false;/s,
'refresh button should be re-enabled after the cooldown'
);
console.log('popup refresh cooldown tests passed');
+85
View File
@@ -0,0 +1,85 @@
import assert from 'node:assert/strict';
import {
buildHealthPayload,
checkCooldown,
getCachedPayload,
isAdminMetricsAuthorized,
isAdminMetricsTokenStrong
} from '../server/ops.js';
const missingAuth = isAdminMetricsAuthorized(undefined, 'secret-token');
assert.equal(missingAuth, false, 'missing Authorization header must not authorize metrics');
const wrongAuth = isAdminMetricsAuthorized('Bearer wrong-token', 'secret-token');
assert.equal(wrongAuth, false, 'wrong bearer token must not authorize metrics');
const correctAuth = isAdminMetricsAuthorized('Bearer secret-token', 'secret-token');
assert.equal(correctAuth, true, 'correct bearer token should authorize metrics');
const disabledAuth = isAdminMetricsAuthorized('Bearer secret-token', '');
assert.equal(disabledAuth, false, 'empty admin token disables admin metrics');
assert.equal(isAdminMetricsTokenStrong(''), true, 'empty admin token is allowed because metrics stay disabled');
assert.equal(isAdminMetricsTokenStrong('short-token'), false, 'short admin token should be reported as weak');
assert.equal(
isAdminMetricsTokenStrong('a'.repeat(32)),
true,
'admin token with at least 32 characters should be considered strong'
);
const cooldowns = new Map();
assert.equal(checkCooldown(cooldowns, 'socket-1', 10_000, 100_000), true, 'first cooldown check passes');
assert.equal(checkCooldown(cooldowns, 'socket-1', 10_000, 105_000), false, 'second cooldown check inside window fails');
assert.equal(checkCooldown(cooldowns, 'socket-1', 10_000, 110_000), true, 'cooldown check after window passes');
const cache = new Map();
let buildCalls = 0;
const firstCached = getCachedPayload(cache, 'basic-health', 60_000, () => ({ value: ++buildCalls }), 1_000);
const secondCached = getCachedPayload(cache, 'basic-health', 60_000, () => ({ value: ++buildCalls }), 30_000);
const expiredCached = getCachedPayload(cache, 'basic-health', 60_000, () => ({ value: ++buildCalls }), 61_001);
assert.deepEqual(firstCached, { value: 1 }, 'cache should return the builder payload on first request');
assert.strictEqual(secondCached, firstCached, 'cache should reuse payloads inside the ttl');
assert.deepEqual(expiredCached, { value: 2 }, 'cache should rebuild payloads after ttl expiry');
const roomA = { peers: new Set(['a', 'b']), activeLobby: null };
const roomB = { peers: new Set(['c', 'd', 'e']), activeLobby: { expectedTitle: 'Episode 2' } };
const rooms = new Map([['room-a', roomA], ['room-b', roomB]]);
const basicHealth = buildHealthPayload({
rooms,
connections: 5,
includeMetrics: false,
now: 1234,
uptime: 99,
memoryUsage: () => ({ rss: 10, heapUsed: 5, heapTotal: 8 }),
rateLimitSizes: { connections: 1, events: 2, health: 3, adminMetricsAuth: 4, authFailures: 5, roomList: 6 }
});
assert.deepEqual(
Object.keys(basicHealth).sort(),
['connections', 'rooms', 'status', 'timestamp', 'uptime'].sort(),
'basic health should not expose extended metrics'
);
const adminHealth = buildHealthPayload({
rooms,
connections: 5,
includeMetrics: true,
now: 1234,
uptime: 99,
memoryUsage: () => ({ rss: 10, heapUsed: 5, heapTotal: 8 }),
rateLimitSizes: { connections: 1, events: 2, health: 3, adminMetricsAuth: 4, authFailures: 5, roomList: 6 }
});
assert.equal(adminHealth.peers, 5, 'admin metrics should include aggregate peer count');
assert.equal(adminHealth.roomsWithLobby, 1, 'admin metrics should count active lobbies');
assert.equal(adminHealth.avgPeersPerRoom, 2.5, 'admin metrics should include average room size');
assert.equal(adminHealth.maxPeersInRoom, 3, 'admin metrics should include max room size');
assert.deepEqual(adminHealth.memory, { rss: 10, heapUsed: 5, heapTotal: 8 }, 'admin metrics should expose process memory');
assert.deepEqual(
adminHealth.rateLimitEntries,
{ connections: 1, events: 2, health: 3, adminMetricsAuth: 4, authFailures: 5, roomList: 6 },
'admin metrics should expose aggregate rate-limit map sizes'
);
console.log('server ops tests passed');
+4 -1
View File
@@ -1,4 +1,7 @@
PORT=3000
MIN_VERSION=1.0.0
MAX_ROOMS=1000
MAX_PEERS_PER_ROOM=50
MAX_PEERS_PER_ROOM=25
# Optional: enables aggregate-only admin metrics on /health with Authorization: Bearer <token>
# Use a long random token, 32+ characters recommended.
ADMIN_METRICS_TOKEN=
+14 -1
View File
@@ -14,10 +14,20 @@ Copy `.env.example` to `.env` and configure your settings.
```bash
PORT=3000
MAX_ROOMS=1000
MAX_PEERS_PER_ROOM=50
MAX_PEERS_PER_ROOM=25
MIN_VERSION=1.0.0
# Optional: enables aggregate-only admin metrics on /health with Authorization: Bearer <token>
# Use a long random token, 32+ characters recommended.
ADMIN_METRICS_TOKEN=
```
### Health & Metrics
`GET /` and `GET /health` are IP-rate-limited to 10 requests per minute per client IP. These health-style responses are cached server-side for 60 seconds and refreshed lazily on request. By default `/health` returns only basic service status, uptime, room count, connection count, and a timestamp.
If `ADMIN_METRICS_TOKEN` is set, requests with `Authorization: Bearer <token>` receive additional aggregate metrics such as total peers, average peers per room, max room size, active lobby count, rate-limit map sizes, and process memory usage. Wrong admin bearer attempts are separately limited to 5 requests per minute per client IP. The metrics response does not include room IDs, peer IDs, usernames, IP addresses, media titles, or other user-level data.
Use a long random `ADMIN_METRICS_TOKEN` of at least 32 characters. Shorter configured tokens still work, but the server logs a startup warning.
### Docker (Recommended)
The server is available as a pre-built image on GHCR.
```bash
@@ -38,6 +48,9 @@ npm start
## Security
- **Rate Limiting**: IP-based connection limits and socket-based event limits.
- **Health Endpoint Throttle**: `GET /` and `GET /health` are limited to 10 requests per minute per IP, with 60-second lazy server-side response caching and stricter throttling for wrong admin bearer attempts.
- **Room Discovery Throttle**: Room-list refreshes are rate-limited server-side to one request every 10 seconds per socket.
- **Token Handshake**: Requires a valid token defined in the root `shared/constants.js`.
- **Single Source of Truth**: The server imports constants directly from the root `shared/` directory.
- **In-Memory**: Rooms are automatically pruned after 2 hours of inactivity.
- **Reverse Proxy Boundary**: The server trusts one reverse proxy hop for client IP detection. Keep the Node port private/firewalled so clients can only reach it through Caddy or another trusted proxy.
+78 -10
View File
@@ -4,6 +4,13 @@ import { Server } from 'socket.io';
import crypto from 'crypto';
import dotenv from 'dotenv';
import { EVENTS, OFFICIAL_SERVER_TOKEN, PROTOCOL_VERSION } from '../shared/constants.js';
import {
buildHealthPayload,
checkCooldown,
getCachedPayload,
isAdminMetricsAuthorized,
isAdminMetricsTokenStrong
} from './ops.js';
dotenv.config();
@@ -15,19 +22,36 @@ function hashPassword(password) {
const PORT = process.env.PORT || 3000;
const MAX_ROOMS = parseInt(process.env.MAX_ROOMS) || 1000;
const MAX_PEERS_PER_ROOM = parseInt(process.env.MAX_PEERS_PER_ROOM) || 50;
const MAX_PEERS_PER_ROOM = parseInt(process.env.MAX_PEERS_PER_ROOM) || 25;
const MIN_VERSION = process.env.MIN_VERSION || '1.0.0';
const ADMIN_METRICS_TOKEN = process.env.ADMIN_METRICS_TOKEN || '';
const ROOM_LIST_COOLDOWN_MS = 10000;
const HEALTH_RATE_LIMIT_PER_MINUTE = 10;
const ADMIN_METRICS_AUTH_RATE_LIMIT_PER_MINUTE = 5;
const HEALTH_RESPONSE_CACHE_TTL_MS = 60000;
if (!isAdminMetricsTokenStrong(ADMIN_METRICS_TOKEN)) {
console.warn('[SECURITY] ADMIN_METRICS_TOKEN is set but shorter than 32 characters. Use a long random token.');
}
const app = express();
app.set('trust proxy', 1); // For real client IP through reverse proxy
const healthResponseCache = new Map();
// Health Check with Rate Limiting
app.get('/', (req, res) => {
const clientIp = req.ip;
if (!checkHealthRate(clientIp)) {
return res.status(429).json({ error: 'Too many requests. Try again later.' });
}
res.json({ status: 'online', service: 'KoalaSync Relay' });
res.set('Cache-Control', 'no-store');
res.json(getCachedPayload(
healthResponseCache,
'root',
HEALTH_RESPONSE_CACHE_TTL_MS,
() => ({ status: 'online', service: 'KoalaSync Relay' })
));
});
app.get('/health', (req, res) => {
@@ -35,13 +59,31 @@ app.get('/health', (req, res) => {
if (!checkHealthRate(clientIp)) {
return res.status(429).json({ error: 'Rate limited' });
}
res.json({
status: 'ok',
uptime: process.uptime(),
rooms: rooms.size,
connections: io.engine?.clientsCount ?? 0,
timestamp: Date.now()
});
const authHeader = req.get('authorization');
const includeMetrics = isAdminMetricsAuthorized(authHeader, ADMIN_METRICS_TOKEN);
if (ADMIN_METRICS_TOKEN && authHeader && !includeMetrics && !checkAdminMetricsAuthRate(clientIp)) {
return res.status(429).json({ error: 'Rate limited' });
}
res.set('Cache-Control', 'no-store');
res.json(getCachedPayload(
healthResponseCache,
includeMetrics ? 'health-admin' : 'health-basic',
HEALTH_RESPONSE_CACHE_TTL_MS,
() => buildHealthPayload({
rooms,
connections: io.engine?.clientsCount ?? 0,
includeMetrics,
uptime: process.uptime(),
rateLimitSizes: {
connections: connectionCounts.size,
events: eventCounts.size,
health: healthCounts.size,
adminMetricsAuth: adminMetricsAuthCounts.size,
authFailures: failedAuthAttempts.size,
roomList: roomListCooldowns.size
}
})
));
});
const httpServer = createServer(app);
@@ -139,6 +181,8 @@ setInterval(() => {
const eventCounts = new Map(); // socketId -> { count, resetTime }
const healthCounts = new Map(); // ip -> { count, resetTime }
const adminMetricsAuthCounts = new Map(); // ip -> { count, resetTime }
const roomListCooldowns = new Map(); // socketId -> last allowed timestamp
// Clean up connection counts and event counts to prevent memory leak
setInterval(() => {
@@ -158,6 +202,16 @@ setInterval(() => {
healthCounts.delete(ip);
}
}
for (const [ip, entry] of adminMetricsAuthCounts.entries()) {
if (now > entry.resetTime) {
adminMetricsAuthCounts.delete(ip);
}
}
for (const [socketId] of roomListCooldowns.entries()) {
if (!io.sockets.sockets.has(socketId)) {
roomListCooldowns.delete(socketId);
}
}
}, 60000);
function checkConnectionRate(ip) {
@@ -184,7 +238,16 @@ function checkHealthRate(ip) {
if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + 60000; }
entry.count++;
healthCounts.set(ip, entry);
return entry.count <= 60;
return entry.count <= HEALTH_RATE_LIMIT_PER_MINUTE;
}
function checkAdminMetricsAuthRate(ip) {
const now = Date.now();
const entry = adminMetricsAuthCounts.get(ip) || { count: 0, resetTime: now + 60000 };
if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + 60000; }
entry.count++;
adminMetricsAuthCounts.set(ip, entry);
return entry.count <= ADMIN_METRICS_AUTH_RATE_LIMIT_PER_MINUTE;
}
/**
@@ -541,6 +604,10 @@ io.on('connection', (socket) => {
socket.disconnect(true);
return;
}
if (!checkCooldown(roomListCooldowns, socket.id, ROOM_LIST_COOLDOWN_MS)) {
socket.emit(EVENTS.ERROR, { message: 'Room list refresh is rate limited. Try again in a few seconds.' });
return;
}
const list = Array.from(rooms.entries()).map(([id, r]) => ({
id,
peerCount: r.peers.size,
@@ -584,6 +651,7 @@ io.on('connection', (socket) => {
socket.on('disconnect', () => {
eventCounts.delete(socket.id);
roomListCooldowns.delete(socket.id);
const mapping = socketToRoom.get(socket.id);
if (mapping) {
// Socket is already disconnected — no need to call socket.leave().
+92
View File
@@ -0,0 +1,92 @@
import crypto from 'crypto';
export function checkCooldown(cooldowns, key, windowMs, now = Date.now()) {
const lastAllowedAt = cooldowns.get(key) || 0;
if (now - lastAllowedAt < windowMs) {
return false;
}
cooldowns.set(key, now);
return true;
}
export function getCachedPayload(cache, key, ttlMs, buildPayload, now = Date.now()) {
const cached = cache.get(key);
if (cached && now - cached.createdAt < ttlMs) {
return cached.payload;
}
const payload = buildPayload();
cache.set(key, { createdAt: now, payload });
return payload;
}
export function isAdminMetricsAuthorized(authHeader, adminToken) {
if (!adminToken || typeof adminToken !== 'string') return false;
if (!authHeader || typeof authHeader !== 'string') return false;
const prefix = 'Bearer ';
if (!authHeader.startsWith(prefix)) return false;
const provided = authHeader.slice(prefix.length);
if (!provided) return false;
const expectedBuffer = Buffer.from(adminToken);
const providedBuffer = Buffer.from(provided);
if (expectedBuffer.length !== providedBuffer.length) return false;
return crypto.timingSafeEqual(expectedBuffer, providedBuffer);
}
export function isAdminMetricsTokenStrong(adminToken, minLength = 32) {
return !adminToken || (typeof adminToken === 'string' && adminToken.length >= minLength);
}
export function buildHealthPayload({
rooms,
connections,
includeMetrics = false,
now = Date.now(),
uptime = 0,
memoryUsage = () => process.memoryUsage(),
rateLimitSizes = {}
}) {
const payload = {
status: 'ok',
uptime,
rooms: rooms.size,
connections,
timestamp: now
};
if (!includeMetrics) return payload;
const roomValues = Array.from(rooms.values());
const roomSizes = roomValues.map(room => room.peers?.size || 0);
const peers = roomSizes.reduce((sum, size) => sum + size, 0);
const maxPeersInRoom = roomSizes.length > 0 ? Math.max(...roomSizes) : 0;
const avgPeersPerRoom = roomSizes.length > 0
? Math.round((peers / roomSizes.length) * 100) / 100
: 0;
const mem = memoryUsage();
return {
...payload,
peers,
roomsWithLobby: roomValues.filter(room => !!room.activeLobby).length,
avgPeersPerRoom,
maxPeersInRoom,
rateLimitEntries: {
connections: rateLimitSizes.connections || 0,
events: rateLimitSizes.events || 0,
health: rateLimitSizes.health || 0,
adminMetricsAuth: rateLimitSizes.adminMetricsAuth || 0,
authFailures: rateLimitSizes.authFailures || 0,
roomList: rateLimitSizes.roomList || 0
},
memory: {
rss: mem.rss,
heapUsed: mem.heapUsed,
heapTotal: mem.heapTotal
}
};
}
-10
View File
@@ -9,7 +9,6 @@
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"bcryptjs": "^3.0.3",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"socket.io": "^4.8.3"
@@ -70,15 +69,6 @@
"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/body-parser": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
-1
View File
@@ -11,7 +11,6 @@
"license": "ISC",
"type": "module",
"dependencies": {
"bcryptjs": "^3.0.3",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"socket.io": "^4.8.3"
+1 -1
View File
@@ -310,7 +310,7 @@ document.addEventListener('DOMContentLoaded', () => {
const updateDynamicVersion = async () => {
try {
const versionPath = document.documentElement.lang === 'de' ? '../version.json' : 'version.json';
const versionPath = document.documentElement.lang === 'en' ? 'version.json' : '../version.json';
const response = await fetch(versionPath);
if (!response.ok) return;
const data = await response.json();
+2 -2
View File
@@ -1,4 +1,4 @@
{
"version": "2.0.1",
"date": "2026-06-01T13:59:31Z"
"version": "2.0.4",
"date": "2026-06-03T09:05:38Z"
}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -218,6 +218,6 @@
</div>
</footer>
<script src="app.e6679b8d.min.js"></script>
<script src="app.deb95738.min.js"></script>
</body>
</html>
+4 -4
View File
@@ -256,7 +256,7 @@
<div class="mock-header-title"><picture><source srcset="../assets/NewLogoIcon.avif" type="image/avif"><img src="../assets/NewLogoIcon.webp" alt="KoalaSync Logo"></picture>KoalaSync</div>
<a href="https://github.com/Shik3i/KoalaSync" target="_blank" class="mock-version-link" title="Visit GitHub Repository">
<svg fill="currentColor" style="width:12px;height:12px;display:block" viewBox="0 0 16 16"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8"/></svg>
<span class="mockup-version">2.0.1</span>
<span class="mockup-version">2.0.4</span>
</a>
</div>
<div class="mock-tabs">
@@ -519,7 +519,7 @@
<div style="margin-top: 16px; text-align: center; border-top: 1px solid rgba(255,255,255,0.05); padding-top: 10px; flex-shrink: 0;">
<a href="https://github.com/Shik3i/KoalaSync" target="_blank" style="color: var(--text-muted); text-decoration: none; font-size: 10px; opacity: 0.6; display: block;">GitHub Repository</a>
<div style="color: var(--text-muted); font-size: 9px; opacity: 0.4; margin-top: 4px;" class="mockup-version">2.0.1</div>
<div style="color: var(--text-muted); font-size: 9px; opacity: 0.4; margin-top: 4px;" class="mockup-version">2.0.4</div>
</div>
</div>
</div>
@@ -887,7 +887,7 @@
<picture><source srcset="../assets/NewLogoIcon.avif" type="image/avif"><img src="../assets/NewLogoIcon.webp" alt="KoalaSync Logo" width="14" height="14"></picture>
<span>KoalaSync</span>
</div>
<div class="illus-popup-version mockup-version">2.0.1</div>
<div class="illus-popup-version mockup-version">2.0.4</div>
</div>
<div class="illus-popup-tabs">
<div class="illus-popup-tab active">
@@ -1189,6 +1189,6 @@
</div>
</footer>
<script src="../app.e6679b8d.min.js"></script>
<script src="../app.deb95738.min.js"></script>
</body>
</html>
+4 -4
View File
@@ -256,7 +256,7 @@
<div class="mock-header-title"><picture><source srcset="../assets/NewLogoIcon.avif" type="image/avif"><img src="../assets/NewLogoIcon.webp" alt="KoalaSync Logo"></picture>KoalaSync</div>
<a href="https://github.com/Shik3i/KoalaSync" target="_blank" class="mock-version-link" title="Visit GitHub Repository">
<svg fill="currentColor" style="width:12px;height:12px;display:block" viewBox="0 0 16 16"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8"/></svg>
<span class="mockup-version">2.0.1</span>
<span class="mockup-version">2.0.4</span>
</a>
</div>
<div class="mock-tabs">
@@ -519,7 +519,7 @@
<div style="margin-top: 16px; text-align: center; border-top: 1px solid rgba(255,255,255,0.05); padding-top: 10px; flex-shrink: 0;">
<a href="https://github.com/Shik3i/KoalaSync" target="_blank" style="color: var(--text-muted); text-decoration: none; font-size: 10px; opacity: 0.6; display: block;">GitHub Repository</a>
<div style="color: var(--text-muted); font-size: 9px; opacity: 0.4; margin-top: 4px;" class="mockup-version">2.0.1</div>
<div style="color: var(--text-muted); font-size: 9px; opacity: 0.4; margin-top: 4px;" class="mockup-version">2.0.4</div>
</div>
</div>
</div>
@@ -887,7 +887,7 @@
<picture><source srcset="../assets/NewLogoIcon.avif" type="image/avif"><img src="../assets/NewLogoIcon.webp" alt="KoalaSync Logo" width="14" height="14"></picture>
<span>KoalaSync</span>
</div>
<div class="illus-popup-version mockup-version">2.0.1</div>
<div class="illus-popup-version mockup-version">2.0.4</div>
</div>
<div class="illus-popup-tabs">
<div class="illus-popup-tab active">
@@ -1189,6 +1189,6 @@
</div>
</footer>
<script src="../app.e6679b8d.min.js"></script>
<script src="../app.deb95738.min.js"></script>
</body>
</html>
+4 -4
View File
@@ -256,7 +256,7 @@
<div class="mock-header-title"><picture><source srcset="../assets/NewLogoIcon.avif" type="image/avif"><img src="../assets/NewLogoIcon.webp" alt="KoalaSync Logo"></picture>KoalaSync</div>
<a href="https://github.com/Shik3i/KoalaSync" target="_blank" class="mock-version-link" title="Visit GitHub Repository">
<svg fill="currentColor" style="width:12px;height:12px;display:block" viewBox="0 0 16 16"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8"/></svg>
<span class="mockup-version">2.0.1</span>
<span class="mockup-version">2.0.4</span>
</a>
</div>
<div class="mock-tabs">
@@ -519,7 +519,7 @@
<div style="margin-top: 16px; text-align: center; border-top: 1px solid rgba(255,255,255,0.05); padding-top: 10px; flex-shrink: 0;">
<a href="https://github.com/Shik3i/KoalaSync" target="_blank" style="color: var(--text-muted); text-decoration: none; font-size: 10px; opacity: 0.6; display: block;">GitHub Repository</a>
<div style="color: var(--text-muted); font-size: 9px; opacity: 0.4; margin-top: 4px;" class="mockup-version">2.0.1</div>
<div style="color: var(--text-muted); font-size: 9px; opacity: 0.4; margin-top: 4px;" class="mockup-version">2.0.4</div>
</div>
</div>
</div>
@@ -887,7 +887,7 @@
<picture><source srcset="../assets/NewLogoIcon.avif" type="image/avif"><img src="../assets/NewLogoIcon.webp" alt="KoalaSync Logo" width="14" height="14"></picture>
<span>KoalaSync</span>
</div>
<div class="illus-popup-version mockup-version">2.0.1</div>
<div class="illus-popup-version mockup-version">2.0.4</div>
</div>
<div class="illus-popup-tabs">
<div class="illus-popup-tab active">
@@ -1189,6 +1189,6 @@
</div>
</footer>
<script src="../app.e6679b8d.min.js"></script>
<script src="../app.deb95738.min.js"></script>
</body>
</html>
+1 -1
View File
@@ -192,6 +192,6 @@
</div>
</footer>
<script src="app.e6679b8d.min.js"></script>
<script src="app.deb95738.min.js"></script>
</body>
</html>
+4 -4
View File
@@ -256,7 +256,7 @@
<div class="mock-header-title"><picture><source srcset="assets/NewLogoIcon.avif" type="image/avif"><img src="assets/NewLogoIcon.webp" alt="KoalaSync Logo"></picture>KoalaSync</div>
<a href="https://github.com/Shik3i/KoalaSync" target="_blank" class="mock-version-link" title="Visit GitHub Repository">
<svg fill="currentColor" style="width:12px;height:12px;display:block" viewBox="0 0 16 16"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8"/></svg>
<span class="mockup-version">2.0.1</span>
<span class="mockup-version">2.0.4</span>
</a>
</div>
<div class="mock-tabs">
@@ -519,7 +519,7 @@
<div style="margin-top: 16px; text-align: center; border-top: 1px solid rgba(255,255,255,0.05); padding-top: 10px; flex-shrink: 0;">
<a href="https://github.com/Shik3i/KoalaSync" target="_blank" style="color: var(--text-muted); text-decoration: none; font-size: 10px; opacity: 0.6; display: block;">GitHub Repository</a>
<div style="color: var(--text-muted); font-size: 9px; opacity: 0.4; margin-top: 4px;" class="mockup-version">2.0.1</div>
<div style="color: var(--text-muted); font-size: 9px; opacity: 0.4; margin-top: 4px;" class="mockup-version">2.0.4</div>
</div>
</div>
</div>
@@ -887,7 +887,7 @@
<picture><source srcset="assets/NewLogoIcon.avif" type="image/avif"><img src="assets/NewLogoIcon.webp" alt="KoalaSync Logo" width="14" height="14"></picture>
<span>KoalaSync</span>
</div>
<div class="illus-popup-version mockup-version">2.0.1</div>
<div class="illus-popup-version mockup-version">2.0.4</div>
</div>
<div class="illus-popup-tabs">
<div class="illus-popup-tab active">
@@ -1189,6 +1189,6 @@
</div>
</footer>
<script src="app.e6679b8d.min.js"></script>
<script src="app.deb95738.min.js"></script>
</body>
</html>
+1 -1
View File
@@ -121,6 +121,6 @@
</div>
</footer>
<script src="app.e6679b8d.min.js"></script>
<script src="app.deb95738.min.js"></script>
</body>
</html>
+4 -4
View File
@@ -256,7 +256,7 @@
<div class="mock-header-title"><picture><source srcset="../assets/NewLogoIcon.avif" type="image/avif"><img src="../assets/NewLogoIcon.webp" alt="KoalaSync Logo"></picture>KoalaSync</div>
<a href="https://github.com/Shik3i/KoalaSync" target="_blank" class="mock-version-link" title="Visit GitHub Repository">
<svg fill="currentColor" style="width:12px;height:12px;display:block" viewBox="0 0 16 16"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8"/></svg>
<span class="mockup-version">2.0.1</span>
<span class="mockup-version">2.0.4</span>
</a>
</div>
<div class="mock-tabs">
@@ -519,7 +519,7 @@
<div style="margin-top: 16px; text-align: center; border-top: 1px solid rgba(255,255,255,0.05); padding-top: 10px; flex-shrink: 0;">
<a href="https://github.com/Shik3i/KoalaSync" target="_blank" style="color: var(--text-muted); text-decoration: none; font-size: 10px; opacity: 0.6; display: block;">GitHub Repository</a>
<div style="color: var(--text-muted); font-size: 9px; opacity: 0.4; margin-top: 4px;" class="mockup-version">2.0.1</div>
<div style="color: var(--text-muted); font-size: 9px; opacity: 0.4; margin-top: 4px;" class="mockup-version">2.0.4</div>
</div>
</div>
</div>
@@ -887,7 +887,7 @@
<picture><source srcset="../assets/NewLogoIcon.avif" type="image/avif"><img src="../assets/NewLogoIcon.webp" alt="KoalaSync Logo" width="14" height="14"></picture>
<span>KoalaSync</span>
</div>
<div class="illus-popup-version mockup-version">2.0.1</div>
<div class="illus-popup-version mockup-version">2.0.4</div>
</div>
<div class="illus-popup-tabs">
<div class="illus-popup-tab active">
@@ -1189,6 +1189,6 @@
</div>
</footer>
<script src="../app.e6679b8d.min.js"></script>
<script src="../app.deb95738.min.js"></script>
</body>
</html>
+4 -4
View File
@@ -256,7 +256,7 @@
<div class="mock-header-title"><picture><source srcset="../assets/NewLogoIcon.avif" type="image/avif"><img src="../assets/NewLogoIcon.webp" alt="KoalaSync Logo"></picture>KoalaSync</div>
<a href="https://github.com/Shik3i/KoalaSync" target="_blank" class="mock-version-link" title="Visit GitHub Repository">
<svg fill="currentColor" style="width:12px;height:12px;display:block" viewBox="0 0 16 16"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8"/></svg>
<span class="mockup-version">2.0.1</span>
<span class="mockup-version">2.0.4</span>
</a>
</div>
<div class="mock-tabs">
@@ -519,7 +519,7 @@
<div style="margin-top: 16px; text-align: center; border-top: 1px solid rgba(255,255,255,0.05); padding-top: 10px; flex-shrink: 0;">
<a href="https://github.com/Shik3i/KoalaSync" target="_blank" style="color: var(--text-muted); text-decoration: none; font-size: 10px; opacity: 0.6; display: block;">GitHub Repository</a>
<div style="color: var(--text-muted); font-size: 9px; opacity: 0.4; margin-top: 4px;" class="mockup-version">2.0.1</div>
<div style="color: var(--text-muted); font-size: 9px; opacity: 0.4; margin-top: 4px;" class="mockup-version">2.0.4</div>
</div>
</div>
</div>
@@ -887,7 +887,7 @@
<picture><source srcset="../assets/NewLogoIcon.avif" type="image/avif"><img src="../assets/NewLogoIcon.webp" alt="KoalaSync Logo" width="14" height="14"></picture>
<span>KoalaSync</span>
</div>
<div class="illus-popup-version mockup-version">2.0.1</div>
<div class="illus-popup-version mockup-version">2.0.4</div>
</div>
<div class="illus-popup-tabs">
<div class="illus-popup-tab active">
@@ -1189,6 +1189,6 @@
</div>
</footer>
<script src="../app.e6679b8d.min.js"></script>
<script src="../app.deb95738.min.js"></script>
</body>
</html>
+2 -2
View File
@@ -1,4 +1,4 @@
{
"version": "2.0.1",
"date": "2026-06-01T13:59:31Z"
"version": "2.0.4",
"date": "2026-06-03T09:05:38Z"
}