Compare commits
8
Commits
f1da0fc30c
..
v0.2.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5da1781120 | ||
|
|
fced72ec97 | ||
|
|
b671e182bf | ||
|
|
401b720310 | ||
|
|
1052564570 | ||
|
|
5b6f943861 | ||
|
|
64bc902b77 | ||
|
|
04f4ee0018 |
@@ -6,8 +6,7 @@ body:
|
|||||||
value: |
|
value: |
|
||||||
Thanks for helping by reporting issues and sharing ideas you might have!
|
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.
|
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.
|
Take a look at the [Owncast product definition](https://github.com/owncast/owncast/blob/develop/docs/product-definition.md) to see what our focus is and how your requests might align.
|
||||||
Note: Please file issues in English so your message can be interpreted as you intended.
|
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: issue-body
|
id: issue-body
|
||||||
|
|||||||
@@ -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! 🎉
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Read first
|
||||||
|
|
||||||
|
If this is an unsolicited change, or there is no existing issue filed for it, please open a GitHub issue before creating a pull request. This will allow us to discuss the motivations and the big picture behind the change first. It's possible there may be other solutions that should be discussed for what you think should be built. It is possible your change will be rejected unless some discussion around your proposal happens first. While creating this PR means you probably already did the work, it still makes sense to file an issue now, and into the future when you have proposed changes.
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
Please include a summary of the change and which issue number is fixed, including relevant motivation and context. Feel free to mark this as a Draft or WIP and write up some details later and start a conversation, even if your PR is not ready for review.
|
||||||
|
|
||||||
|
Fixes # (issue)
|
||||||
|
|
||||||
|
## Screenshot Examples or Logs
|
||||||
|
|
||||||
|
If this is a frontend change, please include a screenshot of the change. If this is a backend change, please include relevant logs or examples of the change in action if applicable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Some things you might want to mention:
|
||||||
|
|
||||||
|
1. Why are you making the change?
|
||||||
|
2. Explain how it works and decisions you made.
|
||||||
|
3. If you're fixing something, what was wrong? How should we stop from having this issue happen again?
|
||||||
|
4. If this is a new feature or addition to functionality, why should it be added? What are the use cases? Who was asking for this functionality?
|
||||||
|
|
||||||
|
Thank you so much for contributing to Owncast! 🎉
|
||||||
@@ -1,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.
|
|
||||||
@@ -14,14 +14,13 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Check out pull request code
|
- name: Check out pull request code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
if: github.event_name == 'pull_request'
|
if: github.event_name == 'pull_request'
|
||||||
with:
|
with:
|
||||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||||
ref: ${{ github.event.pull_request.head.ref }}
|
|
||||||
|
|
||||||
- name: Check out repository code
|
- name: Check out repository code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
if: github.event_name == 'push'
|
if: github.event_name == 'push'
|
||||||
|
|
||||||
- uses: docker://rhysd/actionlint:latest
|
- uses: docker://rhysd/actionlint:latest
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -11,7 +11,7 @@ jobs:
|
|||||||
issues: write
|
issues: write
|
||||||
steps:
|
steps:
|
||||||
- name: Add comment
|
- name: Add comment
|
||||||
uses: peter-evans/create-or-update-comment@57232238742e38b2ccc27136ce596ccae7ca28b4
|
uses: peter-evans/create-or-update-comment@853a4fc475ab347cfa392aa2ee451b4fe83e774e
|
||||||
with:
|
with:
|
||||||
issue-number: ${{ github.event.issue.number }}
|
issue-number: ${{ github.event.issue.number }}
|
||||||
body: |
|
body: |
|
||||||
|
|||||||
@@ -1,13 +1,3 @@
|
|||||||
# 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:
|
||||||
@@ -29,26 +19,18 @@ jobs:
|
|||||||
concurrent_skipping: 'same_content_newer'
|
concurrent_skipping: 'same_content_newer'
|
||||||
|
|
||||||
- name: Check out pull request code
|
- name: Check out pull request code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
if: github.event_name == 'pull_request'
|
if: github.event_name == 'pull_request'
|
||||||
with:
|
with:
|
||||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
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
|
- name: Check out repository code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
if: github.event_name == 'push'
|
if: github.event_name == 'push'
|
||||||
|
|
||||||
- name: Get changed files
|
- name: Get changed files
|
||||||
id: changed-files-yaml
|
id: changed-files-yaml
|
||||||
uses: tj-actions/changed-files@v47
|
uses: tj-actions/changed-files@v45
|
||||||
with:
|
with:
|
||||||
files_yaml: |
|
files_yaml: |
|
||||||
src:
|
src:
|
||||||
|
|||||||
@@ -1,27 +1,14 @@
|
|||||||
# 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
|
name: Browser Tests
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
paths:
|
paths:
|
||||||
- 'web/**'
|
- 'web/**'
|
||||||
- '**.go'
|
|
||||||
- 'go.mod'
|
|
||||||
- 'go.sum'
|
|
||||||
- 'test/automated/browser/**'
|
- 'test/automated/browser/**'
|
||||||
- '.github/workflows/browser-testing.yml'
|
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- 'web/**'
|
- 'web/**'
|
||||||
- '**.go'
|
|
||||||
- 'go.mod'
|
|
||||||
- 'go.sum'
|
|
||||||
- 'test/automated/browser/**'
|
- 'test/automated/browser/**'
|
||||||
- '.github/workflows/browser-testing.yml'
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
cypress-run:
|
cypress-run:
|
||||||
@@ -33,45 +20,21 @@ jobs:
|
|||||||
concurrent_skipping: 'same_content_newer'
|
concurrent_skipping: 'same_content_newer'
|
||||||
|
|
||||||
- name: Check out pull request code
|
- name: Check out pull request code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
if: github.event_name == 'pull_request'
|
if: github.event_name == 'pull_request'
|
||||||
with:
|
with:
|
||||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
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
|
- name: Check out repository code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
if: github.event_name == 'push'
|
if: github.event_name == 'push'
|
||||||
|
|
||||||
- name: Get changed files
|
- uses: actions/setup-node@v4
|
||||||
id: changed-files-yaml
|
|
||||||
uses: tj-actions/changed-files@v47
|
|
||||||
with:
|
with:
|
||||||
files_yaml: |
|
node-version: '22.9.0'
|
||||||
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
|
- name: Cache node modules
|
||||||
if: steps.changed-files-yaml.outputs.testable_any_changed == 'true'
|
uses: actions/cache@v4
|
||||||
uses: actions/cache@v5
|
|
||||||
env:
|
env:
|
||||||
cache-name: cache-node-modules-browser-tests
|
cache-name: cache-node-modules-browser-tests
|
||||||
with:
|
with:
|
||||||
@@ -82,18 +45,15 @@ jobs:
|
|||||||
${{ runner.os }}-build-
|
${{ runner.os }}-build-
|
||||||
${{ runner.os }}-
|
${{ runner.os }}-
|
||||||
|
|
||||||
- uses: actions/setup-go@v6
|
- uses: actions/setup-go@v5
|
||||||
if: steps.changed-files-yaml.outputs.testable_any_changed == 'true'
|
|
||||||
with:
|
with:
|
||||||
go-version: '1.26.2'
|
go-version: '1.22'
|
||||||
cache: true
|
cache: true
|
||||||
|
|
||||||
- name: Install Google Chrome
|
- 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
|
run: sudo apt-get update && sudo apt-get install google-chrome-stable
|
||||||
|
|
||||||
- name: Run Browser tests
|
- name: Run Browser tests
|
||||||
if: steps.changed-files-yaml.outputs.testable_any_changed == 'true'
|
|
||||||
uses: nick-fields/retry@v3
|
uses: nick-fields/retry@v3
|
||||||
with:
|
with:
|
||||||
timeout_minutes: 20
|
timeout_minutes: 20
|
||||||
|
|||||||
@@ -1,26 +1,9 @@
|
|||||||
# 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
|
name: Build and Deploy Components+Style Guide
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- develop
|
- develop
|
||||||
paths: [
|
paths: ['web/stories/**', 'web/components/**', 'web/.storybook/**'] # Trigger the action only when files change in the folders defined here
|
||||||
'web/stories/**',
|
|
||||||
'web/components/**',
|
|
||||||
'web/.storybook/**',
|
|
||||||
'web/i18n/**',
|
|
||||||
] # Trigger the action only when files change in the folders defined here
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-deploy:
|
build-and-deploy:
|
||||||
@@ -28,26 +11,18 @@ jobs:
|
|||||||
if: github.repository == 'owncast/owncast'
|
if: github.repository == 'owncast/owncast'
|
||||||
|
|
||||||
steps:
|
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
|
- name: Check out pull request code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
if: github.event_name == 'pull_request'
|
if: github.event_name == 'pull_request'
|
||||||
with:
|
with:
|
||||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||||
ref: ${{ github.event.pull_request.head.ref }}
|
|
||||||
|
|
||||||
- name: Check out repository code
|
- name: Check out repository code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
if: github.event_name == 'push'
|
if: github.event_name == 'push'
|
||||||
|
|
||||||
- name: Cache node modules
|
- name: Cache node modules
|
||||||
uses: actions/cache@v5
|
uses: actions/cache@v4
|
||||||
env:
|
env:
|
||||||
cache-name: cache-node-modules-bundle-web-app
|
cache-name: cache-node-modules-bundle-web-app
|
||||||
with:
|
with:
|
||||||
@@ -68,7 +43,6 @@ jobs:
|
|||||||
npm run build-storybook -- -o ../docs/components
|
npm run build-storybook -- -o ../docs/components
|
||||||
|
|
||||||
- name: Commit changes
|
- name: Commit changes
|
||||||
if: github.repository == 'owncast/owncast'
|
|
||||||
uses: EndBug/add-and-commit@v9
|
uses: EndBug/add-and-commit@v9
|
||||||
with:
|
with:
|
||||||
author_name: Owncast
|
author_name: Owncast
|
||||||
@@ -80,8 +54,7 @@ jobs:
|
|||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Dispatch event to web site
|
- name: Dispatch event to web site
|
||||||
if: github.repository == 'owncast/owncast'
|
uses: peter-evans/repository-dispatch@v3
|
||||||
uses: peter-evans/repository-dispatch@v4
|
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.BUNDLE_STORYBOOK_OWNCAST_ONLINE }}
|
token: ${{ secrets.BUNDLE_STORYBOOK_OWNCAST_ONLINE }}
|
||||||
repository: owncast/owncast.github.io
|
repository: owncast/owncast.github.io
|
||||||
|
|||||||
@@ -1,44 +1,22 @@
|
|||||||
# .github/workflows/chromatic.yml
|
# .github/workflows/chromatic.yml
|
||||||
|
|
||||||
# This workflow publishes Storybook to Chromatic for visual regression testing
|
# Workflow name
|
||||||
# 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'
|
name: 'Chromatic'
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
paths:
|
paths:
|
||||||
- 'web/components/**'
|
- web/**
|
||||||
- 'web/stories/**'
|
pull_request_target:
|
||||||
- 'web/.storybook/**'
|
|
||||||
- 'web/**/*.{css,scss}'
|
|
||||||
- 'web/i18n/**'
|
|
||||||
- 'web/package.json'
|
|
||||||
- 'web/package-lock.json'
|
|
||||||
- '.github/workflows/chromatic.yml'
|
|
||||||
pull_request:
|
|
||||||
paths:
|
paths:
|
||||||
- 'web/components/**'
|
- web/**
|
||||||
- 'web/stories/**'
|
|
||||||
- 'web/.storybook/**'
|
|
||||||
- 'web/**/*.{css,scss}'
|
|
||||||
- 'web/i18n/**'
|
|
||||||
- 'web/package.json'
|
|
||||||
- 'web/package-lock.json'
|
|
||||||
- '.github/workflows/chromatic.yml'
|
|
||||||
|
|
||||||
# List of jobs
|
# List of jobs
|
||||||
jobs:
|
jobs:
|
||||||
chromatic-deployment:
|
chromatic-deployment:
|
||||||
# Operating System
|
# Operating System
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
if: github.repository == 'owncast/owncast'
|
||||||
|
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
@@ -50,27 +28,19 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
concurrent_skipping: 'same_content_newer'
|
concurrent_skipping: 'same_content_newer'
|
||||||
|
|
||||||
- name: Check out repository code (Push)
|
- name: Check out pull request code
|
||||||
if: github.event_name == 'push'
|
uses: actions/checkout@v4
|
||||||
uses: actions/checkout@v6
|
if: github.event_name == 'pull_request' || github.event_name == 'pull_request_target'
|
||||||
|
|
||||||
- name: Check out pull request code (PR)
|
|
||||||
if: github.event_name == 'pull_request'
|
|
||||||
uses: actions/checkout@v6
|
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.ref }}
|
|
||||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Fetch base branch for comparison
|
- name: Check out repository code
|
||||||
if: github.event_name == 'pull_request'
|
uses: actions/checkout@v4
|
||||||
run: |
|
if: github.event_name == 'push'
|
||||||
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
|
- name: Get changed files
|
||||||
id: changed-files-yaml
|
id: changed-files-yaml
|
||||||
uses: tj-actions/changed-files@v47
|
uses: tj-actions/changed-files@v45
|
||||||
with:
|
with:
|
||||||
path: 'web'
|
path: 'web'
|
||||||
files_ignore: |
|
files_ignore: |
|
||||||
@@ -86,10 +56,10 @@ jobs:
|
|||||||
|
|
||||||
- name: Publish to Chromatic
|
- name: Publish to Chromatic
|
||||||
if: ${{ github.actor != 'renovate[bot]' && github.actor != 'renovate' && steps.changed-files-yaml.outputs.src_any_changed == 'true' }}
|
if: ${{ github.actor != 'renovate[bot]' && github.actor != 'renovate' && steps.changed-files-yaml.outputs.src_any_changed == 'true' }}
|
||||||
uses: chromaui/action@v16
|
uses: chromaui/action@v11
|
||||||
|
|
||||||
|
# Chromatic GitHub Action options
|
||||||
with:
|
with:
|
||||||
workingDir: web
|
workingDir: web
|
||||||
projectToken: f47410569b62
|
projectToken: f47410569b62
|
||||||
onlyChanged: true
|
onlyChanged: true
|
||||||
branch: ${{ github.head_ref || github.ref_name }}
|
|
||||||
sha: ${{ github.event.pull_request.head.sha || github.sha }}
|
|
||||||
|
|||||||
@@ -14,23 +14,13 @@ name: 'CodeQL'
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
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/**'
|
|
||||||
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:
|
||||||
@@ -47,24 +37,23 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Check out pull request code
|
- name: Check out pull request code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
if: github.event_name == 'pull_request'
|
if: github.event_name == 'pull_request'
|
||||||
with:
|
with:
|
||||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||||
ref: ${{ github.event.pull_request.head.ref }}
|
|
||||||
|
|
||||||
- name: Check out repository code
|
- name: Check out repository code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
if: github.event_name == 'push'
|
if: github.event_name == 'push'
|
||||||
|
|
||||||
- uses: actions/setup-go@v6
|
- uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: '1.26.2'
|
go-version: '1.22'
|
||||||
cache: true
|
cache: true
|
||||||
|
|
||||||
# Initializes the CodeQL tools for scanning.
|
# Initializes the CodeQL tools for scanning.
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@v4
|
uses: github/codeql-action/init@v3
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
config-file: ./.github/codeql/${{ matrix.language }}.yml
|
config-file: ./.github/codeql/${{ matrix.language }}.yml
|
||||||
@@ -76,7 +65,7 @@ jobs:
|
|||||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||||
# If this step fails, then you should remove it and run the build manually (see below)
|
# If this step fails, then you should remove it and run the build manually (see below)
|
||||||
- name: Autobuild
|
- name: Autobuild
|
||||||
uses: github/codeql-action/autobuild@v4
|
uses: github/codeql-action/autobuild@v3
|
||||||
|
|
||||||
# ℹ️ Command-line programs to run using the OS shell.
|
# ℹ️ Command-line programs to run using the OS shell.
|
||||||
# 📚 https://git.io/JvXDl
|
# 📚 https://git.io/JvXDl
|
||||||
@@ -90,4 +79,4 @@ jobs:
|
|||||||
# make release
|
# make release
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@v4
|
uses: github/codeql-action/analyze@v3
|
||||||
|
|||||||
@@ -20,14 +20,13 @@ jobs:
|
|||||||
image: aquasec/trivy
|
image: aquasec/trivy
|
||||||
steps:
|
steps:
|
||||||
- name: Check out pull request code
|
- name: Check out pull request code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
if: github.event_name == 'pull_request'
|
if: github.event_name == 'pull_request'
|
||||||
with:
|
with:
|
||||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||||
ref: ${{ github.event.pull_request.head.ref }}
|
|
||||||
|
|
||||||
- name: Check out repository code
|
- name: Check out repository code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
if: github.event_name == 'push'
|
if: github.event_name == 'push'
|
||||||
|
|
||||||
- name: Check critical issues
|
- name: Check critical issues
|
||||||
|
|||||||
@@ -1,14 +1,6 @@
|
|||||||
# See https://docs.earthly.dev/ci-integration/vendor-specific-guides/gh-actions-integration
|
# See https://docs.earthly.dev/ci-integration/vendor-specific-guides/gh-actions-integration
|
||||||
# for details.
|
# 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
|
name: Build development container
|
||||||
|
|
||||||
on:
|
on:
|
||||||
@@ -17,25 +9,9 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- develop
|
- develop
|
||||||
paths:
|
|
||||||
- '**/*.go'
|
|
||||||
- 'go.mod'
|
|
||||||
- 'go.sum'
|
|
||||||
- 'Dockerfile'
|
|
||||||
- 'Earthfile'
|
|
||||||
- 'build/**'
|
|
||||||
- '.github/workflows/container.yaml'
|
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- develop
|
- develop
|
||||||
paths:
|
|
||||||
- '**/*.go'
|
|
||||||
- 'go.mod'
|
|
||||||
- 'go.sum'
|
|
||||||
- 'Dockerfile'
|
|
||||||
- 'Earthfile'
|
|
||||||
- 'build/**'
|
|
||||||
- '.github/workflows/container.yaml'
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
Earthly:
|
Earthly:
|
||||||
@@ -62,14 +38,13 @@ jobs:
|
|||||||
platforms: all
|
platforms: all
|
||||||
|
|
||||||
- name: Check out pull request code
|
- name: Check out pull request code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
if: github.event_name == 'pull_request'
|
if: github.event_name == 'pull_request'
|
||||||
with:
|
with:
|
||||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||||
ref: ${{ github.event.pull_request.head.ref }}
|
|
||||||
|
|
||||||
- name: Check out repository code
|
- name: Check out repository code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
if: github.event_name == 'push' || github.event_name == 'schedule'
|
if: github.event_name == 'push' || github.event_name == 'schedule'
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -1,11 +1,3 @@
|
|||||||
# 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
|
name: CSS Lint and Formatting
|
||||||
|
|
||||||
on:
|
on:
|
||||||
@@ -25,26 +17,18 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Check out pull request code
|
- name: Check out pull request code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
if: github.event_name == 'pull_request'
|
if: github.event_name == 'pull_request'
|
||||||
with:
|
with:
|
||||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
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
|
- name: Check out repository code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
if: github.event_name == 'push'
|
if: github.event_name == 'push'
|
||||||
|
|
||||||
- name: Get changed files
|
- name: Get changed files
|
||||||
id: changed-files-yaml
|
id: changed-files-yaml
|
||||||
uses: tj-actions/changed-files@v47
|
uses: tj-actions/changed-files@v45
|
||||||
with:
|
with:
|
||||||
path: 'web'
|
path: 'web'
|
||||||
files_yaml: |
|
files_yaml: |
|
||||||
@@ -52,9 +36,9 @@ jobs:
|
|||||||
- '**/*.{css,scss}'
|
- '**/*.{css,scss}'
|
||||||
|
|
||||||
- name: Set up Node.js
|
- name: Set up Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '24.12.0'
|
node-version: '22.9.0'
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
if: steps.changed-files-yaml.outputs.src_any_changed == 'true'
|
if: steps.changed-files-yaml.outputs.src_any_changed == 'true'
|
||||||
|
|||||||
@@ -10,14 +10,13 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Check out pull request code
|
- name: Check out pull request code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
if: github.event_name == 'pull_request'
|
if: github.event_name == 'pull_request'
|
||||||
with:
|
with:
|
||||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||||
ref: ${{ github.event.pull_request.head.ref }}
|
|
||||||
|
|
||||||
- name: Check out repository code
|
- name: Check out repository code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
if: github.event_name == 'push'
|
if: github.event_name == 'push'
|
||||||
|
|
||||||
- name: Run redoc on openapi.yaml
|
- name: Run redoc on openapi.yaml
|
||||||
|
|||||||
@@ -1,27 +1,11 @@
|
|||||||
# 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
|
name: Lint
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
paths:
|
paths-ignore:
|
||||||
- '**.go'
|
- 'web/**'
|
||||||
- 'go.mod'
|
|
||||||
- 'go.sum'
|
|
||||||
- '.golangci.yml'
|
|
||||||
- '.github/workflows/go-lint.yml'
|
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths-ignore:
|
||||||
- '**.go'
|
- 'web/**'
|
||||||
- 'go.mod'
|
|
||||||
- 'go.sum'
|
|
||||||
- '.golangci.yml'
|
|
||||||
- '.github/workflows/go-lint.yml'
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -38,18 +22,17 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
concurrent_skipping: 'same_content_newer'
|
concurrent_skipping: 'same_content_newer'
|
||||||
|
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- uses: actions/setup-go@v6
|
- uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: '1.26.2'
|
go-version: '1.22'
|
||||||
cache: true
|
cache: true
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v4
|
||||||
- name: golangci-lint
|
- name: golangci-lint
|
||||||
uses: golangci/golangci-lint-action@v9
|
uses: golangci/golangci-lint-action@v6
|
||||||
with:
|
with:
|
||||||
version: v2.11.4
|
|
||||||
only-new-issues: true
|
only-new-issues: true
|
||||||
args: --timeout=3m
|
args: --timeout=3m
|
||||||
|
|||||||
@@ -1,11 +1,3 @@
|
|||||||
# 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
|
name: Go Tests
|
||||||
|
|
||||||
on:
|
on:
|
||||||
@@ -20,21 +12,21 @@ jobs:
|
|||||||
test:
|
test:
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
go-version: ${{ github.event_name == 'pull_request' && fromJSON('["1.24.x"]') || fromJSON('["1.23.x", "1.24.x"]') }}
|
go-version: [1.21.x, 1.22.x]
|
||||||
os: ${{ github.event_name == 'pull_request' && fromJSON('["ubuntu-latest"]') || fromJSON('["ubuntu-latest", "macos-latest", "windows-latest", "ubuntu-24.04-arm"]') }}
|
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Get changed files
|
- name: Get changed files
|
||||||
id: changed-files-yaml
|
id: changed-files-yaml
|
||||||
uses: tj-actions/changed-files@v47
|
uses: tj-actions/changed-files@v45
|
||||||
with:
|
with:
|
||||||
files_yaml: |
|
files_yaml: |
|
||||||
src:
|
src:
|
||||||
- '**/*.{go,mod,sum}'
|
- '**/*.{go,mod,sum}'
|
||||||
|
|
||||||
- uses: actions/cache@v5
|
- uses: actions/cache@v4
|
||||||
if: steps.changed-files-yaml.outputs.src_any_changed == 'true'
|
if: steps.changed-files-yaml.outputs.src_any_changed == 'true'
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
@@ -46,7 +38,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Install go
|
- name: Install go
|
||||||
if: steps.changed-files-yaml.outputs.src_any_changed == 'true'
|
if: steps.changed-files-yaml.outputs.src_any_changed == 'true'
|
||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: '^1'
|
go-version: '^1'
|
||||||
cache: true
|
cache: true
|
||||||
@@ -56,8 +48,6 @@ jobs:
|
|||||||
run: go test ./...
|
run: go test ./...
|
||||||
|
|
||||||
test-bsds:
|
test-bsds:
|
||||||
# Only run BSD tests on push events (not PRs) for efficiency
|
|
||||||
if: github.event_name == 'push'
|
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
@@ -68,17 +58,17 @@ jobs:
|
|||||||
version: 6.8
|
version: 6.8
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Get changed files
|
- name: Get changed files
|
||||||
id: changed-files-yaml
|
id: changed-files-yaml
|
||||||
uses: tj-actions/changed-files@v47
|
uses: tj-actions/changed-files@v45
|
||||||
with:
|
with:
|
||||||
files_yaml: |
|
files_yaml: |
|
||||||
src:
|
src:
|
||||||
- '**/*.{go,mod,sum}'
|
- '**/*.{go,mod,sum}'
|
||||||
|
|
||||||
- uses: actions/cache@v5
|
- uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.cache/go-build
|
~/.cache/go-build
|
||||||
@@ -89,7 +79,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Install go
|
- name: Install go
|
||||||
if: steps.changed-files-yaml.outputs.src_any_changed == 'true'
|
if: steps.changed-files-yaml.outputs.src_any_changed == 'true'
|
||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: '^1'
|
go-version: '^1'
|
||||||
cache: true
|
cache: true
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -1,28 +1,12 @@
|
|||||||
# This workflow tests HLS video streaming functionality
|
|
||||||
# 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
|
name: HLS tests
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
paths:
|
paths-ignore:
|
||||||
- '**.go'
|
- 'web/**'
|
||||||
- 'go.mod'
|
|
||||||
- 'go.sum'
|
|
||||||
- 'test/automated/hls/**'
|
|
||||||
- '.github/workflows/hls-tests.yml'
|
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths-ignore:
|
||||||
- '**.go'
|
- 'web/**'
|
||||||
- 'go.mod'
|
|
||||||
- 'go.sum'
|
|
||||||
- 'test/automated/hls/**'
|
|
||||||
- '.github/workflows/hls-tests.yml'
|
|
||||||
|
|
||||||
env:
|
env:
|
||||||
S3_BUCKET: ${{ secrets.S3_BUCKET }}
|
S3_BUCKET: ${{ secrets.S3_BUCKET }}
|
||||||
@@ -42,39 +26,31 @@ jobs:
|
|||||||
concurrent_skipping: 'same_content_newer'
|
concurrent_skipping: 'same_content_newer'
|
||||||
|
|
||||||
- name: Check out pull request code
|
- name: Check out pull request code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
if: github.event_name == 'pull_request'
|
if: github.event_name == 'pull_request'
|
||||||
with:
|
with:
|
||||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
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
|
- name: Check out repository code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
if: github.event_name == 'push'
|
if: github.event_name == 'push'
|
||||||
|
|
||||||
- name: Get changed files
|
- name: Get changed files
|
||||||
id: changed-files-yaml
|
id: changed-files-yaml
|
||||||
uses: tj-actions/changed-files@v47
|
uses: tj-actions/changed-files@v45
|
||||||
with:
|
with:
|
||||||
files_yaml: |
|
files_yaml: |
|
||||||
src:
|
src:
|
||||||
- '**/*.{go,mod,sum}'
|
- '**/*.{go,mod,sum}'
|
||||||
|
|
||||||
- uses: actions/setup-go@v6
|
- uses: actions/setup-go@v5
|
||||||
if: steps.changed-files-yaml.outputs.src_any_changed == 'true'
|
if: steps.changed-files-yaml.outputs.src_any_changed == 'true'
|
||||||
with:
|
with:
|
||||||
go-version: '1.26.2'
|
go-version: '1.22'
|
||||||
cache: true
|
cache: true
|
||||||
|
|
||||||
- name: Cache node modules
|
- name: Cache node modules
|
||||||
uses: actions/cache@v5
|
uses: actions/cache@v4
|
||||||
env:
|
env:
|
||||||
cache-name: cache-node-modules-hls-tests
|
cache-name: cache-node-modules-hls-tests
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -1,44 +1,20 @@
|
|||||||
# 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
|
name: Javascript
|
||||||
|
|
||||||
|
# This action works with pull requests and pushes
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
paths:
|
paths:
|
||||||
- 'web/**/*.{js,ts,tsx,jsx,json,css,scss}'
|
- web/**
|
||||||
- 'web/package.json'
|
- '!**.md'
|
||||||
- '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:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- 'web/**/*.{js,ts,tsx,jsx,json,css,scss}'
|
- web/**
|
||||||
- 'web/package.json'
|
- '!**.md'
|
||||||
- '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:
|
jobs:
|
||||||
formatting:
|
formatting:
|
||||||
name: Code formatting
|
name: Code formatting
|
||||||
if: github.actor != 'renovate[bot]' && github.actor != 'dependabot[bot]'
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
@@ -53,31 +29,24 @@ jobs:
|
|||||||
skip_after_successful_duplicate: 'true'
|
skip_after_successful_duplicate: 'true'
|
||||||
|
|
||||||
- name: Check out pull request code
|
- name: Check out pull request code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
if: github.event_name == 'pull_request'
|
if: github.event_name == 'pull_request'
|
||||||
with:
|
with:
|
||||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||||
ref: ${{ github.event.pull_request.head.ref }}
|
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
|
- name: Setup Nodejs
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '24.12.0'
|
node-version: '22.9.0'
|
||||||
|
|
||||||
- name: Check out repository code
|
- name: Check out repository code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
if: github.event_name == 'push'
|
if: github.event_name == 'push'
|
||||||
|
|
||||||
- name: Get changed files
|
- name: Get changed files
|
||||||
id: changed-files-yaml
|
id: changed-files-yaml
|
||||||
uses: tj-actions/changed-files@v47
|
uses: tj-actions/changed-files@v45
|
||||||
with:
|
with:
|
||||||
path: 'web'
|
path: 'web'
|
||||||
files_ignore: |
|
files_ignore: |
|
||||||
@@ -88,7 +57,7 @@ jobs:
|
|||||||
- '**/*.{js,ts,tsx,jsx,md}'
|
- '**/*.{js,ts,tsx,jsx,md}'
|
||||||
|
|
||||||
- name: Cache node modules
|
- name: Cache node modules
|
||||||
uses: actions/cache@v5
|
uses: actions/cache@v4
|
||||||
env:
|
env:
|
||||||
cache-name: cache-node-modules-bundle-web-app
|
cache-name: cache-node-modules-bundle-web-app
|
||||||
with:
|
with:
|
||||||
@@ -134,7 +103,6 @@ jobs:
|
|||||||
|
|
||||||
unused-code:
|
unused-code:
|
||||||
name: Test for unused code
|
name: Test for unused code
|
||||||
if: github.actor != 'renovate[bot]' && github.actor != 'dependabot[bot]'
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
@@ -149,30 +117,22 @@ jobs:
|
|||||||
skip_after_successful_duplicate: 'true'
|
skip_after_successful_duplicate: 'true'
|
||||||
|
|
||||||
- name: Check out pull request code
|
- name: Check out pull request code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
if: github.event_name == 'pull_request'
|
if: github.event_name == 'pull_request'
|
||||||
with:
|
with:
|
||||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
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
|
- name: Check out repository code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
if: github.event_name == 'push'
|
if: github.event_name == 'push'
|
||||||
|
|
||||||
- name: Setup Nodejs
|
- name: Setup Nodejs
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '24.12.0'
|
node-version: '22.9.0'
|
||||||
|
|
||||||
- name: Cache node modules
|
- name: Cache node modules
|
||||||
uses: actions/cache@v5
|
uses: actions/cache@v4
|
||||||
env:
|
env:
|
||||||
cache-name: cache-node-modules-bundle-web-app
|
cache-name: cache-node-modules-bundle-web-app
|
||||||
with:
|
with:
|
||||||
@@ -199,7 +159,7 @@ jobs:
|
|||||||
web-bundle:
|
web-bundle:
|
||||||
name: Build and bundle web project
|
name: Build and bundle web project
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: github.repository == 'owncast/owncast' && !cancelled()
|
if: github.repository == 'owncast/owncast'
|
||||||
needs: [formatting, unused-code]
|
needs: [formatting, unused-code]
|
||||||
steps:
|
steps:
|
||||||
- id: skip_check
|
- id: skip_check
|
||||||
@@ -210,12 +170,12 @@ jobs:
|
|||||||
skip_after_successful_duplicate: 'true'
|
skip_after_successful_duplicate: 'true'
|
||||||
|
|
||||||
- name: Setup Nodejs
|
- name: Setup Nodejs
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '24.12.0'
|
node-version: '22.9.0'
|
||||||
|
|
||||||
- name: Cache node modules
|
- name: Cache node modules
|
||||||
uses: actions/cache@v5
|
uses: actions/cache@v4
|
||||||
env:
|
env:
|
||||||
cache-name: cache-node-modules-bundle-web-app
|
cache-name: cache-node-modules-bundle-web-app
|
||||||
with:
|
with:
|
||||||
@@ -227,14 +187,13 @@ jobs:
|
|||||||
${{ runner.os }}-
|
${{ runner.os }}-
|
||||||
|
|
||||||
- name: Check out pull request code
|
- name: Check out pull request code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
if: github.event_name == 'pull_request'
|
if: github.event_name == 'pull_request'
|
||||||
with:
|
with:
|
||||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||||
ref: ${{ github.event.pull_request.head.ref }}
|
|
||||||
|
|
||||||
- name: Check out repository code
|
- name: Check out repository code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
if: github.event_name == 'push'
|
if: github.event_name == 'push'
|
||||||
|
|
||||||
- name: Bundle web app (next.js build)
|
- name: Bundle web app (next.js build)
|
||||||
|
|||||||
@@ -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: ${{ always() && 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
|
|
||||||
});
|
|
||||||
@@ -1,11 +1,3 @@
|
|||||||
# 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
|
name: Lint
|
||||||
|
|
||||||
on:
|
on:
|
||||||
@@ -28,21 +20,14 @@ jobs:
|
|||||||
container:
|
container:
|
||||||
image: docker.io/ubuntu:24.04
|
image: docker.io/ubuntu:24.04
|
||||||
steps:
|
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
|
- name: Check out pull request code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
if: github.event_name == 'pull_request'
|
if: github.event_name == 'pull_request'
|
||||||
with:
|
with:
|
||||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||||
|
|
||||||
- name: Check out repository code
|
- name: Check out repository code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v4
|
||||||
if: github.event_name == 'push'
|
if: github.event_name == 'push'
|
||||||
|
|
||||||
- name: Install shellcheck
|
- name: Install shellcheck
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ jobs:
|
|||||||
stale:
|
stale:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/stale@v10
|
- uses: actions/stale@v9
|
||||||
with:
|
with:
|
||||||
exempt-all-milestones: true
|
exempt-all-milestones: true
|
||||||
|
|
||||||
|
|||||||
@@ -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
-3
@@ -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
|
||||||
@@ -40,11 +39,10 @@ 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/screenshots
|
||||||
test/automated/browser/cypress/videos
|
test/automated/browser/cypress/videos
|
||||||
web/style-definitions/build/
|
web/style-definitions/build/
|
||||||
|
|
||||||
web/public/sw.js
|
web/public/sw.js
|
||||||
web/public/workbox-*.js
|
web/public/workbox-*.js
|
||||||
bin/
|
|
||||||
|
|||||||
+59
-49
@@ -1,78 +1,88 @@
|
|||||||
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.18
|
||||||
|
go: '1.22'
|
||||||
|
|
||||||
|
issues:
|
||||||
|
# The linter has a default list of ignorable errors. Turning this on will enable that list.
|
||||||
|
exclude-use-default: false
|
||||||
|
|
||||||
|
# Maximum issues count per one linter. Set to 0 to disable. Default is 50.
|
||||||
|
max-issues-per-linter: 0
|
||||||
|
|
||||||
|
# Maximum count of issues with the same text. Set to 0 to disable. Default is 3.
|
||||||
|
max-same-issues: 0
|
||||||
|
|
||||||
|
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
|
||||||
- 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
|
||||||
|
- gosimple
|
||||||
|
- unused
|
||||||
|
- copyloopvar
|
||||||
|
- gocritic
|
||||||
|
- forbidigo
|
||||||
|
- unparam
|
||||||
|
- wastedassign
|
||||||
|
linters-settings:
|
||||||
|
govet:
|
||||||
|
disable:
|
||||||
|
- composite
|
||||||
|
|
||||||
cyclop:
|
cyclop:
|
||||||
|
# the maximal code complexity to report. default is 10. eventually work our way to that.
|
||||||
max-complexity: 15
|
max-complexity: 15
|
||||||
package-average: 0
|
# the max average package complexity. If it's higher than 0.0 (float) the check is enabled (default 0.0)
|
||||||
dupl:
|
package-average: 0.0
|
||||||
threshold: 200
|
# should ignore tests
|
||||||
forbidigo:
|
skip-tests: true
|
||||||
forbid:
|
|
||||||
- pattern: ^(fmt\.Print(|f|ln)|print|println)
|
|
||||||
- pattern: ^panic.*$
|
|
||||||
gocritic:
|
gocritic:
|
||||||
disabled-checks:
|
disabled-checks:
|
||||||
- ifElseChain
|
- ifElseChain
|
||||||
- exitAfterDefer
|
- exitAfterDefer
|
||||||
|
|
||||||
revive:
|
revive:
|
||||||
rules:
|
rules:
|
||||||
- name: package-comments
|
- name: package-comments
|
||||||
disabled: true
|
disabled: true
|
||||||
exclusions:
|
|
||||||
generated: lax
|
forbidigo:
|
||||||
rules:
|
# Forbid the following identifiers (identifiers are written using regexp):
|
||||||
- path: (.+)\.go$
|
forbid:
|
||||||
text: Subprocess launch(ed with variable|ing should be audited)
|
# Logging via Print bypasses our logging framework.
|
||||||
- path: (.+)\.go$
|
- ^(fmt\.Print(|f|ln)|print|println)
|
||||||
text: Error return value of .((os\.)?std(out|err)\..*|.*Close|.*Flush|os\.Remove(All)?|.*print(f|ln)?|os\.(Un)?Setenv). is not checked
|
- ^panic.*$
|
||||||
- path: (.+)\.go$
|
|
||||||
text: G307
|
dupl:
|
||||||
- path: (.+)\.go$
|
# tokens count to trigger issue, 150 by default
|
||||||
text: composite literal uses unkeyed fields
|
threshold: 200
|
||||||
- path: (.+)\.go$
|
|
||||||
text: 'SA1019.*aws-sdk-go.*deprecated'
|
|
||||||
- linters:
|
|
||||||
- cyclop
|
|
||||||
path: (.+)_test\.go
|
|
||||||
paths:
|
|
||||||
- third_party$
|
|
||||||
- builtin$
|
|
||||||
- examples$
|
|
||||||
issues:
|
|
||||||
max-issues-per-linter: 0
|
|
||||||
max-same-issues: 0
|
|
||||||
formatters:
|
|
||||||
enable:
|
|
||||||
- goimports
|
|
||||||
exclusions:
|
|
||||||
generated: lax
|
|
||||||
paths:
|
|
||||||
- third_party$
|
|
||||||
- builtin$
|
|
||||||
- examples$
|
|
||||||
|
|||||||
@@ -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`
|
|
||||||
@@ -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
|
|
||||||
```
|
|
||||||
|
|||||||
+1
-1
@@ -22,7 +22,7 @@ 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:3.21.2
|
||||||
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
|
RUN addgroup -g 101 -S owncast && adduser -u 101 -S owncast -G owncast
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
VERSION --new-platform 0.6
|
VERSION --new-platform 0.6
|
||||||
|
|
||||||
FROM --platform=linux/amd64 alpine:3.23.3
|
FROM --platform=linux/amd64 alpine:3.21.2
|
||||||
ARG version=develop
|
ARG version=develop
|
||||||
|
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
@@ -16,12 +16,11 @@ docker-all:
|
|||||||
|
|
||||||
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 apk add --update --no-cache tar gzip upx >> /dev/null
|
||||||
RUN curl -sfL "https://owncast-infra.nyc3.cdn.digitaloceanspaces.com/build/armv7l-linux-musleabihf-cross.tgz" | tar zxf - -C /usr/ --strip-components=1
|
RUN curl -sfL "https://owncast-infra.nyc3.cdn.digitaloceanspaces.com/build/armv7l-linux-musleabihf-cross.tgz" | tar zxf - -C /usr/ --strip-components=1
|
||||||
RUN curl -sfL "https://owncast-infra.nyc3.cdn.digitaloceanspaces.com/build/i686-linux-musl-cross.tgz" | tar zxf - -C /usr/ --strip-components=1
|
RUN curl -sfL "https://owncast-infra.nyc3.cdn.digitaloceanspaces.com/build/i686-linux-musl-cross.tgz" | tar zxf - -C /usr/ --strip-components=1
|
||||||
RUN curl -sfL "https://owncast-infra.nyc3.cdn.digitaloceanspaces.com/build/x86_64-linux-musl-cross.tgz" | tar zxf - -C /usr/ --strip-components=1
|
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
|
||||||
@@ -84,39 +83,10 @@ build:
|
|||||||
# See https://github.com/upx/upx/issues/612
|
# See https://github.com/upx/upx/issues/612
|
||||||
IF [ "$GOOS" != "darwin" ]
|
IF [ "$GOOS" != "darwin" ]
|
||||||
RUN upx --best --lzma owncast
|
RUN upx --best --lzma owncast
|
||||||
# Test the binary integrity
|
# Test the binary
|
||||||
RUN upx -t owncast
|
RUN upx -t owncast
|
||||||
END
|
END
|
||||||
|
|
||||||
# Sanity check: verify the binary runs without immediate crash.
|
|
||||||
# We can only run Linux binaries in this container. macOS binaries are skipped.
|
|
||||||
# The check downloads a static ffmpeg (required dependency), starts the binary,
|
|
||||||
# waits 3 seconds, then verifies the process is still running.
|
|
||||||
IF [ "$GOOS" = "linux" ]
|
|
||||||
IF [ "$TARGETPLATFORM" = "linux/amd64" ]
|
|
||||||
# Native architecture - run directly
|
|
||||||
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
|
|
||||||
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
|
SAVE ARTIFACT --keep-ts owncast owncast
|
||||||
|
|
||||||
package:
|
package:
|
||||||
@@ -144,10 +114,12 @@ package:
|
|||||||
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 --keep-ts /build/dist/owncast.zip owncast.zip AS LOCAL dist/$ZIPNAME
|
||||||
|
|
||||||
docker-image:
|
docker:
|
||||||
# Internal target that builds the docker image. Used by +docker for testing.
|
# Multiple image names can be tagged at once. They should all be passed
|
||||||
|
# in as space separated strings using the full account/repo:tag format.
|
||||||
|
# https://github.com/earthly/earthly/blob/aea38448fa9c0064b1b70d61be717ae740689fb9/docs/earthfile/earthfile.md#assigning-multiple-image-names
|
||||||
ARG TARGETPLATFORM
|
ARG TARGETPLATFORM
|
||||||
FROM --platform=$TARGETPLATFORM alpine:3.23.3
|
FROM --platform=$TARGETPLATFORM alpine:3.21.2
|
||||||
RUN apk update && apk add --no-cache ffmpeg ffmpeg-libs ca-certificates unzip && update-ca-certificates
|
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
|
RUN addgroup -g 101 -S owncast && adduser -u 101 -S owncast -G owncast
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -161,34 +133,7 @@ docker-image:
|
|||||||
ENTRYPOINT ["/app/owncast"]
|
ENTRYPOINT ["/app/owncast"]
|
||||||
EXPOSE 8080 1935
|
EXPOSE 8080 1935
|
||||||
|
|
||||||
docker:
|
|
||||||
# Multiple image names can be tagged at once. They should all be passed
|
|
||||||
# in as space separated strings using the full account/repo:tag format.
|
|
||||||
# https://github.com/earthly/earthly/blob/aea38448fa9c0064b1b70d61be717ae740689fb9/docs/earthfile/earthfile.md#assigning-multiple-image-names
|
|
||||||
ARG TARGETPLATFORM
|
|
||||||
ARG images=ghcr.io/owncast/owncast:testing
|
ARG images=ghcr.io/owncast/owncast:testing
|
||||||
|
|
||||||
# Sanity check: run the container to verify it starts without crashing.
|
|
||||||
# Only run for linux/amd64 as other architectures would need QEMU emulation.
|
|
||||||
IF [ "$TARGETPLATFORM" = "linux/amd64" ]
|
|
||||||
WITH DOCKER --load owncast:sanity-test=+docker-image
|
|
||||||
RUN docker run -d --name owncast-sanity-test owncast:sanity-test && \
|
|
||||||
sleep 5 && \
|
|
||||||
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}"
|
RUN echo "Saving images: ${images}"
|
||||||
|
|
||||||
# Tag this image with the list of names
|
# Tag this image with the list of names
|
||||||
@@ -201,13 +146,13 @@ dockerfile:
|
|||||||
FROM DOCKERFILE -f Dockerfile .
|
FROM DOCKERFILE -f Dockerfile .
|
||||||
|
|
||||||
unit-tests:
|
unit-tests:
|
||||||
FROM --platform=linux/amd64 ghcr.io/gabek/go-crosscompile:latest
|
FROM --platform=linux/amd64 bdwyertech/go-crosscompile
|
||||||
COPY . /build
|
COPY . /build
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
RUN go test ./...
|
RUN go test ./...
|
||||||
|
|
||||||
api-tests:
|
api-tests:
|
||||||
FROM --platform=linux/amd64 ghcr.io/gabek/go-crosscompile:latest
|
FROM --platform=linux/amd64 bdwyertech/go-crosscompile
|
||||||
RUN apk add npm font-noto && fc-cache -f
|
RUN apk add npm font-noto && fc-cache -f
|
||||||
COPY . /build
|
COPY . /build
|
||||||
WORKDIR /build/test/automated/api
|
WORKDIR /build/test/automated/api
|
||||||
@@ -215,7 +160,7 @@ api-tests:
|
|||||||
RUN ./run.sh
|
RUN ./run.sh
|
||||||
|
|
||||||
hls-tests:
|
hls-tests:
|
||||||
FROM --platform=linux/amd64 ghcr.io/gabek/go-crosscompile:latest
|
FROM --platform=linux/amd64 bdwyertech/go-crosscompile
|
||||||
RUN apk add npm font-noto && fc-cache -f
|
RUN apk add npm font-noto && fc-cache -f
|
||||||
COPY . /build
|
COPY . /build
|
||||||
WORKDIR /build/test/automated/hls
|
WORKDIR /build/test/automated/hls
|
||||||
|
|||||||
-134
@@ -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,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"
|
|
||||||
@@ -28,6 +28,7 @@
|
|||||||
<a href="https://github.com/owncast/owncast/issues">Report Bug</a>
|
<a href="https://github.com/owncast/owncast/issues">Report Bug</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
||||||
<!-- TABLE OF CONTENTS -->
|
<!-- TABLE OF CONTENTS -->
|
||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
@@ -105,7 +106,7 @@ The Owncast backend is a service written in Go.
|
|||||||
1. Ensure you have prerequisites installed.
|
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/)
|
- 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)
|
- [ffmpeg](https://ffmpeg.org/download.html)
|
||||||
1. Install the [Go toolchain](https://golang.org/dl/) (1.24 or above).
|
1. Install the [Go toolchain](https://golang.org/dl/) (1.22 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 the 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.
|
||||||
@@ -132,7 +133,6 @@ If you're new to the project, maybe you'd be interested in looking at [.
|
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
|
### Donors
|
||||||
|
|
||||||
The Owncast project is possible thanks to the people who make a donation to support us and our work.
|
The Owncast project is possible thanks to the people who make a donation to support us and our work.
|
||||||
Thank you to all our donors who help keep Owncast running by donating on OpenCollective. You can support this project by [becoming a backer/sponsor](https://opencollective.com/owncast#suppor).
|
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).
|
||||||
|
|
||||||
@@ -171,11 +171,6 @@ style="vertical-align: middle;margin-left:5px" width="147" height="26"
|
|||||||
<img src="https://owncast.online/images/sponsors/chromatic.png" height="26" style="vertical-align: middle;margin-left:5px">
|
<img src="https://owncast.online/images/sponsors/chromatic.png" height="26" style="vertical-align: middle;margin-left:5px">
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</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>
|
</ul>
|
||||||
<!-- CONTACT -->
|
<!-- CONTACT -->
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
package activitypub
|
package activitypub
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"math"
|
||||||
|
|
||||||
"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/persistence/configrepository"
|
||||||
|
|
||||||
@@ -33,39 +33,17 @@ func Start(datastore *data.Datastore) {
|
|||||||
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 {
|
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
|
var followerCount int64
|
||||||
fc, err := followersRepo.GetCount()
|
fc, err := persistence.GetFollowerCount()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorln("Unable to get follower count", err)
|
log.Errorln("Unable to get follower count", err)
|
||||||
return defaultWorkers
|
fc = 50 // Arbitrary fallback value.
|
||||||
}
|
}
|
||||||
followerCount = fc
|
followerCount = int64(math.Max(float64(fc), 50))
|
||||||
|
return int(followerCount * 5)
|
||||||
// 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 +63,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()
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
+40
-154
@@ -26,8 +26,6 @@ type ActivityPubActor struct {
|
|||||||
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 is the avatar image of the Actor.
|
||||||
Image *url.URL
|
Image *url.URL
|
||||||
// DisabledAt is the time, if any, this follower was blocked/removed.
|
// DisabledAt is the time, if any, this follower was blocked/removed.
|
||||||
@@ -40,156 +38,6 @@ type ActivityPubActor struct {
|
|||||||
FullUsername string
|
FullUsername string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ErrActorMissingRequiredField is returned when an actor is missing a required field.
|
|
||||||
var ErrActorMissingRequiredField = errors.New("actor missing required field")
|
|
||||||
|
|
||||||
// Validate checks that required fields are present on the actor.
|
|
||||||
// Returns an error if ActorIri or Inbox are nil.
|
|
||||||
func (a *ActivityPubActor) Validate() error {
|
|
||||||
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.
|
||||||
type DeleteRequest struct {
|
type DeleteRequest struct {
|
||||||
ActorIri string
|
ActorIri string
|
||||||
@@ -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.
|
||||||
@@ -355,7 +241,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)
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package apmodels
|
package apmodels
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
@@ -47,8 +46,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
|
||||||
@@ -68,6 +65,34 @@ func TestMain(m *testing.M) {
|
|||||||
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)
|
||||||
@@ -155,463 +180,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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package apmodels
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
|
||||||
"net/url"
|
"net/url"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -119,149 +118,3 @@ func GetLogoType() string {
|
|||||||
}
|
}
|
||||||
return logoType
|
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()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -14,7 +14,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/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/persistence/configrepository"
|
||||||
)
|
)
|
||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"`
|
|
||||||
}
|
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -4,24 +4,12 @@ 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/persistence/configrepository"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 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()
|
configRepository := configrepository.Get()
|
||||||
|
|
||||||
@@ -36,14 +24,14 @@ func handleEngagementActivity(eventType events.EventType, isLiveNotification boo
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
@@ -63,8 +51,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 {
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,13 +6,10 @@ 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/persistence/configrepository"
|
"github.com/owncast/owncast/persistence/configrepository"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
|
|
||||||
@@ -21,7 +18,6 @@ import (
|
|||||||
|
|
||||||
func handleFollowInboxRequest(c context.Context, activity vocab.ActivityStreamsFollow) error {
|
func handleFollowInboxRequest(c context.Context, activity vocab.ActivityStreamsFollow) error {
|
||||||
configRepository := configrepository.Get()
|
configRepository := configrepository.Get()
|
||||||
followersRepo := followersrepository.Get()
|
|
||||||
|
|
||||||
follow, err := resolvers.MakeFollowRequest(c, activity)
|
follow, err := resolvers.MakeFollowRequest(c, activity)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -37,33 +33,26 @@ func handleFollowInboxRequest(c context.Context, activity vocab.ActivityStreamsF
|
|||||||
|
|
||||||
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 := configRepository.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 +86,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)
|
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-11
@@ -5,24 +5,24 @@ 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()
|
||||||
|
if object.Len() < 1 {
|
||||||
|
return errors.New("like activity is missing object")
|
||||||
|
}
|
||||||
|
|
||||||
|
if actorReference.Len() < 1 {
|
||||||
|
return errors.New("like activity is missing actor")
|
||||||
|
}
|
||||||
|
|
||||||
|
objectIRI := object.At(0).GetIRI().String()
|
||||||
|
actorIRI := actorReference.At(0).GetIRI().String()
|
||||||
|
|
||||||
if hasPreviouslyhandled, err := persistence.HasPreviouslyHandledInboundActivity(objectIRI, actorIRI, events.FediverseEngagementLike); hasPreviouslyhandled || err != nil {
|
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")
|
||||||
|
|||||||
@@ -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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -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())
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ 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/persistence/configrepository"
|
||||||
|
|
||||||
@@ -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")
|
||||||
@@ -148,8 +145,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")
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ 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"
|
"github.com/owncast/owncast/persistence/configrepository"
|
||||||
)
|
)
|
||||||
@@ -80,14 +79,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 {
|
||||||
|
|||||||
@@ -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() {
|
|
||||||
defer func() {
|
|
||||||
if r := recover(); r != nil {
|
|
||||||
log.Errorf("Recovered from panic in ActivityPub worker %d: %v", workerID, r)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
handle(job.request)
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,14 +6,12 @@ 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"
|
||||||
@@ -68,11 +66,11 @@ func SendLive() error {
|
|||||||
|
|
||||||
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 !configRepository.GetFederationIsPrivate() {
|
||||||
note.SetActivityStreamsCc(cc)
|
note = apmodels.MakeNotePublic(note)
|
||||||
activity.SetActivityStreamsTo(to)
|
activity = apmodels.MakeActivityPublic(activity)
|
||||||
activity.SetActivityStreamsCc(cc)
|
}
|
||||||
|
|
||||||
note.SetActivityStreamsTag(tagProp)
|
note.SetActivityStreamsTag(tagProp)
|
||||||
|
|
||||||
@@ -155,6 +153,8 @@ func SendDirectMessageToAccount(textContent, account string) error {
|
|||||||
|
|
||||||
// SendPublicMessage will send a public message to all followers.
|
// SendPublicMessage will send a public message to all followers.
|
||||||
func SendPublicMessage(textContent string) error {
|
func SendPublicMessage(textContent string) error {
|
||||||
|
configRepository := configrepository.Get()
|
||||||
|
|
||||||
originalContent := textContent
|
originalContent := textContent
|
||||||
textContent = utils.RenderSimpleMarkdown(textContent)
|
textContent = utils.RenderSimpleMarkdown(textContent)
|
||||||
|
|
||||||
@@ -177,11 +177,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 !configRepository.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,18 +199,6 @@ 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()
|
configRepository := configrepository.Get()
|
||||||
@@ -231,92 +218,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()
|
configRepository := configrepository.Get()
|
||||||
followersRepo := followersrepository.Get()
|
|
||||||
localActor := apmodels.MakeLocalIRIForAccount(configRepository.GetDefaultFederationUsername())
|
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.
|
|
||||||
// This spreads CPU load from cryptographic signing over time.
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, follower := range followers {
|
||||||
|
inbox, _ := url.Parse(follower.Inbox)
|
||||||
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.
|
|
||||||
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()
|
configRepository := configrepository.Get()
|
||||||
localActor := apmodels.MakeLocalIRIForAccount(configRepository.GetDefaultFederationUsername())
|
localActor := apmodels.MakeLocalIRIForAccount(configRepository.GetDefaultFederationUsername())
|
||||||
|
|
||||||
@@ -367,13 +297,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)
|
||||||
|
|||||||
@@ -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: limit,
|
||||||
|
Offset: offset,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
followers := make([]models.Follower, 0)
|
||||||
|
|
||||||
|
for _, row := range followersResult {
|
||||||
|
singleFollower := models.Follower{
|
||||||
|
Name: row.Name.String,
|
||||||
|
Username: row.Username,
|
||||||
|
Image: row.Image.String,
|
||||||
|
ActorIRI: row.Iri,
|
||||||
|
Inbox: row.Inbox,
|
||||||
|
Timestamp: utils.NullTime(row.CreatedAt),
|
||||||
|
}
|
||||||
|
|
||||||
|
followers = append(followers, singleFollower)
|
||||||
|
}
|
||||||
|
|
||||||
|
return followers, int(total), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPendingFollowRequests will return pending follow requests.
|
||||||
|
func GetPendingFollowRequests() ([]models.Follower, error) {
|
||||||
|
pendingFollowersResult, err := _datastore.GetQueries().GetFederationFollowerApprovalRequests(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
followers := make([]models.Follower, 0)
|
||||||
|
|
||||||
|
for _, row := range pendingFollowersResult {
|
||||||
|
singleFollower := models.Follower{
|
||||||
|
Name: row.Name.String,
|
||||||
|
Username: row.Username,
|
||||||
|
Image: row.Image.String,
|
||||||
|
ActorIRI: row.Iri,
|
||||||
|
Inbox: row.Inbox,
|
||||||
|
Timestamp: utils.NullTime{Time: row.CreatedAt.Time, Valid: true},
|
||||||
|
}
|
||||||
|
followers = append(followers, singleFollower)
|
||||||
|
}
|
||||||
|
|
||||||
|
return followers, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBlockedAndRejectedFollowers will return blocked and rejected followers.
|
||||||
|
func GetBlockedAndRejectedFollowers() ([]models.Follower, error) {
|
||||||
|
pendingFollowersResult, err := _datastore.GetQueries().GetRejectedAndBlockedFollowers(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
followers := make([]models.Follower, 0)
|
||||||
|
|
||||||
|
for _, row := range pendingFollowersResult {
|
||||||
|
singleFollower := models.Follower{
|
||||||
|
Name: row.Name.String,
|
||||||
|
Username: row.Username,
|
||||||
|
Image: row.Image.String,
|
||||||
|
ActorIRI: row.Iri,
|
||||||
|
DisabledAt: utils.NullTime{Time: row.DisabledAt.Time, Valid: true},
|
||||||
|
Timestamp: utils.NullTime{Time: row.CreatedAt.Time, Valid: true},
|
||||||
|
}
|
||||||
|
followers = append(followers, singleFollower)
|
||||||
|
}
|
||||||
|
|
||||||
|
return followers, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,14 +1,9 @@
|
|||||||
package persistence
|
package persistence
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/url"
|
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
|
||||||
"testing"
|
"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/core/data"
|
||||||
"github.com/owncast/owncast/models"
|
"github.com/owncast/owncast/models"
|
||||||
"github.com/owncast/owncast/utils"
|
"github.com/owncast/owncast/utils"
|
||||||
@@ -27,30 +22,16 @@ func setup() {
|
|||||||
_datastore = data.GetDatastore()
|
_datastore = data.GetDatastore()
|
||||||
createFederationFollowersTable()
|
createFederationFollowersTable()
|
||||||
|
|
||||||
followersRepo := followersrepository.New(_datastore)
|
|
||||||
|
|
||||||
number := 100
|
number := 100
|
||||||
for i := 0; i < number; i++ {
|
for i := 0; i < number; i++ {
|
||||||
u := createFakeFollower()
|
u := createFakeFollower()
|
||||||
actorIRI, _ := url.Parse(u.ActorIRI)
|
createFollow(u.ActorIRI, u.Inbox, "https://fake.fediverse.server/some/request", u.Name, u.Username, u.Image, nil, true)
|
||||||
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)
|
followers = append(followers, u)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestQueryFollowers(t *testing.T) {
|
func TestQueryFollowers(t *testing.T) {
|
||||||
followersRepo := followersrepository.New(_datastore)
|
f, total, err := GetFederationFollowers(10, 0)
|
||||||
f, total, err := followersRepo.GetFollowers(10, 0)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("Error querying followers: %s", err)
|
t.Errorf("Error querying followers: %s", err)
|
||||||
}
|
}
|
||||||
@@ -65,8 +46,7 @@ func TestQueryFollowers(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestQueryFollowersWithOffset(t *testing.T) {
|
func TestQueryFollowersWithOffset(t *testing.T) {
|
||||||
followersRepo := followersrepository.New(_datastore)
|
f, total, err := GetFederationFollowers(10, 10)
|
||||||
f, total, err := followersRepo.GetFollowers(10, 10)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("Error querying followers: %s", err)
|
t.Errorf("Error querying followers: %s", err)
|
||||||
}
|
}
|
||||||
@@ -81,8 +61,7 @@ func TestQueryFollowersWithOffset(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestQueryFollowersWithOffsetAndLimit(t *testing.T) {
|
func TestQueryFollowersWithOffsetAndLimit(t *testing.T) {
|
||||||
followersRepo := followersrepository.New(_datastore)
|
f, total, err := GetFederationFollowers(10, 90)
|
||||||
f, total, err := followersRepo.GetFollowers(10, 90)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("Error querying followers: %s", err)
|
t.Errorf("Error querying followers: %s", err)
|
||||||
}
|
}
|
||||||
@@ -97,8 +76,7 @@ func TestQueryFollowersWithOffsetAndLimit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestQueryFollowersWithPagination(t *testing.T) {
|
func TestQueryFollowersWithPagination(t *testing.T) {
|
||||||
followersRepo := followersrepository.New(_datastore)
|
f, _, err := GetFederationFollowers(15, 10)
|
||||||
f, _, err := followersRepo.GetFollowers(15, 10)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("Error querying followers: %s", err)
|
t.Errorf("Error querying followers: %s", err)
|
||||||
}
|
}
|
||||||
@@ -127,194 +105,3 @@ func createFakeFollower() models.Follower {
|
|||||||
Timestamp: utils.NullTime{},
|
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()
|
|
||||||
}
|
|
||||||
@@ -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"
|
||||||
@@ -29,9 +30,168 @@ func Setup(datastore *data.Datastore) {
|
|||||||
addFollowersFixtureData()
|
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 +237,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: limit, Offset: offset},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return collection, err
|
return collection, err
|
||||||
@@ -149,8 +309,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: limit,
|
||||||
Offset: utils.SafeIntToInt32(offset),
|
Offset: offset,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -11,7 +11,6 @@ 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/persistence/configrepository"
|
"github.com/owncast/owncast/persistence/configrepository"
|
||||||
"github.com/owncast/owncast/utils"
|
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
@@ -46,61 +45,50 @@ 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()
|
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(configRepository.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 {
|
|
||||||
return errors.New("request failed with status: " + response.Status)
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := io.ReadAll(response.Body)
|
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.
|
// Explicitly use only the first actor that might be listed.
|
||||||
actorObjectOrIRI := actor.At(0)
|
actorObjectOrIRI := actor.At(0)
|
||||||
var actorEntity apmodels.ExternalEntity
|
var actorEntity apmodels.ExternalEntity
|
||||||
|
|
||||||
// If the actor is an unresolved IRI then we need to resolve it.
|
// If the actor is an unresolved IRI then we need to resolve it.
|
||||||
if actorObjectOrIRI.IsIRI() {
|
if actorObjectOrIRI.IsIRI() {
|
||||||
iri := actorObjectOrIRI.GetIRI()
|
iri := actorObjectOrIRI.GetIRI().String()
|
||||||
if iri == nil {
|
return GetResolvedActorFromIRI(iri)
|
||||||
return apActor, errors.New("actor IRI is nil despite IsIRI() returning true")
|
|
||||||
}
|
|
||||||
return GetResolvedActorFromIRI(iri.String())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if actorObjectOrIRI.IsActivityStreamsPerson() {
|
if actorObjectOrIRI.IsActivityStreamsPerson() {
|
||||||
@@ -110,17 +98,29 @@ func GetResolvedActorFromActorProperty(actor vocab.ActivityStreamsActorProperty)
|
|||||||
} else if actorObjectOrIRI.IsActivityStreamsApplication() {
|
} else if actorObjectOrIRI.IsActivityStreamsApplication() {
|
||||||
actorEntity = actorObjectOrIRI.GetActivityStreamsApplication()
|
actorEntity = actorObjectOrIRI.GetActivityStreamsApplication()
|
||||||
} else {
|
} else {
|
||||||
return apActor, errors.New("unrecognized external ActivityPub type: " + actorObjectOrIRI.Name())
|
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.
|
// Convert the external AP entity into an internal actor representation.
|
||||||
// NewActivityPubActorFromEntity validates that required fields (ActorIri, Inbox) are present.
|
apa, e := apmodels.MakeActorFromExernalAPEntity(actorEntity)
|
||||||
apa, err := apmodels.NewActivityPubActorFromEntity(actorEntity)
|
if apa != nil {
|
||||||
if err != nil {
|
apActor = *apa
|
||||||
return apActor, errors.Wrap(err, "failed to create actor from entity")
|
resolved = true
|
||||||
|
}
|
||||||
|
err = e
|
||||||
}
|
}
|
||||||
|
|
||||||
return *apa, nil
|
if !resolved && err == nil {
|
||||||
|
err = errors.New("unknown error resolving actor from property value")
|
||||||
|
}
|
||||||
|
|
||||||
|
return apActor, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetResolvedPublicKeyFromIRI will resolve a publicKey IRI string to a vocab.W3IDSecurityV1PublicKey.
|
// GetResolvedPublicKeyFromIRI will resolve a publicKey IRI string to a vocab.W3IDSecurityV1PublicKey.
|
||||||
@@ -190,49 +190,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
|
apActor = *apa
|
||||||
resolved = true
|
resolved = true
|
||||||
return nil
|
}
|
||||||
|
return e
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
apActor = *apa
|
||||||
resolved = true
|
resolved = true
|
||||||
return nil
|
}
|
||||||
|
return e
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
apActor = *apa
|
||||||
resolved = true
|
resolved = true
|
||||||
return nil
|
}
|
||||||
|
return e
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,27 +44,17 @@ func GetWebfingerLinks(account string) ([]map[string]interface{}, error) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", requestURL.String(), nil)
|
response, err := client.Get(requestURL.String())
|
||||||
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
|
||||||
|
|||||||
@@ -2,10 +2,7 @@ package workerpool
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/owncast/owncast/utils"
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -16,44 +13,9 @@ type Job struct {
|
|||||||
|
|
||||||
var queue chan Job
|
var queue chan Job
|
||||||
|
|
||||||
// Circuit breaker backoff durations for exponential backoff.
|
|
||||||
var circuitBreakerBackoffDurations = []time.Duration{
|
|
||||||
1 * time.Minute, // 1st failure: 1 minute
|
|
||||||
5 * time.Minute, // 2nd failure: 5 minutes
|
|
||||||
15 * time.Minute, // 3rd failure: 15 minutes
|
|
||||||
30 * time.Minute, // 4th failure: 30 minutes
|
|
||||||
60 * time.Minute, // 5+ failures: 1 hour (max)
|
|
||||||
}
|
|
||||||
|
|
||||||
// httpClient is a configured HTTP client with timeouts and connection limits.
|
|
||||||
var httpClient *http.Client
|
|
||||||
|
|
||||||
// failedDomains tracks domains that are consistently failing with their failure count and backoff time.
|
|
||||||
var (
|
|
||||||
failedDomains = make(map[string]*domainFailure)
|
|
||||||
failedDomainsMutex sync.RWMutex
|
|
||||||
)
|
|
||||||
|
|
||||||
type domainFailure struct {
|
|
||||||
count int
|
|
||||||
lastFailed time.Time
|
|
||||||
backoffUntil time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// InitOutboundWorkerPool starts n go routines that await ActivityPub jobs.
|
// InitOutboundWorkerPool starts n go routines that await ActivityPub jobs.
|
||||||
func InitOutboundWorkerPool(workerPoolSize int) {
|
func InitOutboundWorkerPool(workerPoolSize int) {
|
||||||
// Use a larger buffer to decouple request creation from processing.
|
queue = make(chan Job, workerPoolSize)
|
||||||
// This prevents SendToFollowers from blocking when many followers need updates.
|
|
||||||
const minQueueBuffer = 500
|
|
||||||
queueBuffer := workerPoolSize * 10
|
|
||||||
if queueBuffer < minQueueBuffer {
|
|
||||||
queueBuffer = minQueueBuffer
|
|
||||||
}
|
|
||||||
queue = make(chan Job, queueBuffer)
|
|
||||||
|
|
||||||
// Initialize HTTP client with retry logic for transient failures
|
|
||||||
// The retryable client handles 502/503/504 errors automatically
|
|
||||||
httpClient = utils.GetRetryableHTTPClient()
|
|
||||||
|
|
||||||
// start workers
|
// start workers
|
||||||
for i := 1; i <= workerPoolSize; i++ {
|
for i := 1; i <= workerPoolSize; i++ {
|
||||||
@@ -63,12 +25,6 @@ func InitOutboundWorkerPool(workerPoolSize int) {
|
|||||||
|
|
||||||
// AddToOutboundQueue will queue up an outbound http request.
|
// AddToOutboundQueue will queue up an outbound http request.
|
||||||
func AddToOutboundQueue(req *http.Request) {
|
func AddToOutboundQueue(req *http.Request) {
|
||||||
// Check if domain should be skipped due to circuit breaker
|
|
||||||
if ShouldSkipDomain(req.URL.Host) {
|
|
||||||
log.Debugf("Skipping request to %s due to circuit breaker", req.URL.Host)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case queue <- Job{req}:
|
case queue <- Job{req}:
|
||||||
default:
|
default:
|
||||||
@@ -84,89 +40,20 @@ func worker(workerID int, queue <-chan Job) {
|
|||||||
for job := range queue {
|
for job := range queue {
|
||||||
if err := sendActivityPubMessageToInbox(job); err != nil {
|
if err := sendActivityPubMessageToInbox(job); err != nil {
|
||||||
log.Errorf("ActivityPub destination %s failed to send Error: %s", job.request.RequestURI, err)
|
log.Errorf("ActivityPub destination %s failed to send Error: %s", job.request.RequestURI, err)
|
||||||
recordDomainFailure(job.request.URL.Host)
|
|
||||||
} else {
|
|
||||||
// Reset domain failure count on success
|
|
||||||
resetDomainFailure(job.request.URL.Host)
|
|
||||||
}
|
}
|
||||||
log.Tracef("Done with ActivityPub destination %s using worker %d", job.request.RequestURI, workerID)
|
log.Tracef("Done with ActivityPub destination %s using worker %d", job.request.RequestURI, workerID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendActivityPubMessageToInbox(job Job) error {
|
func sendActivityPubMessageToInbox(job Job) error {
|
||||||
resp, err := httpClient.Do(job.request)
|
client := &http.Client{}
|
||||||
|
|
||||||
|
resp, err := client.Do(job.request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
// Consider HTTP 4xx and 5xx as failures for circuit breaker purposes
|
|
||||||
if resp.StatusCode >= 400 {
|
|
||||||
return &httpError{statusCode: resp.StatusCode, message: resp.Status}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// httpError represents an HTTP error response.
|
|
||||||
type httpError struct {
|
|
||||||
statusCode int
|
|
||||||
message string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *httpError) Error() string {
|
|
||||||
return e.message
|
|
||||||
}
|
|
||||||
|
|
||||||
// ShouldSkipDomain checks if a domain should be skipped due to circuit breaker.
|
|
||||||
// This is exported so callers can check before expensive operations like request signing.
|
|
||||||
func ShouldSkipDomain(domain string) bool {
|
|
||||||
failedDomainsMutex.RLock()
|
|
||||||
defer failedDomainsMutex.RUnlock()
|
|
||||||
|
|
||||||
failure, exists := failedDomains[domain]
|
|
||||||
if !exists {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we're still in backoff period, skip this domain
|
|
||||||
return time.Now().Before(failure.backoffUntil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// recordDomainFailure records a failure for a domain and implements exponential backoff.
|
|
||||||
func recordDomainFailure(domain string) {
|
|
||||||
failedDomainsMutex.Lock()
|
|
||||||
defer failedDomainsMutex.Unlock()
|
|
||||||
|
|
||||||
failure, exists := failedDomains[domain]
|
|
||||||
if !exists {
|
|
||||||
failure = &domainFailure{}
|
|
||||||
failedDomains[domain] = failure
|
|
||||||
}
|
|
||||||
|
|
||||||
failure.count++
|
|
||||||
failure.lastFailed = time.Now()
|
|
||||||
|
|
||||||
// Use exponential backoff with pre-defined durations
|
|
||||||
backoffIndex := failure.count - 1
|
|
||||||
if backoffIndex >= len(circuitBreakerBackoffDurations) {
|
|
||||||
backoffIndex = len(circuitBreakerBackoffDurations) - 1
|
|
||||||
}
|
|
||||||
|
|
||||||
backoffDuration := circuitBreakerBackoffDurations[backoffIndex]
|
|
||||||
failure.backoffUntil = time.Now().Add(backoffDuration)
|
|
||||||
|
|
||||||
log.Warnf("Domain %s failed %d times, backing off for %v", domain, failure.count, backoffDuration)
|
|
||||||
}
|
|
||||||
|
|
||||||
// resetDomainFailure resets the failure count for a domain on successful delivery.
|
|
||||||
func resetDomainFailure(domain string) {
|
|
||||||
failedDomainsMutex.Lock()
|
|
||||||
defer failedDomainsMutex.Unlock()
|
|
||||||
|
|
||||||
if failure, exists := failedDomains[domain]; exists && failure.count > 0 {
|
|
||||||
log.Debugf("Resetting failure count for domain %s after successful delivery", domain)
|
|
||||||
delete(failedDomains, domain)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,133 +0,0 @@
|
|||||||
package workerpool
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// resetCircuitBreakerForTesting clears all failed domain state for testing purposes.
|
|
||||||
// This function provides test isolation by resetting the global circuit breaker state.
|
|
||||||
func resetCircuitBreakerForTesting() {
|
|
||||||
failedDomainsMutex.Lock()
|
|
||||||
defer failedDomainsMutex.Unlock()
|
|
||||||
failedDomains = make(map[string]*domainFailure)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCircuitBreaker(t *testing.T) {
|
|
||||||
// Ensure clean state before test
|
|
||||||
resetCircuitBreakerForTesting()
|
|
||||||
defer resetCircuitBreakerForTesting() // Clean up after test
|
|
||||||
|
|
||||||
testDomain := "failing.example.com"
|
|
||||||
|
|
||||||
// Initially, domain should not be skipped
|
|
||||||
if ShouldSkipDomain(testDomain) {
|
|
||||||
t.Error("Domain should not be skipped initially")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Record failures
|
|
||||||
recordDomainFailure(testDomain)
|
|
||||||
recordDomainFailure(testDomain)
|
|
||||||
recordDomainFailure(testDomain)
|
|
||||||
|
|
||||||
// Domain should now be skipped
|
|
||||||
if !ShouldSkipDomain(testDomain) {
|
|
||||||
t.Error("Domain should be skipped after failures")
|
|
||||||
}
|
|
||||||
|
|
||||||
// After successful delivery, domain should be reset
|
|
||||||
resetDomainFailure(testDomain)
|
|
||||||
if ShouldSkipDomain(testDomain) {
|
|
||||||
t.Error("Domain should not be skipped after reset")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHTTPTimeouts(t *testing.T) {
|
|
||||||
// Ensure clean state before test
|
|
||||||
resetCircuitBreakerForTesting()
|
|
||||||
defer resetCircuitBreakerForTesting() // Clean up after test
|
|
||||||
|
|
||||||
// Initialize HTTP client
|
|
||||||
InitOutboundWorkerPool(1)
|
|
||||||
|
|
||||||
if httpClient == nil {
|
|
||||||
t.Error("HTTP client should be initialized")
|
|
||||||
}
|
|
||||||
|
|
||||||
if httpClient.Timeout != 8*time.Second {
|
|
||||||
t.Errorf("HTTP client should have 8 second timeout, got %v", httpClient.Timeout)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWorkerPoolSizing(t *testing.T) {
|
|
||||||
// Ensure clean state before test
|
|
||||||
resetCircuitBreakerForTesting()
|
|
||||||
defer resetCircuitBreakerForTesting() // Clean up after test
|
|
||||||
|
|
||||||
// Test that queue buffer is at least the minimum (500) even for small worker pools
|
|
||||||
InitOutboundWorkerPool(5)
|
|
||||||
|
|
||||||
if cap(queue) < 500 {
|
|
||||||
t.Errorf("Queue capacity should be at least 500, got %d", cap(queue))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test that larger worker pools get proportionally larger buffers
|
|
||||||
InitOutboundWorkerPool(100)
|
|
||||||
if cap(queue) != 1000 {
|
|
||||||
t.Errorf("Queue capacity should be 1000 for 100 workers, got %d", cap(queue))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBackoffDurations(t *testing.T) {
|
|
||||||
// Test that backoff durations are properly configured
|
|
||||||
expectedDurations := []time.Duration{
|
|
||||||
1 * time.Minute,
|
|
||||||
5 * time.Minute,
|
|
||||||
15 * time.Minute,
|
|
||||||
30 * time.Minute,
|
|
||||||
60 * time.Minute,
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(circuitBreakerBackoffDurations) != len(expectedDurations) {
|
|
||||||
t.Errorf("Expected %d backoff durations, got %d", len(expectedDurations), len(circuitBreakerBackoffDurations))
|
|
||||||
}
|
|
||||||
|
|
||||||
for i, expected := range expectedDurations {
|
|
||||||
if circuitBreakerBackoffDurations[i] != expected {
|
|
||||||
t.Errorf("Backoff duration at index %d: expected %v, got %v", i, expected, circuitBreakerBackoffDurations[i])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCircuitBreakerIsolation(t *testing.T) {
|
|
||||||
// Test that multiple tests don't interfere with each other
|
|
||||||
resetCircuitBreakerForTesting()
|
|
||||||
defer resetCircuitBreakerForTesting()
|
|
||||||
|
|
||||||
domain1 := "test1.example.com"
|
|
||||||
domain2 := "test2.example.com"
|
|
||||||
|
|
||||||
// Neither domain should be blocked initially
|
|
||||||
if ShouldSkipDomain(domain1) || ShouldSkipDomain(domain2) {
|
|
||||||
t.Error("Domains should not be blocked initially")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Record failures for domain1 only
|
|
||||||
recordDomainFailure(domain1)
|
|
||||||
recordDomainFailure(domain1)
|
|
||||||
recordDomainFailure(domain1)
|
|
||||||
|
|
||||||
// Only domain1 should be blocked
|
|
||||||
if !ShouldSkipDomain(domain1) {
|
|
||||||
t.Error("Domain1 should be blocked after failures")
|
|
||||||
}
|
|
||||||
if ShouldSkipDomain(domain2) {
|
|
||||||
t.Error("Domain2 should not be blocked")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset and verify clean state
|
|
||||||
resetCircuitBreakerForTesting()
|
|
||||||
if ShouldSkipDomain(domain1) || ShouldSkipDomain(domain2) {
|
|
||||||
t.Error("Both domains should be unblocked after reset")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -68,7 +68,7 @@ func RegisterFediverseOTP(accessToken, userID, userDisplayName, account string)
|
|||||||
defer lock.Unlock()
|
defer lock.Unlock()
|
||||||
|
|
||||||
if len(pendingAuthRequests)+1 > maxPendingRequests {
|
if len(pendingAuthRequests)+1 > maxPendingRequests {
|
||||||
return request, false, errors.New("too many pending requests, please try again later")
|
return request, false, errors.New("Please try again later. Too many pending requests.")
|
||||||
}
|
}
|
||||||
|
|
||||||
code, _ := createCode()
|
code, _ := createCode()
|
||||||
|
|||||||
@@ -21,4 +21,4 @@ if [ -n "${EARTHLY_BUILD_BRANCH}" ]; then
|
|||||||
git checkout "${EARTHLY_BUILD_BRANCH}" || exit
|
git checkout "${EARTHLY_BUILD_BRANCH}" || exit
|
||||||
fi
|
fi
|
||||||
|
|
||||||
earthly -P --ci +docker-all --images="ghcr.io/owncast/${EARTHLY_IMAGE_NAME}:${BUILD_TAG}" --version="${VERSION}"
|
earthly --ci +docker-all --images="ghcr.io/owncast/${EARTHLY_IMAGE_NAME}:${BUILD_TAG}" --version="${VERSION}"
|
||||||
|
|||||||
+9
-6
@@ -1,6 +1,6 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
# Tools are managed in tools/go.mod and installed to ./bin
|
# go install github.com/deepmap/oapi-codegen/v2/cmd/oapi-codegen@latest
|
||||||
|
|
||||||
# setup
|
# setup
|
||||||
package="generated"
|
package="generated"
|
||||||
@@ -13,6 +13,12 @@ if ! command -v redocly &>/dev/null; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if ! command -v oapi-codegen &>/dev/null; then
|
||||||
|
echo "Please install \`oapi-codegen\` before running this script"
|
||||||
|
echo "Hint: run \`go install github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@latest\` to install"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
# validate schema
|
# validate schema
|
||||||
npx redocly lint $specPath
|
npx redocly lint $specPath
|
||||||
if [ $? -ne 0 ]; then
|
if [ $? -ne 0 ]; then
|
||||||
@@ -24,12 +30,9 @@ fi
|
|||||||
rm -r $folderPath
|
rm -r $folderPath
|
||||||
mkdir -p $folderPath
|
mkdir -p $folderPath
|
||||||
|
|
||||||
# install oapi-codegen
|
|
||||||
GOBIN=$(pwd)/bin go install -C tools github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen
|
|
||||||
|
|
||||||
# codegen
|
# codegen
|
||||||
./bin/oapi-codegen -generate types -o $folderPath/$package-types.gen.go -package $package $specPath
|
oapi-codegen -generate types -o $folderPath/$package-types.gen.go -package $package $specPath
|
||||||
./bin/oapi-codegen -generate "chi-server" -o $folderPath/$package.gen.go -package $package $specPath
|
oapi-codegen -generate "chi-server" -o $folderPath/$package.gen.go -package $package $specPath
|
||||||
|
|
||||||
# go
|
# go
|
||||||
go mod tidy
|
go mod tidy
|
||||||
|
|||||||
@@ -43,10 +43,6 @@ var EnableAutoUpdate = false
|
|||||||
// A temporary stream key that can be set via the command line.
|
// A temporary stream key that can be set via the command line.
|
||||||
var TemporaryStreamKey = ""
|
var TemporaryStreamKey = ""
|
||||||
|
|
||||||
// FollowerValidationInterval is how often the follower validation job runs.
|
|
||||||
// Defaults to 0 which means use the default (1 hour).
|
|
||||||
var FollowerValidationInterval time.Duration = 0
|
|
||||||
|
|
||||||
// GetCommit will return an identifier used for identifying the point in time this build took place.
|
// GetCommit will return an identifier used for identifying the point in time this build took place.
|
||||||
func GetCommit() string {
|
func GetCommit() string {
|
||||||
if GitCommit == "" {
|
if GitCommit == "" {
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ import "path/filepath"
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// StaticVersionNumber is the version of Owncast that is used when it's not overwritten via build-time settings.
|
// StaticVersionNumber is the version of Owncast that is used when it's not overwritten via build-time settings.
|
||||||
StaticVersionNumber = "0.2.4" // Shown when you build from develop
|
StaticVersionNumber = "0.2.1" // Shown when you build from develop
|
||||||
// FfmpegSuggestedVersion is the version of ffmpeg we suggest.
|
// FfmpegSuggestedVersion is the version of ffmpeg we suggest.
|
||||||
FfmpegSuggestedVersion = "v4.1.5" // Requires the v
|
FfmpegSuggestedVersion = "v4.1.5" // Requires the v
|
||||||
// DataDirectory is the directory we save data to.
|
// DataDirectory is the directory we save data to.
|
||||||
|
|||||||
+5
-8
@@ -4,7 +4,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/owncast/owncast/models"
|
"github.com/owncast/owncast/models"
|
||||||
"github.com/owncast/owncast/webserver/handlers/generated"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Defaults will hold default configuration values.
|
// Defaults will hold default configuration values.
|
||||||
@@ -26,7 +25,7 @@ type Defaults struct {
|
|||||||
WebServerIP string
|
WebServerIP string
|
||||||
Name string
|
Name string
|
||||||
AdminPassword string
|
AdminPassword string
|
||||||
StreamKeys []generated.StreamKey
|
StreamKeys []models.StreamKey
|
||||||
|
|
||||||
StreamVariants []models.StreamOutputVariant
|
StreamVariants []models.StreamOutputVariant
|
||||||
|
|
||||||
@@ -44,16 +43,14 @@ type Defaults struct {
|
|||||||
|
|
||||||
// GetDefaults will return default configuration values.
|
// GetDefaults will return default configuration values.
|
||||||
func GetDefaults() Defaults {
|
func GetDefaults() Defaults {
|
||||||
defaultStreamKey := "abc123"
|
|
||||||
defaultStreamKeyComment := "Default stream key"
|
|
||||||
return Defaults{
|
return Defaults{
|
||||||
Name: "New Owncast Server",
|
Name: "New Owncast Server",
|
||||||
Summary: "This is a new live video streaming server powered by Owncast.",
|
Summary: "This is a new live video streaming server powered by Owncast.",
|
||||||
ServerWelcomeMessage: "",
|
ServerWelcomeMessage: "",
|
||||||
Logo: "logo.svg",
|
Logo: "logo.svg",
|
||||||
AdminPassword: "abc123",
|
AdminPassword: "abc123",
|
||||||
StreamKeys: []generated.StreamKey{
|
StreamKeys: []models.StreamKey{
|
||||||
{Key: &defaultStreamKey, Comment: &defaultStreamKeyComment},
|
{Key: "abc123", Comment: "Default stream key"},
|
||||||
},
|
},
|
||||||
Tags: []string{
|
Tags: []string{
|
||||||
"owncast",
|
"owncast",
|
||||||
@@ -65,7 +62,7 @@ func GetDefaults() Defaults {
|
|||||||
|
|
||||||
- This is a live stream powered by [Owncast](https://owncast.online), a free and open source live streaming server.
|
- This is a live stream powered by [Owncast](https://owncast.online), a free and open source live streaming server.
|
||||||
|
|
||||||
- To discover more examples of streams, visit [Owncast's directory](https://owncast.directory).
|
- To discover more examples of streams, visit [Owncast's directory](https://directory.owncast.online).
|
||||||
|
|
||||||
- If you're the owner of this server you should visit the admin and customize the content on this page.
|
- If you're the owner of this server you should visit the admin and customize the content on this page.
|
||||||
|
|
||||||
@@ -79,7 +76,7 @@ func GetDefaults() Defaults {
|
|||||||
DatabaseFilePath: "data/owncast.db",
|
DatabaseFilePath: "data/owncast.db",
|
||||||
|
|
||||||
YPEnabled: false,
|
YPEnabled: false,
|
||||||
YPServer: "https://owncast.directory",
|
YPServer: "https://directory.owncast.online",
|
||||||
|
|
||||||
WebServerPort: 8080,
|
WebServerPort: 8080,
|
||||||
WebServerIP: "0.0.0.0",
|
WebServerIP: "0.0.0.0",
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
@echo off
|
|
||||||
setlocal enabledelayedexpansion
|
|
||||||
|
|
||||||
REM This script will make your local Owncast server available at a public URL.
|
|
||||||
REM It's particularly useful for testing on mobile devices or want to test
|
|
||||||
REM activitypub integration.
|
|
||||||
REM Pass a custom domain as an argument if you have previously set it up at
|
|
||||||
REM localhost.run. Otherwise, a random hostname will be generated.
|
|
||||||
REM SET DOMAIN=me.example.com && test\test-local.bat
|
|
||||||
REM Pass a port number as an argument if you are running Owncast on a different port.
|
|
||||||
REM By default, it will use port 8080.
|
|
||||||
REM SET PORT=8080 && test\test-local.bat
|
|
||||||
|
|
||||||
REM Set default port if not provided
|
|
||||||
if "%PORT%"=="" set PORT=8080
|
|
||||||
set HOST=localhost
|
|
||||||
|
|
||||||
echo Checking if web server is running on port %PORT%...
|
|
||||||
|
|
||||||
REM Using PowerShell to check if the port is open (equivalent to nc -zv)
|
|
||||||
powershell -Command "try { $tcpConnection = New-Object System.Net.Sockets.TcpClient; $tcpConnection.Connect('%HOST%', %PORT%); $tcpConnection.Close(); exit 0 } catch { exit 1 }"
|
|
||||||
|
|
||||||
if %ERRORLEVEL% equ 0 (
|
|
||||||
echo Your web server is running on port %PORT%. Good.
|
|
||||||
) else (
|
|
||||||
echo Please make sure your Owncast server is running on port %PORT%.
|
|
||||||
exit /b 1
|
|
||||||
)
|
|
||||||
|
|
||||||
if not "%DOMAIN%"=="" (
|
|
||||||
echo Attempting to use custom domain: %DOMAIN%
|
|
||||||
ssh -R "%DOMAIN%":80:localhost:%PORT% localhost.run
|
|
||||||
) else (
|
|
||||||
echo Using auto-generated hostname for tunnel.
|
|
||||||
ssh -R 80:localhost:%PORT% localhost.run
|
|
||||||
)
|
|
||||||
@@ -1,350 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ocTestStream.js - RTMP test stream utility
|
|
||||||
*
|
|
||||||
* Requirements:
|
|
||||||
* - Node.js
|
|
||||||
* - ffmpeg (a recent version with loop video support)
|
|
||||||
* - a Sans family font (for overlay text)
|
|
||||||
*
|
|
||||||
* Example: node ocTestStream.js ~/Downloads/*.mp4 rtmp://127.0.0.1/live/abc123
|
|
||||||
*/
|
|
||||||
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const { spawn, exec } = require('child_process');
|
|
||||||
const os = require('os');
|
|
||||||
|
|
||||||
class OcTestStream {
|
|
||||||
constructor() {
|
|
||||||
this.ffmpegExec = null;
|
|
||||||
this.destinationHost = 'rtmp://127.0.0.1/live/abc123';
|
|
||||||
this.videoFiles = [];
|
|
||||||
this.listFile = 'list.txt';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find ffmpeg executable across different platforms
|
|
||||||
*/
|
|
||||||
async findFFmpeg() {
|
|
||||||
const ffmpegExecs = ['ffmpeg', 'ffmpeg.exe'];
|
|
||||||
const ffmpegPaths = ['./', '../', ''];
|
|
||||||
|
|
||||||
// Try local paths first
|
|
||||||
for (const execName of ffmpegExecs) {
|
|
||||||
for (const execPath of ffmpegPaths) {
|
|
||||||
const fullPath = path.join(execPath, execName);
|
|
||||||
try {
|
|
||||||
await this.checkExecutable(fullPath);
|
|
||||||
this.ffmpegExec = fullPath;
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
|
||||||
// Continue searching
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try system PATH
|
|
||||||
for (const execName of ffmpegExecs) {
|
|
||||||
try {
|
|
||||||
await this.checkExecutable(execName);
|
|
||||||
this.ffmpegExec = execName;
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
|
||||||
// Continue searching
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if an executable exists and is accessible
|
|
||||||
*/
|
|
||||||
checkExecutable(execPath) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
exec(`"${execPath}" -version`, (error, stdout, stderr) => {
|
|
||||||
if (error) {
|
|
||||||
reject(error);
|
|
||||||
} else {
|
|
||||||
resolve(stdout);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get ffmpeg version information
|
|
||||||
*/
|
|
||||||
async getFFmpegVersion() {
|
|
||||||
try {
|
|
||||||
const output = await this.checkExecutable(this.ffmpegExec);
|
|
||||||
const versionMatch = output.match(/ffmpeg version ([^\s]+)/);
|
|
||||||
return versionMatch ? versionMatch[1] : 'unknown';
|
|
||||||
} catch (e) {
|
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get ffmpeg executable path
|
|
||||||
*/
|
|
||||||
async getFFmpegPath() {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const command = os.platform() === 'win32' ? `where "${this.ffmpegExec}"` : `which "${this.ffmpegExec}"`;
|
|
||||||
exec(command, (error, stdout, stderr) => {
|
|
||||||
if (error) {
|
|
||||||
resolve('unknown');
|
|
||||||
} else {
|
|
||||||
resolve(stdout.trim().split('\n')[0]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse command line arguments
|
|
||||||
*/
|
|
||||||
parseArguments(args) {
|
|
||||||
if (args.includes('--help')) {
|
|
||||||
this.showHelp();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if last argument is RTMP URL
|
|
||||||
const lastArg = args[args.length - 1];
|
|
||||||
if (lastArg && lastArg.includes('rtmp://')) {
|
|
||||||
console.log('RTMP server is specified');
|
|
||||||
this.destinationHost = lastArg;
|
|
||||||
this.videoFiles = args.slice(0, -1);
|
|
||||||
} else {
|
|
||||||
console.log('RTMP server is not specified');
|
|
||||||
this.videoFiles = args;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Show help information
|
|
||||||
*/
|
|
||||||
showHelp() {
|
|
||||||
console.log('ocTestStream is used for sending pre-recorded or internal test content to an RTMP server.');
|
|
||||||
console.log('Usage: node ocTestStream.js [VIDEO_FILES] [RTMP_DESTINATION]');
|
|
||||||
console.log('VIDEO_FILES: path to one or multiple videos for sending to the RTMP server (optional)');
|
|
||||||
console.log('RTMP_DESTINATION: URL of RTMP server with key (optional; default: rtmp://127.0.0.1/live/abc123)');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get system font path for overlay text
|
|
||||||
*/
|
|
||||||
getSystemFont() {
|
|
||||||
const platform = os.platform();
|
|
||||||
|
|
||||||
if (platform === 'win32') {
|
|
||||||
// Workaround lack of font config on Windows.
|
|
||||||
const windir = process.env.WINDIR || 'C:\\Windows';
|
|
||||||
return path.join(windir, 'fonts', 'arial.ttf').replace(/\\/g, '/').replace(/:/g, '\\\\:');
|
|
||||||
} else {
|
|
||||||
// Use default font.
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stream internal test video pattern
|
|
||||||
*/
|
|
||||||
streamTestPattern() {
|
|
||||||
console.log(`Streaming internal test video loop to ${this.destinationHost}`);
|
|
||||||
console.log('...press ctrl+c to exit');
|
|
||||||
|
|
||||||
const font = this.getSystemFont();
|
|
||||||
const fontParam = font ? `:fontfile=${font}` : '';
|
|
||||||
|
|
||||||
const args = [
|
|
||||||
'-hide_banner', '-loglevel', 'panic', '-nostdin', '-re', '-f', 'lavfi',
|
|
||||||
'-i', 'testsrc=size=1280x720:rate=60[out0];sine=frequency=400:sample_rate=48000[out1]',
|
|
||||||
'-vf', `[in]drawtext=fontsize=96:box=1:boxcolor=black@0.75:boxborderw=5${fontParam}:fontcolor=white:x=(w-text_w)/2:y=((h-text_h)/2)+((h-text_h)/-2):text='Owncast Test Stream',drawtext=fontsize=96:box=1:boxcolor=black@0.75:boxborderw=5${fontParam}:fontcolor=white:x=(w-text_w)/2:y=((h-text_h)/2)+((h-text_h)/2):text='%{gmtime\\:%H\\\\\\:%M\\\\\\:%S} UTC'[out]`,
|
|
||||||
'-nal-hrd', 'cbr',
|
|
||||||
'-metadata:s:v', 'encoder=test',
|
|
||||||
'-vcodec', 'libx264',
|
|
||||||
'-acodec', 'aac',
|
|
||||||
'-preset', 'veryfast',
|
|
||||||
'-profile:v', 'baseline',
|
|
||||||
'-tune', 'zerolatency',
|
|
||||||
'-bf', '0',
|
|
||||||
'-g', '0',
|
|
||||||
'-b:v', '6320k',
|
|
||||||
'-b:a', '160k',
|
|
||||||
'-ac', '2',
|
|
||||||
'-ar', '48000',
|
|
||||||
'-minrate', '6320k',
|
|
||||||
'-maxrate', '6320k',
|
|
||||||
'-bufsize', '6320k',
|
|
||||||
'-muxrate', '6320k',
|
|
||||||
'-r', '60',
|
|
||||||
'-pix_fmt', 'yuv420p',
|
|
||||||
'-color_range', '1', '-colorspace', '1', '-color_primaries', '1', '-color_trc', '1',
|
|
||||||
'-flags:v', '+global_header',
|
|
||||||
'-bsf:v', 'dump_extra',
|
|
||||||
'-x264-params', 'nal-hrd=cbr:min-keyint=2:keyint=2:scenecut=0:bframes=0',
|
|
||||||
'-f', 'flv', this.destinationHost
|
|
||||||
];
|
|
||||||
|
|
||||||
this.streamWithArgs(args);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stream with arguments
|
|
||||||
*/
|
|
||||||
streamWithArgs(args) {
|
|
||||||
const ffmpeg = spawn(this.ffmpegExec, args, { detached: false, stdio: 'inherit' });
|
|
||||||
|
|
||||||
ffmpeg.on('error', (err) => {
|
|
||||||
console.error('Error starting ffmpeg:', err);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
process.on('SIGINT', () => {
|
|
||||||
if (os.platform() === 'win32') {
|
|
||||||
// Because ffmpeg can spawn children, on windows we need to use taskkill.
|
|
||||||
// Using ffmpeg.kill on Windows may result in orphaned processes.
|
|
||||||
exec(`taskkill /T /PID ${ffmpeg.pid}`);
|
|
||||||
} else {
|
|
||||||
ffmpeg.kill('SIGINT');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
process.on('exit', () => {
|
|
||||||
if (os.platform() === 'win32') {
|
|
||||||
exec(`taskkill /F /T /PID ${ffmpeg.pid}`);
|
|
||||||
} else {
|
|
||||||
ffmpeg.kill('SIGINT');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create playlist file for video files
|
|
||||||
*/
|
|
||||||
createPlaylist() {
|
|
||||||
try {
|
|
||||||
const content = this.videoFiles.map(file => `file '${file}'`).join('\n');
|
|
||||||
fs.writeFileSync(this.listFile, content);
|
|
||||||
return true;
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error creating playlist file:', err);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate video files exist
|
|
||||||
*/
|
|
||||||
validateFiles() {
|
|
||||||
for (const file of this.videoFiles) {
|
|
||||||
if (!fs.existsSync(file)) {
|
|
||||||
console.error(`ERROR: File not found: ${file}`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stream video files
|
|
||||||
*/
|
|
||||||
streamVideoFiles() {
|
|
||||||
if (!this.validateFiles()) {
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.createPlaylist()) {
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Streaming a loop of ${this.videoFiles.length} video(s) to ${this.destinationHost}`);
|
|
||||||
if (this.videoFiles.length > 1) {
|
|
||||||
console.log('Warning: If these files differ greatly in formats, transitioning from one to another may not always work correctly.');
|
|
||||||
}
|
|
||||||
console.log(this.videoFiles.join(' '));
|
|
||||||
console.log('...press ctrl+c to exit');
|
|
||||||
|
|
||||||
const font = this.getSystemFont();
|
|
||||||
const fontParam = font ? `:fontfile=${font}` : '';
|
|
||||||
|
|
||||||
const args = [
|
|
||||||
'-hide_banner', '-loglevel', 'panic', '-nostdin', '-stream_loop', '-1', '-re', '-f', 'concat',
|
|
||||||
'-safe', '0',
|
|
||||||
'-i', this.listFile,
|
|
||||||
'-vcodec', 'libx264',
|
|
||||||
'-profile:v', 'high',
|
|
||||||
'-g', '48',
|
|
||||||
'-r', '24',
|
|
||||||
'-sc_threshold', '0',
|
|
||||||
'-b:v', '1300k',
|
|
||||||
'-preset', 'veryfast',
|
|
||||||
'-acodec', 'copy',
|
|
||||||
'-vf', `drawtext=fontsize=96:box=1:boxcolor=black@0.75:boxborderw=5${fontParam}:fontcolor=white:x=(w-text_w)/2:y=((h-text_h)/2)+((h-text_h)/4):text='%{gmtime\\:%H\\\\\\:%M\\\\\\:%S}'`,
|
|
||||||
'-f', 'flv', this.destinationHost
|
|
||||||
];
|
|
||||||
|
|
||||||
this.streamWithArgs(args);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cleanup temporary files
|
|
||||||
*/
|
|
||||||
cleanup() {
|
|
||||||
try {
|
|
||||||
if (fs.existsSync(this.listFile)) {
|
|
||||||
fs.unlinkSync(this.listFile);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error during cleanup:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Main execution function
|
|
||||||
*/
|
|
||||||
async run() {
|
|
||||||
// Parse command line arguments (skip node and script name)
|
|
||||||
const args = process.argv.slice(2);
|
|
||||||
|
|
||||||
if (!this.parseArguments(args)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find ffmpeg executable
|
|
||||||
if (!(await this.findFFmpeg())) {
|
|
||||||
console.error('ERROR: ffmpeg was not found in path or in the current directory! Please install ffmpeg before using this script.');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show ffmpeg information
|
|
||||||
const version = await this.getFFmpegVersion();
|
|
||||||
const execPath = await this.getFFmpegPath();
|
|
||||||
console.log(`ffmpeg executable: ${this.ffmpegExec} (${version})`);
|
|
||||||
console.log(`ffmpeg path: ${execPath}`);
|
|
||||||
|
|
||||||
// Stream based on whether files were provided
|
|
||||||
if (this.videoFiles.length === 0) {
|
|
||||||
this.streamTestPattern();
|
|
||||||
} else {
|
|
||||||
this.streamVideoFiles();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run if called directly
|
|
||||||
if (require.main === module) {
|
|
||||||
const stream = new OcTestStream();
|
|
||||||
stream.run().catch(err => {
|
|
||||||
console.error('Error:', err);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = OcTestStream;
|
|
||||||
+4
-7
@@ -8,7 +8,6 @@ import (
|
|||||||
"github.com/owncast/owncast/config"
|
"github.com/owncast/owncast/config"
|
||||||
"github.com/owncast/owncast/core/chat/events"
|
"github.com/owncast/owncast/core/chat/events"
|
||||||
"github.com/owncast/owncast/models"
|
"github.com/owncast/owncast/models"
|
||||||
"github.com/owncast/owncast/persistence/chatmessagerepository"
|
|
||||||
"github.com/owncast/owncast/persistence/configrepository"
|
"github.com/owncast/owncast/persistence/configrepository"
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||||
@@ -104,8 +103,7 @@ func SendSystemMessage(text string, ephemeral bool) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !ephemeral {
|
if !ephemeral {
|
||||||
chatMessageRepository := chatmessagerepository.Get()
|
saveEvent(message.ID, nil, message.Body, message.GetMessageType(), nil, message.Timestamp, nil, nil, nil, nil)
|
||||||
chatMessageRepository.SaveEvent(message.ID, nil, message.Body, message.GetMessageType(), nil, message.Timestamp, nil, nil, nil, nil)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -126,14 +124,14 @@ func SendFediverseAction(eventType string, userAccountName string, image *string
|
|||||||
}
|
}
|
||||||
|
|
||||||
message.SetDefaults()
|
message.SetDefaults()
|
||||||
|
message.RenderBody()
|
||||||
|
|
||||||
if err := Broadcast(&message); err != nil {
|
if err := Broadcast(&message); err != nil {
|
||||||
log.Errorln("error sending system message", err)
|
log.Errorln("error sending system message", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
chatMessageRepository := chatmessagerepository.Get()
|
saveFederatedAction(message)
|
||||||
chatMessageRepository.SaveFederatedAction(message)
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -154,8 +152,7 @@ func SendSystemAction(text string, ephemeral bool) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !ephemeral {
|
if !ephemeral {
|
||||||
chatMessageRepository := chatmessagerepository.Get()
|
saveEvent(message.ID, nil, message.Body, message.GetMessageType(), nil, message.Timestamp, nil, nil, nil, nil)
|
||||||
chatMessageRepository.SaveEvent(message.ID, nil, message.Body, message.GetMessageType(), nil, message.Timestamp, nil, nil, nil, nil)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
+2
-15
@@ -9,7 +9,6 @@ import (
|
|||||||
"github.com/owncast/owncast/config"
|
"github.com/owncast/owncast/config"
|
||||||
"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/webhooks"
|
||||||
"github.com/owncast/owncast/persistence/chatmessagerepository"
|
|
||||||
"github.com/owncast/owncast/persistence/configrepository"
|
"github.com/owncast/owncast/persistence/configrepository"
|
||||||
"github.com/owncast/owncast/persistence/userrepository"
|
"github.com/owncast/owncast/persistence/userrepository"
|
||||||
"github.com/owncast/owncast/utils"
|
"github.com/owncast/owncast/utils"
|
||||||
@@ -33,18 +32,6 @@ func (s *Server) userNameChanged(eventData chatClientEvent) {
|
|||||||
// Names have a max length
|
// Names have a max length
|
||||||
proposedUsername = utils.MakeSafeStringOfLength(proposedUsername, config.MaxChatDisplayNameLength)
|
proposedUsername = utils.MakeSafeStringOfLength(proposedUsername, config.MaxChatDisplayNameLength)
|
||||||
|
|
||||||
// Check if the sanitized name is empty or just whitespace
|
|
||||||
if strings.TrimSpace(proposedUsername) == "" {
|
|
||||||
log.Debugln(logSanitize(eventData.client.User.DisplayName), "attempted to change name to empty or whitespace-only name")
|
|
||||||
message := "Display name cannot be empty or contain only whitespace."
|
|
||||||
s.sendActionToClient(eventData.client, message)
|
|
||||||
|
|
||||||
// Resend the client's user so their username is in sync.
|
|
||||||
eventData.client.sendConnectedClientInfo()
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, blockedName := range blocklist {
|
for _, blockedName := range blocklist {
|
||||||
normalizedName := strings.TrimSpace(blockedName)
|
normalizedName := strings.TrimSpace(blockedName)
|
||||||
normalizedName = strings.ToLower(normalizedName)
|
normalizedName = strings.ToLower(normalizedName)
|
||||||
@@ -185,8 +172,8 @@ func (s *Server) userMessageSent(eventData chatClientEvent) {
|
|||||||
// Send chat message sent webhook
|
// Send chat message sent webhook
|
||||||
webhooks.SendChatEvent(&event)
|
webhooks.SendChatEvent(&event)
|
||||||
chatMessagesSentCounter.Inc()
|
chatMessagesSentCounter.Inc()
|
||||||
chatMessageRepository := chatmessagerepository.Get()
|
|
||||||
chatMessageRepository.SaveUserMessage(event)
|
SaveUserMessage(event)
|
||||||
eventData.client.MessageCount++
|
eventData.client.MessageCount++
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+13
-13
@@ -100,13 +100,13 @@ func newEmojis(emotes ...emojiDef.Emoji) emojiDef.Emojis {
|
|||||||
return self
|
return self
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *emojis) Get(shortName string) (*emojiDef.Emoji, bool) {
|
func (self *emojis) Get(shortName string) (*emojiDef.Emoji, bool) {
|
||||||
v, ok := e.names[strings.ToLower(shortName)]
|
v, ok := self.names[strings.ToLower(shortName)]
|
||||||
if ok {
|
if ok {
|
||||||
return v, ok
|
return v, ok
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, child := range e.children {
|
for _, child := range self.children {
|
||||||
v, ok := child.Get(shortName)
|
v, ok := child.Get(shortName)
|
||||||
if ok {
|
if ok {
|
||||||
return v, ok
|
return v, ok
|
||||||
@@ -116,18 +116,18 @@ func (e *emojis) Get(shortName string) (*emojiDef.Emoji, bool) {
|
|||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *emojis) Add(emotes emojiDef.Emojis) {
|
func (self *emojis) Add(emotes emojiDef.Emojis) {
|
||||||
e.children = append(e.children, emotes)
|
self.children = append(self.children, emotes)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *emojis) Clone() emojiDef.Emojis {
|
func (self *emojis) Clone() emojiDef.Emojis {
|
||||||
clone := &emojis{
|
clone := &emojis{
|
||||||
list: e.list,
|
list: self.list,
|
||||||
names: e.names,
|
names: self.names,
|
||||||
children: make([]emojiDef.Emojis, len(e.children)),
|
children: make([]emojiDef.Emojis, len(self.children)),
|
||||||
}
|
}
|
||||||
|
|
||||||
copy(clone.children, e.children)
|
copy(clone.children, self.children)
|
||||||
|
|
||||||
return clone
|
return clone
|
||||||
}
|
}
|
||||||
@@ -137,7 +137,7 @@ var (
|
|||||||
emojiDefs = newEmojis()
|
emojiDefs = newEmojis()
|
||||||
emojiHTML = make(map[string]string)
|
emojiHTML = make(map[string]string)
|
||||||
emojiModTime time.Time
|
emojiModTime time.Time
|
||||||
emojiHTMLFormat = `<img src="{{ .Url }}" class="emoji" alt=":{{ .Name }}:" title=":{{ .Name }}:">`
|
emojiHTMLFormat = `<img src="{{ .URL }}" class="emoji" alt=":{{ .Name }}:" title=":{{ .Name }}:">`
|
||||||
emojiHTMLTemplate = template.Must(template.New("emojiHTML").Parse(emojiHTMLFormat))
|
emojiHTMLTemplate = template.Must(template.New("emojiHTML").Parse(emojiHTMLFormat))
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -162,9 +162,9 @@ func loadEmoji() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
emojiHTML[strings.ToLower(*emojiList[i].Name)] = buf.String()
|
emojiHTML[strings.ToLower(emojiList[i].Name)] = buf.String()
|
||||||
|
|
||||||
emoji := emojiDef.NewEmoji(*emojiList[i].Name, nil, strings.ToLower(*emojiList[i].Name))
|
emoji := emojiDef.NewEmoji(emojiList[i].Name, nil, strings.ToLower(emojiList[i].Name))
|
||||||
emojiArr = append(emojiArr, emoji)
|
emojiArr = append(emojiArr, emoji)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ func (e *FediverseEngagementEvent) GetBroadcastPayload() EventPayload {
|
|||||||
"timestamp": e.Timestamp,
|
"timestamp": e.Timestamp,
|
||||||
"body": e.Body,
|
"body": e.Body,
|
||||||
"image": e.Image,
|
"image": e.Image,
|
||||||
"type": e.Type,
|
"type": e.Event.Type,
|
||||||
"title": e.UserAccountName,
|
"title": e.UserAccountName,
|
||||||
"link": e.Link,
|
"link": e.Link,
|
||||||
"user": EventPayload{
|
"user": EventPayload{
|
||||||
@@ -33,5 +33,5 @@ func (e *FediverseEngagementEvent) GetBroadcastPayload() EventPayload {
|
|||||||
|
|
||||||
// GetMessageType will return the event type for this message.
|
// GetMessageType will return the event type for this message.
|
||||||
func (e *FediverseEngagementEvent) GetMessageType() EventType {
|
func (e *FediverseEngagementEvent) GetMessageType() EventType {
|
||||||
return e.Type
|
return e.Event.Type
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,15 +5,13 @@ import (
|
|||||||
|
|
||||||
"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/webhooks"
|
||||||
"github.com/owncast/owncast/persistence/chatmessagerepository"
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SetMessagesVisibility will set the visibility of multiple messages by ID.
|
// SetMessagesVisibility will set the visibility of multiple messages by ID.
|
||||||
func SetMessagesVisibility(messageIDs []string, visibility bool) error {
|
func SetMessagesVisibility(messageIDs []string, visibility bool) error {
|
||||||
// Save new message visibility
|
// Save new message visibility
|
||||||
chatMessageRepository := chatmessagerepository.Get()
|
if err := saveMessageVisibility(messageIDs, visibility); err != nil {
|
||||||
if err := chatMessageRepository.SetMessageVisibilityForMessageIDs(messageIDs, visibility); err != nil {
|
|
||||||
log.Errorln(err)
|
log.Errorln(err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,25 @@
|
|||||||
package chat
|
package chat
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/owncast/owncast/core/chat/events"
|
||||||
"github.com/owncast/owncast/core/data"
|
"github.com/owncast/owncast/core/data"
|
||||||
|
"github.com/owncast/owncast/models"
|
||||||
"github.com/owncast/owncast/persistence/authrepository"
|
"github.com/owncast/owncast/persistence/authrepository"
|
||||||
"github.com/owncast/owncast/persistence/tables"
|
"github.com/owncast/owncast/persistence/tables"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
var _datastore *data.Datastore
|
var _datastore *data.Datastore
|
||||||
|
|
||||||
const (
|
const (
|
||||||
maxBacklogHours = 2 // Keep backlog max hours worth of messages
|
maxBacklogHours = 2 // Keep backlog max hours worth of messages
|
||||||
|
maxBacklogNumber = 50 // Return max number of messages in history request
|
||||||
)
|
)
|
||||||
|
|
||||||
func setupPersistence() {
|
func setupPersistence() {
|
||||||
@@ -29,3 +37,456 @@ func setupPersistence() {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SaveUserMessage will save a single chat event to the messages database.
|
||||||
|
func SaveUserMessage(event events.UserMessageEvent) {
|
||||||
|
saveEvent(event.ID, &event.User.ID, event.Body, event.Type, event.HiddenAt, event.Timestamp, nil, nil, nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveFederatedAction(event events.FediverseEngagementEvent) {
|
||||||
|
saveEvent(event.ID, nil, event.Body, event.Type, nil, event.Timestamp, event.Image, &event.Link, &event.UserAccountName, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// nolint: unparam
|
||||||
|
func saveEvent(id string, userID *string, body string, eventType string, hidden *time.Time, timestamp time.Time, image *string, link *string, title *string, subtitle *string) {
|
||||||
|
defer func() {
|
||||||
|
_historyCache = nil
|
||||||
|
}()
|
||||||
|
|
||||||
|
tx, err := _datastore.DB.Begin()
|
||||||
|
if err != nil {
|
||||||
|
log.Errorln("error saving", eventType, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
defer tx.Rollback() // nolint
|
||||||
|
|
||||||
|
stmt, err := tx.Prepare("INSERT INTO messages(id, user_id, body, eventType, hidden_at, timestamp, image, link, title, subtitle) values(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
|
||||||
|
if err != nil {
|
||||||
|
log.Errorln("error saving", eventType, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
defer stmt.Close()
|
||||||
|
|
||||||
|
if _, err = stmt.Exec(id, userID, body, eventType, hidden, timestamp, image, link, title, subtitle); err != nil {
|
||||||
|
log.Errorln("error saving", eventType, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err = tx.Commit(); err != nil {
|
||||||
|
log.Errorln("error saving", eventType, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeUserMessageEventFromRowData(row rowData) events.UserMessageEvent {
|
||||||
|
scopes := ""
|
||||||
|
if row.userScopes != nil {
|
||||||
|
scopes = *row.userScopes
|
||||||
|
}
|
||||||
|
|
||||||
|
previousUsernames := []string{}
|
||||||
|
if row.previousUsernames != nil {
|
||||||
|
previousUsernames = strings.Split(*row.previousUsernames, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
displayName := ""
|
||||||
|
if row.userDisplayName != nil {
|
||||||
|
displayName = *row.userDisplayName
|
||||||
|
}
|
||||||
|
|
||||||
|
displayColor := 0
|
||||||
|
if row.userDisplayColor != nil {
|
||||||
|
displayColor = *row.userDisplayColor
|
||||||
|
}
|
||||||
|
|
||||||
|
createdAt := time.Time{}
|
||||||
|
if row.userCreatedAt != nil {
|
||||||
|
createdAt = *row.userCreatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
isBot := (row.userType != nil && *row.userType == "API")
|
||||||
|
scopeSlice := strings.Split(scopes, ",")
|
||||||
|
|
||||||
|
u := models.User{
|
||||||
|
ID: *row.userID,
|
||||||
|
DisplayName: displayName,
|
||||||
|
DisplayColor: displayColor,
|
||||||
|
CreatedAt: createdAt,
|
||||||
|
DisabledAt: row.userDisabledAt,
|
||||||
|
NameChangedAt: row.userNameChangedAt,
|
||||||
|
PreviousNames: previousUsernames,
|
||||||
|
AuthenticatedAt: row.userAuthenticatedAt,
|
||||||
|
Authenticated: row.userAuthenticatedAt != nil,
|
||||||
|
Scopes: scopeSlice,
|
||||||
|
IsBot: isBot,
|
||||||
|
}
|
||||||
|
|
||||||
|
message := events.UserMessageEvent{
|
||||||
|
Event: events.Event{
|
||||||
|
Type: row.eventType,
|
||||||
|
ID: row.id,
|
||||||
|
Timestamp: row.timestamp,
|
||||||
|
},
|
||||||
|
UserEvent: events.UserEvent{
|
||||||
|
User: &u,
|
||||||
|
HiddenAt: row.hiddenAt,
|
||||||
|
},
|
||||||
|
MessageEvent: events.MessageEvent{
|
||||||
|
Body: row.body,
|
||||||
|
RawBody: row.body,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeSystemMessageChatEventFromRowData(row rowData) events.SystemMessageEvent {
|
||||||
|
message := events.SystemMessageEvent{
|
||||||
|
Event: events.Event{
|
||||||
|
Type: row.eventType,
|
||||||
|
ID: row.id,
|
||||||
|
Timestamp: row.timestamp,
|
||||||
|
},
|
||||||
|
MessageEvent: events.MessageEvent{
|
||||||
|
Body: row.body,
|
||||||
|
RawBody: row.body,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeActionMessageChatEventFromRowData(row rowData) events.ActionEvent {
|
||||||
|
message := events.ActionEvent{
|
||||||
|
Event: events.Event{
|
||||||
|
Type: row.eventType,
|
||||||
|
ID: row.id,
|
||||||
|
Timestamp: row.timestamp,
|
||||||
|
},
|
||||||
|
MessageEvent: events.MessageEvent{
|
||||||
|
Body: row.body,
|
||||||
|
RawBody: row.body,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeFederatedActionChatEventFromRowData(row rowData) events.FediverseEngagementEvent {
|
||||||
|
message := events.FediverseEngagementEvent{
|
||||||
|
Event: events.Event{
|
||||||
|
Type: row.eventType,
|
||||||
|
ID: row.id,
|
||||||
|
Timestamp: row.timestamp,
|
||||||
|
},
|
||||||
|
MessageEvent: events.MessageEvent{
|
||||||
|
Body: row.body,
|
||||||
|
RawBody: row.body,
|
||||||
|
},
|
||||||
|
Image: row.image,
|
||||||
|
Link: *row.link,
|
||||||
|
UserAccountName: *row.title,
|
||||||
|
}
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
|
||||||
|
type rowData struct {
|
||||||
|
timestamp time.Time
|
||||||
|
image *string
|
||||||
|
previousUsernames *string
|
||||||
|
|
||||||
|
userDisplayName *string
|
||||||
|
userDisplayColor *int
|
||||||
|
userID *string
|
||||||
|
title *string
|
||||||
|
subtitle *string
|
||||||
|
link *string
|
||||||
|
|
||||||
|
userType *string
|
||||||
|
userScopes *string
|
||||||
|
hiddenAt *time.Time
|
||||||
|
userCreatedAt *time.Time
|
||||||
|
userDisabledAt *time.Time
|
||||||
|
userAuthenticatedAt *time.Time
|
||||||
|
userNameChangedAt *time.Time
|
||||||
|
body string
|
||||||
|
eventType models.EventType
|
||||||
|
id string
|
||||||
|
}
|
||||||
|
|
||||||
|
func getChat(rows *sql.Rows) ([]interface{}, error) {
|
||||||
|
history := make([]interface{}, 0)
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
row := rowData{}
|
||||||
|
|
||||||
|
// Convert a database row into a chat event
|
||||||
|
if err := rows.Scan(
|
||||||
|
&row.id,
|
||||||
|
&row.userID,
|
||||||
|
&row.body,
|
||||||
|
&row.title,
|
||||||
|
&row.subtitle,
|
||||||
|
&row.image,
|
||||||
|
&row.link,
|
||||||
|
&row.eventType,
|
||||||
|
&row.hiddenAt,
|
||||||
|
&row.timestamp,
|
||||||
|
&row.userDisplayName,
|
||||||
|
&row.userDisplayColor,
|
||||||
|
&row.userCreatedAt,
|
||||||
|
&row.userDisabledAt,
|
||||||
|
&row.previousUsernames,
|
||||||
|
&row.userNameChangedAt,
|
||||||
|
&row.userAuthenticatedAt,
|
||||||
|
&row.userScopes,
|
||||||
|
&row.userType,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var message interface{}
|
||||||
|
|
||||||
|
switch row.eventType {
|
||||||
|
case events.MessageSent:
|
||||||
|
message = makeUserMessageEventFromRowData(row)
|
||||||
|
case events.SystemMessageSent:
|
||||||
|
message = makeSystemMessageChatEventFromRowData(row)
|
||||||
|
case events.ChatActionSent:
|
||||||
|
message = makeActionMessageChatEventFromRowData(row)
|
||||||
|
case events.FediverseEngagementFollow:
|
||||||
|
message = makeFederatedActionChatEventFromRowData(row)
|
||||||
|
case events.FediverseEngagementLike:
|
||||||
|
message = makeFederatedActionChatEventFromRowData(row)
|
||||||
|
case events.FediverseEngagementRepost:
|
||||||
|
message = makeFederatedActionChatEventFromRowData(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
history = append(history, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
return history, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var _historyCache *[]interface{}
|
||||||
|
|
||||||
|
// GetChatModerationHistory will return all the chat messages suitable for moderation purposes.
|
||||||
|
func GetChatModerationHistory() []interface{} {
|
||||||
|
if _historyCache != nil {
|
||||||
|
return *_historyCache
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := _datastore.DB.Begin()
|
||||||
|
if err != nil {
|
||||||
|
log.Errorln("error fetching chat moderation history", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
defer tx.Rollback() // nolint
|
||||||
|
|
||||||
|
// Get all messages regardless of visibility
|
||||||
|
query := "SELECT messages.id, user_id, body, title, subtitle, image, link, eventType, hidden_at, timestamp, display_name, display_color, created_at, disabled_at, previous_names, namechanged_at, authenticated_at, scopes, type FROM messages INNER JOIN users ON messages.user_id = users.id ORDER BY timestamp DESC"
|
||||||
|
stmt, err := tx.Prepare(query)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorln("error fetching chat moderation history", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := stmt.Query()
|
||||||
|
if err != nil {
|
||||||
|
log.Errorln("error fetching chat moderation history", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
defer stmt.Close()
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
result, err := getChat(rows)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorln(err)
|
||||||
|
log.Errorln("There is a problem enumerating chat message rows. Please report this:", query)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
_historyCache = &result
|
||||||
|
|
||||||
|
if err = tx.Commit(); err != nil {
|
||||||
|
log.Errorln("error fetching chat moderation history", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetChatHistory will return all the chat messages suitable for returning as user-facing chat history.
|
||||||
|
func GetChatHistory() []interface{} {
|
||||||
|
tx, err := _datastore.DB.Begin()
|
||||||
|
if err != nil {
|
||||||
|
log.Errorln("error fetching chat history", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
defer tx.Rollback() // nolint
|
||||||
|
|
||||||
|
// Get all visible messages
|
||||||
|
query := "SELECT messages.id, messages.user_id, messages.body, messages.title, messages.subtitle, messages.image, messages.link, messages.eventType, messages.hidden_at, messages.timestamp, users.display_name, users.display_color, users.created_at, users.disabled_at, users.previous_names, users.namechanged_at, users.authenticated_at, users.scopes, users.type FROM users JOIN messages ON users.id = messages.user_id WHERE hidden_at IS NULL AND disabled_at IS NULL ORDER BY timestamp DESC LIMIT ?"
|
||||||
|
|
||||||
|
stmt, err := tx.Prepare(query)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorln("error fetching chat history", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := stmt.Query(maxBacklogNumber)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorln("error fetching chat history", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
defer stmt.Close()
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
m, err := getChat(rows)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorln(err)
|
||||||
|
log.Errorln("There is a problem enumerating chat message rows. Please report this:", query)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = tx.Commit(); err != nil {
|
||||||
|
log.Errorln("error fetching chat history", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invert order of messages
|
||||||
|
for i, j := 0, len(m)-1; i < j; i, j = i+1, j-1 {
|
||||||
|
m[i], m[j] = m[j], m[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMessagesFromUser returns chat messages that were sent by a specific user.
|
||||||
|
func GetMessagesFromUser(userID string) ([]events.UserMessageEvent, error) {
|
||||||
|
query, err := _datastore.GetQueries().GetMessagesFromUser(context.Background(), sql.NullString{String: userID, Valid: true})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]events.UserMessageEvent, len(query))
|
||||||
|
for i, row := range query {
|
||||||
|
results[i] = events.UserMessageEvent{
|
||||||
|
Event: events.Event{
|
||||||
|
Timestamp: row.Timestamp.Time,
|
||||||
|
ID: row.ID,
|
||||||
|
},
|
||||||
|
MessageEvent: events.MessageEvent{
|
||||||
|
Body: row.Body.String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMessageVisibilityForUserID will bulk change the visibility of messages for a user
|
||||||
|
// and then send out visibility changed events to chat clients.
|
||||||
|
func SetMessageVisibilityForUserID(userID string, visible bool) error {
|
||||||
|
defer func() {
|
||||||
|
_historyCache = nil
|
||||||
|
}()
|
||||||
|
|
||||||
|
tx, err := _datastore.DB.Begin()
|
||||||
|
if err != nil {
|
||||||
|
log.Errorln("error while setting message visibility", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
defer tx.Rollback() // nolint
|
||||||
|
query := "SELECT messages.id, user_id, body, title, subtitle, image, link, eventType, hidden_at, timestamp, display_name, display_color, created_at, disabled_at, previous_names, namechanged_at, authenticated_at, scopes, type FROM messages INNER JOIN users ON messages.user_id = users.id WHERE user_id IS ?"
|
||||||
|
|
||||||
|
stmt, err := tx.Prepare(query)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorln("error while setting message visibility", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := stmt.Query(userID)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorln("error while setting message visibility", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
defer stmt.Close()
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
// Get a list of IDs to send to the connected clients to hide
|
||||||
|
ids := make([]string, 0)
|
||||||
|
|
||||||
|
messages, err := getChat(rows)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorln(err)
|
||||||
|
log.Errorln("There is a problem enumerating chat message rows. Please report this:", query)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(messages) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, message := range messages {
|
||||||
|
ids = append(ids, message.(events.UserMessageEvent).ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = tx.Commit(); err != nil {
|
||||||
|
log.Errorln("error while setting message visibility ", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tell the clients to hide/show these messages.
|
||||||
|
return SetMessagesVisibility(ids, visible)
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveMessageVisibility(messageIDs []string, visible bool) error {
|
||||||
|
defer func() {
|
||||||
|
_historyCache = nil
|
||||||
|
}()
|
||||||
|
|
||||||
|
_datastore.DbLock.Lock()
|
||||||
|
defer _datastore.DbLock.Unlock()
|
||||||
|
|
||||||
|
tx, err := _datastore.DB.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// nolint:gosec
|
||||||
|
stmt, err := tx.Prepare("UPDATE messages SET hidden_at=? WHERE id IN (?" + strings.Repeat(",?", len(messageIDs)-1) + ")")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer stmt.Close()
|
||||||
|
|
||||||
|
var hiddenAt *time.Time
|
||||||
|
if !visible {
|
||||||
|
now := time.Now()
|
||||||
|
hiddenAt = &now
|
||||||
|
} else {
|
||||||
|
hiddenAt = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
args := make([]interface{}, len(messageIDs)+1)
|
||||||
|
args[0] = hiddenAt
|
||||||
|
for i, id := range messageIDs {
|
||||||
|
args[i+1] = id
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err = stmt.Exec(args...); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = tx.Commit(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -397,14 +397,6 @@ func (s *Server) eventReceived(event chatClientEvent) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if authentication is required for chat
|
|
||||||
if configRepository.GetChatRequireAuthentication() {
|
|
||||||
if u == nil || (!u.Authenticated && !u.IsModerator()) {
|
|
||||||
s.sendActionToClient(c, "Authentication is required to participate in chat.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var typecheck map[string]interface{}
|
var typecheck map[string]interface{}
|
||||||
if err := json.Unmarshal(event.data, &typecheck); err != nil {
|
if err := json.Unmarshal(event.data, &typecheck); err != nil {
|
||||||
log.Debugln(err)
|
log.Debugln(err)
|
||||||
|
|||||||
+2
-2
@@ -15,8 +15,8 @@ import (
|
|||||||
"github.com/owncast/owncast/core/transcoder"
|
"github.com/owncast/owncast/core/transcoder"
|
||||||
"github.com/owncast/owncast/core/webhooks"
|
"github.com/owncast/owncast/core/webhooks"
|
||||||
"github.com/owncast/owncast/models"
|
"github.com/owncast/owncast/models"
|
||||||
|
"github.com/owncast/owncast/notifications"
|
||||||
"github.com/owncast/owncast/persistence/configrepository"
|
"github.com/owncast/owncast/persistence/configrepository"
|
||||||
"github.com/owncast/owncast/persistence/notificationsrepository"
|
|
||||||
"github.com/owncast/owncast/persistence/tables"
|
"github.com/owncast/owncast/persistence/tables"
|
||||||
"github.com/owncast/owncast/utils"
|
"github.com/owncast/owncast/utils"
|
||||||
"github.com/owncast/owncast/yp"
|
"github.com/owncast/owncast/yp"
|
||||||
@@ -83,7 +83,7 @@ func Start() error {
|
|||||||
|
|
||||||
webhooks.SetupWebhooks(GetStatus)
|
webhooks.SetupWebhooks(GetStatus)
|
||||||
|
|
||||||
notificationsrepository.Setup(data.GetStore())
|
notifications.Setup(data.GetStore())
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-3
@@ -19,7 +19,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
schemaVersion = 9
|
schemaVersion = 7
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -75,8 +75,7 @@ func SetupPersistence(file string) error {
|
|||||||
_, _ = db.Exec("pragma temp_store = memory")
|
_, _ = db.Exec("pragma temp_store = memory")
|
||||||
_, _ = db.Exec("pragma wal_checkpoint(full)")
|
_, _ = db.Exec("pragma wal_checkpoint(full)")
|
||||||
|
|
||||||
tables.CreateConfigTable(db)
|
createWebhooksTable()
|
||||||
tables.CreateWebhooksTable(db)
|
|
||||||
tables.CreateUsersTable(db)
|
tables.CreateUsersTable(db)
|
||||||
tables.CreateAccessTokenTable(db)
|
tables.CreateAccessTokenTable(db)
|
||||||
|
|
||||||
|
|||||||
@@ -107,11 +107,19 @@ func (ds *Datastore) Save(e models.ConfigEntry) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup will perform initial initialization.
|
// Setup will create the datastore table and perform initial initialization.
|
||||||
func (ds *Datastore) Setup() {
|
func (ds *Datastore) Setup() {
|
||||||
ds.cache = make(map[string][]byte)
|
ds.cache = make(map[string][]byte)
|
||||||
ds.DB = GetDatabase()
|
ds.DB = GetDatabase()
|
||||||
ds.DbLock = &sync.Mutex{}
|
ds.DbLock = &sync.Mutex{}
|
||||||
|
|
||||||
|
createTableSQL := `CREATE TABLE IF NOT EXISTS datastore (
|
||||||
|
"key" string NOT NULL PRIMARY KEY,
|
||||||
|
"value" BLOB,
|
||||||
|
"timestamp" DATE DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||||
|
);`
|
||||||
|
|
||||||
|
ds.MustExec(createTableSQL)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset will delete all config entries in the datastore and start over.
|
// Reset will delete all config entries in the datastore and start over.
|
||||||
|
|||||||
+6
-6
@@ -10,16 +10,16 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/owncast/owncast/config"
|
"github.com/owncast/owncast/config"
|
||||||
|
"github.com/owncast/owncast/models"
|
||||||
"github.com/owncast/owncast/static"
|
"github.com/owncast/owncast/static"
|
||||||
"github.com/owncast/owncast/utils"
|
"github.com/owncast/owncast/utils"
|
||||||
"github.com/owncast/owncast/webserver/handlers/generated"
|
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
emojiCacheMu sync.Mutex
|
emojiCacheMu sync.Mutex
|
||||||
emojiCacheData = make([]generated.Emoji, 0)
|
emojiCacheData = make([]models.CustomEmoji, 0)
|
||||||
emojiCacheModTime time.Time
|
emojiCacheModTime time.Time
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -51,7 +51,7 @@ func UpdateEmojiList(force bool) (time.Time, error) {
|
|||||||
return modTime, fmt.Errorf("unable to open custom emoji directory")
|
return modTime, fmt.Errorf("unable to open custom emoji directory")
|
||||||
}
|
}
|
||||||
|
|
||||||
emojiCacheData = make([]generated.Emoji, 0)
|
emojiCacheData = make([]models.CustomEmoji, 0)
|
||||||
|
|
||||||
walkFunction := func(path string, d os.DirEntry, err error) error {
|
walkFunction := func(path string, d os.DirEntry, err error) error {
|
||||||
if d == nil || d.IsDir() {
|
if d == nil || d.IsDir() {
|
||||||
@@ -61,7 +61,7 @@ func UpdateEmojiList(force bool) (time.Time, error) {
|
|||||||
emojiPath := filepath.Join(config.EmojiDir, path)
|
emojiPath := filepath.Join(config.EmojiDir, path)
|
||||||
fileName := d.Name()
|
fileName := d.Name()
|
||||||
fileBase := fileName[:len(fileName)-len(filepath.Ext(fileName))]
|
fileBase := fileName[:len(fileName)-len(filepath.Ext(fileName))]
|
||||||
singleEmoji := generated.Emoji{Name: &fileBase, Url: &emojiPath}
|
singleEmoji := models.CustomEmoji{Name: fileBase, URL: emojiPath}
|
||||||
emojiCacheData = append(emojiCacheData, singleEmoji)
|
emojiCacheData = append(emojiCacheData, singleEmoji)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -76,7 +76,7 @@ func UpdateEmojiList(force bool) (time.Time, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetEmojiList returns a list of custom emoji from the emoji directory.
|
// GetEmojiList returns a list of custom emoji from the emoji directory.
|
||||||
func GetEmojiList() []generated.Emoji {
|
func GetEmojiList() []models.CustomEmoji {
|
||||||
_, err := UpdateEmojiList(false)
|
_, err := UpdateEmojiList(false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -88,7 +88,7 @@ func GetEmojiList() []generated.Emoji {
|
|||||||
|
|
||||||
// return a copy of cache data, ensures underlying slice isn't affected
|
// return a copy of cache data, ensures underlying slice isn't affected
|
||||||
// by future update
|
// by future update
|
||||||
emojiData := make([]generated.Emoji, len(emojiCacheData))
|
emojiData := make([]models.CustomEmoji, len(emojiCacheData))
|
||||||
copy(emojiData, emojiCacheData)
|
copy(emojiData, emojiCacheData)
|
||||||
|
|
||||||
return emojiData
|
return emojiData
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package data
|
||||||
|
|
||||||
|
// GetMessagesCount will return the number of messages in the database.
|
||||||
|
func GetMessagesCount() int64 {
|
||||||
|
query := `SELECT COUNT(*) FROM messages`
|
||||||
|
rows, err := _db.Query(query)
|
||||||
|
if err != nil || rows.Err() != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var count int64
|
||||||
|
for rows.Next() {
|
||||||
|
if err := rows.Scan(&count); err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package webhookrepository
|
package data
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
@@ -6,51 +6,38 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/owncast/owncast/core/data"
|
|
||||||
"github.com/owncast/owncast/models"
|
"github.com/owncast/owncast/models"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
type WebhookRepository interface {
|
func createWebhooksTable() {
|
||||||
InsertWebhook(url string, events []models.EventType) (int, error)
|
log.Traceln("Creating webhooks table...")
|
||||||
DeleteWebhook(id int) error
|
|
||||||
GetWebhooksForEvent(event models.EventType) []models.Webhook
|
|
||||||
GetWebhooks() ([]models.Webhook, error)
|
|
||||||
SetWebhookAsUsed(webhook models.Webhook) error
|
|
||||||
}
|
|
||||||
|
|
||||||
type SqlWebhookRepository struct {
|
createTableSQL := `CREATE TABLE IF NOT EXISTS webhooks (
|
||||||
datastore *data.Datastore
|
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
}
|
"url" string NOT NULL,
|
||||||
|
"events" TEXT NOT NULL,
|
||||||
|
"timestamp" DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"last_used" DATETIME
|
||||||
|
);`
|
||||||
|
|
||||||
// NOTE: This is temporary during the transition period.
|
stmt, err := _db.Prepare(createTableSQL)
|
||||||
var temporaryGlobalInstance WebhookRepository
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
// Get will return the user repository.
|
|
||||||
func Get() WebhookRepository {
|
|
||||||
if temporaryGlobalInstance == nil {
|
|
||||||
i := New(data.GetDatastore())
|
|
||||||
temporaryGlobalInstance = i
|
|
||||||
}
|
}
|
||||||
return temporaryGlobalInstance
|
defer stmt.Close()
|
||||||
}
|
if _, err = stmt.Exec(); err != nil {
|
||||||
|
log.Warnln(err)
|
||||||
// New will create a new instance of the UserRepository.
|
|
||||||
func New(datastore *data.Datastore) WebhookRepository {
|
|
||||||
r := SqlWebhookRepository{
|
|
||||||
datastore: datastore,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return &r
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// InsertWebhook will add a new webhook to the database.
|
// InsertWebhook will add a new webhook to the database.
|
||||||
func (r *SqlWebhookRepository) InsertWebhook(url string, events []models.EventType) (int, error) {
|
func InsertWebhook(url string, events []models.EventType) (int, error) {
|
||||||
log.Traceln("Adding new webhook")
|
log.Traceln("Adding new webhook")
|
||||||
|
|
||||||
eventsString := strings.Join(events, ",")
|
eventsString := strings.Join(events, ",")
|
||||||
|
|
||||||
tx, err := r.datastore.DB.Begin()
|
tx, err := _db.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
@@ -78,10 +65,10 @@ func (r *SqlWebhookRepository) InsertWebhook(url string, events []models.EventTy
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DeleteWebhook will delete a webhook from the database.
|
// DeleteWebhook will delete a webhook from the database.
|
||||||
func (r *SqlWebhookRepository) DeleteWebhook(id int) error {
|
func DeleteWebhook(id int) error {
|
||||||
log.Traceln("Deleting webhook")
|
log.Traceln("Deleting webhook")
|
||||||
|
|
||||||
tx, err := r.datastore.DB.Begin()
|
tx, err := _db.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -109,7 +96,7 @@ func (r *SqlWebhookRepository) DeleteWebhook(id int) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetWebhooksForEvent will return all of the webhooks that want to be notified about an event type.
|
// GetWebhooksForEvent will return all of the webhooks that want to be notified about an event type.
|
||||||
func (r *SqlWebhookRepository) GetWebhooksForEvent(event models.EventType) []models.Webhook {
|
func GetWebhooksForEvent(event models.EventType) []models.Webhook {
|
||||||
webhooks := make([]models.Webhook, 0)
|
webhooks := make([]models.Webhook, 0)
|
||||||
|
|
||||||
query := `SELECT * FROM (
|
query := `SELECT * FROM (
|
||||||
@@ -124,9 +111,9 @@ func (r *SqlWebhookRepository) GetWebhooksForEvent(event models.EventType) []mod
|
|||||||
SELECT id, url, event
|
SELECT id, url, event
|
||||||
FROM split
|
FROM split
|
||||||
WHERE event <> ''
|
WHERE event <> ''
|
||||||
) AS webhook WHERE event IS ?`
|
) AS webhook WHERE event IS "` + event + `"`
|
||||||
|
|
||||||
rows, err := r.datastore.DB.Query(query, event)
|
rows, err := _db.Query(query)
|
||||||
if err != nil || rows.Err() != nil {
|
if err != nil || rows.Err() != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -153,12 +140,12 @@ func (r *SqlWebhookRepository) GetWebhooksForEvent(event models.EventType) []mod
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetWebhooks will return all the webhooks.
|
// GetWebhooks will return all the webhooks.
|
||||||
func (r *SqlWebhookRepository) GetWebhooks() ([]models.Webhook, error) { //nolint
|
func GetWebhooks() ([]models.Webhook, error) { //nolint
|
||||||
webhooks := make([]models.Webhook, 0)
|
webhooks := make([]models.Webhook, 0)
|
||||||
|
|
||||||
query := "SELECT * FROM webhooks"
|
query := "SELECT * FROM webhooks"
|
||||||
|
|
||||||
rows, err := r.datastore.DB.Query(query)
|
rows, err := _db.Query(query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return webhooks, err
|
return webhooks, err
|
||||||
}
|
}
|
||||||
@@ -206,8 +193,8 @@ func (r *SqlWebhookRepository) GetWebhooks() ([]models.Webhook, error) { //nolin
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SetWebhookAsUsed will update the last used time for a webhook.
|
// SetWebhookAsUsed will update the last used time for a webhook.
|
||||||
func (r *SqlWebhookRepository) SetWebhookAsUsed(webhook models.Webhook) error {
|
func SetWebhookAsUsed(webhook models.Webhook) error {
|
||||||
tx, err := r.datastore.DB.Begin()
|
tx, err := _db.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -104,5 +104,6 @@ func saveOfflineClipToDisk(offlineFilename string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
offlineFilePath := offlineTmpFile.Name()
|
offlineFilePath := offlineTmpFile.Name()
|
||||||
return filepath.Abs(offlineFilePath)
|
|
||||||
|
return offlineFilePath, nil
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-4
@@ -14,7 +14,6 @@ import (
|
|||||||
"github.com/owncast/owncast/config"
|
"github.com/owncast/owncast/config"
|
||||||
"github.com/owncast/owncast/models"
|
"github.com/owncast/owncast/models"
|
||||||
"github.com/owncast/owncast/persistence/configrepository"
|
"github.com/owncast/owncast/persistence/configrepository"
|
||||||
"github.com/owncast/owncast/webserver/handlers/generated"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var _hasInboundRTMPConnection = false
|
var _hasInboundRTMPConnection = false
|
||||||
@@ -88,11 +87,11 @@ func HandleConn(c *rtmp.Conn, nc net.Conn) {
|
|||||||
|
|
||||||
// If a stream key override was specified then use that instead.
|
// If a stream key override was specified then use that instead.
|
||||||
if config.TemporaryStreamKey != "" {
|
if config.TemporaryStreamKey != "" {
|
||||||
validStreamingKeys = []generated.StreamKey{{Key: &config.TemporaryStreamKey}}
|
validStreamingKeys = []models.StreamKey{{Key: config.TemporaryStreamKey}}
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, key := range validStreamingKeys {
|
for _, key := range validStreamingKeys {
|
||||||
if key.Key != nil && secretMatch(*key.Key, c.URL.Path) {
|
if secretMatch(key.Key, c.URL.Path) {
|
||||||
accessGranted = true
|
accessGranted = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -114,7 +113,11 @@ func HandleConn(c *rtmp.Conn, nc net.Conn) {
|
|||||||
|
|
||||||
w := flv.NewMuxer(rtmpIn)
|
w := flv.NewMuxer(rtmpIn)
|
||||||
|
|
||||||
for _hasInboundRTMPConnection {
|
for {
|
||||||
|
if !_hasInboundRTMPConnection {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
// If we don't get a readable packet in 10 seconds give up and disconnect
|
// If we don't get a readable packet in 10 seconds give up and disconnect
|
||||||
if err := _rtmpConnection.SetReadDeadline(time.Now().Add(10 * time.Second)); err != nil {
|
if err := _rtmpConnection.SetReadDeadline(time.Now().Add(10 * time.Second)); err != nil {
|
||||||
log.Debugln(err)
|
log.Debugln(err)
|
||||||
|
|||||||
+1
-1
@@ -20,7 +20,7 @@ var _getInboundDetailsFromMetadataRE = regexp.MustCompile(`\{(.*?)\}`)
|
|||||||
func getInboundDetailsFromMetadata(metadata []interface{}) (models.RTMPStreamMetadata, error) {
|
func getInboundDetailsFromMetadata(metadata []interface{}) (models.RTMPStreamMetadata, error) {
|
||||||
metadataComponentsString := fmt.Sprintf("%+v", metadata)
|
metadataComponentsString := fmt.Sprintf("%+v", metadata)
|
||||||
if !strings.Contains(metadataComponentsString, "onMetaData") {
|
if !strings.Contains(metadataComponentsString, "onMetaData") {
|
||||||
return models.RTMPStreamMetadata{}, errors.New("not an onMetaData message")
|
return models.RTMPStreamMetadata{}, errors.New("Not a onMetaData message")
|
||||||
}
|
}
|
||||||
|
|
||||||
submatchall := _getInboundDetailsFromMetadataRE.FindAllString(metadataComponentsString, 1)
|
submatchall := _getInboundDetailsFromMetadataRE.FindAllString(metadataComponentsString, 1)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// rewritePlaylistLocations will take a local playlist and rewrite it to have absolute URLs to a specified location.
|
// rewritePlaylistLocations will take a local playlist and rewrite it to have absolute URLs to a specified location.
|
||||||
func rewritePlaylistLocations(localFilePath, remoteServingEndpoint, hlsPrefix string) error {
|
func rewritePlaylistLocations(localFilePath, remoteServingEndpoint, pathPrefix string) error {
|
||||||
f, err := os.Open(localFilePath) // nolint
|
f, err := os.Open(localFilePath) // nolint
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalln(err)
|
log.Fatalln(err)
|
||||||
@@ -24,12 +24,15 @@ func rewritePlaylistLocations(localFilePath, remoteServingEndpoint, hlsPrefix st
|
|||||||
log.Warnln(err)
|
log.Warnln(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if hlsPrefix == "" {
|
|
||||||
hlsPrefix = "hls"
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, item := range p.Variants {
|
for _, item := range p.Variants {
|
||||||
item.URI = remoteServingEndpoint + "/" + hlsPrefix + "/" + item.URI
|
// Determine the final path to this playlist.
|
||||||
|
var finalPath string
|
||||||
|
if pathPrefix != "" {
|
||||||
|
finalPath = filepath.Join(pathPrefix, "/hls")
|
||||||
|
} else {
|
||||||
|
finalPath = "/hls"
|
||||||
|
}
|
||||||
|
item.URI = remoteServingEndpoint + filepath.Join(finalPath, item.URI)
|
||||||
}
|
}
|
||||||
|
|
||||||
publicPath := filepath.Join(config.HLSStoragePath, filepath.Base(localFilePath))
|
publicPath := filepath.Join(config.HLSStoragePath, filepath.Base(localFilePath))
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ func (s *S3Storage) VariantPlaylistWritten(localFilePath string) {
|
|||||||
// MasterPlaylistWritten is called when the master hls playlist is written.
|
// MasterPlaylistWritten is called when the master hls playlist is written.
|
||||||
func (s *S3Storage) MasterPlaylistWritten(localFilePath string) {
|
func (s *S3Storage) MasterPlaylistWritten(localFilePath string) {
|
||||||
// Rewrite the playlist to use absolute remote S3 URLs
|
// Rewrite the playlist to use absolute remote S3 URLs
|
||||||
if err := rewritePlaylistLocations(localFilePath, s.host, s.remoteHLSPrefix()); err != nil {
|
if err := rewritePlaylistLocations(localFilePath, s.host, s.s3PathPrefix); err != nil {
|
||||||
log.Warnln(err)
|
log.Warnln(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -161,8 +161,14 @@ func (s *S3Storage) Save(filePath string, retryCount int) (string, error) {
|
|||||||
|
|
||||||
// Convert the local path to the variant/file path by stripping the local storage location.
|
// Convert the local path to the variant/file path by stripping the local storage location.
|
||||||
normalizedPath := strings.TrimPrefix(filePath, config.HLSStoragePath)
|
normalizedPath := strings.TrimPrefix(filePath, config.HLSStoragePath)
|
||||||
// Build the remote path using the shared HLS prefix.
|
// Build the remote path by adding the "hls" path prefix.
|
||||||
remotePath := s.remoteHLSPrefix() + normalizedPath
|
remotePath := strings.Join([]string{"hls", normalizedPath}, "")
|
||||||
|
|
||||||
|
// If a custom path prefix is set prepend it.
|
||||||
|
if s.s3PathPrefix != "" {
|
||||||
|
prefix := strings.TrimPrefix(s.s3PathPrefix, "/")
|
||||||
|
remotePath = strings.Join([]string{prefix, remotePath}, "/")
|
||||||
|
}
|
||||||
|
|
||||||
maxAgeSeconds := utils.GetCacheDurationSecondsForPath(filePath)
|
maxAgeSeconds := utils.GetCacheDurationSecondsForPath(filePath)
|
||||||
cacheControlHeader := fmt.Sprintf("max-age=%d", maxAgeSeconds)
|
cacheControlHeader := fmt.Sprintf("max-age=%d", maxAgeSeconds)
|
||||||
@@ -197,7 +203,7 @@ func (s *S3Storage) Save(filePath string, retryCount int) (string, error) {
|
|||||||
return s.Save(filePath, retryCount+1)
|
return s.Save(filePath, retryCount+1)
|
||||||
}
|
}
|
||||||
|
|
||||||
return "", fmt.Errorf("giving up uploading %s to object storage %s", filePath, s.s3Endpoint)
|
return "", fmt.Errorf("Giving up uploading %s to object storage %s", filePath, s.s3Endpoint)
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.Location, nil
|
return response.Location, nil
|
||||||
@@ -267,15 +273,14 @@ func (s *S3Storage) getDeletableVideoSegmentsWithOffset(offset int) ([]s3object,
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(objectsToDelete) <= offset {
|
if offset > len(objectsToDelete)-1 {
|
||||||
return nil, nil
|
offset = len(objectsToDelete) - 1
|
||||||
}
|
}
|
||||||
|
|
||||||
return objectsToDelete[offset:], nil
|
objectsToDelete = objectsToDelete[offset : len(objectsToDelete)-1]
|
||||||
}
|
|
||||||
|
|
||||||
// s3MaxDeleteKeys is the maximum number of keys per DeleteObjects request.
|
return objectsToDelete, nil
|
||||||
const s3MaxDeleteKeys = 1000
|
}
|
||||||
|
|
||||||
func (s *S3Storage) deleteObjects(objects []s3object) {
|
func (s *S3Storage) deleteObjects(objects []s3object) {
|
||||||
keys := make([]*s3.ObjectIdentifier, len(objects))
|
keys := make([]*s3.ObjectIdentifier, len(objects))
|
||||||
@@ -285,53 +290,45 @@ func (s *S3Storage) deleteObjects(objects []s3object) {
|
|||||||
|
|
||||||
log.Debugln("Deleting", len(keys), "objects from S3 bucket:", s.s3Bucket)
|
log.Debugln("Deleting", len(keys), "objects from S3 bucket:", s.s3Bucket)
|
||||||
|
|
||||||
for i := 0; i < len(keys); i += s3MaxDeleteKeys {
|
deleteObjectsRequest := &s3.DeleteObjectsInput{
|
||||||
end := i + s3MaxDeleteKeys
|
|
||||||
if end > len(keys) {
|
|
||||||
end = len(keys)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := s.s3Client.DeleteObjects(&s3.DeleteObjectsInput{
|
|
||||||
Bucket: aws.String(s.s3Bucket),
|
Bucket: aws.String(s.s3Bucket),
|
||||||
Delete: &s3.Delete{
|
Delete: &s3.Delete{
|
||||||
Objects: keys[i:end],
|
Objects: keys,
|
||||||
Quiet: aws.Bool(true),
|
Quiet: aws.Bool(true),
|
||||||
},
|
},
|
||||||
})
|
}
|
||||||
|
|
||||||
|
_, err := s.s3Client.DeleteObjects(deleteObjectsRequest)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("Unable to delete objects from bucket %q, %v\n", s.s3Bucket, err)
|
log.Errorf("Unable to delete objects from bucket %q, %v\n", s.s3Bucket, err)
|
||||||
} else if len(resp.Errors) > 0 {
|
|
||||||
log.Errorf("Failed to delete %d objects from bucket %q, first error: %v\n", len(resp.Errors), s.s3Bucket, resp.Errors[0])
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *S3Storage) retrieveAllVideoSegments() ([]s3object, error) {
|
func (s *S3Storage) retrieveAllVideoSegments() ([]s3object, error) {
|
||||||
allObjectsListRequest := &s3.ListObjectsInput{
|
allObjectsListRequest := &s3.ListObjectsInput{
|
||||||
Bucket: aws.String(s.s3Bucket),
|
Bucket: aws.String(s.s3Bucket),
|
||||||
Prefix: aws.String(s.remoteHLSListingPrefix()),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch all objects in the bucket, paginating automatically.
|
// Fetch all objects in the bucket
|
||||||
var allObjects []s3object
|
allObjectsListResponse, err := s.s3Client.ListObjects(allObjectsListRequest)
|
||||||
err := s.s3Client.ListObjectsPages(allObjectsListRequest,
|
if err != nil {
|
||||||
func(page *s3.ListObjectsOutput, lastPage bool) bool {
|
return nil, errors.Wrap(err, "Unable to fetch list of items in bucket for cleanup")
|
||||||
for _, item := range page.Contents {
|
}
|
||||||
if strings.HasSuffix(*item.Key, ".ts") {
|
|
||||||
|
// Filter out non-video segments
|
||||||
|
allObjects := []s3object{}
|
||||||
|
for _, item := range allObjectsListResponse.Contents {
|
||||||
|
if !strings.HasSuffix(*item.Key, ".ts") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
allObjects = append(allObjects, s3object{
|
allObjects = append(allObjects, s3object{
|
||||||
key: *item.Key,
|
key: *item.Key,
|
||||||
lastModified: *item.LastModified,
|
lastModified: *item.LastModified,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return true // continue paging
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.Wrap(err, "Unable to fetch list of items in bucket for cleanup")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort the results by timestamp, newest first.
|
// Sort the results by timestamp
|
||||||
sort.Slice(allObjects, func(i, j int) bool {
|
sort.Slice(allObjects, func(i, j int) bool {
|
||||||
return allObjects[i].lastModified.After(allObjects[j].lastModified)
|
return allObjects[i].lastModified.After(allObjects[j].lastModified)
|
||||||
})
|
})
|
||||||
@@ -343,21 +340,3 @@ type s3object struct {
|
|||||||
lastModified time.Time
|
lastModified time.Time
|
||||||
key string
|
key string
|
||||||
}
|
}
|
||||||
|
|
||||||
// remoteHLSPrefix returns the S3 key prefix under which HLS segments are
|
|
||||||
// stored, without a trailing slash. normalizedPath from Save() starts with
|
|
||||||
// "/" so concatenation produces "hls/0/segment.ts" or "myprefix/hls/0/segment.ts".
|
|
||||||
func (s *S3Storage) remoteHLSPrefix() string {
|
|
||||||
prefix := "hls"
|
|
||||||
if s.s3PathPrefix != "" {
|
|
||||||
prefix = strings.Trim(s.s3PathPrefix, "/") + "/" + prefix
|
|
||||||
}
|
|
||||||
return prefix
|
|
||||||
}
|
|
||||||
|
|
||||||
// remoteHLSListingPrefix returns remoteHLSPrefix with a trailing slash,
|
|
||||||
// ensuring S3 listing is directory-scoped and won't match sibling keys
|
|
||||||
// like "hls-archive/" or "hls_old/".
|
|
||||||
func (s *S3Storage) remoteHLSListingPrefix() string {
|
|
||||||
return s.remoteHLSPrefix() + "/"
|
|
||||||
}
|
|
||||||
|
|||||||
+3
-3
@@ -15,8 +15,8 @@ import (
|
|||||||
"github.com/owncast/owncast/core/transcoder"
|
"github.com/owncast/owncast/core/transcoder"
|
||||||
"github.com/owncast/owncast/core/webhooks"
|
"github.com/owncast/owncast/core/webhooks"
|
||||||
"github.com/owncast/owncast/models"
|
"github.com/owncast/owncast/models"
|
||||||
|
"github.com/owncast/owncast/notifications"
|
||||||
"github.com/owncast/owncast/persistence/configrepository"
|
"github.com/owncast/owncast/persistence/configrepository"
|
||||||
"github.com/owncast/owncast/services/notifications"
|
|
||||||
"github.com/owncast/owncast/utils"
|
"github.com/owncast/owncast/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -189,10 +189,10 @@ func startLiveStreamNotificationsTimer() context.CancelFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Send notification to those who have registered for them.
|
// Send notification to those who have registered for them.
|
||||||
if notificationService, err := notifications.New(data.GetDatastore()); err != nil {
|
if notifier, err := notifications.New(data.GetDatastore()); err != nil {
|
||||||
log.Errorln(err)
|
log.Errorln(err)
|
||||||
} else {
|
} else {
|
||||||
notificationService.Notify()
|
notifier.Notify()
|
||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user