diff --git a/.github/workflows/auto-comment-on-label.yaml b/.github/workflows/auto-comment-on-label.yaml index d4a2448fe..dab82cd0a 100644 --- a/.github/workflows/auto-comment-on-label.yaml +++ b/.github/workflows/auto-comment-on-label.yaml @@ -11,7 +11,7 @@ jobs: issues: write steps: - name: Add comment - uses: peter-evans/create-or-update-comment@0917427245f534bf3543b3a25a7ccf7efcb1bcbe + uses: peter-evans/create-or-update-comment@48bb05bd5554c378187694936d277d48652922e7 with: issue-number: ${{ github.event.issue.number }} body: | diff --git a/.github/workflows/automated-end-to-end-api.yaml b/.github/workflows/automated-end-to-end-api.yaml index cce4c58b8..d42c9a2cc 100644 --- a/.github/workflows/automated-end-to-end-api.yaml +++ b/.github/workflows/automated-end-to-end-api.yaml @@ -35,7 +35,7 @@ jobs: - uses: actions/checkout@v4 - name: Run API tests - uses: nick-fields/retry@v2 + uses: nick-fields/retry@v3 with: timeout_minutes: 10 max_attempts: 3 diff --git a/.github/workflows/browser-testing.yml b/.github/workflows/browser-testing.yml index 2cd745553..61f780964 100644 --- a/.github/workflows/browser-testing.yml +++ b/.github/workflows/browser-testing.yml @@ -24,10 +24,10 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 18.9.0 + node-version: latest - name: Cache node modules - uses: actions/cache@v3 + uses: actions/cache@v4 env: cache-name: cache-node-modules-browser-tests with: @@ -38,7 +38,7 @@ jobs: ${{ runner.os }}-build- ${{ runner.os }}- - - uses: actions/setup-go@v4 + - uses: actions/setup-go@v5 with: go-version: '1.21' cache: true @@ -47,7 +47,7 @@ jobs: run: sudo apt-get update && sudo apt-get install google-chrome-stable - name: Run Browser tests - uses: nick-fields/retry@v2 + uses: nick-fields/retry@v3 with: timeout_minutes: 20 max_attempts: 3 diff --git a/.github/workflows/build-storybook.yml b/.github/workflows/build-storybook.yml index f3b7a315b..ca6c2f358 100644 --- a/.github/workflows/build-storybook.yml +++ b/.github/workflows/build-storybook.yml @@ -15,7 +15,7 @@ jobs: uses: actions/checkout@v4 - name: Cache node modules - uses: actions/cache@v3 + uses: actions/cache@v4 env: cache-name: cache-node-modules-bundle-web-app with: @@ -47,7 +47,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Dispatch event to web site - uses: peter-evans/repository-dispatch@v2 + uses: peter-evans/repository-dispatch@v3 with: token: ${{ secrets.BUNDLE_STORYBOOK_OWNCAST_ONLINE }} repository: owncast/owncast.github.io diff --git a/.github/workflows/chromatic.yml b/.github/workflows/chromatic.yml index ca2336b13..7b9ea24d9 100644 --- a/.github/workflows/chromatic.yml +++ b/.github/workflows/chromatic.yml @@ -43,7 +43,7 @@ jobs: - name: Publish to Chromatic if: ${{ github.actor != 'renovate[bot]' && github.actor != 'renovate' }} - uses: chromaui/action@v1 + uses: chromaui/action@v11 # Chromatic GitHub Action options with: workingDir: web diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index adbad557a..52ddf668a 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -41,7 +41,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v2 + uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} config-file: ./.github/codeql/${{ matrix.language }}.yml @@ -53,7 +53,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v2 + uses: github/codeql-action/autobuild@v3 # âšī¸ Command-line programs to run using the OS shell. # đ https://git.io/JvXDl @@ -67,4 +67,4 @@ jobs: # make release - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/container.yaml b/.github/workflows/container.yaml index d8275833b..7993e1db3 100644 --- a/.github/workflows/container.yaml +++ b/.github/workflows/container.yaml @@ -49,7 +49,7 @@ jobs: EARTHLY_BUILD_TAG: 'nightly' EARTHLY_BUILD_BRANCH: 'develop' EARTHLY_PUSH: true - uses: nick-fields/retry@v2 + uses: nick-fields/retry@v3 with: timeout_minutes: 20 max_attempts: 3 diff --git a/.github/workflows/go-lint.yml b/.github/workflows/go-lint.yml index a9dd2e8b2..169025fd2 100644 --- a/.github/workflows/go-lint.yml +++ b/.github/workflows/go-lint.yml @@ -26,13 +26,13 @@ jobs: with: fetch-depth: 0 - - uses: actions/setup-go@v4 + - uses: actions/setup-go@v5 with: go-version: '1.21' cache: true - uses: actions/checkout@v4 - name: golangci-lint - uses: golangci/golangci-lint-action@v3 + uses: golangci/golangci-lint-action@v4 with: only-new-issues: true args: --timeout=3m diff --git a/.github/workflows/go-tests.yaml b/.github/workflows/go-tests.yaml index 858a2a7fd..d77b526cb 100644 --- a/.github/workflows/go-tests.yaml +++ b/.github/workflows/go-tests.yaml @@ -18,7 +18,7 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: actions/cache@v3 + - uses: actions/cache@v4 with: path: | ~/.cache/go-build @@ -28,7 +28,7 @@ jobs: go-test- - name: Install go - uses: actions/setup-go@v4 + uses: actions/setup-go@v5 with: go-version: '^1' cache: true @@ -49,7 +49,7 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: actions/cache@v3 + - uses: actions/cache@v4 with: path: | ~/.cache/go-build @@ -59,7 +59,7 @@ jobs: go-test- - name: Install go - uses: actions/setup-go@v4 + uses: actions/setup-go@v5 with: go-version: '^1' cache: true diff --git a/.github/workflows/hls-tests.yml b/.github/workflows/hls-tests.yml index 420e94808..1a7f94a16 100644 --- a/.github/workflows/hls-tests.yml +++ b/.github/workflows/hls-tests.yml @@ -25,13 +25,13 @@ jobs: with: concurrent_skipping: 'same_content_newer' - uses: actions/checkout@v4 - - uses: actions/setup-go@v4 + - uses: actions/setup-go@v5 with: go-version: '1.21' cache: true - name: Cache node modules - uses: actions/cache@v3 + uses: actions/cache@v4 env: cache-name: cache-node-modules-hls-tests with: @@ -43,14 +43,14 @@ jobs: ${{ runner.os }}- - name: Local stroage - uses: nick-fields/retry@v2 + uses: nick-fields/retry@v3 with: timeout_minutes: 10 max_attempts: 3 command: cd test/automated/hls && ./run.sh - name: S3 storage - uses: nick-fields/retry@v2 + uses: nick-fields/retry@v3 with: timeout_minutes: 10 max_attempts: 3 diff --git a/.github/workflows/javascript-format-build.yml b/.github/workflows/javascript-format-build.yml index efd19d35e..0ebc1d726 100644 --- a/.github/workflows/javascript-format-build.yml +++ b/.github/workflows/javascript-format-build.yml @@ -39,7 +39,7 @@ jobs: - name: Get changed files id: changed-files-yaml - uses: tj-actions/changed-files@v40 + uses: tj-actions/changed-files@v44 with: path: 'web' files_ignore: | @@ -50,7 +50,7 @@ jobs: - '**/*.{js,ts,tsx,jsx,css,md}' - name: Cache node modules - uses: actions/cache@v3 + uses: actions/cache@v4 env: cache-name: cache-node-modules-bundle-web-app with: @@ -106,7 +106,7 @@ jobs: fetch-depth: 0 - name: Cache node modules - uses: actions/cache@v3 + uses: actions/cache@v4 env: cache-name: cache-node-modules-bundle-web-app with: @@ -140,7 +140,7 @@ jobs: skip_after_successful_duplicate: 'true' - name: Cache node modules - uses: actions/cache@v3 + uses: actions/cache@v4 env: cache-name: cache-node-modules-bundle-web-app with: diff --git a/.github/workflows/javascript-tests.yml b/.github/workflows/javascript-tests.yml index 7cdc4a7ea..8bf6b37ee 100644 --- a/.github/workflows/javascript-tests.yml +++ b/.github/workflows/javascript-tests.yml @@ -25,7 +25,7 @@ jobs: node-version: 18.9.0 - name: Cache node modules - uses: actions/cache@v3 + uses: actions/cache@v4 env: cache-name: cache-node-modules-javascript-tests with: diff --git a/.github/workflows/screenshots.yml b/.github/workflows/screenshots.yml index 07c055d5d..811b85f6f 100644 --- a/.github/workflows/screenshots.yml +++ b/.github/workflows/screenshots.yml @@ -15,13 +15,13 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-go@v4 + - uses: actions/setup-go@v5 with: go-version: '1.21' cache: true - name: Cache node modules - uses: actions/cache@v3 + uses: actions/cache@v4 env: cache-name: cache-node-modules-screenshots with: @@ -33,7 +33,7 @@ jobs: ${{ runner.os }}- - name: Automate screenshots - uses: nick-fields/retry@v2 + uses: nick-fields/retry@v3 with: timeout_minutes: 10 max_attempts: 4 @@ -51,7 +51,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Dispatch event to web site - uses: peter-evans/repository-dispatch@v2 + uses: peter-evans/repository-dispatch@v3 with: token: ${{ secrets.BUNDLE_STORYBOOK_OWNCAST_ONLINE }} repository: owncast/owncast.github.io diff --git a/.github/workflows/shellcheck.yml b/.github/workflows/shellcheck.yml index 409a97c02..3d2989c50 100644 --- a/.github/workflows/shellcheck.yml +++ b/.github/workflows/shellcheck.yml @@ -18,7 +18,7 @@ jobs: env: LANG: C.UTF-8 container: - image: docker.io/ubuntu:23.10 + image: docker.io/ubuntu:24.04 steps: - uses: actions/checkout@v4 diff --git a/Dockerfile b/Dockerfile index 2c02f8acc..5c710a9c8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,7 +22,7 @@ ENV NAME=${NAME} RUN CGO_ENABLED=1 GOOS=linux go build -a -installsuffix cgo -ldflags "-extldflags \"-static\" -s -w -X github.com/owncast/owncast/config.GitCommit=$GIT_COMMIT -X github.com/owncast/owncast/config.VersionNumber=$VERSION -X github.com/owncast/owncast/config.BuildPlatform=$NAME" -o owncast . # Create the image by copying the result of the build into a new alpine image -FROM alpine:3.18.4 +FROM alpine:3.19.1 RUN apk update && apk add --no-cache ffmpeg ffmpeg-libs ca-certificates && update-ca-certificates RUN addgroup -g 101 -S owncast && adduser -u 101 -S owncast -G owncast diff --git a/activitypub/persistence/followers_fixture.go b/activitypub/persistence/followers_fixture.go new file mode 100644 index 000000000..e58a39a8c --- /dev/null +++ b/activitypub/persistence/followers_fixture.go @@ -0,0 +1,36 @@ +//go:build fixture +// +build fixture + +package persistence + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/owncast/owncast/models" + log "github.com/sirupsen/logrus" +) + +func addFollowersFixtureData() { + log.Println("Adding followers fixture data...") + file, err := os.Open("./test/fixture/followers_fixture.json") + if err != nil { + fmt.Println("Error opening file:", err) + return + } + defer file.Close() + + var followers []models.Follower + decoder := json.NewDecoder(file) + err = decoder.Decode(&followers) + if err != nil { + fmt.Println("Error decoding JSON:", err) + return + } + + // Iterate over the followers array + for _, follower := range followers { + createFollow(follower.ActorIRI, follower.Inbox, "", follower.Name, follower.Username, follower.Image, nil, true) + } +} diff --git a/activitypub/persistence/followers_nofixture.go b/activitypub/persistence/followers_nofixture.go new file mode 100644 index 000000000..30a881e3f --- /dev/null +++ b/activitypub/persistence/followers_nofixture.go @@ -0,0 +1,8 @@ +//go:build !fixture +// +build !fixture + +package persistence + +func addFollowersFixtureData() { + // no-op +} diff --git a/activitypub/persistence/persistence.go b/activitypub/persistence/persistence.go index 1f631053a..64ec39998 100644 --- a/activitypub/persistence/persistence.go +++ b/activitypub/persistence/persistence.go @@ -27,6 +27,7 @@ func Setup(datastore *data.Datastore) { createFederationFollowersTable() createFederationOutboxTable() createFederatedActivitiesTable() + addFollowersFixtureData() } // AddFollow will save a follow to the datastore. diff --git a/config/constants.go b/config/constants.go index 36fa69109..c7f8e24f1 100644 --- a/config/constants.go +++ b/config/constants.go @@ -4,7 +4,7 @@ import "path/filepath" const ( // StaticVersionNumber is the version of Owncast that is used when it's not overwritten via build-time settings. - StaticVersionNumber = "0.1.3" // Shown when you build from develop + StaticVersionNumber = "0.2.0" // Shown when you build from develop // FfmpegSuggestedVersion is the version of ffmpeg we suggest. FfmpegSuggestedVersion = "v4.1.5" // Requires the v // DataDirectory is the directory we save data to. diff --git a/controllers/admin/config.go b/controllers/admin/config.go index 5fd78e7df..0648803ed 100644 --- a/controllers/admin/config.go +++ b/controllers/admin/config.go @@ -802,6 +802,42 @@ func SetVideoServingEndpoint(w http.ResponseWriter, r *http.Request) { controllers.WriteSimpleResponse(w, true, "custom video serving endpoint updated") } +// SetChatSpamProtectionEnabled will enable or disable the chat spam protection. +func SetChatSpamProtectionEnabled(w http.ResponseWriter, r *http.Request) { + if !requirePOST(w, r) { + return + } + + configValue, success := getValueFromRequest(w, r) + if !success { + return + } + + if err := data.SetChatSpamProtectionEnabled(configValue.Value.(bool)); err != nil { + controllers.WriteSimpleResponse(w, false, err.Error()) + return + } + controllers.WriteSimpleResponse(w, true, "chat spam protection changed") +} + +// SetChatSlurFilterEnabled will enable or disable the chat slur filter. +func SetChatSlurFilterEnabled(w http.ResponseWriter, r *http.Request) { + if !requirePOST(w, r) { + return + } + + configValue, success := getValueFromRequest(w, r) + if !success { + return + } + + if err := data.SetChatSlurFilterEnabled(configValue.Value.(bool)); err != nil { + controllers.WriteSimpleResponse(w, false, err.Error()) + return + } + controllers.WriteSimpleResponse(w, true, "chat message slur filter changed") +} + func requirePOST(w http.ResponseWriter, r *http.Request) bool { if r.Method != controllers.POST { controllers.WriteSimpleResponse(w, false, r.Method+" not supported") diff --git a/controllers/admin/emoji.go b/controllers/admin/emoji.go index 43028e6b7..810b629d1 100644 --- a/controllers/admin/emoji.go +++ b/controllers/admin/emoji.go @@ -76,9 +76,13 @@ func DeleteCustomEmoji(w http.ResponseWriter, r *http.Request) { return } - // var emojiFileName = filepath.Base(emoji.Name) targetPath := filepath.Join(config.CustomEmojiPath, emoji.Name) + if !filepath.IsLocal(targetPath) { + controllers.WriteSimpleResponse(w, false, "Emoji path is not valid") + return + } + if err := os.Remove(targetPath); err != nil { if os.IsNotExist(err) { controllers.WriteSimpleResponse(w, false, fmt.Sprintf("Emoji %q doesn't exist", emoji.Name)) diff --git a/controllers/admin/serverConfig.go b/controllers/admin/serverConfig.go index 2429fdfa1..43e2720df 100644 --- a/controllers/admin/serverConfig.go +++ b/controllers/admin/serverConfig.go @@ -49,20 +49,22 @@ func GetServerConfig(w http.ResponseWriter, r *http.Request) { CustomJavascript: data.GetCustomJavascript(), AppearanceVariables: data.GetCustomColorVariableValues(), }, - FFmpegPath: ffmpeg, - AdminPassword: data.GetAdminPassword(), - StreamKeys: data.GetStreamKeys(), - StreamKeyOverridden: config.TemporaryStreamKey != "", - WebServerPort: config.WebServerPort, - WebServerIP: config.WebServerIP, - RTMPServerPort: data.GetRTMPPortNumber(), - ChatDisabled: data.GetChatDisabled(), - ChatJoinMessagesEnabled: data.GetChatJoinPartMessagesEnabled(), - SocketHostOverride: data.GetWebsocketOverrideHost(), - VideoServingEndpoint: data.GetVideoServingEndpoint(), - ChatEstablishedUserMode: data.GetChatEstbalishedUsersOnlyMode(), - HideViewerCount: data.GetHideViewerCount(), - DisableSearchIndexing: data.GetDisableSearchIndexing(), + FFmpegPath: ffmpeg, + AdminPassword: data.GetAdminPassword(), + StreamKeys: data.GetStreamKeys(), + StreamKeyOverridden: config.TemporaryStreamKey != "", + WebServerPort: config.WebServerPort, + WebServerIP: config.WebServerIP, + RTMPServerPort: data.GetRTMPPortNumber(), + ChatDisabled: data.GetChatDisabled(), + ChatJoinMessagesEnabled: data.GetChatJoinPartMessagesEnabled(), + SocketHostOverride: data.GetWebsocketOverrideHost(), + VideoServingEndpoint: data.GetVideoServingEndpoint(), + ChatEstablishedUserMode: data.GetChatEstbalishedUsersOnlyMode(), + ChatSpamProtectionEnabled: data.GetChatSpamProtectionEnabled(), + ChatSlurFilterEnabled: data.GetChatSlurFilterEnabled(), + HideViewerCount: data.GetHideViewerCount(), + DisableSearchIndexing: data.GetDisableSearchIndexing(), VideoSettings: videoSettings{ VideoQualityVariants: videoQualityVariants, LatencyLevel: data.GetStreamLatencyLevel().Level, @@ -100,31 +102,33 @@ func GetServerConfig(w http.ResponseWriter, r *http.Request) { } type serverConfigAdminResponse struct { - InstanceDetails webConfigResponse `json:"instanceDetails"` - Notifications notificationsConfigResponse `json:"notifications"` - YP yp `json:"yp"` - FFmpegPath string `json:"ffmpegPath"` - AdminPassword string `json:"adminPassword"` - SocketHostOverride string `json:"socketHostOverride,omitempty"` - WebServerIP string `json:"webServerIP"` - VideoCodec string `json:"videoCodec"` - VideoServingEndpoint string `json:"videoServingEndpoint"` - S3 models.S3 `json:"s3"` - Federation federationConfigResponse `json:"federation"` - SupportedCodecs []string `json:"supportedCodecs"` - ExternalActions []models.ExternalAction `json:"externalActions"` - ForbiddenUsernames []string `json:"forbiddenUsernames"` - SuggestedUsernames []string `json:"suggestedUsernames"` - StreamKeys []models.StreamKey `json:"streamKeys"` - VideoSettings videoSettings `json:"videoSettings"` - RTMPServerPort int `json:"rtmpServerPort"` - WebServerPort int `json:"webServerPort"` - ChatDisabled bool `json:"chatDisabled"` - ChatJoinMessagesEnabled bool `json:"chatJoinMessagesEnabled"` - ChatEstablishedUserMode bool `json:"chatEstablishedUserMode"` - DisableSearchIndexing bool `json:"disableSearchIndexing"` - StreamKeyOverridden bool `json:"streamKeyOverridden"` - HideViewerCount bool `json:"hideViewerCount"` + InstanceDetails webConfigResponse `json:"instanceDetails"` + Notifications notificationsConfigResponse `json:"notifications"` + YP yp `json:"yp"` + FFmpegPath string `json:"ffmpegPath"` + AdminPassword string `json:"adminPassword"` + SocketHostOverride string `json:"socketHostOverride,omitempty"` + WebServerIP string `json:"webServerIP"` + VideoCodec string `json:"videoCodec"` + VideoServingEndpoint string `json:"videoServingEndpoint"` + S3 models.S3 `json:"s3"` + Federation federationConfigResponse `json:"federation"` + SupportedCodecs []string `json:"supportedCodecs"` + ExternalActions []models.ExternalAction `json:"externalActions"` + ForbiddenUsernames []string `json:"forbiddenUsernames"` + SuggestedUsernames []string `json:"suggestedUsernames"` + StreamKeys []models.StreamKey `json:"streamKeys"` + VideoSettings videoSettings `json:"videoSettings"` + RTMPServerPort int `json:"rtmpServerPort"` + WebServerPort int `json:"webServerPort"` + ChatDisabled bool `json:"chatDisabled"` + ChatJoinMessagesEnabled bool `json:"chatJoinMessagesEnabled"` + ChatEstablishedUserMode bool `json:"chatEstablishedUserMode"` + ChatSpamProtectionEnabled bool `json:"chatSpamProtectionEnabled"` + ChatSlurFilterEnabled bool `json:"chatSlurFilterEnabled"` + DisableSearchIndexing bool `json:"disableSearchIndexing"` + StreamKeyOverridden bool `json:"streamKeyOverridden"` + HideViewerCount bool `json:"hideViewerCount"` } type videoSettings struct { diff --git a/controllers/auth/fediverse/fediverse.go b/controllers/auth/fediverse/fediverse.go index 6f7002bff..4803055ae 100644 --- a/controllers/auth/fediverse/fediverse.go +++ b/controllers/auth/fediverse/fediverse.go @@ -39,7 +39,7 @@ func RegisterFediverseOTPRequest(u user.User, w http.ResponseWriter, r *http.Req return } - msg := fmt.Sprintf("
This is an automated message from %s. If you did not request this message please ignore or block. Your requested one-time code is:
%s
", data.GetServerName(), reg.Code) + msg := fmt.Sprintf("One-time code from %s: %s. If you did not request this message please ignore or block.
", data.GetServerName(), reg.Code) if err := activitypub.SendDirectFederatedMessage(msg, reg.Account); err != nil { controllers.WriteSimpleResponse(w, false, "Could not send code to fediverse: "+err.Error()) return diff --git a/controllers/config.go b/controllers/config.go index ceec87692..a8ea9f599 100644 --- a/controllers/config.go +++ b/controllers/config.go @@ -16,26 +16,27 @@ import ( ) type webConfigResponse struct { - AppearanceVariables map[string]string `json:"appearanceVariables"` - Name string `json:"name"` - CustomStyles string `json:"customStyles"` - StreamTitle string `json:"streamTitle,omitempty"` // What's going on with the current stream - OfflineMessage string `json:"offlineMessage"` - Logo string `json:"logo"` - Version string `json:"version"` - SocketHostOverride string `json:"socketHostOverride,omitempty"` - ExtraPageContent string `json:"extraPageContent"` - Summary string `json:"summary"` - Tags []string `json:"tags"` - SocialHandles []models.SocialHandle `json:"socialHandles"` - ExternalActions []models.ExternalAction `json:"externalActions"` - Notifications notificationsConfigResponse `json:"notifications"` - Federation federationConfigResponse `json:"federation"` - MaxSocketPayloadSize int `json:"maxSocketPayloadSize"` - HideViewerCount bool `json:"hideViewerCount"` - ChatDisabled bool `json:"chatDisabled"` - NSFW bool `json:"nsfw"` - Authentication authenticationConfigResponse `json:"authentication"` + AppearanceVariables map[string]string `json:"appearanceVariables"` + Name string `json:"name"` + CustomStyles string `json:"customStyles"` + StreamTitle string `json:"streamTitle,omitempty"` // What's going on with the current stream + OfflineMessage string `json:"offlineMessage"` + Logo string `json:"logo"` + Version string `json:"version"` + SocketHostOverride string `json:"socketHostOverride,omitempty"` + ExtraPageContent string `json:"extraPageContent"` + Summary string `json:"summary"` + Tags []string `json:"tags"` + SocialHandles []models.SocialHandle `json:"socialHandles"` + ExternalActions []models.ExternalAction `json:"externalActions"` + Notifications notificationsConfigResponse `json:"notifications"` + Federation federationConfigResponse `json:"federation"` + MaxSocketPayloadSize int `json:"maxSocketPayloadSize"` + HideViewerCount bool `json:"hideViewerCount"` + ChatDisabled bool `json:"chatDisabled"` + ChatSpamProtectionDisabled bool `json:"chatSpamProtectionDisabled"` + NSFW bool `json:"nsfw"` + Authentication authenticationConfigResponse `json:"authentication"` } type federationConfigResponse struct { @@ -118,26 +119,27 @@ func getConfigResponse() webConfigResponse { } return webConfigResponse{ - Name: data.GetServerName(), - Summary: serverSummary, - OfflineMessage: offlineMessage, - Logo: "/logo", - Tags: data.GetServerMetadataTags(), - Version: config.GetReleaseString(), - NSFW: data.GetNSFW(), - SocketHostOverride: data.GetWebsocketOverrideHost(), - ExtraPageContent: pageContent, - StreamTitle: data.GetStreamTitle(), - SocialHandles: socialHandles, - ChatDisabled: data.GetChatDisabled(), - ExternalActions: data.GetExternalActions(), - CustomStyles: data.GetCustomStyles(), - MaxSocketPayloadSize: config.MaxSocketPayloadSize, - Federation: federationResponse, - Notifications: notificationsResponse, - Authentication: authenticationResponse, - AppearanceVariables: data.GetCustomColorVariableValues(), - HideViewerCount: data.GetHideViewerCount(), + Name: data.GetServerName(), + Summary: serverSummary, + OfflineMessage: offlineMessage, + Logo: "/logo", + Tags: data.GetServerMetadataTags(), + Version: config.GetReleaseString(), + NSFW: data.GetNSFW(), + SocketHostOverride: data.GetWebsocketOverrideHost(), + ExtraPageContent: pageContent, + StreamTitle: data.GetStreamTitle(), + SocialHandles: socialHandles, + ChatDisabled: data.GetChatDisabled(), + ChatSpamProtectionDisabled: data.GetChatSpamProtectionEnabled(), + ExternalActions: data.GetExternalActions(), + CustomStyles: data.GetCustomStyles(), + MaxSocketPayloadSize: config.MaxSocketPayloadSize, + Federation: federationResponse, + Notifications: notificationsResponse, + Authentication: authenticationResponse, + AppearanceVariables: data.GetCustomColorVariableValues(), + HideViewerCount: data.GetHideViewerCount(), } } diff --git a/controllers/customJavascript.go b/controllers/customJavascript.go index f20dfdf25..dc6cf17a9 100644 --- a/controllers/customJavascript.go +++ b/controllers/customJavascript.go @@ -8,6 +8,8 @@ import ( // ServeCustomJavascript will serve optional custom Javascript. func ServeCustomJavascript(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/javascript; charset=utf-8") + js := data.GetCustomJavascript() _, _ = w.Write([]byte(js)) } diff --git a/controllers/index.go b/controllers/index.go index 9ae240abd..23c2e581d 100644 --- a/controllers/index.go +++ b/controllers/index.go @@ -1,15 +1,18 @@ package controllers import ( + "bytes" "encoding/json" "fmt" "net/http" "net/url" "path/filepath" "strings" + "time" "github.com/owncast/owncast/config" "github.com/owncast/owncast/core" + "github.com/owncast/owncast/core/cache" "github.com/owncast/owncast/core/data" "github.com/owncast/owncast/models" "github.com/owncast/owncast/router/middleware" @@ -18,6 +21,8 @@ import ( log "github.com/sirupsen/logrus" ) +var gc = cache.GetGlobalCache() + // IndexHandler handles the default index route. func IndexHandler(w http.ResponseWriter, r *http.Request) { middleware.EnableCors(w) @@ -121,6 +126,17 @@ type MetadataPage struct { // Return a basic HTML page with server-rendered metadata from the config // to give to Opengraph clients and web scrapers (bots, web crawlers, etc). func handleScraperMetadataPage(w http.ResponseWriter, r *http.Request) { + cacheKey := "bot-scraper-html" + cacheHtmlExpiration := time.Duration(10) * time.Second + c := gc.GetOrCreateCache(cacheKey, cacheHtmlExpiration) + + cachedHtml := c.GetValueForKey(cacheKey) + if cachedHtml != nil { + w.Header().Set("Content-Type", "text/html") + _, _ = w.Write(cachedHtml) + return + } + tmpl, err := static.GetBotMetadataTemplate() if err != nil { log.Errorln(err) @@ -173,11 +189,18 @@ func handleScraperMetadataPage(w http.ResponseWriter, r *http.Request) { SocialHandles: data.GetSocialHandles(), } + // Cache the rendered HTML + var b bytes.Buffer + if err := tmpl.Execute(&b, metadata); err != nil { + log.Errorln(err) + } + c.Set(cacheKey, b.Bytes()) + // Set a cache header middleware.SetCachingHeaders(w, r) w.Header().Set("Content-Type", "text/html") - if err := tmpl.Execute(w, metadata); err != nil { + if _, err = w.Write(b.Bytes()); err != nil { log.Errorln(err) } } diff --git a/core/cache/cache.go b/core/cache/cache.go new file mode 100644 index 000000000..1c9c2fb2b --- /dev/null +++ b/core/cache/cache.go @@ -0,0 +1,82 @@ +package cache + +import ( + "time" + + "github.com/jellydator/ttlcache/v3" +) + +// CacheContainer is a container for all caches. +type CacheContainer struct { + caches map[string]*CacheInstance +} + +// CacheInstance is a single cache instance. +type CacheInstance struct { + cache *ttlcache.Cache[string, []byte] +} + +// This is the global singleton instance. (To be removed after refactor). +var _instance *CacheContainer + +// NewCache creates a new cache instance. +func NewGlobalCache() *CacheContainer { + _instance = &CacheContainer{ + caches: make(map[string]*CacheInstance), + } + + return _instance +} + +// GetCache returns the cache instance. +func GetGlobalCache() *CacheContainer { + if _instance != nil { + return _instance + } + return NewGlobalCache() +} + +// GetOrCreateCache returns the cache instance or creates a new one. +func (c *CacheContainer) GetOrCreateCache(name string, expiration time.Duration) *CacheInstance { + if _, ok := c.caches[name]; !ok { + c.CreateCache(name, expiration) + } + return c.caches[name] +} + +// CreateCache creates a new cache instance. +func (c *CacheContainer) CreateCache(name string, expiration time.Duration) *CacheInstance { + cache := ttlcache.New[string, []byte]( + ttlcache.WithTTL[string, []byte](expiration), + ttlcache.WithDisableTouchOnHit[string, []byte](), + ) + ci := &CacheInstance{cache: cache} + c.caches[name] = ci + go cache.Start() + return ci +} + +// GetCache returns the cache instance. +func (c *CacheContainer) GetCache(name string) *CacheInstance { + return c.caches[name] +} + +// GetValueForKey returns the value for the given key. +func (ci *CacheInstance) GetValueForKey(key string) []byte { + value := ci.cache.Get(key, ttlcache.WithDisableTouchOnHit[string, []byte]()) + if value == nil { + return nil + } + + if value.IsExpired() { + return nil + } + + val := value.Value() + return val +} + +// Set sets the value for the given key.. +func (ci *CacheInstance) Set(key string, value []byte) { + ci.cache.Set(key, value, 0) +} diff --git a/core/cache/cache_test.go b/core/cache/cache_test.go new file mode 100644 index 000000000..9d70f5114 --- /dev/null +++ b/core/cache/cache_test.go @@ -0,0 +1,72 @@ +package cache + +import ( + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestCache(t *testing.T) { + expiration := 5 * time.Second + globalCache := GetGlobalCache() + assert.NotNil(t, globalCache, "NewGlobalCache should return a non-nil instance") + assert.Equal(t, globalCache, GetGlobalCache(), "GetGlobalCache should return the created instance") + + cacheName := "testCache" + globalCache.CreateCache(cacheName, expiration) + + createdCache := globalCache.GetCache(cacheName) + assert.NotNil(t, createdCache, "GetCache should return a non-nil cache") + + key := "testKey" + value := []byte("testValue") + createdCache.Set(key, value) + + // Wait for cache to expire + time.Sleep(expiration + 1*time.Second) + + // Verify that the cache has expired + ci := globalCache.GetCache(cacheName) + cachedValue := ci.GetValueForKey(key) + assert.Nil(t, cachedValue, "Cache should not contain the value after expiration") +} + +func TestConcurrentAccess(t *testing.T) { + // Test concurrent access to the cache + globalCache := NewGlobalCache() + cacheName := "concurrentCache" + expiration := 5 * time.Second + globalCache.CreateCache(cacheName, expiration) + + // Start multiple goroutines to access the cache concurrently + numGoroutines := 10 + keyPrefix := "key" + valuePrefix := "value" + + done := make(chan struct{}) + for i := 0; i < numGoroutines; i++ { + go func(index int) { + defer func() { done <- struct{}{} }() + + cache := globalCache.GetCache(cacheName) + key := keyPrefix + strconv.Itoa(index) + value := valuePrefix + strconv.Itoa(index) + + cache.Set(key, []byte(value)) + + // Simulate some work + time.Sleep(100 * time.Millisecond) + + ci := globalCache.GetCache(cacheName) + cachedValue := string(ci.GetValueForKey(key)) + assert.Equal(t, value, cachedValue, "Cached value should match the set value") + }(i) + } + + // Wait for all goroutines to finish + for i := 0; i < numGoroutines; i++ { + <-done + } +} diff --git a/core/chat/chatclient.go b/core/chat/chatclient.go index 64e850b58..df344f17b 100644 --- a/core/chat/chatclient.go +++ b/core/chat/chatclient.go @@ -13,19 +13,21 @@ import ( "github.com/gorilla/websocket" "github.com/owncast/owncast/config" "github.com/owncast/owncast/core/chat/events" + "github.com/owncast/owncast/core/data" "github.com/owncast/owncast/core/user" "github.com/owncast/owncast/geoip" ) // Client represents a single chat client. type Client struct { - ConnectedAt time.Time `json:"connectedAt"` - timeoutTimer *time.Timer - rateLimiter *rate.Limiter - conn *websocket.Conn - User *user.User `json:"user"` - server *Server - Geo *geoip.GeoDetails `json:"geo"` + ConnectedAt time.Time `json:"connectedAt"` + timeoutTimer *time.Timer + rateLimiter *rate.Limiter + messageFilter *ChatMessageFilter + conn *websocket.Conn + User *user.User `json:"user"` + server *Server + Geo *geoip.GeoDetails `json:"geo"` // Buffered channel of outbound messages. send chan []byte accessToken string @@ -90,6 +92,7 @@ func (c *Client) readPump() { // Allow 3 messages every two seconds. limit := rate.Every(2 * time.Second / 3) c.rateLimiter = rate.NewLimiter(limit, 1) + c.messageFilter = NewMessageFilter() defer func() { c.close() @@ -129,6 +132,12 @@ func (c *Client) readPump() { continue } + // Check if this message passes the optional language filter + if data.GetChatSlurFilterEnabled() && !c.messageFilter.Allow(string(message)) { + c.sendAction("Sorry, that message contained language that is not allowed in this chat.") + continue + } + message = bytes.TrimSpace(bytes.ReplaceAll(message, newline, space)) c.handleEvent(message) } @@ -200,7 +209,13 @@ func (c *Client) close() { } func (c *Client) passesRateLimit() bool { - return c.rateLimiter.Allow() && !c.inTimeout + // If spam rate limiting is disabled, or the user is a moderator, always + // allow the message. + if !data.GetChatSpamProtectionEnabled() || c.User.IsModerator() { + return true + } + + return (c.rateLimiter.Allow() && !c.inTimeout) } func (c *Client) startChatRejectionTimeout() { diff --git a/core/chat/messageFilter.go b/core/chat/messageFilter.go new file mode 100644 index 000000000..86d11ec02 --- /dev/null +++ b/core/chat/messageFilter.go @@ -0,0 +1,18 @@ +package chat + +import ( + goaway "github.com/TwiN/go-away" +) + +// ChatMessageFilter is a allow/deny chat message filter. +type ChatMessageFilter struct{} + +// NewMessageFilter will return an instance of the chat message filter. +func NewMessageFilter() *ChatMessageFilter { + return &ChatMessageFilter{} +} + +// Allow will test if this message should be allowed to be sent. +func (*ChatMessageFilter) Allow(message string) bool { + return !goaway.IsProfane(message) +} diff --git a/core/chat/messageFilter_test.go b/core/chat/messageFilter_test.go new file mode 100644 index 000000000..37ca31823 --- /dev/null +++ b/core/chat/messageFilter_test.go @@ -0,0 +1,39 @@ +package chat + +import ( + "testing" +) + +func TestFiltering(t *testing.T) { + filter := NewMessageFilter() + + filteredTestMessages := []string{ + "Hello, fucking world!", + "Suck my dick", + "Eat my ass", + "fuck this shit", + "@$$h073", + "F u C k th1$ $h!t", + "u r fag", + "fucking sucks", + } + + unfilteredTestMessages := []string{ + "bass fish", + "assumptions", + } + + for _, m := range filteredTestMessages { + result := filter.Allow(m) + if result { + t.Errorf("%s should be seen as a filtered profane message", m) + } + } + + for _, m := range unfilteredTestMessages { + result := filter.Allow(m) + if !result { + t.Errorf("%s should not be filtered", m) + } + } +} diff --git a/core/chat/server.go b/core/chat/server.go index 156df0e19..c6129ffae 100644 --- a/core/chat/server.go +++ b/core/chat/server.go @@ -96,6 +96,13 @@ func (s *Server) Addclient(conn *websocket.Conn, user *user.User, accessToken st shouldSendJoinedMessages := data.GetChatJoinPartMessagesEnabled() + // If there are existing clients connected for this user do not send + // a user joined message. Do not put this under a mutex, as + // GetClientsForUser already has a lock. + if existingConnectedClients, _ := GetClientsForUser(user.ID); len(existingConnectedClients) > 0 { + shouldSendJoinedMessages = false + } + s.mu.Lock() { // If there is a pending disconnect timer then clear it. diff --git a/core/core.go b/core/core.go index 5b6e28262..ef7165d5b 100644 --- a/core/core.go +++ b/core/core.go @@ -104,7 +104,7 @@ func transitionToOfflineVideoStreamContent() { _transcoder.SetLatencyLevel(models.GetLatencyLevel(4)) _transcoder.SetIsEvent(true) - offlineFilePath, err := saveOfflineClipToDisk("offline.ts") + offlineFilePath, err := saveOfflineClipToDisk("offline-v2.ts") if err != nil { log.Fatalln("unable to save offline clip:", err) } diff --git a/core/data/config.go b/core/data/config.go index 47cc860af..ca440dc56 100644 --- a/core/data/config.go +++ b/core/data/config.go @@ -59,6 +59,8 @@ const ( suggestedUsernamesKey = "suggested_usernames" chatJoinMessagesEnabledKey = "chat_join_messages_enabled" chatEstablishedUsersOnlyModeKey = "chat_established_users_only_mode" + chatSpamProtectionEnabledKey = "chat_spam_protection_enabled" + chatSlurFilterEnabledKey = "chat_slur_filter_enabled" notificationsEnabledKey = "notifications_enabled" discordConfigurationKey = "discord_configuration" browserPushConfigurationKey = "browser_push_configuration" @@ -528,6 +530,36 @@ func GetChatEstbalishedUsersOnlyMode() bool { return false } +// SetChatSpamProtectionEnabled will enable chat spam protection if set to true. +func SetChatSpamProtectionEnabled(enabled bool) error { + return _datastore.SetBool(chatSpamProtectionEnabledKey, enabled) +} + +// GetChatSpamProtectionEnabled will return if chat spam protection is enabled. +func GetChatSpamProtectionEnabled() bool { + enabled, err := _datastore.GetBool(chatSpamProtectionEnabledKey) + if err == nil { + return enabled + } + + return true +} + +// SetChatSlurFilterEnabled will enable the chat slur filter. +func SetChatSlurFilterEnabled(enabled bool) error { + return _datastore.SetBool(chatSlurFilterEnabledKey, enabled) +} + +// GetChatSlurFilterEnabled will return if the chat slur filter is enabled. +func GetChatSlurFilterEnabled() bool { + enabled, err := _datastore.GetBool(chatSlurFilterEnabledKey) + if err == nil { + return enabled + } + + return false +} + // GetExternalActions will return the registered external actions. func GetExternalActions() []models.ExternalAction { configEntry, err := _datastore.Get(externalActionsKey) diff --git a/core/offlineState.go b/core/offlineState.go index e1257c37f..4fb1c7e04 100644 --- a/core/offlineState.go +++ b/core/offlineState.go @@ -36,7 +36,7 @@ func appendOfflineToVariantPlaylist(index int, playlistFilePath string) { _, _ = atomicWriteTmpPlaylistFile.WriteString("#EXT-X-DISCONTINUITY\n") // If "offline" content gets changed then change the duration below _, _ = atomicWriteTmpPlaylistFile.WriteString("#EXTINF:8.000000,\n") - _, _ = atomicWriteTmpPlaylistFile.WriteString("offline.ts\n") + _, _ = atomicWriteTmpPlaylistFile.WriteString("offline-v2.ts\n") _, _ = atomicWriteTmpPlaylistFile.WriteString("#EXT-X-ENDLIST\n") if err := atomicWriteTmpPlaylistFile.Close(); err != nil { diff --git a/core/storageproviders/local.go b/core/storageproviders/local.go index 65bcaa907..b7baabb72 100644 --- a/core/storageproviders/local.go +++ b/core/storageproviders/local.go @@ -5,10 +5,8 @@ import ( "path/filepath" "sort" - "github.com/pkg/errors" log "github.com/sirupsen/logrus" - "github.com/owncast/owncast/config" "github.com/owncast/owncast/core/data" ) @@ -62,36 +60,12 @@ func (s *LocalStorage) Save(filePath string, retryCount int) (string, error) { return filePath, nil } +// Cleanup will remove old files from the storage provider. func (s *LocalStorage) Cleanup() error { // Determine how many files we should keep on disk maxNumber := data.GetStreamLatencyLevel().SegmentCount buffer := 10 - baseDirectory := config.HLSStoragePath - - files, err := getAllFilesRecursive(baseDirectory) - if err != nil { - return errors.Wrap(err, "unable find old video files for cleanup") - } - - // Delete old private HLS files on disk - for directory := range files { - files := files[directory] - if len(files) < maxNumber+buffer { - continue - } - - filesToDelete := files[maxNumber+buffer:] - log.Traceln("Deleting", len(filesToDelete), "old files from", baseDirectory, "for video variant", directory) - - for _, file := range filesToDelete { - fileToDelete := filepath.Join(baseDirectory, directory, file.Name()) - err := os.Remove(fileToDelete) - if err != nil { - return errors.Wrap(err, "unable to delete old video files") - } - } - } - return nil + return localCleanup(maxNumber + buffer) } func getAllFilesRecursive(baseDirectory string) (map[string][]os.FileInfo, error) { diff --git a/core/storageproviders/localCleanup.go b/core/storageproviders/localCleanup.go new file mode 100644 index 000000000..7e655d4d0 --- /dev/null +++ b/core/storageproviders/localCleanup.go @@ -0,0 +1,39 @@ +package storageproviders + +import ( + "os" + "path/filepath" + + "github.com/owncast/owncast/config" + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" +) + +func localCleanup(maxNumber int) error { + baseDirectory := config.HLSStoragePath + + files, err := getAllFilesRecursive(baseDirectory) + if err != nil { + return errors.Wrap(err, "unable find old video files for cleanup") + } + + // Delete old private HLS files on disk + for directory := range files { + files := files[directory] + if len(files) < maxNumber { + continue + } + + filesToDelete := files[maxNumber:] + log.Traceln("Deleting", len(filesToDelete), "old files from", baseDirectory, "for video variant", directory) + + for _, file := range filesToDelete { + fileToDelete := filepath.Join(baseDirectory, directory, file.Name()) + err := os.Remove(fileToDelete) + if err != nil { + return errors.Wrap(err, "unable to delete old video files") + } + } + } + return nil +} diff --git a/core/storageproviders/s3Storage.go b/core/storageproviders/s3Storage.go index d9e7d1604..c74c025e7 100644 --- a/core/storageproviders/s3Storage.go +++ b/core/storageproviders/s3Storage.go @@ -8,6 +8,7 @@ import ( "path/filepath" "sort" "strings" + "sync" "time" "github.com/owncast/owncast/core/data" @@ -26,26 +27,29 @@ import ( // S3Storage is the s3 implementation of a storage provider. type S3Storage struct { - sess *session.Session + // If we try to upload a playlist but it is not yet on disk + // then keep a reference to it here. + queuedPlaylistUpdates map[string]string + s3Client *s3.S3 uploader *s3manager.Uploader - // If we try to upload a playlist but it is not yet on disk - // then keep a reference to it here. - queuedPlaylistUpdates map[string]string + sess *session.Session + s3Secret string s3Bucket string s3Region string s3ServingEndpoint string s3AccessKey string - s3Secret string s3ACL string s3PathPrefix string s3Endpoint string host string + lock sync.Mutex + s3ForcePathStyle bool } @@ -53,6 +57,7 @@ type S3Storage struct { func NewS3Storage() *S3Storage { return &S3Storage{ queuedPlaylistUpdates: make(map[string]string), + lock: sync.Mutex{}, } } @@ -126,6 +131,8 @@ func (s *S3Storage) VariantPlaylistWritten(localFilePath string) { // We are uploading the variant playlist after uploading the segment // to make sure we're not referring to files in a playlist that don't // yet exist. See SegmentWritten. + s.lock.Lock() + defer s.lock.Unlock() if _, ok := s.queuedPlaylistUpdates[localFilePath]; ok { if _, err := s.Save(localFilePath, 0); err != nil { log.Errorln(err) @@ -195,19 +202,23 @@ func (s *S3Storage) Save(filePath string, retryCount int) (string, error) { return s.Save(filePath, retryCount+1) } - // Upload failure. Remove the local file. - s.removeLocalFile(filePath) - return "", fmt.Errorf("Giving up uploading %s to object storage %s", filePath, s.s3Endpoint) } - // Upload success. Remove the local file. - s.removeLocalFile(filePath) - return response.Location, nil } +// Cleanup will fire the different cleanup tasks required. func (s *S3Storage) Cleanup() error { + if err := s.RemoteCleanup(); err != nil { + log.Errorln(err) + } + + return localCleanup(4) +} + +// RemoteCleanup will remove old files from the remote storage provider. +func (s *S3Storage) RemoteCleanup() error { // Determine how many files we should keep on S3 storage maxNumber := data.GetStreamLatencyLevel().SegmentCount buffer := 20 @@ -269,14 +280,6 @@ func (s *S3Storage) getDeletableVideoSegmentsWithOffset(offset int) ([]s3object, return objectsToDelete, nil } -func (s *S3Storage) removeLocalFile(filePath string) { - cleanFilepath := filepath.Clean(filePath) - - if err := os.Remove(cleanFilepath); err != nil { - log.Errorln(err) - } -} - func (s *S3Storage) deleteObjects(objects []s3object) { keys := make([]*s3.ObjectIdentifier, len(objects)) for i, object := range objects { diff --git a/core/streamState.go b/core/streamState.go index 52b8b844e..cb6f10f5a 100644 --- a/core/streamState.go +++ b/core/streamState.go @@ -93,7 +93,7 @@ func SetStreamAsDisconnected() { _stats.LastConnectTime = nil _broadcaster = nil - offlineFilename := "offline.ts" + offlineFilename := "offline-v2.ts" offlineFilePath, err := saveOfflineClipToDisk(offlineFilename) if err != nil { diff --git a/docs/api/index.html b/docs/api/index.html index 3d9ac4567..32c7188be 100644 --- a/docs/api/index.html +++ b/docs/api/index.html @@ -2261,7 +2261,7 @@ data-styled.g138[id="sc-iIgjPs"]{content:"jIUTls,"}/*!sc*/ 55.627 l 55.6165,55.627 -231.245496,231.24803 c -127.185,127.1864 -231.5279,231.248 -231.873,231.248 -0.3451,0 -104.688, -104.0616 -231.873,-231.248 z - " fill="currentColor">Download OpenAPI specification:Download
Owncast is a self-hosted live video and web chat server for use with existing popular broadcasting software.
Take note that only APIs listed specifically for external use, 3rd parties or for integration purposes are encouraged for external use. Internal APIs may change at any time and are used by the server and frontend itself.
Download OpenAPI specification:Download
Owncast is a self-hosted live video and web chat server for use with existing popular broadcasting software.
Take note that only APIs listed specifically for external use, 3rd parties or for integration purposes are encouraged for external use. Internal APIs may change at any time and are used by the server and frontend itself.
{- "broadcaster": {
- "remoteAddr": "172.217.164.110",
- "time": "2020-10-06T23:20:44.588649-07:00",
- "streamDetails": {
- "width": 640,
- "height": 480,
- "frameRate": 24,
- "videoBitrate": 1500,
- "videoCodec": "mp4a",
- "audioBitrate": 256,
- "audioCodec": "aac",
- "encoder": "obs-output module (libobs version 25.0.8)"
}
}, - "online": true,
- "viewerCount": 3,
- "overallPeakViewerCount": 4,
- "sessionPeakViewerCount": 4,
- "versionNumber": "0.0.3"
}Disconnect the active inbound stream, if one exists, and terminate the broadcast.
@@ -2442,11 +2442,11 @@ data-styled.g138[id="sc-iIgjPs"]{content:"jIUTls,"}/*!sc*/{- "lastConnectTime": "2020-10-03T21:36:22-05:00",
- "lastDisconnectTime": null,
- "online": true,
- "overallMaxViewerCount": 420,
- "sessionMaxViewerCount": 12,
- "viewerCount": 7
}Information to be used in the Yellow Pages service, a global directory of Owncast servers.
{- "name": "string",
- "description": "string",
- "logo": "string",
- "nsfw": true,
- "tags": [
- "string"
], - "online": true,
- "viewerCount": 0,
- "overallMaxViewerCount": 0,
- "sessionMaxViewerCount": 0,
- "lastConnectTime": "2019-08-24T14:15:22Z"
}[- {
- "name": "John Smith",
- "timestamp": "2019-08-24T14:15:22Z",
- "disabledAt": "2019-08-24T14:15:22Z"
}
]| account | string The fediverse username@server.tld account that wants to perform the remote follow action. +Response samples
Content type application/json [Return the information needed to redirect a user to a fediverse server to perform a remote follow action.Request Body schema: application/json
ResponsesRequest samples
Content type application/json {Response samples
Content type application/json 404This page could not be found. |