Compare commits
1 Commits
untagged-0
...
v0.0.10
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
390b3fc468 |
@@ -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 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 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)
|
||||
@@ -1 +0,0 @@
|
||||
test/automated/api/node_modules
|
||||
@@ -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
|
||||
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
|
||||
|
||||
76
.gitattributes
vendored
76
.gitattributes
vendored
@@ -1,65 +1,11 @@
|
||||
# Recreate this file via
|
||||
# find static -type d -print0 | xargs -0 -I {} echo "{}/* linguist-vendored" | xclip -selection clipboard
|
||||
static/* 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
|
||||
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
|
||||
doc/api/* linguist-documentation
|
||||
|
||||
4
.github/ISSUE_TEMPLATE/bug-report-feature-request.md
vendored
Normal file
4
.github/ISSUE_TEMPLATE/bug-report-feature-request.md
vendored
Normal file
@@ -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.
|
||||
---
|
||||
@@ -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.
|
||||
@@ -1,24 +0,0 @@
|
||||
# Read first
|
||||
|
||||
If this is an unsolicited change, or there is no existing issue filed for it, please open a GitHub issue before creating a pull request. This will allow us to discuss the motivations and the big picture behind the change first. It's possible there may be other solutions that should be discussed for what you think should be built. It is possible your change will be rejected unless some discussion around your proposal happens first. While creating this PR means you probably already did the work, it still makes sense to file an issue now, and into the future when you have proposed changes.
|
||||
|
||||
## Description
|
||||
|
||||
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 and start a conversation, even if your PR is not ready for review.
|
||||
|
||||
Fixes # (issue)
|
||||
|
||||
## Screenshot Examples or Logs
|
||||
|
||||
If this is a frontend change, please include a screenshot of the change. If this is a backend change, please include relevant logs or examples of the change in action if applicable.
|
||||
|
||||
---
|
||||
|
||||
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?
|
||||
|
||||
Thank you so much for contributing to Owncast! 🎉
|
||||
1
.github/codeql/go.yml
vendored
1
.github/codeql/go.yml
vendored
@@ -1 +0,0 @@
|
||||
name: Go config
|
||||
4
.github/codeql/javascript.yml
vendored
4
.github/codeql/javascript.yml
vendored
@@ -1,4 +0,0 @@
|
||||
name: Javascript config
|
||||
|
||||
paths-ignore:
|
||||
- static/web
|
||||
26
.github/dependabot.yml
vendored
Normal file
26
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
# To get started with Dependabot version updates, you'll need to specify which
|
||||
# package ecosystems to update and where the package manifests are located.
|
||||
# Please see the documentation for all configuration options:
|
||||
# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "npm" # See documentation for possible values
|
||||
directory: "/build/javascript" # Location of package manifests
|
||||
open-pull-requests-limit: 3
|
||||
schedule:
|
||||
interval: "daily"
|
||||
reviewers:
|
||||
- "gabek"
|
||||
|
||||
- package-ecosystem: "gomod"
|
||||
directory: "/"
|
||||
open-pull-requests-limit: 3
|
||||
schedule:
|
||||
interval: "daily"
|
||||
reviewers:
|
||||
- "gabek"
|
||||
ignore:
|
||||
# Ignore patch revisions of the aws sdk
|
||||
- dependency-name: "github.com/aws/aws-sdk-go"
|
||||
update-types: ["version-update:semver-patch"]
|
||||
27
.github/stale.yml
vendored
Normal file
27
.github/stale.yml
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
# Number of days of inactivity before an issue becomes stale
|
||||
daysUntilStale: 60
|
||||
# Number of days of inactivity before a stale issue is closed
|
||||
daysUntilClose: 7
|
||||
# Issues with these labels will never be considered stale
|
||||
exemptLabels:
|
||||
- backlog
|
||||
# Label to use when marking an issue as stale
|
||||
staleLabel: stale
|
||||
# Comment to post when marking an issue as stale. Set to `false` to disable
|
||||
markComment: >
|
||||
This issue has been automatically marked as stale because it has not had
|
||||
recent activity. It will be closed if no further activity occurs. Thank you
|
||||
for your contributions.
|
||||
# Comment to post when closing a stale issue. Set to `false` to disable
|
||||
closeComment: false
|
||||
exemptMilestones: true
|
||||
|
||||
# Since old PRs are less useful than old issues ping them sooner.
|
||||
pulls:
|
||||
daysUntilStale: 30
|
||||
markComment: >
|
||||
This pull request has not had any activity in 30 days. Since things move fast it's best
|
||||
to get PRs merged in, or to allow somebody else to work on it so the change can get in.
|
||||
This PR will be closed if no further activity occurs. Thank you for your contributions!
|
||||
exemptLabels:
|
||||
- bot
|
||||
28
.github/workflows/actions-lint.yml
vendored
28
.github/workflows/actions-lint.yml
vendored
@@ -1,28 +0,0 @@
|
||||
name: Lint
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- '.github/workflows/*'
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/*'
|
||||
|
||||
jobs:
|
||||
actionlint:
|
||||
name: GitHub actions
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out pull request code
|
||||
uses: actions/checkout@v4
|
||||
if: github.event_name == 'pull_request'
|
||||
with:
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
|
||||
- name: Check out repository code
|
||||
uses: actions/checkout@v4
|
||||
if: github.event_name == 'push'
|
||||
|
||||
- uses: docker://rhysd/actionlint:latest
|
||||
with:
|
||||
args: -shellcheck= -color
|
||||
42
.github/workflows/auto-comment-on-label.yaml
vendored
42
.github/workflows/auto-comment-on-label.yaml
vendored
@@ -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@fdb73c443d3a4f66832374f01fb9a713fad84937
|
||||
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.
|
||||
14
.github/workflows/automated-browser.yml
vendored
Normal file
14
.github/workflows/automated-browser.yml
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
name: Automated browser tests
|
||||
on: [push, pull_request]
|
||||
jobs:
|
||||
browser:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Run browser tests
|
||||
run: cd test/automated/browser && ./run.sh
|
||||
|
||||
- uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: screenshots-${{ github.run_id }}
|
||||
path: test/automated/browser/screenshots/*.png
|
||||
61
.github/workflows/automated-end-to-end-api.yaml
vendored
61
.github/workflows/automated-end-to-end-api.yaml
vendored
@@ -3,60 +3,19 @@ name: Automated API tests
|
||||
on:
|
||||
push:
|
||||
paths-ignore:
|
||||
- 'web/**'
|
||||
- 'webroot/**'
|
||||
- pkged.go
|
||||
pull_request:
|
||||
paths-ignore:
|
||||
- 'web/**'
|
||||
|
||||
- 'webroot/**'
|
||||
- pkged.go
|
||||
|
||||
jobs:
|
||||
test:
|
||||
api:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- id: skip_check
|
||||
uses: fkirc/skip-duplicate-actions@v5
|
||||
with:
|
||||
concurrent_skipping: 'same_content_newer'
|
||||
|
||||
- name: Check out pull request code
|
||||
uses: actions/checkout@v4
|
||||
if: github.event_name == 'pull_request'
|
||||
with:
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
|
||||
- name: Check out repository code
|
||||
uses: actions/checkout@v4
|
||||
if: github.event_name == 'push'
|
||||
|
||||
- name: Get changed files
|
||||
id: changed-files-yaml
|
||||
uses: tj-actions/changed-files@v45
|
||||
with:
|
||||
files_yaml: |
|
||||
src:
|
||||
- '**/*.{go,mod,sum}'
|
||||
|
||||
- uses: earthly/actions-setup@v1
|
||||
if: steps.changed-files-yaml.outputs.src_any_changed == 'true'
|
||||
with:
|
||||
version: 'latest' # or pin to an specific version, e.g. "v0.6.10"
|
||||
|
||||
- name: Earthly version
|
||||
if: steps.changed-files-yaml.outputs.src_any_changed == 'true'
|
||||
run: earthly --version
|
||||
|
||||
- name: Set up QEMU
|
||||
if: steps.changed-files-yaml.outputs.src_any_changed == 'true'
|
||||
id: qemu
|
||||
uses: docker/setup-qemu-action@v3
|
||||
with:
|
||||
image: tonistiigi/binfmt:latest
|
||||
platforms: all
|
||||
|
||||
- uses: actions/checkout@v2
|
||||
- name: Run API tests
|
||||
if: steps.changed-files-yaml.outputs.src_any_changed == 'true'
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 10
|
||||
max_attempts: 3
|
||||
command: earthly +api-tests
|
||||
run: cd test/automated/api && ./run.sh
|
||||
|
||||
|
||||
61
.github/workflows/browser-testing.yml
vendored
61
.github/workflows/browser-testing.yml
vendored
@@ -1,61 +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: Check out pull request code
|
||||
uses: actions/checkout@v4
|
||||
if: github.event_name == 'pull_request'
|
||||
with:
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
|
||||
- name: Check out repository code
|
||||
uses: actions/checkout@v4
|
||||
if: github.event_name == 'push'
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22.9.0'
|
||||
|
||||
- name: Cache node modules
|
||||
uses: actions/cache@v4
|
||||
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.22'
|
||||
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@v3
|
||||
with:
|
||||
timeout_minutes: 20
|
||||
max_attempts: 3
|
||||
command: cd test/automated/browser && ./run.sh
|
||||
61
.github/workflows/build-storybook.yml
vendored
61
.github/workflows/build-storybook.yml
vendored
@@ -1,61 +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: Check out pull request code
|
||||
uses: actions/checkout@v4
|
||||
if: github.event_name == 'pull_request'
|
||||
with:
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
|
||||
- name: Check out repository code
|
||||
uses: actions/checkout@v4
|
||||
if: github.event_name == 'push'
|
||||
|
||||
- name: Cache node modules
|
||||
uses: actions/cache@v4
|
||||
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@v3
|
||||
with:
|
||||
token: ${{ secrets.BUNDLE_STORYBOOK_OWNCAST_ONLINE }}
|
||||
repository: owncast/owncast.github.io
|
||||
event-type: bundle-components-library
|
||||
65
.github/workflows/chromatic.yml
vendored
65
.github/workflows/chromatic.yml
vendored
@@ -1,65 +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 pull request code
|
||||
uses: actions/checkout@v4
|
||||
if: github.event_name == 'pull_request' || github.event_name == 'pull_request_target'
|
||||
with:
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
|
||||
- name: Check out repository code
|
||||
uses: actions/checkout@v4
|
||||
if: github.event_name == 'push'
|
||||
|
||||
- name: Get changed files
|
||||
id: changed-files-yaml
|
||||
uses: tj-actions/changed-files@v45
|
||||
with:
|
||||
path: 'web'
|
||||
files_ignore: |
|
||||
static/**
|
||||
web/next.config.js
|
||||
files_yaml: |
|
||||
src:
|
||||
- '**/*.{js,ts,tsx,jsx,md}'
|
||||
|
||||
- name: Install dependencies
|
||||
if: ${{ github.actor != 'renovate[bot]' && github.actor != 'renovate' && steps.changed-files-yaml.outputs.src_any_changed == 'true'}}
|
||||
run: npm install
|
||||
|
||||
- name: Publish to Chromatic
|
||||
if: ${{ github.actor != 'renovate[bot]' && github.actor != 'renovate' && steps.changed-files-yaml.outputs.src_any_changed == 'true' }}
|
||||
uses: chromaui/action@v11
|
||||
|
||||
# Chromatic GitHub Action options
|
||||
with:
|
||||
workingDir: web
|
||||
projectToken: f47410569b62
|
||||
onlyChanged: true
|
||||
77
.github/workflows/codeql-analysis.yml
vendored
77
.github/workflows/codeql-analysis.yml
vendored
@@ -9,18 +9,16 @@
|
||||
# the `language` matrix defined below to confirm you have the correct set of
|
||||
# supported CodeQL languages.
|
||||
#
|
||||
name: 'CodeQL'
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [develop]
|
||||
paths-ignore:
|
||||
- 'static/**'
|
||||
branches: [ develop ]
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: [develop]
|
||||
paths-ignore:
|
||||
- 'static/**'
|
||||
branches: [ develop ]
|
||||
schedule:
|
||||
- cron: '36 7 * * 4'
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
@@ -30,53 +28,40 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: ['go', 'javascript']
|
||||
language: [ 'go', 'javascript' ]
|
||||
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
|
||||
# Learn more:
|
||||
# https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed
|
||||
|
||||
steps:
|
||||
- name: Check out pull request code
|
||||
uses: actions/checkout@v4
|
||||
if: github.event_name == 'pull_request'
|
||||
with:
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Check out repository code
|
||||
uses: actions/checkout@v4
|
||||
if: github.event_name == 'push'
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v1
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# 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.
|
||||
# queries: ./path/to/local/query, your-org/your-repo/queries@main
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.22'
|
||||
cache: true
|
||||
# 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@v1
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
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.
|
||||
# queries: ./path/to/local/query, your-org/your-repo/queries@main
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
|
||||
# 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
|
||||
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
|
||||
# and modify them (or add more) to build your code if your project
|
||||
# uses a compiled language
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
#- run: |
|
||||
# make bootstrap
|
||||
# make release
|
||||
|
||||
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
|
||||
# and modify them (or add more) to build your code if your project
|
||||
# uses a compiled language
|
||||
|
||||
#- run: |
|
||||
# make bootstrap
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v1
|
||||
|
||||
36
.github/workflows/container-lint.yml
vendored
36
.github/workflows/container-lint.yml
vendored
@@ -1,36 +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:
|
||||
- name: Check out pull request code
|
||||
uses: actions/checkout@v4
|
||||
if: github.event_name == 'pull_request'
|
||||
with:
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
|
||||
- name: Check out repository code
|
||||
uses: actions/checkout@v4
|
||||
if: github.event_name == 'push'
|
||||
|
||||
- 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
|
||||
61
.github/workflows/container.yaml
vendored
61
.github/workflows/container.yaml
vendored
@@ -1,61 +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: Check out pull request code
|
||||
uses: actions/checkout@v4
|
||||
if: github.event_name == 'pull_request'
|
||||
with:
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
|
||||
- name: Check out repository code
|
||||
uses: actions/checkout@v4
|
||||
if: github.event_name == 'push' || github.event_name == 'schedule'
|
||||
|
||||
- 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@v3
|
||||
with:
|
||||
timeout_minutes: 20
|
||||
max_attempts: 3
|
||||
command: ./build/develop/container.sh
|
||||
53
.github/workflows/css-lint.yaml
vendored
53
.github/workflows/css-lint.yaml
vendored
@@ -1,53 +0,0 @@
|
||||
name: CSS Lint and Formatting
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'web/**'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'web/**'
|
||||
|
||||
jobs:
|
||||
css-lint:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./web
|
||||
|
||||
steps:
|
||||
- name: Check out pull request code
|
||||
uses: actions/checkout@v4
|
||||
if: github.event_name == 'pull_request'
|
||||
with:
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
|
||||
- name: Check out repository code
|
||||
uses: actions/checkout@v4
|
||||
if: github.event_name == 'push'
|
||||
|
||||
- name: Get changed files
|
||||
id: changed-files-yaml
|
||||
uses: tj-actions/changed-files@v45
|
||||
with:
|
||||
path: 'web'
|
||||
files_yaml: |
|
||||
src:
|
||||
- '**/*.{css,scss}'
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22.9.0'
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.changed-files-yaml.outputs.src_any_changed == 'true'
|
||||
run: npm install
|
||||
|
||||
- name: Run Prettier
|
||||
if: steps.changed-files-yaml.outputs.src_any_changed == 'true'
|
||||
run: npx prettier --check ${{ steps.changed-files-yaml.outputs.src_all_changed_files }}
|
||||
|
||||
- name: Run Stylelint
|
||||
if: steps.changed-files-yaml.outputs.src_any_changed == 'true'
|
||||
run: npx stylelint ${{ steps.changed-files-yaml.outputs.src_all_changed_files }}
|
||||
24
.github/workflows/docker-nightly.yaml
vendored
Normal file
24
.github/workflows/docker-nightly.yaml
vendored
Normal file
@@ -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@v2
|
||||
- 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
|
||||
@@ -9,26 +9,19 @@ jobs:
|
||||
name: Generate API Documentation
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out pull request code
|
||||
uses: actions/checkout@v4
|
||||
if: github.event_name == 'pull_request'
|
||||
with:
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Check out repository code
|
||||
uses: actions/checkout@v4
|
||||
if: github.event_name == 'push'
|
||||
- name: Run redoc on openapi.yaml
|
||||
run: |
|
||||
npx redoc-cli bundle openapi.yaml -o doc/api/index.html --options '{"hideHostname": true, "pathInMiddlePanel": true}'
|
||||
|
||||
- name: Run redoc on openapi.yaml
|
||||
run: |
|
||||
npx @redocly/cli --config docs/api/redocly.yaml build-docs openapi.yaml -o docs/api/index.html
|
||||
|
||||
- name: Commit changes
|
||||
uses: EndBug/add-and-commit@v9
|
||||
with:
|
||||
author_name: Owncast
|
||||
author_email: owncast@owncast.online
|
||||
message: 'Commit updated API documentation'
|
||||
add: 'docs/api/index.html'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Commit changes
|
||||
uses: EndBug/add-and-commit@v5
|
||||
with:
|
||||
author_name: Owncast
|
||||
author_email: owncast@owncast.online
|
||||
message: "Commit updated API documentation"
|
||||
add: "doc/api/index.html"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
38
.github/workflows/go-lint.yml
vendored
38
.github/workflows/go-lint.yml
vendored
@@ -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.22'
|
||||
cache: true
|
||||
- uses: actions/checkout@v4
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v6
|
||||
with:
|
||||
only-new-issues: true
|
||||
args: --timeout=3m
|
||||
89
.github/workflows/go-tests.yaml
vendored
89
.github/workflows/go-tests.yaml
vendored
@@ -1,89 +0,0 @@
|
||||
name: Go Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
paths-ignore:
|
||||
- 'web/**'
|
||||
pull_request:
|
||||
paths-ignore:
|
||||
- 'web/**'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
strategy:
|
||||
matrix:
|
||||
go-version: [1.21.x, 1.22.x]
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Get changed files
|
||||
id: changed-files-yaml
|
||||
uses: tj-actions/changed-files@v45
|
||||
with:
|
||||
files_yaml: |
|
||||
src:
|
||||
- '**/*.{go,mod,sum}'
|
||||
|
||||
- uses: actions/cache@v4
|
||||
if: steps.changed-files-yaml.outputs.src_any_changed == 'true'
|
||||
with:
|
||||
path: |
|
||||
~/.cache/go-build
|
||||
~/go/pkg/mod
|
||||
key: go-test-${{ github.sha }}
|
||||
restore-keys: |
|
||||
go-test-
|
||||
|
||||
- name: Install go
|
||||
if: steps.changed-files-yaml.outputs.src_any_changed == 'true'
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '^1'
|
||||
cache: true
|
||||
|
||||
- name: Run tests
|
||||
if: steps.changed-files-yaml.outputs.src_any_changed == 'true'
|
||||
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
|
||||
|
||||
- name: Get changed files
|
||||
id: changed-files-yaml
|
||||
uses: tj-actions/changed-files@v45
|
||||
with:
|
||||
files_yaml: |
|
||||
src:
|
||||
- '**/*.{go,mod,sum}'
|
||||
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cache/go-build
|
||||
~/go/pkg/mod
|
||||
key: go-test-${{ github.sha }}
|
||||
restore-keys: |
|
||||
go-test-
|
||||
|
||||
- name: Install go
|
||||
if: steps.changed-files-yaml.outputs.src_any_changed == 'true'
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '^1'
|
||||
cache: true
|
||||
|
||||
- name: Run tests
|
||||
if: steps.changed-files-yaml.outputs.src_any_changed == 'true'
|
||||
run: go test ./...
|
||||
78
.github/workflows/hls-tests.yml
vendored
78
.github/workflows/hls-tests.yml
vendored
@@ -1,78 +0,0 @@
|
||||
name: HLS tests
|
||||
|
||||
on:
|
||||
push:
|
||||
paths-ignore:
|
||||
- 'web/**'
|
||||
pull_request:
|
||||
paths-ignore:
|
||||
- 'web/**'
|
||||
|
||||
env:
|
||||
S3_BUCKET: ${{ secrets.S3_BUCKET }}
|
||||
S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }}
|
||||
S3_ENDPOINT: ${{ secrets.S3_ENDPOINT }}
|
||||
S3_REGION: ${{ secrets.S3_REGION }}
|
||||
S3_SECRET: ${{ secrets.S3_SECRET }}
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- id: skip_check
|
||||
uses: fkirc/skip-duplicate-actions@v5
|
||||
with:
|
||||
concurrent_skipping: 'same_content_newer'
|
||||
|
||||
- name: Check out pull request code
|
||||
uses: actions/checkout@v4
|
||||
if: github.event_name == 'pull_request'
|
||||
with:
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
|
||||
- name: Check out repository code
|
||||
uses: actions/checkout@v4
|
||||
if: github.event_name == 'push'
|
||||
|
||||
- name: Get changed files
|
||||
id: changed-files-yaml
|
||||
uses: tj-actions/changed-files@v45
|
||||
with:
|
||||
files_yaml: |
|
||||
src:
|
||||
- '**/*.{go,mod,sum}'
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
if: steps.changed-files-yaml.outputs.src_any_changed == 'true'
|
||||
with:
|
||||
go-version: '1.22'
|
||||
cache: true
|
||||
|
||||
- name: Cache node modules
|
||||
uses: actions/cache@v4
|
||||
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
|
||||
if: steps.changed-files-yaml.outputs.src_any_changed == 'true'
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 10
|
||||
max_attempts: 3
|
||||
command: cd test/automated/hls && ./run.sh
|
||||
|
||||
- name: S3 storage
|
||||
if: steps.changed-files-yaml.outputs.src_any_changed == 'true'
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 10
|
||||
max_attempts: 3
|
||||
command: cd test/automated/hls && ./run-s3.sh
|
||||
222
.github/workflows/javascript-format-test-build.yml
vendored
222
.github/workflows/javascript-format-test-build.yml
vendored
@@ -1,222 +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: Check out pull request code
|
||||
uses: actions/checkout@v4
|
||||
if: github.event_name == 'pull_request'
|
||||
with:
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
ref: ${{github.event.pull_request.head.ref}}
|
||||
|
||||
- name: Setup Nodejs
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22.9.0'
|
||||
|
||||
- name: Check out repository code
|
||||
uses: actions/checkout@v4
|
||||
if: github.event_name == 'push'
|
||||
|
||||
- name: Get changed files
|
||||
id: changed-files-yaml
|
||||
uses: tj-actions/changed-files@v45
|
||||
with:
|
||||
path: 'web'
|
||||
files_ignore: |
|
||||
static/**
|
||||
web/next.config.js
|
||||
files_yaml: |
|
||||
src:
|
||||
- '**/*.{js,ts,tsx,jsx,md}'
|
||||
|
||||
- name: Cache node modules
|
||||
uses: actions/cache@v4
|
||||
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 and fix
|
||||
if: steps.changed-files-yaml.outputs.src_any_changed == 'true' && github.event_name != 'pull_request'
|
||||
run: npx eslint --fix ${{ steps.changed-files-yaml.outputs.src_all_changed_files }}
|
||||
|
||||
- name: Lint
|
||||
if: steps.changed-files-yaml.outputs.src_any_changed == 'true' && github.event_name == 'pull_request'
|
||||
run: npx eslint ${{ steps.changed-files-yaml.outputs.src_all_changed_files }}
|
||||
|
||||
- name: Prettier formatting
|
||||
if: steps.changed-files-yaml.outputs.src_any_changed == 'true' && github.event_name == 'pull_request'
|
||||
run: npx prettier --write ${{ steps.changed-files-yaml.outputs.src_all_changed_files }}
|
||||
|
||||
- name: Prettier check
|
||||
if: steps.changed-files-yaml.outputs.src_any_changed == 'true' && github.event_name != 'pull_request'
|
||||
run: npx prettier ${{ steps.changed-files-yaml.outputs.src_all_changed_files }}
|
||||
|
||||
- name: Debug changed files output
|
||||
run: 'pwd && echo "Changed files: ${{ steps.changed-files-yaml.outputs.src_all_changed_files }}"'
|
||||
|
||||
- name: Commit changes
|
||||
if: steps.changed-files-yaml.outputs.src_any_changed == 'true' && github.event_name != 'pull_request'
|
||||
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 }}
|
||||
cwd: './web' # Ensure this is the correct relative directory
|
||||
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: Check out pull request code
|
||||
uses: actions/checkout@v4
|
||||
if: github.event_name == 'pull_request'
|
||||
with:
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
|
||||
- name: Check out repository code
|
||||
uses: actions/checkout@v4
|
||||
if: github.event_name == 'push'
|
||||
|
||||
- name: Setup Nodejs
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22.9.0'
|
||||
|
||||
- name: Cache node modules
|
||||
uses: actions/cache@v4
|
||||
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
|
||||
|
||||
- name: Run tests
|
||||
working-directory: ./web
|
||||
run: npm test
|
||||
|
||||
# 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: Setup Nodejs
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22.9.0'
|
||||
|
||||
- name: Cache node modules
|
||||
uses: actions/cache@v4
|
||||
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: Check out pull request code
|
||||
uses: actions/checkout@v4
|
||||
if: github.event_name == 'pull_request'
|
||||
with:
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
|
||||
- name: Check out repository code
|
||||
uses: actions/checkout@v4
|
||||
if: github.event_name == 'push'
|
||||
|
||||
- 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
vendored
Normal file
29
.github/workflows/javascript-formatting.yml
vendored
Normal file
@@ -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@v2
|
||||
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@v3.3
|
||||
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 }}
|
||||
32
.github/workflows/javascript-packages.yaml
vendored
Normal file
32
.github/workflows/javascript-packages.yaml
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
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@v2
|
||||
with:
|
||||
# Make sure the actual branch is checked out when running on pull requests
|
||||
ref: ${{ github.head_ref }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
cd build/javascript
|
||||
npm run build
|
||||
|
||||
- name: Commit changes
|
||||
uses: EndBug/add-and-commit@v5
|
||||
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 }}
|
||||
24
.github/workflows/lint.yml
vendored
Normal file
24
.github/workflows/lint.yml
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
name: lint
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
golangci:
|
||||
name: lint
|
||||
if: ${{ github.actor != 'dependabot[bot]' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v2
|
||||
with:
|
||||
# Optional: golangci-lint command line arguments.
|
||||
args: --timeout 5m0s
|
||||
# Optional: working directory, useful for monorepos
|
||||
# working-directory: somedir
|
||||
# Optional: show only new issues if it's a pull request. The default value is `false`.
|
||||
only-new-issues: true
|
||||
38
.github/workflows/shellcheck.yml
vendored
38
.github/workflows/shellcheck.yml
vendored
@@ -1,38 +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:
|
||||
- name: Check out pull request code
|
||||
uses: actions/checkout@v4
|
||||
if: github.event_name == 'pull_request'
|
||||
with:
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
|
||||
- name: Check out repository code
|
||||
uses: actions/checkout@v4
|
||||
if: github.event_name == 'push'
|
||||
|
||||
- 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
|
||||
49
.github/workflows/stale.yml
vendored
49
.github/workflows/stale.yml
vendored
@@ -1,49 +0,0 @@
|
||||
name: 'Close stale issues and PRs'
|
||||
on:
|
||||
schedule:
|
||||
- cron: '30 */2 * * *'
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v9
|
||||
with:
|
||||
exempt-all-milestones: true
|
||||
|
||||
days-before-issue-stale: 60
|
||||
days-before-issue-close: 67
|
||||
exempt-issue-labels: backlog,long-lived,bot
|
||||
exempt-all-issue-milestones: true
|
||||
stale-issue-message: >
|
||||
This issue has been automatically marked as stale because it has not had
|
||||
recent activity. It will be closed if no further activity occurs. If this
|
||||
was a feature request that others have shown no interest in, then it's
|
||||
unlikely to get implemented due to lack of interest. If others also
|
||||
want to see this feature then now is the time to say something! If this
|
||||
is a bug report or you have questions that still need answering, please say
|
||||
something. Feel free to drop by [our chat](https://owncast.rocket.chat) if
|
||||
you'd like to discuss in real-time with people.
|
||||
close-issue-message: >
|
||||
This issue has been automatically closed due to inactivity. This isn't done
|
||||
to be a jerk, or because the project doesn't care. But simply to keep the focus
|
||||
on things that are actively discussed, and has continued interest from the community and
|
||||
Owncast developers. Feel free to to comment if there is still discussion to be
|
||||
had, or if you plan to work on it. Feel free to drop by [our chat](https://owncast.rocket.chat)
|
||||
if you'd like to discuss in real-time with people. Thank you for being involved!
|
||||
|
||||
days-before-pr-stale: 30
|
||||
days-before-pr-close: 37
|
||||
exempt-pr-labels: backlog,long-lived,bot
|
||||
exempt-all-pr-milestones: true
|
||||
stale-pr-message: >
|
||||
This pull request has not had any activity in 30 days. If it has been abandoned
|
||||
no future actions are necessary, it will be automatically closed. If this is a PR
|
||||
with no clear plan on how to move forward on it getting into the project, then
|
||||
further discussion is needed. Now is a good time to discuss if this is still
|
||||
something that should be worked on. If this PR is idle simply because nobody
|
||||
has reviewed it, then feel free to ping somebody. However, if this PR is not linked to an
|
||||
existing issue regarding something that was previously determined to be important, then even
|
||||
more discussion needs to take place before it can get anywhere.
|
||||
This PR will be closed if no further activity occurs. Thank you for your contributions!
|
||||
close-pr-message: 'This PR was closed because it has been stalled for 10 days with no activity.'
|
||||
42
.github/workflows/test.yaml
vendored
Normal file
42
.github/workflows/test.yaml
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
name: Tests
|
||||
|
||||
on: [push, pull_request]
|
||||
jobs:
|
||||
test:
|
||||
strategy:
|
||||
matrix:
|
||||
go-version: [1.15.x, 1.16.x]
|
||||
os: [ubuntu-latest, macos-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Install go
|
||||
uses: actions/setup-go@v2
|
||||
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@v2
|
||||
|
||||
- name: Install go
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: "^1"
|
||||
|
||||
- name: Run tests
|
||||
run: go test ./...
|
||||
|
||||
62
.github/workflows/translations.yml
vendored
62
.github/workflows/translations.yml
vendored
@@ -1,62 +0,0 @@
|
||||
name: Translation job
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Run the workflow every hour
|
||||
- cron: "0 * * * *"
|
||||
push:
|
||||
paths:
|
||||
- 'web/i18n/en/translation.json'
|
||||
- 'web/**/*.tsx'
|
||||
- 'web/**/*.js'
|
||||
- 'crowdin.yml'
|
||||
- '.github/workflows/translations.yml'
|
||||
- 'web/i18next-parser.config.mjs'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
generate-translations:
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./web
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install dependencies
|
||||
if: ${{ github.actor != 'renovate[bot]' && github.actor != 'renovate' }}
|
||||
run: npm install
|
||||
|
||||
- name: Generate translation files
|
||||
run: npm run translate
|
||||
|
||||
- name: Crowdin upload sources/download translations
|
||||
uses: crowdin/github-action@v1
|
||||
with:
|
||||
upload_sources: true
|
||||
download_translations: true
|
||||
create_pull_request: true
|
||||
pull_request_title: 'New Translations'
|
||||
localization_branch_name: translations
|
||||
pull_request_base_branch_name: 'develop'
|
||||
commit_message: 'Updated translations'
|
||||
config: crowdin.yml
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
|
||||
- name: Commit changes
|
||||
uses: EndBug/add-and-commit@v9
|
||||
with:
|
||||
author_name: Owncast
|
||||
author_email: owncast@owncast.online
|
||||
message: 'Commit updated translations'
|
||||
add: 'web/i18n/**'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -4,7 +4,6 @@
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
.DS_Store
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
@@ -28,7 +27,6 @@ webroot/preview.gif
|
||||
webroot/hls
|
||||
webroot/static/content.md
|
||||
hls/
|
||||
!test/automated/hls/
|
||||
dist/
|
||||
data/
|
||||
transcoder.log
|
||||
@@ -40,10 +38,3 @@ backup/
|
||||
!core/data
|
||||
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
|
||||
|
||||
@@ -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 .
|
||||
@@ -2,18 +2,13 @@ run:
|
||||
tests: false
|
||||
modules-download-mode: readonly
|
||||
|
||||
# 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.22'
|
||||
|
||||
issues:
|
||||
# The linter has a default list of ignorable errors. Turning this on will enable that list.
|
||||
exclude-use-default: false
|
||||
|
||||
|
||||
# Maximum issues count per one linter. Set to 0 to disable. Default is 50.
|
||||
max-issues-per-linter: 0
|
||||
|
||||
|
||||
# Maximum count of issues with the same text. Set to 0 to disable. Default is 3.
|
||||
max-same-issues: 0
|
||||
|
||||
@@ -28,6 +23,7 @@ linters:
|
||||
- bodyclose
|
||||
- dupl
|
||||
- errcheck
|
||||
- exportloopref
|
||||
- goconst
|
||||
- godot
|
||||
- godox
|
||||
@@ -44,45 +40,8 @@ linters:
|
||||
- unconvert
|
||||
- unparam
|
||||
- whitespace
|
||||
- nakedret
|
||||
- cyclop
|
||||
- gosimple
|
||||
- unused
|
||||
- copyloopvar
|
||||
- gocritic
|
||||
- forbidigo
|
||||
- unparam
|
||||
- wastedassign
|
||||
|
||||
linters-settings:
|
||||
govet:
|
||||
disable:
|
||||
- composite
|
||||
|
||||
cyclop:
|
||||
# the maximal code complexity to report. default is 10. eventually work our way to that.
|
||||
max-complexity: 15
|
||||
# the max average package complexity. If it's higher than 0.0 (float) the check is enabled (default 0.0)
|
||||
package-average: 0.0
|
||||
# should ignore tests
|
||||
skip-tests: true
|
||||
|
||||
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.
|
||||
- ^(fmt\.Print(|f|ln)|print|println)
|
||||
- ^panic.*$
|
||||
|
||||
dupl:
|
||||
# tokens count to trigger issue, 150 by default
|
||||
threshold: 200
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
# Ignore artifacts:
|
||||
build/javascript
|
||||
webroot/js/web_modules
|
||||
static/
|
||||
22
.vscode/settings.json
vendored
Normal file
22
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"cSpell.words": [
|
||||
"Debugln",
|
||||
"Errorln",
|
||||
"Ffmpeg",
|
||||
"Mbps",
|
||||
"Owncast",
|
||||
"RTMP",
|
||||
"Tracef",
|
||||
"Traceln",
|
||||
"Warnf",
|
||||
"Warnln",
|
||||
"ffmpegpath",
|
||||
"ffmpg",
|
||||
"mattn",
|
||||
"nolint",
|
||||
"preact",
|
||||
"rtmpserverport",
|
||||
"sqlite",
|
||||
"videojs"
|
||||
]
|
||||
}
|
||||
27
Dockerfile
27
Dockerfile
@@ -1,16 +1,10 @@
|
||||
# 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
|
||||
|
||||
EXPOSE 8080 1935
|
||||
RUN mkdir /build
|
||||
ADD . /build
|
||||
WORKDIR /build
|
||||
COPY . /build
|
||||
RUN apk update && apk add --no-cache gcc build-base linux-headers
|
||||
|
||||
ARG VERSION=dev
|
||||
ENV VERSION=${VERSION}
|
||||
@@ -22,16 +16,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.21.2
|
||||
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
|
||||
COPY --from=build /build/static /app/static
|
||||
RUN mkdir /app/data
|
||||
RUN chown -R owncast:owncast /app
|
||||
USER owncast
|
||||
ENTRYPOINT ["/app/owncast"]
|
||||
EXPOSE 8080 1935
|
||||
CMD ["/app/owncast"]
|
||||
|
||||
168
Earthfile
168
Earthfile
@@ -1,168 +0,0 @@
|
||||
VERSION --new-platform 0.6
|
||||
|
||||
FROM --platform=linux/amd64 alpine:3.21.2
|
||||
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 --keep-ts 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 --keep-ts (+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.21.2
|
||||
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 --keep-ts --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
2
LICENSE
@@ -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
|
||||
|
||||
149
README.md
149
README.md
@@ -1,48 +1,37 @@
|
||||
<br />
|
||||
<p align="center">
|
||||
<a href="https://github.com/owncast/owncast" alt="Owncast">
|
||||
<img src="https://owncast.online/images/logo.png" alt="Owncast Logo" width="200">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<strong>Take control over your content and stream it yourself.</strong>
|
||||
</p>
|
||||
|
||||
<br/>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/owncast/owncast/blob/develop/LICENSE">
|
||||
<img src="https://img.shields.io/badge/License-MIT-green.svg" alt="License" />
|
||||
<img src="https://owncast.online/images/logo.png" alt="Logo" width="200">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<br/>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://owncast.online"><strong>Explore the docs »</strong></a>
|
||||
<br />
|
||||
<a href="https://watch.owncast.online/">View Demo</a>
|
||||
·
|
||||
<a href="https://owncast.online/faq/">FAQ</a>
|
||||
·
|
||||
<a href="https://github.com/owncast/owncast/issues">Report Bug</a>
|
||||
<p align="center">
|
||||
<strong>Take control over your content and stream it yourself.</strong>
|
||||
<br />
|
||||
<a href="http://owncast.online"><strong>Explore the docs »</strong></a>
|
||||
<br />
|
||||
<a href="https://watch.owncast.online/">View Demo</a>
|
||||
·
|
||||
<a href="https://broadcast.owncast.online/">Use Our Server for Testing</a>
|
||||
·
|
||||
<a href="https://owncast.online/docs/faq/">FAQ</a>
|
||||
·
|
||||
<a href="https://github.com/owncast/owncast/issues">Report Bug</a>
|
||||
</p>
|
||||
</p>
|
||||
|
||||
|
||||
<!-- TABLE OF CONTENTS -->
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- 📒 [About the Project](#about-the-project)
|
||||
- 🚀 [Getting Started](#getting-started)
|
||||
- 👨💻 [Use with your broadcasting software](#use-with-your-existing-broadcasting-software)
|
||||
- 🛠 [Building from source](#building-from-source)
|
||||
- 🚨 [Important note about source code and the develop branch](#important-note-about-source-code-and-the-develop-branch)
|
||||
- 🗄️ [Backend](#backend)
|
||||
- ⚛️ [Frontend](#frontend)
|
||||
- 👏 [Contributing](#contributing)
|
||||
- 💵 [Donors](#donors)
|
||||
- 📝 [License](#license)
|
||||
- [About the Project](#about-the-project)
|
||||
- [Getting Started](#getting-started)
|
||||
- [Use with your broadcasting software](#use-with-your-existing-broadcasting-software)
|
||||
- [Building from source](#building-from-source)
|
||||
- [Contributing](#contributing)
|
||||
- [License](#license)
|
||||
- [Contact](#contact)
|
||||
|
||||
<!-- ABOUT THE PROJECT -->
|
||||
@@ -55,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>
|
||||
@@ -70,6 +57,7 @@ Owncast is an open source, self-hosted, decentralized, single user live video st
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
||||
---
|
||||
|
||||
<!-- GETTING STARTED -->
|
||||
@@ -80,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.22 or above).
|
||||
1. Ensure you have the gcc compiler configured.
|
||||
1. Install the [Go toolchain](https://golang.org/dl/).
|
||||
1. Clone the repo. `git clone https://github.com/owncast/owncast`
|
||||
1. `go run main.go` will run from the source.
|
||||
1. Visit `http://yourserver:8080` to access the web interface or `http://yourserver:8080/admin` to access the admin.
|
||||
1. `go run main.go pkged.go` will run from source.
|
||||
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. Install pkger. `go install github.com/markbates/pkger/cmd/...`
|
||||
1. From the owncast directory run the packager script: `./build/admin/bundleAdmin.sh`
|
||||
1. Compile or run like above. `go run main.go pkged.go`
|
||||
|
||||
## Contributing
|
||||
|
||||
@@ -128,56 +100,31 @@ And while we have a small team of kind, talented and thoughtful volunteers, we h
|
||||
We abide by our [Code of Conduct](https://owncast.online/contribute/) and feel strongly about open, appreciative, and empathetic people joining us.
|
||||
We’ve been very lucky to have this so far, so maybe you can help us with your skills and passion, too!
|
||||
|
||||
If you're new to the project, maybe you'd be interested in looking at [](https://github.com/owncast/owncast/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22).
|
||||
|
||||
There is a larger, more detailed, and more up-to-date [guide for helping contribute to Owncast on our website](https://owncast.online/help/).
|
||||
|
||||
### Donors
|
||||
The Owncast project is possible thanks to the people who make a donation to support us and our work.
|
||||
Thank you to all our donors who help keep Owncast running by donating on OpenCollective. You can support this project by [becoming a backer/sponsor](https://opencollective.com/owncast#suppor).
|
||||
### 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 pkged.go` to make sure the Admin (at `/admin`) is available to you in your development environment.
|
||||
|
||||
|
||||
<div>
|
||||
<a href="https://opencollective.com/owncast#support">
|
||||
<img alt="GitHub issues by-label" src="https://opencollective.com/owncast/tiers/backers.svg?avatarHeight=36&width=600" alt="Backer button">
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- LICENSE -->
|
||||
|
||||
## License
|
||||
|
||||
Distributed under the MIT License. See `LICENSE` for more information.
|
||||
|
||||
## Support
|
||||
|
||||
<ul style="font-size:21px; color:black; ">
|
||||
<li>Browser testing via <a
|
||||
href="https://www.lambdatest.com/" target="_blank"><img
|
||||
src="https://www.lambdatest.com/support/img/logo.svg"
|
||||
style="vertical-align: middle;margin-left:5px" width="147" height="26"
|
||||
/></a></li>
|
||||
<li>Project chat provided by
|
||||
<a href="https://rocket.chat" target="_blank">
|
||||
<img src="https://owncast.online/images/sponsors/rocketchat.png" width="147" height="26" style="vertical-align: middle;margin-left:5px">
|
||||
</a>
|
||||
</li>
|
||||
<li>CDN services by
|
||||
<a href="https://fastly.com" target="_blank">
|
||||
<img src="https://owncast.online/images/sponsors/fastly.png" height="26" style="vertical-align: middle;margin-left:5px">
|
||||
</a>
|
||||
</li>
|
||||
<li>UI testing with Chromatic
|
||||
<a href="https://chromatic.com" target="_blank">
|
||||
<img src="https://owncast.online/images/sponsors/chromatic.png" height="26" style="vertical-align: middle;margin-left:5px">
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<!-- 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@mastodon.social](https://mastodon.social/@gabek) - email [gabek@real-ity.com](mailto:gabek@real-ity.com)
|
||||
|
||||
Project Link: [https://github.com/owncast/owncast](https://github.com/owncast/owncast)
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
package activitypub
|
||||
|
||||
import (
|
||||
"math"
|
||||
|
||||
"github.com/owncast/owncast/activitypub/crypto"
|
||||
"github.com/owncast/owncast/activitypub/inbox"
|
||||
"github.com/owncast/owncast/activitypub/outbox"
|
||||
"github.com/owncast/owncast/activitypub/persistence"
|
||||
"github.com/owncast/owncast/activitypub/workerpool"
|
||||
"github.com/owncast/owncast/persistence/configrepository"
|
||||
|
||||
"github.com/owncast/owncast/core/data"
|
||||
"github.com/owncast/owncast/models"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// Start will initialize and start the federation support.
|
||||
func Start(datastore *data.Datastore) {
|
||||
configRepository := configrepository.Get()
|
||||
persistence.Setup(datastore)
|
||||
|
||||
outboundWorkerPoolSize := getOutboundWorkerPoolSize()
|
||||
workerpool.InitOutboundWorkerPool(outboundWorkerPoolSize)
|
||||
inbox.InitInboxWorkerPool()
|
||||
|
||||
// Generate the keys for signing federated activity if needed.
|
||||
if configRepository.GetPrivateKey() == "" {
|
||||
privateKey, publicKey, err := crypto.GenerateKeys()
|
||||
_ = configRepository.SetPrivateKey(string(privateKey))
|
||||
_ = configRepository.SetPublicKey(string(publicKey))
|
||||
if err != nil {
|
||||
log.Errorln("Unable to get private key", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getOutboundWorkerPoolSize() int {
|
||||
var followerCount int64
|
||||
fc, err := persistence.GetFollowerCount()
|
||||
if err != nil {
|
||||
log.Errorln("Unable to get follower count", err)
|
||||
fc = 50 // Arbitrary fallback value.
|
||||
}
|
||||
followerCount = int64(math.Max(float64(fc), 50))
|
||||
return int(followerCount * 5)
|
||||
}
|
||||
|
||||
// SendLive will send a "Go Live" message to followers.
|
||||
func SendLive() error {
|
||||
return outbox.SendLive()
|
||||
}
|
||||
|
||||
// SendPublicFederatedMessage will send an arbitrary provided message to followers.
|
||||
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()
|
||||
}
|
||||
|
||||
// GetPendingFollowRequests will return the pending follow requests.
|
||||
func GetPendingFollowRequests() ([]models.Follower, error) {
|
||||
return persistence.GetPendingFollowRequests()
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
package apmodels
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/go-fed/activity/streams"
|
||||
"github.com/go-fed/activity/streams/vocab"
|
||||
"github.com/owncast/owncast/persistence/configrepository"
|
||||
)
|
||||
|
||||
// PrivacyAudience represents the audience for an activity.
|
||||
type PrivacyAudience = string
|
||||
|
||||
const (
|
||||
// PUBLIC is an audience meaning anybody can view the item.
|
||||
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
|
||||
}
|
||||
|
||||
// MakeActivityPublic sets the required properties to make this activity
|
||||
// seen as public.
|
||||
func MakeActivityPublic(activity vocab.ActivityStreamsCreate) vocab.ActivityStreamsCreate {
|
||||
configRepository := configrepository.Get()
|
||||
|
||||
// TO the public if we're not treating ActivityPub as "private".
|
||||
if !configRepository.GetFederationIsPrivate() {
|
||||
public, _ := url.Parse(PUBLIC)
|
||||
|
||||
to := streams.NewActivityStreamsToProperty()
|
||||
to.AppendIRI(public)
|
||||
activity.SetActivityStreamsTo(to)
|
||||
|
||||
audience := streams.NewActivityStreamsAudienceProperty()
|
||||
audience.AppendIRI(public)
|
||||
activity.SetActivityStreamsAudience(audience)
|
||||
}
|
||||
|
||||
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()
|
||||
id := streams.NewJSONLDIdProperty()
|
||||
id.Set(activityID)
|
||||
activity.SetJSONLDId(id)
|
||||
|
||||
// CC the public if we're not treating ActivityPub as "private".
|
||||
configRepository := configrepository.Get()
|
||||
|
||||
if !configRepository.GetFederationIsPrivate() {
|
||||
public, _ := url.Parse(PUBLIC)
|
||||
cc := streams.NewActivityStreamsCcProperty()
|
||||
cc.AppendIRI(public)
|
||||
activity.SetActivityStreamsCc(cc)
|
||||
}
|
||||
|
||||
return activity
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
published := streams.NewActivityStreamsPublishedProperty()
|
||||
published.Set(time.Now())
|
||||
note.SetActivityStreamsPublished(published)
|
||||
|
||||
attributedTo := attributedToIRI
|
||||
attr := streams.NewActivityStreamsAttributedToProperty()
|
||||
attr.AppendIRI(attributedTo)
|
||||
note.SetActivityStreamsAttributedTo(attr)
|
||||
|
||||
return note
|
||||
}
|
||||
@@ -1,276 +0,0 @@
|
||||
package apmodels
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/go-fed/activity/streams"
|
||||
"github.com/go-fed/activity/streams/vocab"
|
||||
"github.com/owncast/owncast/activitypub/crypto"
|
||||
"github.com/owncast/owncast/models"
|
||||
"github.com/owncast/owncast/persistence/configrepository"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// DeleteRequest represents a request for delete.
|
||||
type DeleteRequest struct {
|
||||
ActorIri string
|
||||
}
|
||||
|
||||
// ExternalEntity represents an ActivityPub Person, Service or Application.
|
||||
type ExternalEntity interface {
|
||||
GetJSONLDId() vocab.JSONLDIdProperty
|
||||
GetActivityStreamsInbox() vocab.ActivityStreamsInboxProperty
|
||||
GetActivityStreamsName() vocab.ActivityStreamsNameProperty
|
||||
GetActivityStreamsPreferredUsername() vocab.ActivityStreamsPreferredUsernameProperty
|
||||
GetActivityStreamsIcon() vocab.ActivityStreamsIconProperty
|
||||
GetW3IDSecurityV1PublicKey() vocab.W3IDSecurityV1PublicKeyProperty
|
||||
}
|
||||
|
||||
// MakeActorFromExernalAPEntity takes a full ActivityPub entity and returns our
|
||||
// internal representation of an actor.
|
||||
func MakeActorFromExernalAPEntity(entity ExternalEntity) (*ActivityPubActor, error) {
|
||||
// Username is required (but not a part of the official ActivityPub spec)
|
||||
if entity.GetActivityStreamsPreferredUsername() == nil || entity.GetActivityStreamsPreferredUsername().GetXMLSchemaString() == "" {
|
||||
return nil, errors.New("remote activitypub entity does not have a preferred username set, rejecting")
|
||||
}
|
||||
username := GetFullUsernameFromExternalEntity(entity)
|
||||
|
||||
// Key is required
|
||||
if entity.GetW3IDSecurityV1PublicKey() == nil {
|
||||
return nil, errors.New("remote activitypub entity does not have a public key set, rejecting")
|
||||
}
|
||||
|
||||
// Name is optional
|
||||
var name string
|
||||
if entity.GetActivityStreamsName() != nil && !entity.GetActivityStreamsName().Empty() {
|
||||
name = entity.GetActivityStreamsName().At(0).GetXMLSchemaString()
|
||||
}
|
||||
|
||||
// Image is optional
|
||||
var image *url.URL
|
||||
if entity.GetActivityStreamsIcon() != nil && !entity.GetActivityStreamsIcon().Empty() && entity.GetActivityStreamsIcon().At(0).GetActivityStreamsImage() != nil {
|
||||
image = entity.GetActivityStreamsIcon().At(0).GetActivityStreamsImage().GetActivityStreamsUrl().Begin().GetIRI()
|
||||
}
|
||||
|
||||
apActor := ActivityPubActor{
|
||||
ActorIri: entity.GetJSONLDId().Get(),
|
||||
Inbox: entity.GetActivityStreamsInbox().GetIRI(),
|
||||
Name: name,
|
||||
Username: entity.GetActivityStreamsPreferredUsername().GetXMLSchemaString(),
|
||||
FullUsername: username,
|
||||
W3IDSecurityV1PublicKey: entity.GetW3IDSecurityV1PublicKey(),
|
||||
Image: image,
|
||||
}
|
||||
|
||||
return &apActor, nil
|
||||
}
|
||||
|
||||
// MakeActorPropertyWithID will return an actor property filled with the provided IRI.
|
||||
func MakeActorPropertyWithID(idIRI *url.URL) vocab.ActivityStreamsActorProperty {
|
||||
actor := streams.NewActivityStreamsActorProperty()
|
||||
actor.AppendIRI(idIRI)
|
||||
return actor
|
||||
}
|
||||
|
||||
// MakeServiceForAccount will create a new local actor service with the the provided username.
|
||||
func MakeServiceForAccount(accountName string) vocab.ActivityStreamsService {
|
||||
configRepository := configrepository.Get()
|
||||
|
||||
actorIRI := MakeLocalIRIForAccount(accountName)
|
||||
|
||||
person := streams.NewActivityStreamsService()
|
||||
nameProperty := streams.NewActivityStreamsNameProperty()
|
||||
nameProperty.AppendXMLSchemaString(configRepository.GetServerName())
|
||||
person.SetActivityStreamsName(nameProperty)
|
||||
|
||||
preferredUsernameProperty := streams.NewActivityStreamsPreferredUsernameProperty()
|
||||
preferredUsernameProperty.SetXMLSchemaString(accountName)
|
||||
person.SetActivityStreamsPreferredUsername(preferredUsernameProperty)
|
||||
|
||||
inboxIRI := MakeLocalIRIForResource("/user/" + accountName + "/inbox")
|
||||
|
||||
inboxProp := streams.NewActivityStreamsInboxProperty()
|
||||
inboxProp.SetIRI(inboxIRI)
|
||||
person.SetActivityStreamsInbox(inboxProp)
|
||||
|
||||
needsFollowApprovalProperty := streams.NewActivityStreamsManuallyApprovesFollowersProperty()
|
||||
needsFollowApprovalProperty.Set(configRepository.GetFederationIsPrivate())
|
||||
person.SetActivityStreamsManuallyApprovesFollowers(needsFollowApprovalProperty)
|
||||
|
||||
outboxIRI := MakeLocalIRIForResource("/user/" + accountName + "/outbox")
|
||||
|
||||
outboxProp := streams.NewActivityStreamsOutboxProperty()
|
||||
outboxProp.SetIRI(outboxIRI)
|
||||
person.SetActivityStreamsOutbox(outboxProp)
|
||||
|
||||
id := streams.NewJSONLDIdProperty()
|
||||
id.Set(actorIRI)
|
||||
person.SetJSONLDId(id)
|
||||
|
||||
publicKey := crypto.GetPublicKey(actorIRI)
|
||||
|
||||
publicKeyProp := streams.NewW3IDSecurityV1PublicKeyProperty()
|
||||
publicKeyType := streams.NewW3IDSecurityV1PublicKey()
|
||||
|
||||
pubKeyIDProp := streams.NewJSONLDIdProperty()
|
||||
pubKeyIDProp.Set(publicKey.ID)
|
||||
|
||||
publicKeyType.SetJSONLDId(pubKeyIDProp)
|
||||
|
||||
ownerProp := streams.NewW3IDSecurityV1OwnerProperty()
|
||||
ownerProp.SetIRI(publicKey.Owner)
|
||||
publicKeyType.SetW3IDSecurityV1Owner(ownerProp)
|
||||
|
||||
publicKeyPemProp := streams.NewW3IDSecurityV1PublicKeyPemProperty()
|
||||
publicKeyPemProp.Set(publicKey.PublicKeyPem)
|
||||
publicKeyType.SetW3IDSecurityV1PublicKeyPem(publicKeyPemProp)
|
||||
publicKeyProp.AppendW3IDSecurityV1PublicKey(publicKeyType)
|
||||
person.SetW3IDSecurityV1PublicKey(publicKeyProp)
|
||||
|
||||
if t, err := configRepository.GetServerInitTime(); t != nil {
|
||||
publishedDateProp := streams.NewActivityStreamsPublishedProperty()
|
||||
publishedDateProp.Set(t.Time)
|
||||
person.SetActivityStreamsPublished(publishedDateProp)
|
||||
} else {
|
||||
log.Errorln("unable to fetch server init time", err)
|
||||
}
|
||||
|
||||
// Profile properties
|
||||
|
||||
// Avatar
|
||||
uniquenessString := configRepository.GetLogoUniquenessString()
|
||||
userAvatarURLString := configRepository.GetServerURL() + "/logo/external"
|
||||
userAvatarURL, err := url.Parse(userAvatarURLString)
|
||||
userAvatarURL.RawQuery = "uc=" + uniquenessString
|
||||
if err != nil {
|
||||
log.Errorln("unable to parse user avatar url", userAvatarURLString, err)
|
||||
}
|
||||
|
||||
image := streams.NewActivityStreamsImage()
|
||||
imgProp := streams.NewActivityStreamsUrlProperty()
|
||||
imgProp.AppendIRI(userAvatarURL)
|
||||
image.SetActivityStreamsUrl(imgProp)
|
||||
icon := streams.NewActivityStreamsIconProperty()
|
||||
icon.AppendActivityStreamsImage(image)
|
||||
person.SetActivityStreamsIcon(icon)
|
||||
|
||||
// Actor URL
|
||||
urlProperty := streams.NewActivityStreamsUrlProperty()
|
||||
urlProperty.AppendIRI(actorIRI)
|
||||
person.SetActivityStreamsUrl(urlProperty)
|
||||
|
||||
// Profile header
|
||||
headerImage := streams.NewActivityStreamsImage()
|
||||
headerImgPropURL := streams.NewActivityStreamsUrlProperty()
|
||||
headerImgPropURL.AppendIRI(userAvatarURL)
|
||||
headerImage.SetActivityStreamsUrl(headerImgPropURL)
|
||||
headerImageProp := streams.NewActivityStreamsImageProperty()
|
||||
headerImageProp.AppendActivityStreamsImage(headerImage)
|
||||
person.SetActivityStreamsImage(headerImageProp)
|
||||
|
||||
// Profile bio
|
||||
summaryProperty := streams.NewActivityStreamsSummaryProperty()
|
||||
summaryProperty.AppendXMLSchemaString(configRepository.GetServerSummary())
|
||||
person.SetActivityStreamsSummary(summaryProperty)
|
||||
|
||||
// Links
|
||||
if serverURL := configRepository.GetServerURL(); serverURL != "" {
|
||||
addMetadataLinkToProfile(person, "Stream", serverURL)
|
||||
}
|
||||
for _, link := range configRepository.GetSocialHandles() {
|
||||
addMetadataLinkToProfile(person, link.Platform, link.URL)
|
||||
}
|
||||
|
||||
// Discoverable
|
||||
discoverableProperty := streams.NewTootDiscoverableProperty()
|
||||
discoverableProperty.Set(true)
|
||||
person.SetTootDiscoverable(discoverableProperty)
|
||||
|
||||
// Followers
|
||||
followersProperty := streams.NewActivityStreamsFollowersProperty()
|
||||
followersURL := *actorIRI
|
||||
followersURL.Path = actorIRI.Path + "/followers"
|
||||
followersProperty.SetIRI(&followersURL)
|
||||
person.SetActivityStreamsFollowers(followersProperty)
|
||||
|
||||
// Tags
|
||||
tagProp := streams.NewActivityStreamsTagProperty()
|
||||
for _, tagString := range configRepository.GetServerMetadataTags() {
|
||||
hashtag := MakeHashtag(tagString)
|
||||
tagProp.AppendTootHashtag(hashtag)
|
||||
}
|
||||
|
||||
person.SetActivityStreamsTag(tagProp)
|
||||
|
||||
// Work around an issue where a single attachment will not serialize
|
||||
// as an array, so add another item to the mix.
|
||||
if len(configRepository.GetSocialHandles()) == 1 {
|
||||
addMetadataLinkToProfile(person, "Owncast", "https://owncast.online")
|
||||
}
|
||||
|
||||
return person
|
||||
}
|
||||
|
||||
// GetFullUsernameFromExternalEntity will return the full username from an
|
||||
// internal representation of an ExternalEntity. Returns user@host.tld.
|
||||
func GetFullUsernameFromExternalEntity(entity ExternalEntity) string {
|
||||
hostname := entity.GetJSONLDId().GetIRI().Hostname()
|
||||
username := entity.GetActivityStreamsPreferredUsername().GetXMLSchemaString()
|
||||
fullUsername := fmt.Sprintf("%s@%s", username, hostname)
|
||||
|
||||
return fullUsername
|
||||
}
|
||||
|
||||
func addMetadataLinkToProfile(profile vocab.ActivityStreamsService, name string, url string) {
|
||||
attachments := profile.GetActivityStreamsAttachment()
|
||||
if attachments == nil {
|
||||
attachments = streams.NewActivityStreamsAttachmentProperty()
|
||||
}
|
||||
|
||||
displayName := name
|
||||
socialHandle := models.GetSocialHandle(name)
|
||||
if socialHandle != nil {
|
||||
displayName = socialHandle.Platform
|
||||
}
|
||||
|
||||
linkValue := fmt.Sprintf("<a href=\"%s\" rel=\"me nofollow noopener noreferrer\" target=\"_blank\">%s</a>", url, url)
|
||||
|
||||
attachment := streams.NewActivityStreamsObject()
|
||||
attachmentProp := streams.NewJSONLDTypeProperty()
|
||||
attachmentProp.AppendXMLSchemaString("PropertyValue")
|
||||
attachment.SetJSONLDType(attachmentProp)
|
||||
attachmentName := streams.NewActivityStreamsNameProperty()
|
||||
attachmentName.AppendXMLSchemaString(displayName)
|
||||
attachment.SetActivityStreamsName(attachmentName)
|
||||
attachment.GetUnknownProperties()["value"] = linkValue
|
||||
|
||||
attachments.AppendActivityStreamsObject(attachment)
|
||||
profile.SetActivityStreamsAttachment(attachments)
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
package apmodels
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"net/url"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/go-fed/activity/streams"
|
||||
"github.com/go-fed/activity/streams/vocab"
|
||||
"github.com/owncast/owncast/core/data"
|
||||
"github.com/owncast/owncast/persistence/configrepository"
|
||||
)
|
||||
|
||||
func makeFakeService() vocab.ActivityStreamsService {
|
||||
iri, _ := url.Parse("https://fake.fediverse.server/user/mrfoo")
|
||||
name := "Mr Foo"
|
||||
username := "foodawg"
|
||||
inbox, _ := url.Parse("https://fake.fediverse.server/user/mrfoo/inbox")
|
||||
userAvatarURL, _ := url.Parse("https://fake.fediverse.server/user/mrfoo/avatar.png")
|
||||
|
||||
service := streams.NewActivityStreamsService()
|
||||
|
||||
id := streams.NewJSONLDIdProperty()
|
||||
id.Set(iri)
|
||||
service.SetJSONLDId(id)
|
||||
|
||||
nameProperty := streams.NewActivityStreamsNameProperty()
|
||||
nameProperty.AppendXMLSchemaString(name)
|
||||
service.SetActivityStreamsName(nameProperty)
|
||||
|
||||
preferredUsernameProperty := streams.NewActivityStreamsPreferredUsernameProperty()
|
||||
preferredUsernameProperty.SetXMLSchemaString(username)
|
||||
service.SetActivityStreamsPreferredUsername(preferredUsernameProperty)
|
||||
|
||||
inboxProp := streams.NewActivityStreamsInboxProperty()
|
||||
inboxProp.SetIRI(inbox)
|
||||
service.SetActivityStreamsInbox(inboxProp)
|
||||
|
||||
image := streams.NewActivityStreamsImage()
|
||||
imgProp := streams.NewActivityStreamsUrlProperty()
|
||||
imgProp.AppendIRI(userAvatarURL)
|
||||
image.SetActivityStreamsUrl(imgProp)
|
||||
icon := streams.NewActivityStreamsIconProperty()
|
||||
icon.AppendActivityStreamsImage(image)
|
||||
service.SetActivityStreamsIcon(icon)
|
||||
|
||||
publicKeyProperty := streams.NewW3IDSecurityV1PublicKeyProperty()
|
||||
service.SetW3IDSecurityV1PublicKey(publicKeyProperty)
|
||||
|
||||
return service
|
||||
}
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
dbFile, err := ioutil.TempFile(os.TempDir(), "owncast-test-db.db")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
data.SetupPersistence(dbFile.Name())
|
||||
|
||||
configRepository := configrepository.Get()
|
||||
|
||||
configRepository.SetServerURL("https://my.cool.site.biz")
|
||||
|
||||
m.Run()
|
||||
}
|
||||
|
||||
func TestMakeActorFromExternalAPEntity(t *testing.T) {
|
||||
service := makeFakeService()
|
||||
actor, err := MakeActorFromExernalAPEntity(service)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
if actor.ActorIri != service.GetJSONLDId().GetIRI() {
|
||||
t.Errorf("actor.ID = %v, want %v", actor.ActorIri, service.GetJSONLDId().GetIRI())
|
||||
}
|
||||
|
||||
if actor.Name != service.GetActivityStreamsName().At(0).GetXMLSchemaString() {
|
||||
t.Errorf("actor.Name = %v, want %v", actor.Name, service.GetActivityStreamsName().At(0).GetXMLSchemaString())
|
||||
}
|
||||
|
||||
if actor.Username != service.GetActivityStreamsPreferredUsername().GetXMLSchemaString() {
|
||||
t.Errorf("actor.Username = %v, want %v", actor.Username, service.GetActivityStreamsPreferredUsername().GetXMLSchemaString())
|
||||
}
|
||||
|
||||
if actor.Inbox != service.GetActivityStreamsInbox().GetIRI() {
|
||||
t.Errorf("actor.Inbox = %v, want %v", actor.Inbox.String(), service.GetActivityStreamsInbox().GetIRI())
|
||||
}
|
||||
|
||||
if actor.Image != service.GetActivityStreamsIcon().At(0).GetActivityStreamsImage().GetActivityStreamsUrl().At(0).GetIRI() {
|
||||
t.Errorf("actor.Image = %v, want %v", actor.Image, service.GetActivityStreamsIcon().At(0).GetActivityStreamsImage().GetActivityStreamsUrl().At(0).GetIRI())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMakeActorPropertyWithID(t *testing.T) {
|
||||
iri, _ := url.Parse("https://fake.fediverse.server/user/mrfoo")
|
||||
actor := MakeActorPropertyWithID(iri)
|
||||
|
||||
if actor.Begin().GetIRI() != iri {
|
||||
t.Errorf("actor.IRI = %v, want %v", actor.Begin().GetIRI(), iri)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFullUsernameFromPerson(t *testing.T) {
|
||||
expected := "foodawg@fake.fediverse.server"
|
||||
person := makeFakeService()
|
||||
username := GetFullUsernameFromExternalEntity(person)
|
||||
|
||||
if username != expected {
|
||||
t.Errorf("actor.Username = %v, want %v", username, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddMetadataLinkToProfile(t *testing.T) {
|
||||
person := makeFakeService()
|
||||
addMetadataLinkToProfile(person, "my site", "https://my.cool.site.biz")
|
||||
attchment := person.GetActivityStreamsAttachment().At(0)
|
||||
|
||||
nameValue := attchment.GetActivityStreamsObject().GetActivityStreamsName().At(0).GetXMLSchemaString()
|
||||
expected := "my site"
|
||||
if nameValue != expected {
|
||||
t.Errorf("attachment name = %v, want %v", nameValue, expected)
|
||||
}
|
||||
|
||||
propertyValue := attchment.GetActivityStreamsObject().GetUnknownProperties()["value"]
|
||||
expected = `<a href="https://my.cool.site.biz" rel="me nofollow noopener noreferrer" target="_blank">https://my.cool.site.biz</a>`
|
||||
if propertyValue != expected {
|
||||
t.Errorf("attachment value = %v, want %v", propertyValue, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMakeServiceForAccount(t *testing.T) {
|
||||
person := MakeServiceForAccount("accountname")
|
||||
expectedIRI := "https://my.cool.site.biz/federation/user/accountname"
|
||||
if person.GetJSONLDId().Get().String() != expectedIRI {
|
||||
t.Errorf("actor.IRI = %v, want %v", person.GetJSONLDId().Get().String(), expectedIRI)
|
||||
}
|
||||
|
||||
if person.GetActivityStreamsPreferredUsername().GetXMLSchemaString() != "accountname" {
|
||||
t.Errorf("actor.PreferredUsername = %v, want %v", person.GetActivityStreamsPreferredUsername().GetXMLSchemaString(), expectedIRI)
|
||||
}
|
||||
|
||||
expectedInbox := "https://my.cool.site.biz/federation/user/accountname/inbox"
|
||||
if person.GetActivityStreamsInbox().GetIRI().String() != expectedInbox {
|
||||
t.Errorf("actor.Inbox = %v, want %v", person.GetActivityStreamsInbox().GetIRI().String(), expectedInbox)
|
||||
}
|
||||
|
||||
expectedOutbox := "https://my.cool.site.biz/federation/user/accountname/outbox"
|
||||
if person.GetActivityStreamsOutbox().GetIRI().String() != expectedOutbox {
|
||||
t.Errorf("actor.Outbox = %v, want %v", person.GetActivityStreamsOutbox().GetIRI().String(), expectedOutbox)
|
||||
}
|
||||
|
||||
expectedFollowers := "https://my.cool.site.biz/federation/user/accountname/followers"
|
||||
if person.GetActivityStreamsFollowers().GetIRI().String() != expectedFollowers {
|
||||
t.Errorf("actor.Followers = %v, want %v", person.GetActivityStreamsFollowers().GetIRI().String(), expectedFollowers)
|
||||
}
|
||||
|
||||
expectedName := "New Owncast Server"
|
||||
if person.GetActivityStreamsName().Begin().GetXMLSchemaString() != expectedName {
|
||||
t.Errorf("actor.Name = %v, want %v", person.GetActivityStreamsName().Begin().GetXMLSchemaString(), expectedName)
|
||||
}
|
||||
|
||||
expectedAvatar := "https://my.cool.site.biz/logo/external"
|
||||
u, err := url.Parse(person.GetActivityStreamsIcon().At(0).GetActivityStreamsImage().GetActivityStreamsUrl().Begin().GetIRI().String())
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
u.RawQuery = ""
|
||||
if u.String() != expectedAvatar {
|
||||
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."
|
||||
if person.GetActivityStreamsSummary().At(0).GetXMLSchemaString() != expectedSummary {
|
||||
t.Errorf("actor.Summary = %v, want %v", person.GetActivityStreamsSummary().At(0).GetXMLSchemaString(), expectedSummary)
|
||||
}
|
||||
|
||||
if person.GetActivityStreamsUrl().At(0).GetIRI().String() != expectedIRI {
|
||||
t.Errorf("actor.URL = %v, want %v", person.GetActivityStreamsUrl().At(0).GetIRI().String(), expectedIRI)
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package apmodels
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
|
||||
"github.com/go-fed/activity/streams"
|
||||
"github.com/go-fed/activity/streams/vocab"
|
||||
)
|
||||
|
||||
// MakeHashtag will create and return a mastodon toot hashtag object with the provided name.
|
||||
func MakeHashtag(name string) vocab.TootHashtag {
|
||||
u, _ := url.Parse("https://directory.owncast.online/tags/" + name)
|
||||
|
||||
hashtag := streams.NewTootHashtag()
|
||||
hashtagName := streams.NewActivityStreamsNameProperty()
|
||||
hashtagName.AppendXMLSchemaString("#" + name)
|
||||
hashtag.SetActivityStreamsName(hashtagName)
|
||||
|
||||
hashtagHref := streams.NewActivityStreamsHrefProperty()
|
||||
hashtagHref.Set(u)
|
||||
hashtag.SetActivityStreamsHref(hashtagHref)
|
||||
|
||||
return hashtag
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
package apmodels
|
||||
|
||||
import "net/http"
|
||||
|
||||
// InboxRequest represents an inbound request to the ActivityPub inbox.
|
||||
type InboxRequest struct {
|
||||
Request *http.Request
|
||||
ForLocalAccount string
|
||||
Body []byte
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
package apmodels
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
|
||||
"github.com/go-fed/activity/streams"
|
||||
"github.com/go-fed/activity/streams/vocab"
|
||||
)
|
||||
|
||||
// CreateCreateActivity will create a new Create Activity model with the provided ID and IRI.
|
||||
func CreateCreateActivity(id string, localAccountIRI *url.URL) vocab.ActivityStreamsCreate {
|
||||
objectID := MakeLocalIRIForResource(id)
|
||||
message := MakeCreateActivity(objectID)
|
||||
|
||||
actorProp := streams.NewActivityStreamsActorProperty()
|
||||
actorProp.AppendIRI(localAccountIRI)
|
||||
message.SetActivityStreamsActor(actorProp)
|
||||
|
||||
return message
|
||||
}
|
||||
|
||||
// AddImageAttachmentToNote will add the provided image URL to the provided note object.
|
||||
func AddImageAttachmentToNote(note vocab.ActivityStreamsNote, image, mediaType string) {
|
||||
imageURL, err := url.Parse(image)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
attachments := note.GetActivityStreamsAttachment()
|
||||
if attachments == nil {
|
||||
attachments = streams.NewActivityStreamsAttachmentProperty()
|
||||
}
|
||||
|
||||
urlProp := streams.NewActivityStreamsUrlProperty()
|
||||
urlProp.AppendIRI(imageURL)
|
||||
|
||||
apImage := streams.NewActivityStreamsImage()
|
||||
apImage.SetActivityStreamsUrl(urlProp)
|
||||
|
||||
imageProp := streams.NewActivityStreamsImageProperty()
|
||||
imageProp.AppendActivityStreamsImage(apImage)
|
||||
|
||||
imageDescription := streams.NewActivityStreamsNameProperty()
|
||||
imageDescription.AppendXMLSchemaString("Live stream preview")
|
||||
apImage.SetActivityStreamsName(imageDescription)
|
||||
|
||||
mediaTypeProperty := streams.NewActivityStreamsMediaTypeProperty()
|
||||
mediaTypeProperty.Set(mediaType)
|
||||
apImage.SetActivityStreamsMediaType(mediaTypeProperty)
|
||||
|
||||
attachments.AppendActivityStreamsImage(apImage)
|
||||
|
||||
note.SetActivityStreamsAttachment(attachments)
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
package apmodels
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"path"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/go-fed/activity/streams"
|
||||
"github.com/go-fed/activity/streams/vocab"
|
||||
"github.com/owncast/owncast/persistence/configrepository"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// MakeRemoteIRIForResource will create an IRI for a remote location.
|
||||
func MakeRemoteIRIForResource(resourcePath string, host string) (*url.URL, error) {
|
||||
generatedURL := "https://" + host
|
||||
u, err := url.Parse(generatedURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
u.Path = path.Join(u.Path, "federation", resourcePath)
|
||||
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// MakeLocalIRIForResource will create an IRI for the local server.
|
||||
func MakeLocalIRIForResource(resourcePath string) *url.URL {
|
||||
configRepository := configrepository.Get()
|
||||
|
||||
host := configRepository.GetServerURL()
|
||||
u, err := url.Parse(host)
|
||||
if err != nil {
|
||||
log.Errorln("unable to parse local IRI url", host, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
u.Path = path.Join(u.Path, "federation", resourcePath)
|
||||
|
||||
return u
|
||||
}
|
||||
|
||||
// MakeLocalIRIForAccount will return a full IRI for the local server account username.
|
||||
func MakeLocalIRIForAccount(account string) *url.URL {
|
||||
configRepository := configrepository.Get()
|
||||
|
||||
host := configRepository.GetServerURL()
|
||||
u, err := url.Parse(host)
|
||||
if err != nil {
|
||||
log.Errorln("unable to parse local IRI account server url", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
u.Path = path.Join(u.Path, "federation", "user", account)
|
||||
|
||||
return u
|
||||
}
|
||||
|
||||
// Serialize will serialize an ActivityPub object to a byte slice.
|
||||
func Serialize(obj vocab.Type) ([]byte, error) {
|
||||
var jsonmap map[string]interface{}
|
||||
jsonmap, _ = streams.Serialize(obj)
|
||||
b, err := json.Marshal(jsonmap)
|
||||
|
||||
return b, err
|
||||
}
|
||||
|
||||
// MakeLocalIRIForStreamURL will return a full IRI for the local server stream url.
|
||||
func MakeLocalIRIForStreamURL() *url.URL {
|
||||
configRepository := configrepository.Get()
|
||||
|
||||
host := configRepository.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 {
|
||||
configRepository := configrepository.Get()
|
||||
|
||||
host := configRepository.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 {
|
||||
configRepository := configrepository.Get()
|
||||
|
||||
imageFilename := configRepository.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
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
package apmodels
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// WebfingerResponse represents a Webfinger response.
|
||||
type WebfingerResponse struct {
|
||||
Aliases []string `json:"aliases"`
|
||||
Subject string `json:"subject"`
|
||||
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"`
|
||||
Type string `json:"type"`
|
||||
Href string `json:"href"`
|
||||
}
|
||||
|
||||
// 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{
|
||||
accountIRI.String(),
|
||||
},
|
||||
Links: []Link{
|
||||
{
|
||||
Rel: "self",
|
||||
Type: "application/activity+json",
|
||||
Href: accountIRI.String(),
|
||||
},
|
||||
{
|
||||
Rel: "http://webfinger.net/rel/profile-page",
|
||||
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
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/owncast/owncast/activitypub/apmodels"
|
||||
"github.com/owncast/owncast/activitypub/crypto"
|
||||
"github.com/owncast/owncast/activitypub/requests"
|
||||
"github.com/owncast/owncast/persistence/configrepository"
|
||||
)
|
||||
|
||||
// ActorHandler handles requests for a single actor.
|
||||
func ActorHandler(w http.ResponseWriter, r *http.Request) {
|
||||
configRepository := configrepository.Get()
|
||||
|
||||
if !configRepository.GetFederationEnabled() {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
pathComponents := strings.Split(r.URL.Path, "/")
|
||||
accountName := pathComponents[3]
|
||||
|
||||
if _, valid := configRepository.GetFederatedInboxMap()[accountName]; !valid {
|
||||
// User is not valid
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// If this request is for an actor's inbox then pass
|
||||
// the request to the inbox controller.
|
||||
if len(pathComponents) == 5 && pathComponents[4] == "inbox" {
|
||||
InboxHandler(w, r)
|
||||
return
|
||||
} else if len(pathComponents) == 5 && pathComponents[4] == "outbox" {
|
||||
OutboxHandler(w, r)
|
||||
return
|
||||
} else if len(pathComponents) == 5 && pathComponents[4] == "followers" {
|
||||
// followers list
|
||||
FollowersHandler(w, r)
|
||||
return
|
||||
} else if len(pathComponents) == 5 && pathComponents[4] == "following" {
|
||||
// following list (none)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
actorIRI := apmodels.MakeLocalIRIForAccount(accountName)
|
||||
publicKey := crypto.GetPublicKey(actorIRI)
|
||||
person := apmodels.MakeServiceForAccount(accountName)
|
||||
|
||||
if err := requests.WriteStreamResponse(person, w, publicKey); err != nil {
|
||||
log.Errorln("unable to write stream response for actor handler", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/go-fed/activity/streams"
|
||||
"github.com/go-fed/activity/streams/vocab"
|
||||
"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/persistence/configrepository"
|
||||
)
|
||||
|
||||
const (
|
||||
followersPageSize = 50
|
||||
)
|
||||
|
||||
// FollowersHandler will return the list of remote followers on the Fediverse.
|
||||
func FollowersHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var response interface{}
|
||||
var err error
|
||||
if r.URL.Query().Get("page") != "" {
|
||||
response, err = getFollowersPage(r.URL.Query().Get("page"), r)
|
||||
} else {
|
||||
response, err = getInitialFollowersRequest(r)
|
||||
}
|
||||
|
||||
if response == nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
_, _ = w.Write([]byte(err.Error()))
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
pathComponents := strings.Split(r.URL.Path, "/")
|
||||
accountName := pathComponents[3]
|
||||
actorIRI := apmodels.MakeLocalIRIForAccount(accountName)
|
||||
publicKey := crypto.GetPublicKey(actorIRI)
|
||||
|
||||
if err := requests.WriteStreamResponse(response.(vocab.Type), w, publicKey); err != nil {
|
||||
log.Errorln("unable to write stream response for followers handler", err)
|
||||
}
|
||||
}
|
||||
|
||||
func getInitialFollowersRequest(r *http.Request) (vocab.ActivityStreamsOrderedCollection, error) {
|
||||
followerCount, _ := persistence.GetFollowerCount()
|
||||
collection := streams.NewActivityStreamsOrderedCollection()
|
||||
idProperty := streams.NewJSONLDIdProperty()
|
||||
id, err := createPageURL(r, nil)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "unable to create followers page property")
|
||||
}
|
||||
idProperty.SetIRI(id)
|
||||
collection.SetJSONLDId(idProperty)
|
||||
|
||||
totalItemsProperty := streams.NewActivityStreamsTotalItemsProperty()
|
||||
totalItemsProperty.Set(int(followerCount))
|
||||
collection.SetActivityStreamsTotalItems(totalItemsProperty)
|
||||
|
||||
first := streams.NewActivityStreamsFirstProperty()
|
||||
page := "1"
|
||||
firstIRI, err := createPageURL(r, &page)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "unable to create first page property")
|
||||
}
|
||||
|
||||
first.SetIRI(firstIRI)
|
||||
collection.SetActivityStreamsFirst(first)
|
||||
|
||||
return collection, nil
|
||||
}
|
||||
|
||||
func getFollowersPage(page string, r *http.Request) (vocab.ActivityStreamsOrderedCollectionPage, error) {
|
||||
pageInt, err := strconv.Atoi(page)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "unable to parse page number")
|
||||
}
|
||||
|
||||
followerCount, err := persistence.GetFollowerCount()
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "unable to get follower count")
|
||||
}
|
||||
|
||||
followers, _, err := persistence.GetFederationFollowers(followersPageSize, (pageInt-1)*followersPageSize)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "unable to get federation followers")
|
||||
}
|
||||
|
||||
collectionPage := streams.NewActivityStreamsOrderedCollectionPage()
|
||||
idProperty := streams.NewJSONLDIdProperty()
|
||||
id, err := createPageURL(r, &page)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "unable to create followers page ID")
|
||||
}
|
||||
idProperty.SetIRI(id)
|
||||
collectionPage.SetJSONLDId(idProperty)
|
||||
|
||||
orderedItems := streams.NewActivityStreamsOrderedItemsProperty()
|
||||
|
||||
for _, follower := range followers {
|
||||
u, _ := url.Parse(follower.ActorIRI)
|
||||
orderedItems.AppendIRI(u)
|
||||
}
|
||||
collectionPage.SetActivityStreamsOrderedItems(orderedItems)
|
||||
|
||||
partOf := streams.NewActivityStreamsPartOfProperty()
|
||||
partOfIRI, err := createPageURL(r, nil)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "unable to create partOf property for followers page")
|
||||
}
|
||||
|
||||
partOf.SetIRI(partOfIRI)
|
||||
collectionPage.SetActivityStreamsPartOf(partOf)
|
||||
|
||||
if pageInt*followersPageSize < int(followerCount) {
|
||||
next := streams.NewActivityStreamsNextProperty()
|
||||
nextPage := fmt.Sprintf("%d", pageInt+1)
|
||||
nextIRI, err := createPageURL(r, &nextPage)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "unable to create next page property")
|
||||
}
|
||||
|
||||
next.SetIRI(nextIRI)
|
||||
collectionPage.SetActivityStreamsNext(next)
|
||||
}
|
||||
|
||||
return collectionPage, nil
|
||||
}
|
||||
|
||||
func createPageURL(r *http.Request, page *string) (*url.URL, error) {
|
||||
configRepository := configrepository.Get()
|
||||
|
||||
domain := configRepository.GetServerURL()
|
||||
if domain == "" {
|
||||
return nil, errors.New("unable to get server URL")
|
||||
}
|
||||
|
||||
pageURL, err := url.Parse(domain)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "unable to parse server URL")
|
||||
}
|
||||
|
||||
if page != nil {
|
||||
query := pageURL.Query()
|
||||
query.Add("page", *page)
|
||||
pageURL.RawQuery = query.Encode()
|
||||
}
|
||||
pageURL.Path = r.URL.Path
|
||||
|
||||
return pageURL, nil
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/owncast/owncast/activitypub/apmodels"
|
||||
"github.com/owncast/owncast/activitypub/inbox"
|
||||
"github.com/owncast/owncast/persistence/configrepository"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// InboxHandler handles inbound federated requests.
|
||||
func InboxHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost {
|
||||
acceptInboxRequest(w, r)
|
||||
} else {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func acceptInboxRequest(w http.ResponseWriter, r *http.Request) {
|
||||
configRepository := configrepository.Get()
|
||||
|
||||
if !configRepository.GetFederationEnabled() {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
urlPathComponents := strings.Split(r.URL.Path, "/")
|
||||
var forLocalAccount string
|
||||
if len(urlPathComponents) == 5 {
|
||||
forLocalAccount = urlPathComponents[3]
|
||||
} else {
|
||||
log.Errorln("Unable to determine username from url path")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// The account this request is for must match the account name we have set
|
||||
// for federation.
|
||||
if forLocalAccount != configRepository.GetFederationUsername() {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
log.Errorln("Unable to read inbox request payload", err)
|
||||
return
|
||||
}
|
||||
|
||||
inboxRequest := apmodels.InboxRequest{Request: r, ForLocalAccount: forLocalAccount, Body: data}
|
||||
inbox.AddToQueue(inboxRequest)
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
}
|
||||
@@ -1,319 +0,0 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"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/config"
|
||||
"github.com/owncast/owncast/persistence/configrepository"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// NodeInfoController returns the V1 node info response.
|
||||
func NodeInfoController(w http.ResponseWriter, r *http.Request) {
|
||||
type links struct {
|
||||
Rel string `json:"rel"`
|
||||
Href string `json:"href"`
|
||||
}
|
||||
|
||||
type response struct {
|
||||
Links []links `json:"links"`
|
||||
}
|
||||
|
||||
configRepository := configrepository.Get()
|
||||
|
||||
if !configRepository.GetFederationEnabled() {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
serverURL := configRepository.GetServerURL()
|
||||
if serverURL == "" {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
v2, err := url.Parse(serverURL)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
v2.Path = "nodeinfo/2.0"
|
||||
|
||||
res := response{
|
||||
Links: []links{
|
||||
{
|
||||
Rel: "http://nodeinfo.diaspora.software/ns/schema/2.0",
|
||||
Href: v2.String(),
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := writeResponse(res, w); err != nil {
|
||||
log.Errorln(err)
|
||||
}
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
type users struct {
|
||||
Total int `json:"total"`
|
||||
ActiveMonth int `json:"activeMonth"`
|
||||
ActiveHalfyear int `json:"activeHalfyear"`
|
||||
}
|
||||
type usage struct {
|
||||
Users users `json:"users"`
|
||||
LocalPosts int `json:"localPosts"`
|
||||
}
|
||||
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"`
|
||||
}
|
||||
|
||||
configRepository := configrepository.Get()
|
||||
|
||||
if !configRepository.GetFederationEnabled() {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
localPostCount, _ := persistence.GetLocalPostCount()
|
||||
|
||||
res := response{
|
||||
Version: "2.0",
|
||||
Services: services{
|
||||
Inbound: []string{},
|
||||
Outbound: []string{},
|
||||
},
|
||||
Software: software{
|
||||
Name: "owncast",
|
||||
Version: config.VersionNumber,
|
||||
},
|
||||
Usage: usage{
|
||||
Users: users{
|
||||
Total: 1,
|
||||
ActiveMonth: 1,
|
||||
ActiveHalfyear: 1,
|
||||
},
|
||||
LocalPosts: int(localPostCount),
|
||||
},
|
||||
OpenRegistrations: false,
|
||||
Protocols: []string{"activitypub"},
|
||||
Metadata: metadata{
|
||||
ChatEnabled: !configRepository.GetChatDisabled(),
|
||||
},
|
||||
}
|
||||
|
||||
if err := writeResponse(res, w); err != nil {
|
||||
log.Errorln(err)
|
||||
}
|
||||
}
|
||||
|
||||
// XNodeInfo2Controller returns the x-nodeinfo2.
|
||||
func XNodeInfo2Controller(w http.ResponseWriter, r *http.Request) {
|
||||
type Organization struct {
|
||||
Name string `json:"name"`
|
||||
Contact string `json:"contact"`
|
||||
}
|
||||
type Server struct {
|
||||
BaseURL string `json:"baseUrl"`
|
||||
Version string `json:"version"`
|
||||
Name string `json:"name"`
|
||||
Software string `json:"software"`
|
||||
}
|
||||
type Services struct {
|
||||
Outbound []string `json:"outbound"`
|
||||
Inbound []string `json:"inbound"`
|
||||
}
|
||||
type Users struct {
|
||||
ActiveWeek int `json:"activeWeek"`
|
||||
Total int `json:"total"`
|
||||
ActiveMonth int `json:"activeMonth"`
|
||||
ActiveHalfyear int `json:"activeHalfyear"`
|
||||
}
|
||||
type Usage struct {
|
||||
Users Users `json:"users"`
|
||||
LocalPosts int `json:"localPosts"`
|
||||
LocalComments int `json:"localComments"`
|
||||
}
|
||||
type response struct {
|
||||
Server Server `json:"server"`
|
||||
Organization Organization `json:"organization"`
|
||||
Version string `json:"version"`
|
||||
Services Services `json:"services"`
|
||||
Protocols []string `json:"protocols"`
|
||||
Usage Usage `json:"usage"`
|
||||
OpenRegistrations bool `json:"openRegistrations"`
|
||||
}
|
||||
|
||||
configRepository := configrepository.Get()
|
||||
|
||||
if !configRepository.GetFederationEnabled() {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
serverURL := configRepository.GetServerURL()
|
||||
if serverURL == "" {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
localPostCount, _ := persistence.GetLocalPostCount()
|
||||
|
||||
res := &response{
|
||||
Organization: Organization{
|
||||
Name: configRepository.GetServerName(),
|
||||
Contact: serverURL,
|
||||
},
|
||||
Server: Server{
|
||||
BaseURL: serverURL,
|
||||
Version: config.VersionNumber,
|
||||
Name: "owncast",
|
||||
Software: "owncast",
|
||||
},
|
||||
Services: Services{
|
||||
Inbound: []string{"activitypub"},
|
||||
Outbound: []string{"activitypub"},
|
||||
},
|
||||
Protocols: []string{"activitypub"},
|
||||
Version: config.VersionNumber,
|
||||
Usage: Usage{
|
||||
Users: Users{
|
||||
ActiveWeek: 1,
|
||||
Total: 1,
|
||||
ActiveMonth: 1,
|
||||
ActiveHalfyear: 1,
|
||||
},
|
||||
|
||||
LocalPosts: int(localPostCount),
|
||||
LocalComments: 0,
|
||||
},
|
||||
}
|
||||
|
||||
if err := writeResponse(res, w); err != nil {
|
||||
log.Errorln(err)
|
||||
}
|
||||
}
|
||||
|
||||
// InstanceV1Controller returns the v1 instance details.
|
||||
func InstanceV1Controller(w http.ResponseWriter, r *http.Request) {
|
||||
type Stats struct {
|
||||
UserCount int `json:"user_count"`
|
||||
StatusCount int `json:"status_count"`
|
||||
DomainCount int `json:"domain_count"`
|
||||
}
|
||||
type response struct {
|
||||
URI string `json:"uri"`
|
||||
Title string `json:"title"`
|
||||
ShortDescription string `json:"short_description"`
|
||||
Description string `json:"description"`
|
||||
Version string `json:"version"`
|
||||
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"`
|
||||
}
|
||||
|
||||
configRepository := configrepository.Get()
|
||||
|
||||
if !configRepository.GetFederationEnabled() {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
serverURL := configRepository.GetServerURL()
|
||||
if serverURL == "" {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
thumbnail, err := url.Parse(serverURL)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
thumbnail.Path = "/logo/external"
|
||||
localPostCount, _ := persistence.GetLocalPostCount()
|
||||
|
||||
res := response{
|
||||
URI: serverURL,
|
||||
Title: configRepository.GetServerName(),
|
||||
ShortDescription: configRepository.GetServerSummary(),
|
||||
Description: configRepository.GetServerSummary(),
|
||||
Version: config.GetReleaseString(),
|
||||
Stats: Stats{
|
||||
UserCount: 1,
|
||||
StatusCount: int(localPostCount),
|
||||
DomainCount: 0,
|
||||
},
|
||||
Thumbnail: thumbnail.String(),
|
||||
Registrations: false,
|
||||
ApprovalRequired: false,
|
||||
InvitesEnabled: false,
|
||||
}
|
||||
|
||||
if err := writeResponse(res, w); err != nil {
|
||||
log.Errorln(err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeResponse(payload interface{}, w http.ResponseWriter) error {
|
||||
configRepository := configrepository.Get()
|
||||
|
||||
accountName := configRepository.GetDefaultFederationUsername()
|
||||
actorIRI := apmodels.MakeLocalIRIForAccount(accountName)
|
||||
publicKey := crypto.GetPublicKey(actorIRI)
|
||||
|
||||
return requests.WritePayloadResponse(payload, w, publicKey)
|
||||
}
|
||||
|
||||
// HostMetaController points to webfinger.
|
||||
func HostMetaController(w http.ResponseWriter, r *http.Request) {
|
||||
configRepository := configrepository.Get()
|
||||
|
||||
if !configRepository.GetFederationEnabled() {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
log.Debugln("host meta request rejected! Federation is not enabled")
|
||||
return
|
||||
}
|
||||
|
||||
serverURL := configRepository.GetServerURL()
|
||||
if serverURL == "" {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
res := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0">
|
||||
<Link rel="lrdd" type="application/json" template="%s/.well-known/webfinger?resource={uri}"/>
|
||||
</XRD>`, serverURL)
|
||||
|
||||
if _, err := w.Write([]byte(res)); err != nil {
|
||||
log.Errorln(err)
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"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/persistence/configrepository"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// ObjectHandler handles requests for a single federated ActivityPub object.
|
||||
func ObjectHandler(w http.ResponseWriter, r *http.Request) {
|
||||
configRepository := configrepository.Get()
|
||||
|
||||
if !configRepository.GetFederationEnabled() {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// If private federation mode is enabled do not allow access to objects.
|
||||
if configRepository.GetFederationIsPrivate() {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
iri := strings.Join([]string{strings.TrimSuffix(configRepository.GetServerURL(), "/"), r.URL.Path}, "")
|
||||
object, _, _, err := persistence.GetObjectByIRI(iri)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
accountName := configRepository.GetDefaultFederationUsername()
|
||||
actorIRI := apmodels.MakeLocalIRIForAccount(accountName)
|
||||
publicKey := crypto.GetPublicKey(actorIRI)
|
||||
|
||||
if err := requests.WriteResponse([]byte(object), w, publicKey); err != nil {
|
||||
log.Errorln(err)
|
||||
}
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/go-fed/activity/streams"
|
||||
"github.com/go-fed/activity/streams/vocab"
|
||||
"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/pkg/errors"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
outboxPageSize = 50
|
||||
)
|
||||
|
||||
// OutboxHandler will handle requests for the local ActivityPub outbox.
|
||||
func OutboxHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var response interface{}
|
||||
var err error
|
||||
if r.URL.Query().Get("page") != "" {
|
||||
response, err = getOutboxPage(r.URL.Query().Get("page"), r)
|
||||
} else {
|
||||
response, err = getInitialOutboxHandler(r)
|
||||
}
|
||||
|
||||
if response == nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
_, _ = w.Write([]byte(err.Error()))
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
pathComponents := strings.Split(r.URL.Path, "/")
|
||||
accountName := pathComponents[3]
|
||||
actorIRI := apmodels.MakeLocalIRIForAccount(accountName)
|
||||
publicKey := crypto.GetPublicKey(actorIRI)
|
||||
|
||||
if err := requests.WriteStreamResponse(response.(vocab.Type), w, publicKey); err != nil {
|
||||
log.Errorln("unable to write stream response for outbox handler", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ActorObjectHandler will handle the request for a single ActivityPub object.
|
||||
func ActorObjectHandler(w http.ResponseWriter, r *http.Request) {
|
||||
object, _, _, err := persistence.GetObjectByIRI(r.URL.Path)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
// controllers.WriteSimpleResponse(w, false, err.Error())
|
||||
}
|
||||
|
||||
if _, err := w.Write([]byte(object)); err != nil {
|
||||
log.Errorln(err)
|
||||
}
|
||||
}
|
||||
|
||||
func getInitialOutboxHandler(r *http.Request) (vocab.ActivityStreamsOrderedCollection, error) {
|
||||
collection := streams.NewActivityStreamsOrderedCollection()
|
||||
|
||||
idProperty := streams.NewJSONLDIdProperty()
|
||||
id, err := createPageURL(r, nil)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "unable to create followers page property")
|
||||
}
|
||||
idProperty.SetIRI(id)
|
||||
collection.SetJSONLDId(idProperty)
|
||||
|
||||
totalPosts, err := persistence.GetOutboxPostCount()
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "unable to get outbox post count")
|
||||
}
|
||||
totalItemsProperty := streams.NewActivityStreamsTotalItemsProperty()
|
||||
totalItemsProperty.Set(int(totalPosts))
|
||||
collection.SetActivityStreamsTotalItems(totalItemsProperty)
|
||||
|
||||
first := streams.NewActivityStreamsFirstProperty()
|
||||
page := "1"
|
||||
firstIRI, err := createPageURL(r, &page)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "unable to create first page property")
|
||||
}
|
||||
|
||||
first.SetIRI(firstIRI)
|
||||
collection.SetActivityStreamsFirst(first)
|
||||
|
||||
return collection, nil
|
||||
}
|
||||
|
||||
func getOutboxPage(page string, r *http.Request) (vocab.ActivityStreamsOrderedCollectionPage, error) {
|
||||
pageInt, err := strconv.Atoi(page)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "unable to parse page number")
|
||||
}
|
||||
|
||||
postCount, err := persistence.GetOutboxPostCount()
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "unable to get outbox post count")
|
||||
}
|
||||
|
||||
collectionPage := streams.NewActivityStreamsOrderedCollectionPage()
|
||||
idProperty := streams.NewJSONLDIdProperty()
|
||||
id, err := createPageURL(r, &page)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "unable to create followers page ID")
|
||||
}
|
||||
idProperty.SetIRI(id)
|
||||
collectionPage.SetJSONLDId(idProperty)
|
||||
|
||||
orderedItems := streams.NewActivityStreamsOrderedItemsProperty()
|
||||
|
||||
outboxItems, err := persistence.GetOutbox(outboxPageSize, (pageInt-1)*outboxPageSize)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "unable to get federation followers")
|
||||
}
|
||||
orderedItems.AppendActivityStreamsOrderedCollection(outboxItems)
|
||||
collectionPage.SetActivityStreamsOrderedItems(orderedItems)
|
||||
|
||||
partOf := streams.NewActivityStreamsPartOfProperty()
|
||||
partOfIRI, err := createPageURL(r, nil)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "unable to create partOf property for outbox page")
|
||||
}
|
||||
|
||||
partOf.SetIRI(partOfIRI)
|
||||
collectionPage.SetActivityStreamsPartOf(partOf)
|
||||
|
||||
if pageInt*followersPageSize < int(postCount) {
|
||||
next := streams.NewActivityStreamsNextProperty()
|
||||
nextPage := fmt.Sprintf("%d", pageInt+1)
|
||||
nextIRI, err := createPageURL(r, &nextPage)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "unable to create next page property")
|
||||
}
|
||||
|
||||
next.SetIRI(nextIRI)
|
||||
collectionPage.SetActivityStreamsNext(next)
|
||||
}
|
||||
|
||||
return collectionPage, nil
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/owncast/owncast/activitypub/apmodels"
|
||||
"github.com/owncast/owncast/persistence/configrepository"
|
||||
"github.com/owncast/owncast/utils"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// WebfingerHandler will handle webfinger lookup requests.
|
||||
func WebfingerHandler(w http.ResponseWriter, r *http.Request) {
|
||||
configRepository := configrepository.Get()
|
||||
|
||||
if !configRepository.GetFederationEnabled() {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
log.Debugln("webfinger request rejected! Federation is not enabled")
|
||||
return
|
||||
}
|
||||
|
||||
instanceHostURL := configRepository.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(): " + configRepository.GetServerURL())
|
||||
return
|
||||
}
|
||||
|
||||
resource := r.URL.Query().Get("resource")
|
||||
preAcct, account, foundAcct := strings.Cut(resource, "acct:")
|
||||
|
||||
if !foundAcct || preAcct != "" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
log.Debugln("webfinger request rejected! Malformed resource in query: " + resource)
|
||||
return
|
||||
}
|
||||
|
||||
userComponents := strings.Split(account, "@")
|
||||
if len(userComponents) != 2 {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
log.Debugln("webfinger request rejected! Malformed account in query: " + account)
|
||||
return
|
||||
}
|
||||
host := userComponents[1]
|
||||
user := userComponents[0]
|
||||
|
||||
if _, valid := configRepository.GetFederatedInboxMap()[user]; !valid {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
log.Debugln("webfinger request rejected! Invalid user: " + user)
|
||||
return
|
||||
}
|
||||
|
||||
// If the webfinger request doesn't match our server then it
|
||||
// should be rejected.
|
||||
if instanceHostString != host {
|
||||
w.WriteHeader(http.StatusNotImplemented)
|
||||
log.Debugln("webfinger request rejected! Invalid query host: " + host + " instanceHostString: " + instanceHostString)
|
||||
return
|
||||
}
|
||||
|
||||
webfingerResponse := apmodels.MakeWebfingerResponse(user, user, host)
|
||||
|
||||
w.Header().Set("Content-Type", "application/jrd+json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(webfingerResponse); err != nil {
|
||||
log.Errorln("unable to write webfinger response", err)
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"net/url"
|
||||
|
||||
"github.com/owncast/owncast/persistence/configrepository"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// GetPublicKey will return the public key for the provided actor.
|
||||
func GetPublicKey(actorIRI *url.URL) PublicKey {
|
||||
configRepository := configrepository.Get()
|
||||
|
||||
key := configRepository.GetPublicKey()
|
||||
idURL, err := url.Parse(actorIRI.String() + "#main-key")
|
||||
if err != nil {
|
||||
log.Errorln("unable to parse actor iri string", idURL, err)
|
||||
}
|
||||
|
||||
return PublicKey{
|
||||
ID: idURL,
|
||||
Owner: actorIRI,
|
||||
PublicKeyPem: key,
|
||||
}
|
||||
}
|
||||
|
||||
// GetPrivateKey will return the internal server private key.
|
||||
func GetPrivateKey() *rsa.PrivateKey {
|
||||
configRepository := configrepository.Get()
|
||||
|
||||
key := configRepository.GetPrivateKey()
|
||||
|
||||
block, _ := pem.Decode([]byte(key))
|
||||
if block == nil {
|
||||
log.Errorln(errors.New("failed to parse PEM block containing the key"))
|
||||
return nil
|
||||
}
|
||||
|
||||
priv, err := x509.ParsePKCS1PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
log.Errorln("unable to parse private key", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
return priv
|
||||
}
|
||||
|
||||
// GenerateKeys will generate the private/public key pair needed for federation.
|
||||
func GenerateKeys() ([]byte, []byte, error) {
|
||||
// generate key
|
||||
privatekey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
log.Errorln("Cannot generate RSA key", err)
|
||||
return nil, nil, err
|
||||
}
|
||||
publickey := &privatekey.PublicKey
|
||||
|
||||
privateKeyBytes := x509.MarshalPKCS1PrivateKey(privatekey)
|
||||
privateKeyBlock := &pem.Block{
|
||||
Type: "RSA PRIVATE KEY",
|
||||
Bytes: privateKeyBytes,
|
||||
}
|
||||
privatePem := pem.EncodeToMemory(privateKeyBlock)
|
||||
|
||||
publicKeyBytes, err := x509.MarshalPKIXPublicKey(publickey)
|
||||
if err != nil {
|
||||
log.Errorln("error when dumping publickey:", err)
|
||||
return nil, nil, err
|
||||
}
|
||||
publicKeyBlock := &pem.Block{
|
||||
Type: "PUBLIC KEY",
|
||||
Bytes: publicKeyBytes,
|
||||
}
|
||||
publicPem := pem.EncodeToMemory(publicKeyBlock)
|
||||
|
||||
return privatePem, publicPem, nil
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
package crypto
|
||||
|
||||
import "net/url"
|
||||
|
||||
// PublicKey represents a public key with associated ownership.
|
||||
type PublicKey struct {
|
||||
ID *url.URL `json:"id"`
|
||||
Owner *url.URL `json:"owner"`
|
||||
PublicKeyPem string `json:"publicKeyPem"`
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/go-fed/httpsig"
|
||||
"github.com/owncast/owncast/config"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// SignResponse will sign a response using the provided response body and public key.
|
||||
func SignResponse(w http.ResponseWriter, body []byte, publicKey PublicKey) error {
|
||||
privateKey := GetPrivateKey()
|
||||
|
||||
return signResponse(privateKey, *publicKey.ID, body, w)
|
||||
}
|
||||
|
||||
func signResponse(privateKey crypto.PrivateKey, pubKeyID url.URL, body []byte, w http.ResponseWriter) error {
|
||||
prefs := []httpsig.Algorithm{httpsig.RSA_SHA256}
|
||||
digestAlgorithm := httpsig.DigestSha256
|
||||
|
||||
// The "Date" and "Digest" headers must already be set on r, as well as r.URL.
|
||||
headersToSign := []string{}
|
||||
if body != nil {
|
||||
headersToSign = append(headersToSign, "digest")
|
||||
}
|
||||
|
||||
signer, _, err := httpsig.NewSigner(prefs, digestAlgorithm, headersToSign, httpsig.Signature, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// If r were a http.ResponseWriter, call SignResponse instead.
|
||||
return signer.SignResponse(privateKey, pubKeyID.String(), w, body)
|
||||
}
|
||||
|
||||
// SignRequest will sign an ounbound request given the provided body.
|
||||
func SignRequest(req *http.Request, body []byte, actorIRI *url.URL) error {
|
||||
publicKey := GetPublicKey(actorIRI)
|
||||
privateKey := GetPrivateKey()
|
||||
|
||||
return signRequest(privateKey, publicKey.ID.String(), body, req)
|
||||
}
|
||||
|
||||
func signRequest(privateKey crypto.PrivateKey, pubKeyID string, body []byte, r *http.Request) error {
|
||||
prefs := []httpsig.Algorithm{httpsig.RSA_SHA256}
|
||||
digestAlgorithm := httpsig.DigestSha256
|
||||
|
||||
date := time.Now().UTC().Format("Mon, 02 Jan 2006 15:04:05 GMT")
|
||||
r.Header["Date"] = []string{date}
|
||||
r.Header["Host"] = []string{r.URL.Host}
|
||||
r.Header["Accept"] = []string{`application/ld+json; profile="https://www.w3.org/ns/activitystreams"`}
|
||||
|
||||
// The "Date" and "Digest" headers must already be set on r, as well as r.URL.
|
||||
headersToSign := []string{httpsig.RequestTarget, "host", "date"}
|
||||
if body != nil {
|
||||
headersToSign = append(headersToSign, "digest")
|
||||
}
|
||||
|
||||
signer, _, err := httpsig.NewSigner(prefs, digestAlgorithm, headersToSign, httpsig.Signature, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// If r were a http.ResponseWriter, call SignResponse instead.
|
||||
return signer.SignRequest(privateKey, pubKeyID, r, body)
|
||||
}
|
||||
|
||||
// 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("POST", 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 := SignRequest(req, payload, fromActorIRI); err != nil {
|
||||
log.Errorln("error signing request:", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return req, nil
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
package inbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/go-fed/activity/streams/vocab"
|
||||
"github.com/owncast/owncast/activitypub/persistence"
|
||||
"github.com/owncast/owncast/core/chat/events"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func handleAnnounceRequest(c context.Context, activity vocab.ActivityStreamsAnnounce) error {
|
||||
object := activity.GetActivityStreamsObject()
|
||||
actorReference := activity.GetActivityStreamsActor()
|
||||
objectIRI := object.At(0).GetIRI().String()
|
||||
actorIRI := actorReference.At(0).GetIRI().String()
|
||||
|
||||
if hasPreviouslyhandled, err := persistence.HasPreviouslyHandledInboundActivity(objectIRI, actorIRI, events.FediverseEngagementRepost); hasPreviouslyhandled || err != nil {
|
||||
return errors.Wrap(err, "inbound activity of share/re-post has already been handled")
|
||||
}
|
||||
|
||||
// Shares need to match a post we had already sent.
|
||||
_, isLiveNotification, timestamp, err := persistence.GetObjectByIRI(objectIRI)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "Could not find post locally")
|
||||
}
|
||||
|
||||
// Don't allow old activities to be liked
|
||||
if time.Since(timestamp) > maxAgeForEngagement {
|
||||
return errors.New("Activity is too old to be shared")
|
||||
}
|
||||
|
||||
// Save as an accepted activity
|
||||
if err := persistence.SaveInboundFediverseActivity(objectIRI, actorIRI, events.FediverseEngagementRepost, time.Now()); err != nil {
|
||||
return errors.Wrap(err, "unable to save inbound share/re-post activity")
|
||||
}
|
||||
|
||||
return handleEngagementActivity(events.FediverseEngagementRepost, isLiveNotification, actorReference, events.FediverseEngagementRepost)
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
package inbox
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/go-fed/activity/streams/vocab"
|
||||
"github.com/owncast/owncast/activitypub/resolvers"
|
||||
"github.com/owncast/owncast/core/chat"
|
||||
"github.com/owncast/owncast/core/chat/events"
|
||||
"github.com/owncast/owncast/persistence/configrepository"
|
||||
)
|
||||
|
||||
func handleEngagementActivity(eventType events.EventType, isLiveNotification bool, actorReference vocab.ActivityStreamsActorProperty, action string) error {
|
||||
configRepository := configrepository.Get()
|
||||
|
||||
// Do nothing if displaying engagement actions has been turned off.
|
||||
if !configRepository.GetFederationShowEngagement() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Do nothing if chat is disabled
|
||||
if configRepository.GetChatDisabled() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get actor of the action
|
||||
actor, _ := resolvers.GetResolvedActorFromActorProperty(actorReference)
|
||||
|
||||
// Send chat message
|
||||
actorName := actor.Name
|
||||
if actorName == "" {
|
||||
actorName = actor.Username
|
||||
}
|
||||
actorIRI := actorReference.Begin().GetIRI().String()
|
||||
|
||||
userPrefix := fmt.Sprintf("%s ", actorName)
|
||||
var suffix string
|
||||
if isLiveNotification && action == events.FediverseEngagementLike {
|
||||
suffix = "liked that this stream went live."
|
||||
} else if action == events.FediverseEngagementLike {
|
||||
suffix = fmt.Sprintf("liked a post from %s.", configRepository.GetServerName())
|
||||
} else if isLiveNotification && action == events.FediverseEngagementRepost {
|
||||
suffix = "shared this stream with their followers."
|
||||
} else if action == events.FediverseEngagementRepost {
|
||||
suffix = fmt.Sprintf("shared a post from %s.", configRepository.GetServerName())
|
||||
} else if action == events.FediverseEngagementFollow {
|
||||
suffix = "followed this stream."
|
||||
} else {
|
||||
return fmt.Errorf("could not handle event for sending to chat: %s", action)
|
||||
}
|
||||
body := fmt.Sprintf("%s %s", userPrefix, suffix)
|
||||
|
||||
var image *string
|
||||
if actor.Image != nil {
|
||||
s := actor.Image.String()
|
||||
image = &s
|
||||
}
|
||||
|
||||
if err := chat.SendFediverseAction(eventType, actor.FullUsername, image, body, actorIRI); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
package inbox
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
maxAgeForEngagement = time.Hour * 36
|
||||
)
|
||||
@@ -1,13 +0,0 @@
|
||||
package inbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/go-fed/activity/streams/vocab"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func handleCreateRequest(c context.Context, activity vocab.ActivityStreamsCreate) error {
|
||||
iri := activity.GetJSONLDId().GetIRI().String()
|
||||
return errors.New("not handling create request of: " + iri)
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
package inbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/go-fed/activity/streams/vocab"
|
||||
"github.com/owncast/owncast/activitypub/persistence"
|
||||
"github.com/owncast/owncast/activitypub/requests"
|
||||
"github.com/owncast/owncast/activitypub/resolvers"
|
||||
"github.com/owncast/owncast/core/chat/events"
|
||||
"github.com/owncast/owncast/persistence/configrepository"
|
||||
"github.com/pkg/errors"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func handleFollowInboxRequest(c context.Context, activity vocab.ActivityStreamsFollow) error {
|
||||
configRepository := configrepository.Get()
|
||||
|
||||
follow, err := resolvers.MakeFollowRequest(c, activity)
|
||||
if err != nil {
|
||||
log.Errorln("unable to create follow inbox request", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if follow == nil {
|
||||
return fmt.Errorf("unable to handle request")
|
||||
}
|
||||
|
||||
approved := !configRepository.GetFederationIsPrivate()
|
||||
|
||||
followRequest := *follow
|
||||
|
||||
if err := persistence.AddFollow(followRequest, approved); err != nil {
|
||||
log.Errorln("unable to save follow request", err)
|
||||
return err
|
||||
}
|
||||
|
||||
localAccountName := configRepository.GetDefaultFederationUsername()
|
||||
|
||||
if approved {
|
||||
if err := requests.SendFollowAccept(follow.Inbox, activity, localAccountName); err != nil {
|
||||
log.Errorln("unable to send follow accept", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Save as an accepted activity
|
||||
actorReference := activity.GetActivityStreamsActor()
|
||||
object := activity.GetActivityStreamsObject()
|
||||
objectIRI := object.At(0).GetIRI().String()
|
||||
actorIRI := actorReference.At(0).GetIRI().String()
|
||||
|
||||
// If this request is approved and we have not previously sent an action to
|
||||
// chat due to a previous follow request, then do so.
|
||||
hasPreviouslyhandled := true // Default so we don't send anything if it fails.
|
||||
if approved {
|
||||
hasPreviouslyhandled, err = persistence.HasPreviouslyHandledInboundActivity(objectIRI, actorIRI, events.FediverseEngagementFollow)
|
||||
if err != nil {
|
||||
log.Errorln("error checking for previously handled follow activity", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Save this follow action to our activities table.
|
||||
if err := persistence.SaveInboundFediverseActivity(objectIRI, actorIRI, events.FediverseEngagementFollow, time.Now()); err != nil {
|
||||
return errors.Wrap(err, "unable to save inbound share/re-post activity")
|
||||
}
|
||||
|
||||
// Send action to chat if it has not been previously handled.
|
||||
if !hasPreviouslyhandled {
|
||||
return handleEngagementActivity(events.FediverseEngagementFollow, false, actorReference, events.FediverseEngagementFollow)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleUnfollowRequest(c context.Context, activity vocab.ActivityStreamsUndo) error {
|
||||
request := resolvers.MakeUnFollowRequest(c, activity)
|
||||
if request == nil {
|
||||
log.Errorf("unable to handle unfollow request")
|
||||
return errors.New("unable to handle unfollow request")
|
||||
}
|
||||
|
||||
unfollowRequest := *request
|
||||
log.Traceln("unfollow request:", unfollowRequest)
|
||||
|
||||
return persistence.RemoveFollow(unfollowRequest)
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
package inbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/go-fed/activity/streams/vocab"
|
||||
"github.com/owncast/owncast/activitypub/persistence"
|
||||
"github.com/owncast/owncast/core/chat/events"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
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()
|
||||
|
||||
if hasPreviouslyhandled, err := persistence.HasPreviouslyHandledInboundActivity(objectIRI, actorIRI, events.FediverseEngagementLike); hasPreviouslyhandled || err != nil {
|
||||
return errors.Wrap(err, "inbound activity of like has already been handled")
|
||||
}
|
||||
|
||||
// Likes need to match a post we had already sent.
|
||||
_, isLiveNotification, timestamp, err := persistence.GetObjectByIRI(objectIRI)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "Could not find post locally")
|
||||
}
|
||||
|
||||
// Don't allow old activities to be liked
|
||||
if time.Since(timestamp) > maxAgeForEngagement {
|
||||
return errors.New("Activity is too old to be liked")
|
||||
}
|
||||
|
||||
// Save as an accepted activity
|
||||
if err := persistence.SaveInboundFediverseActivity(objectIRI, actorIRI, events.FediverseEngagementLike, time.Now()); err != nil {
|
||||
return errors.Wrap(err, "unable to save inbound like activity")
|
||||
}
|
||||
|
||||
return handleEngagementActivity(events.FediverseEngagementLike, isLiveNotification, actorReference, events.FediverseEngagementLike)
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
package inbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/go-fed/activity/streams/vocab"
|
||||
)
|
||||
|
||||
func handleUndoInboxRequest(c context.Context, activity vocab.ActivityStreamsUndo) error {
|
||||
// Determine if this is an undo of a follow, favorite, announce, etc.
|
||||
o := activity.GetActivityStreamsObject()
|
||||
for iter := o.Begin(); iter != o.End(); iter = iter.Next() {
|
||||
if iter.IsActivityStreamsFollow() {
|
||||
// This is an Unfollow request
|
||||
if err := handleUnfollowRequest(c, activity); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
t := iter.GetType()
|
||||
if t != nil {
|
||||
log.Traceln("Undo", t.GetTypeName(), "ignored")
|
||||
} else {
|
||||
log.Traceln("Undo (no type) ignored")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
package inbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/go-fed/activity/streams/vocab"
|
||||
"github.com/owncast/owncast/activitypub/persistence"
|
||||
"github.com/owncast/owncast/activitypub/resolvers"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func handleUpdateRequest(c context.Context, activity vocab.ActivityStreamsUpdate) error {
|
||||
// We only care about update events to followers.
|
||||
if !activity.GetActivityStreamsObject().At(0).IsActivityStreamsPerson() {
|
||||
return nil
|
||||
}
|
||||
|
||||
actor, err := resolvers.GetResolvedActorFromActorProperty(activity.GetActivityStreamsActor())
|
||||
if err != nil {
|
||||
log.Errorln(err)
|
||||
return err
|
||||
}
|
||||
|
||||
return persistence.UpdateFollower(actor.ActorIri.String(), actor.Inbox.String(), actor.Name, actor.FullUsername, actor.Image.String())
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
package inbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/go-fed/httpsig"
|
||||
"github.com/owncast/owncast/activitypub/apmodels"
|
||||
"github.com/owncast/owncast/activitypub/persistence"
|
||||
"github.com/owncast/owncast/activitypub/resolvers"
|
||||
"github.com/owncast/owncast/persistence/configrepository"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func handle(request apmodels.InboxRequest) {
|
||||
if verified, err := Verify(request.Request); err != nil {
|
||||
log.Debugln("Error in attempting to verify request", err)
|
||||
return
|
||||
} else if !verified {
|
||||
log.Debugln("Request failed verification", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := resolvers.Resolve(context.Background(), request.Body, handleUpdateRequest, handleFollowInboxRequest, handleLikeRequest, handleAnnounceRequest, handleUndoInboxRequest, handleCreateRequest); err != nil {
|
||||
log.Debugln("resolver error:", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return false, errors.Wrap(err, "failed to create key verifier for request")
|
||||
}
|
||||
pubKeyID, err := url.Parse(verifier.KeyId())
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "failed to parse key to get key ID")
|
||||
}
|
||||
|
||||
// Force federation only via servers using https.
|
||||
if pubKeyID.Scheme != "https" {
|
||||
return false, errors.New("federated servers must use https: " + pubKeyID.String())
|
||||
}
|
||||
|
||||
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 {
|
||||
kv := strings.Split(component, "=")
|
||||
if kv[0] == "algorithm" {
|
||||
algorithmString = kv[1]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
algorithmString = strings.Trim(algorithmString, "\"")
|
||||
if algorithmString == "" {
|
||||
return false, errors.New("Unable to determine algorithm to verify request")
|
||||
}
|
||||
|
||||
publicKey, err := resolvers.GetResolvedPublicKeyFromIRI(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")
|
||||
}
|
||||
|
||||
// Test to see if the actor is in the list of blocked federated domains.
|
||||
if isBlockedDomain(publicKeyActorIRI.Hostname()) {
|
||||
return false, errors.New("domain is blocked")
|
||||
}
|
||||
|
||||
// If actor is specifically blocked, then fail validation.
|
||||
if blocked, err := isBlockedActor(publicKeyActorIRI); err != nil || blocked {
|
||||
return false, err
|
||||
}
|
||||
|
||||
key := publicKey.GetW3IDSecurityV1PublicKeyPem().Get()
|
||||
block, _ := pem.Decode([]byte(key))
|
||||
if block == nil {
|
||||
log.Errorln("failed to parse PEM block containing the public key")
|
||||
return false, errors.New("failed to parse PEM block containing the public key")
|
||||
}
|
||||
|
||||
parsedKey, err := x509.ParsePKIXPublicKey(block.Bytes)
|
||||
if err != nil {
|
||||
log.Errorln("failed to parse DER encoded public key: " + err.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,
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
return false, fmt.Errorf("http signature verification error(s) for: %s: %+v", pubKeyID.String(), triedAlgos)
|
||||
}
|
||||
|
||||
func isBlockedDomain(domain string) bool {
|
||||
configRepository := configrepository.Get()
|
||||
|
||||
blockedDomains := configRepository.GetBlockedFederatedDomains()
|
||||
|
||||
for _, blockedDomain := range blockedDomains {
|
||||
if strings.Contains(domain, blockedDomain) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func isBlockedActor(actorIRI *url.URL) (bool, error) {
|
||||
blockedactor, err := persistence.GetFollower(actorIRI.String())
|
||||
|
||||
if blockedactor != nil && blockedactor.DisabledAt != nil {
|
||||
return true, errors.Wrap(err, "remote actor is blocked")
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
package inbox
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/go-fed/activity/streams"
|
||||
"github.com/go-fed/activity/streams/vocab"
|
||||
"github.com/owncast/owncast/activitypub/apmodels"
|
||||
"github.com/owncast/owncast/activitypub/persistence"
|
||||
"github.com/owncast/owncast/core/data"
|
||||
"github.com/owncast/owncast/persistence/configrepository"
|
||||
)
|
||||
|
||||
func makeFakePerson() vocab.ActivityStreamsPerson {
|
||||
iri, _ := url.Parse("https://freedom.eagle/user/mrfoo")
|
||||
name := "Mr Foo"
|
||||
username := "foodawg"
|
||||
inbox, _ := url.Parse("https://fake.fediverse.server/user/mrfoo/inbox")
|
||||
userAvatarURL, _ := url.Parse("https://fake.fediverse.server/user/mrfoo/avatar.png")
|
||||
|
||||
person := streams.NewActivityStreamsPerson()
|
||||
|
||||
id := streams.NewJSONLDIdProperty()
|
||||
id.Set(iri)
|
||||
person.SetJSONLDId(id)
|
||||
|
||||
nameProperty := streams.NewActivityStreamsNameProperty()
|
||||
nameProperty.AppendXMLSchemaString(name)
|
||||
person.SetActivityStreamsName(nameProperty)
|
||||
|
||||
preferredUsernameProperty := streams.NewActivityStreamsPreferredUsernameProperty()
|
||||
preferredUsernameProperty.SetXMLSchemaString(username)
|
||||
person.SetActivityStreamsPreferredUsername(preferredUsernameProperty)
|
||||
|
||||
inboxProp := streams.NewActivityStreamsInboxProperty()
|
||||
inboxProp.SetIRI(inbox)
|
||||
person.SetActivityStreamsInbox(inboxProp)
|
||||
|
||||
image := streams.NewActivityStreamsImage()
|
||||
imgProp := streams.NewActivityStreamsUrlProperty()
|
||||
imgProp.AppendIRI(userAvatarURL)
|
||||
image.SetActivityStreamsUrl(imgProp)
|
||||
icon := streams.NewActivityStreamsIconProperty()
|
||||
icon.AppendActivityStreamsImage(image)
|
||||
person.SetActivityStreamsIcon(icon)
|
||||
|
||||
return person
|
||||
}
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
data.SetupPersistence(":memory:")
|
||||
configRepository := configrepository.Get()
|
||||
configRepository.SetServerURL("https://my.cool.site.biz")
|
||||
persistence.Setup(data.GetDatastore())
|
||||
m.Run()
|
||||
}
|
||||
|
||||
func TestBlockedDomains(t *testing.T) {
|
||||
configRepository := configrepository.Get()
|
||||
|
||||
person := makeFakePerson()
|
||||
|
||||
configRepository.SetBlockedFederatedDomains([]string{"freedom.eagle", "guns.life"})
|
||||
|
||||
if len(configRepository.GetBlockedFederatedDomains()) != 2 {
|
||||
t.Error("Blocked federated domains is not set correctly")
|
||||
}
|
||||
|
||||
for _, domain := range configRepository.GetBlockedFederatedDomains() {
|
||||
if domain == person.GetJSONLDId().GetIRI().Host {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
t.Error("Failed to catch blocked domain")
|
||||
}
|
||||
|
||||
func TestBlockedActors(t *testing.T) {
|
||||
person := makeFakePerson()
|
||||
fakeRequest := streams.NewActivityStreamsFollow()
|
||||
persistence.AddFollow(apmodels.ActivityPubActor{
|
||||
ActorIri: person.GetJSONLDId().GetIRI(),
|
||||
Inbox: person.GetJSONLDId().GetIRI(),
|
||||
FollowRequestIri: person.GetJSONLDId().GetIRI(),
|
||||
RequestObject: fakeRequest,
|
||||
}, false)
|
||||
persistence.BlockOrRejectFollower(person.GetJSONLDId().GetIRI().String())
|
||||
|
||||
blocked, err := isBlockedActor(person.GetJSONLDId().GetIRI())
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
if !blocked {
|
||||
t.Error("Failed to block actor")
|
||||
}
|
||||
|
||||
failedBlockIRI, _ := url.Parse("https://freedom.eagle/user/mrbar")
|
||||
failedBlock, err := isBlockedActor(failedBlockIRI)
|
||||
|
||||
if failedBlock {
|
||||
t.Error("Invalid blocking of unblocked actor IRI")
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
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)
|
||||
|
||||
// Job struct bundling the ActivityPub and the payload in one struct.
|
||||
type Job struct {
|
||||
request apmodels.InboxRequest
|
||||
}
|
||||
|
||||
var queue chan Job
|
||||
|
||||
// InitInboxWorkerPool starts n go routines that await ActivityPub jobs.
|
||||
func InitInboxWorkerPool() {
|
||||
queue = make(chan Job)
|
||||
|
||||
// start workers
|
||||
for i := 1; i <= workerPoolSize; i++ {
|
||||
go worker(i, queue)
|
||||
}
|
||||
}
|
||||
|
||||
// AddToQueue will queue up an outbound http request.
|
||||
func AddToQueue(req apmodels.InboxRequest) {
|
||||
log.Tracef("Queued request for ActivityPub inbox handler")
|
||||
queue <- Job{req}
|
||||
}
|
||||
|
||||
func worker(workerID int, queue <-chan Job) {
|
||||
log.Debugf("Started ActivityPub worker %d", workerID)
|
||||
|
||||
for job := range queue {
|
||||
handle(job.request)
|
||||
|
||||
log.Tracef("Done with ActivityPub inbox handler using worker %d", workerID)
|
||||
}
|
||||
}
|
||||
@@ -1,315 +0,0 @@
|
||||
package outbox
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/go-fed/activity/streams"
|
||||
"github.com/go-fed/activity/streams/vocab"
|
||||
"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/owncast/owncast/persistence/configrepository"
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/owncast/owncast/config"
|
||||
"github.com/owncast/owncast/utils"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/teris-io/shortid"
|
||||
)
|
||||
|
||||
// SendLive will send all followers the message saying you started a live stream.
|
||||
func SendLive() error {
|
||||
configRepository := configrepository.Get()
|
||||
|
||||
textContent := configRepository.GetFederationGoLiveMessage()
|
||||
|
||||
// If the message is empty then do not send it.
|
||||
if textContent == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
tagStrings := []string{}
|
||||
reg := regexp.MustCompile("[^a-zA-Z0-9]+")
|
||||
|
||||
tagProp := streams.NewActivityStreamsTagProperty()
|
||||
for _, tagString := range configRepository.GetServerMetadataTags() {
|
||||
tagWithoutSpecialCharacters := reg.ReplaceAllString(tagString, "")
|
||||
hashtag := apmodels.MakeHashtag(tagWithoutSpecialCharacters)
|
||||
tagProp.AppendTootHashtag(hashtag)
|
||||
tagString := getHashtagLinkHTMLFromTagString(tagWithoutSpecialCharacters)
|
||||
tagStrings = append(tagStrings, tagString)
|
||||
}
|
||||
|
||||
// Manually add Owncast hashtag if it doesn't already exist so it shows up
|
||||
// in Owncast search results.
|
||||
// We can remove this down the road, but it'll be nice for now.
|
||||
if _, exists := utils.FindInSlice(tagStrings, "owncast"); !exists {
|
||||
hashtag := apmodels.MakeHashtag("owncast")
|
||||
tagProp.AppendTootHashtag(hashtag)
|
||||
}
|
||||
|
||||
tagsString := strings.Join(tagStrings, " ")
|
||||
|
||||
var streamTitle string
|
||||
if title := configRepository.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, configRepository.GetServerURL(), configRepository.GetServerURL())
|
||||
|
||||
activity, _, note, noteID := createBaseOutboundMessage(textContent)
|
||||
|
||||
// To the public if we're not treating ActivityPub as "private".
|
||||
if !configRepository.GetFederationIsPrivate() {
|
||||
note = apmodels.MakeNotePublic(note)
|
||||
activity = apmodels.MakeActivityPublic(activity)
|
||||
}
|
||||
|
||||
note.SetActivityStreamsTag(tagProp)
|
||||
|
||||
// Attach an image along with the Federated message.
|
||||
previewURL, err := url.Parse(configRepository.GetServerURL())
|
||||
if err == nil {
|
||||
var imageToAttach string
|
||||
var mediaType string
|
||||
previewGif := filepath.Join(config.TempDir, "preview.gif")
|
||||
thumbnailJpg := filepath.Join(config.TempDir, "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)
|
||||
}
|
||||
}
|
||||
|
||||
if configRepository.GetNSFW() {
|
||||
// Mark content as sensitive.
|
||||
sensitive := streams.NewActivityStreamsSensitiveProperty()
|
||||
sensitive.AppendXMLSchemaBoolean(true)
|
||||
note.SetActivityStreamsSensitive(sensitive)
|
||||
}
|
||||
|
||||
b, err := apmodels.Serialize(activity)
|
||||
if err != nil {
|
||||
log.Errorln("unable to serialize go live message activity", err)
|
||||
return errors.New("unable to serialize go live message activity " + err.Error())
|
||||
}
|
||||
|
||||
if err := SendToFollowers(b); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := Add(note, noteID, true); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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 {
|
||||
configRepository := configrepository.Get()
|
||||
|
||||
originalContent := textContent
|
||||
textContent = utils.RenderSimpleMarkdown(textContent)
|
||||
|
||||
tagProp := streams.NewActivityStreamsTagProperty()
|
||||
|
||||
hashtagStrings := utils.GetHashtagsFromText(originalContent)
|
||||
|
||||
for _, hashtag := range hashtagStrings {
|
||||
tagWithoutHashtag := strings.TrimPrefix(hashtag, "#")
|
||||
|
||||
// Replace the instances of the tag with a link to the tag page.
|
||||
tagHTML := getHashtagLinkHTMLFromTagString(tagWithoutHashtag)
|
||||
textContent = strings.ReplaceAll(textContent, hashtag, tagHTML)
|
||||
|
||||
// Create Hashtag object for the tag.
|
||||
hashtag := apmodels.MakeHashtag(tagWithoutHashtag)
|
||||
tagProp.AppendTootHashtag(hashtag)
|
||||
}
|
||||
|
||||
activity, _, note, noteID := createBaseOutboundMessage(textContent)
|
||||
note.SetActivityStreamsTag(tagProp)
|
||||
|
||||
if !configRepository.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)
|
||||
return errors.New("unable to serialize custom fediverse message activity " + err.Error())
|
||||
}
|
||||
|
||||
if err := SendToFollowers(b); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := Add(note, noteID, false); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// nolint: unparam
|
||||
func createBaseOutboundMessage(textContent string) (vocab.ActivityStreamsCreate, string, vocab.ActivityStreamsNote, string) {
|
||||
configRepository := configrepository.Get()
|
||||
localActor := apmodels.MakeLocalIRIForAccount(configRepository.GetDefaultFederationUsername())
|
||||
noteID := shortid.MustGenerate()
|
||||
noteIRI := apmodels.MakeLocalIRIForResource(noteID)
|
||||
id := shortid.MustGenerate()
|
||||
activity := apmodels.CreateCreateActivity(id, localActor)
|
||||
object := streams.NewActivityStreamsObjectProperty()
|
||||
activity.SetActivityStreamsObject(object)
|
||||
|
||||
note := apmodels.MakeNote(textContent, noteIRI, localActor)
|
||||
object.AppendActivityStreamsNote(note)
|
||||
|
||||
return activity, id, note, noteID
|
||||
}
|
||||
|
||||
// Get Hashtag HTML link for a given tag (without # prefix).
|
||||
func getHashtagLinkHTMLFromTagString(baseHashtag string) string {
|
||||
return fmt.Sprintf("<a class=\"hashtag\" href=\"https://directory.owncast.online/tags/%s\">#%s</a>", baseHashtag, baseHashtag)
|
||||
}
|
||||
|
||||
// SendToFollowers will send an arbitrary payload to all follower inboxes.
|
||||
func SendToFollowers(payload []byte) error {
|
||||
configRepository := configrepository.Get()
|
||||
localActor := apmodels.MakeLocalIRIForAccount(configRepository.GetDefaultFederationUsername())
|
||||
|
||||
followers, _, err := persistence.GetFederationFollowers(-1, 0)
|
||||
if err != nil {
|
||||
log.Errorln("unable to fetch followers to send to", err)
|
||||
return errors.New("unable to fetch followers to send payload to")
|
||||
}
|
||||
|
||||
for _, follower := range followers {
|
||||
inbox, _ := url.Parse(follower.Inbox)
|
||||
req, err := crypto.CreateSignedRequest(payload, inbox, localActor)
|
||||
if err != nil {
|
||||
log.Errorln("unable to create outbox request", follower.Inbox, err)
|
||||
return errors.New("unable to create outbox request: " + follower.Inbox)
|
||||
}
|
||||
|
||||
workerpool.AddToOutboundQueue(req)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendToUser will send a payload to a single specific inbox.
|
||||
func SendToUser(inbox *url.URL, payload []byte) error {
|
||||
configRepository := configrepository.Get()
|
||||
localActor := apmodels.MakeLocalIRIForAccount(configRepository.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 {
|
||||
configRepository := configrepository.Get()
|
||||
|
||||
// Don't do anything if federation is disabled.
|
||||
if !configRepository.GetFederationEnabled() {
|
||||
return nil
|
||||
}
|
||||
|
||||
id := shortid.MustGenerate()
|
||||
objectID := apmodels.MakeLocalIRIForResource(id)
|
||||
activity := apmodels.MakeUpdateActivity(objectID)
|
||||
|
||||
actor := streams.NewActivityStreamsPerson()
|
||||
actorID := apmodels.MakeLocalIRIForAccount(configRepository.GetDefaultFederationUsername())
|
||||
actorIDProperty := streams.NewJSONLDIdProperty()
|
||||
actorIDProperty.Set(actorID)
|
||||
actor.SetJSONLDId(actorIDProperty)
|
||||
|
||||
actorProperty := streams.NewActivityStreamsActorProperty()
|
||||
actorProperty.AppendActivityStreamsPerson(actor)
|
||||
activity.SetActivityStreamsActor(actorProperty)
|
||||
|
||||
obj := streams.NewActivityStreamsObjectProperty()
|
||||
obj.AppendIRI(actorID)
|
||||
activity.SetActivityStreamsObject(obj)
|
||||
|
||||
b, err := apmodels.Serialize(activity)
|
||||
if err != nil {
|
||||
log.Errorln("unable to serialize send update actor activity", err)
|
||||
return errors.New("unable to serialize send update actor activity")
|
||||
}
|
||||
return SendToFollowers(b)
|
||||
}
|
||||
|
||||
// Add will save an ActivityPub object to the datastore.
|
||||
func Add(item vocab.Type, id string, isLiveNotification bool) error {
|
||||
iri := item.GetJSONLDId().GetIRI().String()
|
||||
typeString := item.GetTypeName()
|
||||
|
||||
if iri == "" {
|
||||
log.Errorln("Unable to get iri from item")
|
||||
return errors.New("Unable to get iri from item " + id)
|
||||
}
|
||||
|
||||
b, err := apmodels.Serialize(item)
|
||||
if err != nil {
|
||||
log.Errorln("unable to serialize model when saving to outbox", err)
|
||||
return err
|
||||
}
|
||||
|
||||
return persistence.AddToOutbox(iri, b, typeString, isLiveNotification)
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/owncast/owncast/db"
|
||||
"github.com/owncast/owncast/models"
|
||||
"github.com/owncast/owncast/utils"
|
||||
"github.com/pkg/errors"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func createFederationFollowersTable() {
|
||||
log.Traceln("Creating federation followers table...")
|
||||
|
||||
createTableSQL := `CREATE TABLE IF NOT EXISTS ap_followers (
|
||||
"iri" TEXT NOT NULL,
|
||||
"inbox" TEXT NOT NULL,
|
||||
"name" TEXT,
|
||||
"username" TEXT NOT NULL,
|
||||
"image" TEXT,
|
||||
"request" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
"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);`)
|
||||
}
|
||||
|
||||
// GetFollowerCount will return the number of followers we're keeping track of.
|
||||
func GetFollowerCount() (int64, error) {
|
||||
ctx := context.Background()
|
||||
return _datastore.GetQueries().GetFollowerCount(ctx)
|
||||
}
|
||||
|
||||
// GetFederationFollowers will return a slice of the followers we keep track of locally.
|
||||
func GetFederationFollowers(limit int, offset int) ([]models.Follower, int, error) {
|
||||
ctx := context.Background()
|
||||
total, err := _datastore.GetQueries().GetFollowerCount(ctx)
|
||||
if err != nil {
|
||||
return nil, 0, errors.Wrap(err, "unable to fetch total number of followers")
|
||||
}
|
||||
|
||||
followersResult, err := _datastore.GetQueries().GetFederationFollowersWithOffset(ctx, db.GetFederationFollowersWithOffsetParams{
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
followers := make([]models.Follower, 0)
|
||||
|
||||
for _, row := range followersResult {
|
||||
singleFollower := models.Follower{
|
||||
Name: row.Name.String,
|
||||
Username: row.Username,
|
||||
Image: row.Image.String,
|
||||
ActorIRI: row.Iri,
|
||||
Inbox: row.Inbox,
|
||||
Timestamp: utils.NullTime(row.CreatedAt),
|
||||
}
|
||||
|
||||
followers = append(followers, singleFollower)
|
||||
}
|
||||
|
||||
return followers, int(total), nil
|
||||
}
|
||||
|
||||
// GetPendingFollowRequests will return pending follow requests.
|
||||
func GetPendingFollowRequests() ([]models.Follower, error) {
|
||||
pendingFollowersResult, err := _datastore.GetQueries().GetFederationFollowerApprovalRequests(context.Background())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
followers := make([]models.Follower, 0)
|
||||
|
||||
for _, row := range pendingFollowersResult {
|
||||
singleFollower := models.Follower{
|
||||
Name: row.Name.String,
|
||||
Username: row.Username,
|
||||
Image: row.Image.String,
|
||||
ActorIRI: row.Iri,
|
||||
Inbox: row.Inbox,
|
||||
Timestamp: utils.NullTime{Time: row.CreatedAt.Time, Valid: true},
|
||||
}
|
||||
followers = append(followers, singleFollower)
|
||||
}
|
||||
|
||||
return followers, nil
|
||||
}
|
||||
|
||||
// GetBlockedAndRejectedFollowers will return blocked and rejected followers.
|
||||
func GetBlockedAndRejectedFollowers() ([]models.Follower, error) {
|
||||
pendingFollowersResult, err := _datastore.GetQueries().GetRejectedAndBlockedFollowers(context.Background())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
followers := make([]models.Follower, 0)
|
||||
|
||||
for _, row := range pendingFollowersResult {
|
||||
singleFollower := models.Follower{
|
||||
Name: row.Name.String,
|
||||
Username: row.Username,
|
||||
Image: row.Image.String,
|
||||
ActorIRI: row.Iri,
|
||||
DisabledAt: utils.NullTime{Time: row.DisabledAt.Time, Valid: true},
|
||||
Timestamp: utils.NullTime{Time: row.CreatedAt.Time, Valid: true},
|
||||
}
|
||||
followers = append(followers, singleFollower)
|
||||
}
|
||||
|
||||
return followers, nil
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
//go:build fixture
|
||||
// +build fixture
|
||||
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/owncast/owncast/models"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func addFollowersFixtureData() {
|
||||
log.Println("Adding followers fixture data...")
|
||||
file, err := os.Open("./test/fixture/followers_fixture.json")
|
||||
if err != nil {
|
||||
fmt.Println("Error opening file:", err)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var followers []models.Follower
|
||||
decoder := json.NewDecoder(file)
|
||||
err = decoder.Decode(&followers)
|
||||
if err != nil {
|
||||
fmt.Println("Error decoding JSON:", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Iterate over the followers array
|
||||
for _, follower := range followers {
|
||||
createFollow(follower.ActorIRI, follower.Inbox, "", follower.Name, follower.Username, follower.Image, nil, true)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
//go:build !fixture
|
||||
// +build !fixture
|
||||
|
||||
package persistence
|
||||
|
||||
func addFollowersFixtureData() {
|
||||
// no-op
|
||||
}
|
||||
@@ -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{},
|
||||
}
|
||||
}
|
||||
@@ -1,352 +0,0 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/go-fed/activity/streams"
|
||||
"github.com/go-fed/activity/streams/vocab"
|
||||
"github.com/owncast/owncast/activitypub/apmodels"
|
||||
"github.com/owncast/owncast/activitypub/resolvers"
|
||||
"github.com/owncast/owncast/core/data"
|
||||
"github.com/owncast/owncast/db"
|
||||
"github.com/owncast/owncast/models"
|
||||
"github.com/pkg/errors"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
var _datastore *data.Datastore
|
||||
|
||||
// Setup will initialize the ActivityPub persistence layer with the provided datastore.
|
||||
func Setup(datastore *data.Datastore) {
|
||||
_datastore = datastore
|
||||
createFederationFollowersTable()
|
||||
createFederationOutboxTable()
|
||||
createFederatedActivitiesTable()
|
||||
addFollowersFixtureData()
|
||||
}
|
||||
|
||||
// AddFollow will save a follow to the datastore.
|
||||
func AddFollow(follow apmodels.ActivityPubActor, approved bool) error {
|
||||
log.Traceln("Saving", follow.ActorIri, "as a follower.")
|
||||
var image string
|
||||
if follow.Image != nil {
|
||||
image = follow.Image.String()
|
||||
}
|
||||
|
||||
followRequestObject, err := apmodels.Serialize(follow.RequestObject)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "error serializing follow request object")
|
||||
}
|
||||
|
||||
return createFollow(follow.ActorIri.String(), follow.Inbox.String(), follow.FollowRequestIri.String(), follow.Name, follow.Username, image, followRequestObject, approved)
|
||||
}
|
||||
|
||||
// RemoveFollow will remove a follow from the datastore.
|
||||
func RemoveFollow(unfollow apmodels.ActivityPubActor) error {
|
||||
log.Traceln("Removing", unfollow.ActorIri, "as a follower.")
|
||||
return removeFollow(unfollow.ActorIri)
|
||||
}
|
||||
|
||||
// GetFollower will return a single follower/request given an IRI.
|
||||
func GetFollower(iri string) (*apmodels.ActivityPubActor, error) {
|
||||
result, err := _datastore.GetQueries().GetFollowerByIRI(context.Background(), iri)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
followIRI, err := url.Parse(result.Request)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "error parsing follow request IRI")
|
||||
}
|
||||
|
||||
iriURL, err := url.Parse(result.Iri)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "error parsing actor IRI")
|
||||
}
|
||||
|
||||
inbox, err := url.Parse(result.Inbox)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "error parsing acting inbox")
|
||||
}
|
||||
|
||||
image, _ := url.Parse(result.Image.String)
|
||||
|
||||
var disabledAt *time.Time
|
||||
if result.DisabledAt.Valid {
|
||||
disabledAt = &result.DisabledAt.Time
|
||||
}
|
||||
|
||||
follower := apmodels.ActivityPubActor{
|
||||
ActorIri: iriURL,
|
||||
Inbox: inbox,
|
||||
Name: result.Name.String,
|
||||
Username: result.Username,
|
||||
Image: image,
|
||||
FollowRequestIri: followIRI,
|
||||
DisabledAt: disabledAt,
|
||||
}
|
||||
|
||||
return &follower, nil
|
||||
}
|
||||
|
||||
// ApprovePreviousFollowRequest will approve a follow request.
|
||||
func ApprovePreviousFollowRequest(iri string) error {
|
||||
return _datastore.GetQueries().ApproveFederationFollower(context.Background(), db.ApproveFederationFollowerParams{
|
||||
Iri: iri,
|
||||
ApprovedAt: sql.NullTime{
|
||||
Time: time.Now(),
|
||||
Valid: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// BlockOrRejectFollower will block an existing follower or reject a follow request.
|
||||
func BlockOrRejectFollower(iri string) error {
|
||||
return _datastore.GetQueries().RejectFederationFollower(context.Background(), db.RejectFederationFollowerParams{
|
||||
Iri: iri,
|
||||
DisabledAt: sql.NullTime{
|
||||
Time: time.Now(),
|
||||
Valid: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func createFollow(actor, inbox, request, name, username, image string, requestObject []byte, approved bool) error {
|
||||
tx, err := _datastore.DB.Begin()
|
||||
if err != nil {
|
||||
log.Debugln(err)
|
||||
}
|
||||
defer func() {
|
||||
_ = tx.Rollback()
|
||||
}()
|
||||
|
||||
var approvedAt sql.NullTime
|
||||
if approved {
|
||||
approvedAt = sql.NullTime{
|
||||
Time: time.Now(),
|
||||
Valid: true,
|
||||
}
|
||||
}
|
||||
|
||||
if err = _datastore.GetQueries().WithTx(tx).AddFollower(context.Background(), db.AddFollowerParams{
|
||||
Iri: actor,
|
||||
Inbox: inbox,
|
||||
Name: sql.NullString{String: name, Valid: true},
|
||||
Username: username,
|
||||
Image: sql.NullString{String: image, Valid: true},
|
||||
ApprovedAt: approvedAt,
|
||||
Request: request,
|
||||
RequestObject: requestObject,
|
||||
}); err != nil {
|
||||
log.Errorln("error creating new federation follow: ", err)
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// UpdateFollower will update the details of a stored follower given an IRI.
|
||||
func UpdateFollower(actorIRI string, inbox string, name string, username string, image string) error {
|
||||
_datastore.DbLock.Lock()
|
||||
defer _datastore.DbLock.Unlock()
|
||||
|
||||
tx, err := _datastore.DB.Begin()
|
||||
if err != nil {
|
||||
log.Debugln(err)
|
||||
}
|
||||
defer func() {
|
||||
_ = tx.Rollback()
|
||||
}()
|
||||
|
||||
if err = _datastore.GetQueries().WithTx(tx).UpdateFollowerByIRI(context.Background(), db.UpdateFollowerByIRIParams{
|
||||
Inbox: inbox,
|
||||
Name: sql.NullString{String: name, Valid: true},
|
||||
Username: username,
|
||||
Image: sql.NullString{String: image, Valid: true},
|
||||
Iri: actorIRI,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("error updating follower %s %s", actorIRI, err)
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func removeFollow(actor *url.URL) error {
|
||||
_datastore.DbLock.Lock()
|
||||
defer _datastore.DbLock.Unlock()
|
||||
|
||||
tx, err := _datastore.DB.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
_ = tx.Rollback()
|
||||
}()
|
||||
|
||||
if err := _datastore.GetQueries().WithTx(tx).RemoveFollowerByIRI(context.Background(), actor.String()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// createFederatedActivitiesTable will create the accepted
|
||||
// activities table if needed.
|
||||
func createFederatedActivitiesTable() {
|
||||
createTableSQL := `CREATE TABLE IF NOT EXISTS ap_accepted_activities (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"iri" TEXT NOT NULL,
|
||||
"actor" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"timestamp" TIMESTAMP NOT NULL
|
||||
);`
|
||||
|
||||
_datastore.MustExec(createTableSQL)
|
||||
_datastore.MustExec(`CREATE INDEX IF NOT EXISTS idx_iri_actor_index ON ap_accepted_activities (iri,actor);`)
|
||||
}
|
||||
|
||||
func createFederationOutboxTable() {
|
||||
log.Traceln("Creating federation outbox table...")
|
||||
createTableSQL := `CREATE TABLE IF NOT EXISTS ap_outbox (
|
||||
"iri" TEXT NOT NULL,
|
||||
"value" BLOB,
|
||||
"type" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
"live_notification" BOOLEAN DEFAULT FALSE,
|
||||
PRIMARY KEY (iri));`
|
||||
|
||||
_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);`)
|
||||
}
|
||||
|
||||
// GetOutboxPostCount will return the number of posts in the outbox.
|
||||
func GetOutboxPostCount() (int64, error) {
|
||||
ctx := context.Background()
|
||||
return _datastore.GetQueries().GetLocalPostCount(ctx)
|
||||
}
|
||||
|
||||
// GetOutbox will return an instance of the outbox populated by stored items.
|
||||
func GetOutbox(limit int, offset int) (vocab.ActivityStreamsOrderedCollection, error) {
|
||||
collection := streams.NewActivityStreamsOrderedCollection()
|
||||
orderedItems := streams.NewActivityStreamsOrderedItemsProperty()
|
||||
rows, err := _datastore.GetQueries().GetOutboxWithOffset(
|
||||
context.Background(),
|
||||
db.GetOutboxWithOffsetParams{Limit: limit, Offset: offset},
|
||||
)
|
||||
if err != nil {
|
||||
return collection, err
|
||||
}
|
||||
|
||||
for _, value := range rows {
|
||||
createCallback := func(c context.Context, activity vocab.ActivityStreamsCreate) error {
|
||||
orderedItems.AppendActivityStreamsCreate(activity)
|
||||
return nil
|
||||
}
|
||||
if err := resolvers.Resolve(context.Background(), value, createCallback); err != nil {
|
||||
return collection, err
|
||||
}
|
||||
}
|
||||
|
||||
return collection, nil
|
||||
}
|
||||
|
||||
// AddToOutbox will store a single payload to the persistence layer.
|
||||
func AddToOutbox(iri string, itemData []byte, typeString string, isLiveNotification bool) error {
|
||||
tx, err := _datastore.DB.Begin()
|
||||
if err != nil {
|
||||
log.Debugln(err)
|
||||
}
|
||||
defer func() {
|
||||
_ = tx.Rollback()
|
||||
}()
|
||||
|
||||
if err = _datastore.GetQueries().WithTx(tx).AddToOutbox(context.Background(), db.AddToOutboxParams{
|
||||
Iri: iri,
|
||||
Value: itemData,
|
||||
Type: typeString,
|
||||
LiveNotification: sql.NullBool{Bool: isLiveNotification, Valid: true},
|
||||
}); err != nil {
|
||||
return fmt.Errorf("error creating new item in federation outbox %s", err)
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// 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)
|
||||
return string(row.Value), row.LiveNotification.Bool, row.CreatedAt.Time, err
|
||||
}
|
||||
|
||||
// GetLocalPostCount will return the number of posts existing locally.
|
||||
func GetLocalPostCount() (int64, error) {
|
||||
ctx := context.Background()
|
||||
return _datastore.GetQueries().GetLocalPostCount(ctx)
|
||||
}
|
||||
|
||||
// SaveInboundFediverseActivity will save an event to the ap_inbound_activities table.
|
||||
func SaveInboundFediverseActivity(objectIRI string, actorIRI string, eventType string, timestamp time.Time) error {
|
||||
if err := _datastore.GetQueries().AddToAcceptedActivities(context.Background(), db.AddToAcceptedActivitiesParams{
|
||||
Iri: objectIRI,
|
||||
Actor: actorIRI,
|
||||
Type: eventType,
|
||||
Timestamp: timestamp,
|
||||
}); err != nil {
|
||||
return errors.Wrap(err, "error saving event "+objectIRI)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetInboundActivities will return a collection of saved, federated activities
|
||||
// limited and offset by the values provided to support pagination.
|
||||
func GetInboundActivities(limit int, offset int) ([]models.FederatedActivity, int, error) {
|
||||
ctx := context.Background()
|
||||
rows, err := _datastore.GetQueries().GetInboundActivitiesWithOffset(ctx, db.GetInboundActivitiesWithOffsetParams{
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
activities := make([]models.FederatedActivity, 0)
|
||||
|
||||
total, err := _datastore.GetQueries().GetInboundActivityCount(context.Background())
|
||||
if err != nil {
|
||||
return nil, 0, errors.Wrap(err, "unable to fetch total activity count")
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
singleActivity := models.FederatedActivity{
|
||||
IRI: row.Iri,
|
||||
ActorIRI: row.Actor,
|
||||
Type: row.Type,
|
||||
Timestamp: row.Timestamp,
|
||||
}
|
||||
activities = append(activities, singleActivity)
|
||||
}
|
||||
|
||||
return activities, int(total), nil
|
||||
}
|
||||
|
||||
// HasPreviouslyHandledInboundActivity will return if we have previously handled
|
||||
// an inbound federated activity.
|
||||
func HasPreviouslyHandledInboundActivity(iri string, actorIRI string, eventType string) (bool, error) {
|
||||
exists, err := _datastore.GetQueries().DoesInboundActivityExist(context.Background(), db.DoesInboundActivityExistParams{
|
||||
Iri: iri,
|
||||
Actor: actorIRI,
|
||||
Type: eventType,
|
||||
})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return exists > 0, nil
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
package requests
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
|
||||
"github.com/go-fed/activity/streams"
|
||||
"github.com/go-fed/activity/streams/vocab"
|
||||
"github.com/owncast/owncast/activitypub/apmodels"
|
||||
"github.com/owncast/owncast/activitypub/crypto"
|
||||
"github.com/owncast/owncast/activitypub/workerpool"
|
||||
|
||||
"github.com/teris-io/shortid"
|
||||
)
|
||||
|
||||
// SendFollowAccept will send an accept activity to a follow request from a specified local user.
|
||||
func SendFollowAccept(inbox *url.URL, originalFollowActivity vocab.ActivityStreamsFollow, fromLocalAccountName string) error {
|
||||
followAccept := makeAcceptFollow(originalFollowActivity, fromLocalAccountName)
|
||||
localAccountIRI := apmodels.MakeLocalIRIForAccount(fromLocalAccountName)
|
||||
|
||||
var jsonmap map[string]interface{}
|
||||
jsonmap, _ = streams.Serialize(followAccept)
|
||||
b, _ := json.Marshal(jsonmap)
|
||||
req, err := crypto.CreateSignedRequest(b, inbox, localAccountIRI)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
workerpool.AddToOutboundQueue(req)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func makeAcceptFollow(originalFollowActivity vocab.ActivityStreamsFollow, fromAccountName string) vocab.ActivityStreamsAccept {
|
||||
acceptIDString := shortid.MustGenerate()
|
||||
acceptID := apmodels.MakeLocalIRIForResource(acceptIDString)
|
||||
actorID := apmodels.MakeLocalIRIForAccount(fromAccountName)
|
||||
|
||||
accept := streams.NewActivityStreamsAccept()
|
||||
idProperty := streams.NewJSONLDIdProperty()
|
||||
idProperty.SetIRI(acceptID)
|
||||
accept.SetJSONLDId(idProperty)
|
||||
|
||||
actor := apmodels.MakeActorPropertyWithID(actorID)
|
||||
accept.SetActivityStreamsActor(actor)
|
||||
|
||||
object := streams.NewActivityStreamsObjectProperty()
|
||||
object.AppendActivityStreamsFollow(originalFollowActivity)
|
||||
accept.SetActivityStreamsObject(object)
|
||||
|
||||
return accept
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// WriteStreamResponse will write a ActivityPub object to the provided ResponseWriter and sign with the provided key.
|
||||
func WriteStreamResponse(item vocab.Type, w http.ResponseWriter, publicKey crypto.PublicKey) error {
|
||||
var jsonmap map[string]interface{}
|
||||
jsonmap, _ = streams.Serialize(item)
|
||||
b, err := json.Marshal(jsonmap)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return WriteResponse(b, w, publicKey)
|
||||
}
|
||||
|
||||
// WritePayloadResponse will write any arbitrary object to the provided ResponseWriter and sign with the provided key.
|
||||
func WritePayloadResponse(payload interface{}, w http.ResponseWriter, publicKey crypto.PublicKey) error {
|
||||
b, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return WriteResponse(b, w, publicKey)
|
||||
}
|
||||
|
||||
// WriteResponse will write any arbitrary payload to the provided ResponseWriter and sign with the provided key.
|
||||
func WriteResponse(payload []byte, w http.ResponseWriter, publicKey crypto.PublicKey) error {
|
||||
w.Header().Set("Content-Type", "application/activity+json")
|
||||
|
||||
if err := crypto.SignResponse(w, payload, publicKey); err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
log.Errorln("unable to sign response", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := w.Write(payload); err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return err
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
package resolvers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/go-fed/activity/streams/vocab"
|
||||
"github.com/owncast/owncast/activitypub/apmodels"
|
||||
"github.com/pkg/errors"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func getPersonFromFollow(activity vocab.ActivityStreamsFollow) (apmodels.ActivityPubActor, error) {
|
||||
return GetResolvedActorFromActorProperty(activity.GetActivityStreamsActor())
|
||||
}
|
||||
|
||||
// MakeFollowRequest will convert an inbound Follow request to our internal actor model.
|
||||
func MakeFollowRequest(c context.Context, activity vocab.ActivityStreamsFollow) (*apmodels.ActivityPubActor, error) {
|
||||
person, err := getPersonFromFollow(activity)
|
||||
if err != nil {
|
||||
return nil, errors.New("unable to resolve person from follow request: " + err.Error())
|
||||
}
|
||||
|
||||
hostname := person.ActorIri.Hostname()
|
||||
username := person.Username
|
||||
fullUsername := fmt.Sprintf("%s@%s", username, hostname)
|
||||
|
||||
followRequest := apmodels.ActivityPubActor{
|
||||
ActorIri: person.ActorIri,
|
||||
FollowRequestIri: activity.GetJSONLDId().Get(),
|
||||
Inbox: person.Inbox,
|
||||
Name: person.Name,
|
||||
Username: fullUsername,
|
||||
Image: person.Image,
|
||||
RequestObject: activity,
|
||||
}
|
||||
|
||||
return &followRequest, nil
|
||||
}
|
||||
|
||||
// MakeUnFollowRequest will convert an inbound Unfollow request to our internal actor model.
|
||||
func MakeUnFollowRequest(c context.Context, activity vocab.ActivityStreamsUndo) *apmodels.ActivityPubActor {
|
||||
person, err := GetResolvedActorFromActorProperty(activity.GetActivityStreamsActor())
|
||||
if err != nil {
|
||||
log.Errorln("unable to resolve person from actor iri", person.ActorIri, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
unfollowRequest := apmodels.ActivityPubActor{
|
||||
ActorIri: person.ActorIri,
|
||||
FollowRequestIri: activity.GetJSONLDId().Get(),
|
||||
Inbox: person.Inbox,
|
||||
Name: person.Name,
|
||||
Image: person.Image,
|
||||
}
|
||||
|
||||
return &unfollowRequest
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
package resolvers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-fed/activity/streams"
|
||||
"github.com/go-fed/activity/streams/vocab"
|
||||
"github.com/owncast/owncast/activitypub/apmodels"
|
||||
"github.com/owncast/owncast/activitypub/crypto"
|
||||
"github.com/owncast/owncast/persistence/configrepository"
|
||||
"github.com/pkg/errors"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// Resolve will translate a raw ActivityPub payload and fire the callback associated with that activity type.
|
||||
func Resolve(c context.Context, data []byte, callbacks ...interface{}) error {
|
||||
jsonResolver, err := streams.NewJSONResolver(callbacks...)
|
||||
if err != nil {
|
||||
// Something in the setup was wrong. For example, a callback has an
|
||||
// unsupported signature and would never be called
|
||||
return err
|
||||
}
|
||||
|
||||
var jsonMap map[string]interface{}
|
||||
if err = json.Unmarshal(data, &jsonMap); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Debugln("Resolving payload...", string(data))
|
||||
|
||||
// The createCallback function will be called.
|
||||
err = jsonResolver.Resolve(c, jsonMap)
|
||||
if err != nil && !streams.IsUnmatchedErr(err) {
|
||||
// Something went wrong
|
||||
return err
|
||||
} else if streams.IsUnmatchedErr(err) {
|
||||
// Everything went right but the callback didn't match or the ActivityStreams
|
||||
// type is one that wasn't code generated.
|
||||
log.Debugln("No match: ", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResolveIRI will resolve an IRI ahd call the correct callback for the resolved type.
|
||||
func ResolveIRI(c context.Context, iri string, callbacks ...interface{}) error {
|
||||
configRepository := configrepository.Get()
|
||||
log.Debugln("Resolving", iri)
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, iri, nil)
|
||||
|
||||
actor := apmodels.MakeLocalIRIForAccount(configRepository.GetDefaultFederationUsername())
|
||||
if err := crypto.SignRequest(req, nil, actor); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
response, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer response.Body.Close()
|
||||
|
||||
data, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// fmt.Println(string(data))
|
||||
return Resolve(c, data, callbacks...)
|
||||
}
|
||||
|
||||
// GetResolvedActorFromActorProperty resolve an external actor property to a
|
||||
// fully populated internal actor representation.
|
||||
func GetResolvedActorFromActorProperty(actor vocab.ActivityStreamsActorProperty) (apmodels.ActivityPubActor, error) {
|
||||
var err error
|
||||
var apActor apmodels.ActivityPubActor
|
||||
resolved := false
|
||||
|
||||
if !actor.Empty() && actor.Len() > 0 && actor.At(0) != nil {
|
||||
// Explicitly use only the first actor that might be listed.
|
||||
actorObjectOrIRI := actor.At(0)
|
||||
var actorEntity apmodels.ExternalEntity
|
||||
|
||||
// If the actor is an unresolved IRI then we need to resolve it.
|
||||
if actorObjectOrIRI.IsIRI() {
|
||||
iri := actorObjectOrIRI.GetIRI().String()
|
||||
return GetResolvedActorFromIRI(iri)
|
||||
}
|
||||
|
||||
if actorObjectOrIRI.IsActivityStreamsPerson() {
|
||||
actorEntity = actorObjectOrIRI.GetActivityStreamsPerson()
|
||||
} else if actorObjectOrIRI.IsActivityStreamsService() {
|
||||
actorEntity = actorObjectOrIRI.GetActivityStreamsService()
|
||||
} else if actorObjectOrIRI.IsActivityStreamsApplication() {
|
||||
actorEntity = actorObjectOrIRI.GetActivityStreamsApplication()
|
||||
} else {
|
||||
err = errors.New("unrecognized external ActivityPub type: " + actorObjectOrIRI.Name())
|
||||
return apActor, err
|
||||
}
|
||||
|
||||
// If any of the resolution or population failed then return the error.
|
||||
if err != nil {
|
||||
return apActor, err
|
||||
}
|
||||
|
||||
// Convert the external AP entity into an internal actor representation.
|
||||
apa, e := apmodels.MakeActorFromExernalAPEntity(actorEntity)
|
||||
if apa != nil {
|
||||
apActor = *apa
|
||||
resolved = true
|
||||
}
|
||||
err = e
|
||||
}
|
||||
|
||||
if !resolved && err == nil {
|
||||
err = errors.New("unknown error resolving actor from property value")
|
||||
}
|
||||
|
||||
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
|
||||
var apActor apmodels.ActivityPubActor
|
||||
resolved := false
|
||||
personCallback := func(c context.Context, person vocab.ActivityStreamsPerson) error {
|
||||
apa, e := apmodels.MakeActorFromExernalAPEntity(person)
|
||||
if apa != nil {
|
||||
apActor = *apa
|
||||
resolved = true
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
serviceCallback := func(c context.Context, service vocab.ActivityStreamsService) error {
|
||||
apa, e := apmodels.MakeActorFromExernalAPEntity(service)
|
||||
if apa != nil {
|
||||
apActor = *apa
|
||||
resolved = true
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
applicationCallback := func(c context.Context, app vocab.ActivityStreamsApplication) error {
|
||||
apa, e := apmodels.MakeActorFromExernalAPEntity(app)
|
||||
if apa != nil {
|
||||
apActor = *apa
|
||||
resolved = true
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
if e := ResolveIRI(context.Background(), personOrServiceIRI, personCallback, serviceCallback, applicationCallback); e != nil {
|
||||
err = e
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
err = errors.Wrap(err, "error resolving actor from property value")
|
||||
}
|
||||
|
||||
if !resolved {
|
||||
err = errors.New("error resolving actor from property value")
|
||||
}
|
||||
|
||||
return apActor, err
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
package workerpool
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// Job struct bundling the ActivityPub and the payload in one struct.
|
||||
type Job struct {
|
||||
request *http.Request
|
||||
}
|
||||
|
||||
var queue chan Job
|
||||
|
||||
// InitOutboundWorkerPool starts n go routines that await ActivityPub jobs.
|
||||
func InitOutboundWorkerPool(workerPoolSize int) {
|
||||
queue = make(chan Job, workerPoolSize)
|
||||
|
||||
// start workers
|
||||
for i := 1; i <= workerPoolSize; i++ {
|
||||
go worker(i, queue)
|
||||
}
|
||||
}
|
||||
|
||||
// AddToOutboundQueue will queue up an outbound http request.
|
||||
func AddToOutboundQueue(req *http.Request) {
|
||||
select {
|
||||
case queue <- Job{req}:
|
||||
default:
|
||||
log.Debugln("Outbound ActivityPub job queue is full")
|
||||
queue <- Job{req} // will block until received by a worker at this point
|
||||
}
|
||||
log.Tracef("Queued request for ActivityPub destination %s", req.RequestURI)
|
||||
}
|
||||
|
||||
func worker(workerID int, queue <-chan Job) {
|
||||
log.Debugf("Started ActivityPub worker %d", workerID)
|
||||
|
||||
for job := range queue {
|
||||
if err := sendActivityPubMessageToInbox(job); err != nil {
|
||||
log.Errorf("ActivityPub destination %s failed to send Error: %s", job.request.RequestURI, err)
|
||||
}
|
||||
log.Tracef("Done with ActivityPub destination %s using worker %d", job.request.RequestURI, workerID)
|
||||
}
|
||||
}
|
||||
|
||||
func sendActivityPubMessageToInbox(job Job) error {
|
||||
client := &http.Client{}
|
||||
|
||||
resp, err := client.Do(job.request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
return nil
|
||||
}
|
||||
11
auth/auth.go
11
auth/auth.go
@@ -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"
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.")
|
||||
}
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
package indieauth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/owncast/owncast/persistence/configrepository"
|
||||
"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) {
|
||||
configRepository := configrepository.Get()
|
||||
|
||||
// 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 := configRepository.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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
package indieauth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/owncast/owncast/persistence/configrepository"
|
||||
"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) {
|
||||
configRepository := configrepository.Get()
|
||||
|
||||
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: configRepository.GetServerURL(),
|
||||
Profile: ServerProfile{
|
||||
Name: configRepository.GetServerName(),
|
||||
URL: configRepository.GetServerURL(),
|
||||
Photo: fmt.Sprintf("%s/%s", configRepository.GetServerURL(), configRepository.GetLogoPath()),
|
||||
},
|
||||
}
|
||||
|
||||
return &response, nil
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"github.com/owncast/owncast/core/data"
|
||||
)
|
||||
|
||||
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);`)
|
||||
}
|
||||
40
build/admin/bundleAdmin.sh
Executable file
40
build/admin/bundleAdmin.sh
Executable file
@@ -0,0 +1,40 @@
|
||||
#!/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 "$PROJECT_SOURCE_DIR/admin"
|
||||
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
|
||||
cp -R ${ADMIN_BUILD_DIR}/out/* .
|
||||
|
||||
echo "Bundling admin into owncast codebase..."
|
||||
~/go/bin/pkger
|
||||
|
||||
shutdown
|
||||
echo "Done."
|
||||
@@ -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}"
|
||||
@@ -1,38 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# go install github.com/deepmap/oapi-codegen/v2/cmd/oapi-codegen@latest
|
||||
|
||||
# setup
|
||||
package="generated"
|
||||
folderPath="webserver/handlers/generated"
|
||||
specPath="openapi.yaml"
|
||||
|
||||
# validate scripts are installed
|
||||
if ! command -v redocly &>/dev/null; then
|
||||
echo "Please install \`redocly cli\` before running this script: npm install -g @redocly/cli"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v oapi-codegen &>/dev/null; then
|
||||
echo "Please install \`oapi-codegen\` before running this script"
|
||||
echo "Hint: run \`go install github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@latest\` to install"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# validate schema
|
||||
npx redocly lint $specPath
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Open API specification is not valid"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# cleanup
|
||||
rm -r $folderPath
|
||||
mkdir -p $folderPath
|
||||
|
||||
# codegen
|
||||
oapi-codegen -generate types -o $folderPath/$package-types.gen.go -package $package $specPath
|
||||
oapi-codegen -generate "chi-server" -o $folderPath/$package.gen.go -package $package $specPath
|
||||
|
||||
# go
|
||||
go mod tidy
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user