mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-07-26 20:18:14 +00:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a7481d42a2 | |||
| f9577aace9 | |||
| ad33720053 | |||
| fa3341cc8e | |||
| b526e9287b | |||
| a6be6b2670 | |||
| b51e66d824 | |||
| e034882975 | |||
| e53a93829e | |||
| e3dbf2b8a2 | |||
| 595ea297f5 | |||
| a948780745 | |||
| 8c899a6469 | |||
| 4f4cdcd8c0 | |||
| 602be3a724 | |||
| bb7bd21102 | |||
| 543bfe074d |
+2
-1
@@ -106,7 +106,8 @@ Before starting any task, committing, or pushing, you **MUST** run `git pull --r
|
||||
### Releasing a New Version (CRITICAL WORKFLOW FOR AI AGENTS)
|
||||
> [!CAUTION]
|
||||
> **AI AGENTS MUST FOLLOW THIS EXACT SEQUENCE WHEN RELEASING A NEW VERSION OR TAGGING.**
|
||||
> The CI pipeline automatically injects the version from the git tag into `manifest.base.json`, `shared/constants.js`, and `package.json`. You do NOT need to manually bump version numbers.
|
||||
>
|
||||
> **🚫 NO MANUAL VERSION BUMPING**: You MUST **NEVER** manually modify the version strings in `package.json`, `extension/manifest.base.json`, or `website/version.json`. The GitHub Actions CI pipeline automatically extracts the version from the git tag (e.g. `v2.0.5` -> `2.0.5`), injects it into all target files, and commits the updates back to `main` with `[skip ci]`. Manual bumps will cause merge conflicts and build failures.
|
||||
> - **Website Versioning**: **NEVER** manually modify the version fallback strings in `website/index.html`. The website dynamically fetches the latest version and release date from `website/version.json` at runtime using `website/app.js`. Manual bumps in the HTML file are completely redundant and should be avoided.
|
||||
1. **MANDATORY SYNTAX & LINT CHECKS**: Before staging, committing, or pushing any changes, you **MUST** run both checks on every modified JavaScript file:
|
||||
- **Syntax Validation**: Run `node -c` on every single modified JavaScript file (e.g., `node -c extension/background.js` and `node -c extension/content.js`). **NEVER** commit or push code that fails this check.
|
||||
|
||||
@@ -4,6 +4,62 @@ All notable changes to the KoalaSync browser extension and relay server.
|
||||
|
||||
---
|
||||
|
||||
## [v2.0.8] — 2026-06-03
|
||||
|
||||
### Fixed
|
||||
- Fixed a bug where switching language inside the extension popup overwrote dynamic fields (such as active room ID, connection status, active server details, and video debug info) with default localized placeholder texts.
|
||||
- Fixed a version reporting mismatch where the copied logs (debug reports) and connection handshake parameters incorrectly reported the hardcoded `1.9.0` version instead of the actual installed manifest version.
|
||||
|
||||
---
|
||||
|
||||
## [v2.0.7] — 2026-06-03
|
||||
|
||||
### Added
|
||||
- Added a `DEBUG_LOGGING` environment variable to the relay server (defaulting to `"0"` / disabled) to prevent console spam from verbose connection (`CONN`), room activity (`ROOM`, `DEDUPE`), and `CORS` events under load. Critical logs like `SERVER`, `SECURITY`, `AUTH`, and `ERROR` remain enabled at all times.
|
||||
|
||||
---
|
||||
|
||||
## [v2.0.6] — 2026-06-03
|
||||
|
||||
### Performance & Security Hardening
|
||||
- Optimized failed authentication attempts cache eviction algorithm to $O(1)$ by exploiting Javascript `Map` insertion-order properties. This completely removes the previous array copying and sorting bottleneck, neutralizing a potential main-thread blocking DoS vector under heavy brute-force password traffic.
|
||||
|
||||
---
|
||||
|
||||
## [v2.0.5] — 2026-06-03
|
||||
|
||||
### Security & Hardening
|
||||
- Hardened extension room idle auto-leave detection to correctly recognize when the target tab's video heartbeat goes stale (e.g., after tab navigation or media closure).
|
||||
- Exported cleaner graceful shutdown and lifecycle methods (`stopServerForTests`) from the relay server to prevent socket leaks and port-binding conflicts during verify checks.
|
||||
|
||||
### Added
|
||||
- Added a validation step in `test-locales.js` to ensure the supported language list in `extension/i18n.js` is perfectly synchronized with the actual JSON translation files in the locales directory.
|
||||
- Added a robust route verification test suite (`scripts/test-server-routes.mjs`) covering rate limit throttling, caching headers, and admin metrics access control.
|
||||
|
||||
---
|
||||
|
||||
## [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
|
||||
|
||||
@@ -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.2 Release!</b> — See what's changed</a></p>
|
||||
<p align="center"><a href="CHANGELOG.md"><b>New v2.0.7 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>
|
||||
|
||||
|
||||
+2
-1
@@ -74,7 +74,8 @@ KoalaSync's security is grounded in its architecture:
|
||||
|
||||
- **RAM-only relay**: No database, no persistent logs. All session data evaporates on disconnect.
|
||||
- **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) and event rate (per-socket, 10s window).
|
||||
- **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.
|
||||
|
||||
@@ -9,7 +9,7 @@ services:
|
||||
- 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=25 # Maximum number of peers allowed per room
|
||||
- ADMIN_METRICS_TOKEN= # Optional: enables aggregate-only /health metrics with Bearer auth
|
||||
- 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
|
||||
|
||||
@@ -11,7 +11,7 @@ services:
|
||||
- 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=25 # Maximum number of peers allowed per room
|
||||
- ADMIN_METRICS_TOKEN= # Optional: enables aggregate-only /health metrics with Bearer auth
|
||||
- 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -13,3 +13,10 @@ Dieses Dokument erfasst zukünftige technische Pläne und Optimierungen für das
|
||||
* **Geplante Lösung**:
|
||||
- Umstellung auf eine echte, $O(1)$-basierte LRU-Cache-Datenstruktur (z. B. doppelt verkettete Liste in Kombination mit einer Map).
|
||||
- Alternativ: Ein vereinfachtes zeitbasiertes Ablauf-Verfahren oder ein schrittweises Löschen von Segmenten (Chunk-Eviction), um Blockaden des Main-Threads vollständig auszuschließen.
|
||||
|
||||
### 2. Aufteilung großer JavaScript-Dateien (> 800 Zeilen) in kleinere Module
|
||||
* **Kategorie**: Wartbarkeit / AI-Kontext-Optimierung
|
||||
* **Hintergrund**: Einige Kern-Dateien wie `background.js` und `popup.js` sind stark angewachsen und überschreiten 800 Zeilen. Dies erschwert das manuelle Debugging und verbraucht unnötig viel Kontextfenster bei AI-Modellen.
|
||||
* **Geplante Lösung**:
|
||||
- Strukturierte Aufteilung der Logik in separate, fokussierte Module (z. B. UI-Renderer, Message-Router, Storage-Manager, Socket-Client).
|
||||
- Nutzung von ES-Modulen zur sauberen Strukturierung und besseren Wiederverwendbarkeit.
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ The build script performs the following actions:
|
||||
The system enforces a strict `protocolVersion` check during the `JOIN_ROOM` handshake.
|
||||
- The version is defined in `shared/constants.js`.
|
||||
- If the extension and server versions mismatch, the server will reject the connection with an `Incompatible protocol version` error.
|
||||
- **Always run the build script** after bumping the version number to ensure both components are updated.
|
||||
- **Never manually bump version numbers**. The CI pipeline automatically injects the version from the git tag into `manifest.base.json`, `shared/constants.js`, and `package.json` during release builds. Run the build script to synchronize other constant updates.
|
||||
|
||||
> [!CAUTION]
|
||||
> **NEVER** edit the files inside `extension/shared/` directly. They will be overwritten the next time the build script is run. Always edit the files in the root `shared/` directory and then run the build script.
|
||||
|
||||
+2
-1
@@ -36,7 +36,8 @@ export default [
|
||||
URL: "readonly",
|
||||
URLSearchParams: "readonly",
|
||||
WebSocket: "readonly",
|
||||
self: "readonly"
|
||||
self: "readonly",
|
||||
process: "readonly"
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
|
||||
+104
-13
@@ -1,4 +1,4 @@
|
||||
import { EVENTS, PROTOCOL_VERSION, OFFICIAL_SERVER_URL, OFFICIAL_SERVER_TOKEN, APP_VERSION, EPISODE_LOBBY_TIMEOUT, FORCE_SYNC_TIMEOUT } from './shared/constants.js';
|
||||
import { EVENTS, PROTOCOL_VERSION, OFFICIAL_SERVER_URL, OFFICIAL_SERVER_TOKEN, EPISODE_LOBBY_TIMEOUT, FORCE_SYNC_TIMEOUT } from './shared/constants.js';
|
||||
import { generateUsername } from './shared/names.js';
|
||||
import { loadLocale, getMessage, SUPPORTED_LANGUAGES } from './i18n.js';
|
||||
|
||||
@@ -56,7 +56,7 @@ function ensureState() {
|
||||
'logs', 'history', 'currentRoom', 'lastActionState',
|
||||
'eventQueue', 'isForceSyncInitiator', 'forceSyncAcks',
|
||||
'forceSyncDeadline', 'reconnectFailed', 'reconnectStartTime', 'reconnectAttempts', 'currentTabId', 'currentTabTitle',
|
||||
'episodeLobby', 'localSeq', 'lastSeqBySender', 'expectedAcksCount'
|
||||
'episodeLobby', 'localSeq', 'lastSeqBySender', 'expectedAcksCount', 'roomIdleSince', 'lastContentHeartbeatAt'
|
||||
], (data) => {
|
||||
clearTimeout(storageTimeout);
|
||||
if (data.expectedAcksCount !== undefined) expectedAcksCount = data.expectedAcksCount;
|
||||
@@ -80,6 +80,8 @@ function ensureState() {
|
||||
if (data.reconnectFailed !== undefined) reconnectFailed = data.reconnectFailed;
|
||||
if (data.reconnectStartTime) reconnectStartTime = data.reconnectStartTime;
|
||||
if (data.reconnectAttempts !== undefined) reconnectAttempts = data.reconnectAttempts;
|
||||
if (data.roomIdleSince !== undefined) roomIdleSince = data.roomIdleSince;
|
||||
if (data.lastContentHeartbeatAt !== undefined) lastContentHeartbeatAt = data.lastContentHeartbeatAt;
|
||||
|
||||
// Recover Force Sync Timeout
|
||||
if (data.forceSyncDeadline) {
|
||||
@@ -139,9 +141,12 @@ let reconnectStartTime = null;
|
||||
let reconnectFailed = false;
|
||||
let reconnectAttempts = 0;
|
||||
let currentServerUrl = null;
|
||||
let roomIdleSince = null;
|
||||
let lastContentHeartbeatAt = null;
|
||||
const MAX_RECONNECT_ATTEMPTS = 20;
|
||||
const _RECONNECT_BASE_DELAY = 500;
|
||||
const _RECONNECT_MAX_DELAY = 5000;
|
||||
const ROOM_IDLE_AUTO_LEAVE_MS = 2 * 60 * 60 * 1000;
|
||||
|
||||
// Force Sync Coordination
|
||||
let isForceSyncInitiator = false;
|
||||
@@ -304,6 +309,8 @@ function forceDisconnect() {
|
||||
isNamespaceJoined = false;
|
||||
isForceSyncInitiator = false;
|
||||
expectedAcksCount = 0;
|
||||
roomIdleSince = null;
|
||||
lastContentHeartbeatAt = null;
|
||||
forceSyncAcks.clear();
|
||||
eventQueue = [];
|
||||
chrome.storage.session.set({
|
||||
@@ -312,7 +319,9 @@ function forceDisconnect() {
|
||||
forceSyncDeadline: null,
|
||||
expectedAcksCount: 0,
|
||||
eventQueue: [],
|
||||
episodeLobby: null
|
||||
episodeLobby: null,
|
||||
roomIdleSince: null,
|
||||
lastContentHeartbeatAt: null
|
||||
}).catch(() => {});
|
||||
if (currentRoom) {
|
||||
currentRoom.peers = [];
|
||||
@@ -322,6 +331,63 @@ function forceDisconnect() {
|
||||
broadcastConnectionStatus('disconnected');
|
||||
}
|
||||
|
||||
function persistRoomIdleState() {
|
||||
chrome.storage.session.set({ roomIdleSince, lastContentHeartbeatAt }).catch(() => {});
|
||||
}
|
||||
|
||||
function markRoomUseful() {
|
||||
roomIdleSince = null;
|
||||
lastContentHeartbeatAt = Date.now();
|
||||
persistRoomIdleState();
|
||||
}
|
||||
|
||||
function markRoomPotentiallyIdle() {
|
||||
if (!currentRoom) {
|
||||
roomIdleSince = null;
|
||||
lastContentHeartbeatAt = null;
|
||||
persistRoomIdleState();
|
||||
return;
|
||||
}
|
||||
if (!roomIdleSince) {
|
||||
roomIdleSince = Date.now();
|
||||
persistRoomIdleState();
|
||||
}
|
||||
}
|
||||
|
||||
function clearTargetTabForIdle() {
|
||||
currentTabId = null;
|
||||
currentTabTitle = null;
|
||||
lastContentHeartbeatAt = null;
|
||||
if (currentRoom) {
|
||||
roomIdleSince = Date.now();
|
||||
}
|
||||
chrome.storage.session.set({ currentTabId, currentTabTitle, roomIdleSince, lastContentHeartbeatAt }).catch(() => {});
|
||||
updateBadgeStatus();
|
||||
}
|
||||
|
||||
async function leaveRoomAfterIdleGrace(reason) {
|
||||
if (!currentRoom) return;
|
||||
emit(EVENTS.LEAVE_ROOM, { peerId });
|
||||
currentRoom = null;
|
||||
currentTabId = null;
|
||||
currentTabTitle = null;
|
||||
roomIdleSince = null;
|
||||
lastContentHeartbeatAt = null;
|
||||
clearEpisodeLobbyState();
|
||||
await chrome.storage.session.set({
|
||||
currentRoom: null,
|
||||
currentTabId: null,
|
||||
currentTabTitle: null,
|
||||
roomIdleSince: null,
|
||||
lastContentHeartbeatAt: null,
|
||||
episodeLobby: null
|
||||
}).catch(() => {});
|
||||
await chrome.storage.sync.set({ roomId: '', password: '' }).catch(() => {});
|
||||
addLog(reason, 'info');
|
||||
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {});
|
||||
updateBadgeStatus();
|
||||
}
|
||||
|
||||
async function connect() {
|
||||
if (isConnecting) return;
|
||||
isConnecting = true;
|
||||
@@ -391,7 +457,7 @@ async function connect() {
|
||||
url.pathname = '/socket.io/';
|
||||
url.searchParams.set('EIO', '4');
|
||||
url.searchParams.set('transport', 'websocket');
|
||||
url.searchParams.set('version', APP_VERSION);
|
||||
url.searchParams.set('version', chrome.runtime.getManifest().version);
|
||||
url.searchParams.set('token', OFFICIAL_SERVER_TOKEN);
|
||||
|
||||
socket = new WebSocket(url.toString());
|
||||
@@ -482,12 +548,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();
|
||||
}
|
||||
@@ -628,6 +697,7 @@ function handleServerEvent(event, data) {
|
||||
switch (event) {
|
||||
case EVENTS.ROOM_DATA:
|
||||
currentRoom = data;
|
||||
markRoomPotentiallyIdle();
|
||||
if (currentRoom && Array.isArray(currentRoom.peers)) {
|
||||
currentRoom.peers = currentRoom.peers.map(p => typeof p === 'object' ? createPeerData(p) : { peerId: p, username: null, tabTitle: null, mediaTitle: null, playbackState: null, currentTime: null, volume: null, muted: null, lastHeartbeat: Date.now() });
|
||||
|
||||
@@ -1149,8 +1219,7 @@ function _routeToContentInternal(tabId, action, payload, actionTimestamp, comman
|
||||
}).catch(err => {
|
||||
if (retries >= 3) {
|
||||
addLog(`Content Script not responding in tab ${tabId} after ${retries} retries`, 'warn');
|
||||
currentTabId = null;
|
||||
updateBadgeStatus();
|
||||
clearTargetTabForIdle();
|
||||
return;
|
||||
}
|
||||
if (err.message.includes('Receiving end does not exist') || err.message.includes('Extension context invalidated')) {
|
||||
@@ -1164,8 +1233,7 @@ function _routeToContentInternal(tabId, action, payload, actionTimestamp, comman
|
||||
});
|
||||
} else {
|
||||
addLog(`Content Script not responding in tab ${tabId}`, 'warn');
|
||||
currentTabId = null;
|
||||
updateBadgeStatus();
|
||||
clearTargetTabForIdle();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1181,6 +1249,15 @@ chrome.alarms.onAlarm.addListener(async (alarm) => {
|
||||
connect();
|
||||
}
|
||||
} else if (currentRoom) {
|
||||
const now = Date.now();
|
||||
const heartbeatAge = lastContentHeartbeatAt ? (now - lastContentHeartbeatAt) : Infinity;
|
||||
if (!currentTabId || heartbeatAge > 45000) {
|
||||
markRoomPotentiallyIdle();
|
||||
}
|
||||
if (roomIdleSince && Date.now() - roomIdleSince >= ROOM_IDLE_AUTO_LEAVE_MS) {
|
||||
await leaveRoomAfterIdleGrace('Left room after 2 hours without a selected video heartbeat.');
|
||||
return;
|
||||
}
|
||||
// Heartbeat Logic: Always include identity metadata
|
||||
const settings = await getSettings();
|
||||
emit(EVENTS.PEER_STATUS, {
|
||||
@@ -1276,7 +1353,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
reconnectSlowMode: reconnectFailed,
|
||||
roomId: currentRoom ? currentRoom.roomId : null,
|
||||
serverUrl: currentServerUrl,
|
||||
version: APP_VERSION,
|
||||
version: chrome.runtime.getManifest().version,
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
roomPassword: currentRoom ? currentRoom.password : null
|
||||
});
|
||||
@@ -1284,6 +1361,9 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
emit(EVENTS.LEAVE_ROOM, { peerId });
|
||||
currentRoom = null;
|
||||
currentTabId = null;
|
||||
currentTabTitle = null;
|
||||
roomIdleSince = null;
|
||||
lastContentHeartbeatAt = null;
|
||||
|
||||
updateBadgeStatus();
|
||||
|
||||
@@ -1297,6 +1377,10 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
|
||||
chrome.storage.session.set({
|
||||
currentRoom: null,
|
||||
currentTabId: null,
|
||||
currentTabTitle: null,
|
||||
roomIdleSince: null,
|
||||
lastContentHeartbeatAt: null,
|
||||
isForceSyncInitiator: false,
|
||||
forceSyncAcks: [],
|
||||
forceSyncDeadline: null,
|
||||
@@ -1489,6 +1573,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
updateBadgeStatus();
|
||||
}
|
||||
|
||||
markRoomUseful();
|
||||
getSettings().then(settings => {
|
||||
const statusPayload = { ...message.payload, peerId, username: settings.username, tabTitle: currentTabTitle };
|
||||
emit(EVENTS.PEER_STATUS, statusPayload);
|
||||
@@ -1516,7 +1601,11 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
} else if (message.type === 'SET_TARGET_TAB') {
|
||||
currentTabId = message.tabId;
|
||||
currentTabTitle = message.tabTitle;
|
||||
chrome.storage.session.set({ currentTabId, currentTabTitle });
|
||||
lastContentHeartbeatAt = null;
|
||||
if (currentRoom) {
|
||||
roomIdleSince = Date.now();
|
||||
}
|
||||
chrome.storage.session.set({ currentTabId, currentTabTitle, roomIdleSince, lastContentHeartbeatAt });
|
||||
updateBadgeStatus();
|
||||
|
||||
if (currentTabId) {
|
||||
@@ -1642,7 +1731,9 @@ chrome.tabs.onRemoved.addListener(async (tabId) => {
|
||||
const wasInRoom = !!currentRoom;
|
||||
currentTabId = null;
|
||||
currentTabTitle = null;
|
||||
chrome.storage.session.set({ currentTabId: null, currentTabTitle: null });
|
||||
lastContentHeartbeatAt = null;
|
||||
roomIdleSince = Date.now();
|
||||
chrome.storage.session.set({ currentTabId: null, currentTabTitle: null, roomIdleSince, lastContentHeartbeatAt });
|
||||
updateBadgeStatus();
|
||||
addLog('Target tab closed.', 'warn');
|
||||
|
||||
|
||||
@@ -33,6 +33,9 @@
|
||||
"BTN_REFRESH": "AKTUALISIEREN",
|
||||
"BTN_REFRESH_TOOLTIP": "Die Liste der öffentlichen Räume aktualisieren",
|
||||
"PUBLIC_ROOMS_REFRESHING": "Aktualisiere...",
|
||||
"BTN_REFRESH_COOLDOWN": "WARTE {seconds}s",
|
||||
"BTN_REFRESH_COOLDOWN_TOOLTIP": "Die Raumliste kühlt ab. Versuche es in {seconds}s erneut.",
|
||||
"PUBLIC_ROOMS_REFRESHING_COOLDOWN": "Aktualisiere öffentliche Räume. Nächste Aktualisierung in {seconds}s verfügbar.",
|
||||
"LABEL_ACTIVE_ROOM": "Aktiver Raum",
|
||||
"LABEL_ACTIVE_ROOM_TOOLTIP": "Der Raum, mit dem du gerade verbunden bist",
|
||||
"ACTIVE_ROOM_NONE": "KEINER",
|
||||
|
||||
@@ -33,6 +33,9 @@
|
||||
"BTN_REFRESH": "REFRESH",
|
||||
"BTN_REFRESH_TOOLTIP": "Refresh the list of public rooms",
|
||||
"PUBLIC_ROOMS_REFRESHING": "Refreshing...",
|
||||
"BTN_REFRESH_COOLDOWN": "WAIT {seconds}s",
|
||||
"BTN_REFRESH_COOLDOWN_TOOLTIP": "Room list refresh is cooling down. Try again in {seconds}s.",
|
||||
"PUBLIC_ROOMS_REFRESHING_COOLDOWN": "Refreshing public rooms. Next refresh available in {seconds}s.",
|
||||
"LABEL_ACTIVE_ROOM": "Active Room",
|
||||
"LABEL_ACTIVE_ROOM_TOOLTIP": "The room you are currently connected to",
|
||||
"ACTIVE_ROOM_NONE": "NONE",
|
||||
|
||||
@@ -33,6 +33,9 @@
|
||||
"BTN_REFRESH": "ACTUALIZAR",
|
||||
"BTN_REFRESH_TOOLTIP": "Actualizar la lista de salas públicas",
|
||||
"PUBLIC_ROOMS_REFRESHING": "Actualizando...",
|
||||
"BTN_REFRESH_COOLDOWN": "ESPERA {seconds}s",
|
||||
"BTN_REFRESH_COOLDOWN_TOOLTIP": "La lista de salas está en espera. Inténtalo de nuevo en {seconds}s.",
|
||||
"PUBLIC_ROOMS_REFRESHING_COOLDOWN": "Actualizando salas públicas. Próxima actualización en {seconds}s.",
|
||||
"LABEL_ACTIVE_ROOM": "Sala activa",
|
||||
"LABEL_ACTIVE_ROOM_TOOLTIP": "La sala a la que estás conectado actualmente",
|
||||
"ACTIVE_ROOM_NONE": "NINGUNA",
|
||||
|
||||
@@ -33,6 +33,9 @@
|
||||
"BTN_REFRESH": "ACTUALISER",
|
||||
"BTN_REFRESH_TOOLTIP": "Actualiser la liste des salons publics",
|
||||
"PUBLIC_ROOMS_REFRESHING": "Actualisation...",
|
||||
"BTN_REFRESH_COOLDOWN": "ATTENDRE {seconds}s",
|
||||
"BTN_REFRESH_COOLDOWN_TOOLTIP": "La liste des salons est en pause. Réessayez dans {seconds}s.",
|
||||
"PUBLIC_ROOMS_REFRESHING_COOLDOWN": "Actualisation des salons publics. Prochaine actualisation dans {seconds}s.",
|
||||
"LABEL_ACTIVE_ROOM": "Salon actif",
|
||||
"LABEL_ACTIVE_ROOM_TOOLTIP": "Le salon auquel vous êtes actuellement connecté",
|
||||
"ACTIVE_ROOM_NONE": "AUCUN",
|
||||
|
||||
@@ -33,6 +33,9 @@
|
||||
"BTN_REFRESH": "ATUALIZAR",
|
||||
"BTN_REFRESH_TOOLTIP": "Atualizar a lista de salas públicas",
|
||||
"PUBLIC_ROOMS_REFRESHING": "Atualizando...",
|
||||
"BTN_REFRESH_COOLDOWN": "AGUARDE {seconds}s",
|
||||
"BTN_REFRESH_COOLDOWN_TOOLTIP": "A lista de salas está em espera. Tente novamente em {seconds}s.",
|
||||
"PUBLIC_ROOMS_REFRESHING_COOLDOWN": "Atualizando salas públicas. Próxima atualização em {seconds}s.",
|
||||
"LABEL_ACTIVE_ROOM": "Sala ativa",
|
||||
"LABEL_ACTIVE_ROOM_TOOLTIP": "A sala à qual você está conectado no momento",
|
||||
"ACTIVE_ROOM_NONE": "NENHUMA",
|
||||
|
||||
@@ -33,6 +33,9 @@
|
||||
"BTN_REFRESH": "ОБНОВИТЬ",
|
||||
"BTN_REFRESH_TOOLTIP": "Обновить список публичных комнат",
|
||||
"PUBLIC_ROOMS_REFRESHING": "Обновление...",
|
||||
"BTN_REFRESH_COOLDOWN": "ЖДИТЕ {seconds}с",
|
||||
"BTN_REFRESH_COOLDOWN_TOOLTIP": "Список комнат временно ограничен. Повторите через {seconds}с.",
|
||||
"PUBLIC_ROOMS_REFRESHING_COOLDOWN": "Обновление публичных комнат. Следующее обновление через {seconds}с.",
|
||||
"LABEL_ACTIVE_ROOM": "Активная комната",
|
||||
"LABEL_ACTIVE_ROOM_TOOLTIP": "Комната, к которой вы сейчас подключены",
|
||||
"ACTIVE_ROOM_NONE": "НЕТ",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "KoalaSync",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.7",
|
||||
"description": "Synchronize video playback on YouTube, Netflix, Emby, Jellyfin, and any HTML5 site in real-time with friends.",
|
||||
"permissions": [
|
||||
"storage",
|
||||
|
||||
@@ -375,9 +375,9 @@
|
||||
<div class="info-card" style="margin-bottom: 20px; display: flex; justify-content: space-between; align-items: center; border-left: 4px solid var(--accent);">
|
||||
<div>
|
||||
<label style="margin-bottom: 0;" title="The room you are currently connected to" data-i18n="LABEL_ACTIVE_ROOM" data-i18n-title="LABEL_ACTIVE_ROOM_TOOLTIP">Active Room</label>
|
||||
<div id="activeRoomId" style="font-weight: 700; color: var(--accent); font-size: 16px; letter-spacing: 1px;" data-i18n="ACTIVE_ROOM_NONE">NONE</div>
|
||||
<div id="activeRoomId" style="font-weight: 700; color: var(--accent); font-size: 16px; letter-spacing: 1px;">NONE</div>
|
||||
</div>
|
||||
<div id="activeServer" style="font-size: 10px; color: var(--text-muted); text-align: right; max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" data-i18n="ACTIVE_SERVER_OFFICIAL">Official Server</div>
|
||||
<div id="activeServer" style="font-size: 10px; color: var(--text-muted); text-align: right; max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">Official Server</div>
|
||||
</div>
|
||||
|
||||
<div class="info-card" style="margin-bottom: 20px;">
|
||||
@@ -519,13 +519,13 @@
|
||||
<label title="Current WebSocket connection state" data-i18n="LABEL_CONN_STATUS" data-i18n-title="LABEL_CONN_STATUS_TOOLTIP">Connection Status</label>
|
||||
<div id="connStatus" class="info-card" style="display:flex; align-items:center; gap: 10px;">
|
||||
<span id="connDot" class="status-dot status-offline"></span>
|
||||
<span id="connText" style="flex:1;" data-i18n="CONN_STATUS_DISCONNECTED">Disconnected</span>
|
||||
<span id="connText" style="flex:1;">Disconnected</span>
|
||||
<button id="retryBtn" class="secondary" style="display:none; width: auto; padding: 4px 8px; font-size: 10px; margin: 0;" title="Attempt to reconnect to the server" data-i18n="BTN_RETRY" data-i18n-title="BTN_RETRY_TOOLTIP">RETRY</button>
|
||||
<button id="copyLogs" class="btn secondary" style="width: auto; padding: 4px 10px; font-size: 11px;" title="Copy logs to clipboard for sharing" data-i18n="BTN_COPY_LOGS" data-i18n-title="BTN_COPY_LOGS_TOOLTIP">Copy Logs</button>
|
||||
</div>
|
||||
|
||||
<label title="Technical details about the currently selected video element" data-i18n="LABEL_VIDEO_DEBUG" data-i18n-title="LABEL_VIDEO_DEBUG_TOOLTIP">Video Debug Info</label>
|
||||
<div id="videoDebug" class="info-card" style="font-size: 10px; font-family: monospace; color: var(--text-muted); max-height: 250px; overflow-y: auto; line-height: 1.4;" data-i18n="VIDEO_DEBUG_EMPTY">
|
||||
<div id="videoDebug" class="info-card" style="font-size: 10px; font-family: monospace; color: var(--text-muted); max-height: 250px; overflow-y: auto; line-height: 1.4;">
|
||||
No tab selected or video detected.
|
||||
</div>
|
||||
|
||||
|
||||
+60
-7
@@ -69,6 +69,7 @@ let forceSyncDone = false;
|
||||
let connectionErrorTimer = null;
|
||||
let pendingConnectionErrorMsg = null;
|
||||
let roomListRefreshTimer = null;
|
||||
let roomListRefreshInterval = null;
|
||||
const ROOM_LIST_REFRESH_COOLDOWN_MS = 11000;
|
||||
|
||||
// --- Helpers ---
|
||||
@@ -80,6 +81,31 @@ function clearConnectionErrorTimer() {
|
||||
pendingConnectionErrorMsg = null;
|
||||
}
|
||||
|
||||
function setRoomRefreshCooldown() {
|
||||
if (roomListRefreshTimer) clearTimeout(roomListRefreshTimer);
|
||||
if (roomListRefreshInterval) clearInterval(roomListRefreshInterval);
|
||||
const originalLabel = getMessage('BTN_REFRESH');
|
||||
const updateLabel = () => {
|
||||
const secondsLeft = Math.max(1, Math.ceil((cooldownEndsAt - Date.now()) / 1000));
|
||||
elements.refreshRooms.textContent = getMessage('BTN_REFRESH_COOLDOWN', { seconds: secondsLeft });
|
||||
elements.refreshRooms.title = getMessage('BTN_REFRESH_COOLDOWN_TOOLTIP', { seconds: secondsLeft });
|
||||
};
|
||||
|
||||
const cooldownEndsAt = Date.now() + ROOM_LIST_REFRESH_COOLDOWN_MS;
|
||||
elements.refreshRooms.disabled = true;
|
||||
updateLabel();
|
||||
|
||||
roomListRefreshInterval = setInterval(updateLabel, 250);
|
||||
roomListRefreshTimer = setTimeout(() => {
|
||||
elements.refreshRooms.disabled = false;
|
||||
elements.refreshRooms.textContent = originalLabel;
|
||||
elements.refreshRooms.title = getMessage('BTN_REFRESH_TOOLTIP');
|
||||
clearInterval(roomListRefreshInterval);
|
||||
roomListRefreshInterval = null;
|
||||
roomListRefreshTimer = null;
|
||||
}, ROOM_LIST_REFRESH_COOLDOWN_MS);
|
||||
}
|
||||
|
||||
// --- Initialization ---
|
||||
async function init() {
|
||||
// Load Settings
|
||||
@@ -131,6 +157,9 @@ async function init() {
|
||||
refreshLogs();
|
||||
refreshHistory();
|
||||
|
||||
// Default connection status (localized) before async check
|
||||
applyConnectionStatus('disconnected');
|
||||
|
||||
// Initial Status Check
|
||||
chrome.runtime.sendMessage({ type: 'GET_STATUS' }, async (res) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
@@ -921,9 +950,33 @@ if (elements.langSelector) {
|
||||
await chrome.storage.sync.set({ locale: selectedLang });
|
||||
await loadLocale(selectedLang);
|
||||
translateDOM();
|
||||
|
||||
// Re-apply connection and room UI state since translateDOM may overwrite dynamic elements
|
||||
chrome.runtime.sendMessage({ type: 'GET_STATUS' }, async (res) => {
|
||||
if (chrome.runtime.lastError) return;
|
||||
if (res) {
|
||||
localPeerId = res.peerId;
|
||||
reconnectSlowMode = res.reconnectSlowMode || false;
|
||||
applyConnectionStatus(res.status);
|
||||
updatePeerList(res.peers);
|
||||
lastKnownPeers = res.peers || [];
|
||||
if (res.lastActionState) updateLastActionUI(res.lastActionState, res.peers);
|
||||
|
||||
const data = await chrome.storage.sync.get(['roomId', 'password', 'useCustomServer', 'serverUrl']);
|
||||
updateUI(data.roomId, data.password, data.useCustomServer, data.serverUrl);
|
||||
|
||||
await populateTabs(res.peers, res.targetTabId);
|
||||
if (res.episodeLobby) updateLobbyUI(res.episodeLobby, res.peers);
|
||||
} else {
|
||||
applyConnectionStatus('disconnected');
|
||||
const data = await chrome.storage.sync.get(['roomId', 'password', 'useCustomServer', 'serverUrl']);
|
||||
updateUI(data.roomId, data.password, data.useCustomServer, data.serverUrl);
|
||||
await populateTabs();
|
||||
}
|
||||
});
|
||||
|
||||
refreshLogs();
|
||||
refreshHistory();
|
||||
populateTabs();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1099,16 +1152,12 @@ 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);
|
||||
setRoomRefreshCooldown();
|
||||
|
||||
elements.publicRooms.replaceChildren();
|
||||
const el = document.createElement('div');
|
||||
el.style.cssText = 'text-align:center; color: var(--text-muted); font-size: 11px; padding: 10px;';
|
||||
el.textContent = getMessage('PUBLIC_ROOMS_REFRESHING');
|
||||
el.textContent = getMessage('PUBLIC_ROOMS_REFRESHING_COOLDOWN', { seconds: Math.ceil(ROOM_LIST_REFRESH_COOLDOWN_MS / 1000) });
|
||||
elements.publicRooms.appendChild(el);
|
||||
chrome.runtime.sendMessage({ type: 'GET_ROOM_LIST' });
|
||||
});
|
||||
@@ -1832,6 +1881,10 @@ window.addEventListener('unload', () => {
|
||||
clearTimeout(roomListRefreshTimer);
|
||||
roomListRefreshTimer = null;
|
||||
}
|
||||
if (roomListRefreshInterval) {
|
||||
clearInterval(roomListRefreshInterval);
|
||||
roomListRefreshInterval = null;
|
||||
}
|
||||
});
|
||||
|
||||
// --- Episode Lobby UI ---
|
||||
|
||||
Generated
+2
-5
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "koalasync",
|
||||
"version": "1.9.3",
|
||||
"version": "2.0.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "koalasync",
|
||||
"version": "1.9.3",
|
||||
"version": "2.0.5",
|
||||
"devDependencies": {
|
||||
"archiver": "^7.0.1",
|
||||
"esbuild": "^0.28.0",
|
||||
@@ -1240,7 +1240,6 @@
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -1374,7 +1373,6 @@
|
||||
"integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"bare-abort-controller": "*"
|
||||
},
|
||||
@@ -1897,7 +1895,6 @@
|
||||
"integrity": "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
|
||||
+3
-2
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"name": "koalasync",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.7",
|
||||
"description": "KoalaSync Build Scripts",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build:extension": "node scripts/build-extension.js",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix"
|
||||
"lint:fix": "eslint . --fix",
|
||||
"verify": "node scripts/verify-release.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"archiver": "^7.0.1",
|
||||
|
||||
+36
-2
@@ -9,13 +9,47 @@ if (!fs.existsSync(enPath)) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let hasError = false;
|
||||
|
||||
// Verify SUPPORTED_LANGUAGES in extension/i18n.js matches JSON files
|
||||
const i18nPath = path.join(__dirname, '..', 'extension', 'i18n.js');
|
||||
try {
|
||||
if (fs.existsSync(i18nPath)) {
|
||||
const i18nContent = fs.readFileSync(i18nPath, 'utf8');
|
||||
const langMatch = i18nContent.match(/export const SUPPORTED_LANGUAGES = \[(.*?)\];/);
|
||||
if (!langMatch) {
|
||||
hasError = true;
|
||||
console.error('❌ Could not parse SUPPORTED_LANGUAGES from extension/i18n.js');
|
||||
} else {
|
||||
const supportedLangs = langMatch[1].split(',').map(s => s.trim().replace(/['"]/g, ''));
|
||||
const fileLangs = fs.readdirSync(localesDir)
|
||||
.filter(file => file.endsWith('.json'))
|
||||
.map(file => file.replace('.json', ''));
|
||||
|
||||
for (const lang of fileLangs) {
|
||||
if (!supportedLangs.includes(lang)) {
|
||||
hasError = true;
|
||||
console.error(`❌ ${lang}.json exists in extension/locales but is missing from SUPPORTED_LANGUAGES in extension/i18n.js`);
|
||||
}
|
||||
}
|
||||
for (const lang of supportedLangs) {
|
||||
if (!fileLangs.includes(lang)) {
|
||||
hasError = true;
|
||||
console.error(`❌ ${lang} is in SUPPORTED_LANGUAGES in extension/i18n.js but ${lang}.json is missing from extension/locales`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
hasError = true;
|
||||
console.error('❌ Failed to verify SUPPORTED_LANGUAGES synchronization:', err.message);
|
||||
}
|
||||
|
||||
const enDict = JSON.parse(fs.readFileSync(enPath, 'utf8'));
|
||||
const enKeys = Object.keys(enDict);
|
||||
|
||||
const localeFiles = fs.readdirSync(localesDir).filter(file => file.endsWith('.json') && file !== 'en.json');
|
||||
|
||||
let hasError = false;
|
||||
|
||||
console.log(`Auditing i18n locales using ${enKeys.length} baseline keys from en.json...\n`);
|
||||
|
||||
for (const file of localeFiles) {
|
||||
|
||||
@@ -2,7 +2,9 @@ import assert from 'node:assert/strict';
|
||||
import {
|
||||
buildHealthPayload,
|
||||
checkCooldown,
|
||||
isAdminMetricsAuthorized
|
||||
getCachedPayload,
|
||||
isAdminMetricsAuthorized,
|
||||
isAdminMetricsTokenStrong
|
||||
} from '../server/ops.js';
|
||||
|
||||
const missingAuth = isAdminMetricsAuthorized(undefined, 'secret-token');
|
||||
@@ -17,11 +19,28 @@ 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]]);
|
||||
@@ -33,7 +52,7 @@ const basicHealth = buildHealthPayload({
|
||||
now: 1234,
|
||||
uptime: 99,
|
||||
memoryUsage: () => ({ rss: 10, heapUsed: 5, heapTotal: 8 }),
|
||||
rateLimitSizes: { connections: 1, events: 2, health: 3, authFailures: 4, roomList: 5 }
|
||||
rateLimitSizes: { connections: 1, events: 2, health: 3, adminMetricsAuth: 4, authFailures: 5, roomList: 6 }
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
@@ -49,7 +68,7 @@ const adminHealth = buildHealthPayload({
|
||||
now: 1234,
|
||||
uptime: 99,
|
||||
memoryUsage: () => ({ rss: 10, heapUsed: 5, heapTotal: 8 }),
|
||||
rateLimitSizes: { connections: 1, events: 2, health: 3, authFailures: 4, roomList: 5 }
|
||||
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');
|
||||
@@ -59,7 +78,7 @@ assert.equal(adminHealth.maxPeersInRoom, 3, 'admin metrics should include max ro
|
||||
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, authFailures: 4, roomList: 5 },
|
||||
{ connections: 1, events: 2, health: 3, adminMetricsAuth: 4, authFailures: 5, roomList: 6 },
|
||||
'admin metrics should expose aggregate rate-limit map sizes'
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
ADMIN_METRICS_AUTH_RATE_LIMIT_PER_MINUTE,
|
||||
HEALTH_RATE_LIMIT_PER_MINUTE,
|
||||
healthCounts,
|
||||
adminMetricsAuthCounts,
|
||||
healthResponseCache,
|
||||
httpServer,
|
||||
rooms,
|
||||
startServer,
|
||||
stopServerForTests
|
||||
} from '../server/index.js';
|
||||
|
||||
const adminToken = process.env.ADMIN_METRICS_TOKEN || 'test-admin-token-with-more-than-32-chars';
|
||||
const baseHeaders = { 'x-forwarded-for': '203.0.113.10' };
|
||||
|
||||
function url(path) {
|
||||
const address = httpServer.address();
|
||||
return `http://127.0.0.1:${address.port}${path}`;
|
||||
}
|
||||
|
||||
async function request(path, options = {}) {
|
||||
return fetch(url(path), {
|
||||
...options,
|
||||
headers: {
|
||||
...baseHeaders,
|
||||
...(options.headers || {})
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await startServer(0, '127.0.0.1');
|
||||
|
||||
let res = await request('/');
|
||||
assert.equal(res.status, 200, 'root health endpoint should respond');
|
||||
assert.equal(res.headers.get('cache-control'), 'no-store', 'root response should disable HTTP caching');
|
||||
assert.deepEqual(await res.json(), { status: 'online', service: 'KoalaSync Relay' });
|
||||
|
||||
res = await request('/health');
|
||||
assert.equal(res.status, 200, 'basic health endpoint should respond');
|
||||
assert.equal(res.headers.get('cache-control'), 'no-store', 'basic health response should disable HTTP caching');
|
||||
const basicHealth = await res.json();
|
||||
assert.equal(basicHealth.status, 'ok', 'basic health should report ok');
|
||||
assert.equal(basicHealth.rooms, 0, 'basic health should include room count');
|
||||
assert.equal('peers' in basicHealth, false, 'basic health should not expose admin metrics');
|
||||
assert.equal('memory' in basicHealth, false, 'basic health should not expose memory metrics');
|
||||
|
||||
rooms.set('route-test-room', {
|
||||
peers: new Set(['socket-a', 'socket-b']),
|
||||
peerData: new Map(),
|
||||
peerIds: new Map(),
|
||||
activeLobby: { expectedTitle: 'Episode 1', initiatorPeerId: 'peer-a', readyPeers: ['peer-a'] }
|
||||
});
|
||||
healthResponseCache.clear();
|
||||
healthCounts.clear();
|
||||
|
||||
res = await request('/health', {
|
||||
headers: { authorization: `Bearer ${adminToken}`, 'x-forwarded-for': '203.0.113.20' }
|
||||
});
|
||||
assert.equal(res.status, 200, 'authorized admin health endpoint should respond');
|
||||
const adminHealth = await res.json();
|
||||
assert.equal(adminHealth.rooms, 1, 'admin health should include room count');
|
||||
assert.equal(adminHealth.peers, 2, 'admin health should include aggregate peer count');
|
||||
assert.equal(adminHealth.roomsWithLobby, 1, 'admin health should include aggregate lobby count');
|
||||
assert.equal(typeof adminHealth.memory?.rss, 'number', 'admin health should include aggregate memory metrics');
|
||||
assert.equal('route-test-room' in adminHealth, false, 'admin health should not expose room identifiers');
|
||||
|
||||
healthCounts.clear();
|
||||
adminMetricsAuthCounts.clear();
|
||||
for (let i = 0; i < ADMIN_METRICS_AUTH_RATE_LIMIT_PER_MINUTE; i++) {
|
||||
res = await request('/health', {
|
||||
headers: { authorization: 'Bearer wrong-token', 'x-forwarded-for': '203.0.113.30' }
|
||||
});
|
||||
assert.equal(res.status, 200, `wrong admin bearer attempt ${i + 1} should still return basic health`);
|
||||
}
|
||||
res = await request('/health', {
|
||||
headers: { authorization: 'Bearer wrong-token', 'x-forwarded-for': '203.0.113.30' }
|
||||
});
|
||||
assert.equal(res.status, 429, 'wrong admin bearer attempts should be throttled after the limit');
|
||||
|
||||
healthCounts.clear();
|
||||
for (let i = 0; i < HEALTH_RATE_LIMIT_PER_MINUTE; i++) {
|
||||
const path = i % 2 === 0 ? '/' : '/health';
|
||||
res = await request(path, { headers: { 'x-forwarded-for': '203.0.113.40' } });
|
||||
assert.equal(res.status, 200, `shared health request ${i + 1} should be allowed`);
|
||||
}
|
||||
res = await request('/', { headers: { 'x-forwarded-for': '203.0.113.40' } });
|
||||
assert.equal(res.status, 429, 'root and health should share the public health rate limit');
|
||||
|
||||
console.log('server route tests passed');
|
||||
} finally {
|
||||
await stopServerForTests();
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
const checks = [
|
||||
['server ops', 'node', ['scripts/test-server-ops.mjs']],
|
||||
['server routes', 'node', ['scripts/test-server-routes.mjs'], {
|
||||
env: { ADMIN_METRICS_TOKEN: 'verify-admin-token-with-more-than-32-chars' }
|
||||
}],
|
||||
['content video finder', 'node', ['scripts/test-content-video-finder.js']],
|
||||
['popup refresh cooldown', 'node', ['scripts/test-popup-refresh-cooldown.mjs']],
|
||||
['server syntax index', 'node', ['-c', 'server/index.js']],
|
||||
['server syntax ops', 'node', ['-c', 'server/ops.js']],
|
||||
['content syntax', 'node', ['-c', 'extension/content.js']],
|
||||
['popup syntax', 'node', ['-c', 'extension/popup.js']],
|
||||
['background syntax', 'node', ['-c', 'extension/background.js']],
|
||||
['locale coverage', 'node', ['scripts/test-locales.js']],
|
||||
['lint', 'npm', ['run', 'lint']],
|
||||
['root production audit', 'npm', ['audit', '--omit=dev']],
|
||||
['server production audit', 'npm', ['audit', '--omit=dev'], { cwd: path.join(repoRoot, 'server') }],
|
||||
['extension build', 'npm', ['run', 'build:extension']],
|
||||
['website build', 'node', ['website/build.js']]
|
||||
];
|
||||
|
||||
function runCheck([label, command, args, options = {}]) {
|
||||
return new Promise((resolve, reject) => {
|
||||
console.log(`\n==> ${label}`);
|
||||
const child = spawn(command, args, {
|
||||
cwd: options.cwd || repoRoot,
|
||||
env: { ...process.env, ...(options.env || {}) },
|
||||
stdio: 'inherit'
|
||||
});
|
||||
child.on('error', reject);
|
||||
child.on('exit', (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
reject(new Error(`${label} failed with exit code ${code}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
for (const check of checks) {
|
||||
await runCheck(check);
|
||||
}
|
||||
|
||||
console.log('\nRelease verification passed');
|
||||
+9
-5
@@ -1,6 +1,10 @@
|
||||
PORT=3000
|
||||
MIN_VERSION=1.0.0
|
||||
MAX_ROOMS=1000
|
||||
MAX_PEERS_PER_ROOM=25
|
||||
PORT="3000"
|
||||
MIN_VERSION="1.0.0"
|
||||
MAX_ROOMS="1000"
|
||||
MAX_PEERS_PER_ROOM="25"
|
||||
# Optional: enables aggregate-only admin metrics on /health with Authorization: Bearer <token>
|
||||
ADMIN_METRICS_TOKEN=
|
||||
# Use a long random token, 32+ characters recommended.
|
||||
ADMIN_METRICS_TOKEN=""
|
||||
|
||||
# Optional: set to "1" to enable verbose connection, room-join/leave, and CORS logs in the console. Default is "0" (disabled).
|
||||
DEBUG_LOGGING="0"
|
||||
|
||||
+29
-7
@@ -12,18 +12,38 @@ A Node.js relay server for synchronized video playback.
|
||||
### Environment
|
||||
Copy `.env.example` to `.env` and configure your settings.
|
||||
```bash
|
||||
PORT=3000
|
||||
MAX_ROOMS=1000
|
||||
MAX_PEERS_PER_ROOM=25
|
||||
MIN_VERSION=1.0.0
|
||||
PORT="3000"
|
||||
MAX_ROOMS="1000"
|
||||
MAX_PEERS_PER_ROOM="25"
|
||||
MIN_VERSION="1.0.0"
|
||||
# Optional: enables aggregate-only admin metrics on /health with Authorization: Bearer <token>
|
||||
ADMIN_METRICS_TOKEN=
|
||||
# Use a long random token, 32+ characters recommended.
|
||||
ADMIN_METRICS_TOKEN=""
|
||||
|
||||
# Optional: set to "1" to enable verbose connection, room-join/leave, and CORS logs in the console. Default is "0" (disabled).
|
||||
DEBUG_LOGGING="0"
|
||||
```
|
||||
|
||||
### Health & Metrics
|
||||
`GET /` and `GET /health` are IP-rate-limited. By default `/health` returns only basic service status, uptime, room count, connection count, and a timestamp.
|
||||
`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. The metrics response does not include room IDs, peer IDs, usernames, IP addresses, media titles, or other user-level data.
|
||||
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.
|
||||
|
||||
Generate a token with one of these commands:
|
||||
```bash
|
||||
openssl rand -base64 32
|
||||
# or
|
||||
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
|
||||
```
|
||||
|
||||
For Docker Compose deployments, set the generated value in `server/.env`:
|
||||
```bash
|
||||
ADMIN_METRICS_TOKEN=replace-with-a-long-random-token
|
||||
```
|
||||
|
||||
When polling `/health` from Prometheus, Uptime Kuma, cron, or a load balancer, keep the interval comfortably below the public limit of 10 requests per minute per client IP. A 30-60 second interval is recommended for routine monitoring. Use the admin bearer token only from trusted monitoring hosts, and keep the Node server private behind Caddy or another trusted reverse proxy because IP-based limits depend on the configured proxy boundary.
|
||||
|
||||
### Docker (Recommended)
|
||||
The server is available as a pre-built image on GHCR.
|
||||
@@ -45,7 +65,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.
|
||||
|
||||
+151
-47
@@ -1,10 +1,17 @@
|
||||
import express from 'express';
|
||||
import { createServer } from 'http';
|
||||
import { fileURLToPath } from 'url';
|
||||
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, isAdminMetricsAuthorized } from './ops.js';
|
||||
import {
|
||||
buildHealthPayload,
|
||||
checkCooldown,
|
||||
getCachedPayload,
|
||||
isAdminMetricsAuthorized,
|
||||
isAdminMetricsTokenStrong
|
||||
} from './ops.js';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
@@ -20,17 +27,32 @@ 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;
|
||||
export const HEALTH_RATE_LIMIT_PER_MINUTE = 10;
|
||||
export const ADMIN_METRICS_AUTH_RATE_LIMIT_PER_MINUTE = 5;
|
||||
const HEALTH_RESPONSE_CACHE_TTL_MS = 60000;
|
||||
|
||||
const app = express();
|
||||
if (!isAdminMetricsTokenStrong(ADMIN_METRICS_TOKEN)) {
|
||||
console.warn('[SECURITY] ADMIN_METRICS_TOKEN is set but shorter than 32 characters. Use a long random token.');
|
||||
}
|
||||
|
||||
export const app = express();
|
||||
app.set('trust proxy', 1); // For real client IP through reverse proxy
|
||||
|
||||
export 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) => {
|
||||
@@ -38,26 +60,37 @@ app.get('/health', (req, res) => {
|
||||
if (!checkHealthRate(clientIp)) {
|
||||
return res.status(429).json({ error: 'Rate limited' });
|
||||
}
|
||||
const includeMetrics = isAdminMetricsAuthorized(req.get('authorization'), ADMIN_METRICS_TOKEN);
|
||||
res.json(buildHealthPayload({
|
||||
rooms,
|
||||
connections: io.engine?.clientsCount ?? 0,
|
||||
includeMetrics,
|
||||
uptime: process.uptime(),
|
||||
rateLimitSizes: {
|
||||
connections: connectionCounts.size,
|
||||
events: eventCounts.size,
|
||||
health: healthCounts.size,
|
||||
authFailures: failedAuthAttempts.size,
|
||||
roomList: roomListCooldowns.size
|
||||
}
|
||||
}));
|
||||
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);
|
||||
export const httpServer = createServer(app);
|
||||
|
||||
// Socket.IO setup with security constraints
|
||||
const io = new Server(httpServer, {
|
||||
export const io = new Server(httpServer, {
|
||||
cors: {
|
||||
origin: (origin, callback) => {
|
||||
if (!origin || origin === 'https://sync.koalastuff.net' || origin.startsWith('chrome-extension://') || origin.startsWith('moz-extension://')) {
|
||||
@@ -77,18 +110,22 @@ const io = new Server(httpServer, {
|
||||
/**
|
||||
* In-memory storage
|
||||
*/
|
||||
const rooms = new Map();
|
||||
export const rooms = new Map();
|
||||
const socketToRoom = new Map();
|
||||
const peerToSocket = new Map(); // peerId -> socketId (Global lookup)
|
||||
const roomCreationLocks = new Map(); // roomId -> Promise (prevents race on room creation)
|
||||
|
||||
function log(type, message, details = '') {
|
||||
const debugLogging = process.env.DEBUG_LOGGING === '1';
|
||||
const isVerbose = type === 'CONN' || type === 'ROOM' || type === 'DEDUPE' || type === 'CORS';
|
||||
if (!debugLogging && isVerbose) return;
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
console.log(`[${timestamp}] [${type}] ${message}`, details);
|
||||
}
|
||||
|
||||
// Rate Limiting & Security
|
||||
const connectionCounts = new Map(); // ip -> { count, resetTime }
|
||||
export const connectionCounts = new Map(); // ip -> { count, resetTime }
|
||||
const failedAuthAttempts = new Map(); // Map<IP+RoomID, {count, lastAttempt}>
|
||||
|
||||
function checkAuthRate(ip, roomId) {
|
||||
@@ -116,17 +153,19 @@ function recordAuthFailure(ip, roomId) {
|
||||
for (const [key, record] of failedAuthAttempts.entries()) {
|
||||
if (now - record.lastAttempt > 15 * 60 * 1000) {
|
||||
failedAuthAttempts.delete(key);
|
||||
} else {
|
||||
break; // Since entries are insertion-ordered by time, all subsequent entries are newer
|
||||
}
|
||||
}
|
||||
|
||||
// 2. If still over 50k, perform LRU-style eviction on the oldest 10,000 entries
|
||||
// 2. If still over 50k, perform LRU-style eviction on the oldest 10,000 entries (first items in Map order)
|
||||
if (failedAuthAttempts.size > 50000) {
|
||||
log('SECURITY', 'failedAuthAttempts size exceeded 50000. Performing LRU-style eviction.');
|
||||
const sortedEntries = Array.from(failedAuthAttempts.entries())
|
||||
.sort((a, b) => a[1].lastAttempt - b[1].lastAttempt);
|
||||
|
||||
for (let i = 0; i < 10000 && i < sortedEntries.length; i++) {
|
||||
failedAuthAttempts.delete(sortedEntries[i][0]);
|
||||
log('SECURITY', 'failedAuthAttempts size exceeded 50000. Performing insertion-order eviction.');
|
||||
for (const [key] of failedAuthAttempts.entries()) {
|
||||
if (failedAuthAttempts.size <= 40000) {
|
||||
break;
|
||||
}
|
||||
failedAuthAttempts.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -134,11 +173,12 @@ function recordAuthFailure(ip, roomId) {
|
||||
const record = failedAuthAttempts.get(key) || { count: 0, lastAttempt: 0 };
|
||||
record.count++;
|
||||
record.lastAttempt = Date.now();
|
||||
failedAuthAttempts.delete(key); // Remove first to update insertion order (moves key to the end of iteration)
|
||||
failedAuthAttempts.set(key, record);
|
||||
}
|
||||
|
||||
// Periodically clean up old auth failure records (every 15 minutes)
|
||||
setInterval(() => {
|
||||
const authFailureCleanupInterval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, record] of failedAuthAttempts.entries()) {
|
||||
if (now - record.lastAttempt > 15 * 60 * 1000) {
|
||||
@@ -147,12 +187,13 @@ setInterval(() => {
|
||||
}
|
||||
}, 15 * 60 * 1000);
|
||||
|
||||
const eventCounts = new Map(); // socketId -> { count, resetTime }
|
||||
const healthCounts = new Map(); // ip -> { count, resetTime }
|
||||
export const eventCounts = new Map(); // socketId -> { count, resetTime }
|
||||
export const healthCounts = new Map(); // ip -> { count, resetTime }
|
||||
export 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(() => {
|
||||
const rateLimitCleanupInterval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [ip, entry] of connectionCounts.entries()) {
|
||||
if (now > entry.resetTime) {
|
||||
@@ -169,6 +210,11 @@ 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);
|
||||
@@ -200,7 +246,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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -616,7 +671,7 @@ io.on('connection', (socket) => {
|
||||
});
|
||||
|
||||
// Active Room & Dead Peer Cleanup (Every 2m)
|
||||
setInterval(() => {
|
||||
const roomCleanupInterval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
const roomCutoff = now - (2 * 60 * 60 * 1000); // 2 hours
|
||||
const peerCutoff = now - (5 * 60 * 1000); // 5 minutes
|
||||
@@ -653,9 +708,29 @@ setInterval(() => {
|
||||
}
|
||||
}, 2 * 60 * 1000);
|
||||
|
||||
httpServer.listen(PORT, () => {
|
||||
log('SERVER', `KoalaSync Relay running on port ${PORT}`);
|
||||
});
|
||||
export function startServer(port = PORT, host) {
|
||||
if (httpServer.listening) return Promise.resolve(httpServer);
|
||||
return new Promise((resolve, reject) => {
|
||||
const onError = (err) => {
|
||||
httpServer.off('listening', onListening);
|
||||
reject(err);
|
||||
};
|
||||
const onListening = () => {
|
||||
httpServer.off('error', onError);
|
||||
const address = httpServer.address();
|
||||
const actualPort = address && typeof address === 'object' ? address.port : port;
|
||||
log('SERVER', `KoalaSync Relay running on port ${actualPort}`);
|
||||
resolve(httpServer);
|
||||
};
|
||||
httpServer.once('error', onError);
|
||||
httpServer.once('listening', onListening);
|
||||
if (host) {
|
||||
httpServer.listen(port, host);
|
||||
} else {
|
||||
httpServer.listen(port);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- M-4: Graceful Shutdown ---
|
||||
function gracefulShutdown(signal) {
|
||||
@@ -674,15 +749,44 @@ function gracefulShutdown(signal) {
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
||||
export async function stopServerForTests() {
|
||||
clearInterval(authFailureCleanupInterval);
|
||||
clearInterval(rateLimitCleanupInterval);
|
||||
clearInterval(roomCleanupInterval);
|
||||
rooms.clear();
|
||||
socketToRoom.clear();
|
||||
peerToSocket.clear();
|
||||
roomCreationLocks.clear();
|
||||
connectionCounts.clear();
|
||||
failedAuthAttempts.clear();
|
||||
eventCounts.clear();
|
||||
healthCounts.clear();
|
||||
adminMetricsAuthCounts.clear();
|
||||
roomListCooldowns.clear();
|
||||
healthResponseCache.clear();
|
||||
io.removeAllListeners();
|
||||
io.disconnectSockets(true);
|
||||
if (!httpServer.listening) return;
|
||||
await new Promise((resolve, reject) => {
|
||||
httpServer.close((err) => err ? reject(err) : resolve());
|
||||
});
|
||||
}
|
||||
|
||||
process.on('uncaughtException', (err) => {
|
||||
log('ERROR', `Uncaught exception: ${err.message}`, err.stack);
|
||||
process.exit(1);
|
||||
});
|
||||
const isMainModule = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
|
||||
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
log('ERROR', `Unhandled rejection: ${reason}`);
|
||||
process.exit(1);
|
||||
});
|
||||
if (isMainModule) {
|
||||
startServer(PORT);
|
||||
|
||||
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
||||
|
||||
process.on('uncaughtException', (err) => {
|
||||
log('ERROR', `Uncaught exception: ${err.message}`, err.stack);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
log('ERROR', `Unhandled rejection: ${reason}`);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,6 +9,17 @@ export function checkCooldown(cooldowns, key, windowMs, now = Date.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;
|
||||
@@ -26,6 +37,10 @@ export function isAdminMetricsAuthorized(authHeader, adminToken) {
|
||||
return crypto.timingSafeEqual(expectedBuffer, providedBuffer);
|
||||
}
|
||||
|
||||
export function isAdminMetricsTokenStrong(adminToken, minLength = 32) {
|
||||
return !adminToken || (typeof adminToken === 'string' && adminToken.length >= minLength);
|
||||
}
|
||||
|
||||
export function buildHealthPayload({
|
||||
rooms,
|
||||
connections,
|
||||
@@ -64,6 +79,7 @@ export function buildHealthPayload({
|
||||
connections: rateLimitSizes.connections || 0,
|
||||
events: rateLimitSizes.events || 0,
|
||||
health: rateLimitSizes.health || 0,
|
||||
adminMetricsAuth: rateLimitSizes.adminMetricsAuth || 0,
|
||||
authFailures: rateLimitSizes.authFailures || 0,
|
||||
roomList: rateLimitSizes.roomList || 0
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"version": "2.0.2",
|
||||
"date": "2026-06-02T06:17:21Z"
|
||||
"version": "2.0.7",
|
||||
"date": "2026-06-03T09:58:23Z"
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -218,6 +218,6 @@
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="app.e6679b8d.min.js"></script>
|
||||
<script src="app.deb95738.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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.6</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.6</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.6</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>
|
||||
|
||||
@@ -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.6</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.6</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.6</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>
|
||||
|
||||
@@ -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.6</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.6</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.6</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>
|
||||
|
||||
@@ -192,6 +192,6 @@
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="app.e6679b8d.min.js"></script>
|
||||
<script src="app.deb95738.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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.6</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.6</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.6</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>
|
||||
|
||||
@@ -121,6 +121,6 @@
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="app.e6679b8d.min.js"></script>
|
||||
<script src="app.deb95738.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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.6</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.6</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.6</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>
|
||||
|
||||
@@ -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.6</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.6</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.6</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,4 +1,4 @@
|
||||
{
|
||||
"version": "2.0.1",
|
||||
"date": "2026-06-01T13:59:31Z"
|
||||
"version": "2.0.6",
|
||||
"date": "2026-06-03T09:45:12Z"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user