Compare commits

...
Author SHA1 Message Date
601553c582 chore(i18n): update translations from Crowdin (#4862)
Co-authored-by: Owncast <owncast@owncast.online>
2026-04-01 11:39:48 -07:00
renovate[bot]andGitHub 8b5fe84e62 chore(deps): update peter-evans/create-or-update-comment digest to 5723223 2026-04-01 09:42:10 +00:00
33efafecd5 feat: implement custom accessible tooltips with keyboard shortcuts for the video player (#4787)
* feat(video-player): add keyboard shortcut tooltips to control bar

- Display keyboard shortcuts (e.g., "Play (Space)") in button tooltips.
- Inject shortcut suffixes into Video.js language strings dynamically.
- Add CSS to style `.vjs-control-text` as a floating tooltip on hover.
- Enable `noUITitleAttributes` to disable native browser tooltips and prevent duplication.

* fix(player-control-tooltips): Prevent player control tooltips from displaying incorrectly in mobile

- Implement mobile touch detection to set 'player-data-is-touch' attribute on body, preventing sticky hover states.

* chore(linting): fix linting and styling errors in VideoJS

* Improve hover detection for tooltips

---------

Co-authored-by: Gabe Kangas <gabek@real-ity.com>
2026-03-31 20:38:03 -07:00
John CostaandGitHub 8ccec822b4 fix(web): set page title on chat embed pages (#4820)
Set a translated page title on the readonly and readwrite chat embed
pages so popout/embedded chat windows display a meaningful title
based on the configured stream name, addressing #4794.

Uses next-export-i18n with a new chatEmbedTitle key for proper
localization. Also fixes the readonly embed error boundary
componentName from ReadWriteChatEmbed to ReadOnlyChatEmbed.
2026-03-31 20:36:28 -07:00
Gabe KangasandGitHub e738156fd7 Sanitize actor displaynames (#4864)
* Sanitize AP name possible XSS

* Pull out sanitizing method + remove rendering markdown for AP chat messages

* Add tests around AP message rendering and usernames

* Add sanitization tests to AP integration test
2026-03-31 20:33:05 -07:00
Gabe Kangas a2981903c2 Move activitypub automated test to run under a container 2026-03-30 12:32:04 -07:00
renovate[bot]andGitHub 5d4fcf9686 fix(deps): update module github.com/mattn/go-sqlite3 to v1.14.38 2026-03-30 05:36:32 +00:00
Owncast 50854f09b3 Bundle embedded web app 2026-03-29 02:02:57 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
54104a5c3c fix(deps): update dependency @uiw/react-codemirror to v4.25.9 (#4859)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-29 01:59:06 +00:00
Owncast 715c267079 Bundle embedded web app 2026-03-28 21:51:18 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
c48cc989f4 fix(deps): update dependency @uiw/codemirror-theme-bbedit to v4.25.9 (#4858)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-28 21:47:19 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
44970293f8 fix(deps): update module github.com/oapi-codegen/runtime to v1.3.1 (#4852)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-28 01:26:54 +00:00
Owncast 423ddb8e22 Bundle embedded web app 2026-03-28 00:21:50 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
c593ed381d chore(deps-dev): bump picomatch in /test/automated/api (#4850)
Bumps [picomatch](https://github.com/micromatch/picomatch) from 2.3.1 to 2.3.2.
- [Release notes](https://github.com/micromatch/picomatch/releases)
- [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/picomatch/compare/2.3.1...2.3.2)

---
updated-dependencies:
- dependency-name: picomatch
  dependency-version: 2.3.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-27 17:08:17 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
b6a39721dd chore(deps-dev): bump picomatch in /test/automated/hls (#4848)
Bumps [picomatch](https://github.com/micromatch/picomatch) from 2.3.1 to 2.3.2.
- [Release notes](https://github.com/micromatch/picomatch/releases)
- [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/picomatch/compare/2.3.1...2.3.2)

---
updated-dependencies:
- dependency-name: picomatch
  dependency-version: 2.3.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-27 17:07:52 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
159e24438d chore(deps-dev): bump flatted from 3.3.3 to 3.4.2 in /web (#4836)
Bumps [flatted](https://github.com/WebReflection/flatted) from 3.3.3 to 3.4.2.
- [Commits](https://github.com/WebReflection/flatted/compare/v3.3.3...v3.4.2)

---
updated-dependencies:
- dependency-name: flatted
  dependency-version: 3.4.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-27 17:07:21 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2c3e8fa3c1 chore(deps-dev): bump handlebars from 4.7.8 to 4.7.9 in /web (#4854)
Bumps [handlebars](https://github.com/handlebars-lang/handlebars.js) from 4.7.8 to 4.7.9.
- [Release notes](https://github.com/handlebars-lang/handlebars.js/releases)
- [Changelog](https://github.com/handlebars-lang/handlebars.js/blob/v4.7.9/release-notes.md)
- [Commits](https://github.com/handlebars-lang/handlebars.js/compare/v4.7.8...v4.7.9)

---
updated-dependencies:
- dependency-name: handlebars
  dependency-version: 4.7.9
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-27 17:06:57 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
ba8ce8bf66 chore(deps-dev): bump brace-expansion from 1.1.12 to 1.1.13 in /web (#4856)
Bumps [brace-expansion](https://github.com/juliangruber/brace-expansion) from 1.1.12 to 1.1.13.
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v1.1.12...v1.1.13)

---
updated-dependencies:
- dependency-name: brace-expansion
  dependency-version: 1.1.13
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-27 17:06:21 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
84341875b7 chore(deps): bump brace-expansion in /test/load (#4857)
Bumps  and [brace-expansion](https://github.com/juliangruber/brace-expansion). These dependencies needed to be updated together.

Updates `brace-expansion` from 2.0.2 to 2.0.3
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v2.0.2...v2.0.3)

Updates `brace-expansion` from 1.1.12 to 1.1.13
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v2.0.2...v2.0.3)

---
updated-dependencies:
- dependency-name: brace-expansion
  dependency-version: 2.0.3
  dependency-type: indirect
- dependency-name: brace-expansion
  dependency-version: 1.1.13
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-27 17:06:01 -07:00
Owncast d52911160d Bundle embedded web app 2026-03-27 23:45:57 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
4ef036b0f4 chore(deps): update dependency chromatic to v16 (#4861)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-27 16:37:17 -07:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
eb3184bcfa chore(deps): update chromaui/action action to v16 (#4860)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-27 16:37:00 -07:00
Gabe Kangas 5c9b2333f9 fix(i18n): only submit translations from the develop branch, not feature branches 2026-03-27 15:53:36 -07:00
Gabe Kangas 77c7c08587 fix(api): fix for incorrect error handling of favicon uploads 2026-03-27 15:34:09 -07:00
Owncast d9a424ea79 Bundle embedded web app 2026-03-27 21:46:34 +00:00
826c7e8a9e chore(i18n): update translations from Crowdin (#4829)
Co-authored-by: Owncast <owncast@owncast.online>
2026-03-27 14:25:59 -07:00
Gabe Kangas 3e6f6de032 chore(deps): also ignore dependabot 2026-03-27 14:24:38 -07:00
renovate[bot]andGitHub 3909c506a2 chore(deps): update peter-evans/create-or-update-comment digest to 3e11480 2026-03-27 20:32:16 +00:00
Owncast e9556d44da Bundle embedded web app 2026-03-27 20:30:58 +00:00
Gabe Kangas c85529370b chore(deps): do not run code lint/format on renovate PRs and do not run linter during builds 2026-03-27 13:25:32 -07:00
Owncast 285a722fac Bundle embedded web app 2026-03-27 20:09:55 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
234c696af9 chore(deps): lock file maintenance (#4807)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-27 13:03:35 -07:00
Owncast 177840b94a Bundle embedded web app 2026-03-27 19:01:56 +00:00
renovate[bot]andGitHub 35e684bd10 chore(deps): update dependency cypress to v15.13.0 2026-03-27 15:51:53 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
9b4adab8fc chore(deps): bump picomatch in /test/load (#4851)
Bumps  and [picomatch](https://github.com/micromatch/picomatch). These dependencies needed to be updated together.

Updates `picomatch` from 4.0.3 to 4.0.4
- [Release notes](https://github.com/micromatch/picomatch/releases)
- [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/picomatch/compare/4.0.3...4.0.4)

Updates `picomatch` from 2.3.1 to 2.3.2
- [Release notes](https://github.com/micromatch/picomatch/releases)
- [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/picomatch/compare/4.0.3...4.0.4)

---
updated-dependencies:
- dependency-name: picomatch
  dependency-version: 4.0.4
  dependency-type: indirect
- dependency-name: picomatch
  dependency-version: 2.3.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-27 08:49:41 -07:00
Owncast d74d1b55f0 Bundle embedded web app 2026-03-27 01:08:56 +00:00
renovate[bot]andGitHub 4d52d7d1fa chore(deps): update dependency handlebars to v4.7.9 [security] 2026-03-26 22:44:48 +00:00
Owncast 02559acc39 Bundle embedded web app 2026-03-26 22:43:57 +00:00
renovate[bot]andGitHub 892fd3d89e chore(deps): update typescript-eslint monorepo to v8.57.2 2026-03-26 21:59:57 +00:00
Owncast 0bb372d8cb Bundle embedded web app 2026-03-26 21:58:26 +00:00
renovate[bot]andGitHub ddc8e9bed0 chore(deps): update dependency chromatic to v15.3.1 2026-03-26 17:59:18 +00:00
renovate[bot]andGitHub 5b2a3e3127 chore(deps): update peter-evans/create-or-update-comment digest to df7d5bf 2026-03-26 00:29:37 +00:00
Owncast a476142f23 Bundle embedded web app 2026-03-25 23:03:22 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
ad090a0f86 chore(deps): bump socket.io-parser from 4.2.4 to 4.2.6 in /test/load (#4834)
Bumps [socket.io-parser](https://github.com/socketio/socket.io) from 4.2.4 to 4.2.6.
- [Release notes](https://github.com/socketio/socket.io/releases)
- [Changelog](https://github.com/socketio/socket.io/blob/main/CHANGELOG.md)
- [Commits](https://github.com/socketio/socket.io/compare/socket.io-parser@4.2.4...socket.io-parser@4.2.6)

---
updated-dependencies:
- dependency-name: socket.io-parser
  dependency-version: 4.2.6
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-25 15:58:00 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
5365cc0325 chore(deps-dev): bump minimatch from 3.1.2 to 3.1.5 in /web (#4812)
Bumps [minimatch](https://github.com/isaacs/minimatch) from 3.1.2 to 3.1.5.
- [Changelog](https://github.com/isaacs/minimatch/blob/main/changelog.md)
- [Commits](https://github.com/isaacs/minimatch/compare/v3.1.2...v3.1.5)

---
updated-dependencies:
- dependency-name: minimatch
  dependency-version: 3.1.5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-25 15:57:21 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
37404aac5c chore(deps): bump google.golang.org/grpc from 1.75.0 to 1.79.3 in /tools (#4832)
Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from 1.75.0 to 1.79.3.
- [Release notes](https://github.com/grpc/grpc-go/releases)
- [Commits](https://github.com/grpc/grpc-go/compare/v1.75.0...v1.79.3)

---
updated-dependencies:
- dependency-name: google.golang.org/grpc
  dependency-version: 1.79.3
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-25 15:56:33 -07:00
102 changed files with 2483 additions and 1685 deletions
@@ -1,57 +0,0 @@
name: ActivityPub Federation Tests
on:
push:
paths:
- "activitypub/**"
- "webserver/**"
- "test/automated/activitypub/**"
- ".github/workflows/activitypub-federation-tests.yaml"
pull_request:
paths:
- "activitypub/**"
- "webserver/**"
- "test/automated/activitypub/**"
- ".github/workflows/activitypub-federation-tests.yaml"
jobs:
federation-test:
runs-on: ubuntu-latest
steps:
- id: skip_check
uses: fkirc/skip-duplicate-actions@v5
with:
concurrent_skipping: "same_content_newer"
- name: Check out repository code
uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: "^1.23"
cache: true
- name: Install Caddy
run: |
sudo apt-get update
sudo apt-get install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt-get update
sudo apt-get install -y caddy
- name: Install dependencies
run: |
sudo apt-get install -y snac2 ffmpeg
- name: Run setup
run: |
cd test/automated/activitypub
sudo ./setup.sh
- name: Run federation test
run: |
cd test/automated/activitypub
CI=true USER_COUNT=20 ./run.sh
@@ -1,47 +0,0 @@
name: ActivityPub Follower Validation Tests
on:
push:
paths:
- "activitypub/**"
- "config/**"
- "main.go"
- "test/automated/activitypub/**"
- ".github/workflows/activitypub-follower-validation-tests.yaml"
pull_request:
paths:
- "activitypub/**"
- "config/**"
- "main.go"
- "test/automated/activitypub/**"
- ".github/workflows/activitypub-follower-validation-tests.yaml"
jobs:
follower-validation-test:
runs-on: ubuntu-latest
steps:
- id: skip_check
uses: fkirc/skip-duplicate-actions@v5
with:
concurrent_skipping: "same_content_newer"
- name: Check out repository code
uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: "^1.23"
cache: true
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y sqlite3 ffmpeg jq
- name: Run follower validation test
timeout-minutes: 15
run: |
cd test/automated/activitypub
./test-follower-validation.sh
+130
View File
@@ -0,0 +1,130 @@
name: ActivityPub Tests
on:
push:
paths:
- "activitypub/**"
- "core/chat/**"
- "config/**"
- "main.go"
- "webserver/**"
- "test/automated/activitypub/**"
- ".github/workflows/activitypub-tests.yaml"
pull_request:
paths:
- "activitypub/**"
- "core/chat/**"
- "config/**"
- "main.go"
- "webserver/**"
- "test/automated/activitypub/**"
- ".github/workflows/activitypub-tests.yaml"
jobs:
federation-test:
runs-on: ubuntu-latest
steps:
- id: skip_check
uses: fkirc/skip-duplicate-actions@v5
with:
concurrent_skipping: "same_content_newer"
- name: Check out repository code
uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: "^1.23"
cache: true
- name: Install Caddy
run: |
sudo apt-get update
sudo apt-get install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt-get update
sudo apt-get install -y caddy
- name: Install dependencies
run: |
sudo apt-get install -y snac2 ffmpeg
- name: Run setup
run: |
cd test/automated/activitypub
sudo ./setup.sh
- name: Run federation test
run: |
cd test/automated/activitypub
CI=true USER_COUNT=20 ./test-federation.sh
chat-sanitization-test:
runs-on: ubuntu-latest
steps:
- id: skip_check
uses: fkirc/skip-duplicate-actions@v5
with:
concurrent_skipping: "same_content_newer"
- name: Check out repository code
uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: "^1.23"
cache: true
- name: Install Caddy
run: |
sudo apt-get update
sudo apt-get install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt-get update
sudo apt-get install -y caddy
- name: Install dependencies
run: |
sudo apt-get install -y snac2 ffmpeg
- name: Run setup
run: |
cd test/automated/activitypub
sudo ./setup.sh
- name: Run chat sanitization test
run: |
cd test/automated/activitypub
CI=true ./test-chat-sanitization.sh
follower-validation-test:
runs-on: ubuntu-latest
steps:
- id: skip_check
uses: fkirc/skip-duplicate-actions@v5
with:
concurrent_skipping: "same_content_newer"
- name: Check out repository code
uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: "^1.23"
cache: true
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y sqlite3 ffmpeg jq
- name: Run follower validation test
timeout-minutes: 15
run: |
cd test/automated/activitypub
./test-follower-validation.sh
+1 -1
View File
@@ -11,7 +11,7 @@ jobs:
issues: write
steps:
- name: Add comment
uses: peter-evans/create-or-update-comment@9143c495e69aa6ad64fa02b7515ccab6bd35aad1
uses: peter-evans/create-or-update-comment@57232238742e38b2ccc27136ce596ccae7ca28b4
with:
issue-number: ${{ github.event.issue.number }}
body: |
+1 -1
View File
@@ -86,7 +86,7 @@ jobs:
- name: Publish to Chromatic
if: ${{ github.actor != 'renovate[bot]' && github.actor != 'renovate' && steps.changed-files-yaml.outputs.src_any_changed == 'true' }}
uses: chromaui/action@v15
uses: chromaui/action@v16
with:
workingDir: web
projectToken: f47410569b62
@@ -38,6 +38,7 @@ on:
jobs:
formatting:
name: Code formatting
if: github.actor != 'renovate[bot]' && github.actor != 'dependabot[bot]'
runs-on: ubuntu-latest
defaults:
run:
@@ -133,6 +134,7 @@ jobs:
unused-code:
name: Test for unused code
if: github.actor != 'renovate[bot]' && github.actor != 'dependabot[bot]'
runs-on: ubuntu-latest
defaults:
run:
@@ -197,7 +199,7 @@ jobs:
web-bundle:
name: Build and bundle web project
runs-on: ubuntu-latest
if: github.repository == 'owncast/owncast'
if: github.repository == 'owncast/owncast' && !cancelled()
needs: [formatting, unused-code]
steps:
- id: skip_check
+2
View File
@@ -16,6 +16,8 @@ on:
# Run the workflow every hour
- cron: '0 * * * *'
push:
branches:
- develop
paths:
- 'web/i18n/en/translation.json'
- 'web/**/*.tsx'
+13 -4
View File
@@ -4,12 +4,24 @@ import (
"fmt"
"github.com/go-fed/activity/streams/vocab"
"github.com/microcosm-cc/bluemonday"
"github.com/owncast/owncast/activitypub/resolvers"
"github.com/owncast/owncast/core/chat"
"github.com/owncast/owncast/core/chat/events"
"github.com/owncast/owncast/persistence/configrepository"
)
// sanitizeActorName strips HTML tags from the ActivityPub actor display name.
// Falls back to the username if the display name is empty or entirely HTML.
func sanitizeActorName(displayName, username string) string {
strict := bluemonday.StrictPolicy()
name := strict.Sanitize(displayName)
if name == "" {
name = strict.Sanitize(username)
}
return name
}
func handleEngagementActivity(eventType events.EventType, isLiveNotification bool, actorReference vocab.ActivityStreamsActorProperty, action string) error {
configRepository := configrepository.Get()
@@ -30,10 +42,7 @@ func handleEngagementActivity(eventType events.EventType, isLiveNotification boo
}
// Send chat message
actorName := actor.Name
if actorName == "" {
actorName = actor.Username
}
actorName := sanitizeActorName(actor.Name, actor.Username)
actorIRI := actor.ActorIriString()
userPrefix := fmt.Sprintf("%s ", actorName)
+120
View File
@@ -0,0 +1,120 @@
package inbox
import (
"testing"
)
func TestSanitizeActorName(t *testing.T) {
tests := []struct {
name string
displayName string
username string
expected string
}{
{
name: "plain display name",
displayName: "Alice",
username: "alice",
expected: "Alice",
},
{
name: "display name with emoji",
displayName: "Alice 🦊",
username: "alice",
expected: "Alice 🦊",
},
{
name: "display name with unicode",
displayName: "Ålice Böb",
username: "alice",
expected: "Ålice Böb",
},
{
name: "empty display name falls back to username",
displayName: "",
username: "alice",
expected: "alice",
},
{
name: "script tag in display name",
displayName: `<script>alert("xss")</script>`,
username: "alice",
expected: "alice",
},
{
name: "iframe injection in display name",
displayName: `<iframe src="https://evil.com" style="position:fixed;top:0;left:0;width:100%;height:100%"></iframe>`,
username: "alice",
expected: "alice",
},
{
name: "img tag in display name",
displayName: `<img src="https://evil.com/track.png">`,
username: "alice",
expected: "alice",
},
{
name: "form injection in display name",
displayName: `<form action="https://evil.com/steal"><input name="pw" type="password"></form>`,
username: "alice",
expected: "alice",
},
{
name: "meta refresh in display name",
displayName: `<meta http-equiv="refresh" content="0;url=https://evil.com">`,
username: "alice",
expected: "alice",
},
{
name: "mixed text and HTML in display name",
displayName: `Alice <script>alert(1)</script> Bob`,
username: "alice",
expected: "Alice Bob",
},
{
name: "custom emoji HTML in display name",
displayName: `Alice :blobcat: <img src="https://instance.com/emoji/blobcat.png" class="custom-emoji">`,
username: "alice",
expected: "Alice :blobcat: ",
},
{
name: "HTML in both display name and username",
displayName: `<script>alert(1)</script>`,
username: `<b>alice</b>`,
expected: "alice",
},
{
name: "entirely HTML display name falls back to username",
displayName: `<div></div>`,
username: "alice",
expected: "alice",
},
{
name: "style tag in display name",
displayName: `<style>body{display:none}</style>Alice`,
username: "alice",
expected: "Alice",
},
{
name: "nested HTML tags",
displayName: `<div><span><a href="https://evil.com">Click me</a></span></div>`,
username: "alice",
expected: "Click me",
},
{
name: "event handler attributes",
displayName: `<img src=x onerror="alert(1)">Alice`,
username: "alice",
expected: "Alice",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := sanitizeActorName(tt.displayName, tt.username)
if result != tt.expected {
t.Errorf("sanitizeActorName(%q, %q) = %q, want %q", tt.displayName, tt.username, result, tt.expected)
}
})
}
}
-1
View File
@@ -126,7 +126,6 @@ func SendFediverseAction(eventType string, userAccountName string, image *string
}
message.SetDefaults()
message.RenderBody()
if err := Broadcast(&message); err != nil {
log.Errorln("error sending system message", err)
+2 -2
View File
@@ -16,12 +16,12 @@ require (
github.com/hashicorp/go-retryablehttp v0.7.8
github.com/jellydator/ttlcache/v3 v3.4.0
github.com/lestrrat-go/file-rotatelogs v2.4.0+incompatible
github.com/mattn/go-sqlite3 v1.14.37
github.com/mattn/go-sqlite3 v1.14.38
github.com/microcosm-cc/bluemonday v1.0.27
github.com/mssola/user_agent v0.6.0
github.com/nakabonne/tstorage v0.3.6
github.com/nareix/joy5 v0.0.0-20210317075623-2c912ca30590
github.com/oapi-codegen/runtime v1.3.0
github.com/oapi-codegen/runtime v1.3.1
github.com/oschwald/geoip2-golang v1.13.0
github.com/pkg/errors v0.9.1
github.com/prometheus/client_golang v1.23.2
+4
View File
@@ -94,6 +94,8 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D
github.com/mattn/go-sqlite3 v1.14.7/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg=
github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-sqlite3 v1.14.38 h1:tDUzL85kMvOrvpCt8P64SbGgVFtJB11GPi2AdmITgb4=
github.com/mattn/go-sqlite3 v1.14.38/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/mssola/user_agent v0.6.0 h1:uwPR4rtWlCHRFyyP9u2KOV0u8iQXmS7Z7feTrstQwk4=
@@ -106,6 +108,8 @@ github.com/nareix/joy5 v0.0.0-20210317075623-2c912ca30590 h1:PnxRU8L8Y2q82vFC2Qd
github.com/nareix/joy5 v0.0.0-20210317075623-2c912ca30590/go.mod h1:XmAOs6UJXpNXRwKk+KY/nv5kL6xXYXyellk+A1pTlko=
github.com/oapi-codegen/runtime v1.3.0 h1:vyK1zc0gDWWXgk2xoQa4+X4RNNc5SL2RbTpJS/4vMYA=
github.com/oapi-codegen/runtime v1.3.0/go.mod h1:kOdeacKy7t40Rclb1je37ZLFboFxh+YLy0zaPCMibPY=
github.com/oapi-codegen/runtime v1.3.1 h1:RgDY6J4OGQLbRXhG/Xpt3vSVqYpHQS7hN4m85+5xB9g=
github.com/oapi-codegen/runtime v1.3.1/go.mod h1:kOdeacKy7t40Rclb1je37ZLFboFxh+YLy0zaPCMibPY=
github.com/oschwald/geoip2-golang v1.13.0 h1:Q44/Ldc703pasJeP5V9+aFSZFmBN7DKHbNsSFzQATJI=
github.com/oschwald/geoip2-golang v1.13.0/go.mod h1:P9zG+54KPEFOliZ29i7SeYZ/GM6tfEL+rgSn03hYuUo=
github.com/oschwald/maxminddb-golang v1.13.0 h1:R8xBorY71s84yO06NgTmQvqvTvlS/bnYZrrWX1MElnU=
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
File diff suppressed because one or more lines are too long
+2 -2
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+2
View File
@@ -0,0 +1,2 @@
certs/
README.md
+36
View File
@@ -0,0 +1,36 @@
FROM golang:1.25-bookworm
RUN apt-get update && apt-get install -y \
gcc make \
curl libcurl4-openssl-dev libssl-dev \
jq sqlite3 ffmpeg \
libnss3-tools \
lsof procps \
&& rm -rf /var/lib/apt/lists/*
# Install mkcert (architecture-aware)
RUN ARCH="$(dpkg --print-architecture)" && \
curl -sL "https://github.com/FiloSottile/mkcert/releases/download/v1.4.4/mkcert-v1.4.4-linux-${ARCH}" -o /usr/local/bin/mkcert && \
chmod +x /usr/local/bin/mkcert
# Install Caddy (architecture-aware)
RUN ARCH="$(dpkg --print-architecture)" && \
curl -sL "https://github.com/caddyserver/caddy/releases/download/v2.8.4/caddy_2.8.4_linux_${ARCH}.tar.gz" | tar -xz -C /usr/local/bin caddy
# Build and install snac2
RUN git clone --depth 1 https://codeberg.org/grunfink/snac2.git /tmp/snac2-src \
&& cd /tmp/snac2-src && make && cp snac /usr/local/bin/snac \
&& rm -rf /tmp/snac2-src
# Install mkcert CA into system trust store so snac2 trusts the test certs
RUN mkcert -install
# Allow git operations on the mounted repo
RUN git config --global --add safe.directory /owncast
WORKDIR /owncast
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
+33 -73
View File
@@ -2,83 +2,29 @@
This test verifies Owncast's ActivityPub federation by having snac2 users follow the Owncast instance and confirming message delivery.
## One-Time Setup
All test infrastructure (snac2, Caddy, mkcert, Go) runs inside a Docker container so you don't need to install anything on the host besides Docker.
### 1. Install mkcert
## Prerequisites
- Docker installed and running
## Running the Tests
```bash
# Ubuntu/Debian
sudo apt install mkcert
# Or download directly
curl -sLO https://github.com/FiloSottile/mkcert/releases/download/v1.4.4/mkcert-v1.4.4-linux-amd64
chmod +x mkcert-v1.4.4-linux-amd64
sudo mv mkcert-v1.4.4-linux-amd64 /usr/local/bin/mkcert
```
### 2. Install the local CA
This installs a Certificate Authority into your system's trust store. All certificates generated by mkcert will be trusted.
```bash
mkcert -install
```
### 3. Generate certificates for the test domains
```bash
cd test/automated/activitypub
mkdir -p certs
mkcert -cert-file certs/cert.pem -key-file certs/key.pem owncast.local snac.local localhost 127.0.0.1
```
### 4. Add hosts entries
```bash
sudo sh -c 'echo "127.0.0.1 owncast.local snac.local" >> /etc/hosts'
```
### 5. Install snac2
```bash
# Ubuntu/Debian
sudo apt install snac2
# Or build from source
git clone https://codeberg.org/grunfink/snac2.git
cd snac2
make
sudo make install
```
### 6. Install Caddy
Caddy is used as the HTTPS reverse proxy for TLS termination. It will be installed automatically by `setup.sh`, or you can install it manually:
```bash
# Download binary directly
curl -sL "https://github.com/caddyserver/caddy/releases/download/v2.8.4/caddy_2.8.4_linux_amd64.tar.gz" | sudo tar -xz -C /usr/local/bin caddy
# Or see: https://caddyserver.com/docs/install
```
## Running the Test
```bash
# Run with default 100 users
# Run the federation test with default 100 users
./run.sh
# Run with fewer users for quick testing
USER_COUNT=10 ./run.sh
# Run the follower validation test
./run.sh test-follower-validation.sh
# Keep servers running after test for debugging
KEEP_RUNNING=true ./run.sh
# Adjust follow request throttling (default 0.1s)
FOLLOW_DELAY=0.2 ./run.sh
# Run in CI mode (skip interactive prompts)
CI=true ./run.sh
```
## Configuration Options
@@ -88,7 +34,7 @@ CI=true ./run.sh
| `USER_COUNT` | 100 | Number of test users to create |
| `FOLLOW_DELAY` | 0.1 | Delay in seconds between follow requests |
| `KEEP_RUNNING` | false | Keep servers running after test for debugging |
| `CI` | false | Skip interactive prompts for CI environments |
| `CI` | false | Always true inside the container |
| `PROXY_PORT` | 8443 | HTTPS proxy port |
| `SNAC_PORT` | 9080 | snac2 HTTP port |
| `OWNCAST_PORT` | 8080 | Owncast HTTP port |
@@ -111,23 +57,37 @@ The test reports:
- **Follow Success Rate**: Percentage of follow requests that succeeded
- **Delivery Rate**: Percentage of registered followers who received the message
## Docker Image Details
The Docker image (`owncast-ap-test`) bundles all dependencies:
- Go (for building Owncast)
- snac2 (built from source)
- Caddy (HTTPS reverse proxy)
- mkcert (TLS certificates trusted by the container)
- sqlite3, jq, curl
Go module and build caches are stored in named Docker volumes (`owncast-ap-test-gomod`, `owncast-ap-test-gobuild`) so repeated runs are faster.
## Troubleshooting
### Certificate errors from snac2
### Docker build fails
Make sure you ran `mkcert -install` and generated the certificates. The CA must be in the system trust store for snac2 to trust the certificates.
Make sure Docker is running. On macOS, Docker Desktop or a compatible runtime (colima, OrbStack, etc.) is required.
### Port already in use
Kill any leftover processes:
If a previous container didn't shut down cleanly:
```bash
pkill -f "snac httpd /tmp"
pkill -f "caddy run"
docker ps -a | grep owncast-ap-test
docker rm -f <container_id>
```
### Hosts file not configured
### Cleaning up Docker resources
Verify the entries exist:
```bash
grep -E 'owncast.local|snac.local' /etc/hosts
# Remove the image
docker rmi owncast-ap-test
# Remove Go caches
docker volume rm owncast-ap-test-gomod owncast-ap-test-gobuild
```
+20
View File
@@ -0,0 +1,20 @@
#!/bin/bash
set -e
# Generate mkcert certificates for the test domains.
# These are placed in a container-local path so they don't leak into the
# mounted source tree.
export CERT_DIR="/tmp/test-certs"
mkdir -p "${CERT_DIR}"
mkcert -cert-file "${CERT_DIR}/cert.pem" \
-key-file "${CERT_DIR}/key.pem" \
owncast.local snac.local localhost 127.0.0.1
# Change CWD away from the mounted repo root so Owncast doesn't pick up the
# host's (possibly wrong-architecture) ffmpeg binary via ./ffmpeg detection.
# Stay inside the git repo so `git rev-parse --show-toplevel` still works.
cd /owncast/test/automated/activitypub
# Run the specified test script (default: test-federation.sh)
TEST_SCRIPT="${1:-test-federation.sh}"
exec "/owncast/test/automated/activitypub/${TEST_SCRIPT}"
+42 -4
View File
@@ -1,14 +1,52 @@
#!/bin/bash
# Run the ActivityPub federation test
# Run the ActivityPub federation test inside a Docker container.
#
# Usage:
# ./run.sh # Run with 100 users
# ./run.sh # Run federation test with 100 users
# ./run.sh test-follower-validation.sh # Run follower validation test
# USER_COUNT=50 ./run.sh # Run with 50 users
# KEEP_RUNNING=true ./run.sh # Keep servers running after test
#
# Prerequisites:
# Add to /etc/hosts: 127.0.0.1 owncast.local snac.local
# Docker must be installed and running.
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec "${SCRIPT_DIR}/test-federation.sh" "$@"
REPO_ROOT="$(git rev-parse --show-toplevel)"
IMAGE_NAME="owncast-ap-test"
echo "Building Docker image..."
docker build -t "${IMAGE_NAME}" "${SCRIPT_DIR}"
# Collect environment variables to pass through
ENV_ARGS=()
for var in USER_COUNT FOLLOW_DELAY KEEP_RUNNING CI PROXY_PORT SNAC_PORT OWNCAST_PORT CLEAR_SHARED_INBOX_PERCENT; do
if [[ -n "${!var}" ]]; then
ENV_ARGS+=("-e" "${var}=${!var}")
fi
done
# Always skip interactive prompts inside the container
ENV_ARGS+=("-e" "CI=true")
# Port-forward when KEEP_RUNNING is set so the user can access the services
EXTRA_ARGS=()
if [[ "${KEEP_RUNNING}" == "true" ]]; then
OWNCAST_PORT="${OWNCAST_PORT:-8080}"
PROXY_PORT="${PROXY_PORT:-8443}"
EXTRA_ARGS+=("-p" "${OWNCAST_PORT}:${OWNCAST_PORT}" "-p" "${PROXY_PORT}:${PROXY_PORT}")
fi
echo "Running test in Docker container..."
docker run --rm \
--add-host owncast.local:127.0.0.1 \
--add-host snac.local:127.0.0.1 \
-v "${REPO_ROOT}:/owncast" \
-v owncast-ap-test-gomod:/go/pkg/mod \
-v owncast-ap-test-gobuild:/root/.cache/go-build \
"${ENV_ARGS[@]}" \
"${EXTRA_ARGS[@]}" \
"${IMAGE_NAME}" \
"$@"
+520
View File
@@ -0,0 +1,520 @@
#!/bin/bash
# shellcheck disable=SC2317,SC2329 # cleanup() is invoked via trap, not direct call
# shellcheck disable=SC2034 # SNAC_URL is unused but kept for consistency with other AP tests
# Chat Sanitization Test for Fediverse Engagement Events
#
# This test verifies that malicious HTML and markdown in ActivityPub actor
# display names is sanitized before being rendered in chat messages.
#
# The test:
# 1. Starts snac2 + Owncast + HTTPS proxy (same as federation test)
# 2. Creates snac2 users with malicious display names (HTML, markdown)
# 3. Enables engagement display in chat
# 4. Has the malicious users follow Owncast
# 5. Queries the chat messages from the database
# 6. Verifies no HTML tags or markdown artifacts appear in stored messages
#
# Prerequisites:
# Add to /etc/hosts: 127.0.0.1 owncast.local snac.local
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(git rev-parse --show-toplevel)"
# Configuration
FOLLOW_DELAY="${FOLLOW_DELAY:-0.3}"
CI="${CI:-false}"
PROXY_PORT="${PROXY_PORT:-8443}"
SNAC_PORT="${SNAC_PORT:-9080}"
SNAC_HOSTNAME="snac.local"
OWNCAST_PORT="${OWNCAST_PORT:-8080}"
OWNCAST_HOSTNAME="owncast.local"
ADMIN_USER="admin"
ADMIN_PASS="abc123"
FEDERATION_USERNAME="streamer"
# URLs
SNAC_URL="https://${SNAC_HOSTNAME}:${PROXY_PORT}"
OWNCAST_URL="https://${OWNCAST_HOSTNAME}:${PROXY_PORT}"
# Directories
TEMP_DIR=""
SNAC_DATA_DIR=""
SNAC_BIN=""
OWNCAST_DB=""
# PIDs
SNAC_PID=""
OWNCAST_PID=""
PROXY_PID=""
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'
log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
log_test() { echo -e "${CYAN}[TEST]${NC} $1"; }
# Test users: username -> malicious display name
# These simulate real-world attack payloads from the security advisory
declare -A MALICIOUS_USERS
MALICIOUS_USERS=(
["htmlscript"]='<script>alert("xss")</script>'
["htmliframe"]='<iframe src="https://evil.com"></iframe>'
["htmlimg"]='<img src="https://evil.com/track.png">'
["htmlform"]='<form action="https://evil.com"><input type="password"></form>'
["htmlmeta"]='<meta http-equiv="refresh" content="0;url=https://evil.com">'
["htmlstyle"]='<style>body{display:none}</style>Visible'
["mdimage"]='![xss](https://evil.com/track.png)'
["mdlink"]='[Click me](https://evil.com)'
["mixedhtml"]='Alice <script>alert(1)</script> Bob'
["cleanuser"]='Legitimate User'
)
# Patterns that must NOT appear in stored chat message bodies
FORBIDDEN_PATTERNS=(
'<script'
'<iframe'
'<img'
'<form'
'<meta'
'<style'
'<input'
'src="https://evil'
'action="https://evil'
'onerror='
'onload='
)
kill_leftover_processes() {
local killed=false
if pkill -f "snac httpd /tmp" 2>/dev/null; then killed=true; fi
if pkill -f "local-proxy.js" 2>/dev/null; then killed=true; fi
local proxy_pid
proxy_pid=$(lsof -ti :"${PROXY_PORT}" 2>/dev/null) || true
if [[ -n "${proxy_pid}" ]]; then
kill "${proxy_pid}" 2>/dev/null || true
killed=true
fi
local snac_pid
snac_pid=$(lsof -ti :"${SNAC_PORT}" 2>/dev/null) || true
if [[ -n "${snac_pid}" ]]; then
kill "${snac_pid}" 2>/dev/null || true
killed=true
fi
if [[ "${killed}" == "true" ]]; then
log_info "Killed leftover processes from previous run"
sleep 1
fi
}
cleanup() {
log_info "Cleaning up..."
if [[ -n "${PROXY_PID}" ]] && kill -0 "${PROXY_PID}" 2>/dev/null; then
kill "${PROXY_PID}" 2>/dev/null || true
wait "${PROXY_PID}" 2>/dev/null || true
fi
if [[ -n "${OWNCAST_PID}" ]] && kill -0 "${OWNCAST_PID}" 2>/dev/null; then
kill "${OWNCAST_PID}" 2>/dev/null || true
wait "${OWNCAST_PID}" 2>/dev/null || true
fi
if [[ -n "${SNAC_PID}" ]] && kill -0 "${SNAC_PID}" 2>/dev/null; then
kill "${SNAC_PID}" 2>/dev/null || true
wait "${SNAC_PID}" 2>/dev/null || true
fi
if [[ -n "${TEMP_DIR}" ]] && [[ -d "${TEMP_DIR}" ]]; then
rm -rf "${TEMP_DIR}"
fi
log_info "Cleanup complete."
}
trap cleanup EXIT
setup_temp_dir() {
TEMP_DIR=$(mktemp -d)
SNAC_DATA_DIR="${TEMP_DIR}/snac-data"
OWNCAST_DB="${TEMP_DIR}/owncast.db"
log_info "Temp directory: ${TEMP_DIR}"
}
check_hosts_entry() {
if ! grep -q "${OWNCAST_HOSTNAME}" /etc/hosts 2>/dev/null || ! grep -q "${SNAC_HOSTNAME}" /etc/hosts 2>/dev/null; then
log_warn "Required /etc/hosts entries not found."
log_warn "Please add: 127.0.0.1 ${OWNCAST_HOSTNAME} ${SNAC_HOSTNAME}"
exit 1
fi
log_info "Hosts entries verified"
}
install_snac2() {
log_info "Setting up snac2..."
if command -v snac &> /dev/null; then
SNAC_BIN=$(command -v snac)
log_info "Using system snac2: ${SNAC_BIN}"
return
fi
local snac_src="${TEMP_DIR}/snac2-src"
log_info "Cloning snac2..."
git clone --depth 1 https://codeberg.org/grunfink/snac2.git "${snac_src}" 2>/dev/null
log_info "Building snac2..."
pushd "${snac_src}" > /dev/null
make
SNAC_BIN="${snac_src}/snac"
popd > /dev/null
log_info "snac2 built: ${SNAC_BIN}"
}
check_certs() {
# CERT_DIR may be set by the Docker entrypoint; fall back to local certs/
CERT_DIR="${CERT_DIR:-${SCRIPT_DIR}/certs}"
if [[ ! -f "${CERT_DIR}/cert.pem" ]] || [[ ! -f "${CERT_DIR}/key.pem" ]]; then
log_error "Certificates not found in ${CERT_DIR}. See README.md for setup."
exit 1
fi
log_info "Using certificates from ${CERT_DIR}"
}
init_snac2() {
log_info "Initializing snac2..."
local snac_host_port="${SNAC_HOSTNAME}:${PROXY_PORT}"
printf "127.0.0.1\n%s\n%s\n\ntest@test.local\n" "${SNAC_PORT}" "${snac_host_port}" | \
"${SNAC_BIN}" init "${SNAC_DATA_DIR}" > /dev/null 2>&1
log_info "snac2 initialized"
}
create_malicious_users() {
log_info "Creating snac2 users with malicious display names..."
local run_id
run_id=$(date +%s%N | sha256sum | head -c 8)
local created=0
for username in "${!MALICIOUS_USERS[@]}"; do
local displayname="${MALICIOUS_USERS[$username]}"
local full_username="${username}${run_id}"
if printf "%s\n%s\n" "${full_username}" "${displayname}" | "${SNAC_BIN}" adduser "${SNAC_DATA_DIR}" > /dev/null 2>&1; then
# Update the key to include the run_id so we can find them later
MALICIOUS_USERS["${username}"]="${displayname}"
# Store the full username for follow requests
SNAC_FULL_USERNAMES["${username}"]="${full_username}"
created=$((created + 1))
log_info " Created user '${full_username}' with display name: ${displayname}"
else
log_error " Failed to create user '${full_username}'"
fi
done
log_info "Created ${created} users with malicious display names"
}
start_proxy() {
log_info "Starting HTTPS reverse proxy (Caddy)..."
if ! command -v caddy &> /dev/null; then
log_error "Caddy is not installed."
return 1
fi
export PROXY_PORT OWNCAST_PORT SNAC_PORT
export CERT_FILE="${CERT_DIR}/cert.pem"
export KEY_FILE="${CERT_DIR}/key.pem"
local caddy_log="${TEMP_DIR}/caddy.log"
caddy run --config "${SCRIPT_DIR}/Caddyfile" --adapter caddyfile > "${caddy_log}" 2>&1 &
PROXY_PID=$!
sleep 2
if ! kill -0 "${PROXY_PID}" 2>/dev/null; then
log_error "Caddy failed to start"
return 1
fi
local max_attempts=10
local attempt=0
while [[ ${attempt} -lt ${max_attempts} ]]; do
if curl -sk "https://127.0.0.1:${PROXY_PORT}/" > /dev/null 2>&1; then
log_info "Caddy proxy is ready"
return 0
fi
attempt=$((attempt + 1))
sleep 1
done
log_error "Caddy proxy did not become ready"
return 1
}
start_snac2() {
log_info "Starting snac2 server..."
local snac_log="${TEMP_DIR}/snac2.log"
DEBUG=0 "${SNAC_BIN}" httpd "${SNAC_DATA_DIR}" > "${snac_log}" 2>&1 &
SNAC_PID=$!
local max_attempts=30
local attempt=0
while [[ ${attempt} -lt ${max_attempts} ]]; do
if curl -s "http://127.0.0.1:${SNAC_PORT}/" > /dev/null 2>&1; then
log_info "snac2 is ready"
return 0
fi
attempt=$((attempt + 1))
sleep 1
done
log_error "snac2 did not become ready"
return 1
}
build_owncast() {
log_info "Building Owncast..."
pushd "${REPO_ROOT}" > /dev/null
CGO_ENABLED=1 go build -o owncast main.go
popd > /dev/null
log_info "Owncast built"
}
start_owncast() {
log_info "Starting Owncast..."
OWNCAST_ALLOW_INTERNAL_FEDERATION=true \
OWNCAST_INSECURE_SKIP_VERIFY=true \
"${REPO_ROOT}/owncast" -database "${OWNCAST_DB}" &
OWNCAST_PID=$!
local max_attempts=30
local attempt=0
while [[ ${attempt} -lt ${max_attempts} ]]; do
if curl -s "http://localhost:${OWNCAST_PORT}/api/status" > /dev/null 2>&1; then
log_info "Owncast is ready"
return 0
fi
attempt=$((attempt + 1))
sleep 1
done
log_error "Owncast did not become ready"
return 1
}
configure_owncast() {
log_info "Configuring Owncast..."
local base_url="http://localhost:${OWNCAST_PORT}"
local auth
auth=$(echo -n "${ADMIN_USER}:${ADMIN_PASS}" | base64)
# Set server URL
curl -s -X POST "${base_url}/api/admin/config/serverurl" \
-H "Authorization: Basic ${auth}" \
-H "Content-Type: application/json" \
-d "{\"value\": \"${OWNCAST_URL}\"}" > /dev/null
# Set federation username
curl -s -X POST "${base_url}/api/admin/config/federation/username" \
-H "Authorization: Basic ${auth}" \
-H "Content-Type: application/json" \
-d "{\"value\": \"${FEDERATION_USERNAME}\"}" > /dev/null
# Enable federation
curl -s -X POST "${base_url}/api/admin/config/federation/enable" \
-H "Authorization: Basic ${auth}" \
-H "Content-Type: application/json" \
-d '{"value": true}' > /dev/null
# Disable private mode
curl -s -X POST "${base_url}/api/admin/config/federation/private" \
-H "Authorization: Basic ${auth}" \
-H "Content-Type: application/json" \
-d '{"value": false}' > /dev/null
# Enable engagement display in chat
curl -s -X POST "${base_url}/api/admin/config/federation/showengagement" \
-H "Authorization: Basic ${auth}" \
-H "Content-Type: application/json" \
-d '{"value": true}' > /dev/null
log_info "Owncast configured (engagement display enabled)"
}
send_follow_requests() {
log_info "Sending follow requests from malicious users..."
local owncast_actor="${OWNCAST_URL}/federation/user/${FEDERATION_USERNAME}"
local successful=0
for username in "${!MALICIOUS_USERS[@]}"; do
local full_username="${SNAC_FULL_USERNAMES[$username]}"
local follow_output
follow_output=$("${SNAC_BIN}" follow "${SNAC_DATA_DIR}" "${full_username}" "${owncast_actor}" 2>&1)
local follow_exit=$?
if [[ ${follow_exit} -eq 0 ]] && [[ ! "${follow_output}" =~ "cannot" ]]; then
successful=$((successful + 1))
log_info " ${full_username} followed Owncast"
else
log_warn " ${full_username} follow failed: ${follow_output}"
fi
sleep "${FOLLOW_DELAY}"
done
log_test "${successful}/${#MALICIOUS_USERS[@]} follow requests sent"
# Wait for follow requests to be processed
local user_count=${#MALICIOUS_USERS[@]}
local wait_time=$((15 + user_count))
log_info "Waiting ${wait_time}s for engagement events to be processed..."
sleep "${wait_time}"
}
verify_chat_sanitization() {
log_info "Verifying chat message sanitization..."
local passed=true
local tests_run=0
local tests_passed=0
# Query fediverse engagement messages from the database
local messages
messages=$(sqlite3 "${OWNCAST_DB}" \
"SELECT body FROM messages WHERE eventType IN ('FEDIVERSE_ENGAGEMENT_FOLLOW', 'FEDIVERSE_ENGAGEMENT_LIKE', 'FEDIVERSE_ENGAGEMENT_REPOST');" 2>/dev/null)
if [[ -z "${messages}" ]]; then
log_error "No fediverse engagement messages found in database"
return 1
fi
local message_count
message_count=$(echo "${messages}" | wc -l | tr -d ' ')
log_info "Found ${message_count} engagement messages in chat"
# Test 1: No forbidden HTML patterns in any message
for pattern in "${FORBIDDEN_PATTERNS[@]}"; do
tests_run=$((tests_run + 1))
if echo "${messages}" | grep -qi "${pattern}"; then
log_error "FAIL: Found forbidden pattern '${pattern}' in chat messages:"
echo "${messages}" | grep -i "${pattern}" | while read -r line; do
log_error " Body: ${line}"
done
passed=false
else
tests_passed=$((tests_passed + 1))
log_test "PASS: No '${pattern}' found in messages"
fi
done
# Test 2: Verify the clean user's message is present
# Note: snac2 may not serve the display name in actor objects, so we check
# for either the display name or the username fallback.
tests_run=$((tests_run + 1))
local clean_username="${SNAC_FULL_USERNAMES[cleanuser]}"
if echo "${messages}" | grep -q "Legitimate User"; then
tests_passed=$((tests_passed + 1))
log_test "PASS: Clean display name 'Legitimate User' preserved correctly"
elif echo "${messages}" | grep -q "${clean_username}"; then
tests_passed=$((tests_passed + 1))
log_test "PASS: Clean user present via username fallback '${clean_username}'"
else
log_error "FAIL: Clean user message not found in chat"
passed=false
fi
# Test 3: Verify that messages with stripped HTML fell back to expected content
# Messages from users whose display names were entirely HTML should show
# the follow action text but not the HTML
tests_run=$((tests_run + 1))
if echo "${messages}" | grep -q "followed this stream"; then
tests_passed=$((tests_passed + 1))
log_test "PASS: Follow action text present in messages"
else
log_error "FAIL: No 'followed this stream' text found in any message"
passed=false
fi
# Test 4: No markdown image syntax rendered as HTML img tags
tests_run=$((tests_run + 1))
if echo "${messages}" | grep -qi '<img.*src=.*evil'; then
log_error "FAIL: Markdown image syntax was rendered as HTML img tag"
passed=false
else
tests_passed=$((tests_passed + 1))
log_test "PASS: No markdown-rendered img tags found"
fi
# Test 5: No markdown link syntax rendered as HTML anchor tags with evil URLs
tests_run=$((tests_run + 1))
if echo "${messages}" | grep -qi '<a.*href=.*evil'; then
log_error "FAIL: Markdown link syntax was rendered as HTML anchor tag"
passed=false
else
tests_passed=$((tests_passed + 1))
log_test "PASS: No markdown-rendered anchor tags with evil URLs found"
fi
# Print all stored messages for inspection
echo ""
log_info "All stored engagement messages:"
echo "${messages}" | while read -r line; do
log_info " ${line}"
done
echo ""
echo "========================================"
echo "Chat Sanitization Test Results"
echo "========================================"
echo "Tests run: ${tests_run}"
echo "Tests passed: ${tests_passed}"
echo "========================================"
if [[ "${passed}" == "true" ]]; then
echo -e "${GREEN}TEST PASSED${NC}"
return 0
else
echo -e "${RED}TEST FAILED${NC}"
return 1
fi
}
# We need an associative array for the full usernames
declare -A SNAC_FULL_USERNAMES
main() {
kill_leftover_processes
echo ""
echo "========================================"
echo "Chat Sanitization Test"
echo "========================================"
echo ""
# Setup infrastructure
setup_temp_dir
check_hosts_entry
install_snac2
check_certs
init_snac2
create_malicious_users
start_proxy
start_snac2
build_owncast
start_owncast
configure_owncast
sleep 2
# Send follows from malicious users
send_follow_requests
# Verify results
if verify_chat_sanitization; then
exit 0
else
exit 1
fi
}
main "$@"
+51 -13
View File
@@ -47,6 +47,7 @@ OWNCAST_URL="https://${OWNCAST_HOSTNAME}:${PROXY_PORT}"
TEMP_DIR=""
SNAC_DATA_DIR=""
SNAC_BIN=""
OWNCAST_BIN=""
OWNCAST_DB=""
# PIDs and state
@@ -55,6 +56,7 @@ OWNCAST_PID=""
PROXY_PID=""
TEST_STREAM_PID=""
SNAC_USERNAMES=()
CONFIRMED_FOLLOWERS=()
# Colors
RED='\033[0;31m'
@@ -182,8 +184,8 @@ install_snac2() {
}
check_certs() {
# Use pre-generated mkcert certificates from the script directory
CERT_DIR="${SCRIPT_DIR}/certs"
# CERT_DIR may be set by the Docker entrypoint; fall back to local certs/
CERT_DIR="${CERT_DIR:-${SCRIPT_DIR}/certs}"
if [[ ! -f "${CERT_DIR}/cert.pem" ]] || [[ ! -f "${CERT_DIR}/key.pem" ]]; then
log_error "Certificates not found in ${CERT_DIR}"
@@ -390,11 +392,12 @@ verify_snac_shared_inbox() {
build_owncast() {
log_info "Building Owncast..."
OWNCAST_BIN="${TEMP_DIR}/owncast"
pushd "${REPO_ROOT}" > /dev/null
CGO_ENABLED=1 go build -o owncast main.go
CGO_ENABLED=1 go build -o "${OWNCAST_BIN}" main.go
popd > /dev/null
log_info "Owncast built"
log_info "Owncast built: ${OWNCAST_BIN}"
}
start_owncast() {
@@ -403,7 +406,7 @@ start_owncast() {
# Start Owncast with test environment variables and debug flags
OWNCAST_ALLOW_INTERNAL_FEDERATION=true \
OWNCAST_INSECURE_SKIP_VERIFY=true \
"${REPO_ROOT}/owncast" -database "${OWNCAST_DB}" &
"${OWNCAST_BIN}" -database "${OWNCAST_DB}" &
OWNCAST_PID=$!
log_info "Owncast started with PID ${OWNCAST_PID}"
@@ -539,8 +542,8 @@ send_follow_requests() {
log_info "snac2 queue has ${queue_count} pending items"
# Give snac2 background thread time to process all pending follows
# Wait longer for more users
local wait_time=$((10 + USER_COUNT / 10))
# Each follow requires an HTTP round-trip; scale wait with user count
local wait_time=$((10 + USER_COUNT / 5))
log_info "Waiting ${wait_time}s for snac2 to process follow requests..."
sleep "${wait_time}"
}
@@ -562,6 +565,28 @@ verify_followers() {
echo "${count}"
}
populate_confirmed_followers() {
# Query Owncast API for actual registered followers and map their IRIs
# back to snac2 usernames so we only check those inboxes for delivery.
CONFIRMED_FOLLOWERS=()
local auth
auth=$(echo -n "${ADMIN_USER}:${ADMIN_PASS}" | base64)
local response
response=$(curl -s "http://localhost:${OWNCAST_PORT}/api/admin/followers?limit=1000" \
-H "Authorization: Basic ${auth}" 2>/dev/null || echo '{"results":[]}')
# Follower IRIs look like https://snac.local:8443/username
while IFS= read -r iri; do
if [[ -n "${iri}" ]]; then
local username="${iri##*/}"
CONFIRMED_FOLLOWERS+=("${username}")
fi
done < <(echo "${response}" | jq -r '.results[]?.link // empty' 2>/dev/null)
log_info "Confirmed ${#CONFIRMED_FOLLOWERS[@]} follower usernames from Owncast API"
}
send_test_message() {
log_info "Sending test message from Owncast..."
@@ -579,9 +604,16 @@ send_test_message() {
}
check_snac_inboxes_count() {
local users_with_messages=0
# Only check users confirmed as followers by Owncast, not all snac2 users.
# snac2's shared inbox distributes messages to all users it considers followers,
# which may include users whose follow hasn't been registered by Owncast yet.
local usernames_to_check=("${CONFIRMED_FOLLOWERS[@]}")
if [[ ${#usernames_to_check[@]} -eq 0 ]]; then
usernames_to_check=("${SNAC_USERNAMES[@]}")
fi
for username in "${SNAC_USERNAMES[@]}"; do
local users_with_messages=0
for username in "${usernames_to_check[@]}"; do
if user_has_message "${username}"; then
users_with_messages=$((users_with_messages + 1))
fi
@@ -614,11 +646,16 @@ user_has_message() {
check_snac_inboxes() {
log_info "Checking snac2 user inboxes for delivered messages..." >&2
local usernames_to_check=("${CONFIRMED_FOLLOWERS[@]}")
if [[ ${#usernames_to_check[@]} -eq 0 ]]; then
usernames_to_check=("${SNAC_USERNAMES[@]}")
fi
local users_with_messages=0
local users_without_messages=()
local total=${#SNAC_USERNAMES[@]}
local total=${#usernames_to_check[@]}
for username in "${SNAC_USERNAMES[@]}"; do
for username in "${usernames_to_check[@]}"; do
if user_has_message "${username}"; then
users_with_messages=$((users_with_messages + 1))
else
@@ -626,7 +663,7 @@ check_snac_inboxes() {
fi
done
log_test "${users_with_messages}/${total} users received the message" >&2
log_test "${users_with_messages}/${total} confirmed followers received the message" >&2
if [[ ${#users_without_messages[@]} -gt 0 ]] && [[ ${#users_without_messages[@]} -le 10 ]]; then
log_warn "Users missing messages: ${users_without_messages[*]}" >&2
@@ -847,7 +884,7 @@ main() {
# Wait for all followers to be registered (with timeout)
local followers=0
local max_wait=60 # Maximum seconds to wait for followers
local max_wait=$((60 + USER_COUNT)) # Scale timeout with user count
local waited=0
local check_interval=2
@@ -889,6 +926,7 @@ main() {
echo "----------------------------------------"
echo "STEP 4: Owncast sends message to followers"
echo "----------------------------------------"
populate_confirmed_followers
send_test_message
echo ""
@@ -31,6 +31,7 @@ TEST_START_TIME=""
# Directories
TEMP_DIR=""
OWNCAST_BIN=""
OWNCAST_DB=""
# PIDs
@@ -151,11 +152,12 @@ setup_temp_dir() {
build_owncast() {
log_info "Building Owncast..."
OWNCAST_BIN="${TEMP_DIR}/owncast"
pushd "${REPO_ROOT}" > /dev/null
CGO_ENABLED=1 go build -o owncast main.go
CGO_ENABLED=1 go build -o "${OWNCAST_BIN}" main.go
popd > /dev/null
log_info "Owncast built"
log_info "Owncast built: ${OWNCAST_BIN}"
}
start_owncast() {
@@ -164,7 +166,7 @@ start_owncast() {
# Start Owncast with test configuration
OWNCAST_ALLOW_INTERNAL_FEDERATION=true \
OWNCAST_INSECURE_SKIP_VERIFY=true \
"${REPO_ROOT}/owncast" \
"${OWNCAST_BIN}" \
-database "${OWNCAST_DB}" \
-followervalidationinterval "${VALIDATION_INTERVAL}" \
-enableVerboseLogging &
+4 -3
View File
@@ -3621,10 +3621,11 @@
"dev": true
},
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
},
+6 -6
View File
@@ -3036,9 +3036,9 @@
"dev": true
},
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true,
"engines": {
"node": ">=8.6"
@@ -5903,9 +5903,9 @@
"dev": true
},
"picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true
},
"pirates": {
+19 -36
View File
@@ -5732,9 +5732,9 @@
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
"integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
@@ -7309,9 +7309,9 @@
}
},
"node_modules/glob/node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
@@ -8071,9 +8071,9 @@
}
},
"node_modules/matcher-collection/node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
@@ -8620,9 +8620,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"license": "MIT",
"engines": {
"node": ">=8.6"
@@ -9149,35 +9149,18 @@
}
},
"node_modules/socket.io-parser": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz",
"integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==",
"version": "4.2.6",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
"integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1"
"debug": "~4.4.1"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-parser/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/socketio-wildcard": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/socketio-wildcard/-/socketio-wildcard-2.0.0.tgz",
@@ -9417,9 +9400,9 @@
}
},
"node_modules/tinyglobby/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"license": "MIT",
"engines": {
"node": ">=12"
+5 -5
View File
@@ -13,7 +13,7 @@ require (
require (
4d63.com/gocheckcompilerdirectives v1.3.0 // indirect
4d63.com/gochecknoglobals v0.2.2 // indirect
cel.dev/expr v0.24.0 // indirect
cel.dev/expr v0.25.1 // indirect
codeberg.org/chavacava/garif v0.2.0 // indirect
codeberg.org/polyfloyd/go-errorlint v1.9.0 // indirect
dev.gaijin.team/go/exhaustruct/v4 v4.0.0 // indirect
@@ -277,10 +277,10 @@ require (
golang.org/x/term v0.41.0 // indirect
golang.org/x/text v0.35.0 // indirect
golang.org/x/tools v0.43.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c // indirect
google.golang.org/grpc v1.75.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/grpc v1.79.3 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
+22 -22
View File
@@ -2,8 +2,8 @@
4d63.com/gocheckcompilerdirectives v1.3.0/go.mod h1:ofsJ4zx2QAuIP/NO/NAh1ig6R1Fb18/GI7RVMwz7kAY=
4d63.com/gochecknoglobals v0.2.2 h1:H1vdnwnMaZdQW/N+NrkT1SZMTBmcwHe9Vq8lJcYYTtU=
4d63.com/gochecknoglobals v0.2.2/go.mod h1:lLxwTQjL5eIesRbvnzIP3jZtG140FnTdz+AlMa+ogt0=
cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY=
cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw=
cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
@@ -789,18 +789,18 @@ go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
@@ -1138,10 +1138,10 @@ google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7Fc
google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7 h1:FiusG7LWj+4byqhbvmB+Q93B/mOxJLN2DTozDuZm4EU=
google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7/go.mod h1:kXqgZtrWaf6qS3jZOCnCH7WYfrvFjkC51bM8fz3RsCA=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c h1:qXWI/sQtv5UKboZ/zUk7h+mrf/lXORyI+n9DKDAusdg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c/go.mod h1:gw1tLEfykwDz2ET4a12jcXt4couGAm7IwsVaTy0Sflo=
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls=
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
@@ -1154,8 +1154,8 @@ google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKa
google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4=
google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
@@ -1169,8 +1169,8 @@ google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlba
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -2,4 +2,70 @@
.player {
width: 100%;
position: relative;
overflow: hidden;
@media (hover: hover) and (pointer: fine) {
:global(.vjs-control-bar .vjs-control:hover .vjs-control-text) {
/* This ensures the text isn't hidden by default VJS accessibility rules */
display: block !important;
visibility: visible !important;
width: auto !important;
height: auto !important;
/* stylelint-disable-next-line property-no-deprecated */
clip: auto !important; /* necessary to make the control text visible */
margin: 0 !important;
/* Base tooltip styles */
position: absolute;
bottom: 45px;
left: 50%;
transform: translateX(-50%);
white-space: nowrap;
z-index: 1000;
padding: 5px 10px;
background: rgb(20 20 20 / 90%);
color: #fff;
border-radius: 4px;
font-size: 12px;
line-height: 1.2;
pointer-events: none; /* Prevents tooltip from flickering when mouse hits it */
/* If there's no text content, it stays invisible */
&:empty {
display: none !important;
}
}
/* Prevent the "Mute"/"Unmute" tooltip from showing when interacting with the volume slider.
* Since the slider is part of the volume panel container, hovering it technically
* counts as hovering the mute control context. We force-hide the tooltip
* unless the cursor is explicitly over the mute button icon itself.
*/
:global(.vjs-volume-panel:hover .vjs-mute-control:not(:hover) .vjs-control-text) {
display: none !important;
}
/* Move the first and last element slightly right and left to prevent them from overflowing respectively */
:global(.vjs-control:first-child:hover .vjs-control-text) {
left: 0;
transform: translateX(6px);
&::after {
left: 15%;
}
}
:global(.vjs-control:last-child:hover .vjs-control-text) {
left: auto;
right: 0;
transform: translateX(-6px);
&::after {
left: auto;
right: 15%;
transform: translateX(50%);
}
}
}
}
+29 -1
View File
@@ -1,11 +1,23 @@
import React, { FC } from 'react';
import videojs from 'video.js';
import type VideoJsPlayer from 'video.js/dist/types/player';
import { useTranslation } from 'next-export-i18n';
import styles from './VideoJS.module.scss';
require('video.js/dist/video-js.css');
const SHORTCUT_SUFFIXES: Record<string, string> = {
Play: ' (Space)',
Pause: ' (Space)',
Mute: ' (m)',
Unmute: ' (m)',
Fullscreen: ' (f)',
'Non-Fullscreen': ' (f)',
'Picture-in-Picture': ' (i)',
'Exit Picture-in-Picture': ' (i)',
};
export type VideoJSProps = {
options: any;
onReady: (player: VideoJsPlayer, vjsInstance: typeof videojs) => void;
@@ -14,14 +26,30 @@ export type VideoJSProps = {
export const VideoJS: FC<VideoJSProps> = ({ options, onReady }) => {
const videoRef = React.useRef<HTMLVideoElement | null>(null);
const playerRef = React.useRef<VideoJsPlayer | null>(null);
const { t } = useTranslation();
const addShortcutsToLanguage = (vjs: typeof videojs, langCode: string) => {
const updates: Record<string, string> = {};
Object.keys(SHORTCUT_SUFFIXES).forEach(key => {
const currentLabel = key;
const suffix = SHORTCUT_SUFFIXES[key];
updates[key] = t(`${currentLabel}${suffix}`);
});
vjs.addLanguage(langCode, updates);
};
React.useEffect(() => {
// Make sure Video.js player is only initialized once
if (!playerRef.current) {
const videoElement = videoRef.current;
addShortcutsToLanguage(videojs, 'en');
const finalOptions = {
...options,
noUITitleAttributes: true, // Prevents videojs from adding a title attribute to UI elements, thus preventing "double tooltips".
};
// eslint-disable-next-line no-multi-assign
const player: VideoJsPlayer = (playerRef.current = videojs(videoElement, options, () => {
const player: VideoJsPlayer = (playerRef.current = videojs(videoElement, finalOptions, () => {
console.debug('player is ready');
return onReady && onReady(player, videojs);
}));
+2 -1
View File
@@ -221,7 +221,8 @@
"offlineBasic": "هذا البث غير متصل. تحقق قريباً!",
"offlineFediverseOnly": "هذا البث غير متصل. <span class='follow-link'>تابع</span> {{fediverseAccount}} على فيديفيرس لرؤية المرة القادمة التي يذهب فيها {{streamer}} للحياة.",
"offlineNotifyAndFediverse": "هذا البث غير متصل بالإنترنت. يمكنك أن تتلقى <span class='notify-link'>إشعارًا</span> في المرة القادمة {{streamer}} أو <span class='follow-link'>متابعة</span> {{fediverseAccount}} على موقع Fediverse.",
"offlineNotifyOnly": "هذا البث غير متصل بالإنترنت. <span class='notify-link'>كن على علم</span> في المرة القادمة التي يتم فيها البث المباشر {{streamer}} ."
"offlineNotifyOnly": "هذا البث غير متصل بالإنترنت. <span class='notify-link'>كن على علم</span> في المرة القادمة التي يتم فيها البث المباشر {{streamer}} .",
"chatEmbedTitle": "محادثة {{name}}"
},
"Testing": {
"itemCount": "لديك عناصر {{count}}",
+2 -1
View File
@@ -221,7 +221,8 @@
"offlineBasic": "এই স্ট্রিম অফলাইন। কিছুক্ষণ পরে চেক করুন!",
"offlineFediverseOnly": "এই স্ট্রিম অফলাইন। <span class='follow-link'>ফলো করুন</span> {{fediverseAccount}} কে Fediverse এ পরবর্তী সময়ের জন্য যখন {{streamer}} লাইভ হবে তা দেখার জন্য।",
"offlineNotifyAndFediverse": "এই স্ট্রিম অফলাইন। আপনি <span class='notify-link'>বিজ্ঞপ্তি গ্রহণ করতে পারবেন</span> পরবর্তী সময় {{streamer}} লাইভ হলে অথবা <span class='follow-link'>ফলো করতে পারবেন</span> {{fediverseAccount}} কে Fediverse এ।",
"offlineNotifyOnly": "এই স্ট্রিম অফলাইন। <span class='notify-link'>বিজ্ঞপ্তি পান</span> পরবর্তী সময় {{streamer}} লাইভ হলে।"
"offlineNotifyOnly": "এই স্ট্রিম অফলাইন। <span class='notify-link'>বিজ্ঞপ্তি পান</span> পরবর্তী সময় {{streamer}} লাইভ হলে।",
"chatEmbedTitle": "{{name}} চ্যাট"
},
"Testing": {
"itemCount": "আপনার {{count}} টি আইটেম রয়েছে",
+2 -1
View File
@@ -221,7 +221,8 @@
"offlineBasic": "Dieser Stream ist offline. Schauen Sie bald wieder!",
"offlineFediverseOnly": "Dieser Stream ist offline. <span class='follow-link'>Folgen Sie</span> {{fediverseAccount}} auf der Feier, um das nächste Mal {{streamer}} live zu sehen.",
"offlineNotifyAndFediverse": "Dieser Stream ist offline. Sie können <span class='notify-link'>benachrichtigt werden,</span> wenn {{streamer}} das nächste Mal live geht, oder <span class='follow-link'>folgen Sie</span> {{fediverseAccount}} auf dem Fediverse.",
"offlineNotifyOnly": "Dieser Stream ist offline. <span class='notify-link'>Werde benachrichtigt</span> wenn {{streamer}} das nächste Mal live geht."
"offlineNotifyOnly": "Dieser Stream ist offline. <span class='notify-link'>Werde benachrichtigt</span> wenn {{streamer}} das nächste Mal live geht.",
"chatEmbedTitle": "{{name}} Chat"
},
"Testing": {
"itemCount": "Sie haben {{count}} Elemente",
+2 -1
View File
@@ -221,7 +221,8 @@
"offlineBasic": "Αυτό το ρεύμα είναι εκτός σύνδεσης. Ελέγξτε ξανά σύντομα!",
"offlineFediverseOnly": "Αυτό το ρεύμα είναι εκτός σύνδεσης. <span class='follow-link'>Ακολουθήστε</span> {{fediverseAccount}} στο Fediverse για να δείτε την επόμενη φορά που το {{streamer}} ζωντανά.",
"offlineNotifyAndFediverse": "Αυτή η ροή είναι εκτός σύνδεσης. Μπορείτε να ειδοποιηθείτε <span class='notify-link'></span> την επόμενη φορά που το {{streamer}} πηγαίνει ζωντανά ή <span class='follow-link'>ακολουθήστε το</span> {{fediverseAccount}} στο Fediverse.",
"offlineNotifyOnly": "Αυτή η ροή είναι εκτός σύνδεσης. <span class='notify-link'>Θα ειδοποιηθείτε</span> την επόμενη φορά που το {{streamer}} θα συνδεθεί."
"offlineNotifyOnly": "Αυτή η ροή είναι εκτός σύνδεσης. <span class='notify-link'>Θα ειδοποιηθείτε</span> την επόμενη φορά που το {{streamer}} θα συνδεθεί.",
"chatEmbedTitle": "{{name}} Συζήτηση"
},
"Testing": {
"itemCount": "Έχετε {{count}} αντικείμενα",
+2 -1
View File
@@ -221,7 +221,8 @@
"offlineBasic": "This stream is offline. Check back soon!",
"offlineFediverseOnly": "This stream is offline. <span class='follow-link'>Follow</span> {{fediverseAccount}} on the Fediverse to see the next time {{streamer}} goes live.",
"offlineNotifyAndFediverse": "This stream is offline. You can <span class='notify-link'>be notified</span> the next time {{streamer}} goes live or <span class='follow-link'>follow</span> {{fediverseAccount}} on the Fediverse.",
"offlineNotifyOnly": "This stream is offline. <span class='notify-link'>Be notified</span> the next time {{streamer}} goes live."
"offlineNotifyOnly": "This stream is offline. <span class='notify-link'>Be notified</span> the next time {{streamer}} goes live.",
"chatEmbedTitle": "{{name}} Chat"
},
"Testing": {
"itemCount": "You have {{count}} items",
+2 -1
View File
@@ -221,7 +221,8 @@
"offlineBasic": "Esta corriente está fuera de línea. ¡Vuelve pronto!",
"offlineFediverseOnly": "Este stream está fuera de línea. <span class='follow-link'>Sigue</span> {{fediverseAccount}} en el Fediverso para ver la próxima vez que {{streamer}} se ponga en directo.",
"offlineNotifyAndFediverse": "Esta transmisión está desconectada. Puede <span class='notify-link'>recibir una notificación</span> la próxima vez que {{streamer}} salga en directo o <span class='follow-link'>seguir</span> {{fediverseAccount}} en Fediverse.",
"offlineNotifyOnly": "Este stream está desconectado. <span class='notify-link'>Ser notificado</span> la próxima vez que {{streamer}} vaya en directo."
"offlineNotifyOnly": "Este stream está desconectado. <span class='notify-link'>Ser notificado</span> la próxima vez que {{streamer}} vaya en directo.",
"chatEmbedTitle": "Chat {{name}}"
},
"Testing": {
"itemCount": "Tienes {{count}} elementos",
+2 -1
View File
@@ -221,7 +221,8 @@
"offlineBasic": "Torrente hau offline dago. Itzuli laster!",
"offlineFediverseOnly": "Torrente hau offline dago. <span class='follow-link'>Jarraitzaile</span> {{fediverseAccount}} Fediversen hurrengoa {{streamer}} zuzenean joan denean ikusteko.",
"offlineNotifyAndFediverse": "Torrente hau offline dago. <span class='notify-link'>Jakinarazi</span> hurrengoa {{streamer}} zuzenean joan denean edo <span class='follow-link'>jarraitu</span> {{fediverseAccount}} Fediversen.",
"offlineNotifyOnly": "Torrente hau offline dago. <span class='notify-link'>Jakinarazi</span> hurrengoa {{streamer}} zuzenean joan denean."
"offlineNotifyOnly": "Torrente hau offline dago. <span class='notify-link'>Jakinarazi</span> hurrengoa {{streamer}} zuzenean joan denean.",
"chatEmbedTitle": "{{name}} Txata"
},
"Testing": {
"itemCount": "{{count}} elementu dituzu",
+2 -1
View File
@@ -221,7 +221,8 @@
"offlineBasic": "Ce flux est hors ligne. Revenez bientôt !",
"offlineFediverseOnly": "Ce flux est hors ligne. <span class='follow-link'>Suivez</span> {{fediverseAccount}} sur le Fédiverse pour voir la prochaine fois que {{streamer}} sera en live.",
"offlineNotifyAndFediverse": "Ce flux est hors ligne. Vous pouvez <span class='notify-link'>être notifié</span> la prochaine fois que {{streamer}} sera en direct ou <span class='follow-link'>suivre</span> {{fediverseAccount}} sur le Fediverse.",
"offlineNotifyOnly": "Ce flux est hors ligne. <span class='notify-link'>Être notifié</span> la prochaine fois que {{streamer}} sera en ligne."
"offlineNotifyOnly": "Ce flux est hors ligne. <span class='notify-link'>Être notifié</span> la prochaine fois que {{streamer}} sera en ligne.",
"chatEmbedTitle": "Chat {{name}}"
},
"Testing": {
"itemCount": "Vous avez des objets {{count}}",
+2 -1
View File
@@ -221,7 +221,8 @@
"offlineBasic": "Tá an sruth seo as líne. Seiceáil arís go luath!",
"offlineFediverseOnly": "Tá an sruth seo as líne. <span class='follow-link'>Lean</span> {{fediverseAccount}} ar an Fediverse chun a fheiceáil an uair a bhíonn {{streamer}} ar líne.",
"offlineNotifyAndFediverse": "Tá an sruth seo as líne. Is féidir leat <span class='notify-link'>a bheith rabhaidh</span> an uair a bhíonn {{streamer}} ar líne nó <span class='follow-link'>lean</span> {{fediverseAccount}} ar an Fediverse.",
"offlineNotifyOnly": "Tá an sruth seo as líne. <span class='notify-link'>Bí rabhaidh</span> an uair a bhíonn {{streamer}} ar líne."
"offlineNotifyOnly": "Tá an sruth seo as líne. <span class='notify-link'>Bí rabhaidh</span> an uair a bhíonn {{streamer}} ar líne.",
"chatEmbedTitle": "{{name}} Comhrá"
},
"Testing": {
"itemCount": "Tá {{count}} réadanna agat",
+2 -1
View File
@@ -221,7 +221,8 @@
"offlineBasic": "यह स्ट्रीम ऑफ़लाइन है। कृपया जल्द ही वापस आएं!",
"offlineFediverseOnly": "यह स्ट्रीम ऑफ़लाइन है। <span class='follow-link'>अनुसरण करें</span> {{fediverseAccount}} Fediverse पर अगली बार देखने के लिए जब {{streamer}} लाइव जाएं।",
"offlineNotifyAndFediverse": "यह स्ट्रीम ऑफ़लाइन है। आप अगले बार जब {{streamer}} लाइव जाएं तो <span class='notify-link'>सूचित</span> हो सकते हैं या <span class='follow-link'>अनुसरण</span> कर सकते हैं {{fediverseAccount}} को Fediverse पर।",
"offlineNotifyOnly": "यह स्ट्रीम ऑफ़लाइन है। <span class='notify-link'>सूचित हों</span> अगली बार जब {{streamer}} लाइव जाएं।"
"offlineNotifyOnly": "यह स्ट्रीम ऑफ़लाइन है। <span class='notify-link'>सूचित हों</span> अगली बार जब {{streamer}} लाइव जाएं।",
"chatEmbedTitle": "{{name}} चैट"
},
"Testing": {
"itemCount": "आपके पास {{count}} आइटम हैं",
+2 -1
View File
@@ -221,7 +221,8 @@
"offlineBasic": "Ovaj stream je offline. Provjerite ponovno uskoro!",
"offlineFediverseOnly": "Ovaj stream je offline. <span class='follow-link'>Pratite</span> {{fediverseAccount}} na Fediversu kako biste vidjeli kada {{streamer}} ponovno ide uživo.",
"offlineNotifyAndFediverse": "Ovaj stream je offline. Možete <span class='notify-link'>biti obaviješteni</span> kada {{streamer}} ponovno ide uživo ili <span class='follow-link'>pratiti</span> {{fediverseAccount}} na Fediversu.",
"offlineNotifyOnly": "Ovaj stream je offline. <span class='notify-link'>Budite obaviješteni</span> kada {{streamer}} ponovno ide uživo."
"offlineNotifyOnly": "Ovaj stream je offline. <span class='notify-link'>Budite obaviješteni</span> kada {{streamer}} ponovno ide uživo.",
"chatEmbedTitle": "{{name}} Čavrljanje"
},
"Testing": {
"itemCount": "Imate {{count}} stavki",
+2 -1
View File
@@ -221,7 +221,8 @@
"offlineBasic": "Questo stream è offline. Riprova presto!",
"offlineFediverseOnly": "Questo stream è offline. <span class='follow-link'>Segui</span> {{fediverseAccount}} sul Fediverse per vedere la prossima volta che {{streamer}} andrà in diretta.",
"offlineNotifyAndFediverse": "Questo stream è offline. È possibile notificare <span class='notify-link'></span> la prossima volta che {{streamer}} va in diretta o <span class='follow-link'>seguire</span> {{fediverseAccount}} sul Fediverse.",
"offlineNotifyOnly": "Questo stream è offline. <span class='notify-link'>Sii avvisato</span> la prossima volta che {{streamer}} andrà in diretta."
"offlineNotifyOnly": "Questo stream è offline. <span class='notify-link'>Sii avvisato</span> la prossima volta che {{streamer}} andrà in diretta.",
"chatEmbedTitle": "Chat {{name}}"
},
"Testing": {
"itemCount": "Hai {{count}} oggetti",
+2 -1
View File
@@ -221,7 +221,8 @@
"offlineBasic": "このストリームはオフラインです。もう一度お試しください!",
"offlineFediverseOnly": "このストリームはオフラインです。 <span class='follow-link'></span> {{fediverseAccount}} をFediverse でフォローして、次回 {{streamer}} がライブになるのを確認します。",
"offlineNotifyAndFediverse": "このストリームはオフラインです。次回のライブ配信は {{streamer}} <span class='notify-link'>に通知さ</span>れるか、Fediverse で {{fediverseAccount}} を<span class='follow-link'>フォローして</span>ください。",
"offlineNotifyOnly": "このストリームはオフラインです。 <span class='notify-link'></span> 次回 {{streamer}} がライブになるときに通知されます。"
"offlineNotifyOnly": "このストリームはオフラインです。 <span class='notify-link'></span> 次回 {{streamer}} がライブになるときに通知されます。",
"chatEmbedTitle": "{{name}} チャット"
},
"Testing": {
"itemCount": "{{count}} 個のアイテムがあります",
+2 -1
View File
@@ -221,7 +221,8 @@
"offlineBasic": "이 스트림은 오프라인 상태입니다. 곧 다시 확인해주세요!",
"offlineFediverseOnly": "이 스트림은 오프라인 상태입니다. 다음 번 {{streamer}} 라이브 방송을 보려면 Fediverse에서 {{fediverseAccount}} <span class='follow-link'>팔로우하세요</span>.",
"offlineNotifyAndFediverse": "이 스트림은 오프라인 상태입니다. 다음 번에 {{streamer}} 가 생방송될 때 <span class='notify-link'>알림을</span> 받거나 페디버스에서 {{fediverseAccount}} 을 <span class='follow-link'>팔로우하세요</span>.",
"offlineNotifyOnly": "이 스트림은 오프라인 상태입니다. 다음에 {{streamer}} 가 생방송될 때 <span class='notify-link'>알림을</span> 받으세요."
"offlineNotifyOnly": "이 스트림은 오프라인 상태입니다. 다음에 {{streamer}} 가 생방송될 때 <span class='notify-link'>알림을</span> 받으세요.",
"chatEmbedTitle": "{{name}} 채팅"
},
"Testing": {
"itemCount": "귀하는 {{count}} 개 항목이 있습니다",
+2 -1
View File
@@ -221,7 +221,8 @@
"offlineBasic": "Saluran ini sedang offline. Periksa kembali sebentar lagi!",
"offlineFediverseOnly": "Saluran ini sedang offline. <span class='follow-link'>Ikuti</span> {{fediverseAccount}} di Fediverse untuk melihat kali seterusnya {{streamer}} siaran langsung.",
"offlineNotifyAndFediverse": "Saluran ini sedang offline. Anda boleh <span class='notify-link'>diberitahu</span> kali seterusnya {{streamer}} siaran langsung atau <span class='follow-link'>ikuti</span> {{fediverseAccount}} di Fediverse.",
"offlineNotifyOnly": "Saluran ini sedang offline. <span class='notify-link'>Diberitahu</span> kali seterusnya {{streamer}} siaran langsung."
"offlineNotifyOnly": "Saluran ini sedang offline. <span class='notify-link'>Diberitahu</span> kali seterusnya {{streamer}} siaran langsung.",
"chatEmbedTitle": "{{name}} Sembang"
},
"Testing": {
"itemCount": "Anda mempunyai {{count}} item",
+2 -1
View File
@@ -221,7 +221,8 @@
"offlineBasic": "Deze stream is offline. Kom snel terug!",
"offlineFediverseOnly": "Deze stream is offline. <span class='follow-link'>Volg</span> {{fediverseAccount}} op de Fediverse om de volgende keer dat {{streamer}} live gaat te zien.",
"offlineNotifyAndFediverse": "Deze stream is offline. U kunt <span class='notify-link'>een bericht ontvangen</span> wanneer {{streamer}} weer live gaat of {{fediverseAccount}} <span class='follow-link'>volgen</span> op de Fediverse.",
"offlineNotifyOnly": "Deze stream is offline. <span class='notify-link'>Krijg een melding</span> de volgende keer dat {{streamer}} live gaat."
"offlineNotifyOnly": "Deze stream is offline. <span class='notify-link'>Krijg een melding</span> de volgende keer dat {{streamer}} live gaat.",
"chatEmbedTitle": "{{name}} chat"
},
"Testing": {
"itemCount": "Je hebt {{count}} items",
+2 -1
View File
@@ -221,7 +221,8 @@
"offlineBasic": "Denne strømmen er frakoblet. Sjekk igjen snart!",
"offlineFediverseOnly": "Denne strømmen er frakoblet. <span class='follow-link'>Følg</span> {{fediverseAccount}} på Fediverse for å se neste gang {{streamer}} går liv.",
"offlineNotifyAndFediverse": "Denne strømmen er offline. Du kan <span class='notify-link'>bli varslet</span> neste gang {{streamer}} er live, eller <span class='follow-link'>følge</span> {{fediverseAccount}} på Fediverse.",
"offlineNotifyOnly": "Denne strømmen er frakoblet. <span class='notify-link'>bli varslet</span> neste gang {{streamer}} går bor."
"offlineNotifyOnly": "Denne strømmen er frakoblet. <span class='notify-link'>bli varslet</span> neste gang {{streamer}} går bor.",
"chatEmbedTitle": "{{name}} Chat"
},
"Testing": {
"itemCount": "Du har {{count}} objekter",
+2 -1
View File
@@ -221,7 +221,8 @@
"offlineBasic": "ਇਹ ਸਟ੍ਰੀਮ ਅਫਲਾਈਨ ਹੈ। ਜਲਦੀ ਹੀ ਵਾਪਸ ਆਓ!",
"offlineFediverseOnly": "ਇਹ ਸਟ੍ਰੀਮ ਅਫਲਾਈਨ ਹੈ। ਜਅਦੋ {{streamer}} ਲਾਈਵ ਹੋਵੇਗਾ, ਗੇਰੀ ਮਨੱਤਾ ਤੋਂ <span class='follow-link'>ਜਾਣੋ</span>।",
"offlineNotifyAndFediverse": "ਇਹ ਸਟ੍ਰੀਮ ਅਫਲਾਈਨ ਹੈ। ਜਦੋਂ {{streamer}} ਲਾਈਵ ਹੋਵੇਗਾ, ਤੁਸੀਂ <span class='notify-link'>ਜਾਣੋ</span> ਕਰ ਸਕਦੇ ਹੋ ਜਾਂ <span class='follow-link'>ਫਾਲੋ</span> {{fediverseAccount}} ਫੈਡੀਵਰਸ 'ਤੇ।",
"offlineNotifyOnly": "ਇਹ ਸਟ੍ਰੀਮ ਅਫਲਾਈਨ ਹੈ। <span class='notify-link'>ਜਾਣੋ</span> ਜਦੋਂ {{streamer}} ਲਾਈਵ ਹੋਵੇਗਾ।"
"offlineNotifyOnly": "ਇਹ ਸਟ੍ਰੀਮ ਅਫਲਾਈਨ ਹੈ। <span class='notify-link'>ਜਾਣੋ</span> ਜਦੋਂ {{streamer}} ਲਾਈਵ ਹੋਵੇਗਾ।",
"chatEmbedTitle": "{{name}} ਚੈਟ"
},
"Testing": {
"itemCount": "ਤੁਹਾਡੇ ਕੋਲ {{count}} ਆਈਟਮ ਹਨ",
+2 -1
View File
@@ -221,7 +221,8 @@
"offlineBasic": "Ten strumień jest offline. Sprawdź wkrótce!",
"offlineFediverseOnly": "Ten strumień jest offline. <span class='follow-link'>Śledź</span> {{fediverseAccount}} na Fediwerse, aby zobaczyć kiedy następny {{streamer}} będzie na żywo.",
"offlineNotifyAndFediverse": "Ten strumień jest offline. Możesz <span class='notify-link'>zostać powiadomiony</span> następnym razem, gdy {{streamer}} pojawi się lub <span class='follow-link'>podążaj za</span> {{fediverseAccount}} na Fediverse.",
"offlineNotifyOnly": "Ta transmisja jest wyłączona. <span class='notify-link'>Otrzymaj powiadomienie</span>, gdy następnym razem strona {{streamer}} zostanie uruchomiona."
"offlineNotifyOnly": "Ta transmisja jest wyłączona. <span class='notify-link'>Otrzymaj powiadomienie</span>, gdy następnym razem strona {{streamer}} zostanie uruchomiona.",
"chatEmbedTitle": "Czat {{name}}"
},
"Testing": {
"itemCount": "Masz {{count}} przedmiotów",
+2 -1
View File
@@ -221,7 +221,8 @@
"offlineBasic": "Esta transmissão está offline. Volte em breve!",
"offlineFediverseOnly": "Esta transmissão está off-line. <span class='follow-link'>Siga</span> {{fediverseAccount}} no Fediverse para ver a próxima vez que {{streamer}} estiver ao vivo.",
"offlineNotifyAndFediverse": "Esta transmissão está offline. <span class='notify-link'>Pode ser notificado</span> na próxima vez que {{streamer}} for ao ar ou <span class='follow-link'>siga</span> {{fediverseAccount}} no Fediverse.",
"offlineNotifyOnly": "Esta transmissão está offline. U <span class='notify-link'>Seja notificado</span> na próxima vez que {{streamer}} for ao ar."
"offlineNotifyOnly": "Esta transmissão está offline. U <span class='notify-link'>Seja notificado</span> na próxima vez que {{streamer}} for ao ar.",
"chatEmbedTitle": "{{name}} Chat"
},
"Testing": {
"itemCount": "Você tem {{count}} itens",
+2 -1
View File
@@ -221,7 +221,8 @@
"offlineBasic": "Этот поток оффлайн. Возвращайтесь в ближайшее время!",
"offlineFediverseOnly": "Этот поток отключен. <span class='follow-link'>Следите за</span> {{fediverseAccount}} на Fediverse, чтобы узнать, когда {{streamer}} выйдет в эфир в следующий раз.",
"offlineNotifyAndFediverse": "Этот поток отключен. Вы можете <span class='notify-link'>получить уведомление о</span> следующем выходе {{streamer}} в прямой эфир или <span class='follow-link'>следить за</span> {{fediverseAccount}} на Fediverse.",
"offlineNotifyOnly": "Этот поток отключен. <span class='notify-link'>Получите уведомление</span>, когда {{streamer}} появится в следующий раз."
"offlineNotifyOnly": "Этот поток отключен. <span class='notify-link'>Получите уведомление</span>, когда {{streamer}} появится в следующий раз.",
"chatEmbedTitle": "{{name}} Чат"
},
"Testing": {
"itemCount": "У вас есть {{count}} элементов",
+2 -1
View File
@@ -221,7 +221,8 @@
"offlineBasic": "Denna ström är offline. Kom tillbaka snart!",
"offlineFediverseOnly": "Denna ström är offline. <span class='follow-link'>Följ</span> {{fediverseAccount}} på Fediverse för att se nästa gång {{streamer}} går live.",
"offlineNotifyAndFediverse": "Denna ström är offline. Du kan <span class='notify-link'>meddelas</span> nästa gång {{streamer}} går live eller <span class='follow-link'>följ</span> {{fediverseAccount}} på Fediverse.",
"offlineNotifyOnly": "Denna ström är offline. <span class='notify-link'>meddelas</span> nästa gång {{streamer}} går live."
"offlineNotifyOnly": "Denna ström är offline. <span class='notify-link'>meddelas</span> nästa gång {{streamer}} går live.",
"chatEmbedTitle": "{{name}} Chatt"
},
"Testing": {
"itemCount": "Du har {{count}} objekt",
+2 -1
View File
@@ -221,7 +221,8 @@
"offlineBasic": "สตรีมนี้ออฟไลน์ กรุณาตรวจสอบอีกครั้งในภายหลัง!",
"offlineFediverseOnly": "สตรีมนี้ออฟไลน์ <span class='follow-link'>ติดตาม</span> {{fediverseAccount}} บน Fediverse เพื่อดูเมื่อ {{streamer}} เริ่มถ่ายทอดสดครั้งต่อไป",
"offlineNotifyAndFediverse": "สตรีมนี้ออฟไลน์ คุณสามารถ <span class='notify-link'>รับการแจ้งเตือน</span> เมื่อ {{streamer}} เริ่มถ่ายทอดสดอีกครั้งหรือ <span class='follow-link'>ติดตาม</span> {{fediverseAccount}} บน Fediverse",
"offlineNotifyOnly": "สตรีมนี้ออฟไลน์ <span class='notify-link'>รับการแจ้งเตือน</span> เมื่อ {{streamer}} เริ่มถ่ายทอดสด"
"offlineNotifyOnly": "สตรีมนี้ออฟไลน์ <span class='notify-link'>รับการแจ้งเตือน</span> เมื่อ {{streamer}} เริ่มถ่ายทอดสด",
"chatEmbedTitle": "{{name}} แชนแนลแชท"
},
"Testing": {
"itemCount": "คุณมี {{count}} รายการ",
+2 -1
View File
@@ -221,7 +221,8 @@
"offlineBasic": "Luồng này đang ngoại tuyến. Hãy quay lại sau!",
"offlineFediverseOnly": "Luồng này đang ngoại tuyến. <span class='follow-link'>Theo dõi</span> {{fediverseAccount}} trên Fediverse để xem lần tới {{streamer}} trực tiếp.",
"offlineNotifyAndFediverse": "Luồng này đang ngoại tuyến. Bạn có thể <span class='notify-link'>được thông báo</span> khi {{streamer}} trực tiếp hoặc <span class='follow-link'>theo dõi</span> {{fediverseAccount}} trên Fediverse.",
"offlineNotifyOnly": "Luồng này đang ngoại tuyến. <span class='notify-link'>Được thông báo</span> khi {{streamer}} trực tiếp."
"offlineNotifyOnly": "Luồng này đang ngoại tuyến. <span class='notify-link'>Được thông báo</span> khi {{streamer}} trực tiếp.",
"chatEmbedTitle": "Chat {{name}}"
},
"Testing": {
"itemCount": "Bạn có {{count}} mục",
+2 -1
View File
@@ -221,7 +221,8 @@
"offlineBasic": "此串流已離線。請稍後再回來查看!",
"offlineFediverseOnly": "此串流已離線。請在 Fediverse<span class='follow-link'>上追</span>蹤 {{fediverseAccount}} ,以查看 {{streamer}} 的下次直播時間。",
"offlineNotifyAndFediverse": "此串流已離線。您可以在 {{streamer}} 上線時<span class='notify-link'>收到通知,</span>或在 Fediverse<span class='follow-link'>上追蹤</span> {{fediverseAccount}}。",
"offlineNotifyOnly": "此串流已離線。下次 {{streamer}} 上線時,您<span class='notify-link'>將會收到通知</span>。"
"offlineNotifyOnly": "此串流已離線。下次 {{streamer}} 上線時,您<span class='notify-link'>將會收到通知</span>。",
"chatEmbedTitle": "{{name}} 聊天"
},
"Testing": {
"itemCount": "您有 {{count}} 項目",
+3
View File
@@ -59,6 +59,9 @@ module.exports = async phase => {
productionBrowserSourceMaps: process.env.SOURCE_MAPS === 'true',
trailingSlash: true,
reactStrictMode: true,
eslint: {
ignoreDuringBuilds: true,
},
images: {
unoptimized: true,
},
+1189 -1302
View File
File diff suppressed because it is too large Load Diff
+6 -6
View File
@@ -41,8 +41,8 @@
"@next/bundle-analyzer": "^14.0.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@uiw/codemirror-theme-bbedit": "4.25.8",
"@uiw/react-codemirror": "4.25.8",
"@uiw/codemirror-theme-bbedit": "4.25.9",
"@uiw/react-codemirror": "4.25.9",
"@xstate/react": "3.2.2",
"antd": "4.24.16",
"autoprefixer": "^10.4.14",
@@ -105,10 +105,10 @@
"@types/sanitize-html": "^2.9.0",
"@types/ua-parser-js": "0.7.39",
"@types/video.js": "^7.3.50",
"@typescript-eslint/eslint-plugin": "8.57.1",
"@typescript-eslint/parser": "8.57.1",
"@typescript-eslint/eslint-plugin": "8.57.2",
"@typescript-eslint/parser": "8.57.2",
"babel-loader": "10.1.1",
"chromatic": "15.3.0",
"chromatic": "16.0.0",
"css-loader": "7.1.4",
"cypress": "^15.0.0",
"eslint": "8.57.1",
@@ -121,7 +121,7 @@
"eslint-plugin-react": "7.37.5",
"eslint-plugin-react-hooks": "4.6.2",
"eslint-plugin-storybook": "9.1.20",
"handlebars": "^4.7.7",
"handlebars": "^4.7.9",
"html-webpack-plugin": "5.6.6",
"install": "^0.13.0",
"jest-environment-jsdom": "^30.0.4",
+15 -1
View File
@@ -1,4 +1,6 @@
import { useRecoilValue } from 'recoil';
import Head from 'next/head';
import { useTranslation } from 'next-export-i18n';
import { ErrorBoundary } from 'react-error-boundary';
import { ChatMessage } from '../../../../interfaces/chat-message.model';
import { ChatContainer } from '../../../../components/chat/ChatContainer/ChatContainer';
@@ -6,22 +8,34 @@ import {
ClientConfigStore,
currentUserAtom,
visibleChatMessagesSelector,
clientConfigStateAtom,
isChatAvailableSelector,
} from '../../../../components/stores/ClientConfigStore';
import { ClientConfig } from '../../../../interfaces/client-config.model';
import { Theme } from '../../../../components/theme/Theme';
import { ComponentError } from '../../../../components/ui/ComponentError/ComponentError';
import { Localization } from '../../../../types/localization';
export default function ReadOnlyChatEmbed() {
const currentUser = useRecoilValue(currentUserAtom);
const messages = useRecoilValue<ChatMessage[]>(visibleChatMessagesSelector);
const clientConfig = useRecoilValue<ClientConfig>(clientConfigStateAtom);
const isChatAvailable = useRecoilValue(isChatAvailableSelector);
const { name } = clientConfig;
const { t } = useTranslation();
const pageTitle = name ? t(Localization.Frontend.chatEmbedTitle, { name }) : 'Chat';
return (
<div>
<Head>
<title>{pageTitle}</title>
</Head>
<ErrorBoundary
// eslint-disable-next-line react/no-unstable-nested-components
fallbackRender={({ error }) => (
<ComponentError componentName="ReadWriteChatEmbed" message={error.message} />
<ComponentError componentName="ReadOnlyChatEmbed" message={error.message} />
)}
>
<ClientConfigStore />
+7 -1
View File
@@ -1,8 +1,9 @@
/* eslint-disable react/no-unknown-property */
import { useRecoilValue } from 'recoil';
import { useEffect } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import Head from 'next/head';
import { useTranslation } from 'next-export-i18n';
import { ErrorBoundary } from 'react-error-boundary';
import { ChatMessage } from '../../../../interfaces/chat-message.model';
import { ChatContainer } from '../../../../components/chat/ChatContainer/ChatContainer';
import {
@@ -50,6 +51,8 @@ export default function ReadWriteChatEmbed() {
const headerText = online ? streamTitle || name : name;
const pageTitle = name ? t(Localization.Frontend.chatEmbedTitle, { name }) : 'Chat';
// This is a hack to force a specific body background color for just this page.
useEffect(() => {
document.body.classList.add('body-background');
@@ -57,6 +60,9 @@ export default function ReadWriteChatEmbed() {
return (
<div>
<Head>
<title>{pageTitle}</title>
</Head>
<style jsx global>
{`
.body-background {

Some files were not shown because too many files have changed in this diff Show More