From 34f0c2b265f562cf5344fc5b4f8875f8b6519540 Mon Sep 17 00:00:00 2001 From: Timo <6156589+Shik3i@users.noreply.github.com> Date: Wed, 10 Jun 2026 00:56:29 +0200 Subject: [PATCH] =?UTF-8?q?chore(release):=20v2.2.3=20=E2=80=94=20artifact?= =?UTF-8?q?=20attestations,=20bugfixes,=20rate=20limit=20metrics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/release.yml | 17 +++++++++++ README.md | 28 +++++++++++++---- docs/CHANGELOG.md | 25 +++++++++++++++ examples/json_exporter.example.yml | 49 ++++++++++++++++++++++-------- extension/background.js | 4 +-- extension/content.js | 5 +++ package-lock.json | 7 +++-- scripts/test-server-ops.mjs | 9 ++++-- scripts/test-server-routes.mjs | 9 ++++-- server/Dockerfile | 3 ++ server/index.js | 33 +++++++++++++++----- server/ops.js | 26 +++++++++++----- server/package.json | 1 + shared/names.js | 6 ++-- website/locales/pt-BR.json | 1 - 15 files changed, 176 insertions(+), 47 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6282e1b..a066407 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,6 +11,8 @@ jobs: permissions: contents: write packages: write + id-token: write + attestations: write steps: - name: Checkout code uses: actions/checkout@v4 @@ -35,6 +37,7 @@ jobs: type=ref,event=tag - name: Build and push Docker image + id: build uses: docker/build-push-action@v5 with: context: . @@ -44,10 +47,19 @@ jobs: tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + - name: Generate artifact attestation + uses: actions/attest@v4 + with: + subject-name: ghcr.io/${{ github.repository }} + subject-digest: ${{ steps.build.outputs.digest }} + push-to-registry: true + release-extension: runs-on: ubuntu-latest permissions: contents: write + id-token: write + attestations: write steps: - name: Checkout code uses: actions/checkout@v4 @@ -103,6 +115,11 @@ jobs: npm install npm run build:extension + - name: Generate artifact attestation for extensions + uses: actions/attest@v4 + with: + subject-path: dist/koalasync-*.zip + - name: Build Website run: node website/build.js diff --git a/README.md b/README.md index e383ab5..508dcae 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,16 @@ The easiest and safest way to install KoalaSync is directly through the official 2. **Invite Friends:** Share the auto-copied invite link. Once they click it, they automatically join. 3. **Pick a Video:** Navigate to the Sync Tab, select the tab playing your video, and grab some popcorn! 🍿 +--- + +### 🌐 Localization & Translations + +Both the official KoalaSync website and the **v2.0 Browser Extension** feature full dynamic localization: +- **Available Languages**: Support is included for 13 languages: English (`en`), German (`de`), French (`fr`), Spanish (`es`), Portuguese (Brazil) (`pt-BR`), Russian (`ru`), Italian (`it`), Polish (`pl`), Turkish (`tr`), Dutch (`nl`), Japanese (`ja`), Korean (`ko`), and European Portuguese (`pt`). +- **Real-Time Extension Localization**: Inside the extension Settings panel, users can swap languages instantly. The entire interface, notifications, Empty States, and onboarding guides re-translate dynamically in real-time. +- **Contributing**: We welcome community translations for both the website and the extension! Please refer directly to the [TRANSLATION.md](website/TRANSLATION.md) guide for step-by-step instructions on how to audit, refine, or add new languages. + + --- ### 🛠️ For Developers & Self-Hosters @@ -93,15 +103,21 @@ 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.2.2+) -### 🌐 Localization & Translations +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. -Both the official KoalaSync website and the **v2.0 Browser Extension** feature full dynamic localization: -- **Available Languages**: Support is included for 13 languages: English (`en`), German (`de`), French (`fr`), Spanish (`es`), Portuguese (Brazil) (`pt-BR`), Russian (`ru`), Italian (`it`), Polish (`pl`), Turkish (`tr`), Dutch (`nl`), Japanese (`ja`), Korean (`ko`), and European Portuguese (`pt`). -- **Real-Time Extension Localization**: Inside the extension Settings panel, users can swap languages instantly. The entire interface, notifications, Empty States, and onboarding guides re-translate dynamically in real-time. -- **Contributing**: We welcome community translations for both the website and the extension! Please refer directly to the [TRANSLATION.md](website/TRANSLATION.md) guide for step-by-step instructions on how to audit, refine, or add new languages. +**Verify a Docker image:** +```bash +gh attestation verify oci://ghcr.io/shik3i/koalasync:latest \ + -R Shik3i/KoalaSync +``` +**Verify an extension binary:** +```bash +gh attestation verify dist/koalasync-chrome.zip \ + -R Shik3i/KoalaSync +``` --- diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index d70944b..2b8a377 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -4,6 +4,31 @@ All notable changes to the KoalaSync browser extension and relay server. --- +## [v2.2.3] — 2026-06-10 + +### Added +- **Artifact Attestations (Supply Chain Security)**: All release artifacts (Docker images, extension ZIPs) are now published with signed [SLSA provenance attestations](https://docs.github.com/en/actions/how-tos/secure-your-work/use-artifact-attestations) via `actions/attest@v4`. Anyone can verify that an artifact was built from this repository using `gh attestation verify`. +- **Admin health: `rateLimits.denied` counters**: New rolling counters track actual rate-limit denials (429 responses), separate from `rateLimits.trackedClients` which reports unique IPs in the tracking window. +- **Docker HEALTHCHECK**: Container health is now checked every 30s via `GET /health`. +- **`npm start` script**: Server can now be started with `npm start`. + +### Fixed +- **Server: `activeLobby` no longer silently overwritten**: If a second peer sends `EPISODE_LOBBY` while a lobby is already active, the request is now ignored instead of destroying the first peer's lobby. +- **CORS log sanitization**: Rejected origin headers are sanitized (`\r\n` stripped) to prevent log injection. +- **Extension: pagehide resource leak**: `keepAlivePort`, `lobbyPollTimer`, heartbeats, and `MutationObserver` are now properly cleaned up when a tab is hidden or enters bfcache. +- **Extension: unhandled storage rejections**: `chrome.storage.session.set()` calls in the disconnect handler now have `.catch(() => {})`. +- **`'Pixel'` duplicate in name generator**: Second occurrence replaced with `'Nitro'` for better name diversity. +- **`'opposum'` typo**: Corrected to `'opossum'` in the emoji map and added `'Opossum'` to `USERNAME_NOUNS`. +- **Test reliability**: `test-server-routes.mjs` now sets `ADMIN_METRICS_TOKEN` before importing the server module, fixing standalone test execution. +- **`MAX_PEERS_PER_ROOM` konsistent**: `.env` auf 25 gesetzt (wie `.env.example`). +- **pt-BR.json duplicate removed**: Duplicate `FOOTER_DISCLAIMER` key removed from website locale. + +### Changed +- **Admin health: `rateLimitEntries` renamed to `rateLimits.trackedClients`**: The field now accurately describes that it tracks unique clients in the rate-limit window, not denial counts. Update your json_exporter/Grafana config accordingly. +- **README restructured**: Sections reordered by progressive technical depth. New "Supply Chain Security" subsection under "For Developers & Self-Hosters" with verification commands. + +--- + ## [v2.2.2] — 2026-06-09 ### Added diff --git a/examples/json_exporter.example.yml b/examples/json_exporter.example.yml index c89ed0b..fe26e30 100644 --- a/examples/json_exporter.example.yml +++ b/examples/json_exporter.example.yml @@ -54,26 +54,51 @@ modules: path: '{.memory.heapTotal}' help: "V8 engine heap total in bytes" + # Rate limiter tracking — unique clients currently in each tracking window + # (not rate-limit denials; these include legitimate traffic too) + - name: koalasync_rate_limit_connections - path: '{.rateLimitEntries.connections}' - help: "Number of entries tracked in the connection rate limiter" + path: '{.rateLimits.trackedClients.connections}' + help: "Unique clients tracked in the connection rate limiter window" - name: koalasync_rate_limit_events - path: '{.rateLimitEntries.events}' - help: "Number of entries in the event rate limiter" + path: '{.rateLimits.trackedClients.events}' + help: "Unique sockets tracked in the event rate limiter window" - name: koalasync_rate_limit_health - path: '{.rateLimitEntries.health}' - help: "Number of entries in the health rate limiter" + path: '{.rateLimits.trackedClients.health}' + help: "Unique IPs tracked in the health endpoint rate limiter window" - name: koalasync_rate_limit_admin_metrics_auth - path: '{.rateLimitEntries.adminMetricsAuth}' - help: "Number of entries in the admin metrics auth rate limiter" + path: '{.rateLimits.trackedClients.adminMetricsAuth}' + help: "Unique IPs tracked in the admin metrics auth rate limiter window" - name: koalasync_rate_limit_auth_failures - path: '{.rateLimitEntries.authFailures}' - help: "Number of entries in the authentication failures cache" + path: '{.rateLimits.trackedClients.authFailures}' + help: "Unique IPs tracked in the authentication failures cache" - name: koalasync_rate_limit_room_list - path: '{.rateLimitEntries.roomList}' - help: "Number of entries in the room list cooldown cache" + path: '{.rateLimits.trackedClients.roomList}' + help: "Unique sockets in the room list cooldown cache" + + # Actual rate-limit denials — incremented only when a 429 is served + + - name: koalasync_rate_limit_denied_connections + path: '{.rateLimits.denied.connections}' + help: "Total connection attempts denied by rate limiter" + + - name: koalasync_rate_limit_denied_events + path: '{.rateLimits.denied.events}' + help: "Total socket events denied by rate limiter" + + - name: koalasync_rate_limit_denied_health + path: '{.rateLimits.denied.health}' + help: "Total health endpoint requests denied by rate limiter" + + - name: koalasync_rate_limit_denied_admin_metrics_auth + path: '{.rateLimits.denied.adminMetricsAuth}' + help: "Total admin metrics auth attempts denied by rate limiter" + + - name: koalasync_rate_limit_denied_room_list + path: '{.rateLimits.denied.roomList}' + help: "Total room list refresh requests denied by rate limiter" diff --git a/extension/background.js b/extension/background.js index e03b585..e6a04e2 100644 --- a/extension/background.js +++ b/extension/background.js @@ -544,12 +544,12 @@ async function connect() { isForceSyncInitiator: false, forceSyncAcks: [], forceSyncDeadline: null - }); + }).catch(() => {}); if (currentRoom) { currentRoom.peers = []; - if (storageInitialized) chrome.storage.session.set({ currentRoom }); + if (storageInitialized) chrome.storage.session.set({ currentRoom }).catch(() => {}); chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {}); } broadcastConnectionStatus('disconnected'); diff --git a/extension/content.js b/extension/content.js index 0fb6e5b..172b0b6 100644 --- a/extension/content.js +++ b/extension/content.js @@ -837,6 +837,11 @@ window.addEventListener('pagehide', () => { pageVisible = false; closeAudioContext(); + if (keepAlivePort) { try { keepAlivePort.disconnect(); } catch (_e) { /* ignore */ } keepAlivePort = null; } + if (lobbyPollTimer) { clearInterval(lobbyPollTimer); lobbyPollTimer = null; } + if (heartbeatTimeout) { clearTimeout(heartbeatTimeout); heartbeatTimeout = null; } + if (proactiveHeartbeatTimeout) { clearTimeout(proactiveHeartbeatTimeout); proactiveHeartbeatTimeout = null; } + observer.disconnect(); }); window.addEventListener('pageshow', (event) => { // event.persisted is true ONLY when restored from bfcache, not on initial load diff --git a/package-lock.json b/package-lock.json index a48c28e..6a3f21f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "koalasync", - "version": "2.1.3", + "version": "2.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "koalasync", - "version": "2.1.3", + "version": "2.2.2", "devDependencies": { "archiver": "^7.0.1", "esbuild": "^0.28.0", @@ -1240,6 +1240,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1373,6 +1374,7 @@ "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "peerDependencies": { "bare-abort-controller": "*" }, @@ -1895,6 +1897,7 @@ "integrity": "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", diff --git a/scripts/test-server-ops.mjs b/scripts/test-server-ops.mjs index c0d27fb..5506344 100644 --- a/scripts/test-server-ops.mjs +++ b/scripts/test-server-ops.mjs @@ -77,9 +77,12 @@ assert.equal(adminHealth.avgPeersPerRoom, 2.5, 'admin metrics should include ave 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' + adminHealth.rateLimits, + { + trackedClients: { connections: 1, events: 2, health: 3, adminMetricsAuth: 4, authFailures: 5, roomList: 6 }, + denied: { connections: 0, events: 0, health: 0, adminMetricsAuth: 0, roomList: 0 } + }, + 'admin metrics should expose rate-limit tracking and denial counts' ); console.log('server ops tests passed'); diff --git a/scripts/test-server-routes.mjs b/scripts/test-server-routes.mjs index 510e530..44722d8 100644 --- a/scripts/test-server-routes.mjs +++ b/scripts/test-server-routes.mjs @@ -1,5 +1,8 @@ import assert from 'node:assert/strict'; -import { + +// Must set env before dynamic import — ESM hoists static imports +process.env.ADMIN_METRICS_TOKEN = process.env.ADMIN_METRICS_TOKEN || 'test-admin-token-with-more-than-32-chars'; +const { ADMIN_METRICS_AUTH_RATE_LIMIT_PER_MINUTE, HEALTH_RATE_LIMIT_PER_MINUTE, healthCounts, @@ -9,9 +12,9 @@ import { rooms, startServer, stopServerForTests -} from '../server/index.js'; +} = await import('../server/index.js'); -const adminToken = process.env.ADMIN_METRICS_TOKEN || 'test-admin-token-with-more-than-32-chars'; +const adminToken = process.env.ADMIN_METRICS_TOKEN; const baseHeaders = { 'x-forwarded-for': '203.0.113.10' }; function url(path) { diff --git a/server/Dockerfile b/server/Dockerfile index f0f2c36..14610fe 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -10,4 +10,7 @@ RUN cd server && npm install --production COPY server/ ./server/ WORKDIR /app/server +EXPOSE 3000 +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1 CMD ["node", "index.js"] diff --git a/server/index.js b/server/index.js index 87f4473..bb73704 100644 --- a/server/index.js +++ b/server/index.js @@ -82,7 +82,8 @@ app.get('/health', (req, res) => { adminMetricsAuth: adminMetricsAuthCounts.size, authFailures: failedAuthAttempts.size, roomList: roomListCooldowns.size - } + }, + rateLimitDenied }) )); }); @@ -96,7 +97,7 @@ export const io = new Server(httpServer, { if (!origin || origin === 'https://sync.koalastuff.net' || origin.startsWith('chrome-extension://') || origin.startsWith('moz-extension://')) { callback(null, true); } else { - log('CORS', `Rejected origin: ${origin}`); + log('CORS', `Rejected origin: ${(origin || '').replace(/[\r\n]/g, '')}`); callback(new Error('Not allowed by CORS')); } }, @@ -192,6 +193,15 @@ 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(); @@ -228,7 +238,9 @@ function checkConnectionRate(ip) { if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + 60000; } entry.count++; connectionCounts.set(ip, entry); - return entry.count <= 10; + if (entry.count <= 10) return true; + rateLimitDenied.connections++; + return false; } function checkEventRate(socketId) { @@ -237,7 +249,9 @@ function checkEventRate(socketId) { if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + 10000; } entry.count++; eventCounts.set(socketId, entry); - return entry.count <= 30; + if (entry.count <= 30) return true; + rateLimitDenied.events++; + return false; } function checkHealthRate(ip) { @@ -246,7 +260,9 @@ function checkHealthRate(ip) { if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + 60000; } entry.count++; healthCounts.set(ip, entry); - return entry.count <= HEALTH_RATE_LIMIT_PER_MINUTE; + if (entry.count <= HEALTH_RATE_LIMIT_PER_MINUTE) return true; + rateLimitDenied.health++; + return false; } function checkAdminMetricsAuthRate(ip) { @@ -255,7 +271,9 @@ function checkAdminMetricsAuthRate(ip) { 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; + if (entry.count <= ADMIN_METRICS_AUTH_RATE_LIMIT_PER_MINUTE) return true; + rateLimitDenied.adminMetricsAuth++; + return false; } /** @@ -585,7 +603,7 @@ io.on('connection', (socket) => { socket.to(mapping.roomId).emit(eventName, relayPayload); // --- Side-effects: Server-side Episode Lobby Tracking --- - if (eventName === EVENTS.EPISODE_LOBBY && relayPayload.expectedTitle) { + if (eventName === EVENTS.EPISODE_LOBBY && relayPayload.expectedTitle && !room.activeLobby) { room.activeLobby = { expectedTitle: relayPayload.expectedTitle, initiatorPeerId: mapping.peerId, @@ -613,6 +631,7 @@ io.on('connection', (socket) => { return; } if (!checkCooldown(roomListCooldowns, socket.id, ROOM_LIST_COOLDOWN_MS)) { + rateLimitDenied.roomList++; socket.emit(EVENTS.ERROR, { message: 'Room list refresh is rate limited. Try again in a few seconds.' }); return; } diff --git a/server/ops.js b/server/ops.js index c38cbb0..4445819 100644 --- a/server/ops.js +++ b/server/ops.js @@ -48,7 +48,8 @@ export function buildHealthPayload({ now = Date.now(), uptime = 0, memoryUsage = () => process.memoryUsage(), - rateLimitSizes = {} + rateLimitSizes = {}, + rateLimitDenied = {} }) { const payload = { status: 'ok', @@ -75,13 +76,22 @@ export function buildHealthPayload({ 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 + rateLimits: { + trackedClients: { + connections: rateLimitSizes.connections || 0, + events: rateLimitSizes.events || 0, + health: rateLimitSizes.health || 0, + adminMetricsAuth: rateLimitSizes.adminMetricsAuth || 0, + authFailures: rateLimitSizes.authFailures || 0, + roomList: rateLimitSizes.roomList || 0 + }, + denied: { + connections: rateLimitDenied.connections || 0, + events: rateLimitDenied.events || 0, + health: rateLimitDenied.health || 0, + adminMetricsAuth: rateLimitDenied.adminMetricsAuth || 0, + roomList: rateLimitDenied.roomList || 0 + } }, memory: { rss: mem.rss, diff --git a/server/package.json b/server/package.json index aff0288..92cedd2 100644 --- a/server/package.json +++ b/server/package.json @@ -4,6 +4,7 @@ "description": "", "main": "index.js", "scripts": { + "start": "node index.js", "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": [], diff --git a/shared/names.js b/shared/names.js index e718f85..d6eccb0 100644 --- a/shared/names.js +++ b/shared/names.js @@ -21,7 +21,7 @@ export const USERNAME_ADJECTIVES = [ 'Cosmic', 'Neon', 'Shadow', 'Crystal', 'Thunder', 'Silent', 'Golden', 'Fierce', 'Noble', 'Mystic', 'Frozen', 'Blazing', 'Sapphire', 'Iron', 'Crimson', 'Turbo', 'Zen', 'Pixel', 'Cyber', 'Solar', 'Lunar', 'Astro', 'Hyper', - 'Steel', 'Rogue', 'Alpha', 'Omega', 'Royal', 'Pixel', 'Warp', 'Frost', + 'Steel', 'Rogue', 'Alpha', 'Omega', 'Royal', 'Nitro', 'Warp', 'Frost', ]; export const USERNAME_NOUNS = [ @@ -39,7 +39,7 @@ export const USERNAME_NOUNS = [ 'Mouse', 'Pig', 'Boar', 'Polar', 'Orangutan', 'Mammoth', 'Crow', 'Duck', 'Swan', 'Penguin', 'Parrot', 'Peacock', 'Dove', 'Dodo', 'Turkey', 'Flamingo', 'Chicken', 'Rooster', 'Goose', - 'Dolphin', 'Whale', 'Crab', 'Lobster', 'Octopus', 'Squid', + 'Dolphin', 'Whale', 'Crab', 'Lobster', 'Octopus', 'Opossum', 'Squid', 'Jellyfish', 'Turtle', 'Crocodile', 'Lizard', 'Snake', 'Frog', 'Toad', 'Gecko', 'Bee', 'Ant', 'Spider', 'Scorpion', 'Butterfly', 'Ladybug', @@ -102,7 +102,7 @@ export const ANIMAL_EMOJI_MAP = { 'mammoth': '🦣', 'meerkat': '🦦', 'octopus': '🐙', - 'opposum': '🐭', + 'opossum': '🐭', 'ostrich': '🐦', 'panther': '🐆', 'pelican': '🦩', diff --git a/website/locales/pt-BR.json b/website/locales/pt-BR.json index f1c9e53..3771461 100644 --- a/website/locales/pt-BR.json +++ b/website/locales/pt-BR.json @@ -175,6 +175,5 @@ "FOOTER_LEGAL_LINK": "imprint", "FOOTER_PRIVACY_LINK": "privacy", "FOOTER_DISCLAIMER": "KoalaSync não é afiliado, endossado ou associado à Netflix, Disney+, Amazon, YouTube, Twitch ou qualquer outra plataforma de streaming. Todas as marcas registradas são propriedade de seus respectivos titulares.", - "FOOTER_DISCLAIMER": "KoalaSync não é afiliado, endossado ou associado à Netflix, Disney+, Amazon, YouTube, Twitch ou qualquer outra plataforma de streaming. Todas as marcas registradas são propriedade de seus respectivos titulares.", "FOOTER_SUPPORT": "Me pague um café no Ko-Fi" }