mirror of
https://github.com/owncast/owncast.git
synced 2024-11-23 05:14:20 +03:00
Merge branch 'webv2' into develop
This commit is contained in:
commit
ff81536191
1539 changed files with 78215 additions and 19924 deletions
|
@ -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
24
.github/workflows/actions-lint.yml
vendored
Normal 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
|
19
.github/workflows/automated-browser.yml
vendored
19
.github/workflows/automated-browser.yml
vendored
|
@ -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
|
40
.github/workflows/automated-end-to-end-api.yaml
vendored
40
.github/workflows/automated-end-to-end-api.yaml
vendored
|
@ -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
36
.github/workflows/browser-testing.yml
vendored
Normal 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
44
.github/workflows/build-storybook.yml
vendored
Normal 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
|
21
.github/workflows/bundle-admin.yml
vendored
21
.github/workflows/bundle-admin.yml
vendored
|
@ -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
31
.github/workflows/bundle-web.yml
vendored
Normal 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
52
.github/workflows/chromatic.yml
vendored
Normal 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
28
.github/workflows/container-lint.yml
vendored
Normal 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
60
.github/workflows/container.yaml
vendored
Normal 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
|
40
.github/workflows/docker-nightly.yaml
vendored
40
.github/workflows/docker-nightly.yaml
vendored
|
@ -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
|
42
.github/workflows/docker-webv2.yaml
vendored
42
.github/workflows/docker-webv2.yaml
vendored
|
@ -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
|
|
@ -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
|
||||
|
23
.github/workflows/hls-tests.yml
vendored
23
.github/workflows/hls-tests.yml
vendored
|
@ -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
|
||||
|
|
75
.github/workflows/javascript-formatting.yml
vendored
75
.github/workflows/javascript-formatting.yml
vendored
|
@ -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
|
||||
|
|
33
.github/workflows/javascript-packages.yaml
vendored
33
.github/workflows/javascript-packages.yaml
vendored
|
@ -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
30
.github/workflows/shellcheck.yml
vendored
Normal 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
|
38
.github/workflows/test-webapp-build.yaml
vendored
Normal file
38
.github/workflows/test-webapp-build.yaml
vendored
Normal 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
|
18
.github/workflows/test.yaml
vendored
18
.github/workflows/test.yaml
vendored
|
@ -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
3
.gitignore
vendored
|
@ -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
|
||||
|
|
|
@ -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']
|
||||
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
# Ignore artifacts:
|
||||
build/javascript
|
||||
webroot/js/web_modules
|
||||
static/
|
22
Dockerfile
22
Dockerfile
|
@ -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
|
||||
|
|
68
Earthfile
68
Earthfile
|
@ -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
|
||||
|
|
51
README.md
51
README.md
|
@ -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 @@ We’ve 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)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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.")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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
|
||||
|
|
35
auth/indieauth/indieauth_test.go
Normal file
35
auth/indieauth/indieauth_test.go
Normal 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.")
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
24
build/develop/container.sh
Executable 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}"
|
|
@ -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.
|
2218
build/javascript/package-lock.json
generated
2218
build/javascript/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -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"
|
||||
}
|
|
@ -1,7 +0,0 @@
|
|||
module.exports = {
|
||||
plugins: [
|
||||
require('cssnano')({
|
||||
preset: 'default',
|
||||
}),
|
||||
],
|
||||
};
|
|
@ -1,7 +0,0 @@
|
|||
module.exports = {
|
||||
purge: {
|
||||
enabled: true,
|
||||
mode: 'layers',
|
||||
content: ['../../webroot/js/**'],
|
||||
},
|
||||
};
|
|
@ -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}"
|
|
@ -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}"
|
|
@ -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
28
build/web/bundleWeb.sh
Executable 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."
|
|
@ -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 == "" {
|
||||
|
|
|
@ -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")
|
||||
)
|
||||
|
|
|
@ -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,
|
||||
|
||||
|
|
35
controllers/admin/appearance.go
Normal file
35
controllers/admin/appearance.go
Normal 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")
|
||||
}
|
|
@ -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")
|
||||
}
|
||||
|
|
92
controllers/admin/emoji.go
Normal file
92
controllers/admin/emoji.go
Normal 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))
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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))
|
||||
}
|
|
@ -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")
|
||||
}
|
||||
|
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
@ -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, "")
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
13
controllers/customJavascript.go
Normal file
13
controllers/customJavascript.go
Normal 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))
|
||||
}
|
|
@ -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")
|
||||
}
|
|
@ -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
62
controllers/images.go
Normal 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)
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
73
controllers/moderation/moderation.go
Normal file
73
controllers/moderation/moderation.go
Normal 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)
|
||||
}
|
||||
}
|
|
@ -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
14
controllers/web.go
Normal 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)
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)))
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -17,7 +17,7 @@ import (
|
|||
)
|
||||
|
||||
const (
|
||||
schemaVersion = 6
|
||||
schemaVersion = 7
|
||||
)
|
||||
|
||||
var (
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"},
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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
124
core/data/emoji.go
Normal 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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -92,7 +92,7 @@ func SetStreamAsDisconnected() {
|
|||
_stats.LastConnectTime = nil
|
||||
_broadcaster = nil
|
||||
|
||||
offlineFilename := "offline.ts"
|
||||
offlineFilename := "offline.tsclip"
|
||||
|
||||
offlineFilePath, err := saveOfflineClipToDisk(offlineFilename)
|
||||
if err != nil {
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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{
|
||||
|
|
|
@ -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/")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
2
db/db.go
2
db/db.go
|
@ -1,6 +1,6 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.14.0
|
||||
// sqlc v1.15.0
|
||||
|
||||
package db
|
||||
|
||||
|
|
15
db/models.go
15
db/models.go
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
`
|
||||
|
|
|
@ -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
40
docs/Release.md
Normal 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
Loading…
Reference in a new issue