Compare commits

..
Author SHA1 Message Date
Owncast 1d6151c867 Commit updated API documentation 2022-11-22 01:44:19 +00:00
Gabe Kangas 3a7f69531c Bump version for 0.0.13 release 2022-11-21 12:34:56 -08:00
Albin LarssonandGabe Kangas a74ea4ef40 improve chat input accessibility (#2353)
Sets aria-role="textbox" and aria-placeholder
2022-11-21 12:12:02 -08:00
Gabe Kangas c2a7d9f50b Explicitly set AP post as public. Closes #2112 2022-11-21 12:11:54 -08:00
John ReganandGabe Kangas e64dd2d206 activitypub: ensure Undo request has valid type (#2317) 2022-11-21 12:11:41 -08:00
M. Ajmal MoochingalandGabe Kangas f39c1184e5 Using prepared statements for SQL queries. (#2257)
* using prepared statements for sql query for fixing sql injection

* returning error in getChat instead of logging
2022-11-21 12:11:28 -08:00
Florian LehnerandGabe Kangas a9cd28dbec preallocate memory (#2201)
**What this PR does / why we need it:**

Preallocate memory instead of enforcing an incremental growth. This will result in less work for the garbage collector.
2022-11-21 12:11:04 -08:00
Matt OwensandGabe Kangas 4b5a1fcc3f remove extra w.WriteHeader call (#2158) 2022-11-21 12:10:36 -08:00
Matt OwensandGabe Kangas 96274ad541 Treat fediverse usernames as case-insensitive (#2155)
* treat fediverse usernames as case-insensitive for auth

* add test for case insensitive, clean up duplicate import in federverse auth controller

* fix test, there was an issue with state when all the tests were run
2022-11-21 12:10:17 -08:00
Matt OwensandGabe Kangas 717bbcf2e7 Sanitize user submitted values before logging (#2134)
* strip line breaks from user-submitted values before logging

* finish comment
2022-11-21 12:09:47 -08:00
celandGabe Kangas 29972bb4e7 Add Fediverse, Matrix and XMPP social links (#2044) 2022-11-21 12:09:20 -08:00
Gabe Kangas 625b0b0099 Limit chat display names to 30 characters. Closes #1919 2022-11-21 12:09:03 -08:00
Gabe Kangas 27afb93e13 Function has been re-exported for use 2022-11-21 12:08:36 -08:00
Gabe Kangas eb1121e9fa Fix creating table indexes 2022-11-21 12:08:28 -08:00
Gabe Kangas e65abb6073 Add util for ungraceful sql execs 2022-11-21 12:08:21 -08:00
Gabe Kangas 29ec8a9091 Messages table fixes to improve query performance (#2026)
* Move to yaml sqlc config

* Add util for ungraceful sql execs

* Fix messages schema + add indexes

* Add migration to drop+recreate messages table

* Create index only if does not exist

* Fix typo

* Unexport function
2022-11-21 12:07:54 -08:00
Gabe Kangas ba7861cda9 Limit OTP requests to one per expiry window. Closes #2000 2022-11-21 12:07:43 -08:00
Gabe Kangas 1cbe2a56fb Do not log inactionable error. Closes #1992 2022-11-21 12:07:32 -08:00
1998 changed files with 52799 additions and 167919 deletions
-70
View File
@@ -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
View File
@@ -1 +0,0 @@
test/automated/api/node_modules
-10
View File
@@ -14,13 +14,3 @@ quote_type = single
curly_bracket_next_line = true curly_bracket_next_line = true
spaces_around_operators = true spaces_around_operators = true
spaces_around_brackets = true spaces_around_brackets = true
[*.{yml,yaml}]
indent_style = space
indent_size = 2
[*.{md,mdx}]
trim_trailing_whitespace = false
[*.go]
indent_style = tab
+54 -62
View File
@@ -1,65 +1,57 @@
# Recreate this file via webroot/js/web_modules/* linguist-vendored
# find static -type d -print0 | xargs -0 -I {} echo "{}/* linguist-vendored" | xclip -selection clipboard webroot/js/web_modules/@joeattardi/* linguist-vendored
webroot/js/web_modules/@justinribeiro/* linguist-vendored
webroot/js/web_modules/@videojs/http-streaming/dist/* linguist-vendored
webroot/js/web_modules/@videojs/themes/fantasy/* linguist-vendored
webroot/js/web_modules/common/* linguist-vendored
webroot/js/web_modules/markjs/dist/* linguist-vendored
webroot/js/web_modules/tailwindcss/dist/* linguist-vendored
webroot/js/web_modules/videojs/* linguist-vendored
webroot/js/web_modules/micromodal/dist/* linguist-vendored
static/* linguist-vendored static/* linguist-vendored
static/admin/* linguist-vendored
docs/api/* linguist-documentation docs/api/* linguist-documentation
static/* linguist-vendored static/* linguist-vendored
static/web/* linguist-vendored static/admin/* linguist-vendored
static/web/admin/* linguist-vendored static/admin/logs/* linguist-vendored
static/web/admin/federation/* linguist-vendored static/admin/config-social-items/* linguist-vendored
static/web/admin/federation/actions/* linguist-vendored static/admin/offline-notice/* linguist-vendored
static/web/admin/federation/followers/* linguist-vendored static/admin/config-chat/* linguist-vendored
static/web/admin/logs/* linguist-vendored static/admin/404/* linguist-vendored
static/web/admin/config-social-items/* linguist-vendored static/admin/_next/* linguist-vendored
static/web/admin/config/* linguist-vendored static/admin/_next/static/* linguist-vendored
static/web/admin/config/general/* linguist-vendored static/admin/_next/static/chunks/* linguist-vendored
static/web/admin/config/server/* linguist-vendored static/admin/_next/static/chunks/pages/* linguist-vendored
static/web/admin/config-chat/* linguist-vendored static/admin/_next/static/chunks/pages/chat/* linguist-vendored
static/web/admin/config-federation/* linguist-vendored static/admin/_next/static/b1nOF3ZgELnezD8dvvt2B/* linguist-vendored
static/web/admin/viewer-info/* linguist-vendored static/admin/_next/static/css/* linguist-vendored
static/web/admin/access-tokens/* linguist-vendored static/admin/_next/quK9VwW_avTP773Ot9m2x/* linguist-vendored
static/web/admin/actions/* linguist-vendored static/admin/viewer-info/* linguist-vendored
static/web/admin/help/* linguist-vendored static/admin/access-tokens/* linguist-vendored
static/web/admin/webhooks/* linguist-vendored static/admin/config-storage/* linguist-vendored
static/web/admin/chat/* linguist-vendored static/admin/config-public-details/* linguist-vendored
static/web/admin/chat/messages/* linguist-vendored static/admin/config-server-details/* linguist-vendored
static/web/admin/chat/users/* linguist-vendored static/admin/actions/* linguist-vendored
static/web/admin/chat/emojis/* linguist-vendored static/admin/help/* linguist-vendored
static/web/admin/upgrade/* linguist-vendored static/admin/webhooks/* linguist-vendored
static/web/admin/config-notify/* linguist-vendored static/admin/chat/* linguist-vendored
static/web/admin/hardware-info/* linguist-vendored static/admin/chat/messages/* linguist-vendored
static/web/admin/config-video/* linguist-vendored static/admin/chat/users/* linguist-vendored
static/web/admin/stream-health/* linguist-vendored static/admin/upgrade/* linguist-vendored
static/web/404/* linguist-vendored static/admin/hardware-info/* linguist-vendored
static/web/_next/* linguist-vendored static/admin/config-video/* linguist-vendored
static/web/_next/static/* linguist-vendored webroot/js/web_modules/* linguist-vendored
static/web/_next/static/l-3emuM7cUz2zU2fzzpRq/* linguist-vendored webroot/js/web_modules/micromodal/* linguist-vendored
static/web/_next/static/media/* linguist-vendored webroot/js/web_modules/micromodal/dist/* linguist-vendored
static/web/_next/static/chunks/* linguist-vendored webroot/js/web_modules/common/* linguist-vendored
static/web/_next/static/chunks/pages/* linguist-vendored webroot/js/web_modules/@videojs/* linguist-vendored
static/web/_next/static/chunks/pages/admin/* linguist-vendored webroot/js/web_modules/@videojs/http-streaming/* linguist-vendored
static/web/_next/static/chunks/pages/admin/federation/* linguist-vendored webroot/js/web_modules/@videojs/http-streaming/dist/* linguist-vendored
static/web/_next/static/chunks/pages/admin/config/* linguist-vendored webroot/js/web_modules/@videojs/themes/* linguist-vendored
static/web/_next/static/chunks/pages/admin/chat/* linguist-vendored webroot/js/web_modules/@videojs/themes/fantasy/* linguist-vendored
static/web/_next/static/chunks/pages/embed/* linguist-vendored webroot/js/web_modules/markjs/* linguist-vendored
static/web/_next/static/chunks/pages/embed/chat/* linguist-vendored webroot/js/web_modules/markjs/dist/* linguist-vendored
static/web/_next/static/css/* linguist-vendored webroot/js/web_modules/@joeattardi/* linguist-vendored
static/web/_next/static/OQyHVua-s5F40yEopTtjx/* linguist-vendored webroot/js/web_modules/tailwindcss/* linguist-vendored
static/web/_next/OQyHVua-s5F40yEopTtjx/* linguist-vendored webroot/js/web_modules/tailwindcss/dist/* linguist-vendored
static/web/embed/* linguist-vendored webroot/js/web_modules/videojs/* 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
@@ -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,16 +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://docs.owncast.dev/project-definition) to see what our focus is and how your requests might align.
Note: Please file issues in English so your message can be interpreted as you intended.
- type: textarea
id: issue-body
attributes:
label: Share your bug report, feature request, or comment.
description: Please include as much detail as possible.
-55
View File
@@ -1,55 +0,0 @@
<!-- REQUIRED-CHECKLIST:START - Do not remove this section -->
## Required checklist
**Do not remove this section.** These checkboxes are required and validated.
- [ ] I have personally tested these changes and verified they work as intended.
- [ ] I understand the code I'm submitting and can explain how it works if asked.
- [ ] I included a screenshot, logs, example payload to demonstrate the change, or it really doesn't need it.
- [ ] This is a frontend change and it supports [translations](https://docs.owncast.dev/web-translations), or it's not a frontend change.
- [ ] The code has been run through the proper linters and/or formatters for the language.
- [ ] There is an issue discussing this change and it's assigned to me.
<!-- REQUIRED-CHECKLIST:END -->
---
# Read first
## We manage everything through issues, not PRs.
A PR is a **solution**. A solution without a **problem** breaks release notes. **Please open an issue** so the list of problems that were fixed can be correctly listed and be attributed to you.
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.
Additionally, when attributing credit in releases, we use issues to determine who worked on each version. If there isn't an issue assigned to you, you may not get credit in release notes.
## Resources
- [Developing Owncast](https://docs.owncast.dev/development)
- [How We Develop Frontend React Components](https://docs.owncast.dev/develop-frontend-components)
- [Supporting Translations](https://docs.owncast.dev/web-translations)
## Description
Once there is an issue filed, a PR can be linked to it.
Please include a summary of the change in the PR and which issue number is fixed, including relevant motivation and context. 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 or you haven't yet provided all the information.
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?
5. If this is a frontend change, does the text support [translation](https://owncast.notion.site/web-translations)?
Thank you so much for contributing to Owncast! 🎉
+17
View File
@@ -0,0 +1,17 @@
Please include a summary of the change and which issue number is fixed, including relevant motivation and context. Feel free to mark this as a Draft or WIP and write up some details later.
# Description
Fixes # (issue)
---
Some things you might want to mention:
1. Why are you making the change?
2. Explain how it works and decisions you made.
3. If you're fixing something, what was wrong? How should we stop from having this issue happen again?
4. If this is a new feature or addition to functionality, why should it be added? What are the use cases? Who was asking for this functionality?
If this is an unsolicited change or have no issue associated please do your best to detail the motivations behind this PR.
-1
View File
@@ -1 +0,0 @@
name: Go config
-4
View File
@@ -1,4 +0,0 @@
name: Javascript config
paths-ignore:
- static/web
-73
View File
@@ -1,73 +0,0 @@
This is a repository consisting of a Go backend and a React frontend. It supports both an internal web application that is public facing, an internal admin web application, internal-only APIs for powering these web interfaces, and a set of APIs for third parties to take advantage of.
## Code Standards
- UI component standards can be found in https://docs.owncast.dev/develop-frontend-components
### Required Before Each Commit
- Ensure all code is formatted using `golangci-lint` for Go, `stylelint` for CSS, and `prettier` for JavaScript/React.
- Fix any formatting or linting errors.
### Development Flow
- Build: Ensure the code builds successfully using `go build` for Go and `npm run build` for React in the `web` directory.
- Tests: Write unit tests for Go code and Javascript logic. Use `go test` and `npm run test`.
- UI components: Components should be standalone and reusable and show up in Storybook to enable testing, prototyping, and iteration.
- API: All APIs should be documented using OpenAPI specifications. Use `build/gen-api.sh` to generate the stubs, and then fill them in with your actual API implementation.
- If you made new UI components or made changes ensure that Storybook still builds by running `npm run build-storybook`.
## Repository Structure
- `web`: The web source code for the React frontend.
- Root of the repo: Go source code for the backend.
- `static/web`: Generated static files for the web application. Do not edit or commit files in this directory directly; they are generated from the `web` directory. Ignore this.
- `test/automated/api`: A series of automated integration tests for API endpoints written in Javascript.
- `test/automated/browser`: A series of automated browser UI tests for actual real-world browser interaction.
- `build/web`: Script to build and bundle the web application.
## Testing web frontend
Testing the web frontend requires running the frontend development server and the backend service together. The frontend development server must be started using `npm run dev` in the `web` directory, which will serve the web application at `http://localhost:3000`. The backend service can be started using `go run main.go` in the root of the repository.
Do not access the web application via http://localhost:8080 or build the web project to copy files to the `static/web` directory for local development.
## Code Reviews
- When performing a code review for a UI component take a screenshot and attach it to your comments to make the review more detailed.
- When performing a code review for an API provide the payload for the API and attach it to the code review comment.
- When performing a code review check for all linters and formatters for in code and put in the code review comment a suggestion on how to fix each line.
## Key Guidelines
1. All APIs are to be documented using OpenAPI specifications and code is to be generated using `build/gen-api.sh`. Additional details can be found at https://docs.owncast.dev/api-web-routing.
2. Write API tests for all new endpoints in the `test/automated/api` directory.
3. Use the `test/automated/browser` directory for browser-based tests for new functionality that simulate user interactions.
4. All user-facing frontend UI strings need to support localization. Use the `Translation` component to show most displayable strings. To create dynamic translated strings use the `next-export-i18n` library and the `t()` function. But use the `Translation` component unless there is a reason not to as it allows you to set default text. Read https://docs.owncast.dev/web-translations for more details. Test localization by adding "?lang=XX" with XX being a country code, such as "de" for German. Strings that have not yet been translated will not show as changed, but it's good to test anyway to make sure that previously translated strings have not been broken or regressed in any way. Screenshots with some additional languages can be helpful in showing this.
5. For UI component changes, a before and after screenshot of the component should always be added to the pull request to help with review. Additionally a link to the PR's Storybook on Chromatic via the PR's Chromatic job should be included to help with review.
6. For API changes a before and after example of the API response should be added to the pull request to help with review.
7. For backend changes, a before and after example of logs to demonstrate the change should be added to the pull request to help with review.
8. Do not build the web project or copy the files to the `static/web` directory for local development.
9. When running the frontend development server, the local backend service must also be running. You can run the backend service using `go run main.go` in the root of the repository.
10. The credentials for the backend development backend are username: admin and password: abc123 and uses HTTP Basic Auth. This is used for the admin web application and the admin APIs.
11. The admin is found at `/admin`.
12. If a live stream video is needed to run, you can run `./test/ocTestStream.sh` to start an actual stream that will begin streaming from the local development server.
13. You should never commit the `static/web` directory to the repository. It is generated from the `web` directory and should be ignored in your commits.
14. Don't use emoji in code comments or commit messages. That's lame.
## Screenshots
Take a screenshot after every UI change without being asked. Screenshots should be automatically taken after every code change that affects UI.
After every UI change we should be able to see in the PR comments, visually, exactly what the change is. This helps with code review and ensures that the UI changes are clear and understandable. This should happen every time, automatically, without being asked, and never fail or be skipped.
These screenshots should be displayed inline in the PR comments, and not as attachments. This allows reviewers to see the changes without having to download or open files separately.
- If it is a standalone UI component, take a screenshot of the component in Storybook if it is available.
- When taking a screenshot of a component in Storybook, make sure to hide all the controls that are not relevant to the component itself. This means hiding the knobs, actions, and any other controls that are not part of the component's visual representation and would get in the way of the screenshot.
- If it is something that doesn't exist in Storybook, take a screenshot of the component in the live web application using the local development server.
- Most things in the admin do not exist in Storybook, so screenshots should be taken of the admin web application.
- When taking a screenshot of the web frontend or the admin web application, an instance of the Owncast backend service needs to be running locally by running `go run main.go` in the root of the repository as well.
- When taking screenshots for PR documentation, create temporary files in /tmp directory or use patterns like _screenshot_.js and _screenshot_.png that are excluded by .gitignore.
- Screnshots should be taken using the web dev server at `http://localhost:3000` and not the production build at `http://localhost:8080`.
- Never commit temporary screenshot scripts or image files to the repository - they should only be used locally and uploaded directly to GitHub for PR comments.
- Double check that the screenshots are attached to the PR comments. Copilot often forgets to do this or says it did it but doesn't actually do it. If it doesn't do it, it should continue to try until it succeeds.
+31
View File
@@ -0,0 +1,31 @@
# 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. If this
was a feature request that others have shown no interest in then it's
likely to not get implemented due to lack of interest. If others also
want to see this feature then now is the time to say something!
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. If this PR addresses a previously filed issue that needs to be
resolved please work to get it merged in, or allow somebody else to work on a fix.
This PR will be closed if no further activity occurs. Thank you for your contributions!
exemptLabels:
- bot
-29
View File
@@ -1,29 +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@v6
if: github.event_name == 'pull_request'
with:
repository: ${{ github.event.pull_request.head.repo.full_name }}
ref: ${{ github.event.pull_request.head.ref }}
- name: Check out repository code
uses: actions/checkout@v6
if: github.event_name == 'push'
- uses: docker://rhysd/actionlint:latest
with:
args: -shellcheck= -color
-130
View File
@@ -1,130 +0,0 @@
name: ActivityPub Tests
on:
push:
paths:
- "activitypub/**"
- "core/chat/**"
- "config/**"
- "main.go"
- "webserver/**"
- "test/automated/activitypub/**"
- ".github/workflows/activitypub-tests.yaml"
pull_request:
paths:
- "activitypub/**"
- "core/chat/**"
- "config/**"
- "main.go"
- "webserver/**"
- "test/automated/activitypub/**"
- ".github/workflows/activitypub-tests.yaml"
jobs:
federation-test:
runs-on: ubuntu-latest
steps:
- id: skip_check
uses: fkirc/skip-duplicate-actions@v5
with:
concurrent_skipping: "same_content_newer"
- name: Check out repository code
uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: "^1.23"
cache: true
- name: Install Caddy
run: |
sudo apt-get update
sudo apt-get install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt-get update
sudo apt-get install -y caddy
- name: Install dependencies
run: |
sudo apt-get install -y snac2 ffmpeg
- name: Run setup
run: |
cd test/automated/activitypub
sudo ./setup.sh
- name: Run federation test
run: |
cd test/automated/activitypub
CI=true USER_COUNT=20 ./test-federation.sh
chat-sanitization-test:
runs-on: ubuntu-latest
steps:
- id: skip_check
uses: fkirc/skip-duplicate-actions@v5
with:
concurrent_skipping: "same_content_newer"
- name: Check out repository code
uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: "^1.23"
cache: true
- name: Install Caddy
run: |
sudo apt-get update
sudo apt-get install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt-get update
sudo apt-get install -y caddy
- name: Install dependencies
run: |
sudo apt-get install -y snac2 ffmpeg
- name: Run setup
run: |
cd test/automated/activitypub
sudo ./setup.sh
- name: Run chat sanitization test
run: |
cd test/automated/activitypub
CI=true ./test-chat-sanitization.sh
follower-validation-test:
runs-on: ubuntu-latest
steps:
- id: skip_check
uses: fkirc/skip-duplicate-actions@v5
with:
concurrent_skipping: "same_content_newer"
- name: Check out repository code
uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: "^1.23"
cache: true
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y sqlite3 ffmpeg jq
- name: Run follower validation test
timeout-minutes: 15
run: |
cd test/automated/activitypub
./test-follower-validation.sh
@@ -1,50 +0,0 @@
name: Extract Default Translations
on:
push:
paths:
- 'web/**/*.ts'
- 'web/**/*.tsx'
- 'web/**/*.js'
- 'web/**/*.jsx'
jobs:
extract-and-commit:
runs-on: ubuntu-latest
defaults:
run:
working-directory: web
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
persist-credentials: true
fetch-depth: 0 # Required to push back to same branch
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '24'
cache: 'npm'
cache-dependency-path: web/package-lock.json
- name: Install dependencies
run: npm ci
- name: Run translation extraction
run: npm run translate
- name: Commit and push changes
run: |
git config --global user.name "Owncast default web localizations"
git config --global user.email "owncast@owncast.online"
if [ -n "$(git status --porcelain i18n/)" ]; then
git add i18n/
git commit -m "chore: update extracted translations"
git push origin HEAD
else
echo "No translation changes to commit."
fi
@@ -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@57232238742e38b2ccc27136ce596ccae7ca28b4
with:
issue-number: ${{ github.event.issue.number }}
body: |
## Good First Issue
This item was marked as a good first issue because of the following:
- It's self contained as a single feature or change.
- Is clear when it's complete.
- You do not need deep knowledge of Owncast to accomplish it.
### Next Steps
1. Comment on this issue before starting work so it can be assigned to you. Also, this issue may have been filed with limited detail or changes may have occurred that are worth sharing with you before you start work.
2. Drop by our [community chat](https://owncast.rocket.chat/) if you'd like to be involved in more real-time discussion around Owncast to talk about this change.
3. Follow the project's getting started tips to make sure you can [build and run the project from source](https://owncast.online/development).
### Notes
- Development takes place on the `develop` branch.
- We use Storybook for testing and developing React components. `npm run storybook`. A hosted version [is available for viewing](https://owncast.online/components).
- If you need to install the Go programming language to run the Owncast backend it's simple from [here](https://go.dev/dl/).
- Active contributors get an Owncast t-shirt! Ask about it if you feel like you've been contributing and haven't yet been given one.
### New to Git?
If you're brand new to Git you may want a short primer about the Fork -> Commit -> Pull Request workflow that enables changes to get made collaboratively using git. Visit the [First Contributions](https://github.com/firstcontributions/first-contributions) project to learn step-by-step how to commit a change to a Git repository such as this one.
+19
View File
@@ -0,0 +1,19 @@
name: Automated browser tests
on: [push, pull_request]
jobs:
browser:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v3
with:
stable: 'false'
go-version: '1.17.2'
- name: Run browser tests
run: cd test/automated/browser && ./run.sh
- uses: actions/upload-artifact@v3
with:
name: screenshots-${{ github.run_id }}
path: test/automated/browser/screenshots/*.png
+10 -67
View File
@@ -1,80 +1,23 @@
# This workflow runs automated API integration tests using Earthly
# Triggers:
# - Push/PR with Go file changes
# Skips:
# - Web-only changes
# Uses:
# - Earthly for reproducible builds
# - QEMU for cross-platform testing
# - Retries up to 3 times for flaky tests
name: Automated API tests name: Automated API tests
on: on:
push: push:
paths-ignore: paths-ignore:
- 'web/**' - 'webroot/**'
pull_request: pull_request:
paths-ignore: paths-ignore:
- 'web/**' - 'webroot/**'
jobs: jobs:
test: api:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- id: skip_check - uses: actions/checkout@v3
uses: fkirc/skip-duplicate-actions@v5 - uses: actions/setup-go@v3
with: with:
concurrent_skipping: 'same_content_newer' stable: 'false'
go-version: '1.17.2'
- name: Check out pull request code
uses: actions/checkout@v6
if: github.event_name == 'pull_request'
with:
repository: ${{ github.event.pull_request.head.repo.full_name }}
ref: ${{ github.event.pull_request.head.ref }}
fetch-depth: 0
- name: Fetch base branch for comparison
if: github.event_name == 'pull_request'
run: |
git remote add upstream https://github.com/${{ github.event.pull_request.base.repo.full_name }}.git || true
git fetch upstream ${{ github.event.pull_request.base.ref }}
- name: Check out repository code
uses: actions/checkout@v6
if: github.event_name == 'push'
- name: Get changed files
id: changed-files-yaml
uses: tj-actions/changed-files@v47
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
- name: Run API tests - name: Run API tests
if: steps.changed-files-yaml.outputs.src_any_changed == 'true' run: cd test/automated/api && ./run.sh
uses: nick-fields/retry@v3
with:
timeout_minutes: 10
max_attempts: 3
command: earthly +api-tests
-101
View File
@@ -1,101 +0,0 @@
# This workflow runs browser-based tests using Cypress
# Triggers:
# - Push/PR with web, Go backend, or browser test changes
# Note: Browser tests require both frontend and backend
name: Browser Tests
on:
push:
paths:
- 'web/**'
- '**.go'
- 'go.mod'
- 'go.sum'
- 'test/automated/browser/**'
- '.github/workflows/browser-testing.yml'
pull_request:
paths:
- 'web/**'
- '**.go'
- 'go.mod'
- 'go.sum'
- 'test/automated/browser/**'
- '.github/workflows/browser-testing.yml'
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@v6
if: github.event_name == 'pull_request'
with:
repository: ${{ github.event.pull_request.head.repo.full_name }}
ref: ${{ github.event.pull_request.head.ref }}
fetch-depth: 0
- name: Fetch base branch for comparison
if: github.event_name == 'pull_request'
run: |
git remote add upstream https://github.com/${{ github.event.pull_request.base.repo.full_name }}.git || true
git fetch upstream ${{ github.event.pull_request.base.ref }}
- name: Check out repository code
uses: actions/checkout@v6
if: github.event_name == 'push'
- name: Get changed files
id: changed-files-yaml
uses: tj-actions/changed-files@v47
with:
files_yaml: |
testable:
- 'web/**/*.{tsx,ts,jsx,js,css,scss}'
- 'test/automated/browser/**'
- '**/*.go'
- 'go.mod'
- 'go.sum'
docs:
- '**/*.md'
- uses: actions/setup-node@v6
if: steps.changed-files-yaml.outputs.testable_any_changed == 'true'
with:
node-version: '24.12.0'
- name: Cache node modules
if: steps.changed-files-yaml.outputs.testable_any_changed == 'true'
uses: actions/cache@v5
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@v6
if: steps.changed-files-yaml.outputs.testable_any_changed == 'true'
with:
go-version: '1.26.1'
cache: true
- name: Install Google Chrome
if: steps.changed-files-yaml.outputs.testable_any_changed == 'true'
run: sudo apt-get update && sudo apt-get install google-chrome-stable
- name: Run Browser tests
if: steps.changed-files-yaml.outputs.testable_any_changed == 'true'
uses: nick-fields/retry@v3
with:
timeout_minutes: 20
max_attempts: 3
command: cd test/automated/browser && ./run.sh
-88
View File
@@ -1,88 +0,0 @@
# This workflow builds and deploys Storybook component documentation
# Triggers:
# - Push to develop branch with changes to:
# - web/stories/** (story files)
# - web/components/** (component source)
# - web/.storybook/** (Storybook config)
# - web/i18n/** (translations)
# Output:
# - Generates updated story files
# - Builds Storybook to docs/components
# - Dispatches event to owncast.github.io for deployment
name: Build and Deploy Components+Style Guide
on:
push:
branches:
- develop
paths: [
'web/stories/**',
'web/components/**',
'web/.storybook/**',
'web/i18n/**',
] # 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:
- 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@v6
if: github.event_name == 'pull_request'
with:
repository: ${{ github.event.pull_request.head.repo.full_name }}
ref: ${{ github.event.pull_request.head.ref }}
- name: Check out repository code
uses: actions/checkout@v6
if: github.event_name == 'push'
- name: Cache node modules
uses: actions/cache@v5
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
if: github.repository == 'owncast/owncast'
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
if: github.repository == 'owncast/owncast'
uses: peter-evans/repository-dispatch@v4
with:
token: ${{ secrets.BUNDLE_STORYBOOK_OWNCAST_ONLINE }}
repository: owncast/owncast.github.io
event-type: bundle-components-library
+21
View File
@@ -0,0 +1,21 @@
name: Bundle admin (owncast/owncast-admin)
on:
repository_dispatch:
types: [bundle-admin-event]
jobs:
bundle:
runs-on: ubuntu-latest
steps:
- name: Bundle admin
uses: actions/checkout@v3
- run: build/admin/bundleAdmin.sh
- name: Commit changes
uses: EndBug/add-and-commit@v9
with:
author_name: Owncast
author_email: owncast@owncast.online
message: "Update admin to ${{ github.event.client_payload.sha }}"
add: "static/admin"
env:
GITHUB_TOKEN: ${{ secrets.GH_CR_PAT }}
-95
View File
@@ -1,95 +0,0 @@
# .github/workflows/chromatic.yml
# This workflow publishes Storybook to Chromatic for visual regression testing
# Triggers:
# - Push/PR with changes to web components, stories, or styles
# Skips:
# - Static files
# - Renovate bot PRs
# Features:
# - Uses onlyChanged for efficient testing
# - Publishes visual diffs for review
name: 'Chromatic'
on:
push:
paths:
- 'web/components/**'
- 'web/stories/**'
- 'web/.storybook/**'
- 'web/**/*.{css,scss}'
- 'web/i18n/**'
- 'web/package.json'
- 'web/package-lock.json'
- '.github/workflows/chromatic.yml'
pull_request:
paths:
- 'web/components/**'
- 'web/stories/**'
- 'web/.storybook/**'
- 'web/**/*.{css,scss}'
- 'web/i18n/**'
- 'web/package.json'
- 'web/package-lock.json'
- '.github/workflows/chromatic.yml'
# List of jobs
jobs:
chromatic-deployment:
# Operating System
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'
- name: Check out repository code (Push)
if: github.event_name == 'push'
uses: actions/checkout@v6
- name: Check out pull request code (PR)
if: github.event_name == 'pull_request'
uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
fetch-depth: 0
- name: Fetch base branch for comparison
if: github.event_name == 'pull_request'
run: |
git remote add upstream https://github.com/${{ github.event.pull_request.base.repo.full_name }}.git || true
git fetch upstream ${{ github.event.pull_request.base.ref }}
- name: Get changed files
id: changed-files-yaml
uses: tj-actions/changed-files@v47
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@v16
with:
workingDir: web
projectToken: f47410569b62
onlyChanged: true
branch: ${{ github.head_ref || github.ref_name }}
sha: ${{ github.event.pull_request.head.sha || github.sha }}
+35 -57
View File
@@ -9,28 +9,20 @@
# the `language` matrix defined below to confirm you have the correct set of # the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages. # supported CodeQL languages.
# #
name: 'CodeQL' name: "CodeQL"
on: on:
push: push:
branches: [develop] branches: [ develop ]
paths: paths-ignore:
- '**.go' - 'static/**'
- 'go.mod' - 'webroot/js/web_modules/**'
- 'go.sum' - 'build/javascript/**'
- 'web/**/*.{js,ts,tsx,jsx}'
- '.github/workflows/codeql-analysis.yml'
- '.github/codeql/**'
pull_request: pull_request:
# The branches below must be a subset of the branches above # The branches below must be a subset of the branches above
branches: [develop] branches: [ develop ]
paths: paths-ignore:
- '**.go' - 'static/**'
- 'go.mod'
- 'go.sum'
- 'web/**/*.{js,ts,tsx,jsx}'
- '.github/workflows/codeql-analysis.yml'
- '.github/codeql/**'
jobs: jobs:
analyze: analyze:
@@ -40,54 +32,40 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
language: ['go', 'javascript'] language: [ 'go', 'javascript' ]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
# Learn more: # 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 # 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: steps:
- name: Check out pull request code - name: Checkout repository
uses: actions/checkout@v6 uses: actions/checkout@v3
if: github.event_name == 'pull_request'
with:
repository: ${{ github.event.pull_request.head.repo.full_name }}
ref: ${{ github.event.pull_request.head.ref }}
- name: Check out repository code # Initializes the CodeQL tools for scanning.
uses: actions/checkout@v6 - name: Initialize CodeQL
if: github.event_name == 'push' 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@v6 # Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
with: # If this step fails, then you should remove it and run the build manually (see below)
go-version: '1.26.1' - name: Autobuild
cache: true uses: github/codeql-action/autobuild@v1
# Initializes the CodeQL tools for scanning. # ℹ️ Command-line programs to run using the OS shell.
- name: Initialize CodeQL # 📚 https://git.io/JvXDl
uses: github/codeql-action/init@v4
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
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
# If this step fails, then you should remove it and run the build manually (see below) # and modify them (or add more) to build your code if your project
- name: Autobuild # uses a compiled language
uses: github/codeql-action/autobuild@v4
# ℹ️ Command-line programs to run using the OS shell. #- run: |
# 📚 https://git.io/JvXDl # make bootstrap
# make release
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines - name: Perform CodeQL Analysis
# and modify them (or add more) to build your code if your project uses: github/codeql-action/analyze@v1
# uses a compiled language
#- run: |
# make bootstrap
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
-37
View File
@@ -1,37 +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@v6
if: github.event_name == 'pull_request'
with:
repository: ${{ github.event.pull_request.head.repo.full_name }}
ref: ${{ github.event.pull_request.head.ref }}
- name: Check out repository code
uses: actions/checkout@v6
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
-86
View File
@@ -1,86 +0,0 @@
# See https://docs.earthly.dev/ci-integration/vendor-specific-guides/gh-actions-integration
# for details.
# This workflow builds the development container image
# Triggers:
# - Nightly scheduled builds (2 AM UTC)
# - Push/PR to develop with relevant file changes
# Skips:
# - Documentation-only changes
# - Web-only changes (web/** without Go changes)
name: Build development container
on:
schedule:
- cron: '0 2 * * *'
push:
branches:
- develop
paths:
- '**/*.go'
- 'go.mod'
- 'go.sum'
- 'Dockerfile'
- 'Earthfile'
- 'build/**'
- '.github/workflows/container.yaml'
pull_request:
branches:
- develop
paths:
- '**/*.go'
- 'go.mod'
- 'go.sum'
- 'Dockerfile'
- 'Earthfile'
- 'build/**'
- '.github/workflows/container.yaml'
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@v6
if: github.event_name == 'pull_request'
with:
repository: ${{ github.event.pull_request.head.repo.full_name }}
ref: ${{ github.event.pull_request.head.ref }}
- name: Check out repository code
uses: actions/checkout@v6
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
-47
View File
@@ -1,47 +0,0 @@
name: 'Copilot Setup Steps'
# Automatically run the setup steps when they are changed to allow for easy validation, and
# allow manual testing through the repository's "Actions" tab
on:
workflow_dispatch:
push:
paths:
- .github/workflows/copilot-setup-steps.yml
pull_request:
paths:
- .github/workflows/copilot-setup-steps.yml
jobs:
# The job MUST be called `copilot-setup-steps` or it will not be picked up by Copilot.
copilot-setup-steps:
runs-on: ubuntu-latest
# Set the permissions to the lowest permissions possible needed for your steps.
# Copilot will be given its own token for its operations.
permissions:
# If you want to clone the repository as part of your setup steps, for example to install dependencies, you'll need the `contents: read` permission. If you don't clone the repository in your setup steps, Copilot will do this for you automatically after the steps complete.
contents: read
# You can define any steps you want, and they will run before the agent starts.
# If you do not check out your code, Copilot will do this for you.
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Install go
uses: actions/setup-go@v6
with:
go-version: '^1'
cache: true
- name: Install ffmpeg
run: sudo apt-get install -y ffmpeg
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: '24'
cache: 'npm'
- name: Install JavaScript dependencies
run: npm ci
working-directory: web
-69
View File
@@ -1,69 +0,0 @@
# This workflow checks CSS/SCSS code quality and formatting
# Triggers:
# - Push/PR with changes to web/ directory
# Checks:
# - Prettier formatting for CSS/SCSS files
# - Stylelint rules for code quality
# Note: Only runs when CSS or SCSS files are changed
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@v6
if: github.event_name == 'pull_request'
with:
repository: ${{ github.event.pull_request.head.repo.full_name }}
ref: ${{ github.event.pull_request.head.ref }}
fetch-depth: 0
- name: Fetch base branch for comparison
if: github.event_name == 'pull_request'
run: |
git remote add upstream https://github.com/${{ github.event.pull_request.base.repo.full_name }}.git || true
git fetch upstream ${{ github.event.pull_request.base.ref }}
- name: Check out repository code
uses: actions/checkout@v6
if: github.event_name == 'push'
- name: Get changed files
id: changed-files-yaml
uses: tj-actions/changed-files@v47
with:
path: 'web'
files_yaml: |
src:
- '**/*.{css,scss}'
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: '24.12.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 }}
+40
View File
@@ -0,0 +1,40 @@
# See https://docs.earthly.dev/ci-integration/vendor-specific-guides/gh-actions-integration
# for details.
name: Build nightly docker
on:
workflow_dispatch:
schedule:
- cron: '0 2 * * *'
jobs:
Docker:
runs-on: ubuntu-latest
steps:
- uses: earthly/actions-setup@v1
with:
version: 'latest' # or pin to an specific version, e.g. "v0.6.10"
- name: Earthly version
run: earthly --version
- name: Log into GitHub Container Registry
env:
GH_CR_PAT: ${{ secrets.GH_CR_PAT }}
run: echo "${{ secrets.GH_CR_PAT }}" | docker login https://ghcr.io -u ${{ github.actor }} --password-stdin
if: env.GH_CR_PAT != null
- name: Set up QEMU
id: qemu
uses: docker/setup-qemu-action@v1
with:
image: tonistiigi/binfmt:latest
platforms: all
- uses: actions/checkout@v3
- name: Checkout and build
if: env.GH_CR_PAT != null
env:
GH_CR_PAT: ${{ secrets.GH_CR_PAT }}
run: cd build/release && ./docker-nightly.sh
@@ -9,27 +9,19 @@ jobs:
name: Generate API Documentation name: Generate API Documentation
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Check out pull request code - name: Checkout repo
uses: actions/checkout@v6 uses: actions/checkout@v3
if: github.event_name == 'pull_request'
with:
repository: ${{ github.event.pull_request.head.repo.full_name }}
ref: ${{ github.event.pull_request.head.ref }}
- name: Check out repository code - name: Run redoc on openapi.yaml
uses: actions/checkout@v6 run: |
if: github.event_name == 'push' npx redoc-cli bundle openapi.yaml -o docs/api/index.html --options '{"hideHostname": true, "pathInMiddlePanel": true}'
- name: Run redoc on openapi.yaml - name: Commit changes
run: | uses: EndBug/add-and-commit@v9
npx @redocly/cli --config docs/api/redocly.yaml build-docs openapi.yaml -o docs/api/index.html with:
author_name: Owncast
- name: Commit changes author_email: owncast@owncast.online
uses: EndBug/add-and-commit@v9 message: "Commit updated API documentation"
with: add: "docs/api/index.html"
author_name: Owncast env:
author_email: owncast@owncast.online GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
message: 'Commit updated API documentation'
add: 'docs/api/index.html'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-55
View File
@@ -1,55 +0,0 @@
# This workflow runs golangci-lint to check Go code quality
# Triggers:
# - Push/PR with Go file changes
# Skips:
# - Web-only changes
# - Dependabot PRs (separate workflow)
# Note: Only reports on new issues to reduce noise
name: Lint
on:
push:
paths:
- '**.go'
- 'go.mod'
- 'go.sum'
- '.golangci.yml'
- '.github/workflows/go-lint.yml'
pull_request:
paths:
- '**.go'
- 'go.mod'
- 'go.sum'
- '.golangci.yml'
- '.github/workflows/go-lint.yml'
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@v6
with:
fetch-depth: 0
- uses: actions/setup-go@v6
with:
go-version: '1.26.1'
cache: true
- uses: actions/checkout@v6
- name: golangci-lint
uses: golangci/golangci-lint-action@v9
with:
version: v2.11.4
only-new-issues: true
args: --timeout=3m
-99
View File
@@ -1,99 +0,0 @@
# This workflow runs Go unit tests across multiple OS and Go versions
# Triggers:
# - Push/PR with Go file changes (*.go, go.mod, go.sum)
# - Skips when only web/ directory changes
# Test Matrix:
# - PRs: Latest Go on Ubuntu only (fast feedback)
# - Develop/Main: Full matrix (ensure cross-platform compatibility)
name: Go Tests
on:
push:
paths-ignore:
- 'web/**'
pull_request:
paths-ignore:
- 'web/**'
jobs:
test:
strategy:
matrix:
go-version: ${{ github.event_name == 'pull_request' && fromJSON('["1.24.x"]') || fromJSON('["1.23.x", "1.24.x"]') }}
os: ${{ github.event_name == 'pull_request' && fromJSON('["ubuntu-latest"]') || fromJSON('["ubuntu-latest", "macos-latest", "windows-latest", "ubuntu-24.04-arm"]') }}
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v6
- name: Get changed files
id: changed-files-yaml
uses: tj-actions/changed-files@v47
with:
files_yaml: |
src:
- '**/*.{go,mod,sum}'
- uses: actions/cache@v5
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@v6
with:
go-version: '^1'
cache: true
- name: Run tests
if: steps.changed-files-yaml.outputs.src_any_changed == 'true'
run: go test ./...
test-bsds:
# Only run BSD tests on push events (not PRs) for efficiency
if: github.event_name == 'push'
runs-on: macos-latest
strategy:
matrix:
os:
- name: freebsd
version: 12.2
- name: openbsd
version: 6.8
steps:
- uses: actions/checkout@v6
- name: Get changed files
id: changed-files-yaml
uses: tj-actions/changed-files@v47
with:
files_yaml: |
src:
- '**/*.{go,mod,sum}'
- uses: actions/cache@v5
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@v6
with:
go-version: '^1'
cache: true
- name: Run tests
if: steps.changed-files-yaml.outputs.src_any_changed == 'true'
run: go test ./...
@@ -1,47 +0,0 @@
name: Hacktoberfest Reminder
on:
schedule:
# Run on September 15th at 9:00 AM UTC every year
- cron: '0 9 15 9 *'
workflow_dispatch: # Allow manual triggering for testing
jobs:
create-reminder-issue:
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- name: Create Hacktoberfest reminder issue
uses: dacbd/create-issue-action@v2
with:
token: ${{ github.token }}
title: 'Reminder: Update Hacktoberfest branding for this year'
body: |
## Hacktoberfest Update Reminder
It's time to review and update the Hacktoberfest page on the Owncast website!
### What needs to be updated:
- **Branding/Images**: Hacktoberfest releases new branding each year. Check the official [Hacktoberfest website](https://hacktoberfest.com/) for current year branding assets.
- **Year**: Update any references to the year (e.g., "Hacktoberfest 2024" → "Hacktoberfest 2025")
- **Links**: Verify all links point to the current year's Hacktoberfest pages
- **Text/Copy**: Review any text for accuracy and updates from Hacktoberfest organizers
### Resources:
- Hacktoberfest website: https://hacktoberfest.com/
### Action Items:
- [ ] Download new Hacktoberfest branding assets
- [ ] Update images on the website
- [ ] Update year references
- [ ] Verify all links are working
- [ ] Test the updated page
---
_This issue was automatically created by a scheduled GitHub Action._
labels: hacktoberfest,good first issue,help wanted,documentation
+12 -84
View File
@@ -1,28 +1,12 @@
# This workflow tests HLS video streaming functionality name: Automated HLS tests
# Triggers:
# - Push/PR with Go file changes or HLS test changes
# Tests:
# - Local storage backend
# - S3 storage backend (when secrets available)
# - Retries up to 3 times for flaky network tests
name: HLS tests
on: on:
push: push:
paths: paths-ignore:
- '**.go' - 'webroot/**'
- 'go.mod'
- 'go.sum'
- 'test/automated/hls/**'
- '.github/workflows/hls-tests.yml'
pull_request: pull_request:
paths: paths-ignore:
- '**.go' - 'webroot/**'
- 'go.mod'
- 'go.sum'
- 'test/automated/hls/**'
- '.github/workflows/hls-tests.yml'
env: env:
S3_BUCKET: ${{ secrets.S3_BUCKET }} S3_BUCKET: ${{ secrets.S3_BUCKET }}
@@ -32,71 +16,15 @@ env:
S3_SECRET: ${{ secrets.S3_SECRET }} S3_SECRET: ${{ secrets.S3_SECRET }}
jobs: jobs:
tests: api:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- id: skip_check - uses: actions/checkout@v3
uses: fkirc/skip-duplicate-actions@v5 - uses: actions/setup-go@v3
with: with:
concurrent_skipping: 'same_content_newer' stable: 'false'
go-version: '1.17.2'
- name: Run HLS tests
run: cd test/automated/hls && ./run.sh
- name: Check out pull request code
uses: actions/checkout@v6
if: github.event_name == 'pull_request'
with:
repository: ${{ github.event.pull_request.head.repo.full_name }}
ref: ${{ github.event.pull_request.head.ref }}
fetch-depth: 0
- name: Fetch base branch for comparison
if: github.event_name == 'pull_request'
run: |
git remote add upstream https://github.com/${{ github.event.pull_request.base.repo.full_name }}.git || true
git fetch upstream ${{ github.event.pull_request.base.ref }}
- name: Check out repository code
uses: actions/checkout@v6
if: github.event_name == 'push'
- name: Get changed files
id: changed-files-yaml
uses: tj-actions/changed-files@v47
with:
files_yaml: |
src:
- '**/*.{go,mod,sum}'
- uses: actions/setup-go@v6
if: steps.changed-files-yaml.outputs.src_any_changed == 'true'
with:
go-version: '1.26.1'
cache: true
- name: Cache node modules
uses: actions/cache@v5
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
@@ -1,263 +0,0 @@
# This workflow handles Javascript formatting, linting, testing, and building
# Triggers:
# - Push/PR with changes to web/ directory source code
# Skips:
# - Markdown files
# - Static/generated files
# Jobs:
# 1. formatting: ESLint and Prettier formatting
# 2. unused-code: Knip checks for unused code and dependencies
# 3. web-bundle: Builds and bundles the web app (only on develop branch)
# Note: Auto-fixes and commits formatting issues on push events
name: Javascript
on:
push:
paths:
- 'web/**/*.{js,ts,tsx,jsx,json,css,scss}'
- 'web/package.json'
- 'web/package-lock.json'
- 'web/.eslintrc.js'
- 'web/.prettierrc.js'
- 'web/tsconfig.json'
- 'web/next.config.js'
- '.github/workflows/javascript-format-test-build.yml'
pull_request:
paths:
- 'web/**/*.{js,ts,tsx,jsx,json,css,scss}'
- 'web/package.json'
- 'web/package-lock.json'
- 'web/.eslintrc.js'
- 'web/.prettierrc.js'
- 'web/tsconfig.json'
- 'web/next.config.js'
- '.github/workflows/javascript-format-test-build.yml'
jobs:
formatting:
name: Code formatting
if: github.actor != 'renovate[bot]' && github.actor != 'dependabot[bot]'
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@v6
if: github.event_name == 'pull_request'
with:
repository: ${{ github.event.pull_request.head.repo.full_name }}
ref: ${{ github.event.pull_request.head.ref }}
fetch-depth: 0
- name: Fetch base branch for comparison
if: github.event_name == 'pull_request'
run: |
git remote add upstream https://github.com/${{ github.event.pull_request.base.repo.full_name }}.git || true
git fetch upstream ${{ github.event.pull_request.base.ref }}
- name: Setup Nodejs
uses: actions/setup-node@v6
with:
node-version: '24.12.0'
- name: Check out repository code
uses: actions/checkout@v6
if: github.event_name == 'push'
- name: Get changed files
id: changed-files-yaml
uses: tj-actions/changed-files@v47
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@v5
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
if: github.actor != 'renovate[bot]' && github.actor != 'dependabot[bot]'
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@v6
if: github.event_name == 'pull_request'
with:
repository: ${{ github.event.pull_request.head.repo.full_name }}
ref: ${{ github.event.pull_request.head.ref }}
fetch-depth: 0
- name: Fetch base branch for comparison
if: github.event_name == 'pull_request'
run: |
git remote add upstream https://github.com/${{ github.event.pull_request.base.repo.full_name }}.git || true
git fetch upstream ${{ github.event.pull_request.base.ref }}
- name: Check out repository code
uses: actions/checkout@v6
if: github.event_name == 'push'
- name: Setup Nodejs
uses: actions/setup-node@v6
with:
node-version: '24.12.0'
- name: Cache node modules
uses: actions/cache@v5
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' && !cancelled()
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@v6
with:
node-version: '24.12.0'
- name: Cache node modules
uses: actions/cache@v5
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@v6
if: github.event_name == 'pull_request'
with:
repository: ${{ github.event.pull_request.head.repo.full_name }}
ref: ${{ github.event.pull_request.head.ref }}
- name: Check out repository code
uses: actions/checkout@v6
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
@@ -0,0 +1,29 @@
name: Format Javascript
# This action works with pull requests and pushes
on:
push:
branches:
- develop
jobs:
prettier:
runs-on: ubuntu-latest
if: ${{ github.actor != 'dependabot[bot]' }}
steps:
- name: Checkout
uses: actions/checkout@v3
with:
# Make sure the actual branch is checked out when running on pull requests
ref: ${{ github.head_ref }}
fetch-depth: 0
- name: Prettify code
uses: creyD/prettier_action@v4.2
with:
# This part is also where you can pass other options, for example:
prettier_options: --write webroot/**/*.{js,md}
only_changed: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -0,0 +1,33 @@
name: javascript-packages
on:
push:
paths:
- build/javascript/package.json
jobs:
run:
if: ${{ github.actor != 'dependabot[bot]' }}
name: npm run build
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v3
with:
# Make sure the actual branch is checked out when running on pull requests
ref: ${{ github.head_ref }}
- name: Build dependencies
uses: actions/setup-node@v3
with:
node-version: '12'
- run: cd build/javascript && npm run build
- name: Commit changes
uses: EndBug/add-and-commit@v9
with:
author_name: Owncast
author_email: owncast@owncast.online
message: "Commit updated Javascript packages"
add: "build/javascript/package* webroot/js/web_modules"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+26
View File
@@ -0,0 +1,26 @@
name: lint
on:
push:
pull_request:
permissions:
contents: read
jobs:
golangci:
name: Go linter
if: ${{ github.actor != 'dependabot[bot]' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- uses: actions/setup-go@v3
- uses: actions/checkout@v3
- name: golangci-lint
uses: golangci/golangci-lint-action@v3
with:
only-new-issues: true
args: --timeout=3m
-112
View File
@@ -1,112 +0,0 @@
# This workflow validates that required PR checklist items are checked
# Triggers:
# - PR opened, edited, or reopened
# Skips:
# - Organization members
# - Bot PRs (dependabot, renovate, etc.)
# Note: Looks for content between REQUIRED-CHECKLIST:START and REQUIRED-CHECKLIST:END
# markers and fails if any unchecked boxes exist in that section.
name: PR Checklist
on:
pull_request:
types: [opened, edited, reopened]
permissions:
contents: read
pull-requests: write
jobs:
validate-checklist:
name: Validate PR checklist
runs-on: ubuntu-latest
if: ${{ !contains(github.actor, '[bot]') }}
steps:
- name: Check organization membership
id: membership
uses: actions/github-script@v8
with:
script: |
try {
await github.rest.orgs.checkMembershipForUser({
org: 'owncast',
username: context.payload.pull_request.user.login
});
console.log('User is an organization member, skipping checklist validation');
return true;
} catch (error) {
if (error.status === 404 || error.status === 302) {
console.log('User is not an organization member, will validate checklist');
return false;
}
throw error;
}
- name: Validate required checkboxes
id: validate
if: ${{ steps.membership.outputs.result == 'false' }}
uses: actions/github-script@v8
with:
script: |
const prBody = context.payload.pull_request.body || '';
// Extract content between the markers
const markerPattern = /<!-- REQUIRED-CHECKLIST:START[\s\S]*?-->([\s\S]*?)<!-- REQUIRED-CHECKLIST:END -->/;
const match = prBody.match(markerPattern);
if (!match) {
core.setOutput('missing_checklist', 'true');
core.setFailed(
'## PR Checklist Validation Failed\n\n' +
'The required checklist section is missing from your PR description.\n\n' +
'Please do not remove the "Required checklist" section from the PR template. ' +
'If you need to restore this section, please edit your PR description to include it from [the original template](https://raw.githubusercontent.com/owncast/owncast/refs/heads/develop/.github/PULL_REQUEST_TEMPLATE.MD).'
);
return;
}
const checklistSection = match[1];
// Find all unchecked boxes in the section
const uncheckedPattern = /-\s*\[\s*\]\s*(.+)/g;
const uncheckedItems = [];
let uncheckedMatch;
while ((uncheckedMatch = uncheckedPattern.exec(checklistSection)) !== null) {
uncheckedItems.push(uncheckedMatch[1].trim());
}
if (uncheckedItems.length > 0) {
let message = '## PR Checklist Validation Failed\n\n';
message += 'The following required checklist items are not checked:\n\n';
for (const item of uncheckedItems) {
message += `- [ ] ${item}\n`;
}
message += '\nPlease check all items in the required checklist section to confirm you have completed them.';
core.setFailed(message);
} else {
console.log('All required checklist items are checked');
}
- name: Comment on PR about missing checklist
if: ${{ steps.validate.outputs.missing_checklist == 'true' }}
uses: actions/github-script@v8
with:
script: |
const body =
'## PR Checklist Missing\n\n' +
'It looks like the **required checklist section** was removed from your PR description. ' +
'This section is needed for the PR checks to pass.\n\n' +
'Please edit your PR description and restore the checklist from ' +
'[the PR template](https://raw.githubusercontent.com/owncast/owncast/refs/heads/develop/.github/PULL_REQUEST_TEMPLATE.MD). ' +
'Once restored, check off each item to confirm you have completed them.\n\n' +
'The checklist section begins with `<!-- REQUIRED-CHECKLIST:START -->` and ends with `<!-- REQUIRED-CHECKLIST:END -->`.';
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
body: body
});
-53
View File
@@ -1,53 +0,0 @@
# This workflow checks shell scripts for common issues using shellcheck
# Triggers:
# - Push/PR to develop with changes to .sh files
# Checks:
# - All shell scripts recursively
# - Reports info-level severity and above
# - Follows sourced files with -x flag
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:
- 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@v6
if: github.event_name == 'pull_request'
with:
repository: ${{ github.event.pull_request.head.repo.full_name }}
- name: Check out repository code
uses: actions/checkout@v6
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
View File
@@ -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@v10
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
View File
@@ -0,0 +1,42 @@
name: Tests
on: [push, pull_request]
jobs:
test:
strategy:
matrix:
go-version: [1.16.x, 1.17.x]
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v3
- name: Install go
uses: actions/setup-go@v3
with:
go-version: "^1"
- name: Run tests
run: go test ./...
test-bsds:
runs-on: macos-10.15
strategy:
matrix:
os:
- name: freebsd
version: 12.2
- name: openbsd
version: 6.8
steps:
- uses: actions/checkout@v3
- name: Install go
uses: actions/setup-go@v3
with:
go-version: "^1"
- name: Run tests
run: go test ./...
-136
View File
@@ -1,136 +0,0 @@
# This workflow manages internationalization (i18n) with Crowdin
# Triggers:
# - Hourly schedule to sync translations
# - Push with changes to translation files or config
# Process:
# 1. Extracts translatable strings from source code
# 2. Uploads source strings to Crowdin
# 3. Downloads translated strings from Crowdin
# 4. Creates PR with updated translations
# Note: Uses concurrency control to prevent overlapping runs
name: Translation job
on:
schedule:
# Run the workflow every hour
- cron: '0 * * * *'
push:
branches:
- develop
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@v6
with:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0
- name: Configure Git
run: |
git config --global user.name "Owncast"
git config --global user.email "owncast@owncast.online"
- name: Install dependencies
run: npm install
- name: Generate translation files
run: npm run translate
- name: Upload sources to Crowdin
uses: crowdin/github-action@v2
with:
upload_sources: true
upload_translations: false
download_translations: false
config: crowdin.yml
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
- name: Download translations from Crowdin
uses: crowdin/github-action@v2
with:
upload_sources: false
upload_translations: false
download_translations: true
skip_untranslated_strings: true
export_only_approved: false
push_translations: false
commit_message: 'chore(i18n): update translations from Crowdin'
localization_branch_name: crowdin-translations
create_pull_request: false
config: crowdin.yml
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
- name: Check for translation changes
id: changes
run: |
cd ..
git add web/i18n/
if git diff --cached --quiet; then
echo "has_changes=false" >> $GITHUB_OUTPUT
echo "No translation changes detected"
else
echo "has_changes=true" >> $GITHUB_OUTPUT
echo "Translation changes detected"
fi
- name: Create Pull Request
if: steps.changes.outputs.has_changes == 'true'
id: create_pr
uses: peter-evans/create-pull-request@v8
with:
token: ${{ secrets.GITHUB_TOKEN }}
branch: crowdin-translations
base: develop
title: 'chore(i18n): update translations from Crowdin'
body: |
This PR contains updated translations downloaded from Crowdin.
**Changes include:**
- Updated translation files in `web/i18n/`
- Translations synchronized from Crowdin project
Please review the changes and merge if they look correct.
---
This PR was created automatically by the translation workflow.
commit-message: 'chore(i18n): update translations from Crowdin'
author: 'Owncast <owncast@owncast.online>'
committer: 'Owncast <owncast@owncast.online>'
add-paths: |
web/i18n/**
delete-branch: true
- name: Log PR creation result
if: steps.create_pr.outputs.pull-request-number
run: |
echo "Created PR #${{ steps.create_pr.outputs.pull-request-number }}"
echo "PR URL: ${{ steps.create_pr.outputs.pull-request-url }}"
- name: No changes detected
if: steps.changes.outputs.has_changes == 'false'
run: echo "No translation changes to commit"
+1 -10
View File
@@ -4,7 +4,6 @@
*.dll *.dll
*.so *.so
*.dylib *.dylib
.DS_Store
# Test binary, built with `go test -c` # Test binary, built with `go test -c`
*.test *.test
@@ -28,7 +27,6 @@ webroot/preview.gif
webroot/hls webroot/hls
webroot/static/content.md webroot/static/content.md
hls/ hls/
!test/automated/hls/
dist/ dist/
data/ data/
transcoder.log transcoder.log
@@ -40,11 +38,4 @@ backup/
!core/data !core/data
test/test.db test/test.db
test/automated/browser/screenshots test/automated/browser/screenshots
lefthook-local.yml lefthook.yml
test/automated/browser/cypress/screenshots
test/automated/browser/cypress/videos
web/style-definitions/build/
web/public/sw.js
web/public/workbox-*.js
bin/
-5
View File
@@ -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 .
+73 -59
View File
@@ -1,78 +1,92 @@
version: "2"
run: run:
go: "1.25.3"
modules-download-mode: readonly
tests: false 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.17
go: '1.17'
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
exclude:
- Subprocess launch(ed with variable|ing should be audited)
- Error return value of .((os\.)?std(out|err)\..*|.*Close|.*Flush|os\.Remove(All)?|.*print(f|ln)?|os\.(Un)?Setenv). is not checked
- G307 # Allow closing files as a defer without checking error.
- composite literal uses unkeyed fields
linters: linters:
enable: enable:
- bodyclose - bodyclose
- copyloopvar
- cyclop
- dupl - dupl
- forbidigo - errcheck
- exportloopref
- goconst - goconst
- gocritic
- godot - godot
- godox - godox
- goimports
- goprintffuncname - goprintffuncname
- gosec - gosec
- govet
- misspell - misspell
- nakedret
- prealloc - prealloc
- revive - revive
- rowserrcheck - rowserrcheck
- sqlclosecheck - sqlclosecheck
- staticcheck
- unconvert - unconvert
- unparam - unparam
- wastedassign
- whitespace - whitespace
settings: - nakedret
cyclop: - cyclop
max-complexity: 15 - gosimple
package-average: 0 - varcheck
dupl: - unused
threshold: 200 - deadcode
forbidigo: - exportloopref
forbid: - gocritic
- pattern: ^(fmt\.Print(|f|ln)|print|println) - forbidigo
- pattern: ^panic.*$ - unparam
gocritic: - wastedassign
disabled-checks: linters-settings:
- ifElseChain govet:
- exitAfterDefer disable:
revive: - composite
rules:
- name: package-comments cyclop:
disabled: true # the maximal code complexity to report. default is 10. eventually work our way to that.
exclusions: max-complexity: 15
generated: lax # the max average package complexity. If it's higher than 0.0 (float) the check is enabled (default 0.0)
rules: package-average: 0.0
- path: (.+)\.go$ # should ignore tests
text: Subprocess launch(ed with variable|ing should be audited) skip-tests: true
- path: (.+)\.go$
text: Error return value of .((os\.)?std(out|err)\..*|.*Close|.*Flush|os\.Remove(All)?|.*print(f|ln)?|os\.(Un)?Setenv). is not checked gosimple:
- path: (.+)\.go$ # Select the Go version to target. The default is '1.13'.
text: G307 go: "1.17"
- path: (.+)\.go$ # https://staticcheck.io/docs/options#checks
text: composite literal uses unkeyed fields checks: ["all"]
- path: (.+)\.go$
text: 'SA1019.*aws-sdk-go.*deprecated' gocritic:
- linters: disabled-checks:
- cyclop - ifElseChain
path: (.+)_test\.go - exitAfterDefer
paths:
- third_party$ forbidigo:
- builtin$ # Forbid the following identifiers (identifiers are written using regexp):
- examples$ forbid:
issues: # Logging via Print bypasses our logging framework.
max-issues-per-linter: 0 - ^(fmt\.Print(|f|ln)|print|println)
max-same-issues: 0 - ^panic.*$
formatters:
enable: dupl:
- goimports # tokens count to trigger issue, 150 by default
exclusions: threshold: 200
generated: lax
paths:
- third_party$
- builtin$
- examples$
-1
View File
@@ -1,4 +1,3 @@
# Ignore artifacts: # Ignore artifacts:
build/javascript build/javascript
webroot/js/web_modules webroot/js/web_modules
static/
+28
View File
@@ -0,0 +1,28 @@
{
"cSpell.words": [
"Debugln",
"Errorln",
"Fediverse",
"Ffmpeg",
"ffmpegpath",
"ffmpg",
"geoip",
"gosec",
"mattn",
"Mbps",
"nolint",
"Owncast",
"ppid",
"preact",
"RTMP",
"rtmpserverport",
"sqlite",
"Tracef",
"Traceln",
"upgrader",
"Upgrader",
"videojs",
"Warnf",
"Warnln"
]
}
-127
View File
@@ -1,127 +0,0 @@
# Owncast AI Agent Instructions
This is a repository consisting of a Go backend and a React frontend. It supports both an internal web application that is public facing, an internal admin web application, internal-only APIs for powering these web interfaces, and a set of APIs for third parties to take advantage of.
## Quick Reference
| Task | Command |
| ----------------------- | ----------------------------------- |
| Build backend | `go build -o owncast .` |
| Run backend | `go run main.go` |
| Run frontend dev server | `cd web && npm run dev` |
| Run Go tests | `go test ./...` |
| Run JS tests | `cd web && npm test` |
| Lint Go | `make lint` |
| Format Go | `make fmt` |
| Lint JS/CSS | `cd web && npm run lint` |
| Format JS/CSS | `cd web && npm run format` |
| Full web CI check | `cd web && npm run check` |
| Build Storybook | `cd web && npm run build-storybook` |
| Generate API code | `make api-generate` |
| Generate DB code | `make sqlc` |
| Install dev tools | `make install-tools` |
| Install git hooks | `make install-hooks` |
| Start test stream | `./test/ocTestStream.sh` |
## Code Standards
- UI component standards can be found in https://docs.owncast.dev/develop-frontend-components
### Required Before Each Commit
- Ensure all code is formatted using `golangci-lint` for Go, `stylelint` for CSS, and `prettier` for JavaScript/React.
- Fix any formatting or linting errors.
### Development Flow
- Build: Ensure the code builds successfully using `go build` for Go and `npm run build` for React in the `web` directory.
- Tests: Write unit tests for Go code and Javascript logic. Use `go test` and `npm run test`.
- UI components: Components should be standalone and reusable and show up in Storybook to enable testing, prototyping, and iteration.
- API: All APIs should be documented using OpenAPI specifications. Use `build/gen-api.sh` to generate the stubs, and then fill them in with your actual API implementation.
- If you made new UI components or made changes ensure that Storybook still builds by running `npm run build-storybook`.
## Repository Structure
- `web/`: The web source code for the React frontend.
- Root of the repo: Go source code for the backend.
- `static/web/`: Generated static files for the web application. Do not edit or commit files in this directory directly; they are generated from the `web` directory. Ignore this.
- `test/automated/api/`: A series of automated integration tests for API endpoints written in Javascript.
- `test/automated/browser/`: A series of automated browser UI tests for actual real-world browser interaction.
- `build/`: Build and code generation scripts.
### Backend Structure
- `main.go`: Entry point. Initializes logging, database, config, core services, metrics, and the HTTP router.
- `webserver/router/`: Chi (v5) HTTP router with HTTP/2 support. Routes organized under `/api/`, `/api/admin/`, federation endpoints, `/hls/`, and `/ws`.
- `webserver/handlers/`: Request handlers for web, admin, static files, HLS streaming.
- `webserver/handlers/generated/`: Auto-generated API types and Chi server stubs from OpenAPI spec. Do not hand-edit.
- `webserver/router/middleware/`: Authentication (`RequireAdminAuth()` for admin endpoints via HTTP Basic Auth) and ActivityPub content negotiation middleware.
- `persistence/`: Repository pattern implementations (ConfigRepository, UserRepository, ChatMessageRepository, WebhookRepository, AuthRepository). All database access goes through these.
- `db/`: sqlc-generated type-safe database code. Schema in `db/schema.sql`, queries in `db/query.sql`. Run `make sqlc` after modifying.
- `core/`: Core streaming logic including transcoder, chat, webhooks, storage providers, playlist management.
- `core/storageproviders/`: Video storage backends (local filesystem and S3).
- `core/transcoder/`: FFmpeg-based video transcoding.
- `activitypub/`: Federation support (controllers, inbox/outbox, HTTP signatures, WebFinger, NodeInfo, worker pool).
- `models/`: Shared data models.
- `config/`: Configuration package with defaults and constants.
- `tools/`: Separate `go.mod` for development tool dependencies installed to `./bin/`.
### Frontend Structure
- `web/pages/`: Next.js 14 pages with static export.
- `web/components/`: React components organized by domain (`admin/`, `chat/`, `common/`, `layouts/`, `modals/`, `ui/`, `video/`, `action-buttons/`).
- `web/components/stores/`: Recoil state management stores.
- `web/interfaces/`: TypeScript interfaces.
- `web/i18n/`: Localization files (next-export-i18n).
- `web/style-definitions/`: Design token definitions.
### Key Technical Details
- **Go version**: 1.24+ (see `go.mod`)
- **Database**: SQLite (single file, no external DB server needed)
- **Frontend framework**: Next.js 14, React 18, TypeScript, static export
- **UI library**: Ant Design v4 with Less theming
- **State management**: Recoil
- **Styling**: SCSS with component-scoped files, Less for Ant Design
- **API spec**: OpenAPI 3.1 at `openapi.yaml`
- **Commit style**: Conventional Commits (`type(scope): description`)
- **Branches**: `develop` is the development branch; `master` is the production/release branch
- **CI**: GitHub Actions for Go tests, JS linting/testing, browser tests, Storybook builds, Chromatic visual regression, CodeQL security scanning
- **Go linting**: golangci-lint with cyclop (max complexity 15), dupl (threshold 200), gosec, gocritic, forbidigo (no `fmt.Print*`, `print`, `println`, or `panic`), and others. Full config in `.golangci.yml`.
- **JS linting**: ESLint with airbnb config, Prettier, Stylelint, knip for unused code detection
- **Makefile**: For common tasks like building, testing, linting, code generation, and installing dev tools/hooks. Run `make help` for a list of functions.
## Testing Web Frontend
Testing the web frontend requires running the frontend development server and the backend service together. The frontend development server must be started using `npm run dev` in the `web` directory, which will serve the web application at `http://localhost:3000`. The backend service can be started using `go run main.go` in the root of the repository.
Do not access the web application via `http://localhost:8080` or build the web project to copy files to the `static/web` directory for local development.
## Key Guidelines
1. All APIs are to be documented using OpenAPI specifications and code is to be generated using `build/gen-api.sh`. Additional details can be found at https://docs.owncast.dev/api-web-routing.
2. Write API tests for all new endpoints in the `test/automated/api` directory.
3. Use the `test/automated/browser` directory for browser-based tests for new functionality that simulate user interactions.
4. All user-facing frontend UI strings need to support localization. Use the `Translation` component to show most displayable strings. To create dynamic translated strings use the `next-export-i18n` library and the `t()` function. But use the `Translation` component unless there is a reason not to as it allows you to set default text. Read https://docs.owncast.dev/web-translations for more details. Test localization by adding "?lang=XX" with XX being a country code, such as "de" for German. Strings that have not yet been translated will not show as changed, but it's good to test anyway to make sure that previously translated strings have not been broken or regressed in any way.
5. A link to the PR's Storybook on Chromatic via the PR's Chromatic job should be included to help with review.
6. For API changes a before and after example of the API response should be added to the pull request to help with review.
7. For backend changes, a before and after example of logs to demonstrate the change should be added to the pull request to help with review.
8. Do not build the web project or copy the files to the `static/web` directory for local development.
9. When running the frontend development server, the local backend service must also be running. You can run the backend service using `go run main.go` in the root of the repository.
10. The credentials for the backend development backend are username: admin and password: abc123 and uses HTTP Basic Auth. This is used for the admin web application and the admin APIs.
11. The admin is found at `/admin`.
12. If a live stream video is needed to run, you can run `./test/ocTestStream.sh` to start an actual stream that will begin streaming from the local development server.
13. You should never commit the `static/web` directory to the repository. It is generated from the `web` directory and should be ignored in your commits.
14. Don't use emoji in code comments or commit messages. That's lame.
15. User interface components should use Ant Design v4 components, not Ant Design v5.
16. Database access should be through the repository patterns.
17. Any substantial new features or changes around Federation or ActivityPub should be documented in FEDERATION.md.
## External Documentation
- Project documentation: https://docs.owncast.dev
- Frontend component guide: https://docs.owncast.dev/develop-frontend-components
- API and routing: https://docs.owncast.dev/api-web-routing
- Localization: https://docs.owncast.dev/web-translations
- Contributing guide: https://docs.owncast.dev/contributor-guide
- Federation protocol: `FEDERATION.md`
-15
View File
@@ -7,18 +7,3 @@ We abide by our [Code of Conduct](https://owncast.online/contribute/) and feel s
We’ve been very lucky to have this so far, so maybe you can help us with your skills and passion, too! We’ve been very lucky to have this so far, so maybe you can help us with your skills and passion, too!
There is a larger, more detailed, and more up-to-date [guide for helping contribute to Owncast on our website](https://owncast.online/help/). There is a larger, more detailed, and more up-to-date [guide for helping contribute to Owncast on our website](https://owncast.online/help/).
## Before Submitting a PR
For web changes, from the `web/` directory:
```bash
npm run check # Verify your code will pass CI
npm run lint && npm run format # Auto-fix errors
```
For Go changes, from the repository root:
```bash
make lint && make fmt
```
+6 -16
View File
@@ -1,16 +1,9 @@
# IMPORTANT: This Dockerfile has been provided for the sake of convenience. # Perform a build
# Currently, functionality of the containers built based on this file
# is not a part of our continuous testing. Although, patches to keep it
# up to date are always welcome.
#
# See ‘Earthfile’ for the recipes used in official builds.
FROM golang:alpine AS build FROM golang:alpine AS build
RUN mkdir /build
RUN apk update && apk add --no-cache git gcc build-base linux-headers ADD . /build
WORKDIR /build WORKDIR /build
COPY . /build RUN apk update && apk add --no-cache git gcc build-base linux-headers
ARG VERSION=dev ARG VERSION=dev
ENV VERSION=${VERSION} ENV VERSION=${VERSION}
@@ -22,16 +15,13 @@ ENV NAME=${NAME}
RUN CGO_ENABLED=1 GOOS=linux go build -a -installsuffix cgo -ldflags "-extldflags \"-static\" -s -w -X github.com/owncast/owncast/config.GitCommit=$GIT_COMMIT -X github.com/owncast/owncast/config.VersionNumber=$VERSION -X github.com/owncast/owncast/config.BuildPlatform=$NAME" -o owncast . 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 # Create the image by copying the result of the build into a new alpine image
FROM alpine:3.23.3 FROM alpine
RUN apk update && apk add --no-cache ffmpeg ffmpeg-libs ca-certificates && update-ca-certificates 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 # Copy owncast assets
WORKDIR /app WORKDIR /app
COPY --from=build /build/owncast /app/owncast COPY --from=build /build/owncast /app/owncast
COPY --from=build /build/webroot /app/webroot
RUN mkdir /app/data RUN mkdir /app/data
RUN chown -R owncast:owncast /app
USER owncast
ENTRYPOINT ["/app/owncast"] ENTRYPOINT ["/app/owncast"]
EXPOSE 8080 1935 EXPOSE 8080 1935
+44 -127
View File
@@ -1,31 +1,30 @@
VERSION --new-platform 0.6 VERSION --new-platform 0.6
FROM --platform=linux/amd64 alpine:3.23.3 FROM --platform=linux/amd64 alpine:latest
ARG version=develop ARG version=develop
WORKDIR /build WORKDIR /build
build-all: build-all:
BUILD --platform=linux/amd64 --platform=linux/386 --platform=linux/arm64 --platform=linux/arm/v7 --platform=darwin/amd64 --platform=darwin/arm64 +build BUILD --platform=linux/amd64 --platform=linux/386 --platform=linux/arm64 --platform=linux/arm/v7 --platform=darwin/amd64 +build
package-all: package-all:
BUILD --platform=linux/amd64 --platform=linux/386 --platform=linux/arm64 --platform=linux/arm/v7 --platform=darwin/amd64 --platform=darwin/arm64 +package BUILD --platform=linux/amd64 --platform=linux/386 --platform=linux/arm64 --platform=linux/arm/v7 --platform=darwin/amd64 +package
docker-all: docker-all:
BUILD --platform=linux/amd64 --platform=linux/386 --platform=linux/arm64 --platform=linux/arm/v7 +docker BUILD --platform=linux/amd64 --platform=linux/386 --platform=linux/arm64 --platform=linux/arm/v7 +docker
crosscompiler: crosscompiler:
# This image is missing a few platforms, so we'll add them locally # This image is missing a few platforms, so we'll add them locally
FROM --platform=linux/amd64 ghcr.io/gabek/go-crosscompile:latest FROM --platform=linux/amd64 bdwyertech/go-crosscompile
RUN apk add --update --no-cache tar gzip upx >> /dev/null RUN curl -sfL "https://musl.cc/armv7l-linux-musleabihf-cross.tgz" | tar zxf - -C /usr/ --strip-components=1
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://musl.cc/i686-linux-musl-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://musl.cc/x86_64-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
RUN curl -sfL "https://owncast-infra.nyc3.cdn.digitaloceanspaces.com/build/aarch64-linux-musl-cross.tgz" | tar zxf - -C /usr/ --strip-components=1
code: code:
FROM --platform=linux/amd64 +crosscompiler FROM --platform=linux/amd64 +crosscompiler
COPY . /build COPY . /build
# GIT CLONE --branch=$version git@github.com:owncast/owncast.git /build
build: build:
ARG EARTHLY_GIT_HASH # provided by Earthly ARG EARTHLY_GIT_HASH # provided by Earthly
@@ -37,6 +36,7 @@ build:
FROM --platform=linux/amd64 +code FROM --platform=linux/amd64 +code
RUN echo $EARTHLY_GIT_HASH
RUN echo "Finding CC configuration for $TARGETPLATFORM" RUN echo "Finding CC configuration for $TARGETPLATFORM"
IF [ "$TARGETPLATFORM" = "linux/amd64" ] IF [ "$TARGETPLATFORM" = "linux/amd64" ]
ARG NAME=linux-64bit ARG NAME=linux-64bit
@@ -59,10 +59,6 @@ build:
ARG NAME=macOS-64bit ARG NAME=macOS-64bit
ARG CC=o64-clang ARG CC=o64-clang
ARG CXX=o64-clang++ ARG CXX=o64-clang++
ELSE IF [ "$TARGETPLATFORM" = "darwin/arm64" ]
ARG NAME=macOS-arm64
ARG CC=o64-clang
ARG CXX=o64-clang++
ELSE ELSE
RUN echo "Failed to find CC configuration for $TARGETPLATFORM" RUN echo "Failed to find CC configuration for $TARGETPLATFORM"
ARG --required CC ARG --required CC
@@ -78,46 +74,21 @@ build:
WORKDIR /build WORKDIR /build
# MacOSX disallows static executables, so we omit the static flag on this platform # 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 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" -o owncast main.go
COPY +tailwind/prod-tailwind.min.css /build/dist/webroot/js/web_modules/tailwindcss/dist/tailwind.min.css
# Decrease the size of the shipped binary. But only for non-Apple platforms. SAVE ARTIFACT owncast owncast
# See https://github.com/upx/upx/issues/612 SAVE ARTIFACT webroot webroot
IF [ "$GOOS" != "darwin" ] SAVE ARTIFACT README.md README.md
RUN upx --best --lzma owncast
# Test the binary integrity
RUN upx -t owncast
END
# Sanity check: verify the binary runs without immediate crash. tailwind:
# We can only run Linux binaries in this container. macOS binaries are skipped. FROM +code
# The check downloads a static ffmpeg (required dependency), starts the binary, WORKDIR /build/build/javascript
# waits 3 seconds, then verifies the process is still running. RUN apk add --update --no-cache npm >> /dev/null
IF [ "$GOOS" = "linux" ] ENV NODE_ENV=production
IF [ "$TARGETPLATFORM" = "linux/amd64" ] RUN cd /build/build/javascript && npm install --quiet --no-progress >> /dev/null && npm install -g cssnano postcss postcss-cli --quiet --no-progress --save-dev >> /dev/null && ./node_modules/.bin/tailwind build > /build/tailwind.min.css
# Native architecture - run directly RUN npx postcss /build/tailwind.min.css > /build/prod-tailwind.min.css
RUN curl -sL -o /usr/local/bin/ffmpeg https://github.com/eugeneware/ffmpeg-static/releases/download/b6.1.1/ffmpeg-linux-x64 && chmod +x /usr/local/bin/ffmpeg SAVE ARTIFACT /build/prod-tailwind.min.css prod-tailwind.min.css
RUN ./owncast & PID=$!; sleep 3; if kill -0 $PID 2>/dev/null; then kill $PID; echo "Sanity check passed: binary runs on linux/amd64"; else echo "Sanity check FAILED: binary crashed on linux/amd64"; exit 1; fi
ELSE IF [ "$TARGETPLATFORM" = "linux/arm64" ]
# ARM64 - use QEMU
RUN apk add --no-cache qemu-aarch64 >> /dev/null
RUN curl -sL -o /usr/local/bin/ffmpeg https://github.com/eugeneware/ffmpeg-static/releases/download/b6.1.1/ffmpeg-linux-arm64 && chmod +x /usr/local/bin/ffmpeg
RUN qemu-aarch64 ./owncast & PID=$!; sleep 3; if kill -0 $PID 2>/dev/null; then kill $PID; echo "Sanity check passed: binary runs on linux/arm64"; else echo "Sanity check FAILED: binary crashed on linux/arm64"; exit 1; fi
ELSE IF [ "$TARGETPLATFORM" = "linux/arm/v7" ]
# ARMv7 - use QEMU
RUN apk add --no-cache qemu-arm >> /dev/null
RUN curl -sL -o /usr/local/bin/ffmpeg https://github.com/eugeneware/ffmpeg-static/releases/download/b6.1.1/ffmpeg-linux-arm && chmod +x /usr/local/bin/ffmpeg
RUN qemu-arm ./owncast & PID=$!; sleep 3; if kill -0 $PID 2>/dev/null; then kill $PID; echo "Sanity check passed: binary runs on linux/arm/v7"; else echo "Sanity check FAILED: binary crashed on linux/arm/v7"; exit 1; fi
ELSE IF [ "$TARGETPLATFORM" = "linux/386" ]
# 32-bit x86 - use QEMU
RUN apk add --no-cache qemu-i386 >> /dev/null
RUN curl -sL -o /usr/local/bin/ffmpeg https://github.com/eugeneware/ffmpeg-static/releases/download/b6.1.1/ffmpeg-linux-ia32 && chmod +x /usr/local/bin/ffmpeg
RUN qemu-i386 ./owncast & PID=$!; sleep 3; if kill -0 $PID 2>/dev/null; then kill $PID; echo "Sanity check passed: binary runs on linux/386"; else echo "Sanity check FAILED: binary crashed on linux/386"; exit 1; fi
END
ELSE
RUN echo "Skipping sanity check for $TARGETPLATFORM (cannot execute on Linux)"
END
SAVE ARTIFACT --keep-ts owncast owncast
package: package:
RUN apk add --update --no-cache zip >> /dev/null RUN apk add --update --no-cache zip >> /dev/null
@@ -133,91 +104,37 @@ package:
ARG NAME=linux-arm7 ARG NAME=linux-arm7
ELSE IF [ "$TARGETPLATFORM" = "darwin/amd64" ] ELSE IF [ "$TARGETPLATFORM" = "darwin/amd64" ]
ARG NAME=macOS-64bit ARG NAME=macOS-64bit
ELSE IF [ "$TARGETPLATFORM" = "darwin/arm64" ]
ARG NAME=macOS-arm64
ELSE ELSE
ARG NAME=custom ARG NAME=custom
END END
COPY --keep-ts (+build/owncast --platform $TARGETPLATFORM) /build/dist/owncast COPY (+build/webroot --platform $TARGETPLATFORM) /build/dist/webroot
COPY (+build/owncast --platform $TARGETPLATFORM) /build/dist/owncast
COPY (+build/README.md --platform $TARGETPLATFORM) /build/dist/README.md
ENV ZIPNAME owncast-$version-$NAME.zip ENV ZIPNAME owncast-$version-$NAME.zip
RUN cd /build/dist && zip -r -q -8 /build/dist/owncast.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 SAVE ARTIFACT /build/dist/owncast.zip owncast.zip AS LOCAL dist/$ZIPNAME
docker-image:
# Internal target that builds the docker image. Used by +docker for testing.
ARG TARGETPLATFORM
FROM --platform=$TARGETPLATFORM alpine:3.23.3
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
docker: docker:
# Multiple image names can be tagged at once. They should all be passed ARG image=ghcr.io/owncast/owncast
# in as space separated strings using the full account/repo:tag format. ARG tag=develop
# https://github.com/earthly/earthly/blob/aea38448fa9c0064b1b70d61be717ae740689fb9/docs/earthfile/earthfile.md#assigning-multiple-image-names
ARG TARGETPLATFORM ARG TARGETPLATFORM
ARG images=ghcr.io/owncast/owncast:testing FROM --platform=$TARGETPLATFORM alpine:latest
RUN apk update && apk add --no-cache ffmpeg ffmpeg-libs ca-certificates unzip && update-ca-certificates
# Sanity check: run the container to verify it starts without crashing. WORKDIR /app
# Only run for linux/amd64 as other architectures would need QEMU emulation. COPY --platform=$TARGETPLATFORM +package/owncast.zip /app
IF [ "$TARGETPLATFORM" = "linux/amd64" ] RUN unzip -x owncast.zip && mkdir data
WITH DOCKER --load owncast:sanity-test=+docker-image ENTRYPOINT ["/app/owncast"]
RUN docker run -d --name owncast-sanity-test owncast:sanity-test && \ EXPOSE 8080 1935
sleep 5 && \ SAVE IMAGE --push $image:$tag
if docker ps | grep -q owncast-sanity-test; then \
echo "Docker sanity check passed: container runs on $TARGETPLATFORM"; \
docker stop owncast-sanity-test; \
else \
echo "Docker sanity check FAILED: container crashed on $TARGETPLATFORM"; \
docker logs owncast-sanity-test; \
exit 1; \
fi
END
ELSE
RUN echo "Skipping docker sanity check for $TARGETPLATFORM (only runs on linux/amd64)"
END
FROM --platform=$TARGETPLATFORM +docker-image
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 ghcr.io/gabek/go-crosscompile:latest
COPY . /build
WORKDIR /build
RUN go test ./...
api-tests: api-tests:
FROM --platform=linux/amd64 ghcr.io/gabek/go-crosscompile:latest FROM --platform=linux/amd64 +code
RUN apk add npm font-noto && fc-cache -f WORKDIR /build
COPY . /build RUN apk add npm ffmpeg
WORKDIR /build/test/automated/api RUN cd test/automated/api && npm install && ./run.sh
RUN npm install
RUN ./run.sh
hls-tests: unit-tests:
FROM --platform=linux/amd64 ghcr.io/gabek/go-crosscompile:latest FROM --platform=linux/amd64 +code
RUN apk add npm font-noto && fc-cache -f WORKDIR /build
COPY . /build RUN go test ./...
WORKDIR /build/test/automated/hls
RUN npm install
RUN ./run.sh
-134
View File
@@ -1,134 +0,0 @@
# Federation
This document describes how Owncast federates with other ActivityPub servers, following [FEP-67ff](https://codeberg.org/fediverse/fep/src/branch/main/fep/67ff/fep-67ff.md).
## Supported Federation Protocols and Standards
- [ActivityPub](https://www.w3.org/TR/activitypub/) (Server-to-Server)
- [WebFinger](https://webfinger.net/)
- [HTTP Signatures](https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures)
- [NodeInfo](https://nodeinfo.diaspora.software/)
## Supported FEPs
- [FEP-67ff: FEDERATION.md](https://codeberg.org/fediverse/fep/src/branch/main/fep/67ff/fep-67ff.md)
- [FEP-f1d5: NodeInfo in Fediverse Software](https://codeberg.org/fediverse/fep/src/branch/main/fep/f1d5/fep-f1d5.md)
## Actor
Owncast servers present as a single `Service` actor (not `Person`). Each Owncast instance has one actor representing the live stream.
The actor profile includes:
- `icon`: Server logo/avatar
- `image`: Profile banner image
- `summary`: Server description (HTML)
- `attachment`: Array of `PropertyValue` objects containing social links and metadata
- `tag`: Hashtags describing the server content
- `manuallyApprovesFollowers`: `true` if the server operates in private mode
- `discoverable`: Always `true`
Owncast actors do not follow other actors. The `following` collection endpoint returns 404.
## Activities
| Activity | Object | Send | Receive | Notes |
|----------|--------|:----:|:-------:|-------|
| `Create` | `Note` | Yes | No | Sent on go-live and manual fediverse posts. Incoming posts are ignored. |
| `Update` | `Service` | Yes | No | Sent when server profile changes. |
| `Update` | `Person` | No | Yes | Updates cached follower profile information. |
| `Follow` | - | No | Yes | Queued for approval if private mode is enabled. |
| `Accept` | `Follow` | Yes | No | Sent automatically unless in private mode. |
| `Undo` | `Follow` | No | Yes | Removes the follower. |
| `Like` | `Note` | No | Yes | Optionally displayed in live chat. |
| `Announce` | `Note` | No | Yes | Optionally displayed in live chat. |
Owncast is a broadcast-only service. It does not follow other actors or accept incoming posts.
## Notes
Posts from Owncast are `Note` objects with:
- `content`: HTML-formatted text with linked hashtags
- `attachment`: `Image` object with stream thumbnail (for go-live posts)
- `tag`: Array containing `Hashtag` and `Mention` objects
- `sensitive`: `true` if the stream is marked NSFW
### Go-Live Announcements
When a stream starts, Owncast sends a `Create(Note)` containing:
- The configured go-live message (or default announcement)
- A thumbnail image of the stream
- Hashtags configured for the server
- A link to watch the stream
### Hashtags
Hashtags use `toot:Hashtag` type and link to `https://directory.owncast.online/tags/{tag}` for discovery across Owncast instances.
## Addressing
Public posts are addressed to:
```json
{
"to": ["https://www.w3.org/ns/activitystreams#Public"],
"cc": ["{actor}/followers"]
}
```
Owncast supports sending direct messages to specific actors (used for replying to engagement). These include the recipient in `cc` with a corresponding `Mention` tag for Mastodon compatibility.
## HTTP Signatures
**Outbound**: All POST requests are signed using RSA-SHA256. Signed headers: `(request-target)`, `host`, `date`, `digest`.
**Inbound**: All POST requests to the inbox must have a valid signature. Unsigned requests are rejected.
**GET requests** to actor, outbox, and followers endpoints do not require signatures.
## WebFinger
Actor discovery via `/.well-known/webfinger?resource=acct:{username}@{domain}`
Response links include:
- `self` (application/activity+json): Actor document
- `http://webfinger.net/rel/profile-page`: Web profile
- `http://webfinger.net/rel/avatar`: Profile image
- `alternate` (application/x-mpegURL): HLS stream URL
The HLS stream link allows clients to discover the live stream URL directly from WebFinger.
## NodeInfo
Available at `/.well-known/nodeinfo` with NodeInfo 2.0 at `/nodeinfo/2.0`.
Additional endpoints:
- `/.well-known/x-nodeinfo2`: Extended format
- `/api/v1/instance`: Mastodon-compatible instance information
- `/.well-known/host-meta`: WebFinger discovery
## Private Mode
Owncast can operate in private mode where:
- `manuallyApprovesFollowers` is `true` on the actor
- Follow requests are queued for admin approval
- `Accept` is only sent after manual approval
## Blocking
Owncast supports blocking at the domain and individual actor level. Blocked entities:
- Cannot deliver to the inbox (requests rejected)
- Do not receive activities from the server
## Interoperability Notes
- Owncast uses `Service` actor type, not `Person`
- There is no `following` collection (Owncast does not follow accounts)
- Owncast does not accept incoming posts (`Create` from remote actors is ignored)
- Engagement (`Like`, `Announce`) can be displayed in the live chat if enabled
- The `sensitive` flag indicates NSFW content for the entire stream
## Additional Resources
- [Owncast Documentation](https://owncast.online/docs/)
- [ActivityPub Specification](https://www.w3.org/TR/activitypub/)
- [Fediverse Enhancement Proposals](https://codeberg.org/fediverse/fep)
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License 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 Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
-82
View File
@@ -1,82 +0,0 @@
# Development tools are managed in tools/go.mod and installed to ./bin
GOBIN := $(shell pwd)/bin
# Tool binaries
LEFTHOOK := $(GOBIN)/lefthook
GOLANGCI_LINT := $(GOBIN)/golangci-lint
GOFUMPT := $(GOBIN)/gofumpt
SQLC := $(GOBIN)/sqlc
OAPI_CODEGEN := $(GOBIN)/oapi-codegen
.PHONY: install-tools install-hooks lint fmt sqlc api-generate build test clean
## Install all development tools to ./bin
install-tools: $(LEFTHOOK) $(GOLANGCI_LINT) $(GOFUMPT) $(SQLC) $(OAPI_CODEGEN)
$(LEFTHOOK):
GOBIN=$(GOBIN) go install -C tools github.com/evilmartians/lefthook
$(GOLANGCI_LINT):
GOBIN=$(GOBIN) go install -C tools github.com/golangci/golangci-lint/v2/cmd/golangci-lint
$(GOFUMPT):
GOBIN=$(GOBIN) go install -C tools mvdan.cc/gofumpt
$(SQLC):
GOBIN=$(GOBIN) go install -C tools github.com/sqlc-dev/sqlc/cmd/sqlc
$(OAPI_CODEGEN):
GOBIN=$(GOBIN) go install -C tools github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen
## Install git hooks using lefthook
install-hooks: $(LEFTHOOK)
$(LEFTHOOK) install
@echo "Patching hooks to use ./bin/lefthook..."
@for hook in .git/hooks/*; do \
if [ -f "$$hook" ] && grep -q 'call_lefthook' "$$hook" && ! grep -q 'export LEFTHOOK_BIN' "$$hook"; then \
sed -i.bak 's|call_lefthook|export LEFTHOOK_BIN="$$(git rev-parse --show-toplevel)/bin/lefthook"; call_lefthook|' "$$hook" && rm -f "$$hook.bak"; \
fi \
done
@echo "Hooks installed successfully"
## Run golangci-lint
lint: $(GOLANGCI_LINT)
$(GOLANGCI_LINT) run ./...
## Format Go code with gofumpt
fmt: $(GOFUMPT)
$(GOFUMPT) -l -w .
## Generate database models with sqlc
sqlc: $(SQLC)
$(SQLC) generate
## Generate API code from OpenAPI spec
api-generate: $(OAPI_CODEGEN)
./build/gen-api.sh
## Build the application
build:
go build -o owncast .
## Run tests
test:
go test ./...
## Clean build artifacts
clean:
rm -rf bin/
rm -f owncast
## Show help
help:
@echo "Available targets:"
@echo " install-tools - Install all development tools to ./bin"
@echo " install-hooks - Install git hooks using lefthook"
@echo " lint - Run golangci-lint"
@echo " fmt - Format Go code with gofumpt"
@echo " sqlc - Generate database models"
@echo " api-generate - Generate API code from OpenAPI spec"
@echo " build - Build the application"
@echo " test - Run tests"
@echo " clean - Remove build artifacts and tools"
+46 -102
View File
@@ -1,47 +1,37 @@
<br />
<p align="center"> <p align="center">
<a href="https://github.com/owncast/owncast" alt="Owncast"> <a href="https://github.com/owncast/owncast" alt="Owncast">
<img src="https://owncast.online/images/logo.png" alt="Owncast Logo" width="200"> <img src="https://owncast.online/images/logo.png" alt="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" />
</a> </a>
</p> </p>
<br/> <br/>
<p align="center"> <p align="center">
<a href="https://owncast.online"><strong>Explore the docs »</strong></a> <strong>Take control over your content and stream it yourself.</strong>
<br /> <br />
<a href="https://watch.owncast.online/">View Demo</a> <a href="http://owncast.online"><strong>Explore the docs »</strong></a>
· <br />
<a href="https://owncast.online/faq/">FAQ</a> <a href="https://watch.owncast.online/">View Demo</a>
· ·
<a href="https://github.com/owncast/owncast/issues">Report Bug</a> <a href="https://broadcast.owncast.online/">Use Our Server for Testing</a>
·
<a href="https://owncast.online/faq/">FAQ</a>
·
<a href="https://github.com/owncast/owncast/issues">Report Bug</a>
</p>
</p> </p>
<!-- TABLE OF CONTENTS --> <!-- TABLE OF CONTENTS -->
## Table of Contents ## Table of Contents
- 📒 [About the Project](#about-the-project) - [About the Project](#about-the-project)
- 🚀 [Getting Started](#getting-started) - [Getting Started](#getting-started)
- 👨‍💻 [Use with your broadcasting software](#use-with-your-existing-broadcasting-software) - [Use with your broadcasting software](#use-with-your-existing-broadcasting-software)
- 🛠 [Building from source](#building-from-source) - [Building from source](#building-from-source)
- 🚨 [Important note about source code and the develop branch](#important-note-about-source-code-and-the-develop-branch) - [Contributing](#contributing)
- 🗄️ [Backend](#backend) - [License](#license)
- ⚛️ [Frontend](#frontend)
- 👏 [Contributing](#contributing)
- 💵 [Donors](#donors)
- 📝 [License](#license)
- [Contact](#contact) - [Contact](#contact)
<!-- ABOUT THE PROJECT --> <!-- ABOUT THE PROJECT -->
@@ -54,12 +44,12 @@
</a> </a>
</p> </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> <div>
<img alt="GitHub all releases" src="https://img.shields.io/github/downloads/owncast/owncast/total?style=for-the-badge"> <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"> <a href="https://hub.docker.com/r/gabekangas/owncast">
<img alt="Docker Pulls" src="https://img.shields.io/docker/pulls/owncast/owncast?style=for-the-badge"> <img alt="Docker Pulls" src="https://img.shields.io/docker/pulls/gabekangas/owncast?style=for-the-badge">
</a> </a>
<a href="https://github.com/owncast/owncast/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22"> <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"> <img alt="GitHub issues by-label" src="https://img.shields.io/github/issues-raw/owncast/owncast/good%20first%20issue?style=for-the-badge">
@@ -69,6 +59,7 @@ Owncast is an open source, self-hosted, decentralized, single user live video st
</a> </a>
</div> </div>
--- ---
<!-- GETTING STARTED --> <!-- GETTING STARTED -->
@@ -79,45 +70,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 ## 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/). 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 ## Building from Source
Owncast consists of two projects. 1. Ensure you have the gcc compiler installed.
1. Install the [Go toolchain](https://golang.org/dl/) (1.16 or above).
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.24 or above).
1. Clone the repo. `git clone https://github.com/owncast/owncast` 1. Clone the repo. `git clone https://github.com/owncast/owncast`
1. `go run main.go` will run from the source. 1. `go run main.go` will run from source.
1. Visit `http://yourserver:8080` to access the web interface or `http://yourserver:8080/admin` to access the admin. 1. Visit `http://yourserver:8080` to access the web interface or `http://yourserver:8080/admin` to access the admin.
1. Point your [broadcasting software](https://owncast.online/docs/broadcasting/) at your new server and start streaming. 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. The admin ui is built at: https://github.com/owncast/owncast-admin it is bundled into the final binary using pkger.
1. Run `npm install` to install the Javascript dependencies.
1. Run `npm run dev` To bundle in the latest admin UI:
1. From the owncast directory run the packager script: `./build/admin/bundleAdmin.sh`
1. Compile or run like above. `go run main.go`
## Contributing ## Contributing
@@ -127,20 +102,18 @@ 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 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! 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 [![Good First Issue](https://img.shields.io/github/issues/owncast/owncast/good%20first%20issue.svg)](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/). 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 ### Architecture
The Owncast project is possible thanks to the people who make a donation to support us and our work. 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.
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).
### 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`.
<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 -->
@@ -148,41 +121,12 @@ Thank you to all our donors who help keep Owncast running by donating on OpenCol
Distributed under the MIT License. See `LICENSE` for more information. 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>
<li>Infrastructure and hosting by
<a href="https://digitalocean.com?utm_medium=opensource&utm_source=owncast" target="_blank">
<img src="https://owncast.online/images/sponsors/digitalocean.svg" height="26" style="vertical-align: middle;margin-left:5px">
</a>
</li>
</ul>
<!-- CONTACT --> <!-- CONTACT -->
## 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. Project chat: [Join us on Rocket.Chat](https://owncast.rocket.chat/home) if you want to contribute, follow along, or if you have questions.
Gabe Kangas - [@gabek@social.gabekangas.com](https://social.gabekangas.com/gabek) - email [gabek@real-ity.com](mailto:gabek@real-ity.com) Gabe Kangas - [@gabek@fosstodon.org](https://fosstodon.org/@gabek) - email [gabek@real-ity.com](mailto:gabek@real-ity.com)
Project Link: [https://github.com/owncast/owncast](https://github.com/owncast/owncast) Project Link: [https://github.com/owncast/owncast](https://github.com/owncast/owncast)
+7 -47
View File
@@ -3,12 +3,9 @@ package activitypub
import ( import (
"github.com/owncast/owncast/activitypub/crypto" "github.com/owncast/owncast/activitypub/crypto"
"github.com/owncast/owncast/activitypub/inbox" "github.com/owncast/owncast/activitypub/inbox"
"github.com/owncast/owncast/activitypub/jobs"
"github.com/owncast/owncast/activitypub/outbox" "github.com/owncast/owncast/activitypub/outbox"
"github.com/owncast/owncast/activitypub/persistence" "github.com/owncast/owncast/activitypub/persistence"
"github.com/owncast/owncast/activitypub/persistence/followersrepository"
"github.com/owncast/owncast/activitypub/workerpool" "github.com/owncast/owncast/activitypub/workerpool"
"github.com/owncast/owncast/persistence/configrepository"
"github.com/owncast/owncast/core/data" "github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/models" "github.com/owncast/owncast/models"
@@ -17,55 +14,20 @@ import (
// Start will initialize and start the federation support. // Start will initialize and start the federation support.
func Start(datastore *data.Datastore) { func Start(datastore *data.Datastore) {
configRepository := configrepository.Get()
persistence.Setup(datastore) persistence.Setup(datastore)
workerpool.InitOutboundWorkerPool()
outboundWorkerPoolSize := getOutboundWorkerPoolSize()
workerpool.InitOutboundWorkerPool(outboundWorkerPoolSize)
inbox.InitInboxWorkerPool() inbox.InitInboxWorkerPool()
StartRouter()
// Generate the keys for signing federated activity if needed. // Generate the keys for signing federated activity if needed.
if configRepository.GetPrivateKey() == "" { if data.GetPrivateKey() == "" {
privateKey, publicKey, err := crypto.GenerateKeys() privateKey, publicKey, err := crypto.GenerateKeys()
_ = configRepository.SetPrivateKey(string(privateKey)) _ = data.SetPrivateKey(string(privateKey))
_ = configRepository.SetPublicKey(string(publicKey)) _ = data.SetPublicKey(string(publicKey))
if err != nil { if err != nil {
log.Errorln("Unable to get private key", err) log.Errorln("Unable to get private key", err)
} }
} }
// Start follower validation background job.
jobs.StartFollowerValidationJob()
}
func getOutboundWorkerPoolSize() int {
// Use a reasonable fixed worker pool size instead of scaling with followers
// This prevents excessive resource usage when streamers have many followers
const (
minWorkers = 10 // Minimum workers for small instances
maxWorkers = 50 // Maximum workers to prevent resource exhaustion
defaultWorkers = 20 // Default for most instances
)
followersRepo := followersrepository.Get()
var followerCount int64
fc, err := followersRepo.GetCount()
if err != nil {
log.Errorln("Unable to get follower count", err)
return defaultWorkers
}
followerCount = fc
// Scale more conservatively: start with base workers, add 1 worker per 100 followers
// This gives a much more reasonable scaling than the previous followerCount * 5
workers := minWorkers + int(followerCount/100)
if workers > maxWorkers {
workers = maxWorkers
}
log.Debugf("Initializing ActivityPub outbound worker pool with %d workers for %d followers", workers, followerCount)
return workers
} }
// SendLive will send a "Go Live" message to followers. // SendLive will send a "Go Live" message to followers.
@@ -85,12 +47,10 @@ func SendDirectFederatedMessage(message, account string) error {
// GetFollowerCount will return the local tracked follower count. // GetFollowerCount will return the local tracked follower count.
func GetFollowerCount() (int64, error) { func GetFollowerCount() (int64, error) {
followersRepo := followersrepository.Get() return persistence.GetFollowerCount()
return followersRepo.GetCount()
} }
// GetPendingFollowRequests will return the pending follow requests. // GetPendingFollowRequests will return the pending follow requests.
func GetPendingFollowRequests() ([]models.Follower, error) { func GetPendingFollowRequests() ([]models.Follower, error) {
followersRepo := followersrepository.Get() return persistence.GetPendingFollowRequests()
return followersRepo.GetPendingFollowRequests()
} }
+3 -21
View File
@@ -6,7 +6,7 @@ import (
"github.com/go-fed/activity/streams" "github.com/go-fed/activity/streams"
"github.com/go-fed/activity/streams/vocab" "github.com/go-fed/activity/streams/vocab"
"github.com/owncast/owncast/persistence/configrepository" "github.com/owncast/owncast/core/data"
) )
// PrivacyAudience represents the audience for an activity. // PrivacyAudience represents the audience for an activity.
@@ -31,20 +31,6 @@ func MakeNotePublic(note vocab.ActivityStreamsNote) vocab.ActivityStreamsNote {
return note return note
} }
func MakeAddressingToFollowers(followers_iri *url.URL, public bool) (vocab.ActivityStreamsToProperty, vocab.ActivityStreamsCcProperty) {
to := streams.NewActivityStreamsToProperty()
cc := streams.NewActivityStreamsCcProperty()
if public {
public, _ := url.Parse(PUBLIC)
to.AppendIRI(public)
cc.AppendIRI(followers_iri)
} else {
to.AppendIRI(followers_iri)
}
return to, cc
}
// MakeNoteDirect sets the required properties to make this note seen as a // MakeNoteDirect sets the required properties to make this note seen as a
// direct message. // direct message.
func MakeNoteDirect(note vocab.ActivityStreamsNote, toIRI *url.URL) vocab.ActivityStreamsNote { func MakeNoteDirect(note vocab.ActivityStreamsNote, toIRI *url.URL) vocab.ActivityStreamsNote {
@@ -101,10 +87,8 @@ func MakeActivityDirect(activity vocab.ActivityStreamsCreate, toIRI *url.URL) vo
// MakeActivityPublic sets the required properties to make this activity // MakeActivityPublic sets the required properties to make this activity
// seen as public. // seen as public.
func MakeActivityPublic(activity vocab.ActivityStreamsCreate) vocab.ActivityStreamsCreate { func MakeActivityPublic(activity vocab.ActivityStreamsCreate) vocab.ActivityStreamsCreate {
configRepository := configrepository.Get()
// TO the public if we're not treating ActivityPub as "private". // TO the public if we're not treating ActivityPub as "private".
if !configRepository.GetFederationIsPrivate() { if !data.GetFederationIsPrivate() {
public, _ := url.Parse(PUBLIC) public, _ := url.Parse(PUBLIC)
to := streams.NewActivityStreamsToProperty() to := streams.NewActivityStreamsToProperty()
@@ -137,9 +121,7 @@ func MakeUpdateActivity(activityID *url.URL) vocab.ActivityStreamsUpdate {
activity.SetJSONLDId(id) activity.SetJSONLDId(id)
// CC the public if we're not treating ActivityPub as "private". // CC the public if we're not treating ActivityPub as "private".
configRepository := configrepository.Get() if !data.GetFederationIsPrivate() {
if !configRepository.GetFederationIsPrivate() {
public, _ := url.Parse(PUBLIC) public, _ := url.Parse(PUBLIC)
cc := streams.NewActivityStreamsCcProperty() cc := streams.NewActivityStreamsCcProperty()
cc.AppendIRI(public) cc.AppendIRI(public)
+59 -175
View File
@@ -9,185 +9,33 @@ import (
"github.com/go-fed/activity/streams" "github.com/go-fed/activity/streams"
"github.com/go-fed/activity/streams/vocab" "github.com/go-fed/activity/streams/vocab"
"github.com/owncast/owncast/activitypub/crypto" "github.com/owncast/owncast/activitypub/crypto"
"github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/models" "github.com/owncast/owncast/models"
"github.com/owncast/owncast/persistence/configrepository"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
// ActivityPubActor represents a single actor in handling ActivityPub activity. // ActivityPubActor represents a single actor in handling ActivityPub activity.
type ActivityPubActor struct { 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 is the IRI of the remote actor.
ActorIri *url.URL ActorIri *url.URL
// FollowRequestIRI is the unique identifier of the follow request. // FollowRequestIRI is the unique identifier of the follow request.
FollowRequestIri *url.URL FollowRequestIri *url.URL
// Inbox is the inbox URL of the remote follower // Inbox is the inbox URL of the remote follower
Inbox *url.URL Inbox *url.URL
// SharedInbox is the shared inbox URL of the remote server (optional)
SharedInbox *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 is the display name of the follower.
Name string Name string
// Username is the account username of the remote actor. // Username is the account username of the remote actor.
Username string Username string
// FullUsername is the username@account.tld representation of the user. // FullUsername is the username@account.tld representation of the user.
FullUsername string FullUsername string
} // Image is the avatar image of the Actor.
Image *url.URL
// ErrActorMissingRequiredField is returned when an actor is missing a required field. // RequestObject is the actual follow request object.
var ErrActorMissingRequiredField = errors.New("actor missing required field") RequestObject vocab.ActivityStreamsFollow
// W3IDSecurityV1PublicKey is the public key of the actor.
// Validate checks that required fields are present on the actor. W3IDSecurityV1PublicKey vocab.W3IDSecurityV1PublicKeyProperty
// Returns an error if ActorIri or Inbox are nil. // DisabledAt is the time, if any, this follower was blocked/removed.
func (a *ActivityPubActor) Validate() error { DisabledAt *time.Time
if a.ActorIri == nil {
return fmt.Errorf("%w: ActorIri is required", ErrActorMissingRequiredField)
}
if a.Inbox == nil {
return fmt.Errorf("%w: Inbox is required", ErrActorMissingRequiredField)
}
return nil
}
// IsValid returns true if the actor has all required fields.
func (a *ActivityPubActor) IsValid() bool {
return a.Validate() == nil
}
// ActorIriString returns the string representation of ActorIri, or empty string if nil.
func (a *ActivityPubActor) ActorIriString() string {
if a.ActorIri == nil {
return ""
}
return a.ActorIri.String()
}
// InboxString returns the string representation of Inbox, or empty string if nil.
func (a *ActivityPubActor) InboxString() string {
if a.Inbox == nil {
return ""
}
return a.Inbox.String()
}
// SharedInboxString returns the string representation of SharedInbox, or empty string if nil.
func (a *ActivityPubActor) SharedInboxString() string {
if a.SharedInbox == nil {
return ""
}
return a.SharedInbox.String()
}
// ImageString returns the string representation of Image, or empty string if nil.
func (a *ActivityPubActor) ImageString() string {
if a.Image == nil {
return ""
}
return a.Image.String()
}
// FollowRequestIriString returns the string representation of FollowRequestIri, or empty string if nil.
func (a *ActivityPubActor) FollowRequestIriString() string {
if a.FollowRequestIri == nil {
return ""
}
return a.FollowRequestIri.String()
}
// ActorIriHostname returns the hostname of ActorIri, or empty string if nil.
func (a *ActivityPubActor) ActorIriHostname() string {
if a.ActorIri == nil {
return ""
}
return a.ActorIri.Hostname()
}
// NewActivityPubActor creates a new ActivityPubActor with required fields.
// Returns an error if actorIri or inbox are nil.
func NewActivityPubActor(actorIri, inbox *url.URL) (*ActivityPubActor, error) {
if actorIri == nil {
return nil, fmt.Errorf("%w: actorIri is required", ErrActorMissingRequiredField)
}
if inbox == nil {
return nil, fmt.Errorf("%w: inbox is required", ErrActorMissingRequiredField)
}
return &ActivityPubActor{
ActorIri: actorIri,
Inbox: inbox,
}, nil
}
// validateEntityRequiredFields checks that all required fields are present on the entity.
func validateEntityRequiredFields(entity ExternalEntity) error {
if entity.GetJSONLDId() == nil || entity.GetJSONLDId().Get() == nil {
return fmt.Errorf("%w: entity is missing actor IRI", ErrActorMissingRequiredField)
}
if entity.GetActivityStreamsInbox() == nil || entity.GetActivityStreamsInbox().GetIRI() == nil {
return fmt.Errorf("%w: entity is missing inbox", ErrActorMissingRequiredField)
}
if entity.GetActivityStreamsPreferredUsername() == nil || entity.GetActivityStreamsPreferredUsername().GetXMLSchemaString() == "" {
return fmt.Errorf("%w: entity is missing preferred username", ErrActorMissingRequiredField)
}
if entity.GetW3IDSecurityV1PublicKey() == nil || entity.GetW3IDSecurityV1PublicKey().Len() == 0 {
return fmt.Errorf("%w: entity is missing public key", ErrActorMissingRequiredField)
}
return nil
}
// getNameFromEntity extracts the optional name from an entity.
func getNameFromEntity(entity ExternalEntity) string {
nameProp := entity.GetActivityStreamsName()
if nameProp == nil || nameProp.Empty() {
return ""
}
return nameProp.At(0).GetXMLSchemaString()
}
// getSharedInboxFromEntity extracts the optional shared inbox URL from an entity.
func getSharedInboxFromEntity(entity ExternalEntity) *url.URL {
endpointsProp := entity.GetActivityStreamsEndpoints()
if endpointsProp == nil || !endpointsProp.IsActivityStreamsEndpoints() {
return nil
}
endpoints := endpointsProp.Get()
if endpoints == nil {
return nil
}
sharedInboxProp := endpoints.GetActivityStreamsSharedInbox()
if sharedInboxProp == nil || !sharedInboxProp.HasAny() {
return nil
}
return sharedInboxProp.Get()
}
// NewActivityPubActorFromEntity creates a new ActivityPubActor from an external entity
// with validation of required fields.
func NewActivityPubActorFromEntity(entity ExternalEntity) (*ActivityPubActor, error) {
if err := validateEntityRequiredFields(entity); err != nil {
return nil, err
}
apActor := &ActivityPubActor{
ActorIri: entity.GetJSONLDId().Get(),
Inbox: entity.GetActivityStreamsInbox().GetIRI(),
SharedInbox: getSharedInboxFromEntity(entity),
Name: getNameFromEntity(entity),
Username: entity.GetActivityStreamsPreferredUsername().GetXMLSchemaString(),
FullUsername: GetFullUsernameFromExternalEntity(entity),
W3IDSecurityV1PublicKey: entity.GetW3IDSecurityV1PublicKey(),
Image: GetImageFromIcon(entity.GetActivityStreamsIcon()),
}
return apActor, nil
} }
// DeleteRequest represents a request for delete. // DeleteRequest represents a request for delete.
@@ -203,7 +51,45 @@ type ExternalEntity interface {
GetActivityStreamsPreferredUsername() vocab.ActivityStreamsPreferredUsernameProperty GetActivityStreamsPreferredUsername() vocab.ActivityStreamsPreferredUsernameProperty
GetActivityStreamsIcon() vocab.ActivityStreamsIconProperty GetActivityStreamsIcon() vocab.ActivityStreamsIconProperty
GetW3IDSecurityV1PublicKey() vocab.W3IDSecurityV1PublicKeyProperty GetW3IDSecurityV1PublicKey() vocab.W3IDSecurityV1PublicKeyProperty
GetActivityStreamsEndpoints() vocab.ActivityStreamsEndpointsProperty }
// 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. // MakeActorPropertyWithID will return an actor property filled with the provided IRI.
@@ -215,13 +101,11 @@ func MakeActorPropertyWithID(idIRI *url.URL) vocab.ActivityStreamsActorProperty
// MakeServiceForAccount will create a new local actor service with the the provided username. // MakeServiceForAccount will create a new local actor service with the the provided username.
func MakeServiceForAccount(accountName string) vocab.ActivityStreamsService { func MakeServiceForAccount(accountName string) vocab.ActivityStreamsService {
configRepository := configrepository.Get()
actorIRI := MakeLocalIRIForAccount(accountName) actorIRI := MakeLocalIRIForAccount(accountName)
person := streams.NewActivityStreamsService() person := streams.NewActivityStreamsService()
nameProperty := streams.NewActivityStreamsNameProperty() nameProperty := streams.NewActivityStreamsNameProperty()
nameProperty.AppendXMLSchemaString(configRepository.GetServerName()) nameProperty.AppendXMLSchemaString(data.GetServerName())
person.SetActivityStreamsName(nameProperty) person.SetActivityStreamsName(nameProperty)
preferredUsernameProperty := streams.NewActivityStreamsPreferredUsernameProperty() preferredUsernameProperty := streams.NewActivityStreamsPreferredUsernameProperty()
@@ -235,7 +119,7 @@ func MakeServiceForAccount(accountName string) vocab.ActivityStreamsService {
person.SetActivityStreamsInbox(inboxProp) person.SetActivityStreamsInbox(inboxProp)
needsFollowApprovalProperty := streams.NewActivityStreamsManuallyApprovesFollowersProperty() needsFollowApprovalProperty := streams.NewActivityStreamsManuallyApprovesFollowersProperty()
needsFollowApprovalProperty.Set(configRepository.GetFederationIsPrivate()) needsFollowApprovalProperty.Set(data.GetFederationIsPrivate())
person.SetActivityStreamsManuallyApprovesFollowers(needsFollowApprovalProperty) person.SetActivityStreamsManuallyApprovesFollowers(needsFollowApprovalProperty)
outboxIRI := MakeLocalIRIForResource("/user/" + accountName + "/outbox") outboxIRI := MakeLocalIRIForResource("/user/" + accountName + "/outbox")
@@ -268,7 +152,7 @@ func MakeServiceForAccount(accountName string) vocab.ActivityStreamsService {
publicKeyProp.AppendW3IDSecurityV1PublicKey(publicKeyType) publicKeyProp.AppendW3IDSecurityV1PublicKey(publicKeyType)
person.SetW3IDSecurityV1PublicKey(publicKeyProp) person.SetW3IDSecurityV1PublicKey(publicKeyProp)
if t, err := configRepository.GetServerInitTime(); t != nil { if t, err := data.GetServerInitTime(); t != nil {
publishedDateProp := streams.NewActivityStreamsPublishedProperty() publishedDateProp := streams.NewActivityStreamsPublishedProperty()
publishedDateProp.Set(t.Time) publishedDateProp.Set(t.Time)
person.SetActivityStreamsPublished(publishedDateProp) person.SetActivityStreamsPublished(publishedDateProp)
@@ -279,8 +163,8 @@ func MakeServiceForAccount(accountName string) vocab.ActivityStreamsService {
// Profile properties // Profile properties
// Avatar // Avatar
uniquenessString := configRepository.GetLogoUniquenessString() uniquenessString := data.GetLogoUniquenessString()
userAvatarURLString := configRepository.GetServerURL() + "/logo/external" userAvatarURLString := data.GetServerURL() + "/logo/external"
userAvatarURL, err := url.Parse(userAvatarURLString) userAvatarURL, err := url.Parse(userAvatarURLString)
userAvatarURL.RawQuery = "uc=" + uniquenessString userAvatarURL.RawQuery = "uc=" + uniquenessString
if err != nil { if err != nil {
@@ -311,14 +195,14 @@ func MakeServiceForAccount(accountName string) vocab.ActivityStreamsService {
// Profile bio // Profile bio
summaryProperty := streams.NewActivityStreamsSummaryProperty() summaryProperty := streams.NewActivityStreamsSummaryProperty()
summaryProperty.AppendXMLSchemaString(configRepository.GetServerSummary()) summaryProperty.AppendXMLSchemaString(data.GetServerSummary())
person.SetActivityStreamsSummary(summaryProperty) person.SetActivityStreamsSummary(summaryProperty)
// Links // Links
if serverURL := configRepository.GetServerURL(); serverURL != "" { if serverURL := data.GetServerURL(); serverURL != "" {
addMetadataLinkToProfile(person, "Stream", serverURL) addMetadataLinkToProfile(person, "Stream", serverURL)
} }
for _, link := range configRepository.GetSocialHandles() { for _, link := range data.GetSocialHandles() {
addMetadataLinkToProfile(person, link.Platform, link.URL) addMetadataLinkToProfile(person, link.Platform, link.URL)
} }
@@ -336,7 +220,7 @@ func MakeServiceForAccount(accountName string) vocab.ActivityStreamsService {
// Tags // Tags
tagProp := streams.NewActivityStreamsTagProperty() tagProp := streams.NewActivityStreamsTagProperty()
for _, tagString := range configRepository.GetServerMetadataTags() { for _, tagString := range data.GetServerMetadataTags() {
hashtag := MakeHashtag(tagString) hashtag := MakeHashtag(tagString)
tagProp.AppendTootHashtag(hashtag) tagProp.AppendTootHashtag(hashtag)
} }
@@ -345,7 +229,7 @@ func MakeServiceForAccount(accountName string) vocab.ActivityStreamsService {
// Work around an issue where a single attachment will not serialize // Work around an issue where a single attachment will not serialize
// as an array, so add another item to the mix. // as an array, so add another item to the mix.
if len(configRepository.GetSocialHandles()) == 1 { if len(data.GetSocialHandles()) == 1 {
addMetadataLinkToProfile(person, "Owncast", "https://owncast.online") addMetadataLinkToProfile(person, "Owncast", "https://owncast.online")
} }
@@ -355,7 +239,7 @@ func MakeServiceForAccount(accountName string) vocab.ActivityStreamsService {
// GetFullUsernameFromExternalEntity will return the full username from an // GetFullUsernameFromExternalEntity will return the full username from an
// internal representation of an ExternalEntity. Returns user@host.tld. // internal representation of an ExternalEntity. Returns user@host.tld.
func GetFullUsernameFromExternalEntity(entity ExternalEntity) string { func GetFullUsernameFromExternalEntity(entity ExternalEntity) string {
hostname := GetHostnameFromJSONLDId(entity.GetJSONLDId()) hostname := entity.GetJSONLDId().GetIRI().Hostname()
username := entity.GetActivityStreamsPreferredUsername().GetXMLSchemaString() username := entity.GetActivityStreamsPreferredUsername().GetXMLSchemaString()
fullUsername := fmt.Sprintf("%s@%s", username, hostname) fullUsername := fmt.Sprintf("%s@%s", username, hostname)
+32 -470
View File
@@ -1,7 +1,6 @@
package apmodels package apmodels
import ( import (
"errors"
"io/ioutil" "io/ioutil"
"net/url" "net/url"
"os" "os"
@@ -10,7 +9,6 @@ import (
"github.com/go-fed/activity/streams" "github.com/go-fed/activity/streams"
"github.com/go-fed/activity/streams/vocab" "github.com/go-fed/activity/streams/vocab"
"github.com/owncast/owncast/core/data" "github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/persistence/configrepository"
) )
func makeFakeService() vocab.ActivityStreamsService { func makeFakeService() vocab.ActivityStreamsService {
@@ -47,8 +45,6 @@ func makeFakeService() vocab.ActivityStreamsService {
service.SetActivityStreamsIcon(icon) service.SetActivityStreamsIcon(icon)
publicKeyProperty := streams.NewW3IDSecurityV1PublicKeyProperty() publicKeyProperty := streams.NewW3IDSecurityV1PublicKeyProperty()
publicKeyType := streams.NewW3IDSecurityV1PublicKey()
publicKeyProperty.AppendW3IDSecurityV1PublicKey(publicKeyType)
service.SetW3IDSecurityV1PublicKey(publicKeyProperty) service.SetW3IDSecurityV1PublicKey(publicKeyProperty)
return service return service
@@ -59,15 +55,41 @@ func TestMain(m *testing.M) {
if err != nil { if err != nil {
panic(err) panic(err)
} }
data.SetupPersistence(dbFile.Name()) data.SetupPersistence(dbFile.Name())
data.SetServerURL("https://my.cool.site.biz")
configRepository := configrepository.Get()
configRepository.SetServerURL("https://my.cool.site.biz")
m.Run() 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) { func TestMakeActorPropertyWithID(t *testing.T) {
iri, _ := url.Parse("https://fake.fediverse.server/user/mrfoo") iri, _ := url.Parse("https://fake.fediverse.server/user/mrfoo")
actor := MakeActorPropertyWithID(iri) actor := MakeActorPropertyWithID(iri)
@@ -131,7 +153,7 @@ func TestMakeServiceForAccount(t *testing.T) {
t.Errorf("actor.Followers = %v, want %v", person.GetActivityStreamsFollowers().GetIRI().String(), expectedFollowers) t.Errorf("actor.Followers = %v, want %v", person.GetActivityStreamsFollowers().GetIRI().String(), expectedFollowers)
} }
expectedName := "New Owncast Server" expectedName := "Owncast"
if person.GetActivityStreamsName().Begin().GetXMLSchemaString() != expectedName { if person.GetActivityStreamsName().Begin().GetXMLSchemaString() != expectedName {
t.Errorf("actor.Name = %v, want %v", person.GetActivityStreamsName().Begin().GetXMLSchemaString(), expectedName) t.Errorf("actor.Name = %v, want %v", person.GetActivityStreamsName().Begin().GetXMLSchemaString(), expectedName)
} }
@@ -146,7 +168,7 @@ func TestMakeServiceForAccount(t *testing.T) {
t.Errorf("actor.Avatar = %v, want %v", person.GetActivityStreamsIcon().At(0).GetActivityStreamsImage().GetActivityStreamsUrl().Begin().GetIRI().String(), expectedAvatar) t.Errorf("actor.Avatar = %v, want %v", person.GetActivityStreamsIcon().At(0).GetActivityStreamsImage().GetActivityStreamsUrl().Begin().GetIRI().String(), expectedAvatar)
} }
expectedSummary := "This is a new live video streaming server powered by Owncast." expectedSummary := "Welcome to your new Owncast server! This description can be changed in the admin. Visit https://owncast.online/docs/configuration/ to learn more."
if person.GetActivityStreamsSummary().At(0).GetXMLSchemaString() != expectedSummary { if person.GetActivityStreamsSummary().At(0).GetXMLSchemaString() != expectedSummary {
t.Errorf("actor.Summary = %v, want %v", person.GetActivityStreamsSummary().At(0).GetXMLSchemaString(), expectedSummary) t.Errorf("actor.Summary = %v, want %v", person.GetActivityStreamsSummary().At(0).GetXMLSchemaString(), expectedSummary)
} }
@@ -155,463 +177,3 @@ func TestMakeServiceForAccount(t *testing.T) {
t.Errorf("actor.URL = %v, want %v", person.GetActivityStreamsUrl().At(0).GetIRI().String(), expectedIRI) t.Errorf("actor.URL = %v, want %v", person.GetActivityStreamsUrl().At(0).GetIRI().String(), expectedIRI)
} }
} }
// Tests for nil-safe accessor methods
func TestActorIriStringWithNilValue(t *testing.T) {
actor := ActivityPubActor{}
result := actor.ActorIriString()
if result != "" {
t.Errorf("ActorIriString() with nil ActorIri = %v, want empty string", result)
}
}
func TestActorIriStringWithValue(t *testing.T) {
iri, _ := url.Parse("https://example.com/user/test")
actor := ActivityPubActor{ActorIri: iri}
result := actor.ActorIriString()
if result != "https://example.com/user/test" {
t.Errorf("ActorIriString() = %v, want %v", result, "https://example.com/user/test")
}
}
func TestInboxStringWithNilValue(t *testing.T) {
actor := ActivityPubActor{}
result := actor.InboxString()
if result != "" {
t.Errorf("InboxString() with nil Inbox = %v, want empty string", result)
}
}
func TestInboxStringWithValue(t *testing.T) {
inbox, _ := url.Parse("https://example.com/user/test/inbox")
actor := ActivityPubActor{Inbox: inbox}
result := actor.InboxString()
if result != "https://example.com/user/test/inbox" {
t.Errorf("InboxString() = %v, want %v", result, "https://example.com/user/test/inbox")
}
}
func TestImageStringWithNilValue(t *testing.T) {
actor := ActivityPubActor{}
result := actor.ImageString()
if result != "" {
t.Errorf("ImageString() with nil Image = %v, want empty string", result)
}
}
func TestImageStringWithValue(t *testing.T) {
image, _ := url.Parse("https://example.com/avatar.png")
actor := ActivityPubActor{Image: image}
result := actor.ImageString()
if result != "https://example.com/avatar.png" {
t.Errorf("ImageString() = %v, want %v", result, "https://example.com/avatar.png")
}
}
func TestFollowRequestIriStringWithNilValue(t *testing.T) {
actor := ActivityPubActor{}
result := actor.FollowRequestIriString()
if result != "" {
t.Errorf("FollowRequestIriString() with nil FollowRequestIri = %v, want empty string", result)
}
}
func TestFollowRequestIriStringWithValue(t *testing.T) {
followIri, _ := url.Parse("https://example.com/follow/123")
actor := ActivityPubActor{FollowRequestIri: followIri}
result := actor.FollowRequestIriString()
if result != "https://example.com/follow/123" {
t.Errorf("FollowRequestIriString() = %v, want %v", result, "https://example.com/follow/123")
}
}
func TestActorIriHostnameWithNilValue(t *testing.T) {
actor := ActivityPubActor{}
result := actor.ActorIriHostname()
if result != "" {
t.Errorf("ActorIriHostname() with nil ActorIri = %v, want empty string", result)
}
}
func TestActorIriHostnameWithValue(t *testing.T) {
iri, _ := url.Parse("https://example.com/user/test")
actor := ActivityPubActor{ActorIri: iri}
result := actor.ActorIriHostname()
if result != "example.com" {
t.Errorf("ActorIriHostname() = %v, want %v", result, "example.com")
}
}
// Tests for Validate() and IsValid() methods
func TestValidateWithAllNilFields(t *testing.T) {
actor := ActivityPubActor{}
err := actor.Validate()
if err == nil {
t.Error("Validate() with all nil fields should return error")
}
if !errors.Is(err, ErrActorMissingRequiredField) {
t.Errorf("Validate() error = %v, want ErrActorMissingRequiredField", err)
}
}
func TestValidateWithNilActorIri(t *testing.T) {
inbox, _ := url.Parse("https://example.com/inbox")
actor := ActivityPubActor{Inbox: inbox}
err := actor.Validate()
if err == nil {
t.Error("Validate() with nil ActorIri should return error")
}
if !errors.Is(err, ErrActorMissingRequiredField) {
t.Errorf("Validate() error = %v, want ErrActorMissingRequiredField", err)
}
}
func TestValidateWithNilInbox(t *testing.T) {
iri, _ := url.Parse("https://example.com/user/test")
actor := ActivityPubActor{ActorIri: iri}
err := actor.Validate()
if err == nil {
t.Error("Validate() with nil Inbox should return error")
}
if !errors.Is(err, ErrActorMissingRequiredField) {
t.Errorf("Validate() error = %v, want ErrActorMissingRequiredField", err)
}
}
func TestValidateWithRequiredFields(t *testing.T) {
iri, _ := url.Parse("https://example.com/user/test")
inbox, _ := url.Parse("https://example.com/inbox")
actor := ActivityPubActor{ActorIri: iri, Inbox: inbox}
err := actor.Validate()
if err != nil {
t.Errorf("Validate() with required fields should not return error, got %v", err)
}
}
func TestIsValidWithInvalidActor(t *testing.T) {
actor := ActivityPubActor{}
if actor.IsValid() {
t.Error("IsValid() with invalid actor should return false")
}
}
func TestIsValidWithValidActor(t *testing.T) {
iri, _ := url.Parse("https://example.com/user/test")
inbox, _ := url.Parse("https://example.com/inbox")
actor := ActivityPubActor{ActorIri: iri, Inbox: inbox}
if !actor.IsValid() {
t.Error("IsValid() with valid actor should return true")
}
}
// Tests for NewActivityPubActor constructor
func TestNewActivityPubActorWithNilActorIri(t *testing.T) {
inbox, _ := url.Parse("https://example.com/inbox")
_, err := NewActivityPubActor(nil, inbox)
if err == nil {
t.Error("NewActivityPubActor with nil actorIri should return error")
}
if !errors.Is(err, ErrActorMissingRequiredField) {
t.Errorf("NewActivityPubActor error = %v, want ErrActorMissingRequiredField", err)
}
}
func TestNewActivityPubActorWithNilInbox(t *testing.T) {
iri, _ := url.Parse("https://example.com/user/test")
_, err := NewActivityPubActor(iri, nil)
if err == nil {
t.Error("NewActivityPubActor with nil inbox should return error")
}
if !errors.Is(err, ErrActorMissingRequiredField) {
t.Errorf("NewActivityPubActor error = %v, want ErrActorMissingRequiredField", err)
}
}
func TestNewActivityPubActorWithBothNil(t *testing.T) {
_, err := NewActivityPubActor(nil, nil)
if err == nil {
t.Error("NewActivityPubActor with both nil should return error")
}
if !errors.Is(err, ErrActorMissingRequiredField) {
t.Errorf("NewActivityPubActor error = %v, want ErrActorMissingRequiredField", err)
}
}
func TestNewActivityPubActorWithValidArgs(t *testing.T) {
iri, _ := url.Parse("https://example.com/user/test")
inbox, _ := url.Parse("https://example.com/inbox")
actor, err := NewActivityPubActor(iri, inbox)
if err != nil {
t.Errorf("NewActivityPubActor with valid args should not return error, got %v", err)
}
if actor == nil {
t.Error("NewActivityPubActor with valid args should return non-nil actor")
}
if actor.ActorIri != iri {
t.Errorf("actor.ActorIri = %v, want %v", actor.ActorIri, iri)
}
if actor.Inbox != inbox {
t.Errorf("actor.Inbox = %v, want %v", actor.Inbox, inbox)
}
}
// Tests for NewActivityPubActorFromEntity with invalid entities
func makeFakeServiceWithoutUsername() vocab.ActivityStreamsService {
iri, _ := url.Parse("https://fake.fediverse.server/user/mrfoo")
inbox, _ := url.Parse("https://fake.fediverse.server/user/mrfoo/inbox")
service := streams.NewActivityStreamsService()
id := streams.NewJSONLDIdProperty()
id.Set(iri)
service.SetJSONLDId(id)
inboxProp := streams.NewActivityStreamsInboxProperty()
inboxProp.SetIRI(inbox)
service.SetActivityStreamsInbox(inboxProp)
publicKeyProperty := streams.NewW3IDSecurityV1PublicKeyProperty()
service.SetW3IDSecurityV1PublicKey(publicKeyProperty)
return service
}
func makeFakeServiceWithoutPublicKey() vocab.ActivityStreamsService {
iri, _ := url.Parse("https://fake.fediverse.server/user/mrfoo")
inbox, _ := url.Parse("https://fake.fediverse.server/user/mrfoo/inbox")
username := "foodawg"
service := streams.NewActivityStreamsService()
id := streams.NewJSONLDIdProperty()
id.Set(iri)
service.SetJSONLDId(id)
preferredUsernameProperty := streams.NewActivityStreamsPreferredUsernameProperty()
preferredUsernameProperty.SetXMLSchemaString(username)
service.SetActivityStreamsPreferredUsername(preferredUsernameProperty)
inboxProp := streams.NewActivityStreamsInboxProperty()
inboxProp.SetIRI(inbox)
service.SetActivityStreamsInbox(inboxProp)
return service
}
func makeFakeServiceWithEmptyPublicKey() vocab.ActivityStreamsService {
iri, _ := url.Parse("https://fake.fediverse.server/user/mrfoo")
inbox, _ := url.Parse("https://fake.fediverse.server/user/mrfoo/inbox")
username := "foodawg"
service := streams.NewActivityStreamsService()
id := streams.NewJSONLDIdProperty()
id.Set(iri)
service.SetJSONLDId(id)
preferredUsernameProperty := streams.NewActivityStreamsPreferredUsernameProperty()
preferredUsernameProperty.SetXMLSchemaString(username)
service.SetActivityStreamsPreferredUsername(preferredUsernameProperty)
inboxProp := streams.NewActivityStreamsInboxProperty()
inboxProp.SetIRI(inbox)
service.SetActivityStreamsInbox(inboxProp)
// Set an empty public key property (Len() == 0)
publicKeyProperty := streams.NewW3IDSecurityV1PublicKeyProperty()
service.SetW3IDSecurityV1PublicKey(publicKeyProperty)
return service
}
func makeFakeServiceWithoutId() vocab.ActivityStreamsService {
inbox, _ := url.Parse("https://fake.fediverse.server/user/mrfoo/inbox")
username := "foodawg"
service := streams.NewActivityStreamsService()
preferredUsernameProperty := streams.NewActivityStreamsPreferredUsernameProperty()
preferredUsernameProperty.SetXMLSchemaString(username)
service.SetActivityStreamsPreferredUsername(preferredUsernameProperty)
inboxProp := streams.NewActivityStreamsInboxProperty()
inboxProp.SetIRI(inbox)
service.SetActivityStreamsInbox(inboxProp)
publicKeyProperty := streams.NewW3IDSecurityV1PublicKeyProperty()
service.SetW3IDSecurityV1PublicKey(publicKeyProperty)
return service
}
func makeFakeServiceWithoutInbox() vocab.ActivityStreamsService {
iri, _ := url.Parse("https://fake.fediverse.server/user/mrfoo")
username := "foodawg"
service := streams.NewActivityStreamsService()
id := streams.NewJSONLDIdProperty()
id.Set(iri)
service.SetJSONLDId(id)
preferredUsernameProperty := streams.NewActivityStreamsPreferredUsernameProperty()
preferredUsernameProperty.SetXMLSchemaString(username)
service.SetActivityStreamsPreferredUsername(preferredUsernameProperty)
publicKeyProperty := streams.NewW3IDSecurityV1PublicKeyProperty()
service.SetW3IDSecurityV1PublicKey(publicKeyProperty)
return service
}
func TestNewActivityPubActorFromEntityWithoutUsername(t *testing.T) {
service := makeFakeServiceWithoutUsername()
_, err := NewActivityPubActorFromEntity(service)
if err == nil {
t.Error("NewActivityPubActorFromEntity without username should return error")
}
if !errors.Is(err, ErrActorMissingRequiredField) {
t.Errorf("NewActivityPubActorFromEntity error = %v, want ErrActorMissingRequiredField", err)
}
}
func TestNewActivityPubActorFromEntityWithoutPublicKey(t *testing.T) {
service := makeFakeServiceWithoutPublicKey()
_, err := NewActivityPubActorFromEntity(service)
if err == nil {
t.Error("NewActivityPubActorFromEntity without public key should return error")
}
if !errors.Is(err, ErrActorMissingRequiredField) {
t.Errorf("NewActivityPubActorFromEntity error = %v, want ErrActorMissingRequiredField", err)
}
}
func TestNewActivityPubActorFromEntityWithEmptyPublicKey(t *testing.T) {
service := makeFakeServiceWithEmptyPublicKey()
_, err := NewActivityPubActorFromEntity(service)
if err == nil {
t.Error("NewActivityPubActorFromEntity with empty public key should return error")
}
if !errors.Is(err, ErrActorMissingRequiredField) {
t.Errorf("NewActivityPubActorFromEntity error = %v, want ErrActorMissingRequiredField", err)
}
}
func TestNewActivityPubActorFromEntityWithoutId(t *testing.T) {
service := makeFakeServiceWithoutId()
_, err := NewActivityPubActorFromEntity(service)
if err == nil {
t.Error("NewActivityPubActorFromEntity without ID should return error")
}
if !errors.Is(err, ErrActorMissingRequiredField) {
t.Errorf("NewActivityPubActorFromEntity error = %v, want ErrActorMissingRequiredField", err)
}
}
func TestNewActivityPubActorFromEntityWithoutInbox(t *testing.T) {
service := makeFakeServiceWithoutInbox()
_, err := NewActivityPubActorFromEntity(service)
if err == nil {
t.Error("NewActivityPubActorFromEntity without inbox should return error")
}
if !errors.Is(err, ErrActorMissingRequiredField) {
t.Errorf("NewActivityPubActorFromEntity error = %v, want ErrActorMissingRequiredField", err)
}
}
func TestNewActivityPubActorFromEntityWithValidEntity(t *testing.T) {
service := makeFakeService()
actor, err := NewActivityPubActorFromEntity(service)
if err != nil {
t.Errorf("NewActivityPubActorFromEntity with valid entity should not return error, got %v", err)
}
if actor == nil {
t.Fatal("NewActivityPubActorFromEntity with valid entity should return non-nil actor")
}
// Verify required fields are non-nil
if actor.ActorIri == nil {
t.Error("actor.ActorIri should not be nil")
}
if actor.Inbox == nil {
t.Error("actor.Inbox should not be nil")
}
// Verify extracted values match the fake service data
expectedIri := "https://fake.fediverse.server/user/mrfoo"
if actor.ActorIriString() != expectedIri {
t.Errorf("actor.ActorIri = %v, want %v", actor.ActorIriString(), expectedIri)
}
expectedInbox := "https://fake.fediverse.server/user/mrfoo/inbox"
if actor.InboxString() != expectedInbox {
t.Errorf("actor.Inbox = %v, want %v", actor.InboxString(), expectedInbox)
}
expectedName := "Mr Foo"
if actor.Name != expectedName {
t.Errorf("actor.Name = %v, want %v", actor.Name, expectedName)
}
expectedUsername := "foodawg"
if actor.Username != expectedUsername {
t.Errorf("actor.Username = %v, want %v", actor.Username, expectedUsername)
}
expectedImage := "https://fake.fediverse.server/user/mrfoo/avatar.png"
if actor.ImageString() != expectedImage {
t.Errorf("actor.Image = %v, want %v", actor.ImageString(), expectedImage)
}
}
// Test that safe accessors don't panic on zero-value struct
func TestSafeAccessorsOnZeroValueStruct(t *testing.T) {
var actor ActivityPubActor
// These should not panic
_ = actor.ActorIriString()
_ = actor.InboxString()
_ = actor.ImageString()
_ = actor.FollowRequestIriString()
_ = actor.ActorIriHostname()
_ = actor.Validate()
_ = actor.IsValid()
}
// Test that safe accessors work correctly with optional nil fields on otherwise valid actor
func TestSafeAccessorsWithOptionalNilFields(t *testing.T) {
iri, _ := url.Parse("https://example.com/user/test")
inbox, _ := url.Parse("https://example.com/inbox")
actor := ActivityPubActor{
ActorIri: iri,
Inbox: inbox,
// Image and FollowRequestIri are intentionally nil
}
// Required fields should return values
if actor.ActorIriString() != "https://example.com/user/test" {
t.Errorf("ActorIriString() = %v, want non-empty", actor.ActorIriString())
}
if actor.InboxString() != "https://example.com/inbox" {
t.Errorf("InboxString() = %v, want non-empty", actor.InboxString())
}
// Optional nil fields should return empty strings without panicking
if actor.ImageString() != "" {
t.Errorf("ImageString() = %v, want empty string", actor.ImageString())
}
if actor.FollowRequestIriString() != "" {
t.Errorf("FollowRequestIriString() = %v, want empty string", actor.FollowRequestIriString())
}
// Actor should still be valid (only ActorIri and Inbox are required)
if !actor.IsValid() {
t.Error("Actor with ActorIri and Inbox should be valid even with nil optional fields")
}
}
+1 -1
View File
@@ -9,7 +9,7 @@ import (
// MakeHashtag will create and return a mastodon toot hashtag object with the provided name. // MakeHashtag will create and return a mastodon toot hashtag object with the provided name.
func MakeHashtag(name string) vocab.TootHashtag { func MakeHashtag(name string) vocab.TootHashtag {
u, _ := url.Parse("https://owncast.directory/tags/" + name) u, _ := url.Parse("https://directory.owncast.online/tags/" + name)
hashtag := streams.NewTootHashtag() hashtag := streams.NewTootHashtag()
hashtagName := streams.NewActivityStreamsNameProperty() hashtagName := streams.NewActivityStreamsNameProperty()
+3 -208
View File
@@ -2,14 +2,12 @@ package apmodels
import ( import (
"encoding/json" "encoding/json"
"fmt"
"net/url" "net/url"
"path" "path"
"path/filepath"
"github.com/go-fed/activity/streams" "github.com/go-fed/activity/streams"
"github.com/go-fed/activity/streams/vocab" "github.com/go-fed/activity/streams/vocab"
"github.com/owncast/owncast/persistence/configrepository" "github.com/owncast/owncast/core/data"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
@@ -28,9 +26,7 @@ func MakeRemoteIRIForResource(resourcePath string, host string) (*url.URL, error
// MakeLocalIRIForResource will create an IRI for the local server. // MakeLocalIRIForResource will create an IRI for the local server.
func MakeLocalIRIForResource(resourcePath string) *url.URL { func MakeLocalIRIForResource(resourcePath string) *url.URL {
configRepository := configrepository.Get() host := data.GetServerURL()
host := configRepository.GetServerURL()
u, err := url.Parse(host) u, err := url.Parse(host)
if err != nil { if err != nil {
log.Errorln("unable to parse local IRI url", host, err) log.Errorln("unable to parse local IRI url", host, err)
@@ -44,9 +40,7 @@ func MakeLocalIRIForResource(resourcePath string) *url.URL {
// MakeLocalIRIForAccount will return a full IRI for the local server account username. // MakeLocalIRIForAccount will return a full IRI for the local server account username.
func MakeLocalIRIForAccount(account string) *url.URL { func MakeLocalIRIForAccount(account string) *url.URL {
configRepository := configrepository.Get() host := data.GetServerURL()
host := configRepository.GetServerURL()
u, err := url.Parse(host) u, err := url.Parse(host)
if err != nil { if err != nil {
log.Errorln("unable to parse local IRI account server url", err) log.Errorln("unable to parse local IRI account server url", err)
@@ -66,202 +60,3 @@ func Serialize(obj vocab.Type) ([]byte, error) {
return b, err 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
}
// ErrMissingIRI is returned when an IRI cannot be extracted from an ActivityStreams property.
var ErrMissingIRI = fmt.Errorf("missing IRI")
// GetIRIFromActorProperty safely extracts the IRI from an ActivityStreamsActorProperty.
// Returns the IRI and nil error on success, or nil and an error if the IRI cannot be extracted.
func GetIRIFromActorProperty(actor vocab.ActivityStreamsActorProperty) (*url.URL, error) {
if actor == nil || actor.Empty() || actor.Len() == 0 {
return nil, fmt.Errorf("%w: actor property is empty or nil", ErrMissingIRI)
}
first := actor.At(0)
if first == nil {
return nil, fmt.Errorf("%w: actor property first element is nil", ErrMissingIRI)
}
iri := first.GetIRI()
if iri == nil {
return nil, fmt.Errorf("%w: actor IRI is nil", ErrMissingIRI)
}
return iri, nil
}
// GetIRIStringFromActorProperty safely extracts the IRI string from an ActivityStreamsActorProperty.
// Returns the IRI string and nil error on success, or empty string and an error if extraction fails.
func GetIRIStringFromActorProperty(actor vocab.ActivityStreamsActorProperty) (string, error) {
iri, err := GetIRIFromActorProperty(actor)
if err != nil {
return "", err
}
return iri.String(), nil
}
// GetIRIFromObjectProperty safely extracts the IRI from an ActivityStreamsObjectProperty.
// Returns the IRI and nil error on success, or nil and an error if the IRI cannot be extracted.
func GetIRIFromObjectProperty(object vocab.ActivityStreamsObjectProperty) (*url.URL, error) {
if object == nil || object.Len() == 0 {
return nil, fmt.Errorf("%w: object property is empty or nil", ErrMissingIRI)
}
first := object.At(0)
if first == nil {
return nil, fmt.Errorf("%w: object property first element is nil", ErrMissingIRI)
}
iri := first.GetIRI()
if iri == nil {
return nil, fmt.Errorf("%w: object IRI is nil", ErrMissingIRI)
}
return iri, nil
}
// GetIRIStringFromObjectProperty safely extracts the IRI string from an ActivityStreamsObjectProperty.
// Returns the IRI string and nil error on success, or empty string and an error if extraction fails.
func GetIRIStringFromObjectProperty(object vocab.ActivityStreamsObjectProperty) (string, error) {
iri, err := GetIRIFromObjectProperty(object)
if err != nil {
return "", err
}
return iri.String(), nil
}
// GetIRIFromJSONLDIdProperty safely extracts the IRI from a JSONLDIdProperty.
// Returns the IRI and nil error on success, or nil and an error if the IRI cannot be extracted.
func GetIRIFromJSONLDIdProperty(id vocab.JSONLDIdProperty) (*url.URL, error) {
if id == nil {
return nil, fmt.Errorf("%w: JSONLD id property is nil", ErrMissingIRI)
}
iri := id.GetIRI()
if iri == nil {
return nil, fmt.Errorf("%w: JSONLD id IRI is nil", ErrMissingIRI)
}
return iri, nil
}
// GetIRIStringFromJSONLDIdProperty safely extracts the IRI string from a JSONLDIdProperty.
// Returns the IRI string and nil error on success, or empty string and an error if extraction fails.
func GetIRIStringFromJSONLDIdProperty(id vocab.JSONLDIdProperty) (string, error) {
iri, err := GetIRIFromJSONLDIdProperty(id)
if err != nil {
return "", err
}
return iri.String(), nil
}
// GetPublicKeyPem safely extracts the public key PEM from a W3IDSecurityV1PublicKey.
// Returns the PEM string and nil error on success, or empty string and an error if extraction fails.
func GetPublicKeyPem(publicKey vocab.W3IDSecurityV1PublicKey) (string, error) {
if publicKey == nil {
return "", fmt.Errorf("public key is nil")
}
pemProp := publicKey.GetW3IDSecurityV1PublicKeyPem()
if pemProp == nil {
return "", fmt.Errorf("public key PEM property is nil")
}
return pemProp.Get(), nil
}
// IsFirstObjectActivityStreamsPerson safely checks if the first element of an
// ActivityStreamsObjectProperty is an ActivityStreamsPerson.
// Returns false if the object is nil, empty, or the first element is not a Person.
func IsFirstObjectActivityStreamsPerson(object vocab.ActivityStreamsObjectProperty) bool {
if object == nil || object.Len() == 0 {
return false
}
first := object.At(0)
if first == nil {
return false
}
return first.IsActivityStreamsPerson()
}
// GetImageFromIcon safely extracts the image URL from an ActivityStreamsIconProperty.
// Returns the URL and nil error on success, or nil and nil if the icon is not present or invalid.
// This handles the common pattern of icon -> image -> url -> iri.
func GetImageFromIcon(icon vocab.ActivityStreamsIconProperty) *url.URL {
if icon == nil || icon.Empty() {
return nil
}
first := icon.At(0)
if first == nil {
return nil
}
image := first.GetActivityStreamsImage()
if image == nil {
return nil
}
urlProp := image.GetActivityStreamsUrl()
if urlProp == nil {
return nil
}
begin := urlProp.Begin()
if begin == nil {
return nil
}
return begin.GetIRI()
}
// GetHostnameFromJSONLDId safely extracts the hostname from a JSONLDIdProperty.
// Returns the hostname string, or empty string if extraction fails.
func GetHostnameFromJSONLDId(id vocab.JSONLDIdProperty) string {
if id == nil {
return ""
}
iri := id.GetIRI()
if iri == nil {
return ""
}
return iri.Hostname()
}
-302
View File
@@ -1,302 +0,0 @@
package apmodels
import (
"errors"
"net/url"
"testing"
"github.com/go-fed/activity/streams"
)
func TestGetIRIFromActorPropertyWithNil(t *testing.T) {
_, err := GetIRIFromActorProperty(nil)
if err == nil {
t.Error("GetIRIFromActorProperty(nil) should return error")
}
if !errors.Is(err, ErrMissingIRI) {
t.Errorf("GetIRIFromActorProperty(nil) error = %v, want ErrMissingIRI", err)
}
}
func TestGetIRIFromActorPropertyWithEmpty(t *testing.T) {
actor := streams.NewActivityStreamsActorProperty()
_, err := GetIRIFromActorProperty(actor)
if err == nil {
t.Error("GetIRIFromActorProperty with empty actor should return error")
}
if !errors.Is(err, ErrMissingIRI) {
t.Errorf("GetIRIFromActorProperty error = %v, want ErrMissingIRI", err)
}
}
func TestGetIRIFromActorPropertyWithValidIRI(t *testing.T) {
actor := streams.NewActivityStreamsActorProperty()
iri, _ := url.Parse("https://example.com/user/test")
actor.AppendIRI(iri)
result, err := GetIRIFromActorProperty(actor)
if err != nil {
t.Errorf("GetIRIFromActorProperty with valid IRI should not return error, got %v", err)
}
if result.String() != "https://example.com/user/test" {
t.Errorf("GetIRIFromActorProperty = %v, want %v", result.String(), "https://example.com/user/test")
}
}
func TestGetIRIStringFromActorPropertyWithValidIRI(t *testing.T) {
actor := streams.NewActivityStreamsActorProperty()
iri, _ := url.Parse("https://example.com/user/test")
actor.AppendIRI(iri)
result, err := GetIRIStringFromActorProperty(actor)
if err != nil {
t.Errorf("GetIRIStringFromActorProperty with valid IRI should not return error, got %v", err)
}
if result != "https://example.com/user/test" {
t.Errorf("GetIRIStringFromActorProperty = %v, want %v", result, "https://example.com/user/test")
}
}
func TestGetIRIFromObjectPropertyWithNil(t *testing.T) {
_, err := GetIRIFromObjectProperty(nil)
if err == nil {
t.Error("GetIRIFromObjectProperty(nil) should return error")
}
if !errors.Is(err, ErrMissingIRI) {
t.Errorf("GetIRIFromObjectProperty(nil) error = %v, want ErrMissingIRI", err)
}
}
func TestGetIRIFromObjectPropertyWithEmpty(t *testing.T) {
object := streams.NewActivityStreamsObjectProperty()
_, err := GetIRIFromObjectProperty(object)
if err == nil {
t.Error("GetIRIFromObjectProperty with empty object should return error")
}
if !errors.Is(err, ErrMissingIRI) {
t.Errorf("GetIRIFromObjectProperty error = %v, want ErrMissingIRI", err)
}
}
func TestGetIRIFromObjectPropertyWithValidIRI(t *testing.T) {
object := streams.NewActivityStreamsObjectProperty()
iri, _ := url.Parse("https://example.com/post/123")
object.AppendIRI(iri)
result, err := GetIRIFromObjectProperty(object)
if err != nil {
t.Errorf("GetIRIFromObjectProperty with valid IRI should not return error, got %v", err)
}
if result.String() != "https://example.com/post/123" {
t.Errorf("GetIRIFromObjectProperty = %v, want %v", result.String(), "https://example.com/post/123")
}
}
func TestGetIRIStringFromObjectPropertyWithValidIRI(t *testing.T) {
object := streams.NewActivityStreamsObjectProperty()
iri, _ := url.Parse("https://example.com/post/123")
object.AppendIRI(iri)
result, err := GetIRIStringFromObjectProperty(object)
if err != nil {
t.Errorf("GetIRIStringFromObjectProperty with valid IRI should not return error, got %v", err)
}
if result != "https://example.com/post/123" {
t.Errorf("GetIRIStringFromObjectProperty = %v, want %v", result, "https://example.com/post/123")
}
}
func TestGetIRIFromJSONLDIdPropertyWithNil(t *testing.T) {
_, err := GetIRIFromJSONLDIdProperty(nil)
if err == nil {
t.Error("GetIRIFromJSONLDIdProperty(nil) should return error")
}
if !errors.Is(err, ErrMissingIRI) {
t.Errorf("GetIRIFromJSONLDIdProperty(nil) error = %v, want ErrMissingIRI", err)
}
}
func TestGetIRIFromJSONLDIdPropertyWithValidIRI(t *testing.T) {
id := streams.NewJSONLDIdProperty()
iri, _ := url.Parse("https://example.com/activity/456")
id.SetIRI(iri)
result, err := GetIRIFromJSONLDIdProperty(id)
if err != nil {
t.Errorf("GetIRIFromJSONLDIdProperty with valid IRI should not return error, got %v", err)
}
if result.String() != "https://example.com/activity/456" {
t.Errorf("GetIRIFromJSONLDIdProperty = %v, want %v", result.String(), "https://example.com/activity/456")
}
}
func TestGetIRIStringFromJSONLDIdPropertyWithValidIRI(t *testing.T) {
id := streams.NewJSONLDIdProperty()
iri, _ := url.Parse("https://example.com/activity/456")
id.SetIRI(iri)
result, err := GetIRIStringFromJSONLDIdProperty(id)
if err != nil {
t.Errorf("GetIRIStringFromJSONLDIdProperty with valid IRI should not return error, got %v", err)
}
if result != "https://example.com/activity/456" {
t.Errorf("GetIRIStringFromJSONLDIdProperty = %v, want %v", result, "https://example.com/activity/456")
}
}
func TestGetIRIFromJSONLDIdPropertyWithNoIRI(t *testing.T) {
id := streams.NewJSONLDIdProperty()
// Set a non-IRI value (using Set instead of SetIRI)
iri, _ := url.Parse("https://example.com/activity/456")
id.Set(iri)
// When using Set() the IRI should still be retrievable via GetIRI()
result, err := GetIRIFromJSONLDIdProperty(id)
if err != nil {
t.Errorf("GetIRIFromJSONLDIdProperty should work with Set(), got error %v", err)
}
if result == nil {
t.Error("GetIRIFromJSONLDIdProperty result should not be nil")
}
}
func TestGetPublicKeyPemWithNil(t *testing.T) {
_, err := GetPublicKeyPem(nil)
if err == nil {
t.Error("GetPublicKeyPem(nil) should return error")
}
}
func TestGetPublicKeyPemWithValidKey(t *testing.T) {
publicKey := streams.NewW3IDSecurityV1PublicKey()
pemProp := streams.NewW3IDSecurityV1PublicKeyPemProperty()
pemProp.Set("-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----")
publicKey.SetW3IDSecurityV1PublicKeyPem(pemProp)
result, err := GetPublicKeyPem(publicKey)
if err != nil {
t.Errorf("GetPublicKeyPem with valid key should not return error, got %v", err)
}
if result != "-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----" {
t.Errorf("GetPublicKeyPem = %v, want PEM string", result)
}
}
func TestGetPublicKeyPemWithNoPem(t *testing.T) {
publicKey := streams.NewW3IDSecurityV1PublicKey()
// Don't set PEM property
_, err := GetPublicKeyPem(publicKey)
if err == nil {
t.Error("GetPublicKeyPem with no PEM should return error")
}
}
// Test that safe accessors don't panic on nil/empty inputs
func TestSafeAccessorsNoPanic(t *testing.T) {
// These should not panic
_, _ = GetIRIFromActorProperty(nil)
_, _ = GetIRIStringFromActorProperty(nil)
_, _ = GetIRIFromObjectProperty(nil)
_, _ = GetIRIStringFromObjectProperty(nil)
_, _ = GetIRIFromJSONLDIdProperty(nil)
_, _ = GetIRIStringFromJSONLDIdProperty(nil)
_, _ = GetPublicKeyPem(nil)
_ = GetImageFromIcon(nil)
_ = GetHostnameFromJSONLDId(nil)
_ = IsFirstObjectActivityStreamsPerson(nil)
// Empty properties should also not panic
_, _ = GetIRIFromActorProperty(streams.NewActivityStreamsActorProperty())
_, _ = GetIRIFromObjectProperty(streams.NewActivityStreamsObjectProperty())
_ = GetImageFromIcon(streams.NewActivityStreamsIconProperty())
_ = IsFirstObjectActivityStreamsPerson(streams.NewActivityStreamsObjectProperty())
}
func TestGetImageFromIconWithNil(t *testing.T) {
result := GetImageFromIcon(nil)
if result != nil {
t.Error("GetImageFromIcon(nil) should return nil")
}
}
func TestGetImageFromIconWithEmpty(t *testing.T) {
icon := streams.NewActivityStreamsIconProperty()
result := GetImageFromIcon(icon)
if result != nil {
t.Error("GetImageFromIcon with empty icon should return nil")
}
}
func TestGetImageFromIconWithValidImage(t *testing.T) {
icon := streams.NewActivityStreamsIconProperty()
image := streams.NewActivityStreamsImage()
urlProp := streams.NewActivityStreamsUrlProperty()
imageURL, _ := url.Parse("https://example.com/avatar.png")
urlProp.AppendIRI(imageURL)
image.SetActivityStreamsUrl(urlProp)
icon.AppendActivityStreamsImage(image)
result := GetImageFromIcon(icon)
if result == nil {
t.Error("GetImageFromIcon with valid image should not return nil")
}
if result.String() != "https://example.com/avatar.png" {
t.Errorf("GetImageFromIcon = %v, want %v", result.String(), "https://example.com/avatar.png")
}
}
func TestGetHostnameFromJSONLDIdWithNil(t *testing.T) {
result := GetHostnameFromJSONLDId(nil)
if result != "" {
t.Errorf("GetHostnameFromJSONLDId(nil) = %v, want empty string", result)
}
}
func TestGetHostnameFromJSONLDIdWithValidId(t *testing.T) {
id := streams.NewJSONLDIdProperty()
iri, _ := url.Parse("https://example.com/user/test")
id.SetIRI(iri)
result := GetHostnameFromJSONLDId(id)
if result != "example.com" {
t.Errorf("GetHostnameFromJSONLDId = %v, want %v", result, "example.com")
}
}
func TestIsFirstObjectActivityStreamsPersonWithNil(t *testing.T) {
result := IsFirstObjectActivityStreamsPerson(nil)
if result {
t.Error("IsFirstObjectActivityStreamsPerson(nil) should return false")
}
}
func TestIsFirstObjectActivityStreamsPersonWithEmpty(t *testing.T) {
object := streams.NewActivityStreamsObjectProperty()
result := IsFirstObjectActivityStreamsPerson(object)
if result {
t.Error("IsFirstObjectActivityStreamsPerson with empty object should return false")
}
}
func TestIsFirstObjectActivityStreamsPersonWithPerson(t *testing.T) {
object := streams.NewActivityStreamsObjectProperty()
person := streams.NewActivityStreamsPerson()
object.AppendActivityStreamsPerson(person)
result := IsFirstObjectActivityStreamsPerson(object)
if !result {
t.Error("IsFirstObjectActivityStreamsPerson with person should return true")
}
}
func TestIsFirstObjectActivityStreamsPersonWithNonPerson(t *testing.T) {
object := streams.NewActivityStreamsObjectProperty()
note := streams.NewActivityStreamsNote()
object.AppendActivityStreamsNote(note)
result := IsFirstObjectActivityStreamsPerson(object)
if result {
t.Error("IsFirstObjectActivityStreamsPerson with note should return false")
}
}
+1 -13
View File
@@ -26,9 +26,7 @@ type Link struct {
// MakeWebfingerResponse will create a new Webfinger response. // MakeWebfingerResponse will create a new Webfinger response.
func MakeWebfingerResponse(account string, inbox string, host string) WebfingerResponse { func MakeWebfingerResponse(account string, inbox string, host string) WebfingerResponse {
accountIRI := MakeLocalIRIForAccount(account) accountIRI := MakeLocalIRIForAccount(account)
streamIRI := MakeLocalIRIForStreamURL()
logoIRI := MakeLocalIRIforLogo()
logoType := GetLogoType()
return WebfingerResponse{ return WebfingerResponse{
Subject: fmt.Sprintf("acct:%s@%s", account, host), Subject: fmt.Sprintf("acct:%s@%s", account, host),
Aliases: []string{ Aliases: []string{
@@ -45,16 +43,6 @@ func MakeWebfingerResponse(account string, inbox string, host string) WebfingerR
Type: "text/html", Type: "text/html",
Href: accountIRI.String(), Href: accountIRI.String(),
}, },
{
Rel: "http://webfinger.net/rel/avatar",
Type: logoType,
Href: logoIRI.String(),
},
{
Rel: "alternate",
Type: "application/x-mpegURL",
Href: streamIRI.String(),
},
}, },
} }
} }
+3 -5
View File
@@ -9,14 +9,12 @@ import (
"github.com/owncast/owncast/activitypub/apmodels" "github.com/owncast/owncast/activitypub/apmodels"
"github.com/owncast/owncast/activitypub/crypto" "github.com/owncast/owncast/activitypub/crypto"
"github.com/owncast/owncast/activitypub/requests" "github.com/owncast/owncast/activitypub/requests"
"github.com/owncast/owncast/persistence/configrepository" "github.com/owncast/owncast/core/data"
) )
// ActorHandler handles requests for a single actor. // ActorHandler handles requests for a single actor.
func ActorHandler(w http.ResponseWriter, r *http.Request) { func ActorHandler(w http.ResponseWriter, r *http.Request) {
configRepository := configrepository.Get() if !data.GetFederationEnabled() {
if !configRepository.GetFederationEnabled() {
w.WriteHeader(http.StatusMethodNotAllowed) w.WriteHeader(http.StatusMethodNotAllowed)
return return
} }
@@ -24,7 +22,7 @@ func ActorHandler(w http.ResponseWriter, r *http.Request) {
pathComponents := strings.Split(r.URL.Path, "/") pathComponents := strings.Split(r.URL.Path, "/")
accountName := pathComponents[3] accountName := pathComponents[3]
if _, valid := configRepository.GetFederatedInboxMap()[accountName]; !valid { if _, valid := data.GetFederatedInboxMap()[accountName]; !valid {
// User is not valid // User is not valid
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
return return
+7 -11
View File
@@ -14,9 +14,9 @@ import (
"github.com/go-fed/activity/streams/vocab" "github.com/go-fed/activity/streams/vocab"
"github.com/owncast/owncast/activitypub/apmodels" "github.com/owncast/owncast/activitypub/apmodels"
"github.com/owncast/owncast/activitypub/crypto" "github.com/owncast/owncast/activitypub/crypto"
"github.com/owncast/owncast/activitypub/persistence/followersrepository" "github.com/owncast/owncast/activitypub/persistence"
"github.com/owncast/owncast/activitypub/requests" "github.com/owncast/owncast/activitypub/requests"
"github.com/owncast/owncast/persistence/configrepository" "github.com/owncast/owncast/core/data"
) )
const ( const (
@@ -44,8 +44,8 @@ func FollowersHandler(w http.ResponseWriter, r *http.Request) {
} }
if err != nil { if err != nil {
_, _ = w.Write([]byte(err.Error()))
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte("internal server error"))
return return
} }
@@ -60,8 +60,7 @@ func FollowersHandler(w http.ResponseWriter, r *http.Request) {
} }
func getInitialFollowersRequest(r *http.Request) (vocab.ActivityStreamsOrderedCollection, error) { func getInitialFollowersRequest(r *http.Request) (vocab.ActivityStreamsOrderedCollection, error) {
followersRepo := followersrepository.Get() followerCount, _ := persistence.GetFollowerCount()
followerCount, _ := followersRepo.GetCount()
collection := streams.NewActivityStreamsOrderedCollection() collection := streams.NewActivityStreamsOrderedCollection()
idProperty := streams.NewJSONLDIdProperty() idProperty := streams.NewJSONLDIdProperty()
id, err := createPageURL(r, nil) id, err := createPageURL(r, nil)
@@ -94,13 +93,12 @@ func getFollowersPage(page string, r *http.Request) (vocab.ActivityStreamsOrdere
return nil, errors.Wrap(err, "unable to parse page number") return nil, errors.Wrap(err, "unable to parse page number")
} }
followersRepo := followersrepository.Get() followerCount, err := persistence.GetFollowerCount()
followerCount, err := followersRepo.GetCount()
if err != nil { if err != nil {
return nil, errors.Wrap(err, "unable to get follower count") return nil, errors.Wrap(err, "unable to get follower count")
} }
followers, _, err := followersRepo.GetFollowers(followersPageSize, (pageInt-1)*followersPageSize) followers, _, err := persistence.GetFederationFollowers(followersPageSize, (pageInt-1)*followersPageSize)
if err != nil { if err != nil {
return nil, errors.Wrap(err, "unable to get federation followers") return nil, errors.Wrap(err, "unable to get federation followers")
} }
@@ -147,9 +145,7 @@ func getFollowersPage(page string, r *http.Request) (vocab.ActivityStreamsOrdere
} }
func createPageURL(r *http.Request, page *string) (*url.URL, error) { func createPageURL(r *http.Request, page *string) (*url.URL, error) {
configRepository := configrepository.Get() domain := data.GetServerURL()
domain := configRepository.GetServerURL()
if domain == "" { if domain == "" {
return nil, errors.New("unable to get server URL") return nil, errors.New("unable to get server URL")
} }
+3 -5
View File
@@ -7,7 +7,7 @@ import (
"github.com/owncast/owncast/activitypub/apmodels" "github.com/owncast/owncast/activitypub/apmodels"
"github.com/owncast/owncast/activitypub/inbox" "github.com/owncast/owncast/activitypub/inbox"
"github.com/owncast/owncast/persistence/configrepository" "github.com/owncast/owncast/core/data"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
@@ -22,9 +22,7 @@ func InboxHandler(w http.ResponseWriter, r *http.Request) {
} }
func acceptInboxRequest(w http.ResponseWriter, r *http.Request) { func acceptInboxRequest(w http.ResponseWriter, r *http.Request) {
configRepository := configrepository.Get() if !data.GetFederationEnabled() {
if !configRepository.GetFederationEnabled() {
w.WriteHeader(http.StatusMethodNotAllowed) w.WriteHeader(http.StatusMethodNotAllowed)
return return
} }
@@ -41,7 +39,7 @@ func acceptInboxRequest(w http.ResponseWriter, r *http.Request) {
// The account this request is for must match the account name we have set // The account this request is for must match the account name we have set
// for federation. // for federation.
if forLocalAccount != configRepository.GetFederationUsername() { if forLocalAccount != data.GetFederationUsername() {
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
return return
} }
+19 -37
View File
@@ -10,7 +10,7 @@ import (
"github.com/owncast/owncast/activitypub/persistence" "github.com/owncast/owncast/activitypub/persistence"
"github.com/owncast/owncast/activitypub/requests" "github.com/owncast/owncast/activitypub/requests"
"github.com/owncast/owncast/config" "github.com/owncast/owncast/config"
"github.com/owncast/owncast/persistence/configrepository" "github.com/owncast/owncast/core/data"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
@@ -25,14 +25,12 @@ func NodeInfoController(w http.ResponseWriter, r *http.Request) {
Links []links `json:"links"` Links []links `json:"links"`
} }
configRepository := configrepository.Get() if !data.GetFederationEnabled() {
if !configRepository.GetFederationEnabled() {
w.WriteHeader(http.StatusMethodNotAllowed) w.WriteHeader(http.StatusMethodNotAllowed)
return return
} }
serverURL := configRepository.GetServerURL() serverURL := data.GetServerURL()
if serverURL == "" { if serverURL == "" {
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
return return
@@ -91,9 +89,7 @@ func NodeInfoV2Controller(w http.ResponseWriter, r *http.Request) {
Metadata metadata `json:"metadata"` Metadata metadata `json:"metadata"`
} }
configRepository := configrepository.Get() if !data.GetFederationEnabled() {
if !configRepository.GetFederationEnabled() {
w.WriteHeader(http.StatusMethodNotAllowed) w.WriteHeader(http.StatusMethodNotAllowed)
return return
} }
@@ -121,7 +117,7 @@ func NodeInfoV2Controller(w http.ResponseWriter, r *http.Request) {
OpenRegistrations: false, OpenRegistrations: false,
Protocols: []string{"activitypub"}, Protocols: []string{"activitypub"},
Metadata: metadata{ Metadata: metadata{
ChatEnabled: !configRepository.GetChatDisabled(), ChatEnabled: !data.GetChatDisabled(),
}, },
} }
@@ -158,23 +154,21 @@ func XNodeInfo2Controller(w http.ResponseWriter, r *http.Request) {
LocalComments int `json:"localComments"` LocalComments int `json:"localComments"`
} }
type response struct { type response struct {
Server Server `json:"server"`
Organization Organization `json:"organization"` Organization Organization `json:"organization"`
Version string `json:"version"` Server Server `json:"server"`
Services Services `json:"services"` Services Services `json:"services"`
Protocols []string `json:"protocols"` Protocols []string `json:"protocols"`
Usage Usage `json:"usage"` Version string `json:"version"`
OpenRegistrations bool `json:"openRegistrations"` OpenRegistrations bool `json:"openRegistrations"`
Usage Usage `json:"usage"`
} }
configRepository := configrepository.Get() if !data.GetFederationEnabled() {
if !configRepository.GetFederationEnabled() {
w.WriteHeader(http.StatusMethodNotAllowed) w.WriteHeader(http.StatusMethodNotAllowed)
return return
} }
serverURL := configRepository.GetServerURL() serverURL := data.GetServerURL()
if serverURL == "" { if serverURL == "" {
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
return return
@@ -184,7 +178,7 @@ func XNodeInfo2Controller(w http.ResponseWriter, r *http.Request) {
res := &response{ res := &response{
Organization: Organization{ Organization: Organization{
Name: configRepository.GetServerName(), Name: data.GetServerName(),
Contact: serverURL, Contact: serverURL,
}, },
Server: Server{ Server: Server{
@@ -230,22 +224,20 @@ func InstanceV1Controller(w http.ResponseWriter, r *http.Request) {
ShortDescription string `json:"short_description"` ShortDescription string `json:"short_description"`
Description string `json:"description"` Description string `json:"description"`
Version string `json:"version"` Version string `json:"version"`
Stats Stats `json:"stats"`
Thumbnail string `json:"thumbnail"` Thumbnail string `json:"thumbnail"`
Languages []string `json:"languages"` Languages []string `json:"languages"`
Stats Stats `json:"stats"`
Registrations bool `json:"registrations"` Registrations bool `json:"registrations"`
ApprovalRequired bool `json:"approval_required"` ApprovalRequired bool `json:"approval_required"`
InvitesEnabled bool `json:"invites_enabled"` InvitesEnabled bool `json:"invites_enabled"`
} }
configRepository := configrepository.Get() if !data.GetFederationEnabled() {
if !configRepository.GetFederationEnabled() {
w.WriteHeader(http.StatusMethodNotAllowed) w.WriteHeader(http.StatusMethodNotAllowed)
return return
} }
serverURL := configRepository.GetServerURL() serverURL := data.GetServerURL()
if serverURL == "" { if serverURL == "" {
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
return return
@@ -262,9 +254,9 @@ func InstanceV1Controller(w http.ResponseWriter, r *http.Request) {
res := response{ res := response{
URI: serverURL, URI: serverURL,
Title: configRepository.GetServerName(), Title: data.GetServerName(),
ShortDescription: configRepository.GetServerSummary(), ShortDescription: data.GetServerSummary(),
Description: configRepository.GetServerSummary(), Description: data.GetServerSummary(),
Version: config.GetReleaseString(), Version: config.GetReleaseString(),
Stats: Stats{ Stats: Stats{
UserCount: 1, UserCount: 1,
@@ -283,9 +275,7 @@ func InstanceV1Controller(w http.ResponseWriter, r *http.Request) {
} }
func writeResponse(payload interface{}, w http.ResponseWriter) error { func writeResponse(payload interface{}, w http.ResponseWriter) error {
configRepository := configrepository.Get() accountName := data.GetDefaultFederationUsername()
accountName := configRepository.GetDefaultFederationUsername()
actorIRI := apmodels.MakeLocalIRIForAccount(accountName) actorIRI := apmodels.MakeLocalIRIForAccount(accountName)
publicKey := crypto.GetPublicKey(actorIRI) publicKey := crypto.GetPublicKey(actorIRI)
@@ -294,15 +284,7 @@ func writeResponse(payload interface{}, w http.ResponseWriter) error {
// HostMetaController points to webfinger. // HostMetaController points to webfinger.
func HostMetaController(w http.ResponseWriter, r *http.Request) { func HostMetaController(w http.ResponseWriter, r *http.Request) {
configRepository := configrepository.Get() serverURL := data.GetServerURL()
if !configRepository.GetFederationEnabled() {
w.WriteHeader(http.StatusMethodNotAllowed)
log.Debugln("host meta request rejected! Federation is not enabled")
return
}
serverURL := configRepository.GetServerURL()
if serverURL == "" { if serverURL == "" {
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
return return
+5 -7
View File
@@ -8,33 +8,31 @@ import (
"github.com/owncast/owncast/activitypub/crypto" "github.com/owncast/owncast/activitypub/crypto"
"github.com/owncast/owncast/activitypub/persistence" "github.com/owncast/owncast/activitypub/persistence"
"github.com/owncast/owncast/activitypub/requests" "github.com/owncast/owncast/activitypub/requests"
"github.com/owncast/owncast/persistence/configrepository" "github.com/owncast/owncast/core/data"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
// ObjectHandler handles requests for a single federated ActivityPub object. // ObjectHandler handles requests for a single federated ActivityPub object.
func ObjectHandler(w http.ResponseWriter, r *http.Request) { func ObjectHandler(w http.ResponseWriter, r *http.Request) {
configRepository := configrepository.Get() if !data.GetFederationEnabled() {
if !configRepository.GetFederationEnabled() {
w.WriteHeader(http.StatusMethodNotAllowed) w.WriteHeader(http.StatusMethodNotAllowed)
return return
} }
// If private federation mode is enabled do not allow access to objects. // If private federation mode is enabled do not allow access to objects.
if configRepository.GetFederationIsPrivate() { if data.GetFederationIsPrivate() {
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
return return
} }
iri := strings.Join([]string{strings.TrimSuffix(configRepository.GetServerURL(), "/"), r.URL.Path}, "") iri := strings.Join([]string{strings.TrimSuffix(data.GetServerURL(), "/"), r.URL.Path}, "")
object, _, _, err := persistence.GetObjectByIRI(iri) object, _, _, err := persistence.GetObjectByIRI(iri)
if err != nil { if err != nil {
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
return return
} }
accountName := configRepository.GetDefaultFederationUsername() accountName := data.GetDefaultFederationUsername()
actorIRI := apmodels.MakeLocalIRIForAccount(accountName) actorIRI := apmodels.MakeLocalIRIForAccount(accountName)
publicKey := crypto.GetPublicKey(actorIRI) publicKey := crypto.GetPublicKey(actorIRI)
+2 -3
View File
@@ -41,8 +41,8 @@ func OutboxHandler(w http.ResponseWriter, r *http.Request) {
} }
if err != nil { if err != nil {
_, _ = w.Write([]byte(err.Error()))
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte("internal server error"))
return return
} }
@@ -65,8 +65,7 @@ func ActorObjectHandler(w http.ResponseWriter, r *http.Request) {
// controllers.WriteSimpleResponse(w, false, err.Error()) // controllers.WriteSimpleResponse(w, false, err.Error())
} }
w.Header().Set("Content-Type", "application/activity+json") if _, err := w.Write([]byte(object)); err != nil {
if _, err := w.Write([]byte(object)); err != nil { //nolint:gosec
log.Errorln(err) log.Errorln(err)
} }
} }
+20 -31
View File
@@ -6,64 +6,53 @@ import (
"strings" "strings"
"github.com/owncast/owncast/activitypub/apmodels" "github.com/owncast/owncast/activitypub/apmodels"
"github.com/owncast/owncast/persistence/configrepository" "github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/utils" "github.com/owncast/owncast/utils"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
// WebfingerHandler will handle webfinger lookup requests. // WebfingerHandler will handle webfinger lookup requests.
func WebfingerHandler(w http.ResponseWriter, r *http.Request) { func WebfingerHandler(w http.ResponseWriter, r *http.Request) {
configRepository := configrepository.Get() if !data.GetFederationEnabled() {
if !configRepository.GetFederationEnabled() {
w.WriteHeader(http.StatusMethodNotAllowed) 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 return
} }
resource := r.URL.Query().Get("resource") resource := r.URL.Query().Get("resource")
preAcct, account, foundAcct := strings.Cut(resource, "acct:") resourceComponents := strings.Split(resource, ":")
if !foundAcct || preAcct != "" { var account string
w.WriteHeader(http.StatusBadRequest) if len(resourceComponents) == 2 {
log.Debugln("webfinger request rejected! Malformed resource in query: " + resource) account = resourceComponents[1]
return } else {
account = resourceComponents[0]
} }
userComponents := strings.Split(account, "@") userComponents := strings.Split(account, "@")
if len(userComponents) != 2 { if len(userComponents) < 2 {
w.WriteHeader(http.StatusBadRequest)
log.Debugln("webfinger request rejected! Malformed account in query: " + account)
return return
} }
host := userComponents[1] host := userComponents[1]
user := userComponents[0] user := userComponents[0]
if _, valid := configRepository.GetFederatedInboxMap()[user]; !valid { if _, valid := data.GetFederatedInboxMap()[user]; !valid {
// User is not valid
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
log.Debugln("webfinger request rejected! Invalid user: " + user) log.Debugln("webfinger request rejected")
return return
} }
// If the webfinger request doesn't match our server then it // If the webfinger request doesn't match our server then it
// should be rejected. // should be rejected.
if instanceHostString != host { instanceHostString := data.GetServerURL()
if instanceHostString == "" {
w.WriteHeader(http.StatusNotImplemented)
return
}
instanceHostString = utils.GetHostnameFromURLString(instanceHostString)
if instanceHostString == "" || instanceHostString != host {
w.WriteHeader(http.StatusNotImplemented) w.WriteHeader(http.StatusNotImplemented)
log.Debugln("webfinger request rejected! Invalid query host: " + host + " instanceHostString: " + instanceHostString)
return return
} }
+3 -7
View File
@@ -8,15 +8,13 @@ import (
"errors" "errors"
"net/url" "net/url"
"github.com/owncast/owncast/persistence/configrepository" "github.com/owncast/owncast/core/data"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
// GetPublicKey will return the public key for the provided actor. // GetPublicKey will return the public key for the provided actor.
func GetPublicKey(actorIRI *url.URL) PublicKey { func GetPublicKey(actorIRI *url.URL) PublicKey {
configRepository := configrepository.Get() key := data.GetPublicKey()
key := configRepository.GetPublicKey()
idURL, err := url.Parse(actorIRI.String() + "#main-key") idURL, err := url.Parse(actorIRI.String() + "#main-key")
if err != nil { if err != nil {
log.Errorln("unable to parse actor iri string", idURL, err) log.Errorln("unable to parse actor iri string", idURL, err)
@@ -31,9 +29,7 @@ func GetPublicKey(actorIRI *url.URL) PublicKey {
// GetPrivateKey will return the internal server private key. // GetPrivateKey will return the internal server private key.
func GetPrivateKey() *rsa.PrivateKey { func GetPrivateKey() *rsa.PrivateKey {
configRepository := configrepository.Get() key := data.GetPrivateKey()
key := configRepository.GetPrivateKey()
block, _ := pem.Decode([]byte(key)) block, _ := pem.Decode([]byte(key))
if block == nil { if block == nil {
-19
View File
@@ -1,19 +0,0 @@
package events
import (
"time"
"github.com/teris-io/shortid"
)
// Event is any kind of event.
type Event struct {
Timestamp time.Time `json:"timestamp"`
ID string `json:"id"`
}
// SetDefaults will set default properties of all inbound events.
func (e *Event) SetDefaults() {
e.ID = shortid.MustGenerate()
e.Timestamp = time.Now()
}
@@ -1,8 +0,0 @@
package events
type FediverseEngagementFollowEvent struct {
Event
Name string `json:"name"`
Username string `json:"username"`
Image string `json:"image"`
}
+3 -11
View File
@@ -5,24 +5,16 @@ import (
"time" "time"
"github.com/go-fed/activity/streams/vocab" "github.com/go-fed/activity/streams/vocab"
"github.com/owncast/owncast/activitypub/apmodels"
"github.com/owncast/owncast/activitypub/persistence" "github.com/owncast/owncast/activitypub/persistence"
"github.com/owncast/owncast/core/chat/events" "github.com/owncast/owncast/core/chat/events"
"github.com/pkg/errors" "github.com/pkg/errors"
) )
func handleAnnounceRequest(c context.Context, activity vocab.ActivityStreamsAnnounce) error { func handleAnnounceRequest(c context.Context, activity vocab.ActivityStreamsAnnounce) error {
objectIRI, err := apmodels.GetIRIStringFromObjectProperty(activity.GetActivityStreamsObject()) object := activity.GetActivityStreamsObject()
if err != nil {
return errors.Wrap(err, "announce activity is missing object IRI")
}
actorIRI, err := apmodels.GetIRIStringFromActorProperty(activity.GetActivityStreamsActor())
if err != nil {
return errors.Wrap(err, "announce activity is missing actor IRI")
}
actorReference := activity.GetActivityStreamsActor() 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 { 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") return errors.Wrap(err, "inbound activity of share/re-post has already been handled")
+14 -27
View File
@@ -4,57 +4,43 @@ import (
"fmt" "fmt"
"github.com/go-fed/activity/streams/vocab" "github.com/go-fed/activity/streams/vocab"
"github.com/microcosm-cc/bluemonday"
"github.com/owncast/owncast/activitypub/resolvers" "github.com/owncast/owncast/activitypub/resolvers"
"github.com/owncast/owncast/core/chat" "github.com/owncast/owncast/core/chat"
"github.com/owncast/owncast/core/chat/events" "github.com/owncast/owncast/core/chat/events"
"github.com/owncast/owncast/persistence/configrepository" "github.com/owncast/owncast/core/data"
) )
// sanitizeActorName strips HTML tags from the ActivityPub actor display name.
// Falls back to the username if the display name is empty or entirely HTML.
func sanitizeActorName(displayName, username string) string {
strict := bluemonday.StrictPolicy()
name := strict.Sanitize(displayName)
if name == "" {
name = strict.Sanitize(username)
}
return name
}
func handleEngagementActivity(eventType events.EventType, isLiveNotification bool, actorReference vocab.ActivityStreamsActorProperty, action string) error { 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. // Do nothing if displaying engagement actions has been turned off.
if !configRepository.GetFederationShowEngagement() { if !data.GetFederationShowEngagement() {
return nil return nil
} }
// Do nothing if chat is disabled // Do nothing if chat is disabled
if configRepository.GetChatDisabled() { if data.GetChatDisabled() {
return nil return nil
} }
// Get actor of the action // Get actor of the action
actor, err := resolvers.GetResolvedActorFromActorProperty(actorReference) actor, _ := resolvers.GetResolvedActorFromActorProperty(actorReference)
if err != nil {
return fmt.Errorf("unable to resolve actor for engagement activity: %w", err)
}
// Send chat message // Send chat message
actorName := sanitizeActorName(actor.Name, actor.Username) actorName := actor.Name
actorIRI := actor.ActorIriString() if actorName == "" {
actorName = actor.Username
}
actorIRI := actorReference.Begin().GetIRI().String()
userPrefix := fmt.Sprintf("%s ", actorName) userPrefix := fmt.Sprintf("%s ", actorName)
var suffix string var suffix string
if isLiveNotification && action == events.FediverseEngagementLike { if isLiveNotification && action == events.FediverseEngagementLike {
suffix = "liked that this stream went live." suffix = "liked that this stream went live."
} else if action == events.FediverseEngagementLike { } else if action == events.FediverseEngagementLike {
suffix = fmt.Sprintf("liked a post from %s.", configRepository.GetServerName()) suffix = fmt.Sprintf("liked a post from %s.", data.GetServerName())
} else if isLiveNotification && action == events.FediverseEngagementRepost { } else if isLiveNotification && action == events.FediverseEngagementRepost {
suffix = "shared this stream with their followers." suffix = "shared this stream with their followers."
} else if action == events.FediverseEngagementRepost { } else if action == events.FediverseEngagementRepost {
suffix = fmt.Sprintf("shared a post from %s.", configRepository.GetServerName()) suffix = fmt.Sprintf("shared a post from %s.", data.GetServerName())
} else if action == events.FediverseEngagementFollow { } else if action == events.FediverseEngagementFollow {
suffix = "followed this stream." suffix = "followed this stream."
} else { } else {
@@ -63,8 +49,9 @@ func handleEngagementActivity(eventType events.EventType, isLiveNotification boo
body := fmt.Sprintf("%s %s", userPrefix, suffix) body := fmt.Sprintf("%s %s", userPrefix, suffix)
var image *string var image *string
if imageStr := actor.ImageString(); imageStr != "" { if actor.Image != nil {
image = &imageStr s := actor.Image.String()
image = &s
} }
if err := chat.SendFediverseAction(eventType, actor.FullUsername, image, body, actorIRI); err != nil { if err := chat.SendFediverseAction(eventType, actor.FullUsername, image, body, actorIRI); err != nil {
-120
View File
@@ -1,120 +0,0 @@
package inbox
import (
"testing"
)
func TestSanitizeActorName(t *testing.T) {
tests := []struct {
name string
displayName string
username string
expected string
}{
{
name: "plain display name",
displayName: "Alice",
username: "alice",
expected: "Alice",
},
{
name: "display name with emoji",
displayName: "Alice 🦊",
username: "alice",
expected: "Alice 🦊",
},
{
name: "display name with unicode",
displayName: "Ålice Böb",
username: "alice",
expected: "Ålice Böb",
},
{
name: "empty display name falls back to username",
displayName: "",
username: "alice",
expected: "alice",
},
{
name: "script tag in display name",
displayName: `<script>alert("xss")</script>`,
username: "alice",
expected: "alice",
},
{
name: "iframe injection in display name",
displayName: `<iframe src="https://evil.com" style="position:fixed;top:0;left:0;width:100%;height:100%"></iframe>`,
username: "alice",
expected: "alice",
},
{
name: "img tag in display name",
displayName: `<img src="https://evil.com/track.png">`,
username: "alice",
expected: "alice",
},
{
name: "form injection in display name",
displayName: `<form action="https://evil.com/steal"><input name="pw" type="password"></form>`,
username: "alice",
expected: "alice",
},
{
name: "meta refresh in display name",
displayName: `<meta http-equiv="refresh" content="0;url=https://evil.com">`,
username: "alice",
expected: "alice",
},
{
name: "mixed text and HTML in display name",
displayName: `Alice <script>alert(1)</script> Bob`,
username: "alice",
expected: "Alice Bob",
},
{
name: "custom emoji HTML in display name",
displayName: `Alice :blobcat: <img src="https://instance.com/emoji/blobcat.png" class="custom-emoji">`,
username: "alice",
expected: "Alice :blobcat: ",
},
{
name: "HTML in both display name and username",
displayName: `<script>alert(1)</script>`,
username: `<b>alice</b>`,
expected: "alice",
},
{
name: "entirely HTML display name falls back to username",
displayName: `<div></div>`,
username: "alice",
expected: "alice",
},
{
name: "style tag in display name",
displayName: `<style>body{display:none}</style>Alice`,
username: "alice",
expected: "Alice",
},
{
name: "nested HTML tags",
displayName: `<div><span><a href="https://evil.com">Click me</a></span></div>`,
username: "alice",
expected: "Click me",
},
{
name: "event handler attributes",
displayName: `<img src=x onerror="alert(1)">Alice`,
username: "alice",
expected: "Alice",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := sanitizeActorName(tt.displayName, tt.username)
if result != tt.expected {
t.Errorf("sanitizeActorName(%q, %q) = %q, want %q", tt.displayName, tt.username, result, tt.expected)
}
})
}
}
+1 -5
View File
@@ -4,14 +4,10 @@ import (
"context" "context"
"github.com/go-fed/activity/streams/vocab" "github.com/go-fed/activity/streams/vocab"
"github.com/owncast/owncast/activitypub/apmodels"
"github.com/pkg/errors" "github.com/pkg/errors"
) )
func handleCreateRequest(c context.Context, activity vocab.ActivityStreamsCreate) error { func handleCreateRequest(c context.Context, activity vocab.ActivityStreamsCreate) error {
iri, err := apmodels.GetIRIStringFromJSONLDIdProperty(activity.GetJSONLDId()) iri := activity.GetJSONLDId().GetIRI().String()
if err != nil {
return errors.Wrap(err, "create activity is missing IRI")
}
return errors.New("not handling create request of: " + iri) return errors.New("not handling create request of: " + iri)
} }
+11 -25
View File
@@ -6,23 +6,17 @@ import (
"time" "time"
"github.com/go-fed/activity/streams/vocab" "github.com/go-fed/activity/streams/vocab"
"github.com/owncast/owncast/activitypub/apmodels"
"github.com/owncast/owncast/activitypub/persistence" "github.com/owncast/owncast/activitypub/persistence"
"github.com/owncast/owncast/activitypub/persistence/followersrepository"
"github.com/owncast/owncast/activitypub/requests" "github.com/owncast/owncast/activitypub/requests"
"github.com/owncast/owncast/activitypub/resolvers" "github.com/owncast/owncast/activitypub/resolvers"
"github.com/owncast/owncast/core/chat/events" "github.com/owncast/owncast/core/chat/events"
"github.com/owncast/owncast/core/webhooks" "github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/persistence/configrepository"
"github.com/pkg/errors" "github.com/pkg/errors"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
func handleFollowInboxRequest(c context.Context, activity vocab.ActivityStreamsFollow) error { func handleFollowInboxRequest(c context.Context, activity vocab.ActivityStreamsFollow) error {
configRepository := configrepository.Get()
followersRepo := followersrepository.Get()
follow, err := resolvers.MakeFollowRequest(c, activity) follow, err := resolvers.MakeFollowRequest(c, activity)
if err != nil { if err != nil {
log.Errorln("unable to create follow inbox request", err) log.Errorln("unable to create follow inbox request", err)
@@ -33,37 +27,30 @@ func handleFollowInboxRequest(c context.Context, activity vocab.ActivityStreamsF
return fmt.Errorf("unable to handle request") return fmt.Errorf("unable to handle request")
} }
approved := !configRepository.GetFederationIsPrivate() approved := !data.GetFederationIsPrivate()
followRequest := *follow followRequest := *follow
if err := followersRepo.Add(followRequest, approved); err != nil { if err := persistence.AddFollow(followRequest, approved); err != nil {
log.Errorln("unable to save follow request", err) log.Errorln("unable to save follow request", err)
return err return err
} }
localAccountName := configRepository.GetDefaultFederationUsername() localAccountName := data.GetDefaultFederationUsername()
objectIRI, err := apmodels.GetIRIStringFromObjectProperty(activity.GetActivityStreamsObject())
if err != nil {
return errors.Wrap(err, "follow activity is missing object IRI")
}
actorIRI, err := apmodels.GetIRIStringFromActorProperty(activity.GetActivityStreamsActor())
if err != nil {
return errors.Wrap(err, "follow activity is missing actor IRI")
}
actorReference := activity.GetActivityStreamsActor()
if approved { if approved {
if err := requests.SendFollowAccept(follow.Inbox, activity, localAccountName); err != nil { if err := requests.SendFollowAccept(follow.Inbox, activity, localAccountName); err != nil {
log.Errorln("unable to send follow accept", err) log.Errorln("unable to send follow accept", err)
return err return err
} }
go webhooks.SendFediverseEngagementFollowEvent(actorIRI)
} }
// 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 // If this request is approved and we have not previously sent an action to
// chat due to a previous follow request, then do so. // chat due to a previous follow request, then do so.
hasPreviouslyhandled := true // Default so we don't send anything if it fails. hasPreviouslyhandled := true // Default so we don't send anything if it fails.
@@ -97,6 +84,5 @@ func handleUnfollowRequest(c context.Context, activity vocab.ActivityStreamsUndo
unfollowRequest := *request unfollowRequest := *request
log.Traceln("unfollow request:", unfollowRequest) log.Traceln("unfollow request:", unfollowRequest)
followersRepo := followersrepository.Get() return persistence.RemoveFollow(unfollowRequest)
return followersRepo.Remove(unfollowRequest)
} }
+3 -11
View File
@@ -5,24 +5,16 @@ import (
"time" "time"
"github.com/go-fed/activity/streams/vocab" "github.com/go-fed/activity/streams/vocab"
"github.com/owncast/owncast/activitypub/apmodels"
"github.com/owncast/owncast/activitypub/persistence" "github.com/owncast/owncast/activitypub/persistence"
"github.com/owncast/owncast/core/chat/events" "github.com/owncast/owncast/core/chat/events"
"github.com/pkg/errors" "github.com/pkg/errors"
) )
func handleLikeRequest(c context.Context, activity vocab.ActivityStreamsLike) error { func handleLikeRequest(c context.Context, activity vocab.ActivityStreamsLike) error {
objectIRI, err := apmodels.GetIRIStringFromObjectProperty(activity.GetActivityStreamsObject()) object := activity.GetActivityStreamsObject()
if err != nil {
return errors.Wrap(err, "like activity is missing object IRI")
}
actorIRI, err := apmodels.GetIRIStringFromActorProperty(activity.GetActivityStreamsActor())
if err != nil {
return errors.Wrap(err, "like activity is missing actor IRI")
}
actorReference := activity.GetActivityStreamsActor() actorReference := activity.GetActivityStreamsActor()
objectIRI := object.At(0).GetIRI().String()
actorIRI := actorReference.At(0).GetIRI().String()
if hasPreviouslyhandled, err := persistence.HasPreviouslyHandledInboundActivity(objectIRI, actorIRI, events.FediverseEngagementLike); hasPreviouslyhandled || err != nil { if hasPreviouslyhandled, err := persistence.HasPreviouslyHandledInboundActivity(objectIRI, actorIRI, events.FediverseEngagementLike); hasPreviouslyhandled || err != nil {
return errors.Wrap(err, "inbound activity of like has already been handled") return errors.Wrap(err, "inbound activity of like has already been handled")
-251
View File
@@ -1,251 +0,0 @@
package inbox
import (
"context"
"net/url"
"testing"
"github.com/go-fed/activity/streams"
)
func mustParseURL(s string) *url.URL {
u, err := url.Parse(s)
if err != nil {
panic(err)
}
return u
}
// These tests verify that handler functions don't panic when given
// ActivityPub activities with nil or missing properties that could
// cause nil pointer dereferences.
func TestHandleFollowWithNilObject(t *testing.T) {
activity := streams.NewActivityStreamsFollow()
// Don't set object or actor - they will be nil
// This should return an error, not panic
err := handleFollowInboxRequest(context.Background(), activity)
if err == nil {
t.Error("handleFollowInboxRequest with nil object should return error")
}
}
func TestHandleFollowWithEmptyObject(t *testing.T) {
activity := streams.NewActivityStreamsFollow()
object := streams.NewActivityStreamsObjectProperty()
activity.SetActivityStreamsObject(object)
// Object is set but empty (no items)
err := handleFollowInboxRequest(context.Background(), activity)
if err == nil {
t.Error("handleFollowInboxRequest with empty object should return error")
}
}
func TestHandleFollowWithNilActorIRI(t *testing.T) {
activity := streams.NewActivityStreamsFollow()
// Set a valid object with IRI
object := streams.NewActivityStreamsObjectProperty()
objectNote := streams.NewActivityStreamsNote()
objectID := streams.NewJSONLDIdProperty()
objectID.SetIRI(mustParseURL("https://example.com/note/1"))
objectNote.SetJSONLDId(objectID)
object.AppendActivityStreamsNote(objectNote)
activity.SetActivityStreamsObject(object)
// Actor is nil
err := handleFollowInboxRequest(context.Background(), activity)
if err == nil {
t.Error("handleFollowInboxRequest with nil actor should return error")
}
}
func TestHandleAnnounceWithNilObject(t *testing.T) {
activity := streams.NewActivityStreamsAnnounce()
// Don't set object or actor
err := handleAnnounceRequest(context.Background(), activity)
if err == nil {
t.Error("handleAnnounceRequest with nil object should return error")
}
}
func TestHandleAnnounceWithEmptyObject(t *testing.T) {
activity := streams.NewActivityStreamsAnnounce()
object := streams.NewActivityStreamsObjectProperty()
activity.SetActivityStreamsObject(object)
err := handleAnnounceRequest(context.Background(), activity)
if err == nil {
t.Error("handleAnnounceRequest with empty object should return error")
}
}
func TestHandleAnnounceWithNilActorIRI(t *testing.T) {
activity := streams.NewActivityStreamsAnnounce()
// Set object with IRI
object := streams.NewActivityStreamsObjectProperty()
object.AppendIRI(mustParseURL("https://example.com/note/1"))
activity.SetActivityStreamsObject(object)
// Actor is nil
err := handleAnnounceRequest(context.Background(), activity)
if err == nil {
t.Error("handleAnnounceRequest with nil actor should return error")
}
}
func TestHandleLikeWithNilObject(t *testing.T) {
activity := streams.NewActivityStreamsLike()
// Don't set object or actor
err := handleLikeRequest(context.Background(), activity)
if err == nil {
t.Error("handleLikeRequest with nil object should return error")
}
}
func TestHandleLikeWithEmptyObject(t *testing.T) {
activity := streams.NewActivityStreamsLike()
object := streams.NewActivityStreamsObjectProperty()
activity.SetActivityStreamsObject(object)
err := handleLikeRequest(context.Background(), activity)
if err == nil {
t.Error("handleLikeRequest with empty object should return error")
}
}
func TestHandleLikeWithNilActorIRI(t *testing.T) {
activity := streams.NewActivityStreamsLike()
// Set object with IRI
object := streams.NewActivityStreamsObjectProperty()
object.AppendIRI(mustParseURL("https://example.com/note/1"))
activity.SetActivityStreamsObject(object)
// Actor is nil
err := handleLikeRequest(context.Background(), activity)
if err == nil {
t.Error("handleLikeRequest with nil actor should return error")
}
}
func TestHandleCreateWithNilId(t *testing.T) {
activity := streams.NewActivityStreamsCreate()
// Don't set JSONLD ID
err := handleCreateRequest(context.Background(), activity)
if err == nil {
t.Error("handleCreateRequest with nil ID should return error")
}
}
func TestHandleCreateWithIdButNilIRI(t *testing.T) {
activity := streams.NewActivityStreamsCreate()
id := streams.NewJSONLDIdProperty()
// Set the ID property but don't set an IRI on it
activity.SetJSONLDId(id)
err := handleCreateRequest(context.Background(), activity)
if err == nil {
t.Error("handleCreateRequest with ID but nil IRI should return error")
}
}
func TestHandleUpdateWithNilObject(t *testing.T) {
activity := streams.NewActivityStreamsUpdate()
// Don't set object - should return nil (not an error, just skip)
// This should not panic and should return nil since we only care about Person updates
err := handleUpdateRequest(context.Background(), activity)
if err != nil {
t.Errorf("handleUpdateRequest with nil object should return nil (skip), got %v", err)
}
}
func TestHandleUpdateWithEmptyObject(t *testing.T) {
activity := streams.NewActivityStreamsUpdate()
object := streams.NewActivityStreamsObjectProperty()
activity.SetActivityStreamsObject(object)
// Should return nil since empty object means it's not a Person update
err := handleUpdateRequest(context.Background(), activity)
if err != nil {
t.Errorf("handleUpdateRequest with empty object should return nil (skip), got %v", err)
}
}
func TestHandleUpdateWithNonPersonObject(t *testing.T) {
activity := streams.NewActivityStreamsUpdate()
object := streams.NewActivityStreamsObjectProperty()
note := streams.NewActivityStreamsNote()
object.AppendActivityStreamsNote(note)
activity.SetActivityStreamsObject(object)
// Should return nil since it's not a Person update
err := handleUpdateRequest(context.Background(), activity)
if err != nil {
t.Errorf("handleUpdateRequest with non-Person object should return nil (skip), got %v", err)
}
}
// TestNilSafetyNoPanic verifies that none of the handlers panic when given
// completely empty activities. This is the most important test - we want to
// ensure that malformed ActivityPub payloads don't crash the server.
func TestNilSafetyNoPanic(t *testing.T) {
ctx := context.Background()
t.Run("Follow with nil properties", func(t *testing.T) {
defer func() {
if r := recover(); r != nil {
t.Errorf("handleFollowInboxRequest panicked: %v", r)
}
}()
activity := streams.NewActivityStreamsFollow()
_ = handleFollowInboxRequest(ctx, activity)
})
t.Run("Announce with nil properties", func(t *testing.T) {
defer func() {
if r := recover(); r != nil {
t.Errorf("handleAnnounceRequest panicked: %v", r)
}
}()
activity := streams.NewActivityStreamsAnnounce()
_ = handleAnnounceRequest(ctx, activity)
})
t.Run("Like with nil properties", func(t *testing.T) {
defer func() {
if r := recover(); r != nil {
t.Errorf("handleLikeRequest panicked: %v", r)
}
}()
activity := streams.NewActivityStreamsLike()
_ = handleLikeRequest(ctx, activity)
})
t.Run("Create with nil properties", func(t *testing.T) {
defer func() {
if r := recover(); r != nil {
t.Errorf("handleCreateRequest panicked: %v", r)
}
}()
activity := streams.NewActivityStreamsCreate()
_ = handleCreateRequest(ctx, activity)
})
t.Run("Update with nil properties", func(t *testing.T) {
defer func() {
if r := recover(); r != nil {
t.Errorf("handleUpdateRequest panicked: %v", r)
}
}()
activity := streams.NewActivityStreamsUpdate()
_ = handleUpdateRequest(ctx, activity)
})
}
+3 -5
View File
@@ -4,15 +4,14 @@ import (
"context" "context"
"github.com/go-fed/activity/streams/vocab" "github.com/go-fed/activity/streams/vocab"
"github.com/owncast/owncast/activitypub/apmodels" "github.com/owncast/owncast/activitypub/persistence"
"github.com/owncast/owncast/activitypub/persistence/followersrepository"
"github.com/owncast/owncast/activitypub/resolvers" "github.com/owncast/owncast/activitypub/resolvers"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
func handleUpdateRequest(c context.Context, activity vocab.ActivityStreamsUpdate) error { func handleUpdateRequest(c context.Context, activity vocab.ActivityStreamsUpdate) error {
// We only care about update events to followers. // We only care about update events to followers.
if !apmodels.IsFirstObjectActivityStreamsPerson(activity.GetActivityStreamsObject()) { if !activity.GetActivityStreamsObject().At(0).IsActivityStreamsPerson() {
return nil return nil
} }
@@ -22,6 +21,5 @@ func handleUpdateRequest(c context.Context, activity vocab.ActivityStreamsUpdate
return err return err
} }
followersRepo := followersrepository.Get() return persistence.UpdateFollower(actor.ActorIri.String(), actor.Inbox.String(), actor.Name, actor.FullUsername, actor.Image.String())
return followersRepo.Update(actor.ActorIriString(), actor.InboxString(), actor.SharedInboxString(), actor.Name, actor.FullUsername, actor.ImageString())
} }
+5 -11
View File
@@ -13,9 +13,9 @@ import (
"github.com/go-fed/httpsig" "github.com/go-fed/httpsig"
"github.com/owncast/owncast/activitypub/apmodels" "github.com/owncast/owncast/activitypub/apmodels"
"github.com/owncast/owncast/activitypub/persistence/followersrepository" "github.com/owncast/owncast/activitypub/persistence"
"github.com/owncast/owncast/activitypub/resolvers" "github.com/owncast/owncast/activitypub/resolvers"
"github.com/owncast/owncast/persistence/configrepository" "github.com/owncast/owncast/core/data"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
@@ -96,10 +96,7 @@ func Verify(request *http.Request) (bool, error) {
return false, err return false, err
} }
key, err := apmodels.GetPublicKeyPem(publicKey) key := publicKey.GetW3IDSecurityV1PublicKeyPem().Get()
if err != nil {
return false, errors.Wrap(err, "failed to get public key PEM")
}
block, _ := pem.Decode([]byte(key)) block, _ := pem.Decode([]byte(key))
if block == nil { if block == nil {
log.Errorln("failed to parse PEM block containing the public key") log.Errorln("failed to parse PEM block containing the public key")
@@ -134,9 +131,7 @@ func Verify(request *http.Request) (bool, error) {
} }
func isBlockedDomain(domain string) bool { func isBlockedDomain(domain string) bool {
configRepository := configrepository.Get() blockedDomains := data.GetBlockedFederatedDomains()
blockedDomains := configRepository.GetBlockedFederatedDomains()
for _, blockedDomain := range blockedDomains { for _, blockedDomain := range blockedDomains {
if strings.Contains(domain, blockedDomain) { if strings.Contains(domain, blockedDomain) {
@@ -148,8 +143,7 @@ func isBlockedDomain(domain string) bool {
} }
func isBlockedActor(actorIRI *url.URL) (bool, error) { func isBlockedActor(actorIRI *url.URL) (bool, error) {
followersRepo := followersrepository.Get() blockedactor, err := persistence.GetFollower(actorIRI.String())
blockedactor, err := followersRepo.GetByIRI(actorIRI.String())
if blockedactor != nil && blockedactor.DisabledAt != nil { if blockedactor != nil && blockedactor.DisabledAt != nil {
return true, errors.Wrap(err, "remote actor is blocked") return true, errors.Wrap(err, "remote actor is blocked")
+6 -12
View File
@@ -8,9 +8,7 @@ import (
"github.com/go-fed/activity/streams/vocab" "github.com/go-fed/activity/streams/vocab"
"github.com/owncast/owncast/activitypub/apmodels" "github.com/owncast/owncast/activitypub/apmodels"
"github.com/owncast/owncast/activitypub/persistence" "github.com/owncast/owncast/activitypub/persistence"
"github.com/owncast/owncast/activitypub/persistence/followersrepository"
"github.com/owncast/owncast/core/data" "github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/persistence/configrepository"
) )
func makeFakePerson() vocab.ActivityStreamsPerson { func makeFakePerson() vocab.ActivityStreamsPerson {
@@ -51,24 +49,21 @@ func makeFakePerson() vocab.ActivityStreamsPerson {
func TestMain(m *testing.M) { func TestMain(m *testing.M) {
data.SetupPersistence(":memory:") data.SetupPersistence(":memory:")
configRepository := configrepository.Get() data.SetServerURL("https://my.cool.site.biz")
configRepository.SetServerURL("https://my.cool.site.biz")
persistence.Setup(data.GetDatastore()) persistence.Setup(data.GetDatastore())
m.Run() m.Run()
} }
func TestBlockedDomains(t *testing.T) { func TestBlockedDomains(t *testing.T) {
configRepository := configrepository.Get()
person := makeFakePerson() person := makeFakePerson()
configRepository.SetBlockedFederatedDomains([]string{"freedom.eagle", "guns.life"}) data.SetBlockedFederatedDomains([]string{"freedom.eagle", "guns.life"})
if len(configRepository.GetBlockedFederatedDomains()) != 2 { if len(data.GetBlockedFederatedDomains()) != 2 {
t.Error("Blocked federated domains is not set correctly") t.Error("Blocked federated domains is not set correctly")
} }
for _, domain := range configRepository.GetBlockedFederatedDomains() { for _, domain := range data.GetBlockedFederatedDomains() {
if domain == person.GetJSONLDId().GetIRI().Host { if domain == person.GetJSONLDId().GetIRI().Host {
return return
} }
@@ -80,14 +75,13 @@ func TestBlockedDomains(t *testing.T) {
func TestBlockedActors(t *testing.T) { func TestBlockedActors(t *testing.T) {
person := makeFakePerson() person := makeFakePerson()
fakeRequest := streams.NewActivityStreamsFollow() fakeRequest := streams.NewActivityStreamsFollow()
followersRepo := followersrepository.Get() persistence.AddFollow(apmodels.ActivityPubActor{
followersRepo.Add(apmodels.ActivityPubActor{
ActorIri: person.GetJSONLDId().GetIRI(), ActorIri: person.GetJSONLDId().GetIRI(),
Inbox: person.GetJSONLDId().GetIRI(), Inbox: person.GetJSONLDId().GetIRI(),
FollowRequestIri: person.GetJSONLDId().GetIRI(), FollowRequestIri: person.GetJSONLDId().GetIRI(),
RequestObject: fakeRequest, RequestObject: fakeRequest,
}, false) }, false)
followersRepo.BlockOrReject(person.GetJSONLDId().GetIRI().String()) persistence.BlockOrRejectFollower(person.GetJSONLDId().GetIRI().String())
blocked, err := isBlockedActor(person.GetJSONLDId().GetIRI()) blocked, err := isBlockedActor(person.GetJSONLDId().GetIRI())
if err != nil { if err != nil {
+6 -13
View File
@@ -1,14 +1,14 @@
package inbox package inbox
import ( import (
"runtime"
"github.com/owncast/owncast/activitypub/apmodels" "github.com/owncast/owncast/activitypub/apmodels"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
// workerPoolSize defines the number of concurrent ActivityPub handlers. const (
var workerPoolSize = runtime.GOMAXPROCS(0) // InboxWorkerPoolSize defines the number of concurrent ActivityPub handlers.
InboxWorkerPoolSize = 10
)
// Job struct bundling the ActivityPub and the payload in one struct. // Job struct bundling the ActivityPub and the payload in one struct.
type Job struct { type Job struct {
@@ -22,7 +22,7 @@ func InitInboxWorkerPool() {
queue = make(chan Job) queue = make(chan Job)
// start workers // start workers
for i := 1; i <= workerPoolSize; i++ { for i := 1; i <= InboxWorkerPoolSize; i++ {
go worker(i, queue) go worker(i, queue)
} }
} }
@@ -37,14 +37,7 @@ func worker(workerID int, queue <-chan Job) {
log.Debugf("Started ActivityPub worker %d", workerID) log.Debugf("Started ActivityPub worker %d", workerID)
for job := range queue { for job := range queue {
func() { handle(job.request)
defer func() {
if r := recover(); r != nil {
log.Errorf("Recovered from panic in ActivityPub worker %d: %v", workerID, r)
}
}()
handle(job.request)
}()
log.Tracef("Done with ActivityPub inbox handler using worker %d", workerID) log.Tracef("Done with ActivityPub inbox handler using worker %d", workerID)
} }
-117
View File
@@ -1,117 +0,0 @@
package jobs
import (
"time"
"github.com/owncast/owncast/activitypub/persistence/followersrepository"
"github.com/owncast/owncast/activitypub/resolvers"
"github.com/owncast/owncast/config"
"github.com/owncast/owncast/models"
"github.com/owncast/owncast/persistence/configrepository"
log "github.com/sirupsen/logrus"
)
const (
// ValidationInterval is how often the validation job runs.
ValidationInterval = 1 * time.Hour
// FollowersPerRun is how many followers to validate per job run.
FollowersPerRun = 5
// FailureDurationThreshold is how long a follower must be unreachable before removal.
FailureDurationThreshold = 7 * 24 * time.Hour // 7 days
// DelayBetweenFollowers is the delay between validating individual followers.
DelayBetweenFollowers = 2 * time.Second
)
// GetValidationInterval returns the configured validation interval, or the default.
func GetValidationInterval() time.Duration {
if config.FollowerValidationInterval > 0 {
return config.FollowerValidationInterval
}
return ValidationInterval
}
// StartFollowerValidationJob starts the background job that periodically validates followers.
func StartFollowerValidationJob() {
interval := GetValidationInterval()
ticker := time.NewTicker(interval)
go func() {
for range ticker.C {
runFollowerValidation()
}
}()
log.Debugf("Follower validation job scheduled with interval %v", interval)
}
func runFollowerValidation() {
configRepo := configrepository.Get()
if !configRepo.GetFederationEnabled() {
return
}
followersRepo := followersrepository.Get()
followers, err := followersRepo.GetFollowersToValidate(FollowersPerRun)
if err != nil {
log.Errorln("Failed to get followers for validation:", err)
return
}
for _, follower := range followers {
validateAndUpdateFollower(followersRepo, follower)
time.Sleep(DelayBetweenFollowers)
}
}
func validateAndUpdateFollower(repo followersrepository.FollowersRepository, follower models.Follower) {
resolvedActor, err := resolvers.GetResolvedActorFromIRI(follower.ActorIRI)
if err != nil {
handleValidationFailure(repo, follower, err)
return
}
// Success - clear failure timestamp and update data
if err := repo.UpdateFollowerValidationSuccess(follower.ActorIRI); err != nil {
log.Errorln("Failed to update validation success:", err)
}
// Update follower data
if err := repo.Update(
resolvedActor.ActorIriString(),
resolvedActor.InboxString(),
resolvedActor.SharedInboxString(),
resolvedActor.Name,
resolvedActor.FullUsername,
resolvedActor.ImageString(),
); err != nil {
log.Errorln("Failed to update follower data:", err)
}
}
func handleValidationFailure(repo followersrepository.FollowersRepository, follower models.Follower, resolveErr error) {
log.Debugf("Follower validation failed for %s: %v", follower.ActorIRI, resolveErr)
// Check removal eligibility BEFORE updating failure timestamp.
// We use the existing FirstValidationFailureAt from the database to determine
// if this follower has been failing long enough to be removed.
// If FirstValidationFailureAt is not set, this is a new failure and we just record it.
shouldRemove := false
if follower.FirstValidationFailureAt.Valid {
failureDuration := time.Since(follower.FirstValidationFailureAt.Time)
shouldRemove = failureDuration >= FailureDurationThreshold
}
// Update failure timestamp (sets first_validation_failure_at if not already set)
if err := repo.UpdateFollowerValidationFailure(follower.ActorIRI); err != nil {
log.Errorln("Failed to update validation failure:", err)
return
}
// Remove follower if they've exceeded the failure threshold
if shouldRemove {
failureDuration := time.Since(follower.FirstValidationFailureAt.Time)
log.Infof("Removing follower %s after %v of consecutive failures",
follower.ActorIRI, failureDuration.Round(time.Hour))
if err := repo.RemoveByIRI(follower.ActorIRI); err != nil {
log.Errorln("Failed to remove invalid follower:", err)
}
}
}
-351
View File
@@ -1,351 +0,0 @@
package jobs
import (
"net/url"
"os"
"testing"
"time"
"github.com/go-fed/activity/streams"
"github.com/owncast/owncast/activitypub/apmodels"
"github.com/owncast/owncast/activitypub/persistence"
"github.com/owncast/owncast/activitypub/persistence/followersrepository"
"github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/utils"
)
func TestMain(m *testing.M) {
setup()
code := m.Run()
os.Exit(code)
}
var datastore *data.Datastore
func setup() {
resetTestDatabase()
}
// resetTestDatabase initializes a fresh in-memory database for testing.
func resetTestDatabase() {
data.SetupPersistence(":memory:")
datastore = data.GetDatastore()
persistence.Setup(datastore)
}
// setupTestWithRepo resets the database and returns a new repository instance.
func setupTestWithRepo(t *testing.T) followersrepository.FollowersRepository {
t.Helper()
resetTestDatabase()
return followersrepository.New(datastore)
}
func createTestFollower(repo followersrepository.FollowersRepository, iri, inbox, name, username string) {
actorIRI, _ := url.Parse(iri)
inboxURL, _ := url.Parse(inbox)
requestIRI, _ := url.Parse("https://fake.server/follow/request")
fakeRequest := streams.NewActivityStreamsFollow()
repo.Add(apmodels.ActivityPubActor{
ActorIri: actorIRI,
Inbox: inboxURL,
Name: name,
Username: username,
FullUsername: username + "@fake.server",
FollowRequestIri: requestIRI,
RequestObject: fakeRequest,
}, true)
}
func TestGetFollowersToValidate(t *testing.T) {
repo := setupTestWithRepo(t)
// Create some test followers
for i := 0; i < 10; i++ {
user, _ := utils.GenerateRandomString(10)
createTestFollower(repo, "https://fake.server/user/"+user, "https://fake.server/user/"+user+"/inbox", user, user)
}
// Get followers to validate
followers, err := repo.GetFollowersToValidate(5)
if err != nil {
t.Fatalf("Error getting followers to validate: %s", err)
}
if len(followers) != 5 {
t.Errorf("Expected 5 followers to validate, got %d", len(followers))
}
}
func TestUpdateFollowerValidationSuccess(t *testing.T) {
repo := setupTestWithRepo(t)
// Create a test follower
testIRI := "https://fake.server/user/testuser"
createTestFollower(repo, testIRI, "https://fake.server/user/testuser/inbox", "Test User", "testuser")
// Mark validation as successful
err := repo.UpdateFollowerValidationSuccess(testIRI)
if err != nil {
t.Fatalf("Error updating follower validation success: %s", err)
}
// Verify the follower was updated
followers, err := repo.GetFollowersToValidate(10)
if err != nil {
t.Fatalf("Error getting followers: %s", err)
}
// After validation success, FirstValidationFailureAt should be NULL/invalid
for _, f := range followers {
if f.ActorIRI == testIRI {
if f.FirstValidationFailureAt.Valid {
t.Error("Expected FirstValidationFailureAt to be NULL after success")
}
}
}
}
func TestUpdateFollowerValidationFailure(t *testing.T) {
repo := setupTestWithRepo(t)
// Create a test follower
testIRI := "https://fake.server/user/testuser"
createTestFollower(repo, testIRI, "https://fake.server/user/testuser/inbox", "Test User", "testuser")
// Mark validation as failed
err := repo.UpdateFollowerValidationFailure(testIRI)
if err != nil {
t.Fatalf("Error updating follower validation failure: %s", err)
}
// Verify the FirstValidationFailureAt is set
followers, err := repo.GetFollowersToValidate(10)
if err != nil {
t.Fatalf("Error getting followers: %s", err)
}
found := false
for _, f := range followers {
if f.ActorIRI == testIRI {
found = true
if !f.FirstValidationFailureAt.Valid {
t.Error("Expected FirstValidationFailureAt to be set after failure")
}
}
}
if !found {
t.Error("Test follower not found in results")
}
}
func TestValidationFailureClearedOnSuccess(t *testing.T) {
repo := setupTestWithRepo(t)
// Create a test follower
testIRI := "https://fake.server/user/testuser"
createTestFollower(repo, testIRI, "https://fake.server/user/testuser/inbox", "Test User", "testuser")
// Mark validation as failed first
err := repo.UpdateFollowerValidationFailure(testIRI)
if err != nil {
t.Fatalf("Error updating follower validation failure: %s", err)
}
// Then mark as successful
err = repo.UpdateFollowerValidationSuccess(testIRI)
if err != nil {
t.Fatalf("Error updating follower validation success: %s", err)
}
// Verify FirstValidationFailureAt is cleared
followers, err := repo.GetFollowersToValidate(10)
if err != nil {
t.Fatalf("Error getting followers: %s", err)
}
for _, f := range followers {
if f.ActorIRI == testIRI {
if f.FirstValidationFailureAt.Valid {
t.Error("Expected FirstValidationFailureAt to be NULL after success")
}
}
}
}
func TestRemoveByIRI(t *testing.T) {
repo := setupTestWithRepo(t)
// Create a test follower
testIRI := "https://fake.server/user/testuser"
createTestFollower(repo, testIRI, "https://fake.server/user/testuser/inbox", "Test User", "testuser")
// Verify follower exists
count, err := repo.GetCount()
if err != nil {
t.Fatalf("Error getting count: %s", err)
}
if count != 1 {
t.Errorf("Expected 1 follower, got %d", count)
}
// Remove the follower
err = repo.RemoveByIRI(testIRI)
if err != nil {
t.Fatalf("Error removing follower: %s", err)
}
// Verify follower is removed
count, err = repo.GetCount()
if err != nil {
t.Fatalf("Error getting count: %s", err)
}
if count != 0 {
t.Errorf("Expected 0 followers after removal, got %d", count)
}
}
func TestFailureDurationThresholdLogic(t *testing.T) {
repo := setupTestWithRepo(t)
// Create a test follower
testIRI := "https://fake.server/user/testfailure"
createTestFollower(repo, testIRI, "https://fake.server/user/testfailure/inbox", "Test User", "testfailure")
// Set first_validation_failure_at to 8 days ago (past threshold)
eightDaysAgo := time.Now().Add(-8 * 24 * time.Hour)
_, err := datastore.DB.Exec(
"UPDATE ap_followers SET first_validation_failure_at = ? WHERE iri = ?",
eightDaysAgo, testIRI,
)
if err != nil {
t.Fatalf("Error setting first_validation_failure_at: %s", err)
}
// Fetch the follower
followers, err := repo.GetFollowersToValidate(1)
if err != nil {
t.Fatalf("Error getting followers: %s", err)
}
if len(followers) != 1 {
t.Fatalf("Expected 1 follower, got %d", len(followers))
}
// Verify the failure duration is calculated correctly
failureDuration := time.Since(followers[0].FirstValidationFailureAt.Time)
if failureDuration < FailureDurationThreshold {
t.Errorf("Expected failure duration (%v) to be >= threshold (%v)", failureDuration, FailureDurationThreshold)
}
// Test removal - this directly tests the removal logic without network calls
err = repo.RemoveByIRI(testIRI)
if err != nil {
t.Fatalf("Error removing follower: %s", err)
}
// Verify the follower was removed
count, err := repo.GetCount()
if err != nil {
t.Fatalf("Error getting count: %s", err)
}
if count != 0 {
t.Errorf("Expected follower to be removed, but count is %d", count)
}
}
func TestFailureNotRemovedBeforeThreshold(t *testing.T) {
repo := setupTestWithRepo(t)
// Create a test follower
testIRI := "https://fake.server/user/testnotremoved"
createTestFollower(repo, testIRI, "https://fake.server/user/testnotremoved/inbox", "Test User", "testnotremoved")
// Set first_validation_failure_at to 1 day ago (not past threshold)
oneDayAgo := time.Now().Add(-1 * 24 * time.Hour)
_, err := datastore.DB.Exec(
"UPDATE ap_followers SET first_validation_failure_at = ? WHERE iri = ?",
oneDayAgo, testIRI,
)
if err != nil {
t.Fatalf("Error setting first_validation_failure_at: %s", err)
}
// Fetch the follower
followers, err := repo.GetFollowersToValidate(1)
if err != nil {
t.Fatalf("Error getting followers: %s", err)
}
if len(followers) != 1 {
t.Fatalf("Expected 1 follower, got %d", len(followers))
}
// Verify the failure duration is below threshold
failureDuration := time.Since(followers[0].FirstValidationFailureAt.Time)
if failureDuration >= FailureDurationThreshold {
t.Errorf("Expected failure duration (%v) to be < threshold (%v)", failureDuration, FailureDurationThreshold)
}
// Follower should NOT be removed yet
count, err := repo.GetCount()
if err != nil {
t.Fatalf("Error getting count: %s", err)
}
if count != 1 {
t.Errorf("Expected follower to NOT be removed yet, but count is %d", count)
}
}
func TestFollowersOrderedByOldestValidatedFirst(t *testing.T) {
repo := setupTestWithRepo(t)
// Create followers
iri1 := "https://fake.server/user/first"
iri2 := "https://fake.server/user/second"
iri3 := "https://fake.server/user/third"
createTestFollower(repo, iri1, "https://fake.server/user/first/inbox", "First", "first")
createTestFollower(repo, iri2, "https://fake.server/user/second/inbox", "Second", "second")
createTestFollower(repo, iri3, "https://fake.server/user/third/inbox", "Third", "third")
// Set different last_validated_at times
now := time.Now()
_, err := datastore.DB.Exec("UPDATE ap_followers SET last_validated_at = ? WHERE iri = ?",
now.Add(-3*time.Hour), iri1)
if err != nil {
t.Fatalf("Error updating: %s", err)
}
_, err = datastore.DB.Exec("UPDATE ap_followers SET last_validated_at = ? WHERE iri = ?",
now.Add(-1*time.Hour), iri2)
if err != nil {
t.Fatalf("Error updating: %s", err)
}
// iri3 has NULL last_validated_at (never validated)
// Get followers - should return in order: iri3 (NULL), iri1 (oldest), iri2 (newest)
followers, err := repo.GetFollowersToValidate(3)
if err != nil {
t.Fatalf("Error getting followers: %s", err)
}
if len(followers) != 3 {
t.Fatalf("Expected 3 followers, got %d", len(followers))
}
// NULL values should come first (NULLS FIRST in query)
if followers[0].ActorIRI != iri3 {
t.Errorf("Expected first follower to be %s (never validated), got %s", iri3, followers[0].ActorIRI)
}
// Then oldest validated
if followers[1].ActorIRI != iri1 {
t.Errorf("Expected second follower to be %s (oldest validated), got %s", iri1, followers[1].ActorIRI)
}
// Then newest validated
if followers[2].ActorIRI != iri2 {
t.Errorf("Expected third follower to be %s (newest validated), got %s", iri2, followers[2].ActorIRI)
}
}
-79
View File
@@ -1,79 +0,0 @@
package outbox
import (
"testing"
"github.com/go-fed/activity/streams"
"github.com/go-fed/activity/streams/vocab"
)
// TestAddWithNilId verifies that Add doesn't panic when given an item with nil JSONLD ID.
func TestAddWithNilId(t *testing.T) {
defer func() {
if r := recover(); r != nil {
t.Errorf("Add panicked with nil ID: %v", r)
}
}()
note := streams.NewActivityStreamsNote()
// Don't set JSONLD ID
err := Add(note, "test-id", false)
if err == nil {
t.Error("Add with nil ID should return error")
}
}
// TestAddWithIdButNilIRI verifies that Add doesn't panic when given an item
// with a JSONLD ID property that has no IRI set.
func TestAddWithIdButNilIRI(t *testing.T) {
defer func() {
if r := recover(); r != nil {
t.Errorf("Add panicked with ID but nil IRI: %v", r)
}
}()
note := streams.NewActivityStreamsNote()
id := streams.NewJSONLDIdProperty()
// Set the ID property but don't set an IRI on it
note.SetJSONLDId(id)
err := Add(note, "test-id", false)
if err == nil {
t.Error("Add with ID but nil IRI should return error")
}
}
// TestAddNoPanic verifies that Add doesn't panic with various nil/empty inputs.
func TestAddNoPanic(t *testing.T) {
testCases := []struct {
name string
item func() vocab.ActivityStreamsNote
}{
{
name: "note with no properties",
item: func() vocab.ActivityStreamsNote {
return streams.NewActivityStreamsNote()
},
},
{
name: "note with empty ID property",
item: func() vocab.ActivityStreamsNote {
note := streams.NewActivityStreamsNote()
note.SetJSONLDId(streams.NewJSONLDIdProperty())
return note
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
defer func() {
if r := recover(); r != nil {
t.Errorf("Add panicked: %v", r)
}
}()
_ = Add(tc.item(), "test-id", false)
})
}
}
+37 -115
View File
@@ -6,22 +6,20 @@ import (
"path/filepath" "path/filepath"
"regexp" "regexp"
"strings" "strings"
"time"
"github.com/go-fed/activity/streams" "github.com/go-fed/activity/streams"
"github.com/go-fed/activity/streams/vocab" "github.com/go-fed/activity/streams/vocab"
"github.com/owncast/owncast/activitypub/apmodels" "github.com/owncast/owncast/activitypub/apmodels"
"github.com/owncast/owncast/activitypub/crypto" "github.com/owncast/owncast/activitypub/crypto"
"github.com/owncast/owncast/activitypub/persistence" "github.com/owncast/owncast/activitypub/persistence"
"github.com/owncast/owncast/activitypub/persistence/followersrepository"
"github.com/owncast/owncast/activitypub/requests" "github.com/owncast/owncast/activitypub/requests"
"github.com/owncast/owncast/activitypub/resolvers" "github.com/owncast/owncast/activitypub/resolvers"
"github.com/owncast/owncast/activitypub/webfinger" "github.com/owncast/owncast/activitypub/webfinger"
"github.com/owncast/owncast/activitypub/workerpool" "github.com/owncast/owncast/activitypub/workerpool"
"github.com/owncast/owncast/persistence/configrepository"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/owncast/owncast/config" "github.com/owncast/owncast/config"
"github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/utils" "github.com/owncast/owncast/utils"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"github.com/teris-io/shortid" "github.com/teris-io/shortid"
@@ -29,9 +27,7 @@ import (
// SendLive will send all followers the message saying you started a live stream. // SendLive will send all followers the message saying you started a live stream.
func SendLive() error { func SendLive() error {
configRepository := configrepository.Get() textContent := data.GetFederationGoLiveMessage()
textContent := configRepository.GetFederationGoLiveMessage()
// If the message is empty then do not send it. // If the message is empty then do not send it.
if textContent == "" { if textContent == "" {
@@ -42,7 +38,7 @@ func SendLive() error {
reg := regexp.MustCompile("[^a-zA-Z0-9]+") reg := regexp.MustCompile("[^a-zA-Z0-9]+")
tagProp := streams.NewActivityStreamsTagProperty() tagProp := streams.NewActivityStreamsTagProperty()
for _, tagString := range configRepository.GetServerMetadataTags() { for _, tagString := range data.GetServerMetadataTags() {
tagWithoutSpecialCharacters := reg.ReplaceAllString(tagString, "") tagWithoutSpecialCharacters := reg.ReplaceAllString(tagString, "")
hashtag := apmodels.MakeHashtag(tagWithoutSpecialCharacters) hashtag := apmodels.MakeHashtag(tagWithoutSpecialCharacters)
tagProp.AppendTootHashtag(hashtag) tagProp.AppendTootHashtag(hashtag)
@@ -61,28 +57,28 @@ func SendLive() error {
tagsString := strings.Join(tagStrings, " ") tagsString := strings.Join(tagStrings, " ")
var streamTitle string var streamTitle string
if title := configRepository.GetStreamTitle(); title != "" { if title := data.GetStreamTitle(); title != "" {
streamTitle = fmt.Sprintf("<p>%s</p>", 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()) textContent = fmt.Sprintf("<p>%s</p>%s<p>%s</p><a href=\"%s\">%s</a>", textContent, streamTitle, tagsString, data.GetServerURL(), data.GetServerURL())
activity, _, note, noteID := createBaseOutboundMessage(textContent) activity, _, note, noteID := createBaseOutboundMessage(textContent)
to, cc := getAddressingToFollowers() // To the public if we're not treating ActivityPub as "private".
note.SetActivityStreamsTo(to) if !data.GetFederationIsPrivate() {
note.SetActivityStreamsCc(cc) note = apmodels.MakeNotePublic(note)
activity.SetActivityStreamsTo(to) activity = apmodels.MakeActivityPublic(activity)
activity.SetActivityStreamsCc(cc) }
note.SetActivityStreamsTag(tagProp) note.SetActivityStreamsTag(tagProp)
// Attach an image along with the Federated message. // Attach an image along with the Federated message.
previewURL, err := url.Parse(configRepository.GetServerURL()) previewURL, err := url.Parse(data.GetServerURL())
if err == nil { if err == nil {
var imageToAttach string var imageToAttach string
var mediaType string var mediaType string
previewGif := filepath.Join(config.TempDir, "preview.gif") previewGif := filepath.Join(config.WebRoot, "preview.gif")
thumbnailJpg := filepath.Join(config.TempDir, "thumbnail.jpg") thumbnailJpg := filepath.Join(config.WebRoot, "thumbnail.jpg")
uniquenessString := shortid.MustGenerate() uniquenessString := shortid.MustGenerate()
if utils.DoesFileExists(previewGif) { if utils.DoesFileExists(previewGif) {
imageToAttach = "preview.gif" imageToAttach = "preview.gif"
@@ -98,7 +94,7 @@ func SendLive() error {
} }
} }
if configRepository.GetNSFW() { if data.GetNSFW() {
// Mark content as sensitive. // Mark content as sensitive.
sensitive := streams.NewActivityStreamsSensitiveProperty() sensitive := streams.NewActivityStreamsSensitiveProperty()
sensitive.AppendXMLSchemaBoolean(true) sensitive.AppendXMLSchemaBoolean(true)
@@ -177,11 +173,10 @@ func SendPublicMessage(textContent string) error {
activity, _, note, noteID := createBaseOutboundMessage(textContent) activity, _, note, noteID := createBaseOutboundMessage(textContent)
note.SetActivityStreamsTag(tagProp) note.SetActivityStreamsTag(tagProp)
to, cc := getAddressingToFollowers() if !data.GetFederationIsPrivate() {
note.SetActivityStreamsTo(to) note = apmodels.MakeNotePublic(note)
note.SetActivityStreamsCc(cc) activity = apmodels.MakeActivityPublic(activity)
activity.SetActivityStreamsTo(to) }
activity.SetActivityStreamsCc(cc)
b, err := apmodels.Serialize(activity) b, err := apmodels.Serialize(activity)
if err != nil { if err != nil {
@@ -200,22 +195,9 @@ func SendPublicMessage(textContent string) error {
return nil return nil
} }
// if public, cc the followers and to the Public uri, else private, address followers directly.
func getAddressingToFollowers() (vocab.ActivityStreamsToProperty, vocab.ActivityStreamsCcProperty) {
configRepository := configrepository.Get()
server_url := configRepository.GetServerURL()
followers_iri, _ := url.Parse(server_url)
username := configRepository.GetDefaultFederationUsername()
followers_iri = followers_iri.JoinPath("federation", "user", username, "followers")
return apmodels.MakeAddressingToFollowers(followers_iri, !configRepository.GetFederationIsPrivate())
}
// nolint: unparam // nolint: unparam
func createBaseOutboundMessage(textContent string) (vocab.ActivityStreamsCreate, string, vocab.ActivityStreamsNote, string) { func createBaseOutboundMessage(textContent string) (vocab.ActivityStreamsCreate, string, vocab.ActivityStreamsNote, string) {
configRepository := configrepository.Get() localActor := apmodels.MakeLocalIRIForAccount(data.GetDefaultFederationUsername())
localActor := apmodels.MakeLocalIRIForAccount(configRepository.GetDefaultFederationUsername())
noteID := shortid.MustGenerate() noteID := shortid.MustGenerate()
noteIRI := apmodels.MakeLocalIRIForResource(noteID) noteIRI := apmodels.MakeLocalIRIForResource(noteID)
id := shortid.MustGenerate() id := shortid.MustGenerate()
@@ -231,94 +213,35 @@ func createBaseOutboundMessage(textContent string) (vocab.ActivityStreamsCreate,
// Get Hashtag HTML link for a given tag (without # prefix). // Get Hashtag HTML link for a given tag (without # prefix).
func getHashtagLinkHTMLFromTagString(baseHashtag string) string { func getHashtagLinkHTMLFromTagString(baseHashtag string) string {
return fmt.Sprintf("<a class=\"hashtag\" href=\"https://owncast.directory/tags/%s\">#%s</a>", baseHashtag, baseHashtag) 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. // SendToFollowers will send an arbitrary payload to all follower inboxes.
// It uses shared inboxes when available to reduce the number of outbound requests.
func SendToFollowers(payload []byte) error { func SendToFollowers(payload []byte) error {
configRepository := configrepository.Get() localActor := apmodels.MakeLocalIRIForAccount(data.GetDefaultFederationUsername())
followersRepo := followersrepository.Get()
localActor := apmodels.MakeLocalIRIForAccount(configRepository.GetDefaultFederationUsername())
// Get unique delivery inboxes (prefers shared inboxes over individual inboxes) followers, _, err := persistence.GetFederationFollowers(-1, 0)
inboxes, err := followersRepo.GetUniqueDeliveryInboxes()
if err != nil { if err != nil {
log.Errorln("unable to fetch delivery inboxes", err) log.Errorln("unable to fetch followers to send to", err)
return errors.New("unable to fetch delivery inboxes to send payload to") return errors.New("unable to fetch followers to send payload to")
} }
// Batch size and delay to prevent resource exhaustion during delivery. for _, follower := range followers {
// This spreads CPU load from cryptographic signing over time. inbox, _ := url.Parse(follower.Inbox)
const batchSize = 50
const batchDelay = 100 * time.Millisecond
queued := 0
skipped := 0
for i, inboxURL := range inboxes {
inbox, err := url.Parse(inboxURL)
if err != nil {
log.Warnln("unable to parse inbox URL", inboxURL, err)
continue
}
// SSRF protection: reject non-HTTPS schemes and internal/loopback hosts.
// A malicious remote actor could set their inbox to an internal address
// to trick this server into making requests to internal services.
if inbox.Scheme != "https" {
log.Warnln("rejecting non-HTTPS inbox URL for SSRF protection:", inboxURL)
continue
}
if utils.IsHostnameInternal(inbox.Hostname()) {
log.Warnln("rejecting internal/loopback inbox URL for SSRF protection:", inboxURL)
continue
}
// Pre-check circuit breaker BEFORE expensive cryptographic signing.
// This saves CPU cycles for domains we know are failing.
if workerpool.ShouldSkipDomain(inbox.Host) {
skipped++
continue
}
req, err := crypto.CreateSignedRequest(payload, inbox, localActor) req, err := crypto.CreateSignedRequest(payload, inbox, localActor)
if err != nil { if err != nil {
log.Errorln("unable to create outbox request", inboxURL, err) log.Errorln("unable to create outbox request", follower.Inbox, err)
continue return errors.New("unable to create outbox request: " + follower.Inbox)
} }
workerpool.AddToOutboundQueue(req) workerpool.AddToOutboundQueue(req)
queued++
// Add a small delay between batches to spread out CPU and network load.
// This helps prevent ActivityPub delivery from competing with video encoding.
// Use queued count (not loop index) to ensure consistent rate limiting
// even when followers are skipped due to circuit breaker or parse errors.
if queued%batchSize == 0 && i+1 < len(inboxes) {
time.Sleep(batchDelay)
}
} }
if skipped > 0 {
log.Debugf("Skipped %d followers due to circuit breaker, queued %d", skipped, queued)
}
return nil return nil
} }
// SendToUser will send a payload to a single specific inbox. // SendToUser will send a payload to a single specific inbox.
func SendToUser(inbox *url.URL, payload []byte) error { func SendToUser(inbox *url.URL, payload []byte) error {
// SSRF protection: reject non-HTTPS schemes and internal/loopback hosts. localActor := apmodels.MakeLocalIRIForAccount(data.GetDefaultFederationUsername())
if inbox.Scheme != "https" {
return errors.Errorf("rejecting non-HTTPS inbox URL for SSRF protection: %s", inbox.String())
}
if utils.IsHostnameInternal(inbox.Hostname()) {
return errors.Errorf("rejecting internal/loopback inbox URL for SSRF protection: %s", inbox.String())
}
configRepository := configrepository.Get()
localActor := apmodels.MakeLocalIRIForAccount(configRepository.GetDefaultFederationUsername())
req, err := requests.CreateSignedRequest(payload, inbox, localActor) req, err := requests.CreateSignedRequest(payload, inbox, localActor)
if err != nil { if err != nil {
@@ -332,10 +255,8 @@ func SendToUser(inbox *url.URL, payload []byte) error {
// UpdateFollowersWithAccountUpdates will send an update to all followers alerting of a profile update. // UpdateFollowersWithAccountUpdates will send an update to all followers alerting of a profile update.
func UpdateFollowersWithAccountUpdates() error { func UpdateFollowersWithAccountUpdates() error {
configRepository := configrepository.Get()
// Don't do anything if federation is disabled. // Don't do anything if federation is disabled.
if !configRepository.GetFederationEnabled() { if !data.GetFederationEnabled() {
return nil return nil
} }
@@ -344,7 +265,7 @@ func UpdateFollowersWithAccountUpdates() error {
activity := apmodels.MakeUpdateActivity(objectID) activity := apmodels.MakeUpdateActivity(objectID)
actor := streams.NewActivityStreamsPerson() actor := streams.NewActivityStreamsPerson()
actorID := apmodels.MakeLocalIRIForAccount(configRepository.GetDefaultFederationUsername()) actorID := apmodels.MakeLocalIRIForAccount(data.GetDefaultFederationUsername())
actorIDProperty := streams.NewJSONLDIdProperty() actorIDProperty := streams.NewJSONLDIdProperty()
actorIDProperty.Set(actorID) actorIDProperty.Set(actorID)
actor.SetJSONLDId(actorIDProperty) actor.SetJSONLDId(actorIDProperty)
@@ -367,13 +288,14 @@ func UpdateFollowersWithAccountUpdates() error {
// Add will save an ActivityPub object to the datastore. // Add will save an ActivityPub object to the datastore.
func Add(item vocab.Type, id string, isLiveNotification bool) error { func Add(item vocab.Type, id string, isLiveNotification bool) error {
iri, err := apmodels.GetIRIStringFromJSONLDIdProperty(item.GetJSONLDId()) iri := item.GetJSONLDId().GetIRI().String()
if err != nil {
log.Errorln("Unable to get iri from item:", err)
return errors.Wrap(err, "unable to get iri from item "+id)
}
typeString := item.GetTypeName() 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) b, err := apmodels.Serialize(item)
if err != nil { if err != nil {
log.Errorln("unable to serialize model when saving to outbox", err) log.Errorln("unable to serialize model when saving to outbox", err)
+94 -3
View File
@@ -1,6 +1,12 @@
package persistence package persistence
import ( 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" log "github.com/sirupsen/logrus"
) )
@@ -10,7 +16,6 @@ func createFederationFollowersTable() {
createTableSQL := `CREATE TABLE IF NOT EXISTS ap_followers ( createTableSQL := `CREATE TABLE IF NOT EXISTS ap_followers (
"iri" TEXT NOT NULL, "iri" TEXT NOT NULL,
"inbox" TEXT NOT NULL, "inbox" TEXT NOT NULL,
"shared_inbox" TEXT,
"name" TEXT, "name" TEXT,
"username" TEXT NOT NULL, "username" TEXT NOT NULL,
"image" TEXT, "image" TEXT,
@@ -19,10 +24,96 @@ func createFederationFollowersTable() {
"approved_at" TIMESTAMP, "approved_at" TIMESTAMP,
"disabled_at" TIMESTAMP, "disabled_at" TIMESTAMP,
"request_object" BLOB, "request_object" BLOB,
"last_validated_at" TIMESTAMP,
"first_validation_failure_at" TIMESTAMP,
PRIMARY KEY (iri));` PRIMARY KEY (iri));`
_datastore.MustExec(createTableSQL) _datastore.MustExec(createTableSQL)
_datastore.MustExec(`CREATE INDEX IF NOT EXISTS idx_iri ON ap_followers (iri);`) _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);`) _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: int32(limit),
Offset: int32(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
}
-320
View File
@@ -1,320 +0,0 @@
package persistence
import (
"net/url"
"os"
"strings"
"testing"
"github.com/go-fed/activity/streams"
"github.com/owncast/owncast/activitypub/apmodels"
"github.com/owncast/owncast/activitypub/persistence/followersrepository"
"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()
followersRepo := followersrepository.New(_datastore)
number := 100
for i := 0; i < number; i++ {
u := createFakeFollower()
actorIRI, _ := url.Parse(u.ActorIRI)
inboxURL, _ := url.Parse(u.Inbox)
requestIRI, _ := url.Parse("https://fake.fediverse.server/some/request")
fakeRequest := streams.NewActivityStreamsFollow()
followersRepo.Add(apmodels.ActivityPubActor{
ActorIri: actorIRI,
Inbox: inboxURL,
Name: u.Name,
Username: u.Username,
FollowRequestIri: requestIRI,
RequestObject: fakeRequest,
}, true)
followers = append(followers, u)
}
}
func TestQueryFollowers(t *testing.T) {
followersRepo := followersrepository.New(_datastore)
f, total, err := followersRepo.GetFollowers(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) {
followersRepo := followersrepository.New(_datastore)
f, total, err := followersRepo.GetFollowers(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) {
followersRepo := followersrepository.New(_datastore)
f, total, err := followersRepo.GetFollowers(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) {
followersRepo := followersrepository.New(_datastore)
f, _, err := followersRepo.GetFollowers(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{},
}
}
func createTestFollower(followersRepo followersrepository.FollowersRepository, actor, inbox, sharedInbox, request, name, username string) {
actorIRI, _ := url.Parse(actor)
inboxURL, _ := url.Parse(inbox)
var sharedInboxURL *url.URL
if sharedInbox != "" {
sharedInboxURL, _ = url.Parse(sharedInbox)
}
requestIRI, _ := url.Parse(request)
fakeRequest := streams.NewActivityStreamsFollow()
followersRepo.Add(apmodels.ActivityPubActor{
ActorIri: actorIRI,
Inbox: inboxURL,
SharedInbox: sharedInboxURL,
Name: name,
Username: username,
FollowRequestIri: requestIRI,
RequestObject: fakeRequest,
}, true)
}
func TestGetUniqueDeliveryInboxes(t *testing.T) {
// Set up a fresh database for this test
data.SetupPersistence(":memory:")
ds := data.GetDatastore()
_datastore = ds
createFederationFollowersTable()
followersRepo := followersrepository.New(ds)
// Create followers from server1 with a shared inbox (3 users, 1 shared inbox)
server1SharedInbox := "https://server1.example.com/inbox"
for i := 0; i < 3; i++ {
user, _ := utils.GenerateRandomString(10)
createTestFollower(
followersRepo,
"https://server1.example.com/user/"+user,
"https://server1.example.com/user/"+user+"/inbox",
server1SharedInbox,
"https://server1.example.com/follow/"+user,
user,
user,
)
}
// Create followers from server2 with a shared inbox (2 users, 1 shared inbox)
server2SharedInbox := "https://server2.example.com/inbox"
for i := 0; i < 2; i++ {
user, _ := utils.GenerateRandomString(10)
createTestFollower(
followersRepo,
"https://server2.example.com/user/"+user,
"https://server2.example.com/user/"+user+"/inbox",
server2SharedInbox,
"https://server2.example.com/follow/"+user,
user,
user,
)
}
// Create followers from server3 WITHOUT a shared inbox (2 users, 2 individual inboxes)
for i := 0; i < 2; i++ {
user, _ := utils.GenerateRandomString(10)
createTestFollower(
followersRepo,
"https://server3.example.com/user/"+user,
"https://server3.example.com/user/"+user+"/inbox",
"",
"https://server3.example.com/follow/"+user,
user,
user,
)
}
// Total: 7 followers, but should result in 4 unique delivery inboxes:
// - 1 shared inbox for server1
// - 1 shared inbox for server2
// - 2 individual inboxes for server3
inboxes, err := followersRepo.GetUniqueDeliveryInboxes()
if err != nil {
t.Fatalf("Error getting unique delivery inboxes: %s", err)
}
if len(inboxes) != 4 {
t.Errorf("Expected 4 unique delivery inboxes, got %d: %v", len(inboxes), inboxes)
}
// Verify the shared inboxes are included
hasServer1Shared := false
hasServer2Shared := false
server3IndividualCount := 0
for _, inbox := range inboxes {
if inbox == server1SharedInbox {
hasServer1Shared = true
}
if inbox == server2SharedInbox {
hasServer2Shared = true
}
if len(inbox) > 0 && inbox != server1SharedInbox && inbox != server2SharedInbox {
// Should be one of server3's individual inboxes
if !strings.Contains(inbox, "server3.example.com") {
t.Errorf("Unexpected inbox in results: %s", inbox)
}
server3IndividualCount++
}
}
if !hasServer1Shared {
t.Error("Expected server1 shared inbox to be in results")
}
if !hasServer2Shared {
t.Error("Expected server2 shared inbox to be in results")
}
if server3IndividualCount != 2 {
t.Errorf("Expected 2 individual inboxes from server3, got %d", server3IndividualCount)
}
}
func TestSharedInboxPreferredOverIndividual(t *testing.T) {
// Set up a fresh database for this test
data.SetupPersistence(":memory:")
ds := data.GetDatastore()
_datastore = ds
createFederationFollowersTable()
followersRepo := followersrepository.New(ds)
// Create a single follower with both individual and shared inbox
sharedInbox := "https://mastodon.social/inbox"
individualInbox := "https://mastodon.social/users/testuser/inbox"
createTestFollower(
followersRepo,
"https://mastodon.social/users/testuser",
individualInbox,
sharedInbox,
"https://mastodon.social/follow/123",
"Test User",
"testuser",
)
inboxes, err := followersRepo.GetUniqueDeliveryInboxes()
if err != nil {
t.Fatalf("Error getting unique delivery inboxes: %s", err)
}
if len(inboxes) != 1 {
t.Errorf("Expected 1 unique delivery inbox, got %d", len(inboxes))
}
// The shared inbox should be returned, not the individual inbox
if inboxes[0] != sharedInbox {
t.Errorf("Expected shared inbox %s, got %s", sharedInbox, inboxes[0])
}
}
func TestIndividualInboxUsedWhenNoSharedInbox(t *testing.T) {
// Set up a fresh database for this test
data.SetupPersistence(":memory:")
ds := data.GetDatastore()
_datastore = ds
createFederationFollowersTable()
followersRepo := followersrepository.New(ds)
// Create a follower without a shared inbox
individualInbox := "https://pleroma.example.com/users/testuser/inbox"
createTestFollower(
followersRepo,
"https://pleroma.example.com/users/testuser",
individualInbox,
"",
"https://pleroma.example.com/follow/123",
"Test User",
"testuser",
)
inboxes, err := followersRepo.GetUniqueDeliveryInboxes()
if err != nil {
t.Fatalf("Error getting unique delivery inboxes: %s", err)
}
if len(inboxes) != 1 {
t.Errorf("Expected 1 unique delivery inbox, got %d", len(inboxes))
}
// The individual inbox should be returned when no shared inbox exists
if inboxes[0] != individualInbox {
t.Errorf("Expected individual inbox %s, got %s", individualInbox, inboxes[0])
}
}
@@ -1,472 +0,0 @@
package followersrepository
import (
"context"
"database/sql"
"encoding/json"
"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/core/data"
"github.com/owncast/owncast/db"
"github.com/owncast/owncast/models"
"github.com/owncast/owncast/utils"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
)
// FollowersRepository handles persistence of ActivityPub followers.
type FollowersRepository interface {
// GetCount returns the number of followers.
GetCount() (int64, error)
// GetFollowers returns a paginated list of followers.
GetFollowers(limit int, offset int) ([]models.Follower, int, error)
// GetPendingFollowRequests returns pending follow requests.
GetPendingFollowRequests() ([]models.Follower, error)
// GetBlockedAndRejected returns blocked and rejected followers.
GetBlockedAndRejected() ([]models.Follower, error)
// GetUniqueDeliveryInboxes returns unique inbox URLs for delivery.
GetUniqueDeliveryInboxes() ([]string, error)
// GetByIRI returns a single follower by IRI.
GetByIRI(iri string) (*apmodels.ActivityPubActor, error)
// Add saves a new follow to the datastore.
Add(follow apmodels.ActivityPubActor, approved bool) error
// Remove removes a follow from the datastore.
Remove(unfollow apmodels.ActivityPubActor) error
// ApprovePreviousRequest approves a pending follow request.
ApprovePreviousRequest(iri string) error
// BlockOrReject blocks an existing follower or rejects a follow request.
BlockOrReject(iri string) error
// Update updates the details of a stored follower.
Update(actorIRI string, inbox string, sharedInbox string, name string, username string, image string) error
// GetFollowersToValidate returns followers needing validation, ordered by oldest validated first.
GetFollowersToValidate(limit int) ([]models.Follower, error)
// UpdateFollowerValidationSuccess marks a follower as successfully validated and clears failure timestamp.
UpdateFollowerValidationSuccess(iri string) error
// UpdateFollowerValidationFailure marks a validation failure, setting first failure time if not already set.
UpdateFollowerValidationFailure(iri string) error
// RemoveByIRI removes a follower directly by IRI string.
RemoveByIRI(iri string) error
}
// SqlFollowersRepository is the SQL-based implementation of FollowersRepository.
type SqlFollowersRepository struct {
datastore *data.Datastore
}
// NOTE: This is temporary during the transition period.
var temporaryGlobalInstance FollowersRepository
// Get returns the followers repository singleton.
func Get() FollowersRepository {
if temporaryGlobalInstance == nil {
i := New(data.GetDatastore())
temporaryGlobalInstance = i
}
return temporaryGlobalInstance
}
// New creates a new instance of the FollowersRepository.
func New(datastore *data.Datastore) FollowersRepository {
r := SqlFollowersRepository{
datastore: datastore,
}
return &r
}
// GetCount returns the number of followers.
func (r *SqlFollowersRepository) GetCount() (int64, error) {
ctx := context.Background()
return r.datastore.GetQueries().GetFollowerCount(ctx)
}
// GetFollowers returns a paginated list of followers.
func (r *SqlFollowersRepository) GetFollowers(limit int, offset int) ([]models.Follower, int, error) {
ctx := context.Background()
total, err := r.datastore.GetQueries().GetFollowerCount(ctx)
if err != nil {
return nil, 0, errors.Wrap(err, "unable to fetch total number of followers")
}
followersResult, err := r.datastore.GetQueries().GetFederationFollowersWithOffset(ctx, db.GetFederationFollowersWithOffsetParams{
Limit: utils.SafeIntToInt32(limit),
Offset: utils.SafeIntToInt32(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,
SharedInbox: row.SharedInbox.String,
Timestamp: utils.NullTime(row.CreatedAt),
}
followers = append(followers, singleFollower)
}
return followers, int(total), nil
}
// GetPendingFollowRequests returns pending follow requests.
func (r *SqlFollowersRepository) GetPendingFollowRequests() ([]models.Follower, error) {
pendingFollowersResult, err := r.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,
SharedInbox: row.SharedInbox.String,
Timestamp: utils.NullTime{Time: row.CreatedAt.Time, Valid: true},
}
followers = append(followers, singleFollower)
}
return followers, nil
}
// GetBlockedAndRejected returns blocked and rejected followers.
func (r *SqlFollowersRepository) GetBlockedAndRejected() ([]models.Follower, error) {
pendingFollowersResult, err := r.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
}
// GetUniqueDeliveryInboxes returns unique inbox URLs for delivery.
func (r *SqlFollowersRepository) GetUniqueDeliveryInboxes() ([]string, error) {
ctx := context.Background()
return r.datastore.GetQueries().GetUniqueDeliveryInboxes(ctx)
}
// GetByIRI returns a single follower by IRI.
func (r *SqlFollowersRepository) GetByIRI(iri string) (*apmodels.ActivityPubActor, error) {
result, err := r.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")
}
var sharedInbox *url.URL
if result.SharedInbox.Valid && result.SharedInbox.String != "" {
sharedInbox, err = url.Parse(result.SharedInbox.String)
if err != nil {
log.Warnln("error parsing shared inbox, ignoring:", err)
}
}
requestObjectBytes := result.RequestObject
var followRequestObject vocab.ActivityStreamsFollow
resolver, err := streams.NewJSONResolver(func(c context.Context, followObject vocab.ActivityStreamsFollow) error {
followRequestObject = followObject
return nil
})
if err != nil {
return nil, errors.Wrap(err, "error creating JSON resolver")
}
jsonMap := make(map[string]interface{})
err = json.Unmarshal(requestObjectBytes, &jsonMap)
if err != nil {
return nil, errors.Wrap(err, "error unmarshaling follow request object")
}
err = resolver.Resolve(context.Background(), jsonMap)
if err != nil {
return nil, errors.Wrap(err, "error resolving follow request object")
}
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,
SharedInbox: sharedInbox,
Name: result.Name.String,
Username: result.Username,
Image: image,
FollowRequestIri: followIRI,
DisabledAt: disabledAt,
RequestObject: followRequestObject,
}
return &follower, nil
}
// Add saves a new follow to the datastore.
func (r *SqlFollowersRepository) Add(follow apmodels.ActivityPubActor, approved bool) error {
if err := follow.Validate(); err != nil {
return errors.Wrap(err, "cannot add invalid follow")
}
log.Traceln("Saving", follow.ActorIriString(), "as a follower.")
followRequestObject, err := apmodels.Serialize(follow.RequestObject)
if err != nil {
return errors.Wrap(err, "error serializing follow request object")
}
return r.createFollow(follow.ActorIriString(), follow.InboxString(), follow.SharedInboxString(), follow.FollowRequestIriString(), follow.Name, follow.Username, follow.ImageString(), followRequestObject, approved)
}
// Remove removes a follow from the datastore.
func (r *SqlFollowersRepository) Remove(unfollow apmodels.ActivityPubActor) error {
if err := unfollow.Validate(); err != nil {
return errors.Wrap(err, "cannot remove invalid follow")
}
log.Traceln("Removing", unfollow.ActorIriString(), "as a follower.")
return r.removeFollow(unfollow.ActorIri)
}
// ApprovePreviousRequest approves a pending follow request.
func (r *SqlFollowersRepository) ApprovePreviousRequest(iri string) error {
return r.datastore.GetQueries().ApproveFederationFollower(context.Background(), db.ApproveFederationFollowerParams{
Iri: iri,
ApprovedAt: sql.NullTime{
Time: time.Now(),
Valid: true,
},
})
}
// BlockOrReject blocks an existing follower or rejects a follow request.
func (r *SqlFollowersRepository) BlockOrReject(iri string) error {
return r.datastore.GetQueries().RejectFederationFollower(context.Background(), db.RejectFederationFollowerParams{
Iri: iri,
DisabledAt: sql.NullTime{
Time: time.Now(),
Valid: true,
},
})
}
// Update updates the details of a stored follower.
func (r *SqlFollowersRepository) Update(actorIRI string, inbox string, sharedInbox string, name string, username string, image string) error {
r.datastore.DbLock.Lock()
defer r.datastore.DbLock.Unlock()
tx, err := r.datastore.DB.Begin()
if err != nil {
return errors.Wrap(err, "error beginning transaction")
}
defer func() {
_ = tx.Rollback()
}()
if err = r.datastore.GetQueries().WithTx(tx).UpdateFollowerByIRI(context.Background(), db.UpdateFollowerByIRIParams{
Inbox: inbox,
SharedInbox: sql.NullString{String: sharedInbox, Valid: sharedInbox != ""},
Name: sql.NullString{String: name, Valid: true},
Username: username,
Image: sql.NullString{String: image, Valid: true},
Iri: actorIRI,
}); err != nil {
return errors.Wrap(err, "error updating follower "+actorIRI)
}
return tx.Commit()
}
func (r *SqlFollowersRepository) createFollow(actor, inbox, sharedInbox, request, name, username, image string, requestObject []byte, approved bool) error {
r.datastore.DbLock.Lock()
defer r.datastore.DbLock.Unlock()
tx, err := r.datastore.DB.Begin()
if err != nil {
return errors.Wrap(err, "error beginning transaction")
}
defer func() {
_ = tx.Rollback()
}()
var approvedAt sql.NullTime
if approved {
approvedAt = sql.NullTime{
Time: time.Now(),
Valid: true,
}
}
if err = r.datastore.GetQueries().WithTx(tx).AddFollower(context.Background(), db.AddFollowerParams{
Iri: actor,
Inbox: inbox,
SharedInbox: sql.NullString{String: sharedInbox, Valid: sharedInbox != ""},
Name: sql.NullString{String: name, Valid: true},
Username: username,
Image: sql.NullString{String: image, Valid: true},
ApprovedAt: approvedAt,
Request: request,
RequestObject: requestObject,
}); err != nil {
return errors.Wrap(err, "error creating new federation follow")
}
return tx.Commit()
}
func (r *SqlFollowersRepository) removeFollow(actor *url.URL) error {
r.datastore.DbLock.Lock()
defer r.datastore.DbLock.Unlock()
tx, err := r.datastore.DB.Begin()
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
if err := r.datastore.GetQueries().WithTx(tx).RemoveFollowerByIRI(context.Background(), actor.String()); err != nil {
return err
}
return tx.Commit()
}
// GetFollowersToValidate returns followers needing validation, ordered by oldest validated first.
func (r *SqlFollowersRepository) GetFollowersToValidate(limit int) ([]models.Follower, error) {
ctx := context.Background()
followersResult, err := r.datastore.GetQueries().GetFollowersToValidate(ctx, utils.SafeIntToInt32(limit))
if err != nil {
return nil, 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,
SharedInbox: row.SharedInbox.String,
FirstValidationFailureAt: utils.NullTime(row.FirstValidationFailureAt),
}
followers = append(followers, singleFollower)
}
return followers, nil
}
// UpdateFollowerValidationSuccess marks a follower as successfully validated and clears failure timestamp.
func (r *SqlFollowersRepository) UpdateFollowerValidationSuccess(iri string) error {
r.datastore.DbLock.Lock()
defer r.datastore.DbLock.Unlock()
ctx := context.Background()
tx, err := r.datastore.DB.Begin()
if err != nil {
return errors.Wrap(err, "error beginning transaction")
}
defer func() {
_ = tx.Rollback()
}()
if err := r.datastore.GetQueries().WithTx(tx).UpdateFollowerValidationSuccess(ctx, db.UpdateFollowerValidationSuccessParams{
LastValidatedAt: sql.NullTime{Time: time.Now(), Valid: true},
Iri: iri,
}); err != nil {
return errors.Wrap(err, "error updating follower validation success")
}
return tx.Commit()
}
// UpdateFollowerValidationFailure marks a validation failure, setting first failure time if not already set.
func (r *SqlFollowersRepository) UpdateFollowerValidationFailure(iri string) error {
r.datastore.DbLock.Lock()
defer r.datastore.DbLock.Unlock()
ctx := context.Background()
tx, err := r.datastore.DB.Begin()
if err != nil {
return errors.Wrap(err, "error beginning transaction")
}
defer func() {
_ = tx.Rollback()
}()
if err := r.datastore.GetQueries().WithTx(tx).UpdateFollowerValidationFailure(ctx, db.UpdateFollowerValidationFailureParams{
LastValidatedAt: sql.NullTime{Time: time.Now(), Valid: true},
Iri: iri,
}); err != nil {
return errors.Wrap(err, "error updating follower validation failure")
}
return tx.Commit()
}
// RemoveByIRI removes a follower directly by IRI string.
func (r *SqlFollowersRepository) RemoveByIRI(iri string) error {
r.datastore.DbLock.Lock()
defer r.datastore.DbLock.Unlock()
tx, err := r.datastore.DB.Begin()
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
if err := r.datastore.GetQueries().WithTx(tx).RemoveFollowerByIRI(context.Background(), iri); err != nil {
return err
}
return tx.Commit()
}
+173 -8
View File
@@ -4,15 +4,16 @@ import (
"context" "context"
"database/sql" "database/sql"
"fmt" "fmt"
"net/url"
"time" "time"
"github.com/go-fed/activity/streams" "github.com/go-fed/activity/streams"
"github.com/go-fed/activity/streams/vocab" "github.com/go-fed/activity/streams/vocab"
"github.com/owncast/owncast/activitypub/apmodels"
"github.com/owncast/owncast/activitypub/resolvers" "github.com/owncast/owncast/activitypub/resolvers"
"github.com/owncast/owncast/core/data" "github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/db" "github.com/owncast/owncast/db"
"github.com/owncast/owncast/models" "github.com/owncast/owncast/models"
"github.com/owncast/owncast/utils"
"github.com/pkg/errors" "github.com/pkg/errors"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
@@ -26,12 +27,170 @@ func Setup(datastore *data.Datastore) {
createFederationFollowersTable() createFederationFollowersTable()
createFederationOutboxTable() createFederationOutboxTable()
createFederatedActivitiesTable() createFederatedActivitiesTable()
addFollowersFixtureData()
} }
// GetDatastore returns the datastore instance for use by sub-repositories. // AddFollow will save a follow to the datastore.
func GetDatastore() *data.Datastore { func AddFollow(follow apmodels.ActivityPubActor, approved bool) error {
return _datastore 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 // createFederatedActivitiesTable will create the accepted
@@ -77,7 +236,7 @@ func GetOutbox(limit int, offset int) (vocab.ActivityStreamsOrderedCollection, e
orderedItems := streams.NewActivityStreamsOrderedItemsProperty() orderedItems := streams.NewActivityStreamsOrderedItemsProperty()
rows, err := _datastore.GetQueries().GetOutboxWithOffset( rows, err := _datastore.GetQueries().GetOutboxWithOffset(
context.Background(), context.Background(),
db.GetOutboxWithOffsetParams{Limit: utils.SafeIntToInt32(limit), Offset: utils.SafeIntToInt32(offset)}, db.GetOutboxWithOffsetParams{Limit: int32(limit), Offset: int32(offset)},
) )
if err != nil { if err != nil {
return collection, err return collection, err
@@ -118,6 +277,12 @@ func AddToOutbox(iri string, itemData []byte, typeString string, isLiveNotificat
return tx.Commit() return tx.Commit()
} }
// GetObjectByID will return a string representation of a single object by the ID.
func GetObjectByID(id string) (string, error) {
value, err := _datastore.GetQueries().GetObjectFromOutboxByID(context.Background(), id)
return string(value), err
}
// GetObjectByIRI will return a string representation of a single object by the IRI. // GetObjectByIRI will return a string representation of a single object by the IRI.
func GetObjectByIRI(iri string) (string, bool, time.Time, error) { func GetObjectByIRI(iri string) (string, bool, time.Time, error) {
row, err := _datastore.GetQueries().GetObjectFromOutboxByIRI(context.Background(), iri) row, err := _datastore.GetQueries().GetObjectFromOutboxByIRI(context.Background(), iri)
@@ -149,8 +314,8 @@ func SaveInboundFediverseActivity(objectIRI string, actorIRI string, eventType s
func GetInboundActivities(limit int, offset int) ([]models.FederatedActivity, int, error) { func GetInboundActivities(limit int, offset int) ([]models.FederatedActivity, int, error) {
ctx := context.Background() ctx := context.Background()
rows, err := _datastore.GetQueries().GetInboundActivitiesWithOffset(ctx, db.GetInboundActivitiesWithOffsetParams{ rows, err := _datastore.GetQueries().GetInboundActivitiesWithOffset(ctx, db.GetInboundActivitiesWithOffsetParams{
Limit: utils.SafeIntToInt32(limit), Limit: int32(limit),
Offset: utils.SafeIntToInt32(offset), Offset: int32(offset),
}) })
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
-10
View File
@@ -9,22 +9,12 @@ import (
"github.com/owncast/owncast/activitypub/apmodels" "github.com/owncast/owncast/activitypub/apmodels"
"github.com/owncast/owncast/activitypub/crypto" "github.com/owncast/owncast/activitypub/crypto"
"github.com/owncast/owncast/activitypub/workerpool" "github.com/owncast/owncast/activitypub/workerpool"
"github.com/owncast/owncast/utils"
"github.com/pkg/errors"
"github.com/teris-io/shortid" "github.com/teris-io/shortid"
) )
// SendFollowAccept will send an accept activity to a follow request from a specified local user. // 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 { func SendFollowAccept(inbox *url.URL, originalFollowActivity vocab.ActivityStreamsFollow, fromLocalAccountName string) error {
// SSRF protection: reject non-HTTPS schemes and internal/loopback hosts.
if inbox.Scheme != "https" {
return errors.Errorf("rejecting non-HTTPS inbox URL for SSRF protection: %s", inbox.String())
}
if utils.IsHostnameInternal(inbox.Hostname()) {
return errors.Errorf("rejecting internal/loopback inbox URL for SSRF protection: %s", inbox.String())
}
followAccept := makeAcceptFollow(originalFollowActivity, fromLocalAccountName) followAccept := makeAcceptFollow(originalFollowActivity, fromLocalAccountName)
localAccountIRI := apmodels.MakeLocalIRIForAccount(fromLocalAccountName) localAccountIRI := apmodels.MakeLocalIRIForAccount(fromLocalAccountName)
+2 -3
View File
@@ -18,10 +18,10 @@ func getPersonFromFollow(activity vocab.ActivityStreamsFollow) (apmodels.Activit
func MakeFollowRequest(c context.Context, activity vocab.ActivityStreamsFollow) (*apmodels.ActivityPubActor, error) { func MakeFollowRequest(c context.Context, activity vocab.ActivityStreamsFollow) (*apmodels.ActivityPubActor, error) {
person, err := getPersonFromFollow(activity) person, err := getPersonFromFollow(activity)
if err != nil { if err != nil {
return nil, errors.Wrap(err, "unable to resolve person from follow request") return nil, errors.New("unable to resolve person from follow request: " + err.Error())
} }
hostname := person.ActorIriHostname() hostname := person.ActorIri.Hostname()
username := person.Username username := person.Username
fullUsername := fmt.Sprintf("%s@%s", username, hostname) fullUsername := fmt.Sprintf("%s@%s", username, hostname)
@@ -29,7 +29,6 @@ func MakeFollowRequest(c context.Context, activity vocab.ActivityStreamsFollow)
ActorIri: person.ActorIri, ActorIri: person.ActorIri,
FollowRequestIri: activity.GetJSONLDId().Get(), FollowRequestIri: activity.GetJSONLDId().Get(),
Inbox: person.Inbox, Inbox: person.Inbox,
SharedInbox: person.SharedInbox,
Name: person.Name, Name: person.Name,
Username: fullUsername, Username: fullUsername,
Image: person.Image, Image: person.Image,
-171
View File
@@ -1,171 +0,0 @@
package resolvers
import (
"net/url"
"testing"
"github.com/go-fed/activity/streams"
)
func mustParseURL(s string) *url.URL {
u, err := url.Parse(s)
if err != nil {
panic(err)
}
return u
}
// TestGetResolvedActorFromActorPropertyWithNil verifies that the function
// doesn't panic when given a nil actor property.
func TestGetResolvedActorFromActorPropertyWithNil(t *testing.T) {
defer func() {
if r := recover(); r != nil {
t.Errorf("GetResolvedActorFromActorProperty panicked with nil: %v", r)
}
}()
_, err := GetResolvedActorFromActorProperty(nil)
if err == nil {
t.Error("GetResolvedActorFromActorProperty(nil) should return error")
}
}
// TestGetResolvedActorFromActorPropertyWithEmpty verifies that the function
// doesn't panic when given an empty actor property.
func TestGetResolvedActorFromActorPropertyWithEmpty(t *testing.T) {
defer func() {
if r := recover(); r != nil {
t.Errorf("GetResolvedActorFromActorProperty panicked with empty: %v", r)
}
}()
actor := streams.NewActivityStreamsActorProperty()
_, err := GetResolvedActorFromActorProperty(actor)
if err == nil {
t.Error("GetResolvedActorFromActorProperty with empty actor should return error")
}
}
// TestGetResolvedActorFromActorPropertyWithIRI verifies that the function
// handles the case where an IRI needs to be resolved.
// Note: This test involves network calls and config repository access which may
// panic in a test environment. This is expected since we're testing nil safety
// of ActivityPub property handling, not network behavior.
func TestGetResolvedActorFromActorPropertyWithIRI(t *testing.T) {
t.Skip("Skipping IRI resolution test - requires network access and proper config setup")
}
// TestGetResolvedActorFromActorPropertyWithPersonButMissingFields verifies that
// the function handles Person objects that are missing required fields.
func TestGetResolvedActorFromActorPropertyWithPersonMissingFields(t *testing.T) {
defer func() {
if r := recover(); r != nil {
t.Errorf("GetResolvedActorFromActorProperty panicked: %v", r)
}
}()
actor := streams.NewActivityStreamsActorProperty()
person := streams.NewActivityStreamsPerson()
// Person has no ID, inbox, username, or public key
actor.AppendActivityStreamsPerson(person)
_, err := GetResolvedActorFromActorProperty(actor)
if err == nil {
t.Error("GetResolvedActorFromActorProperty with incomplete Person should return error")
}
}
// TestGetResolvedActorFromActorPropertyWithServiceMissingFields verifies that
// the function handles Service objects that are missing required fields.
func TestGetResolvedActorFromActorPropertyWithServiceMissingFields(t *testing.T) {
defer func() {
if r := recover(); r != nil {
t.Errorf("GetResolvedActorFromActorProperty panicked: %v", r)
}
}()
actor := streams.NewActivityStreamsActorProperty()
service := streams.NewActivityStreamsService()
// Service has no ID, inbox, username, or public key
actor.AppendActivityStreamsService(service)
_, err := GetResolvedActorFromActorProperty(actor)
if err == nil {
t.Error("GetResolvedActorFromActorProperty with incomplete Service should return error")
}
}
// TestGetResolvedActorFromActorPropertyWithApplicationMissingFields verifies that
// the function handles Application objects that are missing required fields.
func TestGetResolvedActorFromActorPropertyWithApplicationMissingFields(t *testing.T) {
defer func() {
if r := recover(); r != nil {
t.Errorf("GetResolvedActorFromActorProperty panicked: %v", r)
}
}()
actor := streams.NewActivityStreamsActorProperty()
app := streams.NewActivityStreamsApplication()
// Application has no ID, inbox, username, or public key
actor.AppendActivityStreamsApplication(app)
_, err := GetResolvedActorFromActorProperty(actor)
if err == nil {
t.Error("GetResolvedActorFromActorProperty with incomplete Application should return error")
}
}
// TestNilSafetyNoPanic is a comprehensive test that ensures none of the
// resolver functions panic with nil/empty inputs.
func TestNilSafetyNoPanic(t *testing.T) {
t.Run("GetResolvedActorFromActorProperty with nil", func(t *testing.T) {
defer func() {
if r := recover(); r != nil {
t.Errorf("panicked: %v", r)
}
}()
_, _ = GetResolvedActorFromActorProperty(nil)
})
t.Run("GetResolvedActorFromActorProperty with empty", func(t *testing.T) {
defer func() {
if r := recover(); r != nil {
t.Errorf("panicked: %v", r)
}
}()
_, _ = GetResolvedActorFromActorProperty(streams.NewActivityStreamsActorProperty())
})
t.Run("GetResolvedActorFromActorProperty with Person no fields", func(t *testing.T) {
defer func() {
if r := recover(); r != nil {
t.Errorf("panicked: %v", r)
}
}()
actor := streams.NewActivityStreamsActorProperty()
actor.AppendActivityStreamsPerson(streams.NewActivityStreamsPerson())
_, _ = GetResolvedActorFromActorProperty(actor)
})
t.Run("GetResolvedActorFromActorProperty with Service no fields", func(t *testing.T) {
defer func() {
if r := recover(); r != nil {
t.Errorf("panicked: %v", r)
}
}()
actor := streams.NewActivityStreamsActorProperty()
actor.AppendActivityStreamsService(streams.NewActivityStreamsService())
_, _ = GetResolvedActorFromActorProperty(actor)
})
t.Run("GetResolvedActorFromActorProperty with Application no fields", func(t *testing.T) {
defer func() {
if r := recover(); r != nil {
t.Errorf("panicked: %v", r)
}
}()
actor := streams.NewActivityStreamsActorProperty()
actor.AppendActivityStreamsApplication(streams.NewActivityStreamsApplication())
_, _ = GetResolvedActorFromActorProperty(actor)
})
}
+68 -70
View File
@@ -3,15 +3,14 @@ package resolvers
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"io" "io/ioutil"
"net/http" "net/http"
"github.com/go-fed/activity/streams" "github.com/go-fed/activity/streams"
"github.com/go-fed/activity/streams/vocab" "github.com/go-fed/activity/streams/vocab"
"github.com/owncast/owncast/activitypub/apmodels" "github.com/owncast/owncast/activitypub/apmodels"
"github.com/owncast/owncast/activitypub/crypto" "github.com/owncast/owncast/activitypub/crypto"
"github.com/owncast/owncast/persistence/configrepository" "github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/utils"
"github.com/pkg/errors" "github.com/pkg/errors"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
@@ -46,81 +45,81 @@ func Resolve(c context.Context, data []byte, callbacks ...interface{}) error {
return nil return nil
} }
// ResolveIRI will resolve an IRI and call the correct callback for the resolved type. // ResolveIRI will resolve an IRI ahd call the correct callback for the resolved type.
// Uses a retryable HTTP client for resilience against transient failures.
func ResolveIRI(c context.Context, iri string, callbacks ...interface{}) error { func ResolveIRI(c context.Context, iri string, callbacks ...interface{}) error {
configRepository := configrepository.Get()
log.Debugln("Resolving", iri) log.Debugln("Resolving", iri)
req, _ := http.NewRequest(http.MethodGet, iri, nil) req, _ := http.NewRequest(http.MethodGet, iri, nil)
req.Header.Set("Accept", "application/activity+json, application/ld+json")
actor := apmodels.MakeLocalIRIForAccount(configRepository.GetDefaultFederationUsername()) actor := apmodels.MakeLocalIRIForAccount(data.GetDefaultFederationUsername())
if err := crypto.SignRequest(req, nil, actor); err != nil { if err := crypto.SignRequest(req, nil, actor); err != nil {
return err return err
} }
client := utils.GetRetryableHTTPClient() response, err := http.DefaultClient.Do(req)
response, err := client.Do(req)
if err != nil { if err != nil {
return err return err
} }
defer response.Body.Close() defer response.Body.Close()
if response.StatusCode >= 400 { data, err := ioutil.ReadAll(response.Body)
return errors.New("request failed with status: " + response.Status)
}
data, err := io.ReadAll(response.Body)
if err != nil { if err != nil {
return err return err
} }
// fmt.Println(string(data))
return Resolve(c, data, callbacks...) return Resolve(c, data, callbacks...)
} }
// GetResolvedActorFromActorProperty resolve an external actor property to a // GetResolvedActorFromActorProperty resolve an external actor property to a
// fully populated internal actor representation. // fully populated internal actor representation.
// Returns an error if the actor cannot be resolved or is missing required fields.
func GetResolvedActorFromActorProperty(actor vocab.ActivityStreamsActorProperty) (apmodels.ActivityPubActor, error) { func GetResolvedActorFromActorProperty(actor vocab.ActivityStreamsActorProperty) (apmodels.ActivityPubActor, error) {
var err error
var apActor apmodels.ActivityPubActor var apActor apmodels.ActivityPubActor
resolved := false
if actor == nil || actor.Empty() || actor.Len() == 0 || actor.At(0) == nil { if !actor.Empty() && actor.Len() > 0 && actor.At(0) != nil {
return apActor, errors.New("actor property is empty or nil") // Explicitly use only the first actor that might be listed.
} actorObjectOrIRI := actor.At(0)
var actorEntity apmodels.ExternalEntity
// Explicitly use only the first actor that might be listed. // If the actor is an unresolved IRI then we need to resolve it.
actorObjectOrIRI := actor.At(0) if actorObjectOrIRI.IsIRI() {
var actorEntity apmodels.ExternalEntity iri := actorObjectOrIRI.GetIRI().String()
return GetResolvedActorFromIRI(iri)
// If the actor is an unresolved IRI then we need to resolve it.
if actorObjectOrIRI.IsIRI() {
iri := actorObjectOrIRI.GetIRI()
if iri == nil {
return apActor, errors.New("actor IRI is nil despite IsIRI() returning true")
} }
return GetResolvedActorFromIRI(iri.String())
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 actorObjectOrIRI.IsActivityStreamsPerson() { if !resolved && err == nil {
actorEntity = actorObjectOrIRI.GetActivityStreamsPerson() err = errors.New("unknown error resolving actor from property value")
} else if actorObjectOrIRI.IsActivityStreamsService() {
actorEntity = actorObjectOrIRI.GetActivityStreamsService()
} else if actorObjectOrIRI.IsActivityStreamsApplication() {
actorEntity = actorObjectOrIRI.GetActivityStreamsApplication()
} else {
return apActor, errors.New("unrecognized external ActivityPub type: " + actorObjectOrIRI.Name())
} }
// Convert the external AP entity into an internal actor representation. return apActor, err
// NewActivityPubActorFromEntity validates that required fields (ActorIri, Inbox) are present.
apa, err := apmodels.NewActivityPubActorFromEntity(actorEntity)
if err != nil {
return apActor, errors.Wrap(err, "failed to create actor from entity")
}
return *apa, nil
} }
// GetResolvedPublicKeyFromIRI will resolve a publicKey IRI string to a vocab.W3IDSecurityV1PublicKey. // GetResolvedPublicKeyFromIRI will resolve a publicKey IRI string to a vocab.W3IDSecurityV1PublicKey.
@@ -190,49 +189,48 @@ func GetResolvedPublicKeyFromIRI(publicKeyIRI string) (vocab.W3IDSecurityV1Publi
} }
// GetResolvedActorFromIRI will resolve an IRI string to a fully populated actor. // GetResolvedActorFromIRI will resolve an IRI string to a fully populated actor.
// Returns an error if the actor cannot be resolved or is missing required fields.
func GetResolvedActorFromIRI(personOrServiceIRI string) (apmodels.ActivityPubActor, error) { func GetResolvedActorFromIRI(personOrServiceIRI string) (apmodels.ActivityPubActor, error) {
var resolveErr error var err error
var apActor apmodels.ActivityPubActor var apActor apmodels.ActivityPubActor
resolved := false resolved := false
personCallback := func(c context.Context, person vocab.ActivityStreamsPerson) error { personCallback := func(c context.Context, person vocab.ActivityStreamsPerson) error {
apa, e := apmodels.NewActivityPubActorFromEntity(person) apa, e := apmodels.MakeActorFromExernalAPEntity(person)
if e != nil { if apa != nil {
return e apActor = *apa
resolved = true
} }
apActor = *apa return e
resolved = true
return nil
} }
serviceCallback := func(c context.Context, service vocab.ActivityStreamsService) error { serviceCallback := func(c context.Context, service vocab.ActivityStreamsService) error {
apa, e := apmodels.NewActivityPubActorFromEntity(service) apa, e := apmodels.MakeActorFromExernalAPEntity(service)
if e != nil { if apa != nil {
return e apActor = *apa
resolved = true
} }
apActor = *apa return e
resolved = true
return nil
} }
applicationCallback := func(c context.Context, app vocab.ActivityStreamsApplication) error { applicationCallback := func(c context.Context, app vocab.ActivityStreamsApplication) error {
apa, e := apmodels.NewActivityPubActorFromEntity(app) apa, e := apmodels.MakeActorFromExernalAPEntity(app)
if e != nil { if apa != nil {
return e apActor = *apa
resolved = true
} }
apActor = *apa return e
resolved = true
return nil
} }
if e := ResolveIRI(context.Background(), personOrServiceIRI, personCallback, serviceCallback, applicationCallback); e != nil { if e := ResolveIRI(context.Background(), personOrServiceIRI, personCallback, serviceCallback, applicationCallback); e != nil {
resolveErr = errors.Wrap(e, "error resolving actor from IRI") err = e
} }
if !resolved && resolveErr == nil { if err != nil {
resolveErr = errors.New("failed to resolve actor from IRI: " + personOrServiceIRI) err = errors.Wrap(err, "error resolving actor from property value")
} }
return apActor, resolveErr if !resolved {
err = errors.New("error resolving actor from property value")
}
return apActor, err
} }
+35
View File
@@ -0,0 +1,35 @@
package activitypub
import (
"net/http"
"github.com/owncast/owncast/activitypub/controllers"
"github.com/owncast/owncast/router/middleware"
)
// StartRouter will start the federation specific http router.
func StartRouter() {
// WebFinger
http.HandleFunc("/.well-known/webfinger", controllers.WebfingerHandler)
// Host Metadata
http.HandleFunc("/.well-known/host-meta", controllers.HostMetaController)
// Nodeinfo v1
http.HandleFunc("/.well-known/nodeinfo", controllers.NodeInfoController)
// x-nodeinfo v2
http.HandleFunc("/.well-known/x-nodeinfo2", controllers.XNodeInfo2Controller)
// Nodeinfo v2
http.HandleFunc("/nodeinfo/2.0", controllers.NodeInfoV2Controller)
// Instance details
http.HandleFunc("/api/v1/instance", controllers.InstanceV1Controller)
// Single ActivityPub Actor
http.HandleFunc("/federation/user/", middleware.RequireActivityPubOrRedirect(controllers.ActorHandler))
// Single AP object
http.HandleFunc("/federation/", middleware.RequireActivityPubOrRedirect(controllers.ObjectHandler))
}
+2 -27
View File
@@ -2,13 +2,10 @@ package webfinger
import ( import (
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
"github.com/owncast/owncast/utils"
) )
// GetWebfingerLinks will return webfinger data for an account. // GetWebfingerLinks will return webfinger data for an account.
@@ -21,11 +18,6 @@ func GetWebfingerLinks(account string) ([]map[string]interface{}, error) {
accountComponents := strings.Split(account, "@") accountComponents := strings.Split(account, "@")
fediverseServer := accountComponents[1] 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. // HTTPS is required.
requestURL, err := url.Parse("https://" + fediverseServer) requestURL, err := url.Parse("https://" + fediverseServer)
if err != nil { if err != nil {
@@ -37,34 +29,17 @@ func GetWebfingerLinks(account string) ([]map[string]interface{}, error) {
query.Add("resource", fmt.Sprintf("acct:%s", account)) query.Add("resource", fmt.Sprintf("acct:%s", account))
requestURL.RawQuery = query.Encode() requestURL.RawQuery = query.Encode()
// Do not support redirects. response, err := http.DefaultClient.Get(requestURL.String())
client := &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
req, err := http.NewRequest("GET", requestURL.String(), nil)
if err != nil { if err != nil {
return nil, err return nil, err
} }
req.Header.Set("Accept", "application/json")
response, err := client.Do(req)
if err != nil {
return nil, err
}
if response.StatusCode != http.StatusOK {
return nil, errors.New("webfinger request returned bad status code: " + http.StatusText(response.StatusCode) + ", check account details")
}
defer response.Body.Close() defer response.Body.Close()
var links webfingerResponse var links webfingerResponse
decoder := json.NewDecoder(response.Body) decoder := json.NewDecoder(response.Body)
if err := decoder.Decode(&links); err != nil { if err := decoder.Decode(&links); err != nil {
return nil, fmt.Errorf("error decoding webfinger response: %s", err) return nil, err
} }
return links.Links, nil return links.Links, nil

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