Compare commits

..

2 Commits

  1. 70
      .design/DESIGN.md
  2. 12
      .editorconfig
  3. 116
      .gitattributes
  4. 4
      .github/ISSUE_TEMPLATE/bug-report-feature-request.md
  5. 15
      .github/ISSUE_TEMPLATE/bug-report-feature-request.yml
  6. 17
      .github/PULL_REQUEST_TEMPLATE.md
  7. 19
      .github/PULL_REQUEST_TEMPLATE/standard-pull-request.md
  8. 1
      .github/codeql/go.yml
  9. 4
      .github/codeql/javascript.yml
  10. 20
      .github/workflows/actions-lint.yml
  11. 42
      .github/workflows/auto-comment-on-label.yaml
  12. 19
      .github/workflows/automated-browser.yml
  13. 41
      .github/workflows/automated-end-to-end-api.yaml
  14. 54
      .github/workflows/browser-testing.yml
  15. 54
      .github/workflows/build-storybook.yml
  16. 21
      .github/workflows/bundle-admin.yml
  17. 51
      .github/workflows/chromatic.yml
  18. 11
      .github/workflows/codeql-analysis.yml
  19. 28
      .github/workflows/container-lint.yml
  20. 56
      .github/workflows/container.yaml
  21. 24
      .github/workflows/docker-nightly.yaml
  22. 2
      .github/workflows/generate-api-documentation.yaml
  23. 38
      .github/workflows/go-lint.yml
  24. 68
      .github/workflows/go-tests.yaml
  25. 51
      .github/workflows/hls-tests.yml
  26. 185
      .github/workflows/javascript-format-build.yml
  27. 29
      .github/workflows/javascript-formatting.yml
  28. 33
      .github/workflows/javascript-packages.yaml
  29. 45
      .github/workflows/javascript-tests.yml
  30. 24
      .github/workflows/lint.yml
  31. 58
      .github/workflows/screenshots.yml
  32. 30
      .github/workflows/shellcheck.yml
  33. 42
      .github/workflows/test.yaml
  34. 7
      .gitignore
  35. 5
      .gitpod.yml
  36. 17
      .golangci.yml
  37. 1
      .prettierignore
  38. 28
      .vscode/settings.json
  39. 22
      Dockerfile
  40. 168
      Earthfile
  41. 2
      LICENSE
  42. 66
      README.md
  43. 5
      activitypub/activitypub.go
  44. 99
      activitypub/apmodels/activity.go
  45. 16
      activitypub/apmodels/actor.go
  46. 4
      activitypub/apmodels/actor_test.go
  47. 10
      activitypub/apmodels/message.go
  48. 48
      activitypub/apmodels/utils.go
  49. 34
      activitypub/apmodels/webfinger.go
  50. 26
      activitypub/controllers/nodeinfo.go
  51. 43
      activitypub/controllers/webfinger.go
  52. 8
      activitypub/inbox/like.go
  53. 7
      activitypub/inbox/undo.go
  54. 43
      activitypub/inbox/worker.go
  55. 10
      activitypub/inbox/workerpool.go
  56. 72
      activitypub/outbox/outbox.go
  57. 17
      activitypub/persistence/followers.go
  58. 107
      activitypub/persistence/followers_test.go
  59. 37
      activitypub/persistence/persistence.go
  60. 22
      activitypub/requests/http.go
  61. 70
      activitypub/resolvers/resolve.go
  62. 61
      activitypub/webfinger/webfinger.go
  63. 9
      activitypub/workerpool/outbound.go
  64. 11
      auth/auth.go
  65. 115
      auth/fediverse/fediverse.go
  66. 111
      auth/fediverse/fediverse_test.go
  67. 168
      auth/indieauth/client.go
  68. 126
      auth/indieauth/helpers.go
  69. 35
      auth/indieauth/indieauth_test.go
  70. 34
      auth/indieauth/random.go
  71. 22
      auth/indieauth/request.go
  72. 18
      auth/indieauth/response.go
  73. 101
      auth/indieauth/server.go
  74. 67
      auth/persistence.go
  75. 41
      build/admin/bundleAdmin.sh
  76. 24
      build/develop/container.sh
  77. 16
      build/javascript/README.md
  78. 2209
      build/javascript/package-lock.json
  79. 41
      build/javascript/package.json
  80. 7
      build/javascript/postcss.config.js
  81. 7
      build/javascript/tailwind.config.js
  82. 118
      build/release/build.sh
  83. 29
      build/release/docker-nightly.sh
  84. 40
      build/web/bundleWeb.sh
  85. 3
      config/config.go
  86. 19
      config/constants.go
  87. 62
      config/defaults.go
  88. 4
      config/verifyInstall.go
  89. 7
      contrib/README.md
  90. 19
      contrib/owncast-sample.service
  91. 82
      contrib/owncast_for_windows.md
  92. 32
      contrib/varnish/hitch.conf
  93. 38
      contrib/varnish/vanish.vcl
  94. 35
      controllers/admin/appearance.go
  95. 13
      controllers/admin/chat.go
  96. 191
      controllers/admin/config.go
  97. 92
      controllers/admin/emoji.go
  98. 4
      controllers/admin/externalAPIUsers.go
  99. 1
      controllers/admin/federation.go
  100. 57
      controllers/admin/index.go
  101. Some files were not shown because too many files have changed in this diff Show More

70
.design/DESIGN.md

@ -1,70 +0,0 @@ @@ -1,70 +0,0 @@
# Owncast Design Guidelines & Resources
A collection of design contribution guidelines and resources for the Owncast interface.
> **All participating designers are highly encouraged to shape and evolve these guidelines!**
> It is a work in progress and as we have design contributors we can work to solidify the process, tools and resources.
## 👋 Welcome
Owncast is a is a live streaming and chat server targeted to anybody who has live streaming needs. This means anybody from corporate events, government meetings, game streamers, musicians, churches, TV stations, and more.
Read the detailed [product definition](https://github.com/owncast/owncast/blob/develop/docs/product-definition.md) to learn more.
## 🚢 How to contribute to product design
1. Check out open [issues](https://github.com/owncast/owncast/issues) here on GitHub (we label them with `needs design`)
2. Feel free to open an issue on your own if you find something you would like to contribute to the project.
3. Add your contributions to an issue and we promise we will review your contribution carefully and foster discussions
**We encourage you to:**
- Get in touch with the team by joining our [Community Chat](https://owncast.rocket.chat).
- Check out our [Contributor Guide](https://owncast.online/help) and
[Code of Conduct](https://github.com/owncast/owncast/blob/develop/CODE_OF_CONDUCT.md)
## 🎭 Target audience
Owncast is a is a live streaming and chat server targeted to anybody who has live streaming needs. This means anything from corporate events, government meetings, game streams, concerts, TV stations, and more.
## 🧑🎨 Product design opportunities
Owncast is a constantly moving project with features both old and new. This allows for design contributions to be both big or small.
You may not know how much time you can dedicate to the project, or if you'll be able to see something through to the end, so be honest about that. Take on projects that you'll be able to see completed.
- So maybe start small by finding rough edges and improvements to existing features without requiring complete rewrites. As a small project the bandwidth for rebuilding existing designs is limited, but tweaks are appreciated. This is especially great if you don't know how much time or energy you'll be able to provide the project. If you think you have a week to help, but might not be around in a month small projects are better.
- If you think you'll be around longer term, learn about future new features and start thinking about the design challenges of those so we can build them your feedback and design contributions in mind. See your designs put in the world through brand new functionality!
- Not everything has to be a a feature. Think big picture. What can we start doing now to put the project in a better place six months from now, or a year?
## 💅 Design relevant materials
A collection of design relevant information and materials can be found under the "style" section of "Storybook" here:
http://owncast.online/components
### Fonts
https://owncast.online/components/?path=%2Fdocs%2Fowncast-styles-typography--page
Body text: Inter
Display/Header text: Poppins
### Colors
https://owncast.online/components/?path=%2Fdocs%2Fowncast-styles-colors-components--page
### Design Files, Screenshots, etc
We do not currently have any design files that fully represent the state of
the Owncast interface. However going forward it would be nice to resolve this
and collaborate on designs.
We do have a [PenPot organization](https://design.penpot.app/#/dashboard/team/8373f780-f255-11ec-b774-f940e3befd53/projects). Please ask for access.
## 🎓 License
All design work is licensed under the
[MIT](https://mit-license.org/)
[(Back to top)](#-table-of-contents)

12
.editorconfig

@ -2,7 +2,7 @@ @@ -2,7 +2,7 @@
root = true
[*]
indent_style = tab
indent_style = space
indent_size = 2
tab_width = 2
end_of_line = lf
@ -14,13 +14,3 @@ quote_type = single @@ -14,13 +14,3 @@ 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

116
.gitattributes vendored

@ -1,65 +1,57 @@ @@ -1,65 +1,57 @@
# Recreate this file via
# find static -type d -print0 | xargs -0 -I {} echo "{}/* linguist-vendored" | xclip -selection clipboard
webroot/js/web_modules/* linguist-vendored
webroot/js/web_modules/@joeattardi/* linguist-vendored
webroot/js/web_modules/@justinribeiro/* linguist-vendored
webroot/js/web_modules/@videojs/http-streaming/dist/* linguist-vendored
webroot/js/web_modules/@videojs/themes/fantasy/* linguist-vendored
webroot/js/web_modules/common/* linguist-vendored
webroot/js/web_modules/markjs/dist/* linguist-vendored
webroot/js/web_modules/tailwindcss/dist/* linguist-vendored
webroot/js/web_modules/videojs/* linguist-vendored
webroot/js/web_modules/micromodal/dist/* linguist-vendored
static/* linguist-vendored
static/admin/* linguist-vendored
docs/api/* linguist-documentation
static/* linguist-vendored
static/web/* linguist-vendored
static/web/admin/* linguist-vendored
static/web/admin/federation/* linguist-vendored
static/web/admin/federation/actions/* linguist-vendored
static/web/admin/federation/followers/* linguist-vendored
static/web/admin/logs/* linguist-vendored
static/web/admin/config-social-items/* linguist-vendored
static/web/admin/config/* linguist-vendored
static/web/admin/config/general/* linguist-vendored
static/web/admin/config/server/* linguist-vendored
static/web/admin/config-chat/* linguist-vendored
static/web/admin/config-federation/* linguist-vendored
static/web/admin/viewer-info/* linguist-vendored
static/web/admin/access-tokens/* linguist-vendored
static/web/admin/actions/* linguist-vendored
static/web/admin/help/* linguist-vendored
static/web/admin/webhooks/* linguist-vendored
static/web/admin/chat/* linguist-vendored
static/web/admin/chat/messages/* linguist-vendored
static/web/admin/chat/users/* linguist-vendored
static/web/admin/chat/emojis/* linguist-vendored
static/web/admin/upgrade/* linguist-vendored
static/web/admin/config-notify/* linguist-vendored
static/web/admin/hardware-info/* linguist-vendored
static/web/admin/config-video/* linguist-vendored
static/web/admin/stream-health/* linguist-vendored
static/web/404/* linguist-vendored
static/web/_next/* linguist-vendored
static/web/_next/static/* linguist-vendored
static/web/_next/static/l-3emuM7cUz2zU2fzzpRq/* linguist-vendored
static/web/_next/static/media/* linguist-vendored
static/web/_next/static/chunks/* linguist-vendored
static/web/_next/static/chunks/pages/* linguist-vendored
static/web/_next/static/chunks/pages/admin/* linguist-vendored
static/web/_next/static/chunks/pages/admin/federation/* linguist-vendored
static/web/_next/static/chunks/pages/admin/config/* linguist-vendored
static/web/_next/static/chunks/pages/admin/chat/* linguist-vendored
static/web/_next/static/chunks/pages/embed/* linguist-vendored
static/web/_next/static/chunks/pages/embed/chat/* linguist-vendored
static/web/_next/static/css/* linguist-vendored
static/web/_next/static/OQyHVua-s5F40yEopTtjx/* linguist-vendored
static/web/_next/OQyHVua-s5F40yEopTtjx/* linguist-vendored
static/web/embed/* linguist-vendored
static/web/embed/chat/* linguist-vendored
static/web/embed/chat/readonly/* linguist-vendored
static/web/embed/chat/readwrite/* linguist-vendored
static/web/embed/video/* linguist-vendored
static/web/fonts/* linguist-vendored
static/web/fonts/inter/* linguist-vendored
static/web/styles/* linguist-vendored
static/web/styles/admin/* linguist-vendored
static/web/img/* linguist-vendored
static/web/img/favicon/* linguist-vendored
static/web/img/platformlogos/* linguist-vendored
static/img/* linguist-vendored
static/img/emoji/* linguist-vendored
static/img/emoji/dog/* linguist-vendored
static/img/emoji/conigliolo96/* linguist-vendored
static/img/emoji/mutant/* linguist-vendored
static/img/emoji/blob/* linguist-vendored
static/admin/* linguist-vendored
static/admin/logs/* linguist-vendored
static/admin/config-social-items/* linguist-vendored
static/admin/offline-notice/* linguist-vendored
static/admin/config-chat/* linguist-vendored
static/admin/404/* linguist-vendored
static/admin/_next/* linguist-vendored
static/admin/_next/static/* linguist-vendored
static/admin/_next/static/chunks/* linguist-vendored
static/admin/_next/static/chunks/pages/* linguist-vendored
static/admin/_next/static/chunks/pages/chat/* linguist-vendored
static/admin/_next/static/b1nOF3ZgELnezD8dvvt2B/* linguist-vendored
static/admin/_next/static/css/* linguist-vendored
static/admin/_next/quK9VwW_avTP773Ot9m2x/* linguist-vendored
static/admin/viewer-info/* linguist-vendored
static/admin/access-tokens/* linguist-vendored
static/admin/config-storage/* linguist-vendored
static/admin/config-public-details/* linguist-vendored
static/admin/config-server-details/* linguist-vendored
static/admin/actions/* linguist-vendored
static/admin/help/* linguist-vendored
static/admin/webhooks/* linguist-vendored
static/admin/chat/* linguist-vendored
static/admin/chat/messages/* linguist-vendored
static/admin/chat/users/* linguist-vendored
static/admin/upgrade/* linguist-vendored
static/admin/hardware-info/* linguist-vendored
static/admin/config-video/* linguist-vendored
webroot/js/web_modules/* linguist-vendored
webroot/js/web_modules/micromodal/* linguist-vendored
webroot/js/web_modules/micromodal/dist/* linguist-vendored
webroot/js/web_modules/common/* linguist-vendored
webroot/js/web_modules/@videojs/* linguist-vendored
webroot/js/web_modules/@videojs/http-streaming/* linguist-vendored
webroot/js/web_modules/@videojs/http-streaming/dist/* linguist-vendored
webroot/js/web_modules/@videojs/themes/* linguist-vendored
webroot/js/web_modules/@videojs/themes/fantasy/* linguist-vendored
webroot/js/web_modules/markjs/* linguist-vendored
webroot/js/web_modules/markjs/dist/* linguist-vendored
webroot/js/web_modules/@joeattardi/* linguist-vendored
webroot/js/web_modules/tailwindcss/* linguist-vendored
webroot/js/web_modules/tailwindcss/dist/* linguist-vendored
webroot/js/web_modules/videojs/* linguist-vendored

4
.github/ISSUE_TEMPLATE/bug-report-feature-request.md

@ -0,0 +1,4 @@ @@ -0,0 +1,4 @@
---
name: Bug report or feature request
about: Having problems or have ideas? We'd love to know what you think and help you out.
---

15
.github/ISSUE_TEMPLATE/bug-report-feature-request.yml

@ -1,15 +0,0 @@ @@ -1,15 +0,0 @@
name: Bug report or feature request
description: Submit a bug you encountered or share an idea you have for the project.
body:
- type: markdown
attributes:
value: |
Thanks for helping by reporting issues and sharing ideas you might have!
While no idea is a bad idea, some might make more sense for Owncast than others.
Take a look at the [Owncast product definition](https://github.com/owncast/owncast/blob/develop/docs/product-definition.md) to see what our focus is and how your requests might align.
- type: textarea
id: issue-body
attributes:
label: Share your bug report, feature request, or comment.
description: Please include as much detail as possible.

17
.github/PULL_REQUEST_TEMPLATE.md

@ -0,0 +1,17 @@ @@ -0,0 +1,17 @@
Please include a summary of the change and which issue number is fixed, including relevant motivation and context. Feel free to mark this as a Draft or WIP and write up some details later.
# Description
Fixes # (issue)
---
Some things you might want to mention:
1. Why are you making the change?
2. Explain how it works and decisions you made.
3. If you're fixing something, what was wrong? How should we stop from having this issue happen again?
4. If this is a new feature or addition to functionality, why should it be added? What are the use cases? Who was asking for this functionality?
If this is an unsolicited change or have no issue associated please do your best to detail the motivations behind this PR.

19
.github/PULL_REQUEST_TEMPLATE/standard-pull-request.md

@ -1,19 +0,0 @@ @@ -1,19 +0,0 @@
Please include a summary of the change and which issue number is fixed, including relevant motivation and context. Feel free to mark this as a Draft or WIP and write up some details later.
If there is no issue filed for this particular change it's highly recommended you file one. While creating this PR means you probably already did the work, in the future make sure an issue is filed beforehand so changes, fixes and features can be discussed ahead of time.
# Description
Fixes # (issue)
---
Some things you might want to mention:
1. Why are you making the change?
2. Explain how it works and decisions you made.
3. If you're fixing something, what was wrong? How should we stop from having this issue happen again?
4. If this is a new feature or addition to functionality, why should it be added? What are the use cases? Who was asking for this functionality?
If this is an unsolicited change or have no issue associated please do your best to detail the motivations behind this PR, and think about filing an issue to discuss changes ahead of time in the future.

1
.github/codeql/go.yml

@ -1 +0,0 @@ @@ -1 +0,0 @@
name: Go config

4
.github/codeql/javascript.yml

@ -1,4 +0,0 @@ @@ -1,4 +0,0 @@
name: Javascript config
paths-ignore:
- static/web

20
.github/workflows/actions-lint.yml

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

42
.github/workflows/auto-comment-on-label.yaml

@ -1,42 +0,0 @@ @@ -1,42 +0,0 @@
name: Add comment on good first issues
on:
issues:
types:
- labeled
jobs:
add-comment:
if: github.event.label.name == 'good first issue' || github.event.label.name == 'help wanted' || github.event.label.name == 'hacktoberfest'
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- name: Add comment
uses: peter-evans/create-or-update-comment@0f44b017d10caeea6a4c1b410ba0521ad8a02815
with:
issue-number: ${{ github.event.issue.number }}
body: |
## Good First Issue
This item was marked as a good first issue because of the following:
- It's self contained as a single feature or change.
- Is clear when it's complete.
- You do not need deep knowledge of Owncast to accomplish it.
### Next Steps
1. Comment on this issue before starting work so it can be assigned to you. Also, this issue may have been filed with limited detail or changes may have occurred that are worth sharing with you before you start work.
2. Drop by our [community chat](https://owncast.rocket.chat/) if you'd like to be involved in more real-time discussion around Owncast to talk about this change.
3. Follow the project's getting started tips to make sure you can [build and run the project from source](https://owncast.online/development).
### Notes
- Development takes place on the `develop` branch.
- We use Storybook for testing and developing React components. `npm run storybook`. A hosted version [is available for viewing](https://owncast.online/components).
- If you need to install the Go programming language to run the Owncast backend it's simple from [here](https://go.dev/dl/).
- Active contributors get an Owncast t-shirt! Ask about it if you feel like you've been contributing and haven't yet been given one.
### New to Git?
If you're brand new to Git you may want a short primer about the Fork -> Commit -> Pull Request workflow that enables changes to get made collaboratively using git. Visit the [First Contributions](https://github.com/firstcontributions/first-contributions) project to learn step-by-step how to commit a change to a Git repository such as this one.

19
.github/workflows/automated-browser.yml

@ -0,0 +1,19 @@ @@ -0,0 +1,19 @@
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

41
.github/workflows/automated-end-to-end-api.yaml

@ -3,40 +3,21 @@ name: Automated API tests @@ -3,40 +3,21 @@ name: Automated API tests
on:
push:
paths-ignore:
- 'web/**'
- 'webroot/**'
pull_request:
paths-ignore:
- 'web/**'
- 'webroot/**'
jobs:
test:
api:
runs-on: ubuntu-latest
steps:
- id: skip_check
uses: fkirc/skip-duplicate-actions@v5
with:
concurrent_skipping: 'same_content_newer'
- uses: earthly/actions-setup@v1
- uses: actions/checkout@v3
- uses: actions/setup-go@v3
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@v3
with:
image: tonistiigi/binfmt:latest
platforms: all
- uses: actions/checkout@v4
stable: 'false'
go-version: '1.17.2'
- name: Run API tests
uses: nick-fields/retry@v2
with:
timeout_minutes: 10
max_attempts: 3
command: earthly +api-tests
run: cd test/automated/api && ./run.sh

54
.github/workflows/browser-testing.yml

@ -1,54 +0,0 @@ @@ -1,54 +0,0 @@
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@v4
- uses: actions/setup-node@v4
with:
node-version: 18.9.0
- name: Cache node modules
uses: actions/cache@v3
env:
cache-name: cache-node-modules-browser-tests
with:
path: ~/.npm
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('test/automated/browser/package-lock.json') }}
restore-keys: |
${{ runner.os }}-build-${{ env.cache-name }}-
${{ runner.os }}-build-
${{ runner.os }}-
- uses: actions/setup-go@v5
with:
go-version: '1.21'
cache: true
- name: Install Google Chrome
run: sudo apt-get update && 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

54
.github/workflows/build-storybook.yml

@ -1,54 +0,0 @@ @@ -1,54 +0,0 @@
name: Build and Deploy Components+Style Guide
on:
push:
branches:
- develop
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@v4
- name: Cache node modules
uses: actions/cache@v3
env:
cache-name: cache-node-modules-bundle-web-app
with:
path: ~/.npm
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('web/package-lock.json') }}
restore-keys: |
${{ runner.os }}-build-${{ env.cache-name }}-
${{ runner.os }}-build-
${{ runner.os }}-
- 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

@ -0,0 +1,21 @@ @@ -0,0 +1,21 @@
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 }}

51
.github/workflows/chromatic.yml

@ -1,51 +0,0 @@ @@ -1,51 +0,0 @@
# .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@v4
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
projectToken: f47410569b62
onlyChanged: true

11
.github/workflows/codeql-analysis.yml

@ -16,6 +16,8 @@ on: @@ -16,6 +16,8 @@ on:
branches: [ develop ]
paths-ignore:
- 'static/**'
- 'webroot/js/web_modules/**'
- 'build/javascript/**'
pull_request:
# The branches below must be a subset of the branches above
branches: [ develop ]
@ -37,14 +39,13 @@ jobs: @@ -37,14 +39,13 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v3
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
uses: github/codeql-action/init@v1
with:
languages: ${{ matrix.language }}
config-file: ./.github/codeql/${{ matrix.language }}.yml
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
@ -53,7 +54,7 @@ jobs: @@ -53,7 +54,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v3
uses: github/codeql-action/autobuild@v1
# ℹ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
@ -67,4 +68,4 @@ jobs: @@ -67,4 +68,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
uses: github/codeql-action/analyze@v1

28
.github/workflows/container-lint.yml

@ -1,28 +0,0 @@ @@ -1,28 +0,0 @@
name: Lint
on:
push:
branches:
- develop
paths:
- 'Dockerfile'
pull_request:
branches:
- develop
paths:
- 'Dockerfile'
jobs:
trivy:
name: Dockerfile
runs-on: ubuntu-latest
container:
image: aquasec/trivy
steps:
- uses: actions/checkout@v4
- 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

56
.github/workflows/container.yaml

@ -1,56 +0,0 @@ @@ -1,56 +0,0 @@
# 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:
- develop
pull_request:
branches:
- develop
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@v3
with:
image: tonistiigi/binfmt:latest
platforms: all
- name: Checkout repo
uses: actions/checkout@v4
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: 'nightly'
EARTHLY_BUILD_BRANCH: 'develop'
EARTHLY_PUSH: true
uses: nick-fields/retry@v2
with:
timeout_minutes: 20
max_attempts: 3
command: ./build/develop/container.sh

24
.github/workflows/docker-nightly.yaml

@ -0,0 +1,24 @@ @@ -0,0 +1,24 @@
name: Build nightly docker
on:
workflow_dispatch:
schedule:
- cron: "0 2 * * *"
jobs:
Docker:
runs-on: ubuntu-latest
steps:
- 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
- uses: actions/checkout@v3
- name: Setup and run
env:
GH_CR_PAT: ${{ secrets.GH_CR_PAT }}
run: cd build/release && ./docker-nightly.sh
if: env.GH_CR_PAT != null

2
.github/workflows/generate-api-documentation.yaml

@ -10,7 +10,7 @@ jobs: @@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
uses: actions/checkout@v3
- name: Run redoc on openapi.yaml
run: |

38
.github/workflows/go-lint.yml

@ -1,38 +0,0 @@ @@ -1,38 +0,0 @@
name: Lint
on:
push:
paths-ignore:
- 'web/**'
pull_request:
paths-ignore:
- 'web/**'
permissions:
contents: read
jobs:
golangci:
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@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version: '1.21'
cache: true
- uses: actions/checkout@v4
- name: golangci-lint
uses: golangci/golangci-lint-action@v3
with:
only-new-issues: true
args: --timeout=3m

68
.github/workflows/go-tests.yaml

@ -1,68 +0,0 @@ @@ -1,68 +0,0 @@
name: Go Tests
on:
push:
paths-ignore:
- 'web/**'
pull_request:
paths-ignore:
- 'web/**'
jobs:
test:
strategy:
matrix:
go-version: [1.20.x, 1.21.x]
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/cache@v3
with:
path: |
~/.cache/go-build
~/go/pkg/mod
key: go-test-${{ github.sha }}
restore-keys: |
go-test-
- name: Install go
uses: actions/setup-go@v5
with:
go-version: '^1'
cache: true
- name: Run tests
run: go test ./...
test-bsds:
runs-on: macos-latest
strategy:
matrix:
os:
- name: freebsd
version: 12.2
- name: openbsd
version: 6.8
steps:
- uses: actions/checkout@v4
- uses: actions/cache@v3
with:
path: |
~/.cache/go-build
~/go/pkg/mod
key: go-test-${{ github.sha }}
restore-keys: |
go-test-
- name: Install go
uses: actions/setup-go@v5
with:
go-version: '^1'
cache: true
- name: Run tests
run: go test ./...

51
.github/workflows/hls-tests.yml

@ -1,13 +1,13 @@ @@ -1,13 +1,13 @@
name: HLS tests
name: Automated HLS tests
on:
push:
paths-ignore:
- 'web/**'
- 'webroot/**'
pull_request:
paths-ignore:
- 'web/**'
- 'webroot/**'
env:
S3_BUCKET: ${{ secrets.S3_BUCKET }}
S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }}
@ -16,42 +16,15 @@ env: @@ -16,42 +16,15 @@ env:
S3_SECRET: ${{ secrets.S3_SECRET }}
jobs:
tests:
api:
runs-on: ubuntu-latest
steps:
- id: skip_check
uses: fkirc/skip-duplicate-actions@v5
with:
concurrent_skipping: 'same_content_newer'
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.21'
cache: true
- name: Cache node modules
uses: actions/cache@v3
env:
cache-name: cache-node-modules-hls-tests
with:
path: ~/.npm
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('test/automated/hls/package-lock.json') }}
restore-keys: |
${{ runner.os }}-build-${{ env.cache-name }}-
${{ runner.os }}-build-
${{ runner.os }}-
- name: Local stroage
uses: nick-fields/retry@v2
- uses: actions/checkout@v3
- uses: actions/setup-go@v3
with:
timeout_minutes: 10
max_attempts: 3
command: cd test/automated/hls && ./run.sh
stable: 'false'
go-version: '1.17.2'
- name: Run HLS tests
run: cd test/automated/hls && ./run.sh
- name: S3 storage
uses: nick-fields/retry@v2
with:
timeout_minutes: 10
max_attempts: 3
command: cd test/automated/hls && ./run-s3.sh

185
.github/workflows/javascript-format-build.yml

@ -1,185 +0,0 @@ @@ -1,185 +0,0 @@
name: Javascript
# This action works with pull requests and pushes
on:
push:
paths:
- web/**
- '!**.md'
pull_request:
paths:
- web/**
- '!**.md'
jobs:
formatting:
name: Code formatting
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./web
steps:
- id: skip_check
uses: fkirc/skip-duplicate-actions@v5
with:
concurrent_skipping: 'same_content_newer'
cancel_others: 'true'
skip_after_successful_duplicate: 'true'
- name: Checkout
uses: actions/checkout@v4
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
persist-credentials: true
- name: Get changed files
id: changed-files-yaml
uses: tj-actions/changed-files@v41
with:
path: 'web'
files_ignore: |
static/**
web/next.config.js
files_yaml: |
src:
- '**/*.{js,ts,tsx,jsx,css,md}'
- name: Cache node modules
uses: actions/cache@v3
env:
cache-name: cache-node-modules-bundle-web-app
with:
path: ~/.npm
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('web/package-lock.json') }}
restore-keys: |
${{ runner.os }}-build-${{ env.cache-name }}-
${{ runner.os }}-build-
${{ runner.os }}-
- name: Install Dependencies
run: npm install
- name: Lint
if: steps.changed-files-yaml.outputs.src_any_changed == 'true'
run: npx eslint --fix ${{ steps.changed-files-yaml.outputs.src_all_changed_files }}
- name: Prettier
if: steps.changed-files-yaml.outputs.src_any_changed == 'true'
run: npx prettier --write ${{ steps.changed-files-yaml.outputs.src_all_changed_files }}
- name: Commit changes
if: steps.changed-files-yaml.outputs.src_any_changed == 'true'
uses: EndBug/add-and-commit@v9
with:
author_name: Owncast
author_email: owncast@owncast.online
message: 'Javascript formatting autofixes'
add: ${{ steps.changed-files-yaml.outputs.src_all_changed_files }}
pull: '--rebase --autostash'
unused-code:
name: Test for unused code
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./web
steps:
- id: skip_check
uses: fkirc/skip-duplicate-actions@v5
with:
concurrent_skipping: 'same_content_newer'
cancel_others: 'true'
skip_after_successful_duplicate: 'true'
- name: Checkout
uses: actions/checkout@v4
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: Cache node modules
uses: actions/cache@v3
env:
cache-name: cache-node-modules-bundle-web-app
with:
path: ~/.npm
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('web/package-lock.json') }}
restore-keys: |
${{ runner.os }}-build-${{ env.cache-name }}-
${{ runner.os }}-build-
${{ runner.os }}-
- name: Install Dependencies
run: npm install
- name: Check for unused JS code and dependencies
run: npx knip --include dependencies,files,exports
# After any formatting and linting is complete we can run the build
# and bundle step. This both will verify that the build is successful as
# well as commiting the updated static files into the repository for use.
web-bundle:
name: Build and bundle web project
runs-on: ubuntu-latest
if: github.repository == 'owncast/owncast'
needs: [formatting, unused-code]
steps:
- id: skip_check
uses: fkirc/skip-duplicate-actions@v5
with:
concurrent_skipping: 'same_content_newer'
cancel_others: 'true'
skip_after_successful_duplicate: 'true'
- name: Cache node modules
uses: actions/cache@v3
env:
cache-name: cache-node-modules-bundle-web-app
with:
path: ~/.npm
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('web/package-lock.json') }}
restore-keys: |
${{ runner.os }}-build-${{ env.cache-name }}-
${{ runner.os }}-build-
${{ runner.os }}-
- name: Checkout
uses: actions/checkout@v4
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: Bundle web app (next.js build)
run: build/web/bundleWeb.sh
- name: Rebase
if: ${{ github.ref == 'refs/heads/develop' }}
run: |
git add static/web
git pull --rebase --autostash
# Only commit built web project files on develop.
- name: Commit changes
if: ${{ github.ref == 'refs/heads/develop' }}
uses: EndBug/add-and-commit@v9
with:
message: 'Bundle embedded web app'
add: 'static/web'
author_name: Owncast
author_email: owncast@owncast.online
- name: Push changes
if: ${{ github.ref == 'refs/heads/develop' }}
run: |
git pull --rebase --autostash
git push

29
.github/workflows/javascript-formatting.yml

@ -0,0 +1,29 @@ @@ -0,0 +1,29 @@
name: Format Javascript
# This action works with pull requests and pushes
on:
push:
branches:
- develop
jobs:
prettier:
runs-on: ubuntu-latest
if: ${{ github.actor != 'dependabot[bot]' }}
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
- 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 }}

33
.github/workflows/javascript-packages.yaml

@ -0,0 +1,33 @@ @@ -0,0 +1,33 @@
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 }}

45
.github/workflows/javascript-tests.yml

@ -1,45 +0,0 @@ @@ -1,45 +0,0 @@
name: Javascript Tests
on:
push:
paths:
- 'web/**'
pull_request:
paths:
- 'web/**'
jobs:
jest-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@v4
- uses: actions/setup-node@v4
with:
node-version: 18.9.0
- name: Cache node modules
uses: actions/cache@v3
env:
cache-name: cache-node-modules-javascript-tests
with:
path: ~/.npm
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('web/package-lock.json') }}
restore-keys: |
${{ runner.os }}-build-${{ env.cache-name }}-
${{ runner.os }}-build-
${{ runner.os }}-
- name: Install Dependencies
working-directory: ./web
run: npm install
- name: Run tests
working-directory: ./web
run: npm test

24
.github/workflows/lint.yml

@ -0,0 +1,24 @@ @@ -0,0 +1,24 @@
name: lint
on:
push:
pull_request:
permissions:
contents: read
jobs:
golangci:
name: Go linter
if: ${{ github.actor != 'dependabot[bot]' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- uses: actions/setup-go@v3
- uses: actions/checkout@v3
- name: golangci-lint
uses: golangci/golangci-lint-action@v2
with:
only-new-issues: true

58
.github/workflows/screenshots.yml

@ -1,58 +0,0 @@ @@ -1,58 +0,0 @@
name: Take nightly screenshots
on:
schedule:
- cron: '0 4 * * *'
env:
BROWSERSTACK_KEY: ${{ secrets.BROWSERSTACK_KEY }}
BROWSERSTACK_PASSWORD: ${{ secrets.BROWSERSTACK_PASSWORD }}
BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }}
TEST_URL: http://localhost:8080
jobs:
Screenshots:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.21'
cache: true
- name: Cache node modules
uses: actions/cache@v3
env:
cache-name: cache-node-modules-screenshots
with:
path: ~/.npm
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('test/automated/screenshots/package-lock.json') }}
restore-keys: |
${{ runner.os }}-build-${{ env.cache-name }}-
${{ runner.os }}-build-
${{ runner.os }}-
- name: Automate screenshots
uses: nick-fields/retry@v2
with:
timeout_minutes: 10
max_attempts: 4
command: cd test/automated/screenshots && ./run.sh
- name: Commit changes
uses: EndBug/add-and-commit@v9
with:
author_name: Owncast
author_email: owncast@owncast.online
message: 'Commit screenshots'
add: '*.png'
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

30
.github/workflows/shellcheck.yml

@ -1,30 +0,0 @@ @@ -1,30 +0,0 @@
name: Lint
on:
push:
branches:
- develop
paths:
- '**.sh'
pull_request:
branches:
- develop
paths:
- '**.sh'
jobs:
shellcheck:
runs-on: ubuntu-latest
env:
LANG: C.UTF-8
container:
image: docker.io/ubuntu:24.04
steps:
- uses: actions/checkout@v4
- 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

42
.github/workflows/test.yaml

@ -0,0 +1,42 @@ @@ -0,0 +1,42 @@
name: Tests
on: [push, pull_request]
jobs:
test:
strategy:
matrix:
go-version: [1.16.x, 1.17.x]
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v3
- name: Install go
uses: actions/setup-go@v3
with:
go-version: "^1"
- name: Run tests
run: go test ./...
test-bsds:
runs-on: macos-10.15
strategy:
matrix:
os:
- name: freebsd
version: 12.2
- name: openbsd
version: 6.8
steps:
- uses: actions/checkout@v3
- name: Install go
uses: actions/setup-go@v3
with:
go-version: "^1"
- name: Run tests
run: go test ./...

7
.gitignore vendored

@ -27,7 +27,6 @@ webroot/preview.gif @@ -27,7 +27,6 @@ webroot/preview.gif
webroot/hls
webroot/static/content.md
hls/
!test/automated/hls/
dist/
data/
transcoder.log
@ -40,9 +39,3 @@ backup/ @@ -40,9 +39,3 @@ backup/
test/test.db
test/automated/browser/screenshots
lefthook.yml
test/automated/browser/cypress/screenshots
test/automated/browser/cypress/videos
web/style-definitions/build/
web/public/sw.js
web/public/workbox-*.js

5
.gitpod.yml

@ -1,5 +0,0 @@ @@ -1,5 +0,0 @@
# Automatic workspace preparation for gitpod instances
tasks:
- init: sudo apt-get install ffmpeg -y && go get && go build ./... && go test ./...
command: go run .

17
.golangci.yml

@ -4,8 +4,8 @@ run: @@ -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.18
go: '1.21'
# Default: use Go version from the go.mod file, fallback on the env var `GOVERSION`, fallback on 1.17
go: '1.17'
issues:
# The linter has a default list of ignorable errors. Turning this on will enable that list.
@ -48,7 +48,9 @@ linters: @@ -48,7 +48,9 @@ linters:
- nakedret
- cyclop
- gosimple
- varcheck
- unused
- deadcode
- exportloopref
- gocritic
- forbidigo
@ -69,24 +71,19 @@ linters-settings: @@ -69,24 +71,19 @@ linters-settings:
gosimple:
# Select the Go version to target. The default is '1.13'.
go: '1.21'
go: "1.17"
# https://staticcheck.io/docs/options#checks
checks: ['all']
checks: ["all"]
gocritic:
disabled-checks:
- ifElseChain
- exitAfterDefer
revive:
rules:
- name: package-comments
disabled: true
forbidigo:
# Forbid the following identifiers (identifiers are written using regexp):
forbid:
# Logging via Print bypasses our logging framework.
# Logging via Print bypasses our logging framework.
- ^(fmt\.Print(|f|ln)|print|println)
- ^panic.*$

1
.prettierignore

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

28
.vscode/settings.json vendored

@ -0,0 +1,28 @@ @@ -0,0 +1,28 @@
{
"cSpell.words": [
"Debugln",
"Errorln",
"Fediverse",
"Ffmpeg",
"ffmpegpath",
"ffmpg",
"geoip",
"gosec",
"mattn",
"Mbps",
"nolint",
"Owncast",
"ppid",
"preact",
"RTMP",
"rtmpserverport",
"sqlite",
"Tracef",
"Traceln",
"upgrader",
"Upgrader",
"videojs",
"Warnf",
"Warnln"
]
}

22
Dockerfile

@ -1,16 +1,9 @@ @@ -1,16 +1,9 @@
# 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.
# Perform a build
FROM golang:alpine AS build
RUN apk update && apk add --no-cache git gcc build-base linux-headers
RUN mkdir /build
ADD . /build
WORKDIR /build
COPY . /build
RUN apk update && apk add --no-cache git gcc build-base linux-headers
ARG VERSION=dev
ENV VERSION=${VERSION}
@ -22,16 +15,13 @@ ENV NAME=${NAME} @@ -22,16 +15,13 @@ ENV NAME=${NAME}
RUN CGO_ENABLED=1 GOOS=linux go build -a -installsuffix cgo -ldflags "-extldflags \"-static\" -s -w -X github.com/owncast/owncast/config.GitCommit=$GIT_COMMIT -X github.com/owncast/owncast/config.VersionNumber=$VERSION -X github.com/owncast/owncast/config.BuildPlatform=$NAME" -o owncast .
# Create the image by copying the result of the build into a new alpine image
FROM alpine:3.19.0
FROM alpine
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

168
Earthfile

@ -1,168 +0,0 @@ @@ -1,168 +0,0 @@
VERSION --new-platform 0.6
FROM --platform=linux/amd64 alpine:3.15.5
ARG version=develop
WORKDIR /build
build-all:
BUILD --platform=linux/amd64 --platform=linux/386 --platform=linux/arm64 --platform=linux/arm/v7 --platform=darwin/amd64 --platform=darwin/arm64 +build
package-all:
BUILD --platform=linux/amd64 --platform=linux/386 --platform=linux/arm64 --platform=linux/arm/v7 --platform=darwin/amd64 --platform=darwin/arm64 +package
docker-all:
BUILD --platform=linux/amd64 --platform=linux/386 --platform=linux/arm64 --platform=linux/arm/v7 +docker
crosscompiler:
# This image is missing a few platforms, so we'll add them locally
FROM --platform=linux/amd64 bdwyertech/go-crosscompile
RUN apk add --update --no-cache tar gzip upx >> /dev/null
RUN curl -sfL "https://owncast-infra.nyc3.cdn.digitaloceanspaces.com/build/armv7l-linux-musleabihf-cross.tgz" | tar zxf - -C /usr/ --strip-components=1
RUN curl -sfL "https://owncast-infra.nyc3.cdn.digitaloceanspaces.com/build/i686-linux-musl-cross.tgz" | tar zxf - -C /usr/ --strip-components=1
RUN curl -sfL "https://owncast-infra.nyc3.cdn.digitaloceanspaces.com/build/x86_64-linux-musl-cross.tgz" | tar zxf - -C /usr/ --strip-components=1
code:
FROM --platform=linux/amd64 +crosscompiler
COPY . /build
build:
ARG EARTHLY_GIT_HASH # provided by Earthly
ARG TARGETPLATFORM # provided by Earthly
ARG TARGETOS # provided by Earthly
ARG TARGETARCH # provided by Earthly
ARG GOOS=$TARGETOS
ARG GOARCH=$TARGETARCH
FROM --platform=linux/amd64 +code
RUN echo "Finding CC configuration for $TARGETPLATFORM"
IF [ "$TARGETPLATFORM" = "linux/amd64" ]
ARG NAME=linux-64bit
ARG CC=x86_64-linux-musl-gcc
ARG CXX=x86_64-linux-musl-g++
ELSE IF [ "$TARGETPLATFORM" = "linux/386" ]
ARG NAME=linux-32bit
ARG CC=i686-linux-musl-gcc
ARG CXX=i686-linux-musl-g++
ELSE IF [ "$TARGETPLATFORM" = "linux/arm64" ]
ARG NAME=linux-arm64
ARG CC=aarch64-linux-musl-gcc
ARG CXX=aarch64-linux-musl-g++
ELSE IF [ "$TARGETPLATFORM" = "linux/arm/v7" ]
ARG NAME=linux-arm7
ARG CC=armv7l-linux-musleabihf-gcc
ARG CXX=armv7l-linux-musleabihf-g++
ARG GOARM=7
ELSE IF [ "$TARGETPLATFORM" = "darwin/amd64" ]
ARG NAME=macOS-64bit
ARG CC=o64-clang
ARG CXX=o64-clang++
ELSE IF [ "$TARGETPLATFORM" = "darwin/arm64" ]
ARG NAME=macOS-arm64
ARG CC=o64-clang
ARG CXX=o64-clang++
ELSE
RUN echo "Failed to find CC configuration for $TARGETPLATFORM"
ARG --required CC
ARG --required CXX
END
ENV CGO_ENABLED=1
ENV GOOS=$GOOS
ENV GOARCH=$GOARCH
ENV GOARM=$GOARM
ENV CC=$CC
ENV CXX=$CXX
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
# Decrease the size of the shipped binary. But only for non-Apple platforms.
# See https://github.com/upx/upx/issues/612
IF [ "$GOOS" != "darwin" ]
RUN upx --best --lzma owncast
# Test the binary
RUN upx -t owncast
END
SAVE ARTIFACT owncast owncast
package:
RUN apk add --update --no-cache zip >> /dev/null
ARG TARGETPLATFORM # provided by Earthly
IF [ "$TARGETPLATFORM" = "linux/amd64" ]
ARG NAME=linux-64bit
ELSE IF [ "$TARGETPLATFORM" = "linux/386" ]
ARG NAME=linux-32bit
ELSE IF [ "$TARGETPLATFORM" = "linux/arm64" ]
ARG NAME=linux-arm64
ELSE IF [ "$TARGETPLATFORM" = "linux/arm/v7" ]
ARG NAME=linux-arm7
ELSE IF [ "$TARGETPLATFORM" = "darwin/amd64" ]
ARG NAME=macOS-64bit
ELSE IF [ "$TARGETPLATFORM" = "darwin/arm64" ]
ARG NAME=macOS-arm64
ELSE
ARG NAME=custom
END
COPY (+build/owncast --platform $TARGETPLATFORM) /build/dist/owncast
ENV ZIPNAME owncast-$version-$NAME.zip
RUN cd /build/dist && zip -r -q -8 /build/dist/owncast.zip .
SAVE ARTIFACT --keep-ts /build/dist/owncast.zip owncast.zip AS LOCAL dist/$ZIPNAME
docker:
# 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
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 .
unit-tests:
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

2
LICENSE

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
MIT License
Copyright (c) 2020-2023 Gabe Kangas
Copyright (c) 2020 Gabe Kangas
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

66
README.md

@ -10,7 +10,7 @@ @@ -10,7 +10,7 @@
<p align="center">
<strong>Take control over your content and stream it yourself.</strong>
<br />
<a href="https://owncast.online"><strong>Explore the docs »</strong></a>
<a href="http://owncast.online"><strong>Explore the docs »</strong></a>
<br />
<a href="https://watch.owncast.online/">View Demo</a>
·
@ -44,13 +44,11 @@ @@ -44,13 +44,11 @@
</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">
<a href="https://hub.docker.com/r/owncast/owncast">
<img alt="Docker Pulls" src="https://img.shields.io/docker/pulls/owncast/owncast?style=for-the-badge">
</a>
<img alt="Docker Pulls" src="https://img.shields.io/docker/pulls/gabekangas/owncast?style=for-the-badge">
<a href="https://github.com/owncast/owncast/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22">
<img alt="GitHub issues by-label" src="https://img.shields.io/github/issues-raw/owncast/owncast/good%20first%20issue?style=for-the-badge">
</a>
@ -59,6 +57,7 @@ Owncast is an open source, self-hosted, decentralized, single user live video st @@ -59,6 +57,7 @@ Owncast is an open source, self-hosted, decentralized, single user live video st
</a>
</div>
---
<!-- GETTING STARTED -->
@ -69,45 +68,29 @@ The goal is to have a single service that you can run and it works out of the bo @@ -69,45 +68,29 @@ The goal is to have a single service that you can run and it works out of the bo
## Use with your existing broadcasting software
In general, Owncast is compatible with any software that uses `RTMP` to broadcast to a remote server. `RTMP` is what all the major live streaming services use, so if you’re currently using one of those it’s likely that you can point your existing software at your Owncast instance instead.
In general Owncast is compatible with any software that uses `RTMP` to broadcast to a remote server. `RTMP` is what all the major live streaming services use, so if you’re currently using one of those it’s likely that you can point your existing software at your Owncast instance instead.
OBS, Streamlabs, Restream and many others have been used with Owncast. [Read more about compatibility with existing software](https://owncast.online/docs/broadcasting/).
## Building from Source
Owncast consists of two projects.
1. The Owncast backend is written in Go.
1. The frontend is 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.
> Note: Currently Owncast does not natively support Windows servers. However, Windows Users can use Windows Subsystem for Linux (WSL2) to install Owncast. For details visit [this document](https://github.com/owncast/owncast/blob/develop/contrib/owncast_for_windows.md).
### Backend
The Owncast backend is a service written in Go.
1. Ensure you have prerequisites 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.21 or above).
1. Ensure you have the gcc compiler installed.
1. Install the [Go toolchain](https://golang.org/dl/) (1.16 or above).
1. Clone the repo. `git clone https://github.com/owncast/owncast`
1. `go run main.go` will run from the source.
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.
### Frontend
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/).
The frontend is the web interface that includes the player, chat, embed components, and other UI.
### Bundling in latest admin from source
1. This project lives in the `web` directory.
1. Run `npm install` to install the Javascript dependencies.
1. Run `npm run dev`
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`
## Contributing
@ -119,22 +102,29 @@ We’ve been very lucky to have this so far, so maybe you can help us with your @@ -119,22 +102,29 @@ 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
Distributed under the MIT License. See `LICENSE` for more information.
## Supported by
- This project is tested with [BrowserStack](https://browserstack.com).
<!-- CONTACT -->
## Contact
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@social.gabekangas.com](https://social.gabekangas.com/gabek) - email [gabek@real-ity.com](mailto:gabek@real-ity.com)
Gabe Kangas - [@gabek@fosstodon.org](https://fosstodon.org/@gabek) - email [gabek@real-ity.com](mailto:gabek@real-ity.com)
Project Link: [https://github.com/owncast/owncast](https://github.com/owncast/owncast)

5
activitypub/activitypub.go

@ -40,11 +40,6 @@ func SendPublicFederatedMessage(message string) error { @@ -40,11 +40,6 @@ func SendPublicFederatedMessage(message string) error {
return outbox.SendPublicMessage(message)
}
// SendDirectFederatedMessage will send a direct message to a single account.
func SendDirectFederatedMessage(message, account string) error {
return outbox.SendDirectMessageToAccount(message, account)
}
// GetFollowerCount will return the local tracked follower count.
func GetFollowerCount() (int64, error) {
return persistence.GetFollowerCount()

99
activitypub/apmodels/activity.go

@ -17,76 +17,13 @@ const ( @@ -17,76 +17,13 @@ const (
PUBLIC PrivacyAudience = "https://www.w3.org/ns/activitystreams#Public"
)
// MakeNotePublic ses the required proeprties to make this note seen as public.
func MakeNotePublic(note vocab.ActivityStreamsNote) vocab.ActivityStreamsNote {
public, _ := url.Parse(PUBLIC)
to := streams.NewActivityStreamsToProperty()
to.AppendIRI(public)
note.SetActivityStreamsTo(to)
audience := streams.NewActivityStreamsAudienceProperty()
audience.AppendIRI(public)
note.SetActivityStreamsAudience(audience)
return note
}
// MakeNoteDirect sets the required properties to make this note seen as a
// direct message.
func MakeNoteDirect(note vocab.ActivityStreamsNote, toIRI *url.URL) vocab.ActivityStreamsNote {
to := streams.NewActivityStreamsCcProperty()
to.AppendIRI(toIRI)
to.AppendIRI(toIRI)
note.SetActivityStreamsCc(to)
// Mastodon requires a tag with a type of "mention" and href of the account
// for a message to be a "Direct Message".
tagProperty := streams.NewActivityStreamsTagProperty()
tag := streams.NewTootHashtag()
tagTypeProperty := streams.NewJSONLDTypeProperty()
tagTypeProperty.AppendXMLSchemaString("Mention")
tag.SetJSONLDType(tagTypeProperty)
tagHrefProperty := streams.NewActivityStreamsHrefProperty()
tagHrefProperty.Set(toIRI)
tag.SetActivityStreamsHref(tagHrefProperty)
tagProperty.AppendTootHashtag(tag)
tagProperty.AppendTootHashtag(tag)
note.SetActivityStreamsTag(tagProperty)
return note
}
// MakeActivityDirect sets the required properties to make this activity seen
// as a direct message.
func MakeActivityDirect(activity vocab.ActivityStreamsCreate, toIRI *url.URL) vocab.ActivityStreamsCreate {
to := streams.NewActivityStreamsCcProperty()
to.AppendIRI(toIRI)
to.AppendIRI(toIRI)
activity.SetActivityStreamsCc(to)
// Mastodon requires a tag with a type of "mention" and href of the account
// for a message to be a "Direct Message".
tagProperty := streams.NewActivityStreamsTagProperty()
tag := streams.NewTootHashtag()
tagTypeProperty := streams.NewJSONLDTypeProperty()
tagTypeProperty.AppendXMLSchemaString("Mention")
tag.SetJSONLDType(tagTypeProperty)
tagHrefProperty := streams.NewActivityStreamsHrefProperty()
tagHrefProperty.Set(toIRI)
tag.SetActivityStreamsHref(tagHrefProperty)
tagProperty.AppendTootHashtag(tag)
tagProperty.AppendTootHashtag(tag)
activity.SetActivityStreamsTag(tagProperty)
return activity
}
// MakeCreateActivity will return a new Create activity with the provided ID.
func MakeCreateActivity(activityID *url.URL) vocab.ActivityStreamsCreate {
activity := streams.NewActivityStreamsCreate()
id := streams.NewJSONLDIdProperty()
id.Set(activityID)
activity.SetJSONLDId(id)
// MakeActivityPublic sets the required properties to make this activity
// seen as public.
func MakeActivityPublic(activity vocab.ActivityStreamsCreate) vocab.ActivityStreamsCreate {
// TO the public if we're not treating ActivityPub as "private".
if !data.GetFederationIsPrivate() {
public, _ := url.Parse(PUBLIC)
@ -103,16 +40,6 @@ func MakeActivityPublic(activity vocab.ActivityStreamsCreate) vocab.ActivityStre @@ -103,16 +40,6 @@ func MakeActivityPublic(activity vocab.ActivityStreamsCreate) vocab.ActivityStre
return activity
}
// MakeCreateActivity will return a new Create activity with the provided ID.
func MakeCreateActivity(activityID *url.URL) vocab.ActivityStreamsCreate {
activity := streams.NewActivityStreamsCreate()
id := streams.NewJSONLDIdProperty()
id.Set(activityID)
activity.SetJSONLDId(id)
return activity
}
// MakeUpdateActivity will return a new Update activity with the provided aID.
func MakeUpdateActivity(activityID *url.URL) vocab.ActivityStreamsUpdate {
activity := streams.NewActivityStreamsUpdate()
@ -134,11 +61,9 @@ func MakeUpdateActivity(activityID *url.URL) vocab.ActivityStreamsUpdate { @@ -134,11 +61,9 @@ func MakeUpdateActivity(activityID *url.URL) vocab.ActivityStreamsUpdate {
// MakeNote will return a new Note object.
func MakeNote(text string, noteIRI *url.URL, attributedToIRI *url.URL) vocab.ActivityStreamsNote {
note := streams.NewActivityStreamsNote()
content := streams.NewActivityStreamsContentProperty()
content.AppendXMLSchemaString(text)
note.SetActivityStreamsContent(content)
id := streams.NewJSONLDIdProperty()
id.Set(noteIRI)
note.SetJSONLDId(id)
@ -152,5 +77,17 @@ func MakeNote(text string, noteIRI *url.URL, attributedToIRI *url.URL) vocab.Act @@ -152,5 +77,17 @@ func MakeNote(text string, noteIRI *url.URL, attributedToIRI *url.URL) vocab.Act
attr.AppendIRI(attributedTo)
note.SetActivityStreamsAttributedTo(attr)
// To the public if we're not treating ActivityPub as "private".
if !data.GetFederationIsPrivate() {
public, _ := url.Parse(PUBLIC)
to := streams.NewActivityStreamsToProperty()
to.AppendIRI(public)
note.SetActivityStreamsTo(to)
audience := streams.NewActivityStreamsAudienceProperty()
audience.AppendIRI(public)
note.SetActivityStreamsAudience(audience)
}
return note
}

16
activitypub/apmodels/actor.go

@ -16,26 +16,26 @@ import ( @@ -16,26 +16,26 @@ import (
// ActivityPubActor represents a single actor in handling ActivityPub activity.
type ActivityPubActor struct {
// RequestObject is the actual follow request object.
RequestObject vocab.ActivityStreamsFollow
// W3IDSecurityV1PublicKey is the public key of the actor.
W3IDSecurityV1PublicKey vocab.W3IDSecurityV1PublicKeyProperty
// ActorIRI is the IRI of the remote actor.
ActorIri *url.URL
// FollowRequestIRI is the unique identifier of the follow request.
FollowRequestIri *url.URL
// Inbox is the inbox URL of the remote follower
Inbox *url.URL
// Image is the avatar image of the Actor.
Image *url.URL
// DisabledAt is the time, if any, this follower was blocked/removed.
DisabledAt *time.Time
// Name is the display name of the follower.
Name string
// Username is the account username of the remote actor.
Username string
// FullUsername is the username@account.tld representation of the user.
FullUsername string
// Image is the avatar image of the Actor.
Image *url.URL
// RequestObject is the actual follow request object.
RequestObject vocab.ActivityStreamsFollow
// W3IDSecurityV1PublicKey is the public key of the actor.
W3IDSecurityV1PublicKey vocab.W3IDSecurityV1PublicKeyProperty
// DisabledAt is the time, if any, this follower was blocked/removed.
DisabledAt *time.Time
}
// DeleteRequest represents a request for delete.

4
activitypub/apmodels/actor_test.go

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

10
activitypub/apmodels/message.go

@ -20,7 +20,7 @@ func CreateCreateActivity(id string, localAccountIRI *url.URL) vocab.ActivityStr @@ -20,7 +20,7 @@ func CreateCreateActivity(id string, localAccountIRI *url.URL) vocab.ActivityStr
}
// AddImageAttachmentToNote will add the provided image URL to the provided note object.
func AddImageAttachmentToNote(note vocab.ActivityStreamsNote, image, mediaType string) {
func AddImageAttachmentToNote(note vocab.ActivityStreamsNote, image string) {
imageURL, err := url.Parse(image)
if err != nil {
return
@ -40,13 +40,9 @@ func AddImageAttachmentToNote(note vocab.ActivityStreamsNote, image, mediaType s @@ -40,13 +40,9 @@ func AddImageAttachmentToNote(note vocab.ActivityStreamsNote, image, mediaType s
imageProp := streams.NewActivityStreamsImageProperty()
imageProp.AppendActivityStreamsImage(apImage)
imageDescription := streams.NewActivityStreamsNameProperty()
imageDescription := streams.NewActivityStreamsContentProperty()
imageDescription.AppendXMLSchemaString("Live stream preview")
apImage.SetActivityStreamsName(imageDescription)
mediaTypeProperty := streams.NewActivityStreamsMediaTypeProperty()
mediaTypeProperty.Set(mediaType)
apImage.SetActivityStreamsMediaType(mediaTypeProperty)
apImage.SetActivityStreamsContent(imageDescription)
attachments.AppendActivityStreamsImage(apImage)

48
activitypub/apmodels/utils.go

@ -4,7 +4,6 @@ import ( @@ -4,7 +4,6 @@ import (
"encoding/json"
"net/url"
"path"
"path/filepath"
"github.com/go-fed/activity/streams"
"github.com/go-fed/activity/streams/vocab"
@ -61,50 +60,3 @@ func Serialize(obj vocab.Type) ([]byte, error) { @@ -61,50 +60,3 @@ func Serialize(obj vocab.Type) ([]byte, error) {
return b, err
}
// MakeLocalIRIForStreamURL will return a full IRI for the local server stream url.
func MakeLocalIRIForStreamURL() *url.URL {
host := data.GetServerURL()
u, err := url.Parse(host)
if err != nil {
log.Errorln("unable to parse local IRI stream url", err)
return nil
}
u.Path = path.Join(u.Path, "/hls/stream.m3u8")
return u
}
// MakeLocalIRIforLogo will return a full IRI for the local server logo.
func MakeLocalIRIforLogo() *url.URL {
host := data.GetServerURL()
u, err := url.Parse(host)
if err != nil {
log.Errorln("unable to parse local IRI stream url", err)
return nil
}
u.Path = path.Join(u.Path, "/logo/external")
return u
}
// GetLogoType will return the rel value for the webfinger response and
// the default static image is of type png.
func GetLogoType() string {
imageFilename := data.GetLogoPath()
if imageFilename == "" {
return "image/png"
}
logoType := "image/jpeg"
if filepath.Ext(imageFilename) == ".svg" {
logoType = "image/svg+xml"
} else if filepath.Ext(imageFilename) == ".gif" {
logoType = "image/gif"
} else if filepath.Ext(imageFilename) == ".png" {
logoType = "image/png"
}
return logoType
}

34
activitypub/apmodels/webfinger.go

@ -11,11 +11,6 @@ type WebfingerResponse struct { @@ -11,11 +11,6 @@ type WebfingerResponse struct {
Links []Link `json:"links"`
}
// WebfingerProfileRequestResponse represents a Webfinger profile request response.
type WebfingerProfileRequestResponse struct {
Self string
}
// Link represents a Webfinger response Link entity.
type Link struct {
Rel string `json:"rel"`
@ -26,9 +21,7 @@ type Link struct { @@ -26,9 +21,7 @@ type Link struct {
// MakeWebfingerResponse will create a new Webfinger response.
func MakeWebfingerResponse(account string, inbox string, host string) WebfingerResponse {
accountIRI := MakeLocalIRIForAccount(account)
streamIRI := MakeLocalIRIForStreamURL()
logoIRI := MakeLocalIRIforLogo()
logoType := GetLogoType()
return WebfingerResponse{
Subject: fmt.Sprintf("acct:%s@%s", account, host),
Aliases: []string{
@ -45,31 +38,6 @@ func MakeWebfingerResponse(account string, inbox string, host string) WebfingerR @@ -45,31 +38,6 @@ func MakeWebfingerResponse(account string, inbox string, host string) WebfingerR
Type: "text/html",
Href: accountIRI.String(),
},
{
Rel: "http://webfinger.net/rel/avatar",
Type: logoType,
Href: logoIRI.String(),
},
{
Rel: "alternate",
Type: "application/x-mpegURL",
Href: streamIRI.String(),
},
},
}
}
// MakeWebFingerRequestResponseFromData converts WebFinger data to an easier
// to use model.
func MakeWebFingerRequestResponseFromData(data []map[string]interface{}) WebfingerProfileRequestResponse {
response := WebfingerProfileRequestResponse{}
for _, link := range data {
if link["rel"] == "self" {
return WebfingerProfileRequestResponse{
Self: link["href"].(string),
}
}
}
return response
}

26
activitypub/controllers/nodeinfo.go

@ -59,13 +59,6 @@ func NodeInfoController(w http.ResponseWriter, r *http.Request) { @@ -59,13 +59,6 @@ func NodeInfoController(w http.ResponseWriter, r *http.Request) {
// NodeInfoV2Controller returns the V2 node info response.
func NodeInfoV2Controller(w http.ResponseWriter, r *http.Request) {
type metadata struct {
ChatEnabled bool `json:"chat_enabled"`
}
type services struct {
Outbound []string `json:"outbound"`
Inbound []string `json:"inbound"`
}
type software struct {
Name string `json:"name"`
Version string `json:"version"`
@ -81,12 +74,10 @@ func NodeInfoV2Controller(w http.ResponseWriter, r *http.Request) { @@ -81,12 +74,10 @@ func NodeInfoV2Controller(w http.ResponseWriter, r *http.Request) {
}
type response struct {
Version string `json:"version"`
Services services `json:"services"`
Software software `json:"software"`
Protocols []string `json:"protocols"`
Usage usage `json:"usage"`
OpenRegistrations bool `json:"openRegistrations"`
Metadata metadata `json:"metadata"`
}
if !data.GetFederationEnabled() {
@ -98,12 +89,8 @@ func NodeInfoV2Controller(w http.ResponseWriter, r *http.Request) { @@ -98,12 +89,8 @@ func NodeInfoV2Controller(w http.ResponseWriter, r *http.Request) {
res := response{
Version: "2.0",
Services: services{
Inbound: []string{},
Outbound: []string{},
},
Software: software{
Name: "owncast",
Name: "Owncast",
Version: config.VersionNumber,
},
Usage: usage{
@ -116,9 +103,6 @@ func NodeInfoV2Controller(w http.ResponseWriter, r *http.Request) { @@ -116,9 +103,6 @@ func NodeInfoV2Controller(w http.ResponseWriter, r *http.Request) {
},
OpenRegistrations: false,
Protocols: []string{"activitypub"},
Metadata: metadata{
ChatEnabled: !data.GetChatDisabled(),
},
}
if err := writeResponse(res, w); err != nil {
@ -154,13 +138,13 @@ func XNodeInfo2Controller(w http.ResponseWriter, r *http.Request) { @@ -154,13 +138,13 @@ func XNodeInfo2Controller(w http.ResponseWriter, r *http.Request) {
LocalComments int `json:"localComments"`
}
type response struct {
Server Server `json:"server"`
Organization Organization `json:"organization"`
Version string `json:"version"`
Server Server `json:"server"`
Services Services `json:"services"`
Protocols []string `json:"protocols"`
Usage Usage `json:"usage"`
Version string `json:"version"`
OpenRegistrations bool `json:"openRegistrations"`
Usage Usage `json:"usage"`
}
if !data.GetFederationEnabled() {
@ -224,9 +208,9 @@ func InstanceV1Controller(w http.ResponseWriter, r *http.Request) { @@ -224,9 +208,9 @@ func InstanceV1Controller(w http.ResponseWriter, r *http.Request) {
ShortDescription string `json:"short_description"`
Description string `json:"description"`
Version string `json:"version"`
Stats Stats `json:"stats"`
Thumbnail string `json:"thumbnail"`
Languages []string `json:"languages"`
Stats Stats `json:"stats"`
Registrations bool `json:"registrations"`
ApprovalRequired bool `json:"approval_required"`
InvitesEnabled bool `json:"invites_enabled"`

43
activitypub/controllers/webfinger.go

@ -15,53 +15,44 @@ import ( @@ -15,53 +15,44 @@ 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")
preAcct, account, foundAcct := strings.Cut(resource, "acct:")
resourceComponents := strings.Split(resource, ":")
if !foundAcct || preAcct != "" {
w.WriteHeader(http.StatusBadRequest)
log.Debugln("webfinger request rejected! Malformed resource in query: " + resource)
return
var account string
if len(resourceComponents) == 2 {
account = resourceComponents[1]
} else {
account = resourceComponents[0]
}
userComponents := strings.Split(account, "@")
if len(userComponents) != 2 {
w.WriteHeader(http.StatusBadRequest)
log.Debugln("webfinger request rejected! Malformed account in query: " + account)
if len(userComponents) < 2 {
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! Invalid user: " + user)
log.Debugln("webfinger request rejected")
return
}
// If the webfinger request doesn't match our server then it
// should be rejected.
if instanceHostString != host {
instanceHostString := data.GetServerURL()
if instanceHostString == "" {
w.WriteHeader(http.StatusNotImplemented)
return
}
instanceHostString = utils.GetHostnameFromURLString(instanceHostString)
if instanceHostString == "" || instanceHostString != host {
w.WriteHeader(http.StatusNotImplemented)
log.Debugln("webfinger request rejected! Invalid query host: " + host + " instanceHostString: " + instanceHostString)
return
}

8
activitypub/inbox/like.go

@ -13,14 +13,6 @@ import ( @@ -13,14 +13,6 @@ import (
func handleLikeRequest(c context.Context, activity vocab.ActivityStreamsLike) error {
object := activity.GetActivityStreamsObject()
actorReference := activity.GetActivityStreamsActor()
if object.Len() < 1 {
return errors.New("like activity is missing object")
}
if actorReference.Len() < 1 {
return errors.New("like activity is missing actor")
}
objectIRI := object.At(0).GetIRI().String()
actorIRI := actorReference.At(0).GetIRI().String()

7
activitypub/inbox/undo.go

@ -18,12 +18,7 @@ func handleUndoInboxRequest(c context.Context, activity vocab.ActivityStreamsUnd @@ -18,12 +18,7 @@ func handleUndoInboxRequest(c context.Context, activity vocab.ActivityStreamsUnd
return err
}
} else {
t := iter.GetType()
if t != nil {
log.Traceln("Undo", t.GetTypeName(), "ignored")
} else {
log.Traceln("Undo (no type) ignored")
}
log.Traceln("Undo", iter.GetType().GetTypeName(), "ignored")
return nil
}
}

43
activitypub/inbox/worker.go

@ -4,7 +4,6 @@ import ( @@ -4,7 +4,6 @@ import (
"context"
"crypto/x509"
"encoding/pem"
"fmt"
"net/http"
"net/url"
"strings"
@ -36,7 +35,6 @@ func handle(request apmodels.InboxRequest) { @@ -36,7 +35,6 @@ func handle(request apmodels.InboxRequest) {
// Verify will Verify the http signature of an inbound request as well as
// check it against the list of blocked domains.
// nolint: cyclop
func Verify(request *http.Request) (bool, error) {
verifier, err := httpsig.NewVerifier(request)
if err != nil {
@ -53,10 +51,6 @@ func Verify(request *http.Request) (bool, error) { @@ -53,10 +51,6 @@ func Verify(request *http.Request) (bool, error) {
}
signature := request.Header.Get("signature")
if signature == "" {
return false, errors.New("http signature header not found in request")
}
var algorithmString string
signatureComponents := strings.Split(signature, ",")
for _, component := range signatureComponents {
@ -72,31 +66,26 @@ func Verify(request *http.Request) (bool, error) { @@ -72,31 +66,26 @@ func Verify(request *http.Request) (bool, error) {
return false, errors.New("Unable to determine algorithm to verify request")
}
publicKey, err := resolvers.GetResolvedPublicKeyFromIRI(pubKeyID.String())
actor, err := resolvers.GetResolvedActorFromIRI(pubKeyID.String())
if err != nil {
return false, errors.Wrap(err, "failed to resolve actor from IRI to fetch key")
}
var publicKeyActorIRI *url.URL
if ownerProp := publicKey.GetW3IDSecurityV1Owner(); ownerProp != nil {
publicKeyActorIRI = ownerProp.Get()
}
if publicKeyActorIRI == nil {
return false, errors.New("public key owner IRI is empty")
if actor.ActorIri == nil {
return false, errors.New("actor IRI is empty")
}
// Test to see if the actor is in the list of blocked federated domains.
if isBlockedDomain(publicKeyActorIRI.Hostname()) {
if isBlockedDomain(actor.ActorIri.Hostname()) {
return false, errors.New("domain is blocked")
}
// If actor is specifically blocked, then fail validation.
if blocked, err := isBlockedActor(publicKeyActorIRI); err != nil || blocked {
if blocked, err := isBlockedActor(actor.ActorIri); err != nil || blocked {
return false, err
}
key := publicKey.GetW3IDSecurityV1PublicKeyPem().Get()
key := actor.W3IDSecurityV1PublicKey.Begin().Get().GetW3IDSecurityV1PublicKeyPem().Get()
block, _ := pem.Decode([]byte(key))
if block == nil {
log.Errorln("failed to parse PEM block containing the public key")
@ -109,25 +98,15 @@ func Verify(request *http.Request) (bool, error) { @@ -109,25 +98,15 @@ func Verify(request *http.Request) (bool, error) {
return false, errors.Wrap(err, "failed to parse DER encoded public key")
}
algos := []httpsig.Algorithm{
httpsig.Algorithm(algorithmString), // try stated algorithm first then other common algorithms
httpsig.RSA_SHA256, // <- used by almost all fedi software
httpsig.RSA_SHA512,
}
var algorithm httpsig.Algorithm = httpsig.Algorithm(algorithmString)
// The verifier will verify the Digest in addition to the HTTP signature
triedAlgos := make(map[httpsig.Algorithm]error)
for _, algorithm := range algos {
if _, tried := triedAlgos[algorithm]; !tried {
err := verifier.Verify(parsedKey, algorithm)
if err == nil {
return true, nil
}
triedAlgos[algorithm] = err
}
if err := verifier.Verify(parsedKey, algorithm); err != nil {
log.Warnln("verification error for", pubKeyID, err)
return false, errors.Wrap(err, "verification error: "+pubKeyID.String())
}
return false, fmt.Errorf("http signature verification error(s) for: %s: %+v", pubKeyID.String(), triedAlgos)
return true, nil
}
func isBlockedDomain(domain string) bool {

10
activitypub/inbox/workerpool.go

@ -1,14 +1,14 @@ @@ -1,14 +1,14 @@
package inbox
import (
"runtime"
"github.com/owncast/owncast/activitypub/apmodels"
log "github.com/sirupsen/logrus"
)
// workerPoolSize defines the number of concurrent ActivityPub handlers.
var workerPoolSize = runtime.GOMAXPROCS(0)
const (
// InboxWorkerPoolSize defines the number of concurrent ActivityPub handlers.
InboxWorkerPoolSize = 10
)
// Job struct bundling the ActivityPub and the payload in one struct.
type Job struct {
@ -22,7 +22,7 @@ func InitInboxWorkerPool() { @@ -22,7 +22,7 @@ func InitInboxWorkerPool() {
queue = make(chan Job)
// start workers
for i := 1; i <= workerPoolSize; i++ {
for i := 1; i <= InboxWorkerPoolSize; i++ {
go worker(i, queue)
}
}

72
activitypub/outbox/outbox.go

@ -1,6 +1,7 @@ @@ -1,6 +1,7 @@
package outbox
import (
"errors"
"fmt"
"net/url"
"path/filepath"
@ -12,11 +13,7 @@ import ( @@ -12,11 +13,7 @@ import (
"github.com/owncast/owncast/activitypub/apmodels"
"github.com/owncast/owncast/activitypub/crypto"
"github.com/owncast/owncast/activitypub/persistence"
"github.com/owncast/owncast/activitypub/requests"
"github.com/owncast/owncast/activitypub/resolvers"
"github.com/owncast/owncast/activitypub/webfinger"
"github.com/owncast/owncast/activitypub/workerpool"
"github.com/pkg/errors"
"github.com/owncast/owncast/config"
"github.com/owncast/owncast/core/data"
@ -60,37 +57,28 @@ func SendLive() error { @@ -60,37 +57,28 @@ func SendLive() error {
if title := data.GetStreamTitle(); title != "" {
streamTitle = fmt.Sprintf("<p>%s</p>", title)
}
textContent = fmt.Sprintf("<p>%s</p>%s<p>%s</p><p><a href=\"%s\">%s</a></p>", textContent, streamTitle, tagsString, data.GetServerURL(), data.GetServerURL())
textContent = fmt.Sprintf("<p>%s</p><p>%s</p><p>%s</p><a href=\"%s\">%s</a>", textContent, streamTitle, tagsString, data.GetServerURL(), data.GetServerURL())
activity, _, note, noteID := createBaseOutboundMessage(textContent)
// To the public if we're not treating ActivityPub as "private".
if !data.GetFederationIsPrivate() {
note = apmodels.MakeNotePublic(note)
activity = apmodels.MakeActivityPublic(activity)
}
note.SetActivityStreamsTag(tagProp)
// Attach an image along with the Federated message.
previewURL, err := url.Parse(data.GetServerURL())
if err == nil {
var imageToAttach string
var mediaType string
previewGif := filepath.Join(config.TempDir, "preview.gif")
thumbnailJpg := filepath.Join(config.TempDir, "thumbnail.jpg")
previewGif := filepath.Join(config.WebRoot, "preview.gif")
thumbnailJpg := filepath.Join(config.WebRoot, "thumbnail.jpg")
uniquenessString := shortid.MustGenerate()
if utils.DoesFileExists(previewGif) {
imageToAttach = "preview.gif"
mediaType = "image/gif"
} else if utils.DoesFileExists(thumbnailJpg) {
imageToAttach = "thumbnail.jpg"
mediaType = "image/jpeg"
}
if imageToAttach != "" {
previewURL.Path = imageToAttach
previewURL.RawQuery = "us=" + uniquenessString
apmodels.AddImageAttachmentToNote(note, previewURL.String(), mediaType)
apmodels.AddImageAttachmentToNote(note, previewURL.String())
}
}
@ -118,37 +106,6 @@ func SendLive() error { @@ -118,37 +106,6 @@ func SendLive() error {
return nil
}
// SendDirectMessageToAccount will send a direct message to a single account.
func SendDirectMessageToAccount(textContent, account string) error {
links, err := webfinger.GetWebfingerLinks(account)
if err != nil {
return errors.Wrap(err, "unable to get webfinger links when sending private message")
}
user := apmodels.MakeWebFingerRequestResponseFromData(links)
iri := user.Self
actor, err := resolvers.GetResolvedActorFromIRI(iri)
if err != nil {
return errors.Wrap(err, "unable to resolve actor to send message to")
}
activity, _, note, _ := createBaseOutboundMessage(textContent)
// Set direct message visibility
activity = apmodels.MakeActivityDirect(activity, actor.ActorIri)
note = apmodels.MakeNoteDirect(note, actor.ActorIri)
object := activity.GetActivityStreamsObject()
object.SetActivityStreamsNote(0, note)
b, err := apmodels.Serialize(activity)
if err != nil {
log.Errorln("unable to serialize custom fediverse message activity", err)
return errors.Wrap(err, "unable to serialize custom fediverse message activity")
}
return SendToUser(actor.Inbox, b)
}
// SendPublicMessage will send a public message to all followers.
func SendPublicMessage(textContent string) error {
originalContent := textContent
@ -173,11 +130,6 @@ func SendPublicMessage(textContent string) error { @@ -173,11 +130,6 @@ func SendPublicMessage(textContent string) error {
activity, _, note, noteID := createBaseOutboundMessage(textContent)
note.SetActivityStreamsTag(tagProp)
if !data.GetFederationIsPrivate() {
note = apmodels.MakeNotePublic(note)
activity = apmodels.MakeActivityPublic(activity)
}
b, err := apmodels.Serialize(activity)
if err != nil {
log.Errorln("unable to serialize custom fediverse message activity", err)
@ -239,20 +191,6 @@ func SendToFollowers(payload []byte) error { @@ -239,20 +191,6 @@ func SendToFollowers(payload []byte) error {
return nil
}
// SendToUser will send a payload to a single specific inbox.
func SendToUser(inbox *url.URL, payload []byte) error {
localActor := apmodels.MakeLocalIRIForAccount(data.GetDefaultFederationUsername())
req, err := requests.CreateSignedRequest(payload, inbox, localActor)
if err != nil {
return errors.Wrap(err, "unable to create outbox request")
}
workerpool.AddToOutboundQueue(req)
return nil
}
// UpdateFollowersWithAccountUpdates will send an update to all followers alerting of a profile update.
func UpdateFollowersWithAccountUpdates() error {
// Don't do anything if federation is disabled.

17
activitypub/persistence/followers.go

@ -24,10 +24,19 @@ func createFederationFollowersTable() { @@ -24,10 +24,19 @@ func createFederationFollowersTable() {
"approved_at" TIMESTAMP,
"disabled_at" TIMESTAMP,
"request_object" BLOB,
PRIMARY KEY (iri));`
_datastore.MustExec(createTableSQL)
_datastore.MustExec(`CREATE INDEX IF NOT EXISTS idx_iri ON ap_followers (iri);`)
_datastore.MustExec(`CREATE INDEX IF NOT EXISTS idx_approved_at ON ap_followers (approved_at);`)
PRIMARY KEY (iri));
CREATE INDEX iri_index ON ap_followers (iri);
CREATE INDEX approved_at_index ON ap_followers (approved_at);`
stmt, err := _datastore.DB.Prepare(createTableSQL)
if err != nil {
log.Fatal(err)
}
defer stmt.Close()
_, err = stmt.Exec()
if err != nil {
log.Warnln("error executing sql creating followers table", createTableSQL, err)
}
}
// GetFollowerCount will return the number of followers we're keeping track of.

107
activitypub/persistence/followers_test.go

@ -1,107 +0,0 @@ @@ -1,107 +0,0 @@
package persistence
import (
"os"
"testing"
"github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/models"
"github.com/owncast/owncast/utils"
)
func TestMain(m *testing.M) {
setup()
code := m.Run()
os.Exit(code)
}
var followers = []models.Follower{}
func setup() {
data.SetupPersistence(":memory:")
_datastore = data.GetDatastore()
createFederationFollowersTable()
number := 100
for i := 0; i < number; i++ {
u := createFakeFollower()
createFollow(u.ActorIRI, u.Inbox, "https://fake.fediverse.server/some/request", u.Name, u.Username, u.Image, nil, true)
followers = append(followers, u)
}
}
func TestQueryFollowers(t *testing.T) {
f, total, err := GetFederationFollowers(10, 0)
if err != nil {
t.Errorf("Error querying followers: %s", err)
}
if len(f) != 10 {
t.Errorf("Expected 10 followers, got %d", len(f))
}
if total != 100 {
t.Errorf("Expected 100 followers, got %d", total)
}
}
func TestQueryFollowersWithOffset(t *testing.T) {
f, total, err := GetFederationFollowers(10, 10)
if err != nil {
t.Errorf("Error querying followers: %s", err)
}
if len(f) != 10 {
t.Errorf("Expected 10 followers, got %d", len(f))
}
if total != 100 {
t.Errorf("Expected 100 followers, got %d", total)
}
}
func TestQueryFollowersWithOffsetAndLimit(t *testing.T) {
f, total, err := GetFederationFollowers(10, 90)
if err != nil {
t.Errorf("Error querying followers: %s", err)
}
if len(f) != 10 {
t.Errorf("Expected 10 followers, got %d", len(f))
}
if total != 100 {
t.Errorf("Expected 100 followers, got %d", total)
}
}
func TestQueryFollowersWithPagination(t *testing.T) {
f, _, err := GetFederationFollowers(15, 10)
if err != nil {
t.Errorf("Error querying followers: %s", err)
}
comparisonFollowers := followers[10:25]
if len(f) != len(comparisonFollowers) {
t.Errorf("Expected %d followers, got %d", len(comparisonFollowers), len(f))
}
for i, follower := range f {
if follower.ActorIRI != comparisonFollowers[i].ActorIRI {
t.Errorf("Expected %s, got %s", comparisonFollowers[i].ActorIRI, follower.ActorIRI)
}
}
}
func createFakeFollower() models.Follower {
user, _ := utils.GenerateRandomString(10)
return models.Follower{
ActorIRI: "https://freedom.eagle/user/" + user,
Inbox: "https://fake.fediverse.server/user/" + user + "/inbox",
Image: "https://fake.fediverse.server/user/" + user + "/avatar.png",
Name: user,
Username: user,
Timestamp: utils.NullTime{},
}
}

37
activitypub/persistence/persistence.go

@ -202,10 +202,17 @@ func createFederatedActivitiesTable() { @@ -202,10 +202,17 @@ func createFederatedActivitiesTable() {
"actor" TEXT NOT NULL,
"type" TEXT NOT NULL,
"timestamp" TIMESTAMP NOT NULL
);`
);
CREATE INDEX iri_actor_index ON ap_accepted_activities (iri,actor);`
_datastore.MustExec(createTableSQL)
_datastore.MustExec(`CREATE INDEX IF NOT EXISTS idx_iri_actor_index ON ap_accepted_activities (iri,actor);`)
stmt, err := _datastore.DB.Prepare(createTableSQL)
if err != nil {
log.Fatal("error creating inbox table", err)
}
defer stmt.Close()
if _, err := stmt.Exec(); err != nil {
log.Fatal("error creating inbound federated activities table", err)
}
}
func createFederationOutboxTable() {
@ -216,12 +223,20 @@ func createFederationOutboxTable() { @@ -216,12 +223,20 @@ func createFederationOutboxTable() {
"type" TEXT NOT NULL,
"created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
"live_notification" BOOLEAN DEFAULT FALSE,
PRIMARY KEY (iri));`
PRIMARY KEY (iri));
CREATE INDEX iri ON ap_outbox (iri);
CREATE INDEX type ON ap_outbox (type);
CREATE INDEX live_notification ON ap_outbox (live_notification);`
_datastore.MustExec(createTableSQL)
_datastore.MustExec(`CREATE INDEX IF NOT EXISTS idx_iri ON ap_outbox (iri);`)
_datastore.MustExec(`CREATE INDEX IF NOT EXISTS idx_type ON ap_outbox (type);`)
_datastore.MustExec(`CREATE INDEX IF NOT EXISTS idx_live_notification ON ap_outbox (live_notification);`)
stmt, err := _datastore.DB.Prepare(createTableSQL)
if err != nil {
log.Fatal(err)
}
defer stmt.Close()
_, err = stmt.Exec()
if err != nil {
log.Warnln("error executing sql creating outbox table", createTableSQL, err)
}
}
// GetOutboxPostCount will return the number of posts in the outbox.
@ -277,6 +292,12 @@ func AddToOutbox(iri string, itemData []byte, typeString string, isLiveNotificat @@ -277,6 +292,12 @@ func AddToOutbox(iri string, itemData []byte, typeString string, isLiveNotificat
return tx.Commit()
}
// GetObjectByID will return a string representation of a single object by the ID.
func GetObjectByID(id string) (string, error) {
value, err := _datastore.GetQueries().GetObjectFromOutboxByID(context.Background(), id)
return string(value), err
}
// GetObjectByIRI will return a string representation of a single object by the IRI.
func GetObjectByIRI(iri string) (string, bool, time.Time, error) {
row, err := _datastore.GetQueries().GetObjectFromOutboxByIRI(context.Background(), iri)

22
activitypub/requests/http.go

@ -1,16 +1,12 @@ @@ -1,16 +1,12 @@
package requests
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/url"
"github.com/go-fed/activity/streams"
"github.com/go-fed/activity/streams/vocab"
"github.com/owncast/owncast/activitypub/crypto"
"github.com/owncast/owncast/config"
log "github.com/sirupsen/logrus"
)
@ -54,21 +50,3 @@ func WriteResponse(payload []byte, w http.ResponseWriter, publicKey crypto.Publi @@ -54,21 +50,3 @@ func WriteResponse(payload []byte, w http.ResponseWriter, publicKey crypto.Publi
return nil
}
// CreateSignedRequest will create a signed POST request of a payload to the provided destination.
func CreateSignedRequest(payload []byte, url *url.URL, fromActorIRI *url.URL) (*http.Request, error) {
log.Debugln("Sending", string(payload), "to", url)
req, _ := http.NewRequest(http.MethodPost, url.String(), bytes.NewBuffer(payload))
ua := fmt.Sprintf("%s; https://owncast.online", config.GetReleaseString())
req.Header.Set("User-Agent", ua)
req.Header.Set("Content-Type", "application/activity+json")
if err := crypto.SignRequest(req, payload, fromActorIRI); err != nil {
log.Errorln("error signing request:", err)
return nil, err
}
return req, nil
}

70
activitypub/resolvers/resolve.go

@ -3,7 +3,7 @@ package resolvers @@ -3,7 +3,7 @@ package resolvers
import (
"context"
"encoding/json"
"io"
"io/ioutil"
"net/http"
"github.com/go-fed/activity/streams"
@ -63,7 +63,7 @@ func ResolveIRI(c context.Context, iri string, callbacks ...interface{}) error { @@ -63,7 +63,7 @@ func ResolveIRI(c context.Context, iri string, callbacks ...interface{}) error {
defer response.Body.Close()
data, err := io.ReadAll(response.Body)
data, err := ioutil.ReadAll(response.Body)
if err != nil {
return err
}
@ -122,72 +122,6 @@ func GetResolvedActorFromActorProperty(actor vocab.ActivityStreamsActorProperty) @@ -122,72 +122,6 @@ func GetResolvedActorFromActorProperty(actor vocab.ActivityStreamsActorProperty)
return apActor, err
}
// GetResolvedPublicKeyFromIRI will resolve a publicKey IRI string to a vocab.W3IDSecurityV1PublicKey.
func GetResolvedPublicKeyFromIRI(publicKeyIRI string) (vocab.W3IDSecurityV1PublicKey, error) {
var err error
var pubkey vocab.W3IDSecurityV1PublicKey
resolved := false
personCallback := func(c context.Context, person vocab.ActivityStreamsPerson) error {
if pkProp := person.GetW3IDSecurityV1PublicKey(); pkProp != nil {
for iter := pkProp.Begin(); iter != pkProp.End(); iter = iter.Next() {
if iter.IsW3IDSecurityV1PublicKey() {
pubkey = iter.Get()
resolved = true
return nil
}
}
}
return errors.New("error deriving publickey from activitystreamsperson")
}
serviceCallback := func(c context.Context, service vocab.ActivityStreamsService) error {
if pkProp := service.GetW3IDSecurityV1PublicKey(); pkProp != nil {
for iter := pkProp.Begin(); iter != pkProp.End(); iter = iter.Next() {
if iter.IsW3IDSecurityV1PublicKey() {
pubkey = iter.Get()
resolved = true
return nil
}
}
}
return errors.New("error deriving publickey from activitystreamsservice")
}
applicationCallback := func(c context.Context, app vocab.ActivityStreamsApplication) error {
if pkProp := app.GetW3IDSecurityV1PublicKey(); pkProp != nil {
for iter := pkProp.Begin(); iter != pkProp.End(); iter = iter.Next() {
if iter.IsW3IDSecurityV1PublicKey() {
pubkey = iter.Get()
resolved = true
return nil
}
}
}
return errors.New("error deriving publickey from activitystreamsapp")
}
pubkeyCallback := func(c context.Context, pk vocab.W3IDSecurityV1PublicKey) error {
pubkey = pk
resolved = true
return nil
}
if e := ResolveIRI(context.Background(), publicKeyIRI, personCallback, serviceCallback, applicationCallback, pubkeyCallback); e != nil {
err = e
}
if err != nil {
err = errors.Wrap(err, "error resolving publickey from iri, actor may not be valid: "+publicKeyIRI)
}
if !resolved {
err = errors.New("error resolving publickey from iri, actor may not be valid: " + publicKeyIRI)
}
return pubkey, err
}
// GetResolvedActorFromIRI will resolve an IRI string to a fully populated actor.
func GetResolvedActorFromIRI(personOrServiceIRI string) (apmodels.ActivityPubActor, error) {
var err error

61
activitypub/webfinger/webfinger.go

@ -1,61 +0,0 @@ @@ -1,61 +0,0 @@
package webfinger
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"github.com/owncast/owncast/utils"
)
// GetWebfingerLinks will return webfinger data for an account.
func GetWebfingerLinks(account string) ([]map[string]interface{}, error) {
type webfingerResponse struct {
Links []map[string]interface{} `json:"links"`
}
account = strings.TrimLeft(account, "@") // remove any leading @
accountComponents := strings.Split(account, "@")
fediverseServer := accountComponents[1]
// Reject any requests to our internal network or loopback.
if utils.IsHostnameInternal(fediverseServer) {
return nil, errors.New("unable to use provided host as a valid fediverse server")
}
// HTTPS is required.
requestURL, err := url.Parse("https://" + fediverseServer)
if err != nil {
return nil, fmt.Errorf("unable to parse fediverse server host %s", fediverseServer)
}
requestURL.Path = "/.well-known/webfinger"
query := requestURL.Query()
query.Add("resource", fmt.Sprintf("acct:%s", account))
requestURL.RawQuery = query.Encode()
// Do not support redirects.
client := &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
response, err := client.Get(requestURL.String())
if err != nil {
return nil, err
}
defer response.Body.Close()
var links webfingerResponse
decoder := json.NewDecoder(response.Body)
if err := decoder.Decode(&links); err != nil {
return nil, err
}
return links.Links, nil
}

9
activitypub/workerpool/outbound.go

@ -2,13 +2,14 @@ package workerpool @@ -2,13 +2,14 @@ package workerpool
import (
"net/http"
"runtime"
log "github.com/sirupsen/logrus"
)
// workerPoolSize defines the number of concurrent HTTP ActivityPub requests.
var workerPoolSize = runtime.GOMAXPROCS(0)
const (
// ActivityPubWorkerPoolSize defines the number of concurrent HTTP ActivityPub requests.
ActivityPubWorkerPoolSize = 10
)
// Job struct bundling the ActivityPub and the payload in one struct.
type Job struct {
@ -22,7 +23,7 @@ func InitOutboundWorkerPool() { @@ -22,7 +23,7 @@ func InitOutboundWorkerPool() {
queue = make(chan Job)
// start workers
for i := 1; i <= workerPoolSize; i++ {
for i := 1; i <= ActivityPubWorkerPoolSize; i++ {
go worker(i, queue)
}
}

11
auth/auth.go

@ -1,11 +0,0 @@ @@ -1,11 +0,0 @@
package auth
// Type represents a form of authentication.
type Type string
// The different auth types we support.
const (
// IndieAuth https://indieauth.spec.indieweb.org/.
IndieAuth Type = "indieauth"
Fediverse Type = "fediverse"
)

115
auth/fediverse/fediverse.go

@ -1,115 +0,0 @@ @@ -1,115 +0,0 @@
package fediverse
import (
"crypto/rand"
"errors"
"io"
"strings"
"sync"
"time"
log "github.com/sirupsen/logrus"
)
// OTPRegistration represents a single OTP request.
type OTPRegistration struct {
Timestamp time.Time
UserID string
UserDisplayName string
Code string
Account string
}
// Key by access token to limit one OTP request for a person
// to be active at a time.
var (
pendingAuthRequests = make(map[string]OTPRegistration)
lock = sync.Mutex{}
)
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, 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, 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()
r := OTPRegistration{
Code: code,
UserID: userID,
UserDisplayName: userDisplayName,
Account: strings.ToLower(account),
Timestamp: time.Now(),
}
pendingAuthRequests[accessToken] = r
return r, true, nil
}
// ValidateFediverseOTP will verify a OTP code for a auth request.
func ValidateFediverseOTP(accessToken, code string) (bool, *OTPRegistration) {
request, ok := pendingAuthRequests[accessToken]
if !ok || request.Code != code || time.Since(request.Timestamp) > registrationTimeout {
return false, nil
}
lock.Lock()
defer lock.Unlock()
delete(pendingAuthRequests, accessToken)
return true, &request
}
func createCode() (string, error) {
table := [...]byte{'1', '2', '3', '4', '5', '6', '7', '8', '9', '0'}
digits := 6
b := make([]byte, digits)
n, err := io.ReadAtLeast(rand.Reader, b, digits)
if n != digits {
return "", err
}
for i := 0; i < len(b); i++ {
b[i] = table[int(b[i])%len(table)]
}
return string(b), nil
}

111
auth/fediverse/fediverse_test.go

@ -1,111 +0,0 @@ @@ -1,111 +0,0 @@
package fediverse
import (
"strings"
"testing"
"github.com/owncast/owncast/utils"
)
const (
accessToken = "fake-access-token"
account = "blah"
userID = "fake-user-id"
userDisplayName = "fake-user-display-name"
)
func TestOTPFlowValidation(t *testing.T) {
r, success, err := RegisterFediverseOTP(accessToken, userID, userDisplayName, account)
if err != nil {
t.Error(err)
}
if !success {
t.Error("Registration should be permitted.")
}
if r.Code == "" {
t.Error("Code is empty")
}
if r.Account != account {
t.Error("Account is not set correctly")
}
if r.Timestamp.IsZero() {
t.Error("Timestamp is empty")
}
valid, registration := ValidateFediverseOTP(accessToken, r.Code)
if !valid {
t.Error("Code is not valid")
}
if registration.Account != account {
t.Error("Account is not set correctly")
}
if registration.UserID != userID {
t.Error("UserID is not set correctly")
}
if registration.UserDisplayName != userDisplayName {
t.Error("UserDisplayName is not set correctly")
}
}
func TestSingleOTPFlowRequest(t *testing.T) {
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.")
}
if s2 {
t.Error("Second registration should not be permitted.")
}
}
func TestAccountCaseInsensitive(t *testing.T) {
account := "Account"
accessToken := "another-fake-access-token"
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))
_, 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.")
}
}

168
auth/indieauth/client.go

@ -1,168 +0,0 @@ @@ -1,168 +0,0 @@
package indieauth
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
"github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/utils"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
)
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) {
// Limit the number of pending requests
if len(pendingAuthRequests) >= maxPendingRequests {
return nil, errors.New("Please try again later. Too many pending requests.")
}
// Reject any requests to our internal network or loopback
if utils.IsHostnameInternal(authHost) {
return nil, errors.New("unable to use provided host")
}
// Santity check the server URL
u, err := url.ParseRequestURI(authHost)
if err != nil {
return nil, errors.New("unable to parse server URL")
}
// Limit to only secured connections
if u.Scheme != "https" {
return nil, errors.New("only servers secured with https are supported")
}
serverURL := data.GetServerURL()
if serverURL == "" {
return nil, errors.New("Owncast server URL must be set when using auth")
}
r, err := createAuthRequest(authHost, userID, displayName, accessToken, serverURL)
if err != nil {
return nil, errors.Wrap(err, "unable to generate IndieAuth request")
}
pendingAuthRequests[r.State] = r
return r.Redirect, nil
}
// HandleCallbackCode will handle the callback from the IndieAuth server
// to continue the next step of the auth flow.
func HandleCallbackCode(code, state string) (*Request, *Response, error) {
request, exists := pendingAuthRequests[state]
if !exists {
return nil, nil, errors.New("no auth requests pending")
}
data := url.Values{}
data.Set("grant_type", "authorization_code")
data.Set("code", code)
data.Set("client_id", request.ClientID)
data.Set("redirect_uri", request.Callback.String())
data.Set("code_verifier", request.CodeVerifier)
// Do not support redirects.
client := &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
r, err := http.NewRequest("POST", request.Endpoint.String(), strings.NewReader(data.Encode())) // URL-encoded payload
if err != nil {
return nil, nil, err
}
r.Header.Add("Content-Type", "application/x-www-form-urlencoded")
r.Header.Add("Content-Length", strconv.Itoa(len(data.Encode())))
res, err := client.Do(r)
if err != nil {
return nil, nil, err
}
defer res.Body.Close()
body, err := io.ReadAll(res.Body)
if err != nil {
return nil, nil, err
}
var response Response
if err := json.Unmarshal(body, &response); err != nil {
return nil, nil, errors.Wrap(err, "unable to parse IndieAuth response: "+string(body))
}
if response.Error != "" || response.ErrorDescription != "" {
errorText := makeIndieAuthClientErrorText(response.Error)
log.Debugln("IndieAuth error:", response.Error, response.ErrorDescription)
return nil, nil, fmt.Errorf("IndieAuth error: %s - %s", errorText, response.ErrorDescription)
}
// In case this IndieAuth server does not use OAuth error keys or has internal
// issues resulting in unstructured errors.
if res.StatusCode < 200 || res.StatusCode > 299 {
log.Debugln("IndieAuth error. status code:", res.StatusCode, "body:", string(body))
return nil, nil, errors.New("there was an error authenticating against IndieAuth server")
}
// Trim any trailing slash so we can accurately compare the two "me" values
meResponseVerifier := strings.TrimRight(response.Me, "/")
meRequestVerifier := strings.TrimRight(request.Me.String(), "/")
// What we sent and what we got back must match
if meRequestVerifier != meResponseVerifier {
return nil, nil, errors.New("indieauth response does not match the initial anticipated auth destination")
}
return request, &response, nil
}
// Error value should be from this list:
// https://datatracker.ietf.org/doc/html/rfc6749#section-5.2
func makeIndieAuthClientErrorText(err string) string {
switch err {
case "invalid_request", "invalid_client":
return "The authentication request was invalid. Please report this to the Owncast project."
case "invalid_grant", "unauthorized_client":
return "This authorization request is unauthorized."
case "unsupported_grant_type":
return "The authorization grant type is not supported by the authorization server."
default:
return err
}
}

126
auth/indieauth/helpers.go

@ -1,126 +0,0 @@ @@ -1,126 +0,0 @@
package indieauth
import (
"crypto/sha256"
"encoding/base64"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/andybalholm/cascadia"
"github.com/pkg/errors"
"golang.org/x/net/html"
)
func createAuthRequest(authDestination, userID, displayName, accessToken, baseServer string) (*Request, error) {
authURL, err := url.Parse(authDestination)
if err != nil {
return nil, errors.Wrap(err, "unable to parse IndieAuth destination")
}
authEndpointURL, err := getAuthEndpointFromURL(authURL.String())
if err != nil {
return nil, errors.Wrap(err, "unable to get IndieAuth endpoint from destination URL")
}
baseServerURL, err := url.Parse(baseServer)
if err != nil {
return nil, errors.Wrap(err, "unable to parse local owncast base server URL")
}
callbackURL := *baseServerURL
callbackURL.Path = "/api/auth/indieauth/callback"
codeVerifier := randString(50)
codeChallenge := createCodeChallenge(codeVerifier)
state := randString(20)
responseType := "code"
clientID := baseServerURL.String() // Our local URL
codeChallengeMethod := "S256"
redirect := *authEndpointURL
q := authURL.Query()
q.Add("response_type", responseType)
q.Add("client_id", clientID)
q.Add("state", state)
q.Add("code_challenge_method", codeChallengeMethod)
q.Add("code_challenge", codeChallenge)
q.Add("me", authURL.String())
q.Add("redirect_uri", callbackURL.String())
redirect.RawQuery = q.Encode()
return &Request{
Me: authURL,
UserID: userID,
DisplayName: displayName,
CurrentAccessToken: accessToken,
Endpoint: authEndpointURL,
ClientID: baseServer,
CodeVerifier: codeVerifier,
CodeChallenge: codeChallenge,
State: state,
Redirect: &redirect,
Callback: &callbackURL,
Timestamp: time.Now(),
}, nil
}
func getAuthEndpointFromURL(urlstring string) (*url.URL, error) {
htmlDocScrapeURL, err := url.Parse(urlstring)
if err != nil {
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
}
defer r.Body.Close()
scrapedHTMLDocument, err := html.Parse(r.Body)
if err != nil {
return nil, errors.Wrap(err, "unable to parse html at remote auth host")
}
authorizationEndpointTag := cascadia.MustCompile("link[rel=authorization_endpoint]").MatchAll(scrapedHTMLDocument)
if len(authorizationEndpointTag) == 0 {
return nil, fmt.Errorf("url does not support indieauth")
}
for _, attr := range authorizationEndpointTag[len(authorizationEndpointTag)-1].Attr {
if attr.Key == "href" {
u, err := url.Parse(attr.Val)
if err != nil {
return nil, errors.Wrap(err, "unable to parse authorization endpoint")
}
// If it is a relative URL we an fill in the missing components
// by using the original URL we scraped, since it is the same host.
if u.Scheme == "" {
u.Scheme = htmlDocScrapeURL.Scheme
}
if u.Host == "" {
u.Host = htmlDocScrapeURL.Host
}
return u, nil
}
}
return nil, fmt.Errorf("unable to find href value for authorization_endpoint")
}
func createCodeChallenge(codeVerifier string) string {
sha256hash := sha256.Sum256([]byte(codeVerifier))
encodedHashedCode := strings.TrimRight(base64.URLEncoding.EncodeToString(sha256hash[:]), "=")
return encodedHashedCode
}

35
auth/indieauth/indieauth_test.go

@ -1,35 +0,0 @@ @@ -1,35 +0,0 @@
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.")
}
}

34
auth/indieauth/random.go

@ -1,34 +0,0 @@ @@ -1,34 +0,0 @@
package indieauth
import (
"math/rand"
"time"
"unsafe"
)
const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
const (
letterIdxBits = 6 // 6 bits to represent a letter index
letterIdxMask = 1<<letterIdxBits - 1 // All 1-bits, as many as letterIdxBits
letterIdxMax = 63 / letterIdxBits // # of letter indices fitting in 63 bits
)
var src = rand.NewSource(time.Now().UnixNano())
func randString(n int) string {
b := make([]byte, n)
// A src.Int63() generates 63 random bits, enough for letterIdxMax characters!
for i, cache, remain := n-1, src.Int63(), letterIdxMax; i >= 0; {
if remain == 0 {
cache, remain = src.Int63(), letterIdxMax
}
if idx := int(cache & letterIdxMask); idx < len(letterBytes) {
b[i] = letterBytes[idx]
i--
}
cache >>= letterIdxBits
remain--
}
return *(*string)(unsafe.Pointer(&b)) // nolint:gosec
}

22
auth/indieauth/request.go

@ -1,22 +0,0 @@ @@ -1,22 +0,0 @@
package indieauth
import (
"net/url"
"time"
)
// Request represents a single in-flight IndieAuth request.
type Request struct {
Timestamp time.Time
Endpoint *url.URL
Redirect *url.URL // Outbound redirect URL to continue auth flow
Callback *url.URL // Inbound URL to get auth flow results
Me *url.URL
UserID string
DisplayName string
CurrentAccessToken string
ClientID string
CodeVerifier string
CodeChallenge string
State string
}

18
auth/indieauth/response.go

@ -1,18 +0,0 @@ @@ -1,18 +0,0 @@
package indieauth
// Profile represents optional profile data that is returned
// when completing the IndieAuth flow.
type Profile struct {
Name string `json:"name"`
URL string `json:"url"`
Photo string `json:"photo"`
}
// Response the response returned when completing
// the IndieAuth flow.
type Response struct {
Me string `json:"me,omitempty"`
Profile Profile `json:"profile,omitempty"`
Error string `json:"error,omitempty"`
ErrorDescription string `json:"error_description,omitempty"`
}

101
auth/indieauth/server.go

@ -1,101 +0,0 @@ @@ -1,101 +0,0 @@
package indieauth
import (
"fmt"
"time"
"github.com/owncast/owncast/core/data"
"github.com/pkg/errors"
"github.com/teris-io/shortid"
)
// ServerAuthRequest is n inbound request to authenticate against
// this Owncast instance.
type ServerAuthRequest struct {
Timestamp time.Time
ClientID string
RedirectURI string
CodeChallenge string
State string
Me string
Code string
}
// ServerProfile represents basic user-provided data about this Owncast instance.
type ServerProfile struct {
Name string `json:"name"`
URL string `json:"url"`
Photo string `json:"photo"`
}
// ServerProfileResponse is returned when an auth flow requests the final
// confirmation of the IndieAuth flow.
type ServerProfileResponse struct {
Me string `json:"me,omitempty"`
Profile ServerProfile `json:"profile,omitempty"`
// Error keys need to match the OAuth spec.
Error string `json:"error,omitempty"`
ErrorDescription string `json:"error_description,omitempty"`
}
var pendingServerAuthRequests = map[string]ServerAuthRequest{}
const maxPendingRequests = 100
// 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{
ClientID: clientID,
RedirectURI: redirectURI,
CodeChallenge: codeChallenge,
State: state,
Me: me,
Code: code,
Timestamp: time.Now(),
}
pendingServerAuthRequests[code] = r
return &r, nil
}
// CompleteServerAuth will verify that the values provided in the final step
// of the IndieAuth flow are correct, and return some basic profile info.
func CompleteServerAuth(code, redirectURI, clientID string, codeVerifier string) (*ServerProfileResponse, error) {
request, pending := pendingServerAuthRequests[code]
if !pending {
return nil, errors.New("no pending authentication request")
}
if request.RedirectURI != redirectURI {
return nil, errors.New("redirect URI does not match")
}
if request.ClientID != clientID {
return nil, errors.New("client ID does not match")
}
codeChallengeFromRequest := createCodeChallenge(codeVerifier)
if request.CodeChallenge != codeChallengeFromRequest {
return nil, errors.New("code verifier is incorrect")
}
response := ServerProfileResponse{
Me: data.GetServerURL(),
Profile: ServerProfile{
Name: data.GetServerName(),
URL: data.GetServerURL(),
Photo: fmt.Sprintf("%s/%s", data.GetServerURL(), data.GetLogoPath()),
},
}
return &response, nil
}

67
auth/persistence.go

@ -1,67 +0,0 @@ @@ -1,67 +0,0 @@
package auth
import (
"context"
"strings"
"github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/core/user"
"github.com/owncast/owncast/db"
)
var _datastore *data.Datastore
// Setup will initialize auth persistence.
func Setup(db *data.Datastore) {
_datastore = db
createTableSQL := `CREATE TABLE IF NOT EXISTS auth (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"user_id" TEXT NOT NULL,
"token" TEXT NOT NULL,
"type" TEXT NOT NULL,
"timestamp" DATE DEFAULT CURRENT_TIMESTAMP NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id)
);`
_datastore.MustExec(createTableSQL)
_datastore.MustExec(`CREATE INDEX IF NOT EXISTS idx_auth_token ON auth (token);`)
}
// AddAuth will add an external authentication token and type for a user.
func AddAuth(userID, authToken string, authType Type) error {
return _datastore.GetQueries().AddAuthForUser(context.Background(), db.AddAuthForUserParams{
UserID: userID,
Token: authToken,
Type: string(authType),
})
}
// GetUserByAuth will return an existing user given auth details if a user
// has previously authenticated with that method.
func GetUserByAuth(authToken string, authType Type) *user.User {
u, err := _datastore.GetQueries().GetUserByAuth(context.Background(), db.GetUserByAuthParams{
Token: authToken,
Type: string(authType),
})
if err != nil {
return nil
}
var scopes []string
if u.Scopes.Valid {
scopes = strings.Split(u.Scopes.String, ",")
}
return &user.User{
ID: u.ID,
DisplayName: u.DisplayName,
DisplayColor: int(u.DisplayColor),
CreatedAt: u.CreatedAt.Time,
DisabledAt: &u.DisabledAt.Time,
PreviousNames: strings.Split(u.PreviousNames.String, ","),
NameChangedAt: &u.NamechangedAt.Time,
AuthenticatedAt: &u.AuthenticatedAt.Time,
Scopes: scopes,
}
}

41
build/admin/bundleAdmin.sh

@ -0,0 +1,41 @@ @@ -0,0 +1,41 @@
#!/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

@ -1,24 +0,0 @@ @@ -1,24 +0,0 @@
#!/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:-develop}
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}"

16
build/javascript/README.md

@ -0,0 +1,16 @@ @@ -0,0 +1,16 @@
## 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.

2209
build/javascript/package-lock.json generated

File diff suppressed because it is too large Load Diff

41
build/javascript/package.json

@ -0,0 +1,41 @@ @@ -0,0 +1,41 @@
{
"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"
}

7
build/javascript/postcss.config.js

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

7
build/javascript/tailwind.config.js

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

118
build/release/build.sh

@ -0,0 +1,118 @@ @@ -0,0 +1,118 @@
#!/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}"

29
build/release/docker-nightly.sh

@ -0,0 +1,29 @@ @@ -0,0 +1,29 @@
# 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"
GIT_COMMIT=$(git rev-list -1 HEAD)
# 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 > "../../webroot/js/web_modules/tailwindcss/dist/tailwind.min.css"
popd
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 ghcr.io/owncast/${DOCKER_IMAGE}:nightly .
# Dockerhub
# You must be authenticated via `docker login` with your Dockerhub credentials first.
# docker push gabekangas/owncast:nightly
docker push ghcr.io/owncast/${DOCKER_IMAGE}:nightly

40
build/web/bundleWeb.sh

@ -1,40 +0,0 @@ @@ -1,40 +0,0 @@
#!/usr/bin/env bash
# shellcheck disable=SC2059
set -o errexit
set -o nounset
set -o pipefail
OFFLINE=
while [[ $# -gt 0 ]]; do
case $1 in
--offline)
OFFLINE=1
;;
esac
shift
done
# Change to the root directory of the repository
cd "$(git rev-parse --show-toplevel)"
cd web
if [ ! "$OFFLINE" ]; then
echo "Installing npm modules for the owncast web..."
npm --silent install 2>/dev/null
fi
echo "Building owncast web..."
rm -rf .next
node_modules/.bin/next build | 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."

3
config/config.go

@ -40,9 +40,6 @@ var BuildPlatform = "dev" @@ -40,9 +40,6 @@ 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 == "" {

19
config/constants.go

@ -4,18 +4,15 @@ import "path/filepath" @@ -4,18 +4,15 @@ import "path/filepath"
const (
// StaticVersionNumber is the version of Owncast that is used when it's not overwritten via build-time settings.
StaticVersionNumber = "0.1.3" // Shown when you build from develop
StaticVersionNumber = "0.0.12" // Shown when you build from develop
// WebRoot is the web server root directory.
WebRoot = "webroot"
// 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 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
// EmojiDir is relative to the webroot.
EmojiDir = "/img/emoji"
)
var (
@ -24,10 +21,4 @@ var ( @@ -24,10 +21,4 @@ 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")
)

62
config/defaults.go

@ -8,70 +8,47 @@ import ( @@ -8,70 +8,47 @@ import (
// Defaults will hold default configuration values.
type Defaults struct {
PageBodyContent string
FederationGoLiveMessage string
Name string
Title string
Summary string
ServerWelcomeMessage string
Logo string
YPServer string
Title string
Tags []string
PageBodyContent string
DatabaseFilePath string
WebServerPort int
WebServerIP string
RTMPServerPort int
StreamKey string
FederationUsername string
WebServerIP string
Name string
AdminPassword string
StreamKeys []models.StreamKey
StreamVariants []models.StreamOutputVariant
Tags []string
RTMPServerPort int
SegmentsInPlaylist int
YPEnabled bool
YPServer string
SegmentLengthSeconds int
WebServerPort int
SegmentsInPlaylist int
StreamVariants []models.StreamOutputVariant
ChatEstablishedUserModeTimeDuration time.Duration
FederationUsername string
FederationGoLiveMessage string
YPEnabled bool
ChatEstablishedUserModeTimeDuration time.Duration
}
// GetDefaults will return default configuration values.
func GetDefaults() Defaults {
return Defaults{
Name: "New Owncast Server",
Summary: "This is a new live video streaming server powered by Owncast.",
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.",
ServerWelcomeMessage: "",
Logo: "logo.svg",
AdminPassword: "abc123",
StreamKeys: []models.StreamKey{
{Key: "abc123", Comment: "Default stream key"},
},
Tags: []string{
"owncast",
"streaming",
},
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>
`,
PageBodyContent: "# This is your page content that can be edited from the admin.",
DatabaseFilePath: "data/owncast.db",
@ -81,6 +58,7 @@ func GetDefaults() Defaults { @@ -81,6 +58,7 @@ func GetDefaults() Defaults {
WebServerPort: 8080,
WebServerIP: "0.0.0.0",
RTMPServerPort: 1935,
StreamKey: "abc123",
ChatEstablishedUserModeTimeDuration: time.Minute * 15,

4
config/verifyInstall.go

@ -29,8 +29,8 @@ func VerifyFFMpegPath(path string) error { @@ -29,8 +29,8 @@ func VerifyFFMpegPath(path string) error {
}
mode := stat.Mode()
// source: https://stackoverflow.com/a/60128480
if mode&0o111 == 0 {
//source: https://stackoverflow.com/a/60128480
if mode&0111 == 0 {
return errors.New("ffmpeg path is not executable")
}

7
contrib/README.md

@ -1,7 +0,0 @@ @@ -1,7 +0,0 @@
# Contrib
This directory contains unmaintained, unsupported, and unorganized contributions by the Owncast community.
It is a place to put scripts, config files and examples that might be useful to share with others without expectation that they're an official part of the project.
[Read Drew DeVault's description of the contrib directory](https://drewdevault.com/2020/06/06/Add-a-contrib-directory.html) for details and background.

19
contrib/owncast-sample.service

@ -1,19 +0,0 @@ @@ -1,19 +0,0 @@
[Unit]
Description=Owncast Service
[Service]
Type=simple
WorkingDirectory=[path to owncast directory]
ReadWritePaths=[path to owncast directory]
ExecStart=[path to owncast directory]/owncast
Restart=always
RestartSec=5
User=[user to run owncast as]
Group=[group to run owncast as]
NoNewPrivileges=true
SecureBits=noroot
ProtectSystem=strict
ProtectHome=read-only
[Install]
WantedBy=multi-user.target

82
contrib/owncast_for_windows.md

@ -1,82 +0,0 @@ @@ -1,82 +0,0 @@
# Owncast on Windows
> Note: Owncast currently **does not natively support the Windows Operating System**, however it is possible to run Owncast on Windows using the Windows Subsystem for Linux (WSL2).
This document is a user-contributed document and the Owncast project does not actively maintain Windows support. Hopefully this can be helpful in pointing people in the right direction.
This document list out the steps in detail to install and run Owncast in Windows using Windows Subsystem for Linux, specifically **WSL2**.
Below are steps both for local development, contributing to the project and running it in production.
---
## Required: Installing WSL2 in Windows
There are lots of tutorials available online (videos and docs both) on how to install WSL2.
Here are the official documents from Microsoft -> [Install Linux on Windows with WSL](https://learn.microsoft.com/en-us/windows/wsl/setup/environment)
Some points to remember ->
- Preferable method to install WSL2 is by using the `wsl --install `. If you are facing issues with this method you can look at - [Manual installation steps for older versions of WSL](https://learn.microsoft.com/en-us/windows/wsl/install-manual)
- Make sure you have enabled the Virtual Machine feature. (ignore if used wsl --install method)
- Make sure you have WSL2
- Installed your Linux distribution of choice and make sure you installed the latest available version (Preferably Ubuntu)
### Setting up WSL2 and the distribution of your choice
After basic setup, you can look into setting WSL2 for development. Here is the link for a detailed document by Microsoft - [https://learn.microsoft.com/en-us/windows/wsl/setup/environment](https://learn.microsoft.com/en-us/windows/wsl/setup/environment)
---
## Installing Owncast under WSL2
Once you're running WSL2 in Windows you can install Owncast the same way you would on any Linux distribution by following the [Quickstart](https://owncast.online/quickstart/) guide.
## Contributing to Owncast by performing local development
If you want to use your Windows machine to contribute to Owncast, you'll need to do so under WSL2 and make sure the following prerequisites are installed.
### Make sure all the prerequisites are installed in WSL2
Here is the list for all the prerequisites required ->
- C compiler, such as [GCC compiler](https://gcc.gnu.org/install/download.html) or a [Musl-compatible compiler](https://musl.libc.org/)
- npm (Node Package Manager) is installed as `sudo apt install npm`.
- Node.js is installed (LTS Version) `sudo apt install nodejs`.
- [ffmpeg](https://ffmpeg.org/download.html)
- Install the [Go toolchain](https://golang.org/dl/) (1.21 or above).
### Read more
Once your local development environment is setup, you can read more about how to contribute to Owncast [by reading the development document](https://owncast.online/development/).
## Some possible issues you can face while setting up WSL2
### You have an older version of Nodejs installed in the WSL2
To solve this issue you can look at nvm. Here is one tutorial - [Node-Version-Manager](https://www.digitalocean.com/community/tutorials/how-to-install-node-js-on-ubuntu-20-04#option-3-installing-node-using-the-node-version-manager).
### The broadcasting Software failed to connect to the server
This issue arises when you try to use `rtmp://localhost:1935/live` for example in OBS.
To solve this issue you need to find the correct IP address for the WSL2 you are running and use that instead of localhost.
You can use the below commands to find that ->
Note: you can use either of these, whichever works for you.
- In WSL2 Terminal -
`ip addr show eth0 | grep -oP '(?<=inet\s)\d+(\.\d+){3}'`
- In Windows Terminal -
`wsl -- ip -o -4 -json addr list eth0`
In this result look for "local": X.X.X.X
After finding the IP address in your broadcasting software make the server point to
`rtmp://<your version of IP address>:1935/live`
Example in OBS-Studio ->
![image](https://user-images.githubusercontent.com/73140257/228762798-a0c56695-c692-4295-b11b-f2e85e867ce7.png)
## More resources
- [Windows Subsystem for Linux](https://learn.microsoft.com/en-us/windows/wsl/)
- [Owncast development documentation](https://owncast.online/development/)
- [Owncast quickstart guide](https://owncast.online/quickstart/)
- [Owncast README](https://github.com/owncast/owncast/blob/develop/README.md#building-from-source)

32
contrib/varnish/hitch.conf

@ -1,32 +0,0 @@ @@ -1,32 +0,0 @@
# Run 'man hitch.conf' for a description of all options.
frontend = {
host = "*"
port = "443"
}
backend = "[127.0.0.1]:8443"
workers = 4 # number of CPU cores
daemon = on
# We strongly recommend you create a separate non-privileged hitch
# user and group
user = "hitch"
group = "hitch"
# Enable to let clients negotiate HTTP/2 with ALPN. (default off)
# alpn-protos = "h2, http/1.1"
# run Varnish as backend over PROXY; varnishd -a :80 -a localhost:6086,PROXY ..
write-proxy-v2 = on # Write PROXY header
## ssl config
pem-dir = "/etc/tls/private"
tls-protos = TLSv1.2 TLSv1.3
# ocsp
ocsp-dir = "/etc/hitch/ocsp"
ocsp-verify-staple = on
syslog = on
log-level = 1
tcp-fastopen = on

38
contrib/varnish/vanish.vcl

@ -1,38 +0,0 @@ @@ -1,38 +0,0 @@
vcl 4.0;
backend default {
.host = "localhost";
.port = "8080";
}
sub vcl_recv {
# Implementing websocket support (https://www.varnish-cache.org/docs/4.0/users-guide/vcl-example-websockets.html)
if (req.http.Upgrade ~ "(?i)websocket") {
return (pipe);
}
}
sub vcl_pipe {
if (req.http.upgrade) {
set bereq.http.upgrade = req.http.upgrade;
set bereq.http.connection = req.http.connection;
}
}
sub vcl_backend_response {
# Set 1s ttl if origin response HTTP status code is anything other than 200
if (beresp.status != 200) {
set beresp.ttl = 1s;
set beresp.uncacheable = true;
return (deliver);
}
if (bereq.url ~ "m3u8") {
# assuming chunks are 2 seconds long
set beresp.ttl = 1s;
set beresp.grace = 0s;
}
if (bereq.url ~ "ts") {
set beresp.ttl = 10m;
set beresp.grace = 5m;
}
}

35
controllers/admin/appearance.go

@ -1,35 +0,0 @@ @@ -1,35 +0,0 @@
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")
}

13
controllers/admin/chat.go

@ -31,7 +31,6 @@ func UpdateMessageVisibility(w http.ResponseWriter, r *http.Request) { @@ -31,7 +31,6 @@ func UpdateMessageVisibility(w http.ResponseWriter, r *http.Request) {
}
if r.Method != controllers.POST {
// nolint:goconst
controllers.WriteSimpleResponse(w, false, r.Method+" not supported")
return
}
@ -98,7 +97,6 @@ func GetIPAddressBans(w http.ResponseWriter, r *http.Request) { @@ -98,7 +97,6 @@ func GetIPAddressBans(w http.ResponseWriter, r *http.Request) {
bans, err := data.GetIPAddressBans()
if err != nil {
controllers.WriteSimpleResponse(w, false, err.Error())
return
}
controllers.WriteResponse(w, bans)
@ -165,17 +163,12 @@ func UpdateUserEnabled(w http.ResponseWriter, r *http.Request) { @@ -165,17 +163,12 @@ func UpdateUserEnabled(w http.ResponseWriter, r *http.Request) {
disconnectedUser := user.GetUserByID(request.UserID)
_ = chat.SendSystemAction(fmt.Sprintf("**%s** has been removed from chat.", disconnectedUser.DisplayName), true)
localIP4Address := "127.0.0.1"
localIP6Address := "::1"
// Ban this user's IP address.
for _, client := range clients {
ipAddress := client.IPAddress
if ipAddress != localIP4Address && ipAddress != localIP6Address {
reason := fmt.Sprintf("Banning of %s", disconnectedUser.DisplayName)
if err := data.BanIPAddress(ipAddress, reason); err != nil {
log.Errorln("error banning IP address: ", err)
}
reason := fmt.Sprintf("Banning of %s", disconnectedUser.DisplayName)
if err := data.BanIPAddress(ipAddress, reason); err != nil {
log.Errorln("error banning IP address: ", err)
}
}
}

191
controllers/admin/config.go

@ -1,11 +1,11 @@ @@ -1,11 +1,11 @@
package admin
import (
"encoding/base64"
"encoding/json"
"fmt"
"net"
"net/http"
"net/netip"
"os"
"path/filepath"
"reflect"
@ -16,7 +16,6 @@ import ( @@ -16,7 +16,6 @@ import (
"github.com/owncast/owncast/core/chat"
"github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/core/user"
"github.com/owncast/owncast/core/webhooks"
"github.com/owncast/owncast/models"
"github.com/owncast/owncast/utils"
log "github.com/sirupsen/logrus"
@ -77,7 +76,6 @@ func SetStreamTitle(w http.ResponseWriter, r *http.Request) { @@ -77,7 +76,6 @@ func SetStreamTitle(w http.ResponseWriter, r *http.Request) {
}
if value != "" {
sendSystemChatAction(fmt.Sprintf("Stream title changed to **%s**", value), true)
go webhooks.SendStreamStatusEvent(models.StreamTitleUpdated)
}
controllers.WriteSimpleResponse(w, true, "changed")
}
@ -143,25 +141,6 @@ func SetServerSummary(w http.ResponseWriter, r *http.Request) { @@ -143,25 +141,6 @@ 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) {
@ -200,8 +179,8 @@ func SetExtraPageContent(w http.ResponseWriter, r *http.Request) { @@ -200,8 +179,8 @@ func SetExtraPageContent(w http.ResponseWriter, r *http.Request) {
controllers.WriteSimpleResponse(w, true, "changed")
}
// SetAdminPassword will handle the web config request to set the server admin password.
func SetAdminPassword(w http.ResponseWriter, r *http.Request) {
// SetStreamKey will handle the web config request to set the server stream key.
func SetStreamKey(w http.ResponseWriter, r *http.Request) {
if !requirePOST(w, r) {
return
}
@ -211,7 +190,7 @@ func SetAdminPassword(w http.ResponseWriter, r *http.Request) { @@ -211,7 +190,7 @@ func SetAdminPassword(w http.ResponseWriter, r *http.Request) {
return
}
if err := data.SetAdminPassword(configValue.Value.(string)); err != nil {
if err := data.SetStreamKey(configValue.Value.(string)); err != nil {
controllers.WriteSimpleResponse(w, false, err.Error())
return
}
@ -230,17 +209,39 @@ func SetLogo(w http.ResponseWriter, r *http.Request) { @@ -230,17 +209,39 @@ func SetLogo(w http.ResponseWriter, r *http.Request) {
return
}
value, ok := configValue.Value.(string)
if !ok {
controllers.WriteSimpleResponse(w, false, "unable to find image data")
s := strings.SplitN(configValue.Value.(string), ",", 2)
if len(s) < 2 {
controllers.WriteSimpleResponse(w, false, "Error splitting base64 image data.")
return
}
bytes, extension, err := utils.DecodeBase64Image(value)
bytes, err := base64.StdEncoding.DecodeString(s[1])
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())
@ -397,24 +398,10 @@ func SetServerURL(w http.ResponseWriter, r *http.Request) { @@ -397,24 +398,10 @@ 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
}
// Block Private IP URLs
ipAddr, ipErr := netip.ParseAddr(utils.GetHostnameWithoutPortFromURLString(rawValue))
if ipErr == nil && ipAddr.IsPrivate() {
controllers.WriteSimpleResponse(w, false, "Server URL cannot be private")
return
}
// Trim any trailing slash
serverURL := strings.TrimRight(rawValue, "/")
@ -639,7 +626,6 @@ func SetExternalActions(w http.ResponseWriter, r *http.Request) { @@ -639,7 +626,6 @@ func SetExternalActions(w http.ResponseWriter, r *http.Request) {
if err := data.SetExternalActions(actions.Value); err != nil {
controllers.WriteSimpleResponse(w, false, "unable to update external actions with provided values")
return
}
controllers.WriteSimpleResponse(w, true, "external actions update")
@ -661,22 +647,6 @@ func SetCustomStyles(w http.ResponseWriter, r *http.Request) { @@ -661,22 +647,6 @@ 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 {
@ -692,7 +662,6 @@ func SetForbiddenUsernameList(w http.ResponseWriter, r *http.Request) { @@ -692,7 +662,6 @@ func SetForbiddenUsernameList(w http.ResponseWriter, r *http.Request) {
if err := data.SetForbiddenUsernameList(request.Value); err != nil {
controllers.WriteSimpleResponse(w, false, err.Error())
return
}
controllers.WriteSimpleResponse(w, true, "forbidden username list updated")
@ -714,7 +683,6 @@ func SetSuggestedUsernameList(w http.ResponseWriter, r *http.Request) { @@ -714,7 +683,6 @@ func SetSuggestedUsernameList(w http.ResponseWriter, r *http.Request) {
if err := data.SetSuggestedUsernamesList(request.Value); err != nil {
controllers.WriteSimpleResponse(w, false, err.Error())
return
}
controllers.WriteSimpleResponse(w, true, "suggested username list updated")
@ -740,68 +708,6 @@ func SetChatJoinMessagesEnabled(w http.ResponseWriter, r *http.Request) { @@ -740,68 +708,6 @@ 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")
}
// SetDisableSearchIndexing will set search indexing support.
func SetDisableSearchIndexing(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 search indexing")
return
}
if err := data.SetDisableSearchIndexing(configValue.Value.(bool)); err != nil {
controllers.WriteSimpleResponse(w, false, err.Error())
return
}
controllers.WriteSimpleResponse(w, true, "search indexing support updated")
}
// SetVideoServingEndpoint will save the video serving endpoint.
func SetVideoServingEndpoint(w http.ResponseWriter, r *http.Request) {
endpoint, success := getValueFromRequest(w, r)
if !success {
controllers.WriteSimpleResponse(w, false, "unable to update custom video serving endpoint")
return
}
value, ok := endpoint.Value.(string)
if !ok {
controllers.WriteSimpleResponse(w, false, "unable to update custom video serving endpoint")
return
}
if err := data.SetVideoServingEndpoint(value); err != nil {
controllers.WriteSimpleResponse(w, false, err.Error())
return
}
controllers.WriteSimpleResponse(w, true, "custom video serving endpoint updated")
}
func requirePOST(w http.ResponseWriter, r *http.Request) bool {
if r.Method != controllers.POST {
controllers.WriteSimpleResponse(w, false, r.Method+" not supported")
@ -841,40 +747,3 @@ func getValuesFromRequest(w http.ResponseWriter, r *http.Request) ([]ConfigValue @@ -841,40 +747,3 @@ 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 len(streamKeys.Value) == 0 {
controllers.WriteSimpleResponse(w, false, "must provide at least one valid stream key")
return
}
for _, streamKey := range streamKeys.Value {
if streamKey.Key == "" {
controllers.WriteSimpleResponse(w, false, "stream key cannot be empty")
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

@ -1,92 +0,0 @@ @@ -1,92 +0,0 @@
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))
}

4
controllers/admin/externalAPIUsers.go

@ -6,7 +6,6 @@ import ( @@ -6,7 +6,6 @@ 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"
@ -42,7 +41,7 @@ func CreateExternalAPIUser(w http.ResponseWriter, r *http.Request) { @@ -42,7 +41,7 @@ func CreateExternalAPIUser(w http.ResponseWriter, r *http.Request) {
return
}
color := utils.GenerateRandomDisplayColor(config.MaxUserColor)
color := utils.GenerateRandomDisplayColor()
if err := user.InsertExternalAPIUser(token, request.Name, color, request.Scopes); err != nil {
controllers.InternalErrorHandler(w, err)
@ -69,6 +68,7 @@ func GetExternalAPIUsers(w http.ResponseWriter, r *http.Request) { @@ -69,6 +68,7 @@ func GetExternalAPIUsers(w http.ResponseWriter, r *http.Request) {
controllers.InternalErrorHandler(w, err)
return
}
controllers.WriteResponse(w, tokens)
}

1
controllers/admin/federation.go

@ -24,7 +24,6 @@ func SendFederatedMessage(w http.ResponseWriter, r *http.Request) { @@ -24,7 +24,6 @@ func SendFederatedMessage(w http.ResponseWriter, r *http.Request) {
message, ok := configValue.Value.(string)
if !ok {
controllers.WriteSimpleResponse(w, false, "unable to send message")
return
}
if err := activitypub.SendPublicFederatedMessage(message); err != nil {

57
controllers/admin/index.go

@ -0,0 +1,57 @@ @@ -0,0 +1,57 @@
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))
}

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

Loading…
Cancel
Save