mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-07-27 04:20:25 +00:00
Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 394b9ba474 | |||
| 526736b045 | |||
| 86112057b4 | |||
| c56fcfd281 | |||
| 5c43fc6236 | |||
| 101761e984 | |||
| 3bc68a5713 | |||
| 6c96dd6344 | |||
| d7bb8dc97c | |||
| d38a840114 | |||
| e7d2d76ba3 | |||
| 5277cd0f21 | |||
| 4d7cbfe347 | |||
| 91aa76e842 | |||
| 5f0a189451 | |||
| 312d4f2cf7 | |||
| 884feb982d | |||
| b2da17ab62 | |||
| b518685e2c | |||
| b7a7a14a35 | |||
| 3f8cf33dc9 | |||
| f12bb0a532 | |||
| 1685b6a327 | |||
| ec8f56a85d | |||
| 38dc923a7c | |||
| 27e57862c0 |
@@ -98,13 +98,17 @@ jobs:
|
||||
sed -i "s/v[0-9]\+\.[0-9]\+\.[0-9]\+/v$VERSION/g" README.md
|
||||
echo " ✓ README.md -> v$VERSION"
|
||||
|
||||
# 7. website/sitemap.xml — lastmod dates
|
||||
sed -i "s/<lastmod>[0-9-]*<\/lastmod>/<lastmod>$(date +%Y-%m-%d)<\/lastmod>/g" website/sitemap.xml
|
||||
echo " ✓ website/sitemap.xml -> lastmod $(date +%Y-%m-%d)"
|
||||
|
||||
echo "Version injection complete."
|
||||
|
||||
- name: Commit and push version updates back to main
|
||||
run: |
|
||||
git config --local user.email "action@github.com"
|
||||
git config --local user.name "GitHub Action"
|
||||
git add extension/manifest.base.json shared/constants.js package.json website/version.json website/template.html README.md
|
||||
git add extension/manifest.base.json shared/constants.js package.json website/version.json website/template.html README.md website/sitemap.xml
|
||||
git commit -m "chore(release): update versions to $GITHUB_REF_NAME [skip ci]" || echo "No changes to commit"
|
||||
git push origin HEAD:main
|
||||
env:
|
||||
@@ -121,7 +125,7 @@ jobs:
|
||||
subject-path: dist/koalasync-*.zip
|
||||
|
||||
- name: Build Website
|
||||
run: node website/build.js
|
||||
run: node website/build.cjs
|
||||
|
||||
- name: Upload Website Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
<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>
|
||||
|
||||
<p align="center"><a href="docs/CHANGELOG.md"><b>New v2.3.1 Release!</b> — See what's changed</a></p>
|
||||
<p align="center"><a href="docs/CHANGELOG.md"><b>New v2.3.2 Release!</b> — See what's changed</a></p>
|
||||
|
||||
### 🌟 Why KoalaSync?
|
||||
|
||||
@@ -103,7 +103,7 @@ To connect your extension to a self-hosted server, open the popup → **Room** t
|
||||
|
||||
To verify your relay is reachable from outside, visit `https://your-domain.com` in a browser — it should return `{"status":"online","service":"KoalaSync Relay"}`.
|
||||
|
||||
#### Supply Chain Security (v2.3.1+)
|
||||
#### Supply Chain Security (v2.3.2+)
|
||||
|
||||
All official release artifacts (Docker images and extension binaries) are published with signed [artifact attestations](https://docs.github.com/en/actions/how-tos/secure-your-work/use-artifact-attestations) to prove they were built from this repository's source code.
|
||||
|
||||
|
||||
@@ -4,6 +4,28 @@ All notable changes to the KoalaSync browser extension and relay server.
|
||||
|
||||
---
|
||||
|
||||
## [v2.4.0] — 2026-06-16
|
||||
|
||||
### Added
|
||||
- **Extension: Lazy WebSocket connection** — The extension no longer maintains a permanent WebSocket connection to the relay server. Instead, the connection is established only when actively in a room or when the popup is opened with a saved room configuration. This improves privacy (IP is not exposed while idle), reduces battery/network usage, and prevents the server from tracking online status of inactive users. Automatic reconnect is guaranteed while in a room — zero behavior change during active sync sessions. See `connectIntent` flag in `background.js`.
|
||||
- **Extension: Episode title regex unification** — `extractEpisodeId()` had inconsistent regex patterns between `background.js` and `content.js`. The content script correctly matched Crunchyroll-style separators (`S01/E01`) while the service worker's stricter pattern (`[\s\-\.]*`) silently rejected them, causing episode lobby sync failures. Now unified to `[^a-zA-Z0-9]*` via shared `episode-utils.js`.
|
||||
- **Unit tests: `rate-limiter` and `episode-utils`** — 12 test groups for rate-limit functions and 30+ assertions for episode title parsing, covering all 6 separator types (dash, dot, slash, colon, comma, space). Run automatically via `npm run verify`.
|
||||
|
||||
### Changed
|
||||
- **Server: Rate limiter extracted to `rate-limiter.js`** — 6 rate-limit functions, all rate-limit Maps, and cleanup intervals moved from `index.js` (149 lines). `index.js` now imports via facade pattern with re-exports for backward compatibility.
|
||||
- **Extension: Episode utilities extracted to `episode-utils.js`** — `extractEpisodeId()` and `sameEpisode()` deduplicated from `background.js` and `content.js`. The shared module is imported as an ES module by the service worker and injected into the content script IIFE by the build script.
|
||||
- **Build: `"type": "module"` in root `package.json`** — All scripts standardized to ESM (`.mjs`) or explicitly CommonJS (`.cjs`). Eliminated Node.js `MODULE_TYPELESS_PACKAGE_JSON` warnings.
|
||||
- **Build: 4 CJS scripts renamed to `.cjs`** — `build-extension.js`, `test-content-video-finder.js`, `test-locales.js`, `website/build.js`.
|
||||
|
||||
### Fixed
|
||||
- **Server: npm audit resolved** — `ws` package vulnerability (CVE-2024-37890) fixed. Zero vulnerabilities in production dependencies.
|
||||
- **Pop-up: Connection status flicker fixed** — Removed hardcoded `disconnected` state on every pop-up open. Status now reflects actual background state from the first frame.
|
||||
- **Pop-up: Join button timeout improved** — No longer blindly re-enables after 15s. Polls connection status and extends window if still connecting.
|
||||
- **Pop-up: Validation failure state cleanup** — Custom server URL validation errors now properly reset `isProcessingConnection` and `joinBtnTimeout`.
|
||||
- **Extension: `WEB_JOIN_REQUEST` channel leak fixed** — Missing `sendResponse()` call when already in the target room.
|
||||
- **Extension: `LEAVE_ROOM` now clears `roomId` from storage** — Prevents phantom auto-reconnect on browser restart after explicit leave.
|
||||
- **Extension: Reconnect attempt counters reset on leave** — Prevents stale `reconnecting` status display after intentional disconnect.
|
||||
|
||||
## [v2.3.2] — 2026-06-16
|
||||
|
||||
### Changed
|
||||
@@ -17,6 +39,9 @@ All notable changes to the KoalaSync browser extension and relay server.
|
||||
|
||||
## [v2.3.1] — 2026-06-15
|
||||
|
||||
### Fixed
|
||||
- **Server: Concurrent peer join race condition and teardown error handling**
|
||||
|
||||
### Changed
|
||||
- **Server: Smart unhandled rejection handling (exits after 5/min instead of 1)**
|
||||
- **Server: Optimized admin health metrics allocation**
|
||||
|
||||
+4
-2
@@ -37,8 +37,10 @@ export default [
|
||||
URL: "readonly",
|
||||
URLSearchParams: "readonly",
|
||||
WebSocket: "readonly",
|
||||
history: "readonly",
|
||||
location: "readonly",
|
||||
self: "readonly",
|
||||
process: "readonly"
|
||||
process: "readonly",
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
@@ -56,7 +58,7 @@ export default [
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ["server/**/*.js", "scripts/**/*.js", "website/build.js"],
|
||||
files: ["server/**/*.js", "scripts/**/*.js", "scripts/**/*.cjs", "website/build.cjs"],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
require: "readonly",
|
||||
|
||||
@@ -38,3 +38,15 @@ If you modify `shared/constants.js`, you must synchronize the changes by running
|
||||
node scripts/build-extension.js
|
||||
```
|
||||
This ensures that the `extension/shared` folder is updated with the latest protocol constants.
|
||||
|
||||
## Module Structure
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `background.js` | Service worker: message routing, tab listeners, startup |
|
||||
| `content.js` | Video detection, audio processing, episode transition (IIFE) |
|
||||
| `popup.js` | Popup UI: join/create, tabs, status, settings |
|
||||
| `bridge.js` | Landing page bridge (injected into sync.koalastuff.net) |
|
||||
| `episode-utils.js` | Shared `extractEpisodeId()` / `sameEpisode()` — used by background.js, injected into content.js at build time |
|
||||
| `i18n.js` | Translation loader |
|
||||
| `shared/` | Constants, blacklist, name generator |
|
||||
|
||||
+70
-39
@@ -1,6 +1,7 @@
|
||||
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, getSystemLanguage } from './i18n.js';
|
||||
import { sameEpisode } from './episode-utils.js';
|
||||
|
||||
// --- Uninstall URL Initialization ---
|
||||
chrome.runtime.onInstalled.addListener((details) => {
|
||||
@@ -176,6 +177,7 @@ let reconnectAttempts = 0;
|
||||
let currentServerUrl = null;
|
||||
let roomIdleSince = null;
|
||||
let lastContentHeartbeatAt = null;
|
||||
let connectIntent = false;
|
||||
const MAX_RECONNECT_ATTEMPTS = 20;
|
||||
const _RECONNECT_BASE_DELAY = 500;
|
||||
const _RECONNECT_MAX_DELAY = 5000;
|
||||
@@ -190,26 +192,6 @@ let forceSyncTimeout = null;
|
||||
let episodeLobby = null; // { expectedTitle, initiatorPeerId, readyPeers: [], createdAt }
|
||||
let episodeLobbyTimeout = null;
|
||||
|
||||
// --- Episode Title Extraction (synced with content.js) ---
|
||||
function extractEpisodeId(title) {
|
||||
if (!title || typeof title !== 'string') return null;
|
||||
const se = title.match(/S(?:eason\s*)?(\d+)[\s\-\.]*E(?:pisode\s*)?(\d+)/i);
|
||||
if (se) return `S${String(se[1]).padStart(2, '0')}E${String(se[2]).padStart(2, '0')}`;
|
||||
const ep = title.match(/(?:Episode|Folge|Ep\.?|#)\s*(\d+)/i);
|
||||
if (ep) return `EP${String(ep[1]).padStart(3, '0')}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
function sameEpisode(titleA, titleB) {
|
||||
if (!titleA && !titleB) return true; // Both unknown → assume same (backward compat)
|
||||
if (!titleA || !titleB) return false; // One unknown, one known → different
|
||||
const idA = extractEpisodeId(titleA);
|
||||
const idB = extractEpisodeId(titleB);
|
||||
if (idA && idB) return idA === idB; // Both have parseable IDs → compare IDs
|
||||
if (idA || idB) return false; // One has ID, other doesn't → different
|
||||
return titleA === titleB; // Neither has ID → exact string match
|
||||
}
|
||||
|
||||
// --- Storage Utils ---
|
||||
|
||||
/**
|
||||
@@ -404,7 +386,12 @@ function clearTargetTabForIdle() {
|
||||
|
||||
async function leaveRoomAfterIdleGrace(reason) {
|
||||
if (!currentRoom) return;
|
||||
connectIntent = false;
|
||||
reconnectFailed = false;
|
||||
reconnectAttempts = 0;
|
||||
chrome.storage.session.set({ reconnectFailed: false, reconnectAttempts: 0, reconnectStartTime: null });
|
||||
emit(EVENTS.LEAVE_ROOM, { peerId });
|
||||
forceDisconnect();
|
||||
currentRoom = null;
|
||||
currentTabId = null;
|
||||
currentTabTitle = null;
|
||||
@@ -457,7 +444,9 @@ async function connect() {
|
||||
addLog('Browser is offline. Waiting...', 'warn');
|
||||
broadcastConnectionStatus('offline');
|
||||
isConnecting = false;
|
||||
scheduleReconnect();
|
||||
if (currentRoom || connectIntent) {
|
||||
scheduleReconnect();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -564,25 +553,32 @@ async function connect() {
|
||||
isNamespaceJoined = false;
|
||||
stopPing();
|
||||
|
||||
isForceSyncInitiator = false;
|
||||
forceSyncAcks.clear();
|
||||
if (forceSyncTimeout) clearTimeout(forceSyncTimeout);
|
||||
chrome.storage.session.set({
|
||||
isForceSyncInitiator: false,
|
||||
forceSyncAcks: [],
|
||||
forceSyncDeadline: null
|
||||
}).catch(() => {});
|
||||
if (!connectIntent && !currentRoom) {
|
||||
isForceSyncInitiator = false;
|
||||
forceSyncAcks.clear();
|
||||
if (forceSyncTimeout) clearTimeout(forceSyncTimeout);
|
||||
chrome.storage.session.set({
|
||||
isForceSyncInitiator: false,
|
||||
forceSyncAcks: [],
|
||||
forceSyncDeadline: null
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
|
||||
if (currentRoom) {
|
||||
if (currentRoom && !connectIntent) {
|
||||
currentRoom.peers = [];
|
||||
if (storageInitialized) chrome.storage.session.set({ currentRoom }).catch(() => {});
|
||||
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {});
|
||||
}
|
||||
broadcastConnectionStatus('disconnected');
|
||||
addLog('Disconnected. Scheduling reconnect...', 'warn');
|
||||
socket = null;
|
||||
scheduleReconnect();
|
||||
if (currentRoom || connectIntent) {
|
||||
addLog('Disconnected. Scheduling reconnect...', 'warn');
|
||||
socket = null;
|
||||
scheduleReconnect();
|
||||
} else {
|
||||
addLog('Disconnected. No active session — staying disconnected.', 'info');
|
||||
socket = null;
|
||||
}
|
||||
};
|
||||
|
||||
socket.onerror = () => {
|
||||
@@ -597,7 +593,9 @@ async function connect() {
|
||||
const errMsg = (e && e.message) ? e.message : String(e || 'Unknown connection error');
|
||||
addLog(errMsg, logType);
|
||||
broadcastConnectionStatus('disconnected');
|
||||
scheduleReconnect();
|
||||
if (currentRoom || connectIntent) {
|
||||
scheduleReconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -738,6 +736,9 @@ function sendPing() {
|
||||
addLog('Ping timeout reached, force disconnecting to trigger reconnect', 'warn');
|
||||
pendingPingT = null;
|
||||
forceDisconnect();
|
||||
if (currentRoom || connectIntent) {
|
||||
scheduleReconnect();
|
||||
}
|
||||
}
|
||||
pingTimeout = null;
|
||||
}, 5000);
|
||||
@@ -836,6 +837,14 @@ function handleServerEvent(event, data) {
|
||||
break;
|
||||
case EVENTS.ERROR:
|
||||
isConnecting = false;
|
||||
// If we get a server error before successfully joining a room,
|
||||
// clear connectIntent to prevent an infinite reconnect loop.
|
||||
if (!currentRoom) {
|
||||
connectIntent = false;
|
||||
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
|
||||
reconnectAttempts = 0;
|
||||
reconnectFailed = false;
|
||||
}
|
||||
broadcastConnectionStatus('disconnected');
|
||||
addLog(`Server Error: ${data.message}`, 'error');
|
||||
chrome.storage.local.get(['browserNotifications', 'locale'], async (settings) => {
|
||||
@@ -1334,7 +1343,7 @@ chrome.alarms.onAlarm.addListener(async (alarm) => {
|
||||
if (alarm.name === 'keepAlive') {
|
||||
chrome.storage.session.get('keepAlive', () => {});
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||
if (!reconnectFailed) {
|
||||
if (!reconnectFailed && (currentRoom || connectIntent)) {
|
||||
connect();
|
||||
}
|
||||
} else if (currentRoom) {
|
||||
@@ -1417,6 +1426,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
|
||||
if (message.type === 'CONNECT') {
|
||||
const settings = await getSettings();
|
||||
connectIntent = !!settings.roomId;
|
||||
const desiredUrl = resolveServerUrl(settings);
|
||||
|
||||
if (settings.roomId && currentRoom && currentRoom.roomId === settings.roomId && socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined && desiredUrl === currentServerUrl) {
|
||||
@@ -1439,7 +1449,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
}
|
||||
if (desiredUrl !== currentServerUrl || !socket || socket.readyState !== WebSocket.OPEN || !isNamespaceJoined) {
|
||||
if (desiredUrl !== currentServerUrl) forceDisconnect();
|
||||
connect();
|
||||
if (settings.roomId) connect();
|
||||
} else if (settings.roomId) {
|
||||
emit(EVENTS.JOIN_ROOM, {
|
||||
roomId: settings.roomId,
|
||||
@@ -1452,6 +1462,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
}
|
||||
sendResponse({ status: 'ok' });
|
||||
} else if (message.type === 'RETRY_CONNECT') {
|
||||
connectIntent = true;
|
||||
reconnectFailed = false;
|
||||
reconnectStartTime = null;
|
||||
reconnectAttempts = 0;
|
||||
@@ -1480,6 +1491,10 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
ping: currentPingMs
|
||||
});
|
||||
} else if (message.type === 'LEAVE_ROOM') {
|
||||
connectIntent = false;
|
||||
reconnectFailed = false;
|
||||
reconnectAttempts = 0;
|
||||
chrome.storage.session.set({ reconnectFailed: false, reconnectAttempts: 0, reconnectStartTime: null });
|
||||
resetAudioProcessingInTab(currentTabId);
|
||||
emit(EVENTS.LEAVE_ROOM, { peerId });
|
||||
currentRoom = null;
|
||||
@@ -1510,8 +1525,10 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
episodeLobby: null,
|
||||
expectedAcksCount: 0
|
||||
});
|
||||
chrome.storage.local.set({ roomId: '', password: '' }).catch(() => {});
|
||||
addLog('Left Room', 'info');
|
||||
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {});
|
||||
forceDisconnect();
|
||||
sendResponse({ status: 'ok' });
|
||||
} else if (message.type === 'CLEAR_LOGS') {
|
||||
logs = [];
|
||||
@@ -1526,6 +1543,16 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
} else if (message.type === 'WEB_JOIN_REQUEST') {
|
||||
const { roomId: rawRoomId, password, useCustomServer, serverUrl } = message;
|
||||
const roomId = typeof rawRoomId === 'string' ? rawRoomId.replace(/[^a-zA-Z0-9\-]/g, '') : '';
|
||||
if (!roomId) {
|
||||
const errMsg = { type: 'JOIN_STATUS', success: false, message: 'Invalid room ID' };
|
||||
chrome.runtime.sendMessage(errMsg).catch(() => {});
|
||||
chrome.tabs.query({}, (tabs) => {
|
||||
tabs.forEach(tab => chrome.tabs.sendMessage(tab.id, errMsg).catch(() => {}));
|
||||
});
|
||||
sendResponse({ status: 'invalid_room_id' });
|
||||
return;
|
||||
}
|
||||
connectIntent = true;
|
||||
chrome.storage.local.set({
|
||||
roomId,
|
||||
password,
|
||||
@@ -1541,6 +1568,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
for (const tab of tabs) {
|
||||
chrome.tabs.sendMessage(tab.id, { type: 'JOIN_STATUS', success: true, message: 'Already in room' }).catch(() => {});
|
||||
}
|
||||
sendResponse({ status: 'already_joined' });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1554,7 +1582,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
if (desiredUrl !== currentServerUrl || !socket || socket.readyState !== WebSocket.OPEN || !isNamespaceJoined) {
|
||||
if (desiredUrl !== currentServerUrl) forceDisconnect();
|
||||
connect();
|
||||
} else {
|
||||
} else if (roomId) {
|
||||
emit(EVENTS.JOIN_ROOM, {
|
||||
roomId,
|
||||
password,
|
||||
@@ -1939,5 +1967,8 @@ chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, _tab) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Initial Connect
|
||||
connect();
|
||||
// Initial Connect — only if user has an active room configuration
|
||||
getSettings().then(settings => {
|
||||
connectIntent = !!settings.roomId;
|
||||
if (connectIntent) connect();
|
||||
}).catch(() => connectIntent = false);
|
||||
|
||||
@@ -290,28 +290,27 @@
|
||||
// Extract a canonical episode identifier from a title string.
|
||||
// Handles: S01E01, S1E1, S01 - E01, Season 1 Episode 1, "Folge 5", "Episode 5", "Ep. 5", "#5"
|
||||
// Returns null if no episode pattern found.
|
||||
// --- SHARED_EPISODE_UTILS_INJECT_START ---
|
||||
// This block is automatically replaced by /scripts/build-extension.js
|
||||
function extractEpisodeId(title) {
|
||||
if (!title || typeof title !== 'string') return null;
|
||||
// S01E01 patterns (with any non-alphanumeric separator between season and E)
|
||||
const se = title.match(/S(?:eason\s*)?(\d+)[^a-zA-Z0-9]*E(?:pisode\s*)?(\d+)/i);
|
||||
if (se) return `S${String(se[1]).padStart(2, '0')}E${String(se[2]).padStart(2, '0')}`;
|
||||
// "Episode X", "Folge X", "Ep. X", "#X"
|
||||
const ep = title.match(/(?:Episode|Folge|Ep\.?|#)\s*(\d+)/i);
|
||||
if (ep) return `EP${String(ep[1]).padStart(3, '0')}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Returns true if two titles likely refer to the same episode.
|
||||
// Strict: both must have IDs and match, OR neither has IDs and exact match.
|
||||
function sameEpisode(titleA, titleB) {
|
||||
if (!titleA && !titleB) return true; // Both unknown → assume same (backward compat)
|
||||
if (!titleA || !titleB) return false; // One unknown, one known → different
|
||||
if (!titleA && !titleB) return true;
|
||||
if (!titleA || !titleB) return false;
|
||||
const idA = extractEpisodeId(titleA);
|
||||
const idB = extractEpisodeId(titleB);
|
||||
if (idA && idB) return idA === idB; // Both have parseable IDs → compare IDs
|
||||
if (idA || idB) return false; // One has ID, other doesn't → different
|
||||
return titleA === titleB; // Neither has ID → exact string match
|
||||
if (idA && idB) return idA === idB;
|
||||
if (idA || idB) return false;
|
||||
return titleA === titleB;
|
||||
}
|
||||
// --- SHARED_EPISODE_UTILS_INJECT_END ---
|
||||
|
||||
// Returns true only when we are CERTAIN the episodes differ.
|
||||
// Permissive: only blocks if BOTH titles have parseable IDs AND they differ.
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* KoalaSync Episode Title Utilities
|
||||
* Single source of truth — synced to content.js by build-extension.js.
|
||||
* Keep in sync with the injection block in content.js!
|
||||
*/
|
||||
|
||||
export function extractEpisodeId(title) {
|
||||
if (!title || typeof title !== 'string') return null;
|
||||
const se = title.match(/S(?:eason\s*)?(\d+)[^a-zA-Z0-9]*E(?:pisode\s*)?(\d+)/i);
|
||||
if (se) return `S${String(se[1]).padStart(2, '0')}E${String(se[2]).padStart(2, '0')}`;
|
||||
const ep = title.match(/(?:Episode|Folge|Ep\.?|#)\s*(\d+)/i);
|
||||
if (ep) return `EP${String(ep[1]).padStart(3, '0')}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function sameEpisode(titleA, titleB) {
|
||||
if (!titleA && !titleB) return true;
|
||||
if (!titleA || !titleB) return false;
|
||||
const idA = extractEpisodeId(titleA);
|
||||
const idB = extractEpisodeId(titleB);
|
||||
if (idA && idB) return idA === idB;
|
||||
if (idA || idB) return false;
|
||||
return titleA === titleB;
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
"manifest_version": 3,
|
||||
"default_locale": "en",
|
||||
"name": "KoalaSync",
|
||||
"version": "2.3.1",
|
||||
"version": "2.3.2",
|
||||
"description": "Synchronize video playback on YouTube, Netflix, Emby, Jellyfin, and any HTML5 site in real-time with friends.",
|
||||
"permissions": [
|
||||
"storage",
|
||||
|
||||
+31
-7
@@ -237,8 +237,7 @@ async function init() {
|
||||
refreshLogs();
|
||||
refreshHistory();
|
||||
|
||||
// Default connection status (localized) before async check
|
||||
applyConnectionStatus('disconnected');
|
||||
// Initial Status Check (status shows via GET_STATUS below)
|
||||
|
||||
// Initial Status Check
|
||||
chrome.runtime.sendMessage({ type: 'GET_STATUS' }, async (res) => {
|
||||
@@ -255,6 +254,13 @@ async function init() {
|
||||
updatePeerList(res.peers);
|
||||
lastKnownPeers = res.peers || [];
|
||||
if (res.lastActionState) updateLastActionUI(res.lastActionState, res.peers);
|
||||
|
||||
// If user has a room configured but background is not connected,
|
||||
// trigger connection now — the popup opening is explicit user intent.
|
||||
if (res.status === 'disconnected' && localData.roomId) {
|
||||
chrome.runtime.sendMessage({ type: 'CONNECT' }).catch(() => {});
|
||||
applyConnectionStatus('connecting');
|
||||
}
|
||||
|
||||
// Populate Tabs using the background's targetTabId
|
||||
await populateTabs(res.peers, res.targetTabId);
|
||||
@@ -1195,11 +1201,23 @@ elements.joinBtn.addEventListener('click', async () => {
|
||||
|
||||
if (joinBtnTimeout) clearTimeout(joinBtnTimeout);
|
||||
joinBtnTimeout = setTimeout(() => {
|
||||
elements.joinBtn.disabled = false;
|
||||
elements.joinBtn.textContent = getMessage('BTN_JOIN_ROOM');
|
||||
joinBtnTimeout = null;
|
||||
isProcessingConnection = false;
|
||||
showError(getMessage('ERR_CONN_TIMEOUT'));
|
||||
chrome.runtime.sendMessage({ type: 'GET_STATUS' }, (res) => {
|
||||
if (res && res.status === 'connecting') {
|
||||
joinBtnTimeout = setTimeout(() => {
|
||||
elements.joinBtn.disabled = false;
|
||||
elements.joinBtn.textContent = getMessage('BTN_JOIN_ROOM');
|
||||
joinBtnTimeout = null;
|
||||
isProcessingConnection = false;
|
||||
showError(getMessage('ERR_CONN_TIMEOUT'));
|
||||
}, 15000);
|
||||
return;
|
||||
}
|
||||
elements.joinBtn.disabled = false;
|
||||
elements.joinBtn.textContent = getMessage('BTN_JOIN_ROOM');
|
||||
joinBtnTimeout = null;
|
||||
isProcessingConnection = false;
|
||||
if (res && res.status !== 'connected') showError(getMessage('ERR_CONN_TIMEOUT'));
|
||||
});
|
||||
}, 15000);
|
||||
|
||||
const serverUrl = elements.serverUrl.value.trim();
|
||||
@@ -1210,6 +1228,8 @@ elements.joinBtn.addEventListener('click', async () => {
|
||||
showError(getMessage('ERR_INVALID_SERVER_URL'));
|
||||
elements.joinBtn.disabled = false;
|
||||
elements.joinBtn.textContent = getMessage('BTN_JOIN_ROOM');
|
||||
if (joinBtnTimeout) { clearTimeout(joinBtnTimeout); joinBtnTimeout = null; }
|
||||
isProcessingConnection = false;
|
||||
return;
|
||||
}
|
||||
if (useCustom && serverUrl) {
|
||||
@@ -1220,6 +1240,7 @@ elements.joinBtn.addEventListener('click', async () => {
|
||||
showError(getMessage('ERR_INVALID_SERVER_URL'));
|
||||
elements.joinBtn.disabled = false;
|
||||
elements.joinBtn.textContent = getMessage('BTN_JOIN_ROOM');
|
||||
if (joinBtnTimeout) { clearTimeout(joinBtnTimeout); joinBtnTimeout = null; }
|
||||
isProcessingConnection = false;
|
||||
return;
|
||||
}
|
||||
@@ -1608,6 +1629,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' || msg.status === 'disconnected') {
|
||||
if (joinBtnTimeout) { clearTimeout(joinBtnTimeout); joinBtnTimeout = null; }
|
||||
}
|
||||
if (msg.status === 'connected') {
|
||||
clearConnectionErrorTimer();
|
||||
}
|
||||
|
||||
+3
-2
@@ -1,10 +1,11 @@
|
||||
{
|
||||
"name": "koalasync",
|
||||
"version": "2.3.1",
|
||||
"version": "2.3.2",
|
||||
"description": "KoalaSync Build Scripts",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build:extension": "node scripts/build-extension.js",
|
||||
"build:extension": "node scripts/build-extension.cjs",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"verify": "node scripts/verify-release.mjs"
|
||||
|
||||
@@ -97,6 +97,25 @@ function copyExtensionFiles(targetDir, browserName) {
|
||||
console.warn('⚠️ WARNING: Heartbeat markers not found in content.js');
|
||||
}
|
||||
|
||||
// 3. Inject Episode Utils
|
||||
const euStart = '// --- SHARED_EPISODE_UTILS_INJECT_START ---';
|
||||
const euEnd = '// --- SHARED_EPISODE_UTILS_INJECT_END ---';
|
||||
const euPattern = new RegExp(euStart.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '[\\s\\S]+?' + euEnd.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
||||
const euPath = path.join(rootDir, 'extension', 'episode-utils.js');
|
||||
if (fs.existsSync(euPath)) {
|
||||
const euContent = fs.readFileSync(euPath, 'utf8');
|
||||
const stripped = euContent
|
||||
.replace(/^\/\*\*[\s\S]*?\*\/\s*/m, '')
|
||||
.replace(/export function /g, 'function ')
|
||||
.trim();
|
||||
const euRep = `${euStart}\n // This block is automatically updated by /scripts/build-extension.js\n${stripped.split('\n').map(l => ' ' + l).join('\n')}\n ${euEnd}`;
|
||||
if (euPattern.test(content)) {
|
||||
content = content.replace(euPattern, euRep);
|
||||
} else {
|
||||
console.warn('⚠ WARNING: Episode utils markers not found in content.js');
|
||||
}
|
||||
}
|
||||
|
||||
fs.writeFileSync(destPath, content);
|
||||
console.log('✓ Injected shared constants into content.js');
|
||||
} else if (item === 'background.js') {
|
||||
@@ -0,0 +1,76 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { extractEpisodeId, sameEpisode } from '../extension/episode-utils.js';
|
||||
|
||||
// --- extractEpisodeId ---
|
||||
|
||||
// Standard SxxExx patterns
|
||||
assert.equal(extractEpisodeId('S01E01'), 'S01E01');
|
||||
assert.equal(extractEpisodeId('S1E1'), 'S01E01');
|
||||
assert.equal(extractEpisodeId('s01e01'), 'S01E01', 'case insensitive');
|
||||
assert.equal(extractEpisodeId('Season 1 Episode 2'), 'S01E02');
|
||||
assert.equal(extractEpisodeId('season 01 episode 02'), 'S01E02');
|
||||
|
||||
// Separators: dash, dot, slash, colon, space, comma
|
||||
assert.equal(extractEpisodeId('S01 - E01'), 'S01E01', 'dash separator');
|
||||
assert.equal(extractEpisodeId('S01.E01'), 'S01E01', 'dot separator');
|
||||
assert.equal(extractEpisodeId('S01/E01'), 'S01E01', 'slash separator (Crunchyroll)');
|
||||
assert.equal(extractEpisodeId('S01:E01'), 'S01E01', 'colon separator');
|
||||
assert.equal(extractEpisodeId('S01,E01'), 'S01E01', 'comma separator');
|
||||
assert.equal(extractEpisodeId('S01 E01'), 'S01E01', 'space separator');
|
||||
|
||||
// German / multi-language
|
||||
assert.equal(extractEpisodeId('Folge 5'), 'EP005');
|
||||
assert.equal(extractEpisodeId('Episode 12'), 'EP012');
|
||||
assert.equal(extractEpisodeId('Ep. 3'), 'EP003');
|
||||
assert.equal(extractEpisodeId('#42'), 'EP042');
|
||||
|
||||
// Edge cases
|
||||
assert.equal(extractEpisodeId(null), null);
|
||||
assert.equal(extractEpisodeId(undefined), null);
|
||||
assert.equal(extractEpisodeId(''), null);
|
||||
assert.equal(extractEpisodeId(123), null);
|
||||
assert.equal(extractEpisodeId('Some Movie Title'), null);
|
||||
assert.equal(extractEpisodeId('Breaking Bad'), null);
|
||||
|
||||
// Leading zeros preserved
|
||||
assert.equal(extractEpisodeId('S01E001'), 'S01E001');
|
||||
|
||||
// --- sameEpisode ---
|
||||
|
||||
// Identical episodes
|
||||
assert.equal(sameEpisode('S01E01', 'S01E01'), true);
|
||||
assert.equal(sameEpisode('S01E01 - Pilot', 'S01E01'), true, 'extra text ignored');
|
||||
assert.equal(sameEpisode('Folge 5', 'Episode 5'), true, 'German vs English');
|
||||
|
||||
// Different episodes
|
||||
assert.equal(sameEpisode('S01E01', 'S01E02'), false);
|
||||
assert.equal(sameEpisode('Folge 1', 'Folge 2'), false);
|
||||
assert.equal(sameEpisode('S01E01', 'S02E01'), false);
|
||||
|
||||
// Both unknown → assume same (backward compat)
|
||||
assert.equal(sameEpisode(null, null), true);
|
||||
assert.equal(sameEpisode(undefined, undefined), true);
|
||||
assert.equal(sameEpisode('', ''), true);
|
||||
assert.equal(sameEpisode('Some Movie', 'Some Movie'), true);
|
||||
assert.equal(sameEpisode('Some Movie', 'Other Movie'), false, 'different unknowns differ');
|
||||
|
||||
// One unknown, one known → different
|
||||
assert.equal(sameEpisode('S01E01', null), false);
|
||||
assert.equal(sameEpisode(null, 'Episode 5'), false);
|
||||
assert.equal(sameEpisode(undefined, 'S01E01'), false);
|
||||
|
||||
// Mixed formats — only match when the same episode
|
||||
assert.equal(sameEpisode('S01E05', 'S01E05'), true, 'same SxxExx');
|
||||
assert.equal(sameEpisode('Folge 5', 'Episode 5'), true, 'German Folge vs English Episode');
|
||||
assert.equal(sameEpisode('Episode 12', 'Ep. 12'), true, 'Episode X vs Ep. X');
|
||||
assert.equal(sameEpisode('#42', 'Folge 42'), true, '#X vs Folge X');
|
||||
|
||||
// Different format IDs → different (season-tagged vs seasonless)
|
||||
assert.equal(sameEpisode('S01E05', 'Episode 5'), false, 'SxxExx vs Episode X: different IDs');
|
||||
assert.equal(sameEpisode('S01E01', 'EP001'), false, 'SxxExx vs EPxxx: different IDs');
|
||||
|
||||
// parseable but truly different
|
||||
assert.equal(sameEpisode('S01E01', 'S01E02'), false, 'different episodes');
|
||||
assert.equal(sameEpisode('S01E01', 'S02E01'), false, 'different seasons');
|
||||
|
||||
console.log('episode-utils tests passed');
|
||||
@@ -0,0 +1,56 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { getAvatarForName, generateUsername, USERNAME_ADJECTIVES, USERNAME_NOUNS } from '../shared/names.js';
|
||||
|
||||
// --- getAvatarForName (deterministic) ---
|
||||
|
||||
// Exact matches
|
||||
assert.equal(getAvatarForName('Koala'), '🐨', 'Koala');
|
||||
assert.equal(getAvatarForName('Tiger'), '🐯', 'Tiger');
|
||||
assert.equal(getAvatarForName('Panda'), '🐼', 'Panda');
|
||||
assert.equal(getAvatarForName('Fox'), '🦊', 'Fox');
|
||||
|
||||
// Case insensitive
|
||||
assert.equal(getAvatarForName('koala'), '🐨', 'lowercase');
|
||||
assert.equal(getAvatarForName('MyKoalaUser'), '🐨', 'embedded uppercase');
|
||||
|
||||
// Longest match wins (caterpillar > cat)
|
||||
assert.equal(getAvatarForName('CaterpillarCat'), '🐛', 'caterpillar before cat');
|
||||
assert.equal(getAvatarForName('Cat'), '🐱', 'cat alone');
|
||||
|
||||
// Emoji with ZWJ sequences (multi-codepoint)
|
||||
assert.equal(getAvatarForName('Polar'), '🐻\u200D❄️', 'polar bear ZWJ');
|
||||
assert.equal(getAvatarForName('Crow'), '🐦\u200D⬛', 'crow ZWJ');
|
||||
|
||||
// Human-like characters
|
||||
assert.equal(getAvatarForName('Ninja'), '🥷', 'ninja');
|
||||
assert.equal(getAvatarForName('Wizard'), '🧙', 'wizard');
|
||||
assert.equal(getAvatarForName('Pirate'), '🏴', 'pirate');
|
||||
assert.equal(getAvatarForName('Alien'), '👾', 'alien');
|
||||
assert.equal(getAvatarForName('Robot'), '🤖', 'robot');
|
||||
|
||||
// Fallback
|
||||
assert.equal(getAvatarForName(''), '👤', 'empty string');
|
||||
assert.equal(getAvatarForName('Xyzzy123'), '👤', 'unknown name');
|
||||
assert.equal(getAvatarForName(null), '👤', 'null');
|
||||
assert.equal(getAvatarForName(undefined), '👤', 'undefined');
|
||||
|
||||
// --- generateUsername (format check) ---
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const name = generateUsername();
|
||||
// Format: AdjectiveNoun (e.g. "HappyKoala")
|
||||
assert.ok(/^[A-Z][a-z]+[A-Z][a-z]+$/.test(name), `format: ${name}`);
|
||||
// Adjective from list
|
||||
const adj = USERNAME_ADJECTIVES.some(a => name.startsWith(a));
|
||||
assert.ok(adj, `adjective from list: ${name}`);
|
||||
// Noun from list
|
||||
const noun = USERNAME_NOUNS.some(n => name.endsWith(n));
|
||||
assert.ok(noun, `noun from list: ${name}`);
|
||||
}
|
||||
|
||||
// Every noun has an emoji (no broken usernames)
|
||||
for (const noun of USERNAME_NOUNS) {
|
||||
const avatar = getAvatarForName(noun);
|
||||
assert.notEqual(avatar, '👤', `noun "${noun}" has no emoji — add to ANIMAL_EMOJI_MAP`);
|
||||
}
|
||||
|
||||
console.log('names tests passed');
|
||||
@@ -0,0 +1,110 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
checkConnectionRate,
|
||||
checkEventRate,
|
||||
checkHealthRate,
|
||||
checkAdminMetricsAuthRate,
|
||||
checkAuthRate,
|
||||
recordAuthFailure,
|
||||
clearRateLimitMaps,
|
||||
connectionCounts,
|
||||
failedAuthAttempts,
|
||||
eventCounts,
|
||||
healthCounts,
|
||||
adminMetricsAuthCounts,
|
||||
roomListCooldowns,
|
||||
rateLimitDenied,
|
||||
startRateLimitCleanup,
|
||||
stopRateLimitCleanup
|
||||
} from '../server/rate-limiter.js';
|
||||
|
||||
// Helper: mock io for cleanup
|
||||
const mockIo = { sockets: { sockets: new Map() } };
|
||||
|
||||
// Reset state before each test group
|
||||
function reset() {
|
||||
clearRateLimitMaps();
|
||||
Object.assign(rateLimitDenied, { connections: 0, events: 0, health: 0, adminMetricsAuth: 0, roomList: 0 });
|
||||
stopRateLimitCleanup();
|
||||
}
|
||||
|
||||
// --- checkConnectionRate ---
|
||||
reset();
|
||||
assert.equal(checkConnectionRate('1.1.1.1'), true, 'first connection allowed');
|
||||
for (let i = 0; i < 9; i++) checkConnectionRate('1.1.1.1');
|
||||
assert.equal(checkConnectionRate('1.1.1.1'), false, '11th connection blocked');
|
||||
assert.equal(rateLimitDenied.connections, 1, 'denial counter incremented');
|
||||
|
||||
reset();
|
||||
assert.equal(checkConnectionRate('2.2.2.2'), true, 'separate IP independent');
|
||||
|
||||
// --- checkEventRate ---
|
||||
reset();
|
||||
assert.equal(checkEventRate('sock1'), true, 'first event allowed');
|
||||
for (let i = 0; i < 29; i++) checkEventRate('sock1');
|
||||
assert.equal(checkEventRate('sock1'), false, '31st event blocked');
|
||||
assert.equal(rateLimitDenied.events, 1);
|
||||
|
||||
reset();
|
||||
assert.equal(checkEventRate('sock2'), true, 'separate socket independent');
|
||||
|
||||
// --- checkHealthRate ---
|
||||
reset();
|
||||
assert.equal(checkHealthRate('1.2.3.4'), true, 'first health check allowed');
|
||||
for (let i = 0; i < 9; i++) checkHealthRate('1.2.3.4');
|
||||
assert.equal(checkHealthRate('1.2.3.4'), false, '11th health check blocked');
|
||||
assert.equal(rateLimitDenied.health, 1);
|
||||
|
||||
// --- checkAdminMetricsAuthRate ---
|
||||
reset();
|
||||
assert.equal(checkAdminMetricsAuthRate('5.6.7.8'), true, 'first admin auth allowed');
|
||||
for (let i = 0; i < 4; i++) checkAdminMetricsAuthRate('5.6.7.8');
|
||||
assert.equal(checkAdminMetricsAuthRate('5.6.7.8'), false, '6th admin auth blocked');
|
||||
assert.equal(rateLimitDenied.adminMetricsAuth, 1);
|
||||
|
||||
// --- checkAuthRate ---
|
||||
reset();
|
||||
assert.equal(checkAuthRate('10.0.0.1', 'room-a'), true, 'first auth attempt allowed');
|
||||
for (let i = 0; i < 5; i++) recordAuthFailure('10.0.0.1', 'room-a');
|
||||
assert.equal(checkAuthRate('10.0.0.1', 'room-a'), false, '6th auth attempt blocked');
|
||||
assert.equal(checkAuthRate('10.0.0.1', 'room-b'), true, 'different room not blocked');
|
||||
|
||||
// --- recordAuthFailure ---
|
||||
reset();
|
||||
recordAuthFailure('10.0.0.2', 'room-x');
|
||||
assert.equal(failedAuthAttempts.size, 1, 'failure recorded');
|
||||
const record = failedAuthAttempts.get('10.0.0.2:room-x');
|
||||
assert.equal(record.count, 1, 'count incremented');
|
||||
assert.ok(record.lastAttempt <= Date.now(), 'timestamp set');
|
||||
|
||||
recordAuthFailure('10.0.0.2', 'room-x');
|
||||
assert.equal(failedAuthAttempts.get('10.0.0.2:room-x').count, 2, 'count increments on repeat');
|
||||
|
||||
// --- clearRateLimitMaps ---
|
||||
reset();
|
||||
connectionCounts.set('ip1', { count: 1, resetTime: Date.now() + 60000 });
|
||||
eventCounts.set('sock1', { count: 1, resetTime: Date.now() + 10000 });
|
||||
healthCounts.set('ip2', { count: 1, resetTime: Date.now() + 60000 });
|
||||
adminMetricsAuthCounts.set('ip3', { count: 1, resetTime: Date.now() + 60000 });
|
||||
roomListCooldowns.set('sock2', Date.now());
|
||||
clearRateLimitMaps();
|
||||
assert.equal(connectionCounts.size, 0, 'connectionCounts cleared');
|
||||
assert.equal(eventCounts.size, 0, 'eventCounts cleared');
|
||||
assert.equal(healthCounts.size, 0, 'healthCounts cleared');
|
||||
assert.equal(adminMetricsAuthCounts.size, 0, 'adminMetricsAuthCounts cleared');
|
||||
assert.equal(roomListCooldowns.size, 0, 'roomListCooldowns cleared');
|
||||
|
||||
// --- startRateLimitCleanup / stopRateLimitCleanup ---
|
||||
reset();
|
||||
startRateLimitCleanup(mockIo);
|
||||
startRateLimitCleanup(mockIo); // double-start guard
|
||||
stopRateLimitCleanup();
|
||||
assert.ok(true, 'cleanup start/stop does not throw');
|
||||
|
||||
// --- rateLimitDenied reset ---
|
||||
reset();
|
||||
rateLimitDenied.connections = 5;
|
||||
Object.assign(rateLimitDenied, { connections: 0, events: 0, health: 0, adminMetricsAuth: 0, roomList: 0 });
|
||||
assert.equal(rateLimitDenied.connections, 0, 'denial counter resettable');
|
||||
|
||||
console.log('rate-limiter tests passed');
|
||||
@@ -0,0 +1,103 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import http from 'node:http';
|
||||
import { createRequire } from 'node:module';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const require = createRequire(path.join(__dirname, '..', 'server', 'package.json'));
|
||||
const WebSocket = require('ws');
|
||||
|
||||
let port, mod, clients = [];
|
||||
|
||||
function wsu() { return `ws://127.0.0.1:${port}/socket.io/?EIO=4&transport=websocket&version=2.4.0&token=62170b705234c4f4807a9b22420bb93cf1a2aacfa4c5d3b47804482babb8eb50`; }
|
||||
async function c() {
|
||||
const ws = new WebSocket(wsu()); clients.push(ws); ws._m = []; ws.on('message', d => ws._m.push(d.toString()));
|
||||
await new Promise((r, j) => { const t = setTimeout(() => j(Error('connect')), 5e3); ws.on('open', () => { clearTimeout(t); r(); }); });
|
||||
ws.send('40'); const s = Date.now(); while (ws._m.length < 2 && Date.now()-s < 5e3) await new Promise(r => setTimeout(r, 50));
|
||||
if (ws._m.length < 2) throw Error('handshake');
|
||||
ws._m.length = 0; return ws;
|
||||
}
|
||||
function s(ws, evt, d={}) { ws.send(`42${JSON.stringify([evt,d])}`); }
|
||||
function a(ws) { if (ws._m.length) { const r=ws._m.shift(); return r.startsWith('42') ? JSON.parse(r.substring(2)) : r; } return new Promise((resolve, reject) => { const t=setTimeout(()=>reject(Error('timeout')),3e3); const h=(d)=>{clearTimeout(t);ws.removeListener('message',h);const r=d.toString();resolve(r.startsWith('42')?JSON.parse(r.substring(2)):r);};ws.on('message',h);}); }
|
||||
async function w(ws, evt, ms=3000) { const st=Date.now(); while(Date.now()-st<ms) { for(let i=0;i<ws._m.length;i++){const r=ws._m[i];ws._m.splice(i,1);if(r.startsWith('42')){try{const[e]=JSON.parse(r.substring(2));if(e===evt)return e}catch{/* skip */}}} await new Promise(r=>setTimeout(r,50));} throw Error(`wait:${evt}`); }
|
||||
async function j(ws, rid, pid, pw=null) { s(ws,'join_room',{roomId:rid,peerId:pid,password:pw,protocolVersion:'1.0.0'}); assert.equal((await a(ws))[0],'room_data'); }
|
||||
function close() { clients.forEach(w=>{try{w.close()}catch{/* ignore */}}); clients.length=0; }
|
||||
|
||||
try {
|
||||
process.env.ADMIN_METRICS_TOKEN = 'ws-integration-test-32chars-minimum!';
|
||||
mod = await import('../server/index.js');
|
||||
await mod.startServer(0,'127.0.0.1');
|
||||
port = mod.httpServer.address().port;
|
||||
|
||||
// --- Pool: 2 peers in 1 room, test everything ---
|
||||
const rid = 't-'+Date.now();
|
||||
const p1 = await c(), p2 = await c();
|
||||
|
||||
// Room + join
|
||||
await j(p1, rid, 'a'); await j(p2, rid, 'b'); p1._m.length = p2._m.length = 0;
|
||||
|
||||
// Relay
|
||||
s(p1,'play',{currentTime:10}); await w(p2,'play');
|
||||
s(p1,'pause',{currentTime:20}); await w(p2,'pause');
|
||||
s(p1,'seek',{currentTime:30}); await w(p2,'seek');
|
||||
|
||||
// Force Sync
|
||||
s(p1,'force_sync_prepare',{targetTime:0}); await w(p2,'force_sync_prepare');
|
||||
s(p1,'force_sync_ack',{}); await w(p2,'force_sync_ack');
|
||||
s(p1,'force_sync_execute',{}); await w(p2,'force_sync_execute');
|
||||
|
||||
// EVENT_ACK
|
||||
s(p2,'event_ack',{targetId:'a',actionTimestamp:Date.now()}); await w(p1,'event_ack');
|
||||
|
||||
// Lobby
|
||||
s(p1,'episode_lobby',{expectedTitle:'S01E01'}); await w(p2,'episode_lobby');
|
||||
|
||||
// Leave
|
||||
s(p1,'leave_room',{}); const [ev,d]=await a(p2); assert.equal(ev,'peer_status');assert.equal(d.status,'left');
|
||||
|
||||
close();
|
||||
|
||||
// --- Password room ---
|
||||
const prid = 'pw-'+Date.now();
|
||||
const pw1 = await c(); await j(pw1, prid, 'admin', 's3cret');
|
||||
const pw2 = await c();
|
||||
s(pw2,'join_room',{roomId:prid,password:'BAD',peerId:'bad',protocolVersion:'1.0.0'});
|
||||
assert.equal((await a(pw2))[0],'error','wrong pw');
|
||||
const pw3 = await c();
|
||||
s(pw3,'join_room',{roomId:prid,password:'s3cret',peerId:'good',protocolVersion:'1.0.0'});
|
||||
assert.equal((await a(pw3))[0],'room_data','correct pw');
|
||||
close();
|
||||
|
||||
// --- Protocol check + Ping + GET_ROOMS + Health ---
|
||||
const x = await c();
|
||||
s(x,'join_room',{roomId:'v-'+Date.now(),peerId:'old',protocolVersion:'0.0.1'});
|
||||
await w(x,'error'); // version mismatch
|
||||
x._m.length = 0;
|
||||
s(x,'ping',{t:Date.now()}); await w(x,'pong');
|
||||
await j(x,'lst-'+Date.now(),'l1');
|
||||
x._m.length = 0;
|
||||
s(x,'get_rooms',{}); await w(x,'room_list');
|
||||
close();
|
||||
|
||||
// Dedup
|
||||
const did = 'dup-'+Date.now();
|
||||
const d1 = await c(), d2 = await c();
|
||||
await j(d1, did, 'dup'); d1._m.length = 0;
|
||||
s(d2,'join_room',{roomId:did,peerId:'dup',protocolVersion:'1.0.0'});
|
||||
assert.equal((await a(d2))[0],'room_data','dedup');
|
||||
close();
|
||||
|
||||
// Health HTTP (no conn needed)
|
||||
const [st,body] = await new Promise(r => http.get(`http://127.0.0.1:${port}/`, res => {
|
||||
let d=''; res.on('data',c=>d+=c); res.on('end',()=>r([res.statusCode,JSON.parse(d)])); }));
|
||||
assert.equal(st,200); assert.equal(body.status,'online');
|
||||
|
||||
console.log('All 13 WebSocket integration tests passed');
|
||||
} catch(e) {
|
||||
console.error('FAILED:', e.message);
|
||||
process.exitCode=1;
|
||||
} finally {
|
||||
close();
|
||||
if (mod?.stopServerForTests) await mod.stopServerForTests();
|
||||
}
|
||||
@@ -11,21 +11,26 @@ const checks = [
|
||||
['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']],
|
||||
['rate-limiter unit tests', 'node', ['scripts/test-rate-limiter.mjs']],
|
||||
['episode-utils unit tests', 'node', ['scripts/test-episode-utils.mjs']],
|
||||
['server WebSocket integration', 'node', ['scripts/test-server-ws.mjs']],
|
||||
['names generator', 'node', ['scripts/test-names.mjs']],
|
||||
['content video finder', 'node', ['scripts/test-content-video-finder.cjs']],
|
||||
['audio settings', 'node', ['scripts/test-audio-settings.mjs']],
|
||||
['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']],
|
||||
['server syntax rate-limiter', 'node', ['-c', 'server/rate-limiter.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']],
|
||||
['locale coverage', 'node', ['scripts/test-locales.cjs']],
|
||||
['website locale coverage', 'node', ['scripts/test-website-locales.mjs']],
|
||||
['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']]
|
||||
['website build', 'node', ['website/build.cjs']]
|
||||
];
|
||||
|
||||
function runCheck([label, command, args, options = {}]) {
|
||||
|
||||
@@ -71,3 +71,11 @@ npm start
|
||||
- **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.
|
||||
|
||||
## Module Structure
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `index.js` | Express + Socket.IO server: room management, relay loop, graceful shutdown |
|
||||
| `rate-limiter.js` | Connection, event, health, and auth rate limiting with 6 functions + cleanup intervals |
|
||||
| `ops.js` | Health endpoint helpers, metrics payload builder, auth validation |
|
||||
|
||||
+36
-162
@@ -12,6 +12,38 @@ import {
|
||||
isAdminMetricsAuthorized,
|
||||
isAdminMetricsTokenStrong
|
||||
} from './ops.js';
|
||||
import {
|
||||
ROOM_LIST_COOLDOWN_MS,
|
||||
HEALTH_RATE_LIMIT_PER_MINUTE,
|
||||
ADMIN_METRICS_AUTH_RATE_LIMIT_PER_MINUTE,
|
||||
connectionCounts,
|
||||
failedAuthAttempts,
|
||||
eventCounts,
|
||||
healthCounts,
|
||||
adminMetricsAuthCounts,
|
||||
roomListCooldowns,
|
||||
rateLimitDenied,
|
||||
checkAuthRate,
|
||||
recordAuthFailure,
|
||||
checkConnectionRate,
|
||||
checkEventRate,
|
||||
checkHealthRate,
|
||||
checkAdminMetricsAuthRate,
|
||||
startRateLimitCleanup,
|
||||
stopRateLimitCleanup,
|
||||
clearRateLimitMaps
|
||||
} from './rate-limiter.js';
|
||||
|
||||
// Re-export for external consumers (tests, ops)
|
||||
export {
|
||||
HEALTH_RATE_LIMIT_PER_MINUTE,
|
||||
ADMIN_METRICS_AUTH_RATE_LIMIT_PER_MINUTE,
|
||||
healthCounts,
|
||||
adminMetricsAuthCounts,
|
||||
connectionCounts,
|
||||
eventCounts,
|
||||
rateLimitDenied
|
||||
};
|
||||
|
||||
dotenv.config();
|
||||
|
||||
@@ -26,9 +58,6 @@ const MAX_ROOMS = parseInt(process.env.MAX_ROOMS) || 1000;
|
||||
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;
|
||||
|
||||
if (!isAdminMetricsTokenStrong(ADMIN_METRICS_TOKEN)) {
|
||||
@@ -108,6 +137,8 @@ export const io = new Server(httpServer, {
|
||||
allowUpgrades: false
|
||||
});
|
||||
|
||||
startRateLimitCleanup(io);
|
||||
|
||||
/**
|
||||
* In-memory storage
|
||||
*/
|
||||
@@ -126,157 +157,6 @@ function log(type, message, details = '') {
|
||||
console.log(`[${timestamp}] [${type}] ${message}`, details);
|
||||
}
|
||||
|
||||
// Rate Limiting & Security
|
||||
export const connectionCounts = new Map(); // ip -> { count, resetTime }
|
||||
const failedAuthAttempts = new Map(); // Map<IP+RoomID, {count, lastAttempt}>
|
||||
|
||||
function checkAuthRate(ip, roomId) {
|
||||
const key = `${ip}:${roomId}`;
|
||||
const now = Date.now();
|
||||
const record = failedAuthAttempts.get(key) || { count: 0, lastAttempt: 0 };
|
||||
|
||||
// Block for 15 mins if 5 fails in 2 mins
|
||||
if (record.count >= 5 && (now - record.lastAttempt) < 15 * 60 * 1000) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Reset if last attempt was long ago
|
||||
if ((now - record.lastAttempt) > 2 * 60 * 1000) {
|
||||
record.count = 0;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function recordAuthFailure(ip, roomId) {
|
||||
if (failedAuthAttempts.size > 200000) {
|
||||
const now = Date.now();
|
||||
// 1. Clear expired entries (> 15 mins)
|
||||
for (const [key, record] of failedAuthAttempts.entries()) {
|
||||
if (now - record.lastAttempt > 15 * 60 * 1000) {
|
||||
failedAuthAttempts.delete(key);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. If still over 200k, perform LRU-style eviction on the oldest 10,000 entries (first items in Map order)
|
||||
if (failedAuthAttempts.size > 200000) {
|
||||
log('SECURITY', 'failedAuthAttempts size exceeded 200000. Performing insertion-order eviction.');
|
||||
for (const [key] of failedAuthAttempts.entries()) {
|
||||
if (failedAuthAttempts.size <= 190000) {
|
||||
break;
|
||||
}
|
||||
failedAuthAttempts.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
const key = `${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)
|
||||
const authFailureCleanupInterval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, record] of failedAuthAttempts.entries()) {
|
||||
if (now - record.lastAttempt > 15 * 60 * 1000) {
|
||||
failedAuthAttempts.delete(key);
|
||||
}
|
||||
}
|
||||
}, 15 * 60 * 1000);
|
||||
|
||||
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
|
||||
|
||||
// Actual rate-limit denial counters (incremented only when a request is denied)
|
||||
const rateLimitDenied = {
|
||||
connections: 0,
|
||||
events: 0,
|
||||
health: 0,
|
||||
adminMetricsAuth: 0,
|
||||
roomList: 0
|
||||
};
|
||||
|
||||
// Clean up connection counts and event counts to prevent memory leak
|
||||
const rateLimitCleanupInterval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [ip, entry] of connectionCounts.entries()) {
|
||||
if (now > entry.resetTime) {
|
||||
connectionCounts.delete(ip);
|
||||
}
|
||||
}
|
||||
for (const [socketId, entry] of eventCounts.entries()) {
|
||||
if (now > entry.resetTime || !io.sockets.sockets.has(socketId)) {
|
||||
eventCounts.delete(socketId);
|
||||
}
|
||||
}
|
||||
for (const [ip, entry] of healthCounts.entries()) {
|
||||
if (now > entry.resetTime) {
|
||||
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) {
|
||||
const now = Date.now();
|
||||
const entry = connectionCounts.get(ip) || { count: 0, resetTime: now + 60000 };
|
||||
if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + 60000; }
|
||||
entry.count++;
|
||||
connectionCounts.set(ip, entry);
|
||||
if (entry.count <= 10) return true;
|
||||
rateLimitDenied.connections++;
|
||||
return false;
|
||||
}
|
||||
|
||||
function checkEventRate(socketId) {
|
||||
const now = Date.now();
|
||||
const entry = eventCounts.get(socketId) || { count: 0, resetTime: now + 10000 };
|
||||
if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + 10000; }
|
||||
entry.count++;
|
||||
eventCounts.set(socketId, entry);
|
||||
if (entry.count <= 30) return true;
|
||||
rateLimitDenied.events++;
|
||||
return false;
|
||||
}
|
||||
|
||||
function checkHealthRate(ip) {
|
||||
const now = Date.now();
|
||||
const entry = healthCounts.get(ip) || { count: 0, resetTime: now + 60000 };
|
||||
if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + 60000; }
|
||||
entry.count++;
|
||||
healthCounts.set(ip, entry);
|
||||
if (entry.count <= HEALTH_RATE_LIMIT_PER_MINUTE) return true;
|
||||
rateLimitDenied.health++;
|
||||
return false;
|
||||
}
|
||||
|
||||
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);
|
||||
if (entry.count <= ADMIN_METRICS_AUTH_RATE_LIMIT_PER_MINUTE) return true;
|
||||
rateLimitDenied.adminMetricsAuth++;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Central peer teardown. Removes a socket from all room state and notifies
|
||||
* remaining peers. Call this from every disconnect/leave/reaper/dedupe path.
|
||||
@@ -836,20 +716,14 @@ function gracefulShutdown(signal) {
|
||||
}
|
||||
|
||||
export async function stopServerForTests() {
|
||||
clearInterval(authFailureCleanupInterval);
|
||||
clearInterval(rateLimitCleanupInterval);
|
||||
stopRateLimitCleanup();
|
||||
clearInterval(roomCleanupInterval);
|
||||
rooms.clear();
|
||||
socketToRoom.clear();
|
||||
peerToSocket.clear();
|
||||
roomCreationLocks.clear();
|
||||
peerJoinLocks.clear();
|
||||
connectionCounts.clear();
|
||||
failedAuthAttempts.clear();
|
||||
eventCounts.clear();
|
||||
healthCounts.clear();
|
||||
adminMetricsAuthCounts.clear();
|
||||
roomListCooldowns.clear();
|
||||
clearRateLimitMaps();
|
||||
healthResponseCache.clear();
|
||||
io.removeAllListeners();
|
||||
io.disconnectSockets(true);
|
||||
|
||||
Generated
+11
-11
@@ -256,9 +256,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io": {
|
||||
"version": "6.6.8",
|
||||
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.8.tgz",
|
||||
"integrity": "sha512-2agL3ueZhqxoVrfmntO8yuVj+uNSlIOnhykYHk3Cq0ShYPdUjjUiSJrQvXjq01I9jAuI0Zl2YO8Evv5Mqytm5g==",
|
||||
"version": "6.6.9",
|
||||
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.9.tgz",
|
||||
"integrity": "sha512-clKkw4C7nJ22mGgoVcCg6V/W/TxdNyIOTr89k2ONZu81qqkddPFDF0LXcbAwhzPD8DjkiRCjzuiO6Y+fkpD4vg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/cors": "^2.8.12",
|
||||
@@ -270,7 +270,7 @@
|
||||
"cors": "~2.8.5",
|
||||
"debug": "~4.4.1",
|
||||
"engine.io-parser": "~5.2.1",
|
||||
"ws": "~8.20.1"
|
||||
"ws": "~8.21.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.2.0"
|
||||
@@ -941,13 +941,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-adapter": {
|
||||
"version": "2.5.7",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.7.tgz",
|
||||
"integrity": "sha512-e0LyK91f3cUxTmv95/KzoLg47+zF+s/sbxRGDNsyG4dmIP8ZSX8ax6byOxfJXeNNtS/8AZlfD+uP7gBeR7DLlg==",
|
||||
"version": "2.5.8",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.8.tgz",
|
||||
"integrity": "sha512-6Oy52pbg+kvdCVvjcN+FnY7BvxZ7cIHNScbvztT/It5d0vbwoJoVZmF2gjJmnV0/4WlXRfG15zc45ySk9Ah8bw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "~4.4.1",
|
||||
"ws": "~8.20.1"
|
||||
"ws": "~8.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-parser": {
|
||||
@@ -1069,9 +1069,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.20.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
|
||||
"integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
|
||||
"version": "8.21.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
||||
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* KoalaSync Rate Limiter
|
||||
* Connection, event, health, and auth rate limiting for the relay server.
|
||||
*/
|
||||
|
||||
export const ROOM_LIST_COOLDOWN_MS = 10000;
|
||||
export const HEALTH_RATE_LIMIT_PER_MINUTE = 10;
|
||||
export const ADMIN_METRICS_AUTH_RATE_LIMIT_PER_MINUTE = 5;
|
||||
|
||||
export const connectionCounts = new Map(); // ip -> { count, resetTime }
|
||||
export const failedAuthAttempts = new Map(); // Map<IP+RoomID, {count, lastAttempt}>
|
||||
export const eventCounts = new Map(); // socketId -> { count, resetTime }
|
||||
export const healthCounts = new Map(); // ip -> { count, resetTime }
|
||||
export const adminMetricsAuthCounts = new Map(); // ip -> { count, resetTime }
|
||||
export const roomListCooldowns = new Map(); // socketId -> last allowed timestamp
|
||||
|
||||
export const rateLimitDenied = {
|
||||
connections: 0,
|
||||
events: 0,
|
||||
health: 0,
|
||||
adminMetricsAuth: 0,
|
||||
roomList: 0
|
||||
};
|
||||
|
||||
let authCleanupId = null;
|
||||
let rateLimitCleanupId = null;
|
||||
|
||||
export function checkAuthRate(ip, roomId) {
|
||||
const key = `${ip}:${roomId}`;
|
||||
const now = Date.now();
|
||||
const record = failedAuthAttempts.get(key) || { count: 0, lastAttempt: 0 };
|
||||
|
||||
if (record.count >= 5 && (now - record.lastAttempt) < 15 * 60 * 1000) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((now - record.lastAttempt) > 2 * 60 * 1000) {
|
||||
record.count = 0;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function recordAuthFailure(ip, roomId) {
|
||||
if (failedAuthAttempts.size > 200000) {
|
||||
const now = Date.now();
|
||||
for (const [key, record] of failedAuthAttempts.entries()) {
|
||||
if (now - record.lastAttempt > 15 * 60 * 1000) {
|
||||
failedAuthAttempts.delete(key);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (failedAuthAttempts.size > 200000) {
|
||||
console.warn('SECURITY: failedAuthAttempts size exceeded 200000. Performing insertion-order eviction.');
|
||||
for (const [key] of failedAuthAttempts.entries()) {
|
||||
if (failedAuthAttempts.size <= 190000) {
|
||||
break;
|
||||
}
|
||||
failedAuthAttempts.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
const key = `${ip}:${roomId}`;
|
||||
const record = failedAuthAttempts.get(key) || { count: 0, lastAttempt: 0 };
|
||||
record.count++;
|
||||
record.lastAttempt = Date.now();
|
||||
failedAuthAttempts.delete(key);
|
||||
failedAuthAttempts.set(key, record);
|
||||
}
|
||||
|
||||
export function checkConnectionRate(ip) {
|
||||
const now = Date.now();
|
||||
const entry = connectionCounts.get(ip) || { count: 0, resetTime: now + 60000 };
|
||||
if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + 60000; }
|
||||
entry.count++;
|
||||
connectionCounts.set(ip, entry);
|
||||
if (entry.count <= 10) return true;
|
||||
rateLimitDenied.connections++;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function checkEventRate(socketId) {
|
||||
const now = Date.now();
|
||||
const entry = eventCounts.get(socketId) || { count: 0, resetTime: now + 10000 };
|
||||
if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + 10000; }
|
||||
entry.count++;
|
||||
eventCounts.set(socketId, entry);
|
||||
if (entry.count <= 30) return true;
|
||||
rateLimitDenied.events++;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function checkHealthRate(ip) {
|
||||
const now = Date.now();
|
||||
const entry = healthCounts.get(ip) || { count: 0, resetTime: now + 60000 };
|
||||
if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + 60000; }
|
||||
entry.count++;
|
||||
healthCounts.set(ip, entry);
|
||||
if (entry.count <= HEALTH_RATE_LIMIT_PER_MINUTE) return true;
|
||||
rateLimitDenied.health++;
|
||||
return false;
|
||||
}
|
||||
|
||||
export 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);
|
||||
if (entry.count <= ADMIN_METRICS_AUTH_RATE_LIMIT_PER_MINUTE) return true;
|
||||
rateLimitDenied.adminMetricsAuth++;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function startRateLimitCleanup(io) {
|
||||
if (authCleanupId !== null || rateLimitCleanupId !== null) return; // guard double-start
|
||||
// Clean up old auth failure records (every 15 minutes)
|
||||
authCleanupId = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, record] of failedAuthAttempts.entries()) {
|
||||
if (now - record.lastAttempt > 15 * 60 * 1000) {
|
||||
failedAuthAttempts.delete(key);
|
||||
}
|
||||
}
|
||||
}, 15 * 60 * 1000);
|
||||
|
||||
// Clean up rate-limit maps to prevent memory leaks (every 60 seconds)
|
||||
rateLimitCleanupId = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [ip, entry] of connectionCounts.entries()) {
|
||||
if (now > entry.resetTime) connectionCounts.delete(ip);
|
||||
}
|
||||
for (const [socketId, entry] of eventCounts.entries()) {
|
||||
if (now > entry.resetTime || !io.sockets.sockets.has(socketId)) {
|
||||
eventCounts.delete(socketId);
|
||||
}
|
||||
}
|
||||
for (const [ip, entry] of healthCounts.entries()) {
|
||||
if (now > entry.resetTime) 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);
|
||||
}
|
||||
|
||||
export function stopRateLimitCleanup() {
|
||||
if (authCleanupId) { clearInterval(authCleanupId); authCleanupId = null; }
|
||||
if (rateLimitCleanupId) { clearInterval(rateLimitCleanupId); rateLimitCleanupId = null; }
|
||||
}
|
||||
|
||||
export function clearRateLimitMaps() {
|
||||
connectionCounts.clear();
|
||||
failedAuthAttempts.clear();
|
||||
eventCounts.clear();
|
||||
healthCounts.clear();
|
||||
adminMetricsAuthCounts.clear();
|
||||
roomListCooldowns.clear();
|
||||
}
|
||||
+14
-17
@@ -34,6 +34,17 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
revealElements.forEach(el => revealObserver.observe(el));
|
||||
|
||||
// Auto-update URL hash as user scrolls through sections
|
||||
// (preserves position across language switches)
|
||||
const sectionObserver = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
history.replaceState(null, null, '#' + entry.target.id);
|
||||
}
|
||||
});
|
||||
}, { threshold: 0.3 });
|
||||
document.querySelectorAll('section[id], header[id]').forEach(el => sectionObserver.observe(el));
|
||||
|
||||
// Navbar scroll effect
|
||||
const nav = document.querySelector('nav');
|
||||
window.addEventListener('scroll', () => {
|
||||
@@ -46,20 +57,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
});
|
||||
|
||||
// Smooth scroll for anchors
|
||||
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
|
||||
anchor.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
const target = document.querySelector(this.getAttribute('href'));
|
||||
if (target) {
|
||||
target.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start'
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Invite Detection & Bridge
|
||||
const checkInvite = () => {
|
||||
const isJoinPage = window.location.pathname.includes('join');
|
||||
@@ -508,7 +505,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
target = hasHtml ? 'imprint.html' : 'imprint';
|
||||
if (path.includes('/de/')) target = hasHtml ? '../imprint.html' : '../imprint';
|
||||
}
|
||||
window.location.href = target;
|
||||
window.location.href = target + window.location.hash;
|
||||
return;
|
||||
} else if (isLegalPrivacy) {
|
||||
let target;
|
||||
@@ -520,7 +517,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
target = hasHtml ? 'privacy.html' : 'privacy';
|
||||
if (path.includes('/de/')) target = hasHtml ? '../privacy.html' : '../privacy';
|
||||
}
|
||||
window.location.href = target;
|
||||
window.location.href = target + window.location.hash;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -549,7 +546,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
}
|
||||
|
||||
window.location.href = targetPath;
|
||||
window.location.href = targetPath + window.location.hash;
|
||||
} else {
|
||||
// Dynamic page: Toggle classes and update elements dynamically without navigating away
|
||||
const html = document.documentElement;
|
||||
|
||||
+17
-17
@@ -3,31 +3,31 @@
|
||||
xmlns:xhtml="http://www.w3.org/1999/xhtml">
|
||||
<url>
|
||||
<loc>https://sync.koalastuff.net/imprint</loc>
|
||||
<lastmod>2026-06-09</lastmod>
|
||||
<lastmod>2026-06-16</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.3</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://sync.koalastuff.net/privacy</loc>
|
||||
<lastmod>2026-06-09</lastmod>
|
||||
<lastmod>2026-06-16</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.3</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://sync.koalastuff.net/de/impressum</loc>
|
||||
<lastmod>2026-06-09</lastmod>
|
||||
<lastmod>2026-06-16</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.3</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://sync.koalastuff.net/de/datenschutz</loc>
|
||||
<lastmod>2026-06-09</lastmod>
|
||||
<lastmod>2026-06-16</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.3</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://sync.koalastuff.net/</loc>
|
||||
<lastmod>2026-06-09</lastmod>
|
||||
<lastmod>2026-06-16</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>1.0</priority>
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/"/>
|
||||
@@ -47,7 +47,7 @@
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://sync.koalastuff.net/de/</loc>
|
||||
<lastmod>2026-06-09</lastmod>
|
||||
<lastmod>2026-06-16</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/"/>
|
||||
@@ -67,7 +67,7 @@
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://sync.koalastuff.net/fr/</loc>
|
||||
<lastmod>2026-06-09</lastmod>
|
||||
<lastmod>2026-06-16</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/"/>
|
||||
@@ -87,7 +87,7 @@
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://sync.koalastuff.net/es/</loc>
|
||||
<lastmod>2026-06-09</lastmod>
|
||||
<lastmod>2026-06-16</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/"/>
|
||||
@@ -107,7 +107,7 @@
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://sync.koalastuff.net/pt-BR/</loc>
|
||||
<lastmod>2026-06-09</lastmod>
|
||||
<lastmod>2026-06-16</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/"/>
|
||||
@@ -127,7 +127,7 @@
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://sync.koalastuff.net/ru/</loc>
|
||||
<lastmod>2026-06-09</lastmod>
|
||||
<lastmod>2026-06-16</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/"/>
|
||||
@@ -147,7 +147,7 @@
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://sync.koalastuff.net/it/</loc>
|
||||
<lastmod>2026-06-09</lastmod>
|
||||
<lastmod>2026-06-16</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/"/>
|
||||
@@ -167,7 +167,7 @@
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://sync.koalastuff.net/pl/</loc>
|
||||
<lastmod>2026-06-09</lastmod>
|
||||
<lastmod>2026-06-16</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/"/>
|
||||
@@ -187,7 +187,7 @@
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://sync.koalastuff.net/tr/</loc>
|
||||
<lastmod>2026-06-09</lastmod>
|
||||
<lastmod>2026-06-16</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/"/>
|
||||
@@ -207,7 +207,7 @@
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://sync.koalastuff.net/nl/</loc>
|
||||
<lastmod>2026-06-09</lastmod>
|
||||
<lastmod>2026-06-16</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/"/>
|
||||
@@ -227,7 +227,7 @@
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://sync.koalastuff.net/ja/</loc>
|
||||
<lastmod>2026-06-09</lastmod>
|
||||
<lastmod>2026-06-16</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/"/>
|
||||
@@ -247,7 +247,7 @@
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://sync.koalastuff.net/ko/</loc>
|
||||
<lastmod>2026-06-09</lastmod>
|
||||
<lastmod>2026-06-16</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/"/>
|
||||
@@ -267,7 +267,7 @@
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://sync.koalastuff.net/pt/</loc>
|
||||
<lastmod>2026-06-09</lastmod>
|
||||
<lastmod>2026-06-16</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/"/>
|
||||
|
||||
+13
-10
@@ -61,6 +61,10 @@
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Twemoji Country Flags', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
|
||||
background-color: var(--bg);
|
||||
@@ -2007,12 +2011,14 @@ html:not(.lang-de) [lang="de"] {
|
||||
}
|
||||
|
||||
.lang-select-container .chevron-icon {
|
||||
position: absolute;
|
||||
right: 14px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
flex-shrink: 0;
|
||||
pointer-events: none;
|
||||
opacity: 0.6;
|
||||
margin-left: -2px;
|
||||
}
|
||||
|
||||
.lang-select-container:hover .chevron-icon {
|
||||
@@ -2031,21 +2037,16 @@ html:not(.lang-de) [lang="de"] {
|
||||
font-weight: 600;
|
||||
color: currentColor;
|
||||
cursor: pointer;
|
||||
padding: 0 12px 0 0;
|
||||
padding: 0 28px 0 0;
|
||||
margin: 0;
|
||||
width: auto;
|
||||
max-width: 150px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 110px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@supports (appearance: base-select) {
|
||||
.lang-dropdown,
|
||||
.lang-dropdown::picker(select) {
|
||||
appearance: base-select;
|
||||
}
|
||||
.lang-dropdown::picker(select) {
|
||||
background-color: #0f172a;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 8px;
|
||||
@@ -2748,7 +2749,9 @@ html:not(.lang-de) [lang="de"] {
|
||||
}
|
||||
.feature-card,
|
||||
.use-case-card,
|
||||
.compat-logo {
|
||||
.compat-logo,
|
||||
section[id],
|
||||
header[id] {
|
||||
scroll-margin-top: 100px;
|
||||
}
|
||||
|
||||
|
||||
@@ -102,7 +102,7 @@
|
||||
"priceCurrency": "EUR"
|
||||
},
|
||||
"description": "Watch Netflix, YouTube, Twitch, Jellyfin, Emby and almost any HTML5 video in perfect sync with friends. Open source, privacy-first, free.",
|
||||
"softwareVersion": "2.3.1",
|
||||
"softwareVersion": "2.3.2",
|
||||
"license": "https://opensource.org/licenses/MIT",
|
||||
"sameAs": "https://github.com/Shik3i/KoalaSync",
|
||||
"image": "https://sync.koalastuff.net/assets/NewLogoIcon.webp",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"version": "2.3.1",
|
||||
"date": "2026-06-15T11:14:22Z"
|
||||
"version": "2.3.2",
|
||||
"date": "2026-06-16T02:29:41Z"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user