mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-07-26 12:08:15 +00:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 595ea297f5 | |||
| a948780745 | |||
| 8c899a6469 | |||
| 4f4cdcd8c0 | |||
| 602be3a724 | |||
| bb7bd21102 | |||
| 543bfe074d | |||
| 81c50eff16 | |||
| bf0fb5741f | |||
| 6d2355f404 | |||
| 732d585273 | |||
| 666808f876 |
@@ -4,6 +4,48 @@ All notable changes to the KoalaSync browser extension and relay server.
|
||||
|
||||
---
|
||||
|
||||
## [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
|
||||
- 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
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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!".
|
||||
|
||||
|
||||
+2
-1
@@ -36,7 +36,8 @@ export default [
|
||||
URL: "readonly",
|
||||
URLSearchParams: "readonly",
|
||||
WebSocket: "readonly",
|
||||
self: "readonly"
|
||||
self: "readonly",
|
||||
process: "readonly"
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
|
||||
+101
-10
@@ -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;
|
||||
@@ -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, {
|
||||
@@ -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');
|
||||
|
||||
|
||||
+13
-14
@@ -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);
|
||||
|
||||
@@ -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.1",
|
||||
"version": "2.0.5",
|
||||
"description": "Synchronize video playback on YouTube, Netflix, Emby, Jellyfin, and any HTML5 site in real-time with friends.",
|
||||
"permissions": [
|
||||
"storage",
|
||||
|
||||
+112
-7
@@ -66,21 +66,58 @@ let popupIntervals = [];
|
||||
let populateTabsToken = null;
|
||||
let errorToken = 0;
|
||||
let forceSyncDone = false;
|
||||
let connectionErrorTimer = null;
|
||||
let pendingConnectionErrorMsg = null;
|
||||
let roomListRefreshTimer = null;
|
||||
let roomListRefreshInterval = null;
|
||||
const ROOM_LIST_REFRESH_COOLDOWN_MS = 11000;
|
||||
|
||||
// --- Helpers ---
|
||||
function clearConnectionErrorTimer() {
|
||||
if (connectionErrorTimer) {
|
||||
clearTimeout(connectionErrorTimer);
|
||||
connectionErrorTimer = null;
|
||||
}
|
||||
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
|
||||
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 +185,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 +989,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 +1023,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 +1083,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,10 +1124,13 @@ if (syncTabCreateRoomBtn) syncTabCreateRoomBtn.addEventListener('click', () => {
|
||||
});
|
||||
|
||||
elements.refreshRooms.addEventListener('click', () => {
|
||||
if (elements.refreshRooms.disabled) return;
|
||||
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' });
|
||||
});
|
||||
@@ -1310,7 +1357,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 +1431,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 +1465,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 +1664,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 +1850,14 @@ window.addEventListener('unload', () => {
|
||||
clearTimeout(forceSyncResetTimer);
|
||||
forceSyncResetTimer = null;
|
||||
}
|
||||
if (roomListRefreshTimer) {
|
||||
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.1",
|
||||
"version": "2.0.5",
|
||||
"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",
|
||||
|
||||
@@ -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');
|
||||
+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) {
|
||||
|
||||
@@ -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');
|
||||
@@ -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');
|
||||
@@ -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');
|
||||
+4
-1
@@ -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=
|
||||
|
||||
+28
-1
@@ -14,10 +14,34 @@ 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.
|
||||
|
||||
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.
|
||||
```bash
|
||||
@@ -38,6 +62,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
-33
@@ -1,9 +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,
|
||||
getCachedPayload,
|
||||
isAdminMetricsAuthorized,
|
||||
isAdminMetricsTokenStrong
|
||||
} from './ops.js';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
@@ -15,19 +23,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;
|
||||
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) => {
|
||||
@@ -35,19 +60,37 @@ 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);
|
||||
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://')) {
|
||||
@@ -67,7 +110,7 @@ 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)
|
||||
@@ -78,7 +121,7 @@ function log(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) {
|
||||
@@ -128,7 +171,7 @@ function recordAuthFailure(ip, roomId) {
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -137,11 +180,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) {
|
||||
@@ -158,6 +203,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 +239,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 +605,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 +652,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().
|
||||
@@ -595,7 +664,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
|
||||
@@ -632,9 +701,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) {
|
||||
@@ -653,15 +742,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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
}
|
||||
Generated
-10
@@ -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",
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"version": "2.0.1",
|
||||
"date": "2026-06-01T13:59:31Z"
|
||||
"version": "2.0.5",
|
||||
"date": "2026-06-03T09:32:00Z"
|
||||
}
|
||||
|
||||
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.5</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.5</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.5</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.5</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.5</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.5</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.5</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.5</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.5</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.5</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.5</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.5</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.5</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.5</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.5</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.5</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.5</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.5</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.5",
|
||||
"date": "2026-06-03T09:32:00Z"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user