Merge branch 'webv2' into develop

This commit is contained in:
Gabe Kangas 2023-01-30 13:35:15 -08:00
commit ff81536191
No known key found for this signature in database
GPG key ID: 4345B2060657F330
1539 changed files with 78215 additions and 19924 deletions

View file

@ -14,3 +14,13 @@ quote_type = single
curly_bracket_next_line = true
spaces_around_operators = true
spaces_around_brackets = true
[*.{yml,yaml}]
indent_style = space
indent_size = 2
[*.{md,mdx}]
trim_trailing_whitespace = false
[*.go]
indent_style = tab

24
.github/workflows/actions-lint.yml vendored Normal file
View file

@ -0,0 +1,24 @@
name: Lint
on:
push:
branches:
- webv2
paths:
- '.github/workflows/*'
pull_request:
branches:
- webv2
paths:
- '.github/workflows/*'
jobs:
actionlint:
name: GitHub actions
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: docker://rhysd/actionlint:latest
with:
args: -shellcheck= -color

View file

@ -1,19 +0,0 @@
name: Automated browser tests
on: [push, pull_request]
jobs:
browser:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v3
with:
stable: 'false'
go-version: '1.17.2'
- name: Run browser tests
run: cd test/automated/browser && ./run.sh
- uses: actions/upload-artifact@v3
with:
name: screenshots-${{ github.run_id }}
path: test/automated/browser/screenshots/*.png

View file

@ -3,22 +3,40 @@ name: Automated API tests
on:
push:
paths-ignore:
- "webroot/**"
- "web/**"
- 'web/**'
pull_request:
paths-ignore:
- "webroot/**"
- "web/**"
- 'web/**'
jobs:
api:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v3
- id: skip_check
uses: fkirc/skip-duplicate-actions@v5
with:
stable: "false"
go-version: "1.17.2"
concurrent_skipping: 'same_content_newer'
- uses: earthly/actions-setup@v1
with:
version: 'latest' # or pin to an specific version, e.g. "v0.6.10"
- name: Earthly version
run: earthly --version
- name: Set up QEMU
id: qemu
uses: docker/setup-qemu-action@v2
with:
image: tonistiigi/binfmt:latest
platforms: all
- uses: actions/checkout@v3
- name: Run API tests
run: cd test/automated/api && ./run.sh
uses: nick-fields/retry@v2
with:
timeout_minutes: 10
max_attempts: 3
command: earthly +api-tests

36
.github/workflows/browser-testing.yml vendored Normal file
View file

@ -0,0 +1,36 @@
name: Browser Tests
on:
push:
paths:
- 'web/**'
- 'test/automated/browser/**'
pull_request:
paths:
- 'web/**'
- 'test/automated/browser/**'
jobs:
cypress-run:
runs-on: ubuntu-latest
steps:
- id: skip_check
uses: fkirc/skip-duplicate-actions@v5
with:
concurrent_skipping: 'same_content_newer'
- name: Checkout
uses: actions/checkout@v3
- uses: actions/setup-go@v3
with:
go-version: '1.18.8'
- name: Install Google Chrome
run: sudo apt-get install google-chrome-stable
- name: Run Browser tests
uses: nick-fields/retry@v2
with:
timeout_minutes: 20
max_attempts: 3
command: cd test/automated/browser && ./run.sh

44
.github/workflows/build-storybook.yml vendored Normal file
View file

@ -0,0 +1,44 @@
name: Build and Deploy Components+Style Guide
on:
push:
branches:
- webv2
paths: ['web/stories/**', 'web/components/**', 'web/.storybook/**'] # Trigger the action only when files change in the folders defined here
jobs:
build-and-deploy:
runs-on: ubuntu-latest
if: github.repository == 'owncast/owncast'
steps:
- name: Checkout
uses: actions/checkout@v3
with:
ref: webv2 # Remove when webv2 gets merged into develop
- name: Install and Build
run: | # Install npm packages and build the Storybook files
cd web
npm install --include-dev --force
cd .storybook/tools
./generate-stories.sh
cd -
npm run build-storybook -- -o ../docs/components
- name: Commit changes
uses: EndBug/add-and-commit@v9
with:
author_name: Owncast
author_email: owncast@owncast.online
message: 'Commit updated Storybook stories'
add: '*.stories.*'
pull: '--rebase --autostash'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Dispatch event to web site
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.BUNDLE_STORYBOOK_OWNCAST_ONLINE }}
repository: owncast/owncast.github.io
event-type: bundle-components-library

View file

@ -1,21 +0,0 @@
name: Bundle admin (owncast/owncast-admin)
on:
repository_dispatch:
types: [bundle-admin-event]
jobs:
bundle:
runs-on: ubuntu-latest
steps:
- name: Bundle admin
uses: actions/checkout@v3
- run: build/admin/bundleAdmin.sh
- name: Commit changes
uses: EndBug/add-and-commit@v9
with:
author_name: Owncast
author_email: owncast@owncast.online
message: "Update admin to ${{ github.event.client_payload.sha }}"
add: "static/admin"
env:
GITHUB_TOKEN: ${{ secrets.GH_CR_PAT }}

31
.github/workflows/bundle-web.yml vendored Normal file
View file

@ -0,0 +1,31 @@
name: Build and bundle web app into Owncast
on:
push:
branches:
- webv2
- develop
paths:
- 'web/**'
- '!**.md'
jobs:
bundle:
runs-on: ubuntu-latest
if: github.repository == 'owncast/owncast'
steps:
- id: skip_check
uses: fkirc/skip-duplicate-actions@v5
with:
concurrent_skipping: 'same_content_newer'
- name: Bundle web app (next.js build)
uses: actions/checkout@v3
- run: build/web/bundleWeb.sh
- name: Commit changes
uses: EndBug/add-and-commit@v9
with:
pull: --rebase --autostash
message: 'Bundle embedded web app'
add: 'static/web'

52
.github/workflows/chromatic.yml vendored Normal file
View file

@ -0,0 +1,52 @@
# .github/workflows/chromatic.yml
# Workflow name
name: 'Chromatic'
on:
push:
paths:
- web/**
pull_request_target:
paths:
- web/**
# List of jobs
jobs:
chromatic-deployment:
# Operating System
runs-on: ubuntu-latest
if: github.repository == 'owncast/owncast'
defaults:
run:
working-directory: ./web
steps:
- id: skip_check
uses: fkirc/skip-duplicate-actions@v5
with:
concurrent_skipping: 'same_content_newer'
- name: Check out code
if: ${{ github.actor != 'renovate[bot]' && github.actor != 'renovate' }}
uses: actions/checkout@v3
with:
# Make sure the actual branch is checked out when running on pull requests
ref: ${{ github.event.pull_request.head.ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
fetch-depth: 0
- name: Install dependencies
if: ${{ github.actor != 'renovate[bot]' && github.actor != 'renovate' }}
run: npm install
- name: Publish to Chromatic
if: ${{ github.actor != 'renovate[bot]' && github.actor != 'renovate' }}
uses: chromaui/action@v1
# Chromatic GitHub Action options
with:
workingDir: web
autoAcceptChanges: webv2
projectToken: f47410569b62
onlyChanged: true

28
.github/workflows/container-lint.yml vendored Normal file
View file

@ -0,0 +1,28 @@
name: Lint
on:
push:
branches:
- webv2
paths:
- 'Dockerfile'
pull_request:
branches:
- webv2
paths:
- 'Dockerfile'
jobs:
trivy:
name: Dockerfile
runs-on: ubuntu-latest
container:
image: aquasec/trivy
steps:
- uses: actions/checkout@v3
- name: Check critical issues
run: trivy config --exit-code 1 --severity "HIGH,CRITICAL" ./Dockerfile
- name: Check non-critical issues
run: trivy config --severity "LOW,MEDIUM" ./Dockerfile

60
.github/workflows/container.yaml vendored Normal file
View file

@ -0,0 +1,60 @@
# See https://docs.earthly.dev/ci-integration/vendor-specific-guides/gh-actions-integration
# for details.
name: Build development container
on:
schedule:
- cron: '0 2 * * *'
push:
branches:
- webv2
pull_request:
branches:
- webv2
jobs:
Earthly:
runs-on: ubuntu-latest
steps:
- name: Set up Earthly
uses: earthly/actions-setup@v1
with:
version: 'latest' # or pin to an specific version, e.g. "v0.6.10"
- name: Log Earthly version
run: earthly --version
- name: Authenticate to GitHub Container Registry
if: ${{ github.event_name == 'schedule' && env.GH_CR_PAT != null }}
env:
GH_CR_PAT: ${{ secrets.GH_CR_PAT }}
run: echo "${{ secrets.GH_CR_PAT }}" | docker login https://ghcr.io -u ${{ github.actor }} --password-stdin
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
with:
image: tonistiigi/binfmt:latest
platforms: all
- name: Checkout repo
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Build and push
if: ${{ github.event_name == 'schedule' && env.GH_CR_PAT != null }}
env:
GH_CR_PAT: ${{ secrets.GH_CR_PAT }}
EARTHLY_BUILD_TAG: 'webv2'
EARTHLY_BUILD_BRANCH: 'webv2'
EARTHLY_PUSH: true
run: ./build/develop/container.sh
- name: Build and push
if: ${{ github.event_name == 'schedule' && env.GH_CR_PAT != null }}
uses: nick-fields/retry@v2
with:
timeout_minutes: 10
max_attempts: 3
command: ./build/develop/container.sh

View file

@ -1,40 +0,0 @@
# See https://docs.earthly.dev/ci-integration/vendor-specific-guides/gh-actions-integration
# for details.
name: Build nightly docker
on:
workflow_dispatch:
schedule:
- cron: '0 2 * * *'
jobs:
Docker:
runs-on: ubuntu-latest
steps:
- uses: earthly/actions-setup@v1
with:
version: 'latest' # or pin to an specific version, e.g. "v0.6.10"
- name: Earthly version
run: earthly --version
- name: Log into GitHub Container Registry
env:
GH_CR_PAT: ${{ secrets.GH_CR_PAT }}
run: echo "${{ secrets.GH_CR_PAT }}" | docker login https://ghcr.io -u ${{ github.actor }} --password-stdin
if: env.GH_CR_PAT != null
- name: Set up QEMU
id: qemu
uses: docker/setup-qemu-action@v2
with:
image: tonistiigi/binfmt:latest
platforms: all
- uses: actions/checkout@v3
- name: Checkout and build
if: env.GH_CR_PAT != null
env:
GH_CR_PAT: ${{ secrets.GH_CR_PAT }}
run: cd build/release && ./docker-nightly.sh

View file

@ -1,42 +0,0 @@
# See https://docs.earthly.dev/ci-integration/vendor-specific-guides/gh-actions-integration
# for details.
name: Build webv2 docker
on:
workflow_dispatch:
schedule:
- cron: '0 3 * * *'
jobs:
Docker:
runs-on: ubuntu-latest
steps:
- uses: earthly/actions-setup@v1
with:
version: 'latest' # or pin to an specific version, e.g. "v0.6.10"
- name: Earthly version
run: earthly --version
- name: Log into GitHub Container Registry
env:
GH_CR_PAT: ${{ secrets.GH_CR_PAT }}
run: echo "${{ secrets.GH_CR_PAT }}" | docker login https://ghcr.io -u ${{ github.actor }} --password-stdin
if: env.GH_CR_PAT != null
- name: Set up QEMU
id: qemu
uses: docker/setup-qemu-action@v2
with:
image: tonistiigi/binfmt:latest
platforms: all
- uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Checkout and build
if: env.GH_CR_PAT != null
env:
GH_CR_PAT: ${{ secrets.GH_CR_PAT }}
run: cd build/release && ./docker-webv2.sh

View file

@ -1,12 +1,10 @@
name: lint
name: Lint
on:
push:
paths-ignore:
- 'webroot/**'
- 'web/**'
pull_request:
paths-ignore:
- 'webroot/**'
- 'web/**'
permissions:
@ -17,16 +15,23 @@ jobs:
name: Go linter
if: ${{ github.actor != 'dependabot[bot]' }}
runs-on: ubuntu-latest
steps:
- id: skip_check
uses: fkirc/skip-duplicate-actions@v5
with:
concurrent_skipping: 'same_content_newer'
- uses: actions/checkout@v3
with:
fetch-depth: 0
- uses: actions/setup-go@v3
with:
go-version: '1.18.8'
- uses: actions/checkout@v3
- name: golangci-lint
uses: golangci/golangci-lint-action@v3
with:
only-new-issues: true
args: --timeout=3m

View file

@ -3,11 +3,11 @@ name: Automated HLS tests
on:
push:
paths-ignore:
- 'webroot/**'
- 'web/**'
pull_request:
paths-ignore:
- 'webroot/**'
- 'web/**'
env:
S3_BUCKET: ${{ secrets.S3_BUCKET }}
S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }}
@ -18,13 +18,20 @@ env:
jobs:
api:
runs-on: ubuntu-latest
steps:
- id: skip_check
uses: fkirc/skip-duplicate-actions@v5
with:
concurrent_skipping: 'same_content_newer'
- uses: actions/checkout@v3
- uses: actions/setup-go@v3
with:
stable: 'false'
go-version: '1.17.2'
- name: Run HLS tests
run: cd test/automated/hls && ./run.sh
go-version: '1.18.8'
- name: Run HLS tests
uses: nick-fields/retry@v2
with:
timeout_minutes: 10
max_attempts: 3
command: cd test/automated/hls && ./run.sh

View file

@ -1,30 +1,69 @@
name: Format Javascript
name: Lint
# This action works with pull requests and pushes
on:
push:
branches:
- develop
paths:
- web/**
pull_request_target:
paths:
- web/**
jobs:
prettier:
name: Javascript prettier
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./web
if: ${{ github.actor != 'dependabot[bot]' }}
steps:
- id: skip_check
uses: fkirc/skip-duplicate-actions@v5
with:
concurrent_skipping: 'same_content_newer'
- name: Checkout
uses: actions/checkout@v3
with:
# Make sure the actual branch is checked out when running on pull requests
ref: ${{ github.event.pull_request.head.ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
fetch-depth: 0
- name: Prettify code
uses: creyD/prettier_action@v4.2
with:
# This part is also where you can pass other options, for example:
prettier_options: --write **/*.{js,ts,jsx,tsx,css,md}
only_changed: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
linter:
name: Javascript linter
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./web
steps:
- name: Checkout
uses: actions/checkout@v3
with:
# Make sure the actual branch is checked out when running on pull requests
ref: ${{ github.head_ref }}
fetch-depth: 0
pull: --rebase --autostash
- id: skip_check
uses: fkirc/skip-duplicate-actions@v5
with:
concurrent_skipping: 'same_content_newer'
- name: Checkout
uses: actions/checkout@v3
with:
# Make sure the actual branch is checked out when running on pull requests
ref: ${{ github.event.pull_request.head.ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
fetch-depth: 0
- name: Prettify code
uses: creyD/prettier_action@v4.2
with:
# This part is also where you can pass other options, for example:
prettier_options: --write webroot/**/*.{js,md}
only_changed: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Install Dependencies
run: npm install
- name: Lint
run: npm run lint

View file

@ -1,33 +0,0 @@
name: javascript-packages
on:
push:
paths:
- build/javascript/package.json
jobs:
run:
if: ${{ github.actor != 'dependabot[bot]' }}
name: npm run build
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v3
with:
# Make sure the actual branch is checked out when running on pull requests
ref: ${{ github.head_ref }}
- name: Build dependencies
uses: actions/setup-node@v3
with:
node-version: '12'
- run: cd build/javascript && npm run build
- name: Commit changes
uses: EndBug/add-and-commit@v9
with:
author_name: Owncast
author_email: owncast@owncast.online
message: "Commit updated Javascript packages"
add: "build/javascript/package* webroot/js/web_modules"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

30
.github/workflows/shellcheck.yml vendored Normal file
View file

@ -0,0 +1,30 @@
name: Lint
on:
push:
branches:
- webv2
paths:
- '**.sh'
pull_request:
branches:
- webv2
paths:
- '**.sh'
jobs:
shellcheck:
runs-on: ubuntu-latest
env:
LANG: C.UTF-8
container:
image: docker.io/ubuntu:23.04
steps:
- uses: actions/checkout@v3
- name: Install shellcheck
run: apt update && apt install -y shellcheck bash && shellcheck --version
- name: Check shell scripts
run: shopt -s globstar && ls **/*.sh && shellcheck -x -P "SCRIPTDIR" --severity=info **/*.sh
shell: bash

View file

@ -0,0 +1,38 @@
name: Webapp Test Build
# This action works with pull requests and pushes
on:
push:
paths:
- web/**
pull_request:
paths:
- web/**
jobs:
build:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./web
name: Build webapp
steps:
- id: skip_check
uses: fkirc/skip-duplicate-actions@v5
with:
concurrent_skipping: 'same_content_newer'
- name: Checkout
uses: actions/checkout@v3
with:
# Make sure the actual branch is checked out when running on pull requests
ref: ${{ github.event.pull_request.head.ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
fetch-depth: 0
- name: Install Dependencies
run: npm install
- name: Build
run: npm run build

View file

@ -1,6 +1,13 @@
name: Tests
name: Go Tests
on:
push:
paths-ignore:
- 'web/**'
pull_request:
paths-ignore:
- 'web/**'
on: [push, pull_request]
jobs:
test:
strategy:
@ -14,7 +21,7 @@ jobs:
- name: Install go
uses: actions/setup-go@v3
with:
go-version: "^1"
go-version: '^1'
- name: Run tests
run: go test ./...
@ -35,8 +42,7 @@ jobs:
- name: Install go
uses: actions/setup-go@v3
with:
go-version: "^1"
go-version: '^1'
- name: Run tests
run: go test -race ./...
run: go test ./...

3
.gitignore vendored
View file

@ -27,6 +27,7 @@ webroot/preview.gif
webroot/hls
webroot/static/content.md
hls/
!test/automated/hls/
dist/
data/
transcoder.log
@ -39,3 +40,5 @@ backup/
test/test.db
test/automated/browser/screenshots
lefthook.yml
test/automated/browser/cypress/screenshots
test/automated/browser/cypress/videos

View file

@ -4,8 +4,8 @@ run:
# Define the Go version limit.
# Mainly related to generics support in go1.18.
# Default: use Go version from the go.mod file, fallback on the env var `GOVERSION`, fallback on 1.17
go: '1.17'
# Default: use Go version from the go.mod file, fallback on the env var `GOVERSION`, fallback on 1.18
go: '1.18'
issues:
# The linter has a default list of ignorable errors. Turning this on will enable that list.
@ -69,7 +69,7 @@ linters-settings:
gosimple:
# Select the Go version to target. The default is '1.13'.
go: '1.17'
go: '1.18'
# https://staticcheck.io/docs/options#checks
checks: ['all']

View file

@ -1,3 +1,4 @@
# Ignore artifacts:
build/javascript
webroot/js/web_modules
static/

View file

@ -1,10 +1,17 @@
# Perform a build
# IMPORTANT: This Dockerfile has been provided for the sake of convenience.
# Currently, functionality of the containers built based on this file
# is not a part of our continuous testing. Although, patches to keep it
# up to date are always welcome.
#
# See Earthfile for the recipes used in official builds.
FROM golang:alpine AS build
RUN mkdir /build
ADD . /build
WORKDIR /build
RUN apk update && apk add --no-cache git gcc build-base linux-headers
WORKDIR /build
COPY . /build
ARG VERSION=dev
ENV VERSION=${VERSION}
ARG GIT_COMMIT
@ -15,13 +22,16 @@ 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
FROM alpine:3.17.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
# Copy owncast assets
WORKDIR /app
COPY --from=build /build/owncast /app/owncast
COPY --from=build /build/webroot /app/webroot
RUN mkdir /app/data
RUN chown -R owncast:owncast /app
USER owncast
ENTRYPOINT ["/app/owncast"]
EXPOSE 8080 1935

View file

@ -25,7 +25,6 @@ crosscompiler:
code:
FROM --platform=linux/amd64 +crosscompiler
COPY . /build
# GIT CLONE --branch=$version git@github.com:owncast/owncast.git /build
build:
ARG EARTHLY_GIT_HASH # provided by Earthly
@ -76,20 +75,8 @@ build:
WORKDIR /build
# MacOSX disallows static executables, so we omit the static flag on this platform
RUN go build -a -installsuffix cgo -ldflags "$([ "$GOOS"z != darwinz ] && echo "-linkmode external -extldflags -static ") -s -w -X github.com/owncast/owncast/config.GitCommit=$EARTHLY_GIT_HASH -X github.com/owncast/owncast/config.VersionNumber=$version -X github.com/owncast/owncast/config.BuildPlatform=$NAME" -tags sqlite_omit_load_extension -o owncast main.go
COPY +tailwind/prod-tailwind.min.css /build/dist/webroot/js/web_modules/tailwindcss/dist/tailwind.min.css
SAVE ARTIFACT owncast owncast
SAVE ARTIFACT webroot webroot
SAVE ARTIFACT README.md README.md
tailwind:
FROM +code
WORKDIR /build/build/javascript
RUN apk add --update --no-cache npm >> /dev/null
ENV NODE_ENV=production
RUN cd /build/build/javascript && npm install --quiet --no-progress >> /dev/null && npm install -g cssnano postcss postcss-cli --quiet --no-progress --save-dev >> /dev/null && ./node_modules/.bin/tailwind build > /build/tailwind.min.css
RUN npx postcss /build/tailwind.min.css > /build/prod-tailwind.min.css
SAVE ARTIFACT /build/prod-tailwind.min.css prod-tailwind.min.css
package:
RUN apk add --update --no-cache zip >> /dev/null
@ -109,33 +96,66 @@ package:
ARG NAME=custom
END
COPY (+build/webroot --platform $TARGETPLATFORM) /build/dist/webroot
COPY (+build/owncast --platform $TARGETPLATFORM) /build/dist/owncast
COPY (+build/README.md --platform $TARGETPLATFORM) /build/dist/README.md
ENV ZIPNAME owncast-$version-$NAME.zip
RUN cd /build/dist && zip -r -q -8 /build/dist/owncast.zip .
SAVE ARTIFACT /build/dist/owncast.zip owncast.zip AS LOCAL dist/$ZIPNAME
docker:
ARG image=ghcr.io/owncast/owncast
ARG tag=develop
# Multiple image names can be tagged at once. They should all be passed
# in as space separated strings using the full account/repo:tag format.
# https://github.com/earthly/earthly/blob/aea38448fa9c0064b1b70d61be717ae740689fb9/docs/earthfile/earthfile.md#assigning-multiple-image-names
ARG TARGETPLATFORM
FROM --platform=$TARGETPLATFORM alpine:3.15.5
RUN apk update && apk add --no-cache ffmpeg ffmpeg-libs ca-certificates unzip && update-ca-certificates
RUN addgroup -g 101 -S owncast && adduser -u 101 -S owncast -G owncast
WORKDIR /app
COPY --platform=$TARGETPLATFORM +package/owncast.zip /app
RUN unzip -x owncast.zip && mkdir data
# temporarily disable until we figure out how to move forward
# RUN chown -R owncast:owncast /app
# USER owncast
ENTRYPOINT ["/app/owncast"]
EXPOSE 8080 1935
SAVE IMAGE --push $image:$tag
api-tests:
FROM --platform=linux/amd64 +code
WORKDIR /build
RUN apk add npm ffmpeg
RUN cd test/automated/api && npm install && ./run.sh
ARG images=ghcr.io/owncast/owncast:testing
RUN echo "Saving images: ${images}"
# Tag this image with the list of names
# passed along.
FOR --no-cache i IN ${images}
SAVE IMAGE --push "${i}"
END
dockerfile:
FROM DOCKERFILE -f Dockerfile .
testing:
ARG images
FOR i IN ${images}
RUN echo "Testing ${i}"
END
unit-tests:
FROM --platform=linux/amd64 +code
FROM --platform=linux/amd64 bdwyertech/go-crosscompile
COPY . /build
WORKDIR /build
RUN go test ./...
api-tests:
FROM --platform=linux/amd64 bdwyertech/go-crosscompile
RUN apk add npm font-noto && fc-cache -f
COPY . /build
WORKDIR /build/test/automated/api
RUN npm install
RUN ./run.sh
hls-tests:
FROM --platform=linux/amd64 bdwyertech/go-crosscompile
RUN apk add npm font-noto && fc-cache -f
COPY . /build
WORKDIR /build/test/automated/hls
RUN npm install
RUN ./run.sh

View file

@ -44,7 +44,7 @@
</a>
</p>
Owncast is an open source, self-hosted, decentralized, single user live video streaming and chat server for running your own live streams similar in style to the large mainstream options. It offers complete ownership over your content, interface, moderation and audience. <a href="https://watch.owncast.online">Visit the demo</a> for an example.
Owncast is an open source, self-hosted, decentralized, single user live video streaming and chat server for running your own live streams similar in style to the large mainstream options. It offers complete ownership over your content, interface, moderation and audience. <a href="https://watch.owncast.online">Visit the demo</a> for an example.
<div>
<img alt="GitHub all releases" src="https://img.shields.io/github/downloads/owncast/owncast/total?style=for-the-badge">
@ -59,8 +59,6 @@ Owncast is an open source, self-hosted, decentralized, single user live video st
</a>
</div>
[![contribute.design](https://contribute.design/api/shield/owncast/owncast)](https://contribute.design/owncast/owncast)
---
<!-- GETTING STARTED -->
@ -77,25 +75,37 @@ OBS, Streamlabs, Restream and many others have been used with Owncast. [Read mor
## Building from Source
Owncast consists of two projects.
1. The Owncast backend written in Go.
1. The frontend written in React.
[Read more about running from source](https://owncast.online/development/).
### Important note about source code and the develop branch
The `develop` branch is always the most up-to-date state of development and this may not be what you always want. If you want to run the latest released stable version, check out the tag related to that release. For example, if you'd only like the source prior to the v0.1.0 development cycle you can check out the `v0.0.13` tag.
### Backend
The Owncast backend is a service written in Go.
1. Ensure you have pre-requisites installed.
- C compiler, such as [GCC compiler](https://gcc.gnu.org/install/download.html) or a [Musl-compatible compiler](https://musl.libc.org/)
- [ffmpeg](https://ffmpeg.org/download.html)
1. Install the [Go toolchain](https://golang.org/dl/) (1.17 or above).
- C compiler, such as [GCC compiler](https://gcc.gnu.org/install/download.html) or a [Musl-compatible compiler](https://musl.libc.org/)
- [ffmpeg](https://ffmpeg.org/download.html)
1. Install the [Go toolchain](https://golang.org/dl/) (1.18 or above).
1. Clone the repo. `git clone https://github.com/owncast/owncast`
1. `go run main.go` will run from source.
1. Visit `http://yourserver:8080` to access the web interface or `http://yourserver:8080/admin` to access the admin.
1. Point your [broadcasting software](https://owncast.online/docs/broadcasting/) at your new server and start streaming.
There is also a supplied `Dockerfile` so you can spin it up from source with little effort. [Read more about running from source](https://owncast.online/docs/building/).
### Frontend
### Bundling in latest admin from source
The frontend is the web interface that includes the player, chat, embed components, and other UI.
The admin ui is built at: https://github.com/owncast/owncast-admin it is bundled into the final binary using pkger.
To bundle in the latest admin UI:
1. From the owncast directory run the packager script: `./build/admin/bundleAdmin.sh`
1. Compile or run like above. `go run main.go`
1. This project lives in the `web` directory.
1. Run `npm install` to install the Javascript dependencies.
1. Run `npm run dev`
## Contributing
@ -107,17 +117,6 @@ Weve been very lucky to have this so far, so maybe you can help us with your
There is a larger, more detailed, and more up-to-date [guide for helping contribute to Owncast on our website](https://owncast.online/help/).
### Architecture
Owncast consists of two repositories with two standalone projects. [The repo you're looking at now](https://github.com/owncast/owncast) is the core repository with the backend and frontend. [owncast/owncast-admin](https://github.com/owncast/owncast-admin) is an additional web project that is built separately and used for configuration and management of an Owncast server.
### Suggestions when working with the Owncast codebase
1. Install [golangci-lint](https://golangci-lint.run/usage/install/) for helpful warnings and suggestions [directly in your editor](https://golangci-lint.run/usage/integrations/) when writing Go.
1. If using VSCode install the [lit-html](https://marketplace.visualstudio.com/items?itemName=bierner.lit-html) extension to aid in syntax highlighting of our frontend HTML + Preact.
1. Run the project with `go run main.go`.
<!-- LICENSE -->
## License
@ -130,6 +129,6 @@ Distributed under the MIT License. See `LICENSE` for more information.
Project chat: [Join us on Rocket.Chat](https://owncast.rocket.chat/home) if you want to contribute, follow along, or if you have questions.
Gabe Kangas - [@gabek@fosstodon.org](https://fosstodon.org/@gabek) - email [gabek@real-ity.com](mailto:gabek@real-ity.com)
Gabe Kangas - [@gabek@social.gabekangas.com](https://social.gabekangas.com/gabek) - email [gabek@real-ity.com](mailto:gabek@real-ity.com)
Project Link: [https://github.com/owncast/owncast](https://github.com/owncast/owncast)

View file

@ -153,7 +153,7 @@ func TestMakeServiceForAccount(t *testing.T) {
t.Errorf("actor.Followers = %v, want %v", person.GetActivityStreamsFollowers().GetIRI().String(), expectedFollowers)
}
expectedName := "Owncast"
expectedName := "New Owncast Server"
if person.GetActivityStreamsName().Begin().GetXMLSchemaString() != expectedName {
t.Errorf("actor.Name = %v, want %v", person.GetActivityStreamsName().Begin().GetXMLSchemaString(), expectedName)
}
@ -168,7 +168,7 @@ func TestMakeServiceForAccount(t *testing.T) {
t.Errorf("actor.Avatar = %v, want %v", person.GetActivityStreamsIcon().At(0).GetActivityStreamsImage().GetActivityStreamsUrl().Begin().GetIRI().String(), expectedAvatar)
}
expectedSummary := "Welcome to your new Owncast server! This description can be changed in the admin. Visit https://owncast.online/docs/configuration/ to learn more."
expectedSummary := "This is a new live video streaming server powered by Owncast."
if person.GetActivityStreamsSummary().At(0).GetXMLSchemaString() != expectedSummary {
t.Errorf("actor.Summary = %v, want %v", person.GetActivityStreamsSummary().At(0).GetXMLSchemaString(), expectedSummary)
}

View file

@ -15,44 +15,53 @@ import (
func WebfingerHandler(w http.ResponseWriter, r *http.Request) {
if !data.GetFederationEnabled() {
w.WriteHeader(http.StatusMethodNotAllowed)
log.Debugln("webfinger request rejected! Federation is not enabled")
return
}
instanceHostURL := data.GetServerURL()
if instanceHostURL == "" {
w.WriteHeader(http.StatusNotFound)
log.Warnln("webfinger request rejected! Federation is enabled but server URL is empty.")
return
}
instanceHostString := utils.GetHostnameFromURLString(instanceHostURL)
if instanceHostString == "" {
w.WriteHeader(http.StatusNotFound)
log.Warnln("webfinger request rejected! Federation is enabled but server URL is not set properly. data.GetServerURL(): " + data.GetServerURL())
return
}
resource := r.URL.Query().Get("resource")
resourceComponents := strings.Split(resource, ":")
preAcct, account, foundAcct := strings.Cut(resource, "acct:")
var account string
if len(resourceComponents) == 2 {
account = resourceComponents[1]
} else {
account = resourceComponents[0]
if !foundAcct || preAcct != "" {
w.WriteHeader(http.StatusBadRequest)
log.Debugln("webfinger request rejected! Malformed resource in query: " + resource)
return
}
userComponents := strings.Split(account, "@")
if len(userComponents) < 2 {
if len(userComponents) != 2 {
w.WriteHeader(http.StatusBadRequest)
log.Debugln("webfinger request rejected! Malformed account in query: " + account)
return
}
host := userComponents[1]
user := userComponents[0]
if _, valid := data.GetFederatedInboxMap()[user]; !valid {
// User is not valid
w.WriteHeader(http.StatusNotFound)
log.Debugln("webfinger request rejected")
log.Debugln("webfinger request rejected! Invalid user: " + user)
return
}
// If the webfinger request doesn't match our server then it
// should be rejected.
instanceHostString := data.GetServerURL()
if instanceHostString == "" {
w.WriteHeader(http.StatusNotImplemented)
return
}
instanceHostString = utils.GetHostnameFromURLString(instanceHostString)
if instanceHostString == "" || instanceHostString != host {
if instanceHostString != host {
w.WriteHeader(http.StatusNotImplemented)
log.Debugln("webfinger request rejected! Invalid query host: " + host + " instanceHostString: " + instanceHostString)
return
}

View file

@ -77,8 +77,8 @@ func SendLive() error {
if err == nil {
var imageToAttach string
var mediaType string
previewGif := filepath.Join(config.WebRoot, "preview.gif")
thumbnailJpg := filepath.Join(config.WebRoot, "thumbnail.jpg")
previewGif := filepath.Join(config.TempDir, "preview.gif")
thumbnailJpg := filepath.Join(config.TempDir, "thumbnail.jpg")
uniquenessString := shortid.MustGenerate()
if utils.DoesFileExists(previewGif) {
imageToAttach = "preview.gif"

View file

@ -2,9 +2,13 @@ package fediverse
import (
"crypto/rand"
"errors"
"io"
"strings"
"sync"
"time"
log "github.com/sirupsen/logrus"
)
// OTPRegistration represents a single OTP request.
@ -18,19 +22,53 @@ type OTPRegistration struct {
// Key by access token to limit one OTP request for a person
// to be active at a time.
var pendingAuthRequests = make(map[string]OTPRegistration)
var (
pendingAuthRequests = make(map[string]OTPRegistration)
lock = sync.Mutex{}
)
const registrationTimeout = time.Minute * 10
const (
registrationTimeout = time.Minute * 10
maxPendingRequests = 1000
)
func init() {
go setupExpiredRequestPruner()
}
// Clear out any pending requests that have been pending for greater than
// the specified timeout value.
func setupExpiredRequestPruner() {
pruneExpiredRequestsTimer := time.NewTicker(registrationTimeout)
for range pruneExpiredRequestsTimer.C {
lock.Lock()
log.Debugln("Pruning expired OTP requests.")
for k, v := range pendingAuthRequests {
if time.Since(v.Timestamp) > registrationTimeout {
delete(pendingAuthRequests, k)
}
}
lock.Unlock()
}
}
// RegisterFediverseOTP will start the OTP flow for a user, creating a new
// code and returning it to be sent to a destination.
func RegisterFediverseOTP(accessToken, userID, userDisplayName, account string) (OTPRegistration, bool) {
func RegisterFediverseOTP(accessToken, userID, userDisplayName, account string) (OTPRegistration, bool, error) {
request, requestExists := pendingAuthRequests[accessToken]
// If a request is already registered and has not expired then return that
// existing request.
if requestExists && time.Since(request.Timestamp) < registrationTimeout {
return request, false
return request, false, nil
}
lock.Lock()
defer lock.Unlock()
if len(pendingAuthRequests)+1 > maxPendingRequests {
return request, false, errors.New("Please try again later. Too many pending requests.")
}
code, _ := createCode()
@ -43,7 +81,7 @@ func RegisterFediverseOTP(accessToken, userID, userDisplayName, account string)
}
pendingAuthRequests[accessToken] = r
return r, true
return r, true, nil
}
// ValidateFediverseOTP will verify a OTP code for a auth request.
@ -54,6 +92,9 @@ func ValidateFediverseOTP(accessToken, code string) (bool, *OTPRegistration) {
return false, nil
}
lock.Lock()
defer lock.Unlock()
delete(pendingAuthRequests, accessToken)
return true, &request
}

View file

@ -3,6 +3,8 @@ package fediverse
import (
"strings"
"testing"
"github.com/owncast/owncast/utils"
)
const (
@ -13,7 +15,10 @@ const (
)
func TestOTPFlowValidation(t *testing.T) {
r, success := RegisterFediverseOTP(accessToken, userID, userDisplayName, account)
r, success, err := RegisterFediverseOTP(accessToken, userID, userDisplayName, account)
if err != nil {
t.Error(err)
}
if !success {
t.Error("Registration should be permitted.")
@ -50,8 +55,8 @@ func TestOTPFlowValidation(t *testing.T) {
}
func TestSingleOTPFlowRequest(t *testing.T) {
r1, _ := RegisterFediverseOTP(accessToken, userID, userDisplayName, account)
r2, s2 := RegisterFediverseOTP(accessToken, userID, userDisplayName, account)
r1, _, _ := RegisterFediverseOTP(accessToken, userID, userDisplayName, account)
r2, s2, _ := RegisterFediverseOTP(accessToken, userID, userDisplayName, account)
if r1.Code != r2.Code {
t.Error("Only one registration should be permitted.")
@ -65,14 +70,42 @@ func TestSingleOTPFlowRequest(t *testing.T) {
func TestAccountCaseInsensitive(t *testing.T) {
account := "Account"
accessToken := "another-fake-access-token"
r1, _ := RegisterFediverseOTP(accessToken, userID, userDisplayName, account)
r1, _, _ := RegisterFediverseOTP(accessToken, userID, userDisplayName, account)
_, reg1 := ValidateFediverseOTP(accessToken, r1.Code)
// Simulate second auth with account in different case
r2, _ := RegisterFediverseOTP(accessToken, userID, userDisplayName, strings.ToUpper(account))
r2, _, _ := RegisterFediverseOTP(accessToken, userID, userDisplayName, strings.ToUpper(account))
_, reg2 := ValidateFediverseOTP(accessToken, r2.Code)
if reg1.Account != reg2.Account {
t.Errorf("Account names should be case-insensitive: %s %s", reg1.Account, reg2.Account)
}
}
func TestLimitGlobalPendingRequests(t *testing.T) {
for i := 0; i < maxPendingRequests-1; i++ {
at, _ := utils.GenerateRandomString(10)
uid, _ := utils.GenerateRandomString(10)
account, _ := utils.GenerateRandomString(10)
_, success, error := RegisterFediverseOTP(at, uid, "userDisplayName", account)
if !success {
t.Error("Registration should be permitted.", i, " of ", len(pendingAuthRequests))
}
if error != nil {
t.Error(error)
}
}
// This one should fail
at, _ := utils.GenerateRandomString(10)
uid, _ := utils.GenerateRandomString(10)
account, _ := utils.GenerateRandomString(10)
_, success, error := RegisterFediverseOTP(at, uid, "userDisplayName", account)
if success {
t.Error("Registration should not be permitted.")
}
if error == nil {
t.Error("Error should be returned.")
}
}

View file

@ -8,16 +8,48 @@ import (
"net/url"
"strconv"
"strings"
"sync"
"time"
"github.com/owncast/owncast/core/data"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
)
var pendingAuthRequests = make(map[string]*Request)
var (
pendingAuthRequests = make(map[string]*Request)
lock = sync.Mutex{}
)
const registrationTimeout = time.Minute * 10
func init() {
go setupExpiredRequestPruner()
}
// Clear out any pending requests that have been pending for greater than
// the specified timeout value.
func setupExpiredRequestPruner() {
pruneExpiredRequestsTimer := time.NewTicker(registrationTimeout)
for range pruneExpiredRequestsTimer.C {
lock.Lock()
log.Debugln("Pruning expired IndieAuth requests.")
for k, v := range pendingAuthRequests {
if time.Since(v.Timestamp) > registrationTimeout {
delete(pendingAuthRequests, k)
}
}
lock.Unlock()
}
}
// StartAuthFlow will begin the IndieAuth flow by generating an auth request.
func StartAuthFlow(authHost, userID, accessToken, displayName string) (*url.URL, error) {
if len(pendingAuthRequests) >= maxPendingRequests {
return nil, errors.New("Please try again later. Too many pending requests.")
}
serverURL := data.GetServerURL()
if serverURL == "" {
return nil, errors.New("Owncast server URL must be set when using auth")

View file

@ -7,6 +7,7 @@ import (
"net/http"
"net/url"
"strings"
"time"
"github.com/andybalholm/cascadia"
"github.com/pkg/errors"
@ -63,6 +64,7 @@ func createAuthRequest(authDestination, userID, displayName, accessToken, baseSe
State: state,
Redirect: &redirect,
Callback: &callbackURL,
Timestamp: time.Now(),
}, nil
}
@ -72,6 +74,10 @@ func getAuthEndpointFromURL(urlstring string) (*url.URL, error) {
return nil, errors.Wrap(err, "unable to parse URL")
}
if htmlDocScrapeURL.Scheme != "https" {
return nil, fmt.Errorf("url must be https")
}
r, err := http.Get(htmlDocScrapeURL.String()) // nolint:gosec
if err != nil {
return nil, err

View file

@ -0,0 +1,35 @@
package indieauth
import (
"testing"
"github.com/owncast/owncast/utils"
)
func TestLimitGlobalPendingRequests(t *testing.T) {
// Simulate 10 pending requests
for i := 0; i < maxPendingRequests-1; i++ {
cid, _ := utils.GenerateRandomString(10)
redirectURL, _ := utils.GenerateRandomString(10)
cc, _ := utils.GenerateRandomString(10)
state, _ := utils.GenerateRandomString(10)
me, _ := utils.GenerateRandomString(10)
_, err := StartServerAuth(cid, redirectURL, cc, state, me)
if err != nil {
t.Error("Registration should be permitted.", i, " of ", len(pendingAuthRequests), err)
}
}
// This should throw an error
cid, _ := utils.GenerateRandomString(10)
redirectURL, _ := utils.GenerateRandomString(10)
cc, _ := utils.GenerateRandomString(10)
state, _ := utils.GenerateRandomString(10)
me, _ := utils.GenerateRandomString(10)
_, err := StartServerAuth(cid, redirectURL, cc, state, me)
if err == nil {
t.Error("Registration should not be permitted.")
}
}

View file

@ -1,6 +1,9 @@
package indieauth
import "net/url"
import (
"net/url"
"time"
)
// Request represents a single in-flight IndieAuth request.
type Request struct {
@ -15,4 +18,5 @@ type Request struct {
CodeChallenge string
State string
Me *url.URL
Timestamp time.Time
}

View file

@ -2,6 +2,7 @@ package indieauth
import (
"fmt"
"time"
"github.com/owncast/owncast/core/data"
"github.com/pkg/errors"
@ -17,6 +18,7 @@ type ServerAuthRequest struct {
State string
Me string
Code string
Timestamp time.Time
}
// ServerProfile represents basic user-provided data about this Owncast instance.
@ -38,10 +40,16 @@ type ServerProfileResponse struct {
var pendingServerAuthRequests = map[string]ServerAuthRequest{}
const maxPendingRequests = 1000
// StartServerAuth will handle the authentication for the admin user of this
// Owncast server. Initiated via a GET of the auth endpoint.
// https://indieweb.org/authorization-endpoint
func StartServerAuth(clientID, redirectURI, codeChallenge, state, me string) (*ServerAuthRequest, error) {
if len(pendingServerAuthRequests)+1 >= maxPendingRequests {
return nil, errors.New("Please try again later. Too many pending requests.")
}
code := shortid.MustGenerate()
r := ServerAuthRequest{
@ -51,6 +59,7 @@ func StartServerAuth(clientID, redirectURI, codeChallenge, state, me string) (*S
State: state,
Me: me,
Code: code,
Timestamp: time.Now(),
}
pendingServerAuthRequests[code] = r

View file

@ -1,41 +0,0 @@
#!/usr/bin/env bash
# shellcheck disable=SC2059
set -o errexit
set -o nounset
set -o pipefail
INSTALL_TEMP_DIRECTORY="$(mktemp -d)"
PROJECT_SOURCE_DIR=$(pwd)
cd $INSTALL_TEMP_DIRECTORY
shutdown () {
rm -rf "$INSTALL_TEMP_DIRECTORY"
}
trap shutdown INT TERM ABRT EXIT
echo "Cloning owncast admin into $INSTALL_TEMP_DIRECTORY..."
git clone https://github.com/owncast/owncast-admin 2> /dev/null
cd owncast-admin
echo "Installing npm modules for the owncast admin..."
npm --silent install 2> /dev/null
echo "Building owncast admin..."
rm -rf .next
(node_modules/.bin/next build && node_modules/.bin/next export) | grep info
echo "Copying admin to project directory..."
ADMIN_BUILD_DIR=$(pwd)
cd $PROJECT_SOURCE_DIR
mkdir -p admin 2> /dev/null
cd admin
# Remove the old one
rm -rf $PROJECT_SOURCE_DIR/static/admin
# Copy over the new one
mv ${ADMIN_BUILD_DIR}/out $PROJECT_SOURCE_DIR/static/admin
shutdown
echo "Done."

24
build/develop/container.sh Executable file
View file

@ -0,0 +1,24 @@
#!/bin/sh
set -e
# Development container builder
#
# Must authenticate first: https://docs.github.com/en/packages/using-github-packages-with-your-projects-ecosystem/configuring-docker-for-use-with-github-packages#authenticating-to-github-packages
# env vars:
# $EARTHLY_BUILD_BRANCH: git branch to checkout
# $EARTHLY_BUILD_TAG: tag for container image
EARTHLY_IMAGE_NAME="owncast"
BUILD_TAG=${EARTHLY_BUILD_TAG:-webv2}
DATE=$(date +"%Y%m%d")
VERSION="${DATE}-${BUILD_TAG}"
echo "Building container image ${EARTHLY_IMAGE_NAME}:${BUILD_TAG} ..."
# Change to the root directory of the repository
cd "$(git rev-parse --show-toplevel)" || exit
if [ -n "${EARTHLY_BUILD_BRANCH}" ]; then
git checkout "${EARTHLY_BUILD_BRANCH}" || exit
fi
earthly --ci +docker-all --images="ghcr.io/owncast/${EARTHLY_IMAGE_NAME}:${BUILD_TAG}" --version="${VERSION}"

View file

@ -1,16 +0,0 @@
## Third party web dependencies
Owncast's web frontend utilizes a few third party Javascript and CSS dependencies that we ship with the application.
To add, remove, or update one of these components:
1. Perform your `npm install/uninstall/etc`, or edit the `package.json` file to reflect the change you want to make.
2. Edit the `snowpack` `install` block of `package.json` to specify what files you want to add to the Owncast project. This can be an entire library (such as `preact`) or a single file (such as `video.js/dist/video.min.js`). These paths point to files that live in `node_modules`.
3. Run `npm run build`. This will download the requested module from NPM, package up the assets you specified, and then copy them to the Owncast web app in the `webroot/js/web_modules` directory.
4. Your new web dependency is now available for use in your web code.
## VideoJS versions
Currently Videojs version 7.8.3 and http-streaming version 2.2.0 are hardcoded because these are versions that have been found to work properly with our HLS stream. Other versions have had issues with things like discontinuities causing a loading spinner.
So if you update videojs or vhs make sure you do an end-to-end test of a stream and make sure the "this stream is offline" ending video displays properly.

File diff suppressed because it is too large Load diff

View file

@ -1,41 +0,0 @@
{
"name": "owncast-dependencies",
"version": "1.0.0",
"description": "Javascript dependencies for Owncast web app",
"main": "index.js",
"dependencies": {
"@joeattardi/emoji-button": "^4.6.2",
"@videojs/themes": "^1.0.1",
"htm": "^3.1.0",
"mark.js": "^8.11.1",
"micromodal": "^0.4.10",
"preact": "10.6.6",
"tailwindcss": "^1.9.6",
"video.js": "7.17.0"
},
"devDependencies": {
"cssnano": "5.1.0",
"postcss": "8.4.7",
"postcss-cli": "9.1.0"
},
"snowpack": {
"install": [
"@videojs/themes/fantasy/*",
"video.js/dist/video-js.min.css",
"video.js/dist/video.min.js",
"@joeattardi/emoji-button",
"htm",
"preact",
"preact/hooks",
"mark.js/dist/mark.es6.min.js",
"tailwindcss/dist/tailwind.min.css",
"micromodal/dist/micromodal.min.js"
]
},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"build": "npm install && npx snowpack@2.18.4 install && cp node_modules/video.js/dist/video-js.min.css web_modules/videojs && rm -rf ../../webroot/js/web_modules && cp -R web_modules ../../webroot/js"
},
"author": "Owncast",
"license": "ISC"
}

View file

@ -1,7 +0,0 @@
module.exports = {
plugins: [
require('cssnano')({
preset: 'default',
}),
],
};

View file

@ -1,7 +0,0 @@
module.exports = {
purge: {
enabled: true,
mode: 'layers',
content: ['../../webroot/js/**'],
},
};

View file

@ -1,118 +0,0 @@
#!/bin/sh
# Human readable names of binary distributions
DISTRO=(macOS-64bit linux-64bit linux-32bit linux-arm7 linux-arm64)
# Operating systems for the respective distributions
OS=(darwin linux linux linux linux)
# Architectures for the respective distributions
ARCH=(amd64 amd64 386 arm-7 arm64)
# Version
VERSION=$1
SHOULD_RELEASE=$2
# Build info
GIT_COMMIT=$(git rev-list -1 HEAD)
GIT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
if [[ -z "${VERSION}" ]]; then
echo "Version must be specified when running build"
exit
fi
BUILD_TEMP_DIRECTORY="$(mktemp -d)"
cd $BUILD_TEMP_DIRECTORY
echo "Cloning owncast into $BUILD_TEMP_DIRECTORY..."
git clone https://github.com/owncast/owncast 2> /dev/null
cd owncast
echo "Changing to branch: $GIT_BRANCH"
git checkout $GIT_BRANCH
[[ -z "${VERSION}" ]] && VERSION='unknownver' || VERSION="${VERSION}"
# Change to the root directory of the repository
cd $(git rev-parse --show-toplevel)
echo "Cleaning working directories..."
rm -rf ./webroot/hls/* ./hls/* ./webroot/thumbnail.jpg
echo "Creating version ${VERSION} from commit ${GIT_COMMIT}"
# Create production build of Tailwind CSS
pushd build/javascript >> /dev/null
# Install the tailwind & postcss CLIs
npm install --quiet --no-progress
# Run the tailwind CLI and pipe it to postcss for minification.
# Save it to a temp directory that we will reference below.
NODE_ENV="production" ./node_modules/.bin/tailwind build | ./node_modules/.bin/postcss > "${TMPDIR}tailwind.min.css"
popd
mkdir -p dist
build() {
NAME=$1
OS=$2
ARCH=$3
VERSION=$4
GIT_COMMIT=$5
echo "Building ${NAME} (${OS}/${ARCH}) release from ${GIT_BRANCH} ${GIT_COMMIT}..."
mkdir -p dist/${NAME}
mkdir -p dist/${NAME}/data
cp -R webroot/ dist/${NAME}/webroot/
# Copy the production pruned+minified css to the build's directory.
cp "${TMPDIR}tailwind.min.css" ./dist/${NAME}/webroot/js/web_modules/tailwindcss/dist/tailwind.min.css
cp README.md dist/${NAME}
pushd dist/${NAME} >> /dev/null
CGO_ENABLED=1 ~/go/bin/xgo -go latest --branch ${GIT_BRANCH} -ldflags "-s -w -X github.com/owncast/owncast/config.GitCommit=${GIT_COMMIT} -X github.com/owncast/owncast/config.BuildVersion=${VERSION} -X github.com/owncast/owncast/config.BuildPlatform=${NAME}" -tags enable_updates -targets "${OS}/${ARCH}" github.com/owncast/owncast
mv owncast-*-${ARCH} owncast
zip -r -q -8 ../owncast-$VERSION-$NAME.zip .
popd >> /dev/null
rm -rf dist/${NAME}/
}
for i in "${!DISTRO[@]}"; do
build ${DISTRO[$i]} ${OS[$i]} ${ARCH[$i]} $VERSION $GIT_COMMIT
done
echo "Build archives are available in $BUILD_TEMP_DIRECTORY/owncast/dist"
ls -alh "$BUILD_TEMP_DIRECTORY/owncast/dist"
# Use the second argument "release" to create an actual release.
if [ "$SHOULD_RELEASE" != "release" ]; then
echo "Not uploading a release."
exit
fi
# Create the tag
git tag -a "v${VERSION}" -m "Release build v${VERSION}"
# On macOS open the Github page for new releases so they can be uploaded
if test -f "/usr/bin/open"; then
open "https://github.com/owncast/owncast/releases/new"
open dist
fi
# Docker build
# Must authenticate first: https://docs.github.com/en/packages/using-github-packages-with-your-projects-ecosystem/configuring-docker-for-use-with-github-packages#authenticating-to-github-packages
DOCKER_IMAGE="owncast-${VERSION}"
echo "Building Docker image ${DOCKER_IMAGE}..."
# Change to the root directory of the repository
cd $(git rev-parse --show-toplevel)
# Docker build
docker build --build-arg NAME=docker --build-arg VERSION=${VERSION} --build-arg GIT_COMMIT=$GIT_COMMIT -t gabekangas/owncast:$VERSION -t gabekangas/owncast:latest -t owncast .
# Dockerhub
# You must be authenticated via `docker login` with your Dockerhub credentials first.
docker push "gabekangas/owncast:${VERSION}"

View file

@ -1,14 +0,0 @@
#!/bin/sh
# Docker build
# Must authenticate first: https://docs.github.com/en/packages/using-github-packages-with-your-projects-ecosystem/configuring-docker-for-use-with-github-packages#authenticating-to-github-packages
DOCKER_IMAGE="owncast"
DATE=$(date +"%Y%m%d")
VERSION="${DATE}-nightly"
echo "Building Docker image ${DOCKER_IMAGE}..."
# Change to the root directory of the repository
cd $(git rev-parse --show-toplevel)
earthly --ci --push +docker-all --image="ghcr.io/owncast/${DOCKER_IMAGE}" --tag=nightly --version="${VERSION}"

View file

@ -1,15 +0,0 @@
#!/bin/sh
# Docker build
# Must authenticate first: https://docs.github.com/en/packages/using-github-packages-with-your-projects-ecosystem/configuring-docker-for-use-with-github-packages#authenticating-to-github-packages
DOCKER_IMAGE="owncast"
DATE=$(date +"%Y%m%d")
TAG="webv2"
VERSION="${DATE}-${TAG}"
echo "Building Docker image ${DOCKER_IMAGE}..."
# Change to the root directory of the repository
cd $(git rev-parse --show-toplevel)
git checkout webv2
earthly --ci --push +docker-all --images="ghcr.io/owncast/${DOCKER_IMAGE}:${TAG}" --version="${VERSION}"

28
build/web/bundleWeb.sh Executable file
View file

@ -0,0 +1,28 @@
#!/usr/bin/env bash
# shellcheck disable=SC2059
set -o errexit
set -o nounset
set -o pipefail
# Change to the root directory of the repository
cd "$(git rev-parse --show-toplevel)"
cd web
echo "Installing npm modules for the owncast web..."
npm --silent install 2>/dev/null
echo "Building owncast web..."
rm -rf .next
(node_modules/.bin/next build && node_modules/.bin/next export) | grep info
echo "Copying web project to dist directory..."
# Remove the old one
rm -rf ../static/web
# Copy over the new one
mv ./out ../static/web
echo "Done."

View file

@ -40,6 +40,9 @@ var BuildPlatform = "dev"
// EnableAutoUpdate will explicitly enable in-place auto-updates via the admin.
var EnableAutoUpdate = false
// A temporary stream key that can be set via the command line.
var TemporaryStreamKey = ""
// GetCommit will return an identifier used for identifying the point in time this build took place.
func GetCommit() string {
if GitCommit == "" {

View file

@ -4,15 +4,16 @@ import "path/filepath"
const (
// StaticVersionNumber is the version of Owncast that is used when it's not overwritten via build-time settings.
StaticVersionNumber = "0.0.13" // Shown when you build from develop
// WebRoot is the web server root directory.
WebRoot = "webroot"
StaticVersionNumber = "0.1.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.
DataDirectory = "data"
// EmojiDir is relative to the webroot.
EmojiDir = "/img/emoji"
// EmojiDir defines the URL route prefix for emoji requests.
EmojiDir = "/img/emoji/"
// MaxUserColor is the largest color value available to assign to users.
// They start at 0 and can be treated as IDs more than colors themselves.
MaxUserColor = 7
// MaxChatDisplayNameLength is the maximum length of a chat display name.
MaxChatDisplayNameLength = 30
)
@ -23,4 +24,10 @@ var (
// HLSStoragePath is the directory HLS video is written to.
HLSStoragePath = filepath.Join(DataDirectory, "hls")
// CustomEmojiPath is the emoji directory.
CustomEmojiPath = filepath.Join(DataDirectory, "emoji")
// PublicFilesPath is the optional directory for hosting public files.
PublicFilesPath = filepath.Join(DataDirectory, "public")
)

View file

@ -20,7 +20,8 @@ type Defaults struct {
WebServerPort int
WebServerIP string
RTMPServerPort int
StreamKey string
AdminPassword string
StreamKeys []models.StreamKey
YPEnabled bool
YPServer string
@ -38,17 +39,34 @@ type Defaults struct {
// GetDefaults will return default configuration values.
func GetDefaults() Defaults {
return Defaults{
Name: "Owncast",
Title: "My Owncast Server",
Summary: "This is brief summary of whom you are or what your stream is. You can edit this description in the admin.",
Name: "New Owncast Server",
Summary: "This is a new live video streaming server powered by Owncast.",
ServerWelcomeMessage: "",
Logo: "logo.svg",
AdminPassword: "abc123",
StreamKeys: []models.StreamKey{
{Key: "abc123", Comment: "Default stream key"},
},
Tags: []string{
"owncast",
"streaming",
},
PageBodyContent: "# This is your page content that can be edited from the admin.",
PageBodyContent: `
# Welcome to Owncast!
- This is a live stream powered by [Owncast](https://owncast.online), a free and open source live streaming server.
- To discover more examples of streams, visit [Owncast's directory](https://directory.owncast.online).
- If you're the owner of this server you should visit the admin and customize the content on this page.
<hr/>
<video id="video" controls preload="metadata" style="width: 60vw; max-width: 600px; min-width: 200px;" poster="https://videos.owncast.online/t/xaJ3xNn9Y6pWTdB25m9ai3">
<source src="https://assets.owncast.tv/video/owncast-embed.mp4" type="video/mp4" />
</video>
`,
DatabaseFilePath: "data/owncast.db",
@ -58,7 +76,6 @@ func GetDefaults() Defaults {
WebServerPort: 8080,
WebServerIP: "0.0.0.0",
RTMPServerPort: 1935,
StreamKey: "abc123",
ChatEstablishedUserModeTimeDuration: time.Minute * 15,

View file

@ -0,0 +1,35 @@
package admin
import (
"encoding/json"
"net/http"
"github.com/owncast/owncast/controllers"
"github.com/owncast/owncast/core/data"
)
// SetCustomColorVariableValues sets the custom color variables.
func SetCustomColorVariableValues(w http.ResponseWriter, r *http.Request) {
if !requirePOST(w, r) {
return
}
type request struct {
Value map[string]string `json:"value"`
}
decoder := json.NewDecoder(r.Body)
var values request
if err := decoder.Decode(&values); err != nil {
controllers.WriteSimpleResponse(w, false, "unable to update appearance variable values")
return
}
if err := data.SetCustomColorVariableValues(values.Value); err != nil {
controllers.WriteSimpleResponse(w, false, err.Error())
return
}
controllers.WriteSimpleResponse(w, true, "custom appearance variables updated")
}

View file

@ -1,7 +1,6 @@
package admin
import (
"encoding/base64"
"encoding/json"
"fmt"
"net"
@ -141,6 +140,25 @@ func SetServerSummary(w http.ResponseWriter, r *http.Request) {
controllers.WriteSimpleResponse(w, true, "changed")
}
// SetCustomOfflineMessage will set a message to display when the server is offline.
func SetCustomOfflineMessage(w http.ResponseWriter, r *http.Request) {
if !requirePOST(w, r) {
return
}
configValue, success := getValueFromRequest(w, r)
if !success {
return
}
if err := data.SetCustomOfflineMessage(strings.TrimSpace(configValue.Value.(string))); err != nil {
controllers.WriteSimpleResponse(w, false, err.Error())
return
}
controllers.WriteSimpleResponse(w, true, "changed")
}
// SetServerWelcomeMessage will handle the web config request to set the welcome message text.
func SetServerWelcomeMessage(w http.ResponseWriter, r *http.Request) {
if !requirePOST(w, r) {
@ -179,8 +197,8 @@ func SetExtraPageContent(w http.ResponseWriter, r *http.Request) {
controllers.WriteSimpleResponse(w, true, "changed")
}
// SetStreamKey will handle the web config request to set the server stream key.
func SetStreamKey(w http.ResponseWriter, r *http.Request) {
// SetAdminPassword will handle the web config request to set the server admin password.
func SetAdminPassword(w http.ResponseWriter, r *http.Request) {
if !requirePOST(w, r) {
return
}
@ -190,7 +208,7 @@ func SetStreamKey(w http.ResponseWriter, r *http.Request) {
return
}
if err := data.SetStreamKey(configValue.Value.(string)); err != nil {
if err := data.SetAdminPassword(configValue.Value.(string)); err != nil {
controllers.WriteSimpleResponse(w, false, err.Error())
return
}
@ -209,39 +227,17 @@ func SetLogo(w http.ResponseWriter, r *http.Request) {
return
}
s := strings.SplitN(configValue.Value.(string), ",", 2)
if len(s) < 2 {
controllers.WriteSimpleResponse(w, false, "Error splitting base64 image data.")
value, ok := configValue.Value.(string)
if !ok {
controllers.WriteSimpleResponse(w, false, "unable to find image data")
return
}
bytes, err := base64.StdEncoding.DecodeString(s[1])
bytes, extension, err := utils.DecodeBase64Image(value)
if err != nil {
controllers.WriteSimpleResponse(w, false, err.Error())
return
}
splitHeader := strings.Split(s[0], ":")
if len(splitHeader) < 2 {
controllers.WriteSimpleResponse(w, false, "Error splitting base64 image header.")
return
}
contentType := strings.Split(splitHeader[1], ";")[0]
extension := ""
if contentType == "image/svg+xml" {
extension = ".svg"
} else if contentType == "image/gif" {
extension = ".gif"
} else if contentType == "image/png" {
extension = ".png"
} else if contentType == "image/jpeg" {
extension = ".jpeg"
}
if extension == "" {
controllers.WriteSimpleResponse(w, false, "Missing or invalid contentType in base64 image.")
return
}
imgPath := filepath.Join("data", "logo"+extension)
if err := os.WriteFile(imgPath, bytes, 0o600); err != nil {
controllers.WriteSimpleResponse(w, false, err.Error())
@ -398,6 +394,12 @@ func SetServerURL(w http.ResponseWriter, r *http.Request) {
rawValue, ok := configValue.Value.(string)
if !ok {
controllers.WriteSimpleResponse(w, false, "could not read server url")
return
}
serverHostString := utils.GetHostnameFromURLString(rawValue)
if serverHostString == "" {
controllers.WriteSimpleResponse(w, false, "server url value invalid")
return
}
@ -648,6 +650,22 @@ func SetCustomStyles(w http.ResponseWriter, r *http.Request) {
controllers.WriteSimpleResponse(w, true, "custom styles updated")
}
// SetCustomJavascript will set the Javascript string we insert into the page.
func SetCustomJavascript(w http.ResponseWriter, r *http.Request) {
customJavascript, success := getValueFromRequest(w, r)
if !success {
controllers.WriteSimpleResponse(w, false, "unable to update custom javascript")
return
}
if err := data.SetCustomJavascript(customJavascript.Value.(string)); err != nil {
controllers.WriteSimpleResponse(w, false, err.Error())
return
}
controllers.WriteSimpleResponse(w, true, "custom styles updated")
}
// SetForbiddenUsernameList will set the list of usernames we do not allow to use.
func SetForbiddenUsernameList(w http.ResponseWriter, r *http.Request) {
type forbiddenUsernameListRequest struct {
@ -711,6 +729,26 @@ func SetChatJoinMessagesEnabled(w http.ResponseWriter, r *http.Request) {
controllers.WriteSimpleResponse(w, true, "chat join message status updated")
}
// SetHideViewerCount will enable or disable hiding the viewer count.
func SetHideViewerCount(w http.ResponseWriter, r *http.Request) {
if !requirePOST(w, r) {
return
}
configValue, success := getValueFromRequest(w, r)
if !success {
controllers.WriteSimpleResponse(w, false, "unable to update hiding viewer count")
return
}
if err := data.SetHideViewerCount(configValue.Value.(bool)); err != nil {
controllers.WriteSimpleResponse(w, false, err.Error())
return
}
controllers.WriteSimpleResponse(w, true, "hide viewer count setting updated")
}
func requirePOST(w http.ResponseWriter, r *http.Request) bool {
if r.Method != controllers.POST {
controllers.WriteSimpleResponse(w, false, r.Method+" not supported")
@ -750,3 +788,28 @@ func getValuesFromRequest(w http.ResponseWriter, r *http.Request) ([]ConfigValue
return values, true
}
// SetStreamKeys will set the valid stream keys.
func SetStreamKeys(w http.ResponseWriter, r *http.Request) {
if !requirePOST(w, r) {
return
}
type streamKeysRequest struct {
Value []models.StreamKey `json:"value"`
}
decoder := json.NewDecoder(r.Body)
var streamKeys streamKeysRequest
if err := decoder.Decode(&streamKeys); err != nil {
controllers.WriteSimpleResponse(w, false, "unable to update stream keys with provided values")
return
}
if err := data.SetStreamKeys(streamKeys.Value); err != nil {
controllers.WriteSimpleResponse(w, false, err.Error())
return
}
controllers.WriteSimpleResponse(w, true, "changed")
}

View file

@ -0,0 +1,92 @@
package admin
import (
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"github.com/owncast/owncast/config"
"github.com/owncast/owncast/controllers"
"github.com/owncast/owncast/utils"
)
// UploadCustomEmoji allows POSTing a new custom emoji to the server.
func UploadCustomEmoji(w http.ResponseWriter, r *http.Request) {
if !requirePOST(w, r) {
return
}
type postEmoji struct {
Name string `json:"name"`
Data string `json:"data"`
}
emoji := new(postEmoji)
if err := json.NewDecoder(r.Body).Decode(emoji); err != nil {
controllers.WriteSimpleResponse(w, false, err.Error())
return
}
bytes, _, err := utils.DecodeBase64Image(emoji.Data)
if err != nil {
controllers.WriteSimpleResponse(w, false, err.Error())
return
}
// Prevent path traversal attacks
emojiFileName := filepath.Base(emoji.Name)
targetPath := filepath.Join(config.CustomEmojiPath, emojiFileName)
err = os.MkdirAll(config.CustomEmojiPath, 0o700)
if err != nil {
controllers.WriteSimpleResponse(w, false, err.Error())
return
}
if utils.DoesFileExists(targetPath) {
controllers.WriteSimpleResponse(w, false, fmt.Sprintf("An emoji with the name %q already exists", emojiFileName))
return
}
if err = os.WriteFile(targetPath, bytes, 0o600); err != nil {
controllers.WriteSimpleResponse(w, false, err.Error())
return
}
controllers.WriteSimpleResponse(w, true, fmt.Sprintf("Emoji %q has been uploaded", emojiFileName))
}
// DeleteCustomEmoji deletes a custom emoji.
func DeleteCustomEmoji(w http.ResponseWriter, r *http.Request) {
if !requirePOST(w, r) {
return
}
type deleteEmoji struct {
Name string `json:"name"`
}
emoji := new(deleteEmoji)
if err := json.NewDecoder(r.Body).Decode(emoji); err != nil {
controllers.WriteSimpleResponse(w, false, err.Error())
return
}
// var emojiFileName = filepath.Base(emoji.Name)
targetPath := filepath.Join(config.CustomEmojiPath, emoji.Name)
if err := os.Remove(targetPath); err != nil {
if os.IsNotExist(err) {
controllers.WriteSimpleResponse(w, false, fmt.Sprintf("Emoji %q doesn't exist", emoji.Name))
} else {
controllers.WriteSimpleResponse(w, false, err.Error())
}
return
}
controllers.WriteSimpleResponse(w, true, fmt.Sprintf("Emoji %q has been deleted", emoji.Name))
}

View file

@ -6,6 +6,7 @@ import (
"net/http"
"time"
"github.com/owncast/owncast/config"
"github.com/owncast/owncast/controllers"
"github.com/owncast/owncast/core/user"
"github.com/owncast/owncast/utils"
@ -41,7 +42,7 @@ func CreateExternalAPIUser(w http.ResponseWriter, r *http.Request) {
return
}
color := utils.GenerateRandomDisplayColor()
color := utils.GenerateRandomDisplayColor(config.MaxUserColor)
if err := user.InsertExternalAPIUser(token, request.Name, color, request.Scopes); err != nil {
controllers.InternalErrorHandler(w, err)

View file

@ -1,57 +0,0 @@
package admin
import (
"bytes"
"io/fs"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/owncast/owncast/router/middleware"
"github.com/owncast/owncast/static"
log "github.com/sirupsen/logrus"
)
// ServeAdmin will return admin web assets.
func ServeAdmin(w http.ResponseWriter, r *http.Request) {
// If the ETags match then return a StatusNotModified
if responseCode := middleware.ProcessEtags(w, r); responseCode != 0 {
w.WriteHeader(responseCode)
return
}
adminFiles := static.GetAdmin()
path := strings.TrimPrefix(r.URL.Path, "/")
// Determine if the requested path is a directory.
// If so, append index.html to the request.
if info, err := fs.Stat(adminFiles, path); err == nil && info.IsDir() {
path = filepath.Join(path, "index.html")
} else if _, err := fs.Stat(adminFiles, path+"index.html"); err == nil {
path = filepath.Join(path, "index.html")
}
f, err := adminFiles.Open(path)
if os.IsNotExist(err) {
w.WriteHeader(http.StatusNotFound)
return
}
info, err := f.Stat()
if os.IsNotExist(err) {
w.WriteHeader(http.StatusNotFound)
return
}
// Set a cache control max-age header
middleware.SetCachingHeaders(w, r)
d, err := adminFiles.ReadFile(path)
if err != nil {
log.Errorln(err)
w.WriteHeader(http.StatusInternalServerError)
return
}
http.ServeContent(w, r, info.Name(), info.ModTime(), bytes.NewReader(d))
}

View file

@ -58,28 +58,3 @@ func SetBrowserNotificationConfiguration(w http.ResponseWriter, r *http.Request)
controllers.WriteSimpleResponse(w, true, "updated browser push config with provided values")
}
// SetTwitterConfiguration will set the browser notification configuration.
func SetTwitterConfiguration(w http.ResponseWriter, r *http.Request) {
if !requirePOST(w, r) {
return
}
type request struct {
Value models.TwitterConfiguration `json:"value"`
}
decoder := json.NewDecoder(r.Body)
var config request
if err := decoder.Decode(&config); err != nil {
controllers.WriteSimpleResponse(w, false, "unable to update twitter config with provided values")
return
}
if err := data.SetTwitterConfiguration(config.Value); err != nil {
controllers.WriteSimpleResponse(w, false, "unable to update twitter config with provided values")
return
}
controllers.WriteSimpleResponse(w, true, "updated twitter config with provided values")
}

View file

@ -35,19 +35,23 @@ func GetServerConfig(w http.ResponseWriter, r *http.Request) {
}
response := serverConfigAdminResponse{
InstanceDetails: webConfigResponse{
Name: data.GetServerName(),
Summary: data.GetServerSummary(),
Tags: data.GetServerMetadataTags(),
ExtraPageContent: data.GetExtraPageBodyContent(),
StreamTitle: data.GetStreamTitle(),
WelcomeMessage: data.GetServerWelcomeMessage(),
Logo: data.GetLogoPath(),
SocialHandles: data.GetSocialHandles(),
NSFW: data.GetNSFW(),
CustomStyles: data.GetCustomStyles(),
Name: data.GetServerName(),
Summary: data.GetServerSummary(),
Tags: data.GetServerMetadataTags(),
ExtraPageContent: data.GetExtraPageBodyContent(),
StreamTitle: data.GetStreamTitle(),
WelcomeMessage: data.GetServerWelcomeMessage(),
OfflineMessage: data.GetCustomOfflineMessage(),
Logo: data.GetLogoPath(),
SocialHandles: data.GetSocialHandles(),
NSFW: data.GetNSFW(),
CustomStyles: data.GetCustomStyles(),
CustomJavascript: data.GetCustomJavascript(),
AppearanceVariables: data.GetCustomColorVariableValues(),
},
FFmpegPath: ffmpeg,
StreamKey: data.GetStreamKey(),
AdminPassword: data.GetAdminPassword(),
StreamKeys: data.GetStreamKeys(),
WebServerPort: config.WebServerPort,
WebServerIP: config.WebServerIP,
RTMPServerPort: data.GetRTMPPortNumber(),
@ -55,6 +59,7 @@ func GetServerConfig(w http.ResponseWriter, r *http.Request) {
ChatJoinMessagesEnabled: data.GetChatJoinMessagesEnabled(),
SocketHostOverride: data.GetWebsocketOverrideHost(),
ChatEstablishedUserMode: data.GetChatEstbalishedUsersOnlyMode(),
HideViewerCount: data.GetHideViewerCount(),
VideoSettings: videoSettings{
VideoQualityVariants: videoQualityVariants,
LatencyLevel: data.GetStreamLatencyLevel().Level,
@ -80,7 +85,6 @@ func GetServerConfig(w http.ResponseWriter, r *http.Request) {
Notifications: notificationsConfigResponse{
Discord: data.GetDiscordConfig(),
Browser: data.GetBrowserPushConfig(),
Twitter: data.GetTwitterConfiguration(),
},
}
@ -95,7 +99,8 @@ func GetServerConfig(w http.ResponseWriter, r *http.Request) {
type serverConfigAdminResponse struct {
InstanceDetails webConfigResponse `json:"instanceDetails"`
FFmpegPath string `json:"ffmpegPath"`
StreamKey string `json:"streamKey"`
AdminPassword string `json:"adminPassword"`
StreamKeys []models.StreamKey `json:"streamKeys"`
WebServerPort int `json:"webServerPort"`
WebServerIP string `json:"webServerIP"`
RTMPServerPort int `json:"rtmpServerPort"`
@ -113,6 +118,7 @@ type serverConfigAdminResponse struct {
SuggestedUsernames []string `json:"suggestedUsernames"`
SocketHostOverride string `json:"socketHostOverride,omitempty"`
Notifications notificationsConfigResponse `json:"notifications"`
HideViewerCount bool `json:"hideViewerCount"`
}
type videoSettings struct {
@ -121,17 +127,20 @@ type videoSettings struct {
}
type webConfigResponse struct {
Name string `json:"name"`
Summary string `json:"summary"`
WelcomeMessage string `json:"welcomeMessage"`
Logo string `json:"logo"`
Tags []string `json:"tags"`
Version string `json:"version"`
NSFW bool `json:"nsfw"`
ExtraPageContent string `json:"extraPageContent"`
StreamTitle string `json:"streamTitle"` // What's going on with the current stream
SocialHandles []models.SocialHandle `json:"socialHandles"`
CustomStyles string `json:"customStyles"`
Name string `json:"name"`
Summary string `json:"summary"`
WelcomeMessage string `json:"welcomeMessage"`
OfflineMessage string `json:"offlineMessage"`
Logo string `json:"logo"`
Tags []string `json:"tags"`
Version string `json:"version"`
NSFW bool `json:"nsfw"`
ExtraPageContent string `json:"extraPageContent"`
StreamTitle string `json:"streamTitle"` // What's going on with the current stream
SocialHandles []models.SocialHandle `json:"socialHandles"`
CustomStyles string `json:"customStyles"`
CustomJavascript string `json:"customJavascript"`
AppearanceVariables map[string]string `json:"appearanceVariables"`
}
type yp struct {
@ -152,5 +161,4 @@ type federationConfigResponse struct {
type notificationsConfigResponse struct {
Browser models.BrowserNotificationConfiguration `json:"browser"`
Discord models.DiscordConfiguration `json:"discord"`
Twitter models.TwitterConfiguration `json:"twitter"`
}

View file

@ -28,7 +28,12 @@ func RegisterFediverseOTPRequest(u user.User, w http.ResponseWriter, r *http.Req
}
accessToken := r.URL.Query().Get("accessToken")
reg, success := fediverseauth.RegisterFediverseOTP(accessToken, u.ID, u.DisplayName, req.FediverseAccount)
reg, success, err := fediverseauth.RegisterFediverseOTP(accessToken, u.ID, u.DisplayName, req.FediverseAccount)
if err != nil {
controllers.WriteSimpleResponse(w, false, "Could not register auth request: "+err.Error())
return
}
if !success {
controllers.WriteSimpleResponse(w, false, "Could not register auth request. One may already be pending. Try again later.")
return
@ -74,9 +79,11 @@ func VerifyFediverseOTPRequest(w http.ResponseWriter, r *http.Request) {
return
}
loginMessage := fmt.Sprintf("**%s** is now authenticated as **%s**", authRegistration.UserDisplayName, u.DisplayName)
if err := chat.SendSystemAction(loginMessage, true); err != nil {
log.Errorln(err)
if authRegistration.UserDisplayName != u.DisplayName {
loginMessage := fmt.Sprintf("**%s** is now authenticated as **%s**", authRegistration.UserDisplayName, u.DisplayName)
if err := chat.SendSystemAction(loginMessage, true); err != nil {
log.Errorln(err)
}
}
controllers.WriteSimpleResponse(w, true, "")

View file

@ -58,7 +58,7 @@ func HandleRedirect(w http.ResponseWriter, r *http.Request) {
request, response, err := ia.HandleCallbackCode(code, state)
if err != nil {
log.Debugln(err)
msg := fmt.Sprintf("Unable to complete authentication. <a href=\"/\">Go back.</a><hr/> %s", err.Error())
msg := `Unable to complete authentication. <a href="/">Go back.</a><hr/>`
_ = controllers.WriteString(w, msg, http.StatusBadRequest)
return
}
@ -76,9 +76,11 @@ func HandleRedirect(w http.ResponseWriter, r *http.Request) {
return
}
loginMessage := fmt.Sprintf("**%s** is now authenticated as **%s**", request.DisplayName, u.DisplayName)
if err := chat.SendSystemAction(loginMessage, true); err != nil {
log.Errorln(err)
if request.DisplayName != u.DisplayName {
loginMessage := fmt.Sprintf("**%s** is now authenticated as **%s**", request.DisplayName, u.DisplayName)
if err := chat.SendSystemAction(loginMessage, true); err != nil {
log.Errorln(err)
}
}
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)

View file

@ -33,7 +33,7 @@ func handleAuthEndpointGet(w http.ResponseWriter, r *http.Request) {
request, err := ia.StartServerAuth(clientID, redirectURI, codeChallenge, state, me)
if err != nil {
// Return a human readable, HTML page as an error. JSON is no use here.
_ = controllers.WriteString(w, err.Error(), http.StatusInternalServerError)
return
}

View file

@ -4,9 +4,11 @@ import (
"encoding/json"
"net/http"
"github.com/owncast/owncast/config"
"github.com/owncast/owncast/core/chat"
"github.com/owncast/owncast/core/user"
"github.com/owncast/owncast/router/middleware"
"github.com/owncast/owncast/utils"
log "github.com/sirupsen/logrus"
)
@ -18,6 +20,7 @@ func ExternalGetChatMessages(integration user.ExternalAPIUser, w http.ResponseWr
// GetChatMessages gets all of the chat messages.
func GetChatMessages(u user.User, w http.ResponseWriter, r *http.Request) {
middleware.EnableCors(w)
getChatMessages(w, r)
}
@ -41,7 +44,16 @@ func getChatMessages(w http.ResponseWriter, r *http.Request) {
// RegisterAnonymousChatUser will register a new user.
func RegisterAnonymousChatUser(w http.ResponseWriter, r *http.Request) {
if r.Method != POST {
middleware.EnableCors(w)
if r.Method == "OPTIONS" {
// All OPTIONS requests should have a wildcard CORS header.
w.Header().Set("Access-Control-Allow-Origin", "*")
w.WriteHeader(http.StatusNoContent)
return
}
if r.Method != http.MethodPost {
WriteSimpleResponse(w, false, r.Method+" not supported")
return
}
@ -66,7 +78,8 @@ func RegisterAnonymousChatUser(w http.ResponseWriter, r *http.Request) {
request.DisplayName = r.Header.Get("X-Forwarded-User")
}
newUser, accessToken, err := user.CreateAnonymousUser(request.DisplayName)
proposedNewDisplayName := utils.MakeSafeStringOfLength(request.DisplayName, config.MaxChatDisplayNameLength)
newUser, accessToken, err := user.CreateAnonymousUser(proposedNewDisplayName)
if err != nil {
WriteSimpleResponse(w, false, err.Error())
return

View file

@ -18,6 +18,7 @@ import (
type webConfigResponse struct {
Name string `json:"name"`
Summary string `json:"summary"`
OfflineMessage string `json:"offlineMessage"`
Logo string `json:"logo"`
Tags []string `json:"tags"`
Version string `json:"version"`
@ -29,6 +30,7 @@ type webConfigResponse struct {
ChatDisabled bool `json:"chatDisabled"`
ExternalActions []models.ExternalAction `json:"externalActions"`
CustomStyles string `json:"customStyles"`
AppearanceVariables map[string]string `json:"appearanceVariables"`
MaxSocketPayloadSize int `json:"maxSocketPayloadSize"`
Federation federationConfigResponse `json:"federation"`
Notifications notificationsConfigResponse `json:"notifications"`
@ -60,6 +62,14 @@ func GetWebConfig(w http.ResponseWriter, r *http.Request) {
middleware.DisableCache(w)
w.Header().Set("Content-Type", "application/json")
configuration := getConfigResponse()
if err := json.NewEncoder(w).Encode(configuration); err != nil {
BadRequestHandler(w, err)
}
}
func getConfigResponse() webConfigResponse {
pageContent := utils.RenderPageContentMarkdown(data.GetExtraPageBodyContent())
socialHandles := data.GetSocialHandles()
for i, handle := range socialHandles {
@ -71,7 +81,6 @@ func GetWebConfig(w http.ResponseWriter, r *http.Request) {
}
serverSummary := data.GetServerSummary()
serverSummary = utils.RenderPageContentMarkdown(serverSummary)
var federationResponse federationConfigResponse
federationEnabled := data.GetFederationEnabled()
@ -106,9 +115,10 @@ func GetWebConfig(w http.ResponseWriter, r *http.Request) {
IndieAuthEnabled: data.GetServerURL() != "",
}
configuration := webConfigResponse{
return webConfigResponse{
Name: data.GetServerName(),
Summary: serverSummary,
OfflineMessage: data.GetCustomOfflineMessage(),
Logo: "/logo",
Tags: data.GetServerMetadataTags(),
Version: config.GetReleaseString(),
@ -124,10 +134,7 @@ func GetWebConfig(w http.ResponseWriter, r *http.Request) {
Federation: federationResponse,
Notifications: notificationsResponse,
Authentication: authenticationResponse,
}
if err := json.NewEncoder(w).Encode(configuration); err != nil {
BadRequestHandler(w, err)
AppearanceVariables: data.GetCustomColorVariableValues(),
}
}

View file

@ -0,0 +1,13 @@
package controllers
import (
"net/http"
"github.com/owncast/owncast/core/data"
)
// ServeCustomJavascript will serve optional custom Javascript.
func ServeCustomJavascript(w http.ResponseWriter, r *http.Request) {
js := data.GetCustomJavascript()
_, _ = w.Write([]byte(js))
}

View file

@ -1,31 +0,0 @@
package controllers
import (
"net/http"
"github.com/owncast/owncast/router/middleware"
)
// GetChatEmbedreadwrite gets the embed for readwrite chat.
func GetChatEmbedreadwrite(w http.ResponseWriter, r *http.Request) {
// Set our global HTTP headers
middleware.SetHeaders(w)
http.ServeFile(w, r, "webroot/index-standalone-chat-readwrite.html")
}
// GetChatEmbedreadonly gets the embed for readonly chat.
func GetChatEmbedreadonly(w http.ResponseWriter, r *http.Request) {
// Set our global HTTP headers
middleware.SetHeaders(w)
http.ServeFile(w, r, "webroot/index-standalone-chat-readonly.html")
}
// GetVideoEmbed gets the embed for video.
func GetVideoEmbed(w http.ResponseWriter, r *http.Request) {
// Set our global HTTP headers
middleware.SetHeaders(w)
http.ServeFile(w, r, "webroot/index-video-only.html")
}

View file

@ -4,55 +4,26 @@ import (
"encoding/json"
"net/http"
"os"
"path"
"path/filepath"
"strings"
"time"
"github.com/owncast/owncast/config"
"github.com/owncast/owncast/models"
log "github.com/sirupsen/logrus"
"github.com/owncast/owncast/core/data"
)
var emojiCache = make([]models.CustomEmoji, 0)
var emojiCacheTimestamp time.Time
// getCustomEmojiList returns a list of custom emoji either from the cache or from the emoji directory.
func getCustomEmojiList() []models.CustomEmoji {
fullPath := filepath.Join(config.WebRoot, config.EmojiDir)
emojiDirInfo, err := os.Stat(fullPath)
if err != nil {
log.Errorln(err)
}
if emojiDirInfo.ModTime() != emojiCacheTimestamp {
log.Traceln("Emoji cache invalid")
emojiCache = make([]models.CustomEmoji, 0)
}
if len(emojiCache) == 0 {
files, err := os.ReadDir(fullPath)
if err != nil {
log.Errorln(err)
return emojiCache
}
for _, f := range files {
name := strings.TrimSuffix(f.Name(), path.Ext(f.Name()))
emojiPath := filepath.Join(config.EmojiDir, f.Name())
singleEmoji := models.CustomEmoji{Name: name, Emoji: emojiPath}
emojiCache = append(emojiCache, singleEmoji)
}
emojiCacheTimestamp = emojiDirInfo.ModTime()
}
return emojiCache
}
// GetCustomEmoji returns a list of custom emoji via the API.
func GetCustomEmoji(w http.ResponseWriter, r *http.Request) {
emojiList := getCustomEmojiList()
// GetCustomEmojiList returns a list of emoji via the API.
func GetCustomEmojiList(w http.ResponseWriter, r *http.Request) {
emojiList := data.GetEmojiList()
if err := json.NewEncoder(w).Encode(emojiList); err != nil {
InternalErrorHandler(w, err)
}
}
// GetCustomEmojiImage returns a single emoji image.
func GetCustomEmojiImage(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/img/emoji/")
r.URL.Path = path
emojiFS := os.DirFS(config.CustomEmojiPath)
http.FileServer(http.FS(emojiFS)).ServeHTTP(w, r)
}

62
controllers/images.go Normal file
View file

@ -0,0 +1,62 @@
package controllers
import (
"net/http"
"path/filepath"
"github.com/owncast/owncast/config"
"github.com/owncast/owncast/utils"
)
const (
contentTypeJPEG = "image/jpeg"
contentTypeGIF = "image/gif"
)
// GetThumbnail will return the thumbnail image as a response.
func GetThumbnail(w http.ResponseWriter, r *http.Request) {
imageFilename := "thumbnail.jpg"
imagePath := filepath.Join(config.TempDir, imageFilename)
var imageBytes []byte
var err error
if utils.DoesFileExists(imagePath) {
imageBytes, err = getImage(imagePath)
} else {
GetLogo(w, r)
return
}
if err != nil {
GetLogo(w, r)
return
}
cacheTime := utils.GetCacheDurationSecondsForPath(imagePath)
writeBytesAsImage(imageBytes, contentTypeJPEG, w, cacheTime)
}
// GetPreview will return the preview gif as a response.
func GetPreview(w http.ResponseWriter, r *http.Request) {
imageFilename := "preview.gif"
imagePath := filepath.Join(config.TempDir, imageFilename)
var imageBytes []byte
var err error
if utils.DoesFileExists(imagePath) {
imageBytes, err = getImage(imagePath)
} else {
GetLogo(w, r)
return
}
if err != nil {
GetLogo(w, r)
return
}
cacheTime := utils.GetCacheDurationSecondsForPath(imagePath)
writeBytesAsImage(imageBytes, contentTypeGIF, w, cacheTime)
}

View file

@ -1,142 +1,93 @@
package controllers
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"strings"
log "github.com/sirupsen/logrus"
"github.com/owncast/owncast/config"
"github.com/owncast/owncast/core"
"github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/models"
"github.com/owncast/owncast/router/middleware"
"github.com/owncast/owncast/static"
"github.com/owncast/owncast/utils"
)
// MetadataPage represents a server-rendered web page for bots and web scrapers.
type MetadataPage struct {
RequestedURL string
Image string
Thumbnail string
TagsString string
Summary string
Name string
Tags []string
SocialHandles []models.SocialHandle
}
// IndexHandler handles the default index route.
func IndexHandler(w http.ResponseWriter, r *http.Request) {
middleware.EnableCors(w)
// Treat recordings and schedule as index requests
pathComponents := strings.Split(r.URL.Path, "/")
pathRequest := pathComponents[1]
if pathRequest == "recordings" || pathRequest == "schedule" {
r.URL.Path = "index.html"
}
isIndexRequest := r.URL.Path == "/" || filepath.Base(r.URL.Path) == "index.html" || filepath.Base(r.URL.Path) == ""
// For search engine bots and social scrapers return a special
// server-rendered page.
if utils.IsUserAgentABot(r.UserAgent()) && isIndexRequest {
handleScraperMetadataPage(w, r)
return
}
if utils.IsUserAgentAPlayer(r.UserAgent()) && isIndexRequest {
http.Redirect(w, r, "/hls/stream.m3u8", http.StatusTemporaryRedirect)
return
}
// If the ETags match then return a StatusNotModified
if responseCode := middleware.ProcessEtags(w, r); responseCode != 0 {
w.WriteHeader(responseCode)
return
}
// If this is a directory listing request then return a 404
info, err := os.Stat(path.Join(config.WebRoot, r.URL.Path))
if err != nil || (info.IsDir() && !isIndexRequest) {
w.WriteHeader(http.StatusNotFound)
return
}
// Set a cache control max-age header
middleware.SetCachingHeaders(w, r)
nonceRandom, _ := utils.GenerateRandomString(5)
// Set our global HTTP headers
middleware.SetHeaders(w)
middleware.SetHeaders(w, fmt.Sprintf("nonce-%s", nonceRandom))
http.ServeFile(w, r, path.Join(config.WebRoot, r.URL.Path))
}
// 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) {
tmpl, err := static.GetBotMetadataTemplate()
if err != nil {
log.Errorln(err)
w.WriteHeader(http.StatusInternalServerError)
if isIndexRequest {
renderIndexHtml(w, nonceRandom)
return
}
scheme := "http"
serveWeb(w, r)
}
if siteURL := data.GetServerURL(); siteURL != "" {
if parsed, err := url.Parse(siteURL); err == nil && parsed.Scheme != "" {
scheme = parsed.Scheme
}
func renderIndexHtml(w http.ResponseWriter, nonce string) {
type serverSideContent struct {
Name string
Summary string
RequestedURL string
TagsString string
ThumbnailURL string
Thumbnail string
Image string
StatusJSON string
ServerConfigJSON string
Nonce string
}
fullURL, err := url.Parse(fmt.Sprintf("%s://%s%s", scheme, r.Host, r.URL.Path))
status := getStatusResponse()
sb, err := json.Marshal(status)
if err != nil {
log.Errorln(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
imageURL, err := url.Parse(fmt.Sprintf("%s://%s%s", scheme, r.Host, "/logo/external"))
config := getConfigResponse()
cb, err := json.Marshal(config)
if err != nil {
log.Errorln(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
status := core.GetStatus()
// If the thumbnail does not exist or we're offline then just use the logo image
var thumbnailURL string
if status.Online && utils.DoesFileExists(filepath.Join(config.WebRoot, "thumbnail.jpg")) {
thumbnail, err := url.Parse(fmt.Sprintf("%s://%s%s", scheme, r.Host, "/thumbnail.jpg"))
if err != nil {
log.Errorln(err)
thumbnailURL = imageURL.String()
} else {
thumbnailURL = thumbnail.String()
}
} else {
thumbnailURL = imageURL.String()
content := serverSideContent{
Name: data.GetServerName(),
Summary: data.GetServerSummary(),
RequestedURL: data.GetServerURL(),
TagsString: strings.Join(data.GetServerMetadataTags(), ","),
ThumbnailURL: "/thumbnail",
Thumbnail: "/thumbnail",
Image: "/logo/external",
StatusJSON: string(sb),
ServerConfigJSON: string(cb),
Nonce: nonce,
}
tagsString := strings.Join(data.GetServerMetadataTags(), ",")
metadata := MetadataPage{
Name: data.GetServerName(),
RequestedURL: fullURL.String(),
Image: imageURL.String(),
Summary: data.GetServerSummary(),
Thumbnail: thumbnailURL,
TagsString: tagsString,
Tags: data.GetServerMetadataTags(),
SocialHandles: data.GetSocialHandles(),
index, err := static.GetWebIndexTemplate()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html")
if err := tmpl.Execute(w, metadata); err != nil {
log.Errorln(err)
if err := index.Execute(w, content); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}

View file

@ -8,6 +8,7 @@ import (
"github.com/owncast/owncast/config"
"github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/static"
"github.com/owncast/owncast/utils"
log "github.com/sirupsen/logrus"
)
@ -21,7 +22,7 @@ func GetLogo(w http.ResponseWriter, r *http.Request) {
returnDefault(w)
return
}
imagePath := filepath.Join("data", imageFilename)
imagePath := filepath.Join(config.DataDirectory, imageFilename)
imageBytes, err := getImage(imagePath)
if err != nil {
returnDefault(w)
@ -56,7 +57,7 @@ func GetCompatibleLogo(w http.ResponseWriter, r *http.Request) {
}
// Otherwise use a fallback logo.png.
imagePath := filepath.Join(config.WebRoot, "img", "logo.png")
imagePath := filepath.Join(config.DataDirectory, "logo.png")
contentType := "image/png"
imageBytes, err := getImage(imagePath)
if err != nil {
@ -74,14 +75,9 @@ func GetCompatibleLogo(w http.ResponseWriter, r *http.Request) {
}
func returnDefault(w http.ResponseWriter) {
imagePath := filepath.Join(config.WebRoot, "img", "logo.svg")
imageBytes, err := getImage(imagePath)
if err != nil {
log.Errorln(err)
return
}
cacheTime := utils.GetCacheDurationSecondsForPath(imagePath)
writeBytesAsImage(imageBytes, "image/svg+xml", w, cacheTime)
imageBytes := static.GetLogo()
cacheTime := utils.GetCacheDurationSecondsForPath("logo.png")
writeBytesAsImage(imageBytes, "image/png", w, cacheTime)
}
func writeBytesAsImage(data []byte, contentType string, w http.ResponseWriter, cacheSeconds int) {

View file

@ -0,0 +1,73 @@
package moderation
import (
"encoding/json"
"net/http"
"strings"
"time"
"github.com/owncast/owncast/controllers"
"github.com/owncast/owncast/core/chat"
"github.com/owncast/owncast/core/chat/events"
"github.com/owncast/owncast/core/user"
log "github.com/sirupsen/logrus"
)
// GetUserDetails returns the details of a chat user for moderators.
func GetUserDetails(w http.ResponseWriter, r *http.Request) {
type connectedClient struct {
Id uint `json:"id"`
MessageCount int `json:"messageCount"`
UserAgent string `json:"userAgent"`
ConnectedAt time.Time `json:"connectedAt"`
Geo string `json:"geo,omitempty"`
}
type response struct {
User *user.User `json:"user"`
ConnectedClients []connectedClient `json:"connectedClients"`
Messages []events.UserMessageEvent `json:"messages"`
}
pathComponents := strings.Split(r.URL.Path, "/")
uid := pathComponents[len(pathComponents)-1]
u := user.GetUserByID(uid)
if u == nil {
w.WriteHeader(http.StatusNotFound)
return
}
c, _ := chat.GetClientsForUser(uid)
clients := make([]connectedClient, len(c))
for i, c := range c {
client := connectedClient{
Id: c.Id,
MessageCount: c.MessageCount,
UserAgent: c.UserAgent,
ConnectedAt: c.ConnectedAt,
}
if c.Geo != nil {
client.Geo = c.Geo.CountryCode
}
clients[i] = client
}
messages, err := chat.GetMessagesFromUser(uid)
if err != nil {
log.Errorln(err)
}
res := response{
User: u,
ConnectedClients: clients,
Messages: messages,
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(res); err != nil {
controllers.InternalErrorHandler(w, err)
}
}

View file

@ -6,24 +6,14 @@ import (
"time"
"github.com/owncast/owncast/core"
"github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/router/middleware"
"github.com/owncast/owncast/utils"
)
// GetStatus gets the status of the server.
func GetStatus(w http.ResponseWriter, r *http.Request) {
middleware.EnableCors(w)
status := core.GetStatus()
response := webStatusResponse{
Online: status.Online,
ViewerCount: status.ViewerCount,
ServerTime: time.Now(),
LastConnectTime: status.LastConnectTime,
LastDisconnectTime: status.LastDisconnectTime,
VersionNumber: status.VersionNumber,
StreamTitle: status.StreamTitle,
}
response := getStatusResponse()
w.Header().Set("Content-Type", "application/json")
middleware.DisableCache(w)
@ -33,9 +23,25 @@ func GetStatus(w http.ResponseWriter, r *http.Request) {
}
}
func getStatusResponse() webStatusResponse {
status := core.GetStatus()
response := webStatusResponse{
Online: status.Online,
ServerTime: time.Now(),
LastConnectTime: status.LastConnectTime,
LastDisconnectTime: status.LastDisconnectTime,
VersionNumber: status.VersionNumber,
StreamTitle: status.StreamTitle,
}
if !data.GetHideViewerCount() {
response.ViewerCount = status.ViewerCount
}
return response
}
type webStatusResponse struct {
Online bool `json:"online"`
ViewerCount int `json:"viewerCount"`
ViewerCount int `json:"viewerCount,omitempty"`
ServerTime time.Time `json:"serverTime"`
LastConnectTime *utils.NullTime `json:"lastConnectTime"`
LastDisconnectTime *utils.NullTime `json:"lastDisconnectTime"`

14
controllers/web.go Normal file
View file

@ -0,0 +1,14 @@
package controllers
import (
"net/http"
"github.com/owncast/owncast/static"
)
var staticServer = http.FileServer(http.FS(static.GetWeb()))
// serveWeb will serve web assets.
func serveWeb(w http.ResponseWriter, r *http.Request) {
staticServer.ServeHTTP(w, r)
}

View file

@ -20,7 +20,7 @@ import (
// Client represents a single chat client.
type Client struct {
mu sync.RWMutex
id uint
Id uint `json:"-"`
accessToken string
conn *websocket.Conn
User *user.User `json:"user"`
@ -123,7 +123,7 @@ func (c *Client) readPump() {
// Guard against floods.
if !c.passesRateLimit() {
log.Warnln("Client", c.id, c.User.DisplayName, "has exceeded the messaging rate limiting thresholds and messages are being rejected temporarily.")
log.Warnln("Client", c.Id, c.User.DisplayName, "has exceeded the messaging rate limiting thresholds and messages are being rejected temporarily.")
c.startChatRejectionTimeout()
continue
@ -186,14 +186,14 @@ func (c *Client) handleEvent(data []byte) {
}
func (c *Client) close() {
log.Traceln("client closed:", c.User.DisplayName, c.id, c.IPAddress)
log.Traceln("client closed:", c.User.DisplayName, c.Id, c.IPAddress)
c.mu.Lock()
defer c.mu.Unlock()
if c.send != nil {
_ = c.conn.Close()
c.server.unregister <- c.id
c.server.unregister <- c.Id
close(c.send)
c.send = nil
}

View file

@ -11,6 +11,7 @@ import (
"github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/core/user"
"github.com/owncast/owncast/core/webhooks"
"github.com/owncast/owncast/utils"
log "github.com/sirupsen/logrus"
)
@ -27,9 +28,7 @@ func (s *Server) userNameChanged(eventData chatClientEvent) {
blocklist := data.GetForbiddenUsernameList()
// Names have a max length
if len(proposedUsername) > config.MaxChatDisplayNameLength {
proposedUsername = proposedUsername[:config.MaxChatDisplayNameLength]
}
proposedUsername = utils.MakeSafeStringOfLength(proposedUsername, config.MaxChatDisplayNameLength)
for _, blockedName := range blocklist {
normalizedName := strings.TrimSpace(blockedName)
@ -90,8 +89,34 @@ func (s *Server) userNameChanged(eventData chatClientEvent) {
// Send chat user name changed webhook
receivedEvent.User = savedUser
receivedEvent.ClientID = eventData.client.id
receivedEvent.ClientID = eventData.client.Id
webhooks.SendChatEventUsernameChanged(receivedEvent)
// Resend the client's user so their username is in sync.
eventData.client.sendConnectedClientInfo()
}
func (s *Server) userColorChanged(eventData chatClientEvent) {
var receivedEvent events.ColorChangeEvent
if err := json.Unmarshal(eventData.data, &receivedEvent); err != nil {
log.Errorln("error unmarshalling to ColorChangeEvent", err)
return
}
// Verify this color is valid
if receivedEvent.NewColor > config.MaxUserColor {
log.Errorln("invalid color requested when changing user display color")
return
}
// Save the new color
if err := user.ChangeUserColor(eventData.client.User.ID, receivedEvent.NewColor); err != nil {
log.Errorln("error changing user display color", err)
}
// Resend client's user info with new color, otherwise the name change dialog would still show the old color
eventData.client.User.DisplayColor = receivedEvent.NewColor
eventData.client.sendConnectedClientInfo()
}
func (s *Server) userMessageSent(eventData chatClientEvent) {
@ -102,7 +127,7 @@ func (s *Server) userMessageSent(eventData chatClientEvent) {
}
event.SetDefaults()
event.ClientID = eventData.client.id
event.ClientID = eventData.client.Id
// Ignore empty messages
if event.Empty() {

View file

@ -10,6 +10,8 @@ const (
UserJoined EventType = "USER_JOINED"
// UserNameChanged is the event sent when a chat username change takes place.
UserNameChanged EventType = "NAME_CHANGE"
// UserColorChanged is the event sent when a chat user color change takes place.
UserColorChanged EventType = "COLOR_CHANGE"
// VisibiltyUpdate is the event sent when a chat message's visibility changes.
VisibiltyUpdate EventType = "VISIBILITY-UPDATE"
// PING is a ping message.

View file

@ -7,6 +7,13 @@ type NameChangeEvent struct {
NewName string `json:"newName"`
}
// ColorChangeEvent is received when a user changes their chat display color.
type ColorChangeEvent struct {
Event
UserEvent
NewColor int `json:"newColor"`
}
// NameChangeBroadcast represents a user changing their chat display name.
type NameChangeBroadcast struct {
Event

View file

@ -1,6 +1,7 @@
package chat
import (
"context"
"database/sql"
"strings"
"time"
@ -295,7 +296,6 @@ func GetChatModerationHistory() []interface{} {
defer rows.Close()
result, err := getChat(rows)
if err != nil {
log.Errorln(err)
log.Errorln("There is a problem enumerating chat message rows. Please report this:", query)
@ -341,7 +341,6 @@ func GetChatHistory() []interface{} {
defer rows.Close()
m, err := getChat(rows)
if err != nil {
log.Errorln(err)
log.Errorln("There is a problem enumerating chat message rows. Please report this:", query)
@ -361,6 +360,29 @@ func GetChatHistory() []interface{} {
return m
}
// GetMessagesFromUser returns chat messages that were sent by a specific user.
func GetMessagesFromUser(userID string) ([]events.UserMessageEvent, error) {
query, err := _datastore.GetQueries().GetMessagesFromUser(context.Background(), sql.NullString{String: userID, Valid: true})
if err != nil {
return nil, err
}
results := make([]events.UserMessageEvent, len(query))
for i, row := range query {
results[i] = events.UserMessageEvent{
Event: events.Event{
Timestamp: row.Timestamp.Time,
ID: row.ID,
},
MessageEvent: events.MessageEvent{
Body: row.Body.String,
},
}
}
return results, nil
}
// SetMessageVisibilityForUserID will bulk change the visibility of messages for a user
// and then send out visibility changed events to chat clients.
func SetMessageVisibilityForUserID(userID string, visible bool) error {
@ -396,7 +418,6 @@ func SetMessageVisibilityForUserID(userID string, visible bool) error {
ids := make([]string, 0)
messages, err := getChat(rows)
if err != nil {
log.Errorln(err)
log.Errorln("There is a problem enumerating chat message rows. Please report this:", query)

View file

@ -99,14 +99,14 @@ func (s *Server) Addclient(conn *websocket.Conn, user *user.User, accessToken st
s.mu.Lock()
{
client.id = s.seq
s.clients[client.id] = client
client.Id = s.seq
s.clients[client.Id] = client
s.seq++
_lastSeenCache[user.ID] = time.Now()
}
s.mu.Unlock()
log.Traceln("Adding client", client.id, "total count:", len(s.clients))
log.Traceln("Adding client", client.Id, "total count:", len(s.clients))
go client.writePump()
go client.readPump()
@ -132,7 +132,7 @@ func (s *Server) sendUserJoinedMessage(c *Client) {
userJoinedEvent := events.UserJoinedEvent{}
userJoinedEvent.SetDefaults()
userJoinedEvent.User = c.User
userJoinedEvent.ClientID = c.id
userJoinedEvent.ClientID = c.Id
if err := s.Broadcast(userJoinedEvent.GetBroadcastPayload()); err != nil {
log.Errorln("error adding client to chat server", err)
@ -148,9 +148,9 @@ func (s *Server) ClientClosed(c *Client) {
defer s.mu.Unlock()
c.close()
if _, ok := s.clients[c.id]; ok {
log.Debugln("Deleting", c.id)
delete(s.clients, c.id)
if _, ok := s.clients[c.Id]; ok {
log.Debugln("Deleting", c.Id)
delete(s.clients, c.Id)
}
}
@ -184,6 +184,11 @@ func (s *Server) HandleClientConnection(w http.ResponseWriter, r *http.Request)
return
}
// To allow dev web environments to connect.
upgrader.CheckOrigin = func(r *http.Request) bool {
return true
}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Debugln(err)
@ -354,6 +359,8 @@ func (s *Server) eventReceived(event chatClientEvent) {
case events.UserNameChanged:
s.userNameChanged(event)
case events.UserColorChanged:
s.userColorChanged(event)
default:
log.Debugln(logSanitize(fmt.Sprint(eventType)), "event not found:", logSanitize(fmt.Sprint(typecheck)))
}

View file

@ -102,7 +102,7 @@ func transitionToOfflineVideoStreamContent() {
_transcoder.SetLatencyLevel(models.GetLatencyLevel(4))
_transcoder.SetIsEvent(true)
offlineFilePath, err := saveOfflineClipToDisk("offline.ts")
offlineFilePath, err := saveOfflineClipToDisk("offline.tsclip")
if err != nil {
log.Fatalln("unable to save offline clip:", err)
}
@ -112,12 +112,13 @@ func transitionToOfflineVideoStreamContent() {
// Copy the logo to be the thumbnail
logo := data.GetLogoPath()
if err = utils.Copy(filepath.Join("data", logo), "webroot/thumbnail.jpg"); err != nil {
dst := filepath.Join(config.TempDir, "thumbnail.jpg")
if err = utils.Copy(filepath.Join("data", logo), dst); err != nil {
log.Warnln(err)
}
// Delete the preview Gif
_ = os.Remove(path.Join(config.WebRoot, "preview.gif"))
_ = os.Remove(path.Join(config.DataDirectory, "preview.gif"))
}
func resetDirectories() {
@ -129,7 +130,7 @@ func resetDirectories() {
// Remove the previous thumbnail
logo := data.GetLogoPath()
if utils.DoesFileExists(logo) {
err := utils.Copy(path.Join("data", logo), filepath.Join(config.WebRoot, "thumbnail.jpg"))
err := utils.Copy(path.Join("data", logo), filepath.Join(config.DataDirectory, "thumbnail.jpg"))
if err != nil {
log.Warnln(err)
}

View file

@ -1,7 +1,7 @@
package data
import (
"errors"
"os"
"path/filepath"
"sort"
"strings"
@ -9,14 +9,16 @@ import (
"github.com/owncast/owncast/config"
"github.com/owncast/owncast/models"
"github.com/owncast/owncast/static"
"github.com/owncast/owncast/utils"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
)
const (
extraContentKey = "extra_page_content"
streamTitleKey = "stream_title"
streamKeyKey = "stream_key"
adminPasswordKey = "admin_password_key"
logoPathKey = "logo_path"
logoUniquenessKey = "logo_uniqueness"
serverSummaryKey = "server_summary"
@ -42,6 +44,7 @@ const (
chatDisabledKey = "chat_disabled"
externalActionsKey = "external_actions"
customStylesKey = "custom_styles"
customJavascriptKey = "custom_javascript"
videoCodecKey = "video_codec"
blockedUsernamesKey = "blocked_usernames"
publicKeyKey = "public_key"
@ -61,8 +64,11 @@ const (
browserPushConfigurationKey = "browser_push_configuration"
browserPushPublicKeyKey = "browser_push_public_key"
browserPushPrivateKeyKey = "browser_push_private_key"
twitterConfigurationKey = "twitter_configuration"
hasConfiguredInitialNotificationsKey = "has_configured_initial_notifications"
hideViewerCountKey = "hide_viewer_count"
customOfflineMessageKey = "custom_offline_message"
customColorVariableValuesKey = "custom_color_variable_values"
streamKeysKey = "stream_keys"
)
// GetExtraPageBodyContent will return the user-supplied body content.
@ -96,20 +102,15 @@ func SetStreamTitle(title string) error {
return _datastore.SetString(streamTitleKey, title)
}
// GetStreamKey will return the inbound streaming password.
func GetStreamKey() string {
key, err := _datastore.GetString(streamKeyKey)
if err != nil {
log.Traceln(streamKeyKey, err)
return config.GetDefaults().StreamKey
}
// GetAdminPassword will return the admin password.
func GetAdminPassword() string {
key, _ := _datastore.GetString(adminPasswordKey)
return key
}
// SetStreamKey will set the inbound streaming password.
func SetStreamKey(key string) error {
return _datastore.SetString(streamKeyKey, key)
// SetAdminPassword will set the admin password.
func SetAdminPassword(key string) error {
return _datastore.SetString(adminPasswordKey, key)
}
// GetLogoPath will return the path for the logo, relative to webroot.
@ -560,6 +561,21 @@ func GetCustomStyles() string {
return style
}
// SetCustomJavascript will save a string with Javascript to insert into the page.
func SetCustomJavascript(styles string) error {
return _datastore.SetString(customJavascriptKey, styles)
}
// GetCustomJavascript will return a string with Javascript to insert into the page.
func GetCustomJavascript() string {
style, err := _datastore.GetString(customJavascriptKey)
if err != nil {
return ""
}
return style
}
// SetVideoCodec will set the codec used for video encoding.
func SetVideoCodec(codec string) error {
return _datastore.SetString(videoCodecKey, codec)
@ -577,19 +593,23 @@ func GetVideoCodec() string {
// VerifySettings will perform a sanity check for specific settings values.
func VerifySettings() error {
if GetStreamKey() == "" {
return errors.New("no stream key set. Please set one via the admin or command line arguments")
if len(GetStreamKeys()) == 0 && config.TemporaryStreamKey == "" {
log.Errorln("No stream key set. Streaming is disabled. Please set one via the admin or command line arguments")
}
if GetAdminPassword() == "" {
return errors.New("no admin password set. Please set one via the admin or command line arguments")
}
logoPath := GetLogoPath()
if !utils.DoesFileExists(filepath.Join(config.DataDirectory, logoPath)) {
defaultLogo := filepath.Join(config.WebRoot, "img/logo.svg")
log.Traceln(logoPath, "not found in the data directory. copying a default logo.")
if err := utils.Copy(defaultLogo, filepath.Join(config.DataDirectory, "logo.svg")); err != nil {
log.Errorln("error copying default logo: ", err)
logo := static.GetLogo()
if err := os.WriteFile(filepath.Join(config.DataDirectory, "logo.png"), logo, 0o600); err != nil {
return errors.Wrap(err, "failed to write logo to disk")
}
if err := SetLogoPath("logo.svg"); err != nil {
log.Errorln("unable to set default logo to logo.svg", err)
if err := SetLogoPath("logo.png"); err != nil {
return errors.Wrap(err, "failed to save logo filename")
}
}
@ -875,27 +895,6 @@ func GetBrowserPushPrivateKey() (string, error) {
return _datastore.GetString(browserPushPrivateKeyKey)
}
// SetTwitterConfiguration will set the Twitter configuration.
func SetTwitterConfiguration(config models.TwitterConfiguration) error {
configEntry := ConfigEntry{Key: twitterConfigurationKey, Value: config}
return _datastore.Save(configEntry)
}
// GetTwitterConfiguration will return the Twitter configuration.
func GetTwitterConfiguration() models.TwitterConfiguration {
configEntry, err := _datastore.Get(twitterConfigurationKey)
if err != nil {
return models.TwitterConfiguration{Enabled: false}
}
var config models.TwitterConfiguration
if err := configEntry.getObject(&config); err != nil {
return models.TwitterConfiguration{Enabled: false}
}
return config
}
// SetHasPerformedInitialNotificationsConfig sets when performed initial setup.
func SetHasPerformedInitialNotificationsConfig(hasConfigured bool) error {
return _datastore.SetBool(hasConfiguredInitialNotificationsKey, true)
@ -906,3 +905,57 @@ func GetHasPerformedInitialNotificationsConfig() bool {
configured, _ := _datastore.GetBool(hasConfiguredInitialNotificationsKey)
return configured
}
// GetHideViewerCount will return if the viewer count shold be hidden.
func GetHideViewerCount() bool {
hide, _ := _datastore.GetBool(hideViewerCountKey)
return hide
}
// SetHideViewerCount will set if the viewer count should be hidden.
func SetHideViewerCount(hide bool) error {
return _datastore.SetBool(hideViewerCountKey, hide)
}
// GetCustomOfflineMessage will return the custom offline message.
func GetCustomOfflineMessage() string {
message, _ := _datastore.GetString(customOfflineMessageKey)
return message
}
// SetCustomOfflineMessage will set the custom offline message.
func SetCustomOfflineMessage(message string) error {
return _datastore.SetString(customOfflineMessageKey, message)
}
// SetCustomColorVariableValues sets CSS variable names and values.
func SetCustomColorVariableValues(variables map[string]string) error {
return _datastore.SetStringMap(customColorVariableValuesKey, variables)
}
// GetCustomColorVariableValues gets CSS variable names and values.
func GetCustomColorVariableValues() map[string]string {
values, _ := _datastore.GetStringMap(customColorVariableValuesKey)
return values
}
// GetStreamKeys will return valid stream keys.
func GetStreamKeys() []models.StreamKey {
configEntry, err := _datastore.Get(streamKeysKey)
if err != nil {
return []models.StreamKey{}
}
var streamKeys []models.StreamKey
if err := configEntry.getObject(&streamKeys); err != nil {
return []models.StreamKey{}
}
return streamKeys
}
// SetStreamKeys will set valid stream keys.
func SetStreamKeys(actions []models.StreamKey) error {
configEntry := ConfigEntry{Key: streamKeysKey, Value: actions}
return _datastore.Save(configEntry)
}

View file

@ -19,6 +19,13 @@ func (c *ConfigEntry) getStringSlice() ([]string, error) {
return result, err
}
func (c *ConfigEntry) getStringMap() (map[string]string, error) {
decoder := c.getDecoder()
var result map[string]string
err := decoder.Decode(&result)
return result, err
}
func (c *ConfigEntry) getString() (string, error) {
decoder := c.getDecoder()
var result string

View file

@ -17,7 +17,7 @@ import (
)
const (
schemaVersion = 6
schemaVersion = 7
)
var (

View file

@ -110,6 +110,40 @@ func TestCustomType(t *testing.T) {
}
}
func TestStringMap(t *testing.T) {
const testKey = "test string map key"
testMap := map[string]string{
"test string 1": "test string 2",
"test string 3": "test string 4",
}
// Save config entry to the database
if err := _datastore.Save(ConfigEntry{testKey, &testMap}); err != nil {
t.Error(err)
}
// Get the config entry from the database
entryResult, err := _datastore.Get(testKey)
if err != nil {
t.Error(err)
}
testResult, err := entryResult.getStringMap()
if err != nil {
t.Error(err)
}
fmt.Printf("%+v", testResult)
if testResult["test string 1"] != testMap["test string 1"] {
t.Error("expected", testMap["test string 1"], "but test returned", testResult["test string 1"])
}
if testResult["test string 3"] != testMap["test string 3"] {
t.Error("expected", testMap["test string 3"], "but test returned", testResult["test string 3"])
}
}
// Custom type for testing
type TestStruct struct {
Test string

View file

@ -3,22 +3,28 @@ package data
import (
"strings"
"github.com/owncast/owncast/models"
log "github.com/sirupsen/logrus"
)
const (
datastoreValuesVersion = 1
datastoreValuesVersion = 2
datastoreValueVersionKey = "DATA_STORE_VERSION"
)
func migrateDatastoreValues(datastore *Datastore) {
currentVersion, _ := _datastore.GetNumber(datastoreValueVersionKey)
if currentVersion == 0 {
currentVersion = datastoreValuesVersion
}
for v := currentVersion; v < datastoreValuesVersion; v++ {
log.Tracef("Migration datastore values from %d to %d\n", int(v), int(v+1))
log.Infof("Migration datastore values from %d to %d\n", int(v), int(v+1))
switch v {
case 0:
migrateToDatastoreValues1(datastore)
case 1:
migrateToDatastoreValues2(datastore)
default:
log.Fatalln("missing datastore values migration step")
}
@ -47,3 +53,11 @@ func migrateToDatastoreValues1(datastore *Datastore) {
}
}
}
func migrateToDatastoreValues2(datastore *Datastore) {
oldAdminPassword, _ := datastore.GetString("stream_key")
_ = SetAdminPassword(oldAdminPassword)
_ = SetStreamKeys([]models.StreamKey{
{Key: oldAdminPassword, Comment: "Default stream key"},
})
}

View file

@ -32,16 +32,16 @@ func PopulateDefaults() {
return
}
_ = SetStreamKey(defaults.StreamKey)
_ = SetAdminPassword(defaults.AdminPassword)
_ = SetStreamKeys(defaults.StreamKeys)
_ = SetHTTPPortNumber(float64(defaults.WebServerPort))
_ = SetRTMPPortNumber(float64(defaults.RTMPServerPort))
_ = SetLogoPath(defaults.Logo)
_ = SetServerMetadataTags([]string{"owncast", "streaming"})
_ = SetServerSummary("Welcome to your new Owncast server! This description can be changed in the admin. Visit https://owncast.online/docs/configuration/ to learn more.")
_ = SetServerSummary(defaults.Summary)
_ = SetServerWelcomeMessage("")
_ = SetServerName("Owncast")
_ = SetStreamKey(defaults.StreamKey)
_ = SetExtraPageBodyContent("This is your page's content that can be edited in the admin.")
_ = SetServerName(defaults.Name)
_ = SetExtraPageBodyContent(defaults.PageBodyContent)
_ = SetFederationGoLiveMessage(defaults.FederationGoLiveMessage)
_ = SetSocialHandles([]models.SocialHandle{
{

124
core/data/emoji.go Normal file
View file

@ -0,0 +1,124 @@
package data
import (
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"github.com/owncast/owncast/config"
"github.com/owncast/owncast/models"
"github.com/owncast/owncast/static"
"github.com/owncast/owncast/utils"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
)
// GetEmojiList returns a list of custom emoji from the emoji directory.
func GetEmojiList() []models.CustomEmoji {
emojiFS := os.DirFS(config.CustomEmojiPath)
emojiResponse := make([]models.CustomEmoji, 0)
walkFunction := func(path string, d os.DirEntry, err error) error {
if d.IsDir() {
return nil
}
emojiPath := filepath.Join(config.EmojiDir, path)
singleEmoji := models.CustomEmoji{Name: d.Name(), URL: emojiPath}
emojiResponse = append(emojiResponse, singleEmoji)
return nil
}
if err := fs.WalkDir(emojiFS, ".", walkFunction); err != nil {
log.Errorln("unable to fetch emojis: " + err.Error())
return emojiResponse
}
return emojiResponse
}
// SetupEmojiDirectory sets up the custom emoji directory by copying all built-in
// emojis if the directory does not yet exist.
func SetupEmojiDirectory() (err error) {
type emojiDirectory struct {
path string
isDir bool
}
if utils.DoesFileExists(config.CustomEmojiPath) {
return nil
}
if err = os.MkdirAll(config.CustomEmojiPath, 0o750); err != nil {
return fmt.Errorf("unable to create custom emoji directory: %w", err)
}
staticFS := static.GetEmoji()
files := []emojiDirectory{}
walkFunction := func(path string, d os.DirEntry, err error) error {
if path == "." {
return nil
}
if d.Name() == "LICENSE.md" {
return nil
}
files = append(files, emojiDirectory{path: path, isDir: d.IsDir()})
return nil
}
if err := fs.WalkDir(staticFS, ".", walkFunction); err != nil {
log.Errorln("unable to fetch emojis: " + err.Error())
return errors.Wrap(err, "unable to fetch embedded emoji files")
}
if err != nil {
return fmt.Errorf("unable to read built-in emoji files: %w", err)
}
// Now copy all built-in emojis to the custom emoji directory
for _, path := range files {
emojiPath := filepath.Join(config.CustomEmojiPath, path.path)
if path.isDir {
if err := os.Mkdir(emojiPath, 0o700); err != nil {
return errors.Wrap(err, "unable to create emoji directory, check permissions?: "+path.path)
}
continue
}
memFile, staticOpenErr := staticFS.Open(path.path)
if staticOpenErr != nil {
return errors.Wrap(staticOpenErr, "unable to open emoji file from embedded filesystem")
}
// nolint:gosec
diskFile, err := os.Create(emojiPath)
if err != nil {
return fmt.Errorf("unable to create custom emoji file on disk: %w", err)
}
if err != nil {
_ = diskFile.Close()
return fmt.Errorf("unable to open built-in emoji file: %w", err)
}
if _, err = io.Copy(diskFile, memFile); err != nil {
_ = diskFile.Close()
_ = os.Remove(emojiPath)
return fmt.Errorf("unable to copy built-in emoji file to disk: %w", err)
}
if err = diskFile.Close(); err != nil {
_ = os.Remove(emojiPath)
return fmt.Errorf("unable to close custom emoji file on disk: %w", err)
}
}
return nil
}

View file

@ -31,6 +31,8 @@ func migrateDatabaseSchema(db *sql.DB, from, to int) error {
migrateToSchema5(db)
case 5:
migrateToSchema6(db)
case 6:
migrateToSchema7(db)
default:
log.Fatalln("missing database migration step")
}
@ -44,6 +46,50 @@ func migrateDatabaseSchema(db *sql.DB, from, to int) error {
return nil
}
func migrateToSchema7(db *sql.DB) {
log.Println("Migrating users. This may take time if you have lots of users...")
var ids []string
rows, err := db.Query(`SELECT id FROM users`)
if err != nil {
log.Errorln("error migrating access tokens to schema v5", err)
return
}
if rows.Err() != nil {
log.Errorln("error migrating users to schema v7", rows.Err())
return
}
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
log.Error("There is a problem reading the database when migrating users.", err)
return
}
ids = append(ids, id)
}
defer rows.Close()
tx, _ := db.Begin()
stmt, _ := tx.Prepare("update users set display_color=? WHERE id=?")
defer stmt.Close()
for _, id := range ids {
displayColor := utils.GenerateRandomDisplayColor(config.MaxUserColor)
if _, err := stmt.Exec(displayColor, id); err != nil {
log.Panic(err)
return
}
}
if err := tx.Commit(); err != nil {
log.Panicln(err)
}
}
func migrateToSchema6(db *sql.DB) {
// Fix chat messages table schema. Since chat is ephemeral we can drop
// the table and recreate it.
@ -291,7 +337,7 @@ func migrateToSchema1(db *sql.DB) {
// Recreate them as users
for _, token := range oldAccessTokens {
color := utils.GenerateRandomDisplayColor()
color := utils.GenerateRandomDisplayColor(config.MaxUserColor)
if err := insertAPIToken(db, token.accessToken, token.displayName, color, token.scopes); err != nil {
log.Errorln("Error migrating access token", err)
}

View file

@ -59,3 +59,18 @@ func (ds *Datastore) SetBool(key string, value bool) error {
configEntry := ConfigEntry{key, value}
return ds.Save(configEntry)
}
// GetStringMap will return the string map value for a key.
func (ds *Datastore) GetStringMap(key string) (map[string]string, error) {
configEntry, err := ds.Get(key)
if err != nil {
return map[string]string{}, err
}
return configEntry.getStringMap()
}
// SetStringMap will set the string map value for a key.
func (ds *Datastore) SetStringMap(key string, value map[string]string) error {
configEntry := ConfigEntry{key, value}
return ds.Save(configEntry)
}

View file

@ -11,19 +11,22 @@ import (
log "github.com/sirupsen/logrus"
"github.com/nareix/joy5/format/rtmp"
"github.com/owncast/owncast/config"
"github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/models"
)
var _hasInboundRTMPConnection = false
var (
_hasInboundRTMPConnection = false
_pipe *io.PipeWriter
_rtmpConnection net.Conn
)
var _pipe *io.PipeWriter
var _rtmpConnection net.Conn
var _setStreamAsConnected func(*io.PipeReader)
var _setBroadcaster func(models.Broadcaster)
var (
_setStreamAsConnected func(*io.PipeReader)
_setBroadcaster func(models.Broadcaster)
)
// Start starts the rtmp service, listening on specified RTMP port.
func Start(setStreamAsConnected func(*io.PipeReader), setBroadcaster func(models.Broadcaster)) {
@ -75,7 +78,22 @@ func HandleConn(c *rtmp.Conn, nc net.Conn) {
return
}
if !secretMatch(data.GetStreamKey(), c.URL.Path) {
accessGranted := false
validStreamingKeys := data.GetStreamKeys()
for _, key := range validStreamingKeys {
if secretMatch(key.Key, c.URL.Path) {
accessGranted = true
break
}
}
// Test against the temporary key if it was set at runtime.
if config.TemporaryStreamKey != "" && secretMatch(config.TemporaryStreamKey, c.URL.Path) {
accessGranted = true
}
if !accessGranted {
log.Errorln("invalid streaming key; rejecting incoming stream")
_ = nc.Close()
return

View file

@ -1,6 +1,7 @@
package rtmp
import (
"crypto/subtle"
"encoding/json"
"errors"
"fmt"
@ -89,5 +90,7 @@ func secretMatch(configStreamKey string, path string) bool {
}
streamingKey := path[len(prefix):] // Remove $prefix
return streamingKey == configStreamKey
matches := subtle.ConstantTimeCompare([]byte(streamingKey), []byte(configStreamKey)) == 1
return matches
}

View file

@ -92,7 +92,7 @@ func SetStreamAsDisconnected() {
_stats.LastConnectTime = nil
_broadcaster = nil
offlineFilename := "offline.ts"
offlineFilename := "offline.tsclip"
offlineFilePath, err := saveOfflineClipToDisk(offlineFilename)
if err != nil {

View file

@ -31,8 +31,7 @@ var supportedCodecs = map[string]string{
}
// Libx264Codec represents an instance of the Libx264 Codec.
type Libx264Codec struct {
}
type Libx264Codec struct{}
// Name returns the codec name.
func (c *Libx264Codec) Name() string {
@ -77,24 +76,26 @@ func (c *Libx264Codec) VariantFlags(v *HLSVariant) string {
// GetPresetForLevel returns the string preset for this codec given an integer level.
func (c *Libx264Codec) GetPresetForLevel(l int) string {
presetMapping := []string{
"ultrafast",
"superfast",
"veryfast",
"faster",
"fast",
presetMapping := map[int]string{
0: "ultrafast",
1: "superfast",
2: "veryfast",
3: "faster",
4: "fast",
}
if l >= len(presetMapping) {
return "superfast" //nolint:goconst
preset, ok := presetMapping[l]
if !ok {
defaultPreset := presetMapping[1]
log.Errorf("Invalid level for x264 preset %d, defaulting to %s", l, defaultPreset)
return defaultPreset
}
return presetMapping[l]
return preset
}
// OmxCodec represents an instance of the Omx codec.
type OmxCodec struct {
}
type OmxCodec struct{}
// Name returns the codec name.
func (c *OmxCodec) Name() string {
@ -135,24 +136,26 @@ func (c *OmxCodec) VariantFlags(v *HLSVariant) string {
// GetPresetForLevel returns the string preset for this codec given an integer level.
func (c *OmxCodec) GetPresetForLevel(l int) string {
presetMapping := []string{
"ultrafast",
"superfast",
"veryfast",
"faster",
"fast",
presetMapping := map[int]string{
0: "ultrafast",
1: "superfast",
2: "veryfast",
3: "faster",
4: "fast",
}
if l >= len(presetMapping) {
return "superfast"
preset, ok := presetMapping[l]
if !ok {
defaultPreset := presetMapping[1]
log.Errorf("Invalid level for omx preset %d, defaulting to %s", l, defaultPreset)
return defaultPreset
}
return presetMapping[l]
return preset
}
// VaapiCodec represents an instance of the Vaapi codec.
type VaapiCodec struct {
}
type VaapiCodec struct{}
// Name returns the codec name.
func (c *VaapiCodec) Name() string {
@ -195,24 +198,26 @@ func (c *VaapiCodec) VariantFlags(v *HLSVariant) string {
// GetPresetForLevel returns the string preset for this codec given an integer level.
func (c *VaapiCodec) GetPresetForLevel(l int) string {
presetMapping := []string{
"ultrafast",
"superfast",
"veryfast",
"faster",
"fast",
presetMapping := map[int]string{
0: "ultrafast",
1: "superfast",
2: "veryfast",
3: "faster",
4: "fast",
}
if l >= len(presetMapping) {
return "superfast"
preset, ok := presetMapping[l]
if !ok {
defaultPreset := presetMapping[1]
log.Errorf("Invalid level for vaapi preset %d, defaulting to %s", l, defaultPreset)
return defaultPreset
}
return presetMapping[l]
return preset
}
// NvencCodec represents an instance of the Nvenc Codec.
type NvencCodec struct {
}
type NvencCodec struct{}
// Name returns the codec name.
func (c *NvencCodec) Name() string {
@ -256,24 +261,26 @@ func (c *NvencCodec) VariantFlags(v *HLSVariant) string {
// GetPresetForLevel returns the string preset for this codec given an integer level.
func (c *NvencCodec) GetPresetForLevel(l int) string {
presetMapping := []string{
"p1",
"p2",
"p3",
"p4",
"p5",
presetMapping := map[int]string{
0: "p1",
1: "p2",
2: "p3",
3: "p4",
4: "p5",
}
if l >= len(presetMapping) {
return "p3"
preset, ok := presetMapping[l]
if !ok {
defaultPreset := presetMapping[2]
log.Errorf("Invalid level for nvenc preset %d, defaulting to %s", l, defaultPreset)
return defaultPreset
}
return presetMapping[l]
return preset
}
// QuicksyncCodec represents an instance of the Intel Quicksync Codec.
type QuicksyncCodec struct {
}
type QuicksyncCodec struct{}
// Name returns the codec name.
func (c *QuicksyncCodec) Name() string {
@ -312,19 +319,22 @@ func (c *QuicksyncCodec) VariantFlags(v *HLSVariant) string {
// GetPresetForLevel returns the string preset for this codec given an integer level.
func (c *QuicksyncCodec) GetPresetForLevel(l int) string {
presetMapping := []string{
"ultrafast",
"superfast",
"veryfast",
"faster",
"fast",
presetMapping := map[int]string{
0: "ultrafast",
1: "superfast",
2: "veryfast",
3: "faster",
4: "fast",
}
if l >= len(presetMapping) {
return "superfast"
preset, ok := presetMapping[l]
if !ok {
defaultPreset := presetMapping[1]
log.Errorf("Invalid level for quicksync preset %d, defaulting to %s", l, defaultPreset)
return defaultPreset
}
return presetMapping[l]
return preset
}
// Video4Linux represents an instance of the V4L Codec.
@ -367,24 +377,25 @@ func (c *Video4Linux) VariantFlags(v *HLSVariant) string {
// GetPresetForLevel returns the string preset for this codec given an integer level.
func (c *Video4Linux) GetPresetForLevel(l int) string {
presetMapping := []string{
"ultrafast",
"superfast",
"veryfast",
"faster",
"fast",
presetMapping := map[int]string{
0: "ultrafast",
1: "superfast",
2: "veryfast",
3: "faster",
4: "fast",
}
if l >= len(presetMapping) {
return "superfast"
preset, ok := presetMapping[l]
if !ok {
defaultPreset := presetMapping[1]
log.Errorf("Invalid level for v4l preset %d, defaulting to %s", l, defaultPreset)
return defaultPreset
}
return presetMapping[l]
return preset
}
// VideoToolboxCodec represents an instance of the VideoToolbox codec.
type VideoToolboxCodec struct {
}
type VideoToolboxCodec struct{}
// Name returns the codec name.
func (c *VideoToolboxCodec) Name() string {
@ -435,19 +446,22 @@ func (c *VideoToolboxCodec) VariantFlags(v *HLSVariant) string {
// GetPresetForLevel returns the string preset for this codec given an integer level.
func (c *VideoToolboxCodec) GetPresetForLevel(l int) string {
presetMapping := []string{
"ultrafast",
"superfast",
"veryfast",
"faster",
"fast",
presetMapping := map[int]string{
0: "ultrafast",
1: "superfast",
2: "veryfast",
3: "faster",
4: "fast",
}
if l >= len(presetMapping) {
return "superfast"
preset, ok := presetMapping[l]
if !ok {
defaultPreset := presetMapping[1]
log.Errorf("Invalid level for videotoolbox preset %d, defaulting to %s", l, defaultPreset)
return defaultPreset
}
return presetMapping[l]
return preset
}
// GetCodecs will return the supported codecs available on the system.

View file

@ -49,8 +49,8 @@ func StartThumbnailGenerator(chunkPath string, variantIndex int) {
func fireThumbnailGenerator(segmentPath string, variantIndex int) error {
// JPG takes less time to encode than PNG
outputFile := path.Join(config.WebRoot, "thumbnail.jpg")
previewGifFile := path.Join(config.WebRoot, "preview.gif")
outputFile := path.Join(config.TempDir, "thumbnail.jpg")
previewGifFile := path.Join(config.TempDir, "preview.gif")
framePath := path.Join(segmentPath, strconv.Itoa(variantIndex))
files, err := os.ReadDir(framePath)
@ -87,7 +87,7 @@ func fireThumbnailGenerator(segmentPath string, variantIndex int) error {
mostRecentFile := path.Join(framePath, names[0])
ffmpegPath := utils.ValidatedFfmpegPath(data.GetFfMpegPath())
outputFileTemp := path.Join(config.WebRoot, "tempthumbnail.jpg")
outputFileTemp := path.Join(config.TempDir, "tempthumbnail.jpg")
thumbnailCmdFlags := []string{
ffmpegPath,
@ -117,7 +117,7 @@ func fireThumbnailGenerator(segmentPath string, variantIndex int) error {
func makeAnimatedGifPreview(sourceFile string, outputFile string) {
ffmpegPath := utils.ValidatedFfmpegPath(data.GetFfMpegPath())
outputFileTemp := path.Join(config.WebRoot, "temppreview.gif")
outputFileTemp := path.Join(config.TempDir, "temppreview.gif")
// Filter is pulled from https://engineering.giphy.com/how-to-make-gifs-with-ffmpeg/
animatedGifFlags := []string{

View file

@ -132,7 +132,7 @@ func (t *Transcoder) Start() {
}
if err := _commandExec.Start(); err != nil {
log.Errorln("Transcoder error. See ", logging.GetTranscoderLogFilePath(), " for full output to debug.")
log.Errorln("Transcoder error. See", logging.GetTranscoderLogFilePath(), "for full output to debug.")
log.Panicln(err, command)
}
@ -150,7 +150,7 @@ func (t *Transcoder) Start() {
}
if err != nil {
log.Errorln("transcoding error. look at ", logging.GetTranscoderLogFilePath(), " to help debug. your copy of ffmpeg may not support your selected codec of", t.codec.Name(), "https://owncast.online/docs/codecs/")
log.Errorln("transcoding error. look at", logging.GetTranscoderLogFilePath(), "to help debug. your copy of ffmpeg may not support your selected codec of", t.codec.Name(), "https://owncast.online/docs/codecs/")
}
}

View file

@ -8,6 +8,7 @@ import (
"strings"
"time"
"github.com/owncast/owncast/config"
"github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/db"
"github.com/owncast/owncast/utils"
@ -55,23 +56,32 @@ func SetupUsers() {
_datastore = data.GetDatastore()
}
func generateDisplayName() string {
suggestedUsernamesList := data.GetSuggestedUsernamesList()
if len(suggestedUsernamesList) >= minSuggestedUsernamePoolLength {
index := utils.RandomIndex(len(suggestedUsernamesList))
return suggestedUsernamesList[index]
} else {
return utils.GeneratePhrase()
}
}
// CreateAnonymousUser will create a new anonymous user with the provided display name.
func CreateAnonymousUser(displayName string) (*User, string, error) {
id := shortid.MustGenerate()
if displayName == "" {
suggestedUsernamesList := data.GetSuggestedUsernamesList()
if len(suggestedUsernamesList) >= minSuggestedUsernamePoolLength {
index := utils.RandomIndex(len(suggestedUsernamesList))
displayName = suggestedUsernamesList[index]
} else {
displayName = utils.GeneratePhrase()
// Try to assign a name that was requested.
if displayName != "" {
// If name isn't available then generate a random one.
if available, _ := IsDisplayNameAvailable(displayName); !available {
displayName = generateDisplayName()
}
} else {
displayName = generateDisplayName()
}
displayColor := utils.GenerateRandomDisplayColor()
displayColor := utils.GenerateRandomDisplayColor(config.MaxUserColor)
id := shortid.MustGenerate()
user := &User{
ID: id,
DisplayName: displayName,
@ -125,6 +135,21 @@ func ChangeUsername(userID string, username string) error {
return nil
}
// ChangeUserColor will change the user associated to userID from one display name to another.
func ChangeUserColor(userID string, color int) error {
_datastore.DbLock.Lock()
defer _datastore.DbLock.Unlock()
if err := _datastore.GetQueries().ChangeDisplayColor(context.Background(), db.ChangeDisplayColorParams{
DisplayColor: int32(color),
ID: userID,
}); err != nil {
return errors.Wrap(err, "unable to change display color")
}
return nil
}
func addAccessTokenForUser(accessToken, userID string) error {
return _datastore.GetQueries().AddAccessTokenForUser(context.Background(), db.AddAccessTokenForUserParams{
Token: accessToken,

View file

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.14.0
// sqlc v1.15.0
package db

View file

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.14.0
// sqlc v1.15.0
package db
@ -52,6 +52,19 @@ type IpBan struct {
CreatedAt sql.NullTime
}
type Message struct {
ID string
UserID sql.NullString
Body sql.NullString
EventType sql.NullString
HiddenAt sql.NullTime
Timestamp sql.NullTime
Title sql.NullString
Subtitle sql.NullString
Image sql.NullString
Link sql.NullString
}
type Notification struct {
ID int32
Channel string

View file

@ -97,8 +97,14 @@ UPDATE user_access_tokens SET user_id = $1 WHERE token = $2;
-- name: SetUserAsAuthenticated :exec
UPDATE users SET authenticated_at = CURRENT_TIMESTAMP WHERE id = $1;
-- name: GetMessagesFromUser :many
SELECT id, body, hidden_at, timestamp FROM messages WHERE eventType = 'CHAT' AND user_id = $1 ORDER BY TIMESTAMP DESC;
-- name: IsDisplayNameAvailable :one
SELECT count(*) FROM users WHERE display_name = $1 AND authenticated_at is not null AND disabled_at is NULL;
-- name: ChangeDisplayName :exec
UPDATE users SET display_name = $1, previous_names = previous_names || $2, namechanged_at = $3 WHERE id = $4;
-- name: ChangeDisplayColor :exec
UPDATE users SET display_color = $1 WHERE id = $2;

View file

@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.14.0
// sqlc v1.15.0
// source: query.sql
package db
@ -153,6 +153,20 @@ func (q *Queries) BanIPAddress(ctx context.Context, arg BanIPAddressParams) erro
return err
}
const changeDisplayColor = `-- name: ChangeDisplayColor :exec
UPDATE users SET display_color = $1 WHERE id = $2
`
type ChangeDisplayColorParams struct {
DisplayColor int32
ID string
}
func (q *Queries) ChangeDisplayColor(ctx context.Context, arg ChangeDisplayColorParams) error {
_, err := q.db.ExecContext(ctx, changeDisplayColor, arg.DisplayColor, arg.ID)
return err
}
const changeDisplayName = `-- name: ChangeDisplayName :exec
UPDATE users SET display_name = $1, previous_names = previous_names || $2, namechanged_at = $3 WHERE id = $4
`
@ -412,6 +426,45 @@ func (q *Queries) GetLocalPostCount(ctx context.Context) (int64, error) {
return count, err
}
const getMessagesFromUser = `-- name: GetMessagesFromUser :many
SELECT id, body, hidden_at, timestamp FROM messages WHERE eventType = 'CHAT' AND user_id = $1 ORDER BY TIMESTAMP DESC
`
type GetMessagesFromUserRow struct {
ID string
Body sql.NullString
HiddenAt sql.NullTime
Timestamp sql.NullTime
}
func (q *Queries) GetMessagesFromUser(ctx context.Context, userID sql.NullString) ([]GetMessagesFromUserRow, error) {
rows, err := q.db.QueryContext(ctx, getMessagesFromUser, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetMessagesFromUserRow
for rows.Next() {
var i GetMessagesFromUserRow
if err := rows.Scan(
&i.ID,
&i.Body,
&i.HiddenAt,
&i.Timestamp,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getNotificationDestinationsForChannel = `-- name: GetNotificationDestinationsForChannel :many
SELECT destination FROM notifications WHERE channel = $1
`

View file

@ -79,3 +79,21 @@ CREATE TABLE IF NOT EXISTS auth (
"timestamp" DATE DEFAULT CURRENT_TIMESTAMP NOT NULL
);
CREATE INDEX auth_token ON auth (token);
CREATE TABLE IF NOT EXISTS messages (
"id" string NOT NULL,
"user_id" TEXT,
"body" TEXT,
"eventType" TEXT,
"hidden_at" DATE,
"timestamp" DATE,
"title" TEXT,
"subtitle" TEXT,
"image" TEXT,
"link" TEXT,
PRIMARY KEY (id)
);CREATE INDEX index ON messages (id, user_id, hidden_at, timestamp);
CREATE INDEX id ON messages (id);
CREATE INDEX user_id ON messages (user_id);
CREATE INDEX hidden_at ON messages (hidden_at);
CREATE INDEX timestamp ON messages (timestamp);

40
docs/Release.md Normal file
View file

@ -0,0 +1,40 @@
# Build + Distribute Official Owncast Releases
Owncast is released both as standalone archives that can be downloaded and installed themselves, as well as Docker images that can be pulled from Docker Hub.
The original Docker Hub image was [gabekangas/owncast](https://hub.docker.com/repository/docker/gabekangas/owncast) but it has been deprecated in favor of [owncast/owncast](https://hub.docker.com/repository/docker/owncast/owncast). In the short term both images will need to be updated with new releases and in the future we can deprecate the old one.
## Dependencies
1. Install [Earthly](https://earthly.dev/get-earthly), a build automation tool. It uses our [Earthfile](https://github.com/owncast/owncast/blob/develop/Earthfile) to reproducably build the release files and Docker images.
2. Be [logged into Docker Hub](https://docs.docker.com/engine/reference/commandline/login/) with an account that has access to `gabekangas/owncast` and `owncast/owncast` so the images can be pushed to Docker Hub.
## Build release files
1. Create the release archive files for all the different architectures. Specify the human readable version number in the `version` flag such as `v0.1.0`, `nightly`, `develop`, etc. It will be used to identify this binary when running Owncast. You'll find the archives for this release in the `dist` directory when it's complete.
**Run**: `earthly +package-all --version="v0.1.0"`
2. Create a release on GitHub with release notes and Changelog for the version.
3. Upload the release archive files to the release on GitHub via the web interface.
## Build and upload Docker images
Specify the human readable version number in the `version` flag such as `v0.1.0`, `nightly`, `develop`, etc. It will be used to identify this binary when running Owncast.
Create and push the image to Docker Hub with a list of tags. You'll want to tag the image with both the new version number and `latest`.
**Run**: `earthly --push +docker-all --images="owncast/owncast:0.1.0 owncast/owncast:latest gabekangas/owncast:0.1.0 gabekangas/owncast:latest" --version="webv2"`
Omit `--push` if you don't want to push the image to Docker Hub and want to just build and test the image locally first.
## Update installer script
Once you have uploaded the release archive files and made the new files public and are confident the release is working and available you can update the installer script to point to the new release.
Edit the `OWNCAST_VERSION` in [`install.sh`](https://github.com/owncast/owncast.github.io/blob/master/static/install.sh).
## Final
Once the installer is pointing to the new release number and Docker Hub has new images tagged as `latest` the new version is released to the public.

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