Social features / ActivityPub federation (#1629)

* Support webfinger requests for the live account. Closes https://github.com/owncast/owncast/issues/1193

* Support for actor requests. Returns response for live actor. Closes https://github.com/owncast/owncast/issues/1203

* Handle follow and unfollow requests. Closes
https://github.com/owncast/owncast/issues/1191 and https://github.com/owncast/owncast/issues/1205 and https://github.com/owncast/owncast/issues/1206 and https://github.com/owncast/owncast/issues/1194

* Add basic support for sending out text activities. For https://github.com/owncast/owncast/issues/1192

* Some error handling and passing of dynamic local account names.

* Add hardcoded example image attachment to test post

* Centralize the map of accounts and inboxes

* No longer disable the preview generator based on YP toggle

* Send a federated message to followers when stream starts. For https://github.com/owncast/owncast/issues/1192

* Placeholder for attaching tags

* Add image description

* Save and get to outbox persistence. Return using outbox endpoint for actor

* Pass payloads to be handled through the gochan

* Handle undo follow requests explitly, not all undo requests

* Add API for manually sending simple federated messages. Closes #1215

* Verify inbox requests. Closes #1321

* Add route to fetch a single AP object by ID. For #1329

* Add responses to fediverse nodeinfo requests

* Set and get federation config values for admin

* Handle host-meta requests

* Do not send out message if disabled. Use saved go live message.

* Require AP-compatible content types for AP-related requests

* Rename ap models to apmodels for clarity

* Change how content type matching takes place.

* io -> ioutil

* Add stub delete activity callback

* Handle likes and announces to surface engagement in chat. Part of #1229

* Append url to go live posts

* Do not require specific content types for nodeinfo requests

* Add follow engagement chat message via AP

* add owncast user-agent to requests

* Set note visibility to public (for now)

* Fix saving/fetching a single object

* Add support for x-nodeinfo2 responses

* Point to the dev admin branch for ap

* Bundle in dev admin for testing

* Add error logging

* Add AP middleware back

* Point to the new external compatible logo endpoint

* Clean up more AP logging to help testing

* Tweak go live text and link hashtags

* Fix bug in fetching init time

* Send update actor activities when server details/profile is updated

* Add federation config overview to web client config

* Add additional actor properties

* Make the AP middleware checking more flexible when looking at types

* First pass at remote fediverse follow flow. For #1371

* Added a basic AP actor followers endpoint

* WIP client followers API

* Add profile-page reference to webfinger response

* Add aliases to webfinger response

* Fix content-type returned to be expected activitypub+json

* First pass at followers api

* Point at local dev copy of go-fed/activity

* Add custom toot Hashtag objects to posts

* Store additional user details to followers table

* Fix AP followers endpoint. Closes #1204

* Add owncast hashtag as an invisible tag to go live posts

* Reject AP requests when it is disabled

* Add actor util for generating full account user from person object

* Verify inbox requests before performing any other work

* Accept actor update requests

* Fix linter errors in federation branch

* Migrate AP SQL to sqlc for type safe queries

* Use the @unclearParadigm REST parameter helper

* Fix verifying post ID on AP engagement

* WIP privacy/request approval

* Style the remote follow modal

* First pass at a followers list component w/ mock data. #1370

* Revert "Use the @unclearParadigm REST parameter helper"

This reverts commit c8af8a413f.

* Fix get followers API

* Add support for requiring approval. Closes https://github.com/owncast/owncast/issues/1208

* Handle Applications as Actors partly for PeerTube support

* add temp todo list

* check route on load, this might change later

* style followers

* account for just 1 tab case

* Remove mock data. Allow showing follow button even when there are no external actions defined

* Point to actual followers API

* Support fallback img for follower views

* Remove duplicate verification. Add some additional verbose logging

* Bundle dev admin

* Add type to host-meta webfinger template response

* Tweak remote follow modal content

* WIP federation followers refactor

* Do not send pointer to middleware

* Update admin

* Add setting for toggling displaying fediverse engagement. Closes #1404

* Add in-development admin

* Do not enable cors on admin followers api

* Add db migration for updating messages table

* Enable empty string go live messages to disable

* Remove debug messages

* Rework some ActivityPub handling.

Create new Actor->Person handling.
Create new Actor->Service handling.
Add engagement handlers to send chat events and store event objects.
Store inbound activities to new ap_inbound_activities table.

* Support federated engagement events.

Store them in the messages table and surface them via chat events.

* Support federated event engatement in the chat

* Tweak web UI followers handling

* Point go.mod at remote fork instead of local

* Update admin

* Merged in develop. Couple fixes

* Update dev admin

* Update fedi engagement posts.

- Fix incorrect action text.
- Add action icons.

* Set public as to instead of cc for ap msg

* Updated styling for federated actions in chat

* Add support for blocking federated domains. Closes #1209

* Force checking of https in verify step

* Update dev admin

* Return user scopes in chat history api. Closes #1586

* Update dev admin

* Add AP outbound request worker pool. Closes #1571

* Disable (temporarily?) owncast tag on AP posts

* Consolidate creating activity+notes in outbound AP messages

* Add inbox worker pool. Closes #1570

* Update dev admin bundle

* Clean up some logs

* Re-enable inbound verfication

* Save full IRI to outbox instead of path

* Reject if full IRI is not found in outbox

* Use full ActivityPub user account in chat event

* Fix and expand follower APIs

- Add missing IDs to AP follower endpoints
- Split AP follower endpoints into initial request and pages.
- Support pagination in AP requests.

* Include IRI in error message

* Hide chat toggle when chat is hidden. Closes #1606

* Updates to followers pagination

* Set default go live message

* Remove log

* indirect -> direct import

* Updates for inbound federated event handling.

- Keep track of existing events and reject duplicates.
- Change what is sent to chat for surfing federated engagement.
- Keep track if outbound events are automated "go live" events or not.

* Update chat federated engagement.

* Update dev admin.

* Move from being a person to a bot (service). Closes #1619

* Only set server init date if not already set

* Only save notes to outbox able

* Rework private-mode followers/approvals

* API for returning a list of federated actions for #1573

* Fix too-small follower cells and jumpy tabs. Closes #1616 and closes #1516

* Fix shortcuts getting fired on inputs. Fixes #1489 and #1201

* Add spinner, autoclose + other fixes to follow modal. Fixes #1593

* Fix fetching a single object by IRI

* SendFederationMessage -> SendFederatedMessage

* Autolink and create tag objects from manual posts. Closes #1620

* Update dev admin bundle

* Handle engagement from non-automated/live posts

* Reject federated engagement actions if they do not match a local post

* Update dev admin bundle

* A bunch of cleanup

* Fix unused assignments and logic

* Remove unused function

* Add content warning and sentive content flag if stream is NSFW. Closes #1624

* Disable fetching objects by IRI when in private mode. Closes #1623

* Update the error message of the remote follow dialog. closes #1622

* Update dev admin

* Fix NREs throwing in test content

* Fix query that wasn't properly filtering out hidden messages

* Test against user being disabled instead of message visibility

* Fix automated test NRE

* Update comment

* Adjust federated engagement chat views. Closes #1617

* Add additional index to users table

* Add support for removing followers/requests. Closes #1630

* Reject federated actions from blocked actors. #1631

* Use fallback avatar if it fails to load. Closes #1635

* Fix styling of follower list. Closes #1636

* Add basic blurb stating they should follow the server. Closes #1641

* Update dev admin

* Set default go live message in migration. Closes #1642

* Reset the messages table on 0.0.11 schema migration

* Fix js error with moderation actions. Closes #1621

* Add a bit more clarification on follow modal. Closes #1599

* Remove todos

* Split out actor and domain blocking checks

* Check for errors on default values being set

* Clean up actor rejection due to being blocked

* Update dev admin

* Add colon to error to make it easier to read

* Remove markdown rendering of go live message. Reorganize text. Remove content warning. Closes #1645

* Break out the sort+render messages logic so it can be fired on visibility change. Closes #1643

* Do not send profile updates if federation is disabled

* Save follow references to inbound activities table

* Update dev admin

* Add blocked actor test

* Remove the overloaded term of Follow from social links

* Fix test running in memory only

* Remove "just" in engagement messags

* Replace star with heart for like action.

* Update dev admin

* Explicitly set cc as public

* Remove overly using the stream name in fediverse engagement messages

* Some federated/follow UI tweaks

* Remove explicit cc and bcc as they are not required

* Explicitly set the audience

* Remove extra margin

* Add Join Fediverse button to follow modal. Closes #1651

* Do not allow multiple follows to send multiple events. Closes #1650

* Give events a min height

* Do not allow old posts to be liked/shared. Closes #1652

* Remove value from log message

* Alert followers on private mode toggle

* Ignore clicks to follow button if disabled

* Remove underline from action buttons

* Add moderator icon to join message

* Update admin

* Post-merge remove unused var

* Remove pointing at feature branch

Co-authored-by: Ginger Wong <omqmail@gmail.com>
This commit is contained in:
Gabe Kangas 2022-01-12 13:53:10 -08:00 committed by GitHub
parent c51d9cdbf4
commit 045a0a2afd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
174 changed files with 7295 additions and 404 deletions

View file

@ -81,3 +81,7 @@ linters-settings:
# Logging via Print bypasses our logging framework.
- ^(fmt\.Print(|f|ln)|print|println)
- ^panic.*$
dupl:
# tokens count to trigger issue, 150 by default
threshold: 160

View file

@ -0,0 +1,51 @@
package activitypub
import (
"github.com/owncast/owncast/activitypub/crypto"
"github.com/owncast/owncast/activitypub/inbox"
"github.com/owncast/owncast/activitypub/outbox"
"github.com/owncast/owncast/activitypub/persistence"
"github.com/owncast/owncast/activitypub/workerpool"
"github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/models"
log "github.com/sirupsen/logrus"
)
// Start will initialize and start the federation support.
func Start(datastore *data.Datastore) {
persistence.Setup(datastore)
workerpool.InitOutboundWorkerPool()
inbox.InitInboxWorkerPool()
StartRouter()
// Test
if data.GetPrivateKey() == "" {
privateKey, publicKey, err := crypto.GenerateKeys()
_ = data.SetPrivateKey(string(privateKey))
_ = data.SetPublicKey(string(publicKey))
if err != nil {
log.Errorln("Unable to get private key", err)
}
}
}
// SendLive will send a "Go Live" message to followers.
func SendLive() error {
return outbox.SendLive()
}
// SendPublicFederatedMessage will send an arbitrary provided message to followers.
func SendPublicFederatedMessage(message string) error {
return outbox.SendPublicMessage(message)
}
// GetFollowerCount will return the local tracked follower count.
func GetFollowerCount() (int64, error) {
return persistence.GetFollowerCount()
}
// GetPendingFollowRequests will return the pending follow requests.
func GetPendingFollowRequests() ([]models.Follower, error) {
return persistence.GetPendingFollowRequests()
}

View file

@ -0,0 +1,88 @@
package apmodels
import (
"net/url"
"time"
"github.com/go-fed/activity/streams"
"github.com/go-fed/activity/streams/vocab"
"github.com/owncast/owncast/core/data"
)
// PrivacyAudience represents the audience for an activity.
type PrivacyAudience = string
const (
// PUBLIC is an audience meaning anybody can view the item.
PUBLIC PrivacyAudience = "https://www.w3.org/ns/activitystreams#Public"
)
// MakeCreateActivity will return a new Create activity with the provided ID.
func MakeCreateActivity(activityID *url.URL) vocab.ActivityStreamsCreate {
activity := streams.NewActivityStreamsCreate()
id := streams.NewJSONLDIdProperty()
id.Set(activityID)
activity.SetJSONLDId(id)
// CC the public if we're not treating ActivityPub as "private".
if !data.GetFederationIsPrivate() {
public, _ := url.Parse(PUBLIC)
to := streams.NewActivityStreamsToProperty()
to.AppendIRI(public)
activity.SetActivityStreamsTo(to)
audience := streams.NewActivityStreamsAudienceProperty()
audience.AppendIRI(public)
activity.SetActivityStreamsAudience(audience)
}
return activity
}
// MakeUpdateActivity will return a new Update activity with the provided aID.
func MakeUpdateActivity(activityID *url.URL) vocab.ActivityStreamsUpdate {
activity := streams.NewActivityStreamsUpdate()
id := streams.NewJSONLDIdProperty()
id.Set(activityID)
activity.SetJSONLDId(id)
// CC the public if we're not treating ActivityPub as "private".
if !data.GetFederationIsPrivate() {
public, _ := url.Parse(PUBLIC)
cc := streams.NewActivityStreamsCcProperty()
cc.AppendIRI(public)
activity.SetActivityStreamsCc(cc)
}
return activity
}
// MakeNote will return a new Note object.
func MakeNote(text string, noteIRI *url.URL, attributedToIRI *url.URL) vocab.ActivityStreamsNote {
note := streams.NewActivityStreamsNote()
content := streams.NewActivityStreamsContentProperty()
content.AppendXMLSchemaString(text)
note.SetActivityStreamsContent(content)
id := streams.NewJSONLDIdProperty()
id.Set(noteIRI)
note.SetJSONLDId(id)
published := streams.NewActivityStreamsPublishedProperty()
published.Set(time.Now())
note.SetActivityStreamsPublished(published)
attributedTo := attributedToIRI
attr := streams.NewActivityStreamsAttributedToProperty()
attr.AppendIRI(attributedTo)
note.SetActivityStreamsAttributedTo(attr)
// CC the public if we're not treating ActivityPub as "private".
if !data.GetFederationIsPrivate() {
public, _ := url.Parse(PUBLIC)
cc := streams.NewActivityStreamsCcProperty()
cc.AppendIRI(public)
note.SetActivityStreamsCc(cc)
}
return note
}

View file

@ -0,0 +1,263 @@
package apmodels
import (
"fmt"
"net/url"
"time"
"github.com/go-fed/activity/streams"
"github.com/go-fed/activity/streams/vocab"
"github.com/owncast/owncast/activitypub/crypto"
"github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/models"
log "github.com/sirupsen/logrus"
)
// ActivityPubActor represents a single actor in handling ActivityPub activity.
type ActivityPubActor struct {
// ActorIRI is the IRI of the remote actor.
ActorIri *url.URL
// FollowRequestIRI is the unique identifier of the follow request.
FollowRequestIri *url.URL
// Inbox is the inbox URL of the remote follower
Inbox *url.URL
// Name is the display name of the follower.
Name string
// Username is the account username of the remote actor.
Username string
// FullUsername is the username@account.tld representation of the user.
FullUsername string
// Image is the avatar image of the Actor.
Image *url.URL
// W3IDSecurityV1PublicKey is the public key of the actor.
W3IDSecurityV1PublicKey vocab.W3IDSecurityV1PublicKeyProperty
// DisabledAt is the time, if any, this follower was blocked/removed.
DisabledAt *time.Time
}
// DeleteRequest represents a request for delete.
type DeleteRequest struct {
ActorIri string
}
// MakeActorFromPerson takes a full ActivityPub Person and returns our internal
// representation of an actor.
func MakeActorFromPerson(person vocab.ActivityStreamsPerson) ActivityPubActor {
apActor := ActivityPubActor{
ActorIri: person.GetJSONLDId().Get(),
Inbox: person.GetActivityStreamsInbox().GetIRI(),
Name: person.GetActivityStreamsName().Begin().GetXMLSchemaString(),
Username: person.GetActivityStreamsPreferredUsername().GetXMLSchemaString(),
FullUsername: GetFullUsernameFromPerson(person),
W3IDSecurityV1PublicKey: person.GetW3IDSecurityV1PublicKey(),
}
if person.GetActivityStreamsIcon() != nil && person.GetActivityStreamsIcon().Len() > 0 {
apActor.Image = person.GetActivityStreamsIcon().At(0).GetActivityStreamsImage().GetActivityStreamsUrl().Begin().GetIRI()
}
return apActor
}
// MakeActorFromService takes a full ActivityPub Service and returns our internal
// representation of an actor.
func MakeActorFromService(service vocab.ActivityStreamsService) ActivityPubActor {
apActor := ActivityPubActor{
ActorIri: service.GetJSONLDId().Get(),
Inbox: service.GetActivityStreamsInbox().GetIRI(),
Name: service.GetActivityStreamsName().Begin().GetXMLSchemaString(),
Username: service.GetActivityStreamsPreferredUsername().GetXMLSchemaString(),
FullUsername: GetFullUsernameFromService(service),
W3IDSecurityV1PublicKey: service.GetW3IDSecurityV1PublicKey(),
}
if service.GetActivityStreamsIcon() != nil && service.GetActivityStreamsIcon().Len() > 0 {
apActor.Image = service.GetActivityStreamsIcon().At(0).GetActivityStreamsImage().GetActivityStreamsUrl().Begin().GetIRI()
}
return apActor
}
// MakeActorPropertyWithID will return an actor property filled with the provided IRI.
func MakeActorPropertyWithID(idIRI *url.URL) vocab.ActivityStreamsActorProperty {
actor := streams.NewActivityStreamsActorProperty()
actor.AppendIRI(idIRI)
return actor
}
// MakeServiceForAccount will create a new local actor service with the the provided username.
func MakeServiceForAccount(accountName string) vocab.ActivityStreamsService {
actorIRI := MakeLocalIRIForAccount(accountName)
person := streams.NewActivityStreamsService()
nameProperty := streams.NewActivityStreamsNameProperty()
nameProperty.AppendXMLSchemaString(data.GetServerName())
person.SetActivityStreamsName(nameProperty)
preferredUsernameProperty := streams.NewActivityStreamsPreferredUsernameProperty()
preferredUsernameProperty.SetXMLSchemaString(accountName)
person.SetActivityStreamsPreferredUsername(preferredUsernameProperty)
inboxIRI := MakeLocalIRIForResource("/user/" + accountName + "/inbox")
inboxProp := streams.NewActivityStreamsInboxProperty()
inboxProp.SetIRI(inboxIRI)
person.SetActivityStreamsInbox(inboxProp)
needsFollowApprovalProperty := streams.NewActivityStreamsManuallyApprovesFollowersProperty()
needsFollowApprovalProperty.Set(data.GetFederationIsPrivate())
person.SetActivityStreamsManuallyApprovesFollowers(needsFollowApprovalProperty)
outboxIRI := MakeLocalIRIForResource("/user/" + accountName + "/outbox")
outboxProp := streams.NewActivityStreamsOutboxProperty()
outboxProp.SetIRI(outboxIRI)
person.SetActivityStreamsOutbox(outboxProp)
id := streams.NewJSONLDIdProperty()
id.Set(actorIRI)
person.SetJSONLDId(id)
publicKey := crypto.GetPublicKey(actorIRI)
publicKeyProp := streams.NewW3IDSecurityV1PublicKeyProperty()
publicKeyType := streams.NewW3IDSecurityV1PublicKey()
pubKeyIDProp := streams.NewJSONLDIdProperty()
pubKeyIDProp.Set(publicKey.ID)
publicKeyType.SetJSONLDId(pubKeyIDProp)
ownerProp := streams.NewW3IDSecurityV1OwnerProperty()
ownerProp.SetIRI(publicKey.Owner)
publicKeyType.SetW3IDSecurityV1Owner(ownerProp)
publicKeyPemProp := streams.NewW3IDSecurityV1PublicKeyPemProperty()
publicKeyPemProp.Set(publicKey.PublicKeyPem)
publicKeyType.SetW3IDSecurityV1PublicKeyPem(publicKeyPemProp)
publicKeyProp.AppendW3IDSecurityV1PublicKey(publicKeyType)
person.SetW3IDSecurityV1PublicKey(publicKeyProp)
if t, err := data.GetServerInitTime(); t != nil {
publishedDateProp := streams.NewActivityStreamsPublishedProperty()
publishedDateProp.Set(t.Time)
person.SetActivityStreamsPublished(publishedDateProp)
} else {
log.Errorln("unable to fetch server init time", err)
}
// Profile properties
// Avatar
userAvatarURLString := data.GetServerURL() + "/logo/external"
userAvatarURL, err := url.Parse(userAvatarURLString)
if err != nil {
log.Errorln("unable to parse user avatar url", userAvatarURLString, err)
}
image := streams.NewActivityStreamsImage()
imgProp := streams.NewActivityStreamsUrlProperty()
imgProp.AppendIRI(userAvatarURL)
image.SetActivityStreamsUrl(imgProp)
icon := streams.NewActivityStreamsIconProperty()
icon.AppendActivityStreamsImage(image)
person.SetActivityStreamsIcon(icon)
// Actor URL
urlProperty := streams.NewActivityStreamsUrlProperty()
urlProperty.AppendIRI(actorIRI)
person.SetActivityStreamsUrl(urlProperty)
// Profile header
headerImage := streams.NewActivityStreamsImage()
headerImgPropURL := streams.NewActivityStreamsUrlProperty()
headerImgPropURL.AppendIRI(userAvatarURL)
headerImage.SetActivityStreamsUrl(headerImgPropURL)
headerImageProp := streams.NewActivityStreamsImageProperty()
headerImageProp.AppendActivityStreamsImage(headerImage)
person.SetActivityStreamsImage(headerImageProp)
// Profile bio
summaryProperty := streams.NewActivityStreamsSummaryProperty()
summaryProperty.AppendXMLSchemaString(data.GetServerSummary())
person.SetActivityStreamsSummary(summaryProperty)
// Links
for _, link := range data.GetSocialHandles() {
addMetadataLinkToProfile(person, link.Platform, link.URL)
}
// Discoverable
discoverableProperty := streams.NewTootDiscoverableProperty()
discoverableProperty.Set(true)
person.SetTootDiscoverable(discoverableProperty)
// Followers
followersProperty := streams.NewActivityStreamsFollowersProperty()
followersURL := *actorIRI
followersURL.Path = actorIRI.Path + "/followers"
followersProperty.SetIRI(&followersURL)
person.SetActivityStreamsFollowers(followersProperty)
// Tags
tagProp := streams.NewActivityStreamsTagProperty()
for _, tagString := range data.GetServerMetadataTags() {
hashtag := MakeHashtag(tagString)
tagProp.AppendTootHashtag(hashtag)
}
person.SetActivityStreamsTag(tagProp)
// Work around an issue where a single attachment will not serialize
// as an array, so add another item to the mix.
if len(data.GetSocialHandles()) == 1 {
addMetadataLinkToProfile(person, "Owncast", "https://owncast.online")
}
return person
}
// GetFullUsernameFromPerson will return the user@host.tld formatted user given a person object.
func GetFullUsernameFromPerson(person vocab.ActivityStreamsPerson) string {
hostname := person.GetJSONLDId().GetIRI().Hostname()
username := person.GetActivityStreamsPreferredUsername().GetXMLSchemaString()
fullUsername := fmt.Sprintf("%s@%s", username, hostname)
return fullUsername
}
// GetFullUsernameFromService will return the user@host.tld formatted user given a service object.
func GetFullUsernameFromService(person vocab.ActivityStreamsService) string {
hostname := person.GetJSONLDId().GetIRI().Hostname()
username := person.GetActivityStreamsPreferredUsername().GetXMLSchemaString()
fullUsername := fmt.Sprintf("%s@%s", username, hostname)
return fullUsername
}
func addMetadataLinkToProfile(profile vocab.ActivityStreamsService, name string, url string) {
attachments := profile.GetActivityStreamsAttachment()
if attachments == nil {
attachments = streams.NewActivityStreamsAttachmentProperty()
}
displayName := name
socialHandle := models.GetSocialHandle(name)
if socialHandle != nil {
displayName = socialHandle.Platform
}
linkValue := fmt.Sprintf("<a href=\"%s\" rel=\"me nofollow noopener noreferrer\" target=\"_blank\">%s</a>", url, url)
attachment := streams.NewActivityStreamsObject()
attachmentProp := streams.NewJSONLDTypeProperty()
attachmentProp.AppendXMLSchemaString("PropertyValue")
attachment.SetJSONLDType(attachmentProp)
attachmentName := streams.NewActivityStreamsNameProperty()
attachmentName.AppendXMLSchemaString(displayName)
attachment.SetActivityStreamsName(attachmentName)
attachment.GetUnknownProperties()["value"] = linkValue
attachments.AppendActivityStreamsObject(attachment)
profile.SetActivityStreamsAttachment(attachments)
}

View file

@ -0,0 +1,168 @@
package apmodels
import (
"io/ioutil"
"net/url"
"os"
"testing"
"github.com/go-fed/activity/streams"
"github.com/go-fed/activity/streams/vocab"
"github.com/owncast/owncast/core/data"
)
func makeFakeService() vocab.ActivityStreamsService {
iri, _ := url.Parse("https://fake.fediverse.server/user/mrfoo")
name := "Mr Foo"
username := "foodawg"
inbox, _ := url.Parse("https://fake.fediverse.server/user/mrfoo/inbox")
userAvatarURL, _ := url.Parse("https://fake.fediverse.server/user/mrfoo/avatar.png")
service := streams.NewActivityStreamsService()
id := streams.NewJSONLDIdProperty()
id.Set(iri)
service.SetJSONLDId(id)
nameProperty := streams.NewActivityStreamsNameProperty()
nameProperty.AppendXMLSchemaString(name)
service.SetActivityStreamsName(nameProperty)
preferredUsernameProperty := streams.NewActivityStreamsPreferredUsernameProperty()
preferredUsernameProperty.SetXMLSchemaString(username)
service.SetActivityStreamsPreferredUsername(preferredUsernameProperty)
inboxProp := streams.NewActivityStreamsInboxProperty()
inboxProp.SetIRI(inbox)
service.SetActivityStreamsInbox(inboxProp)
image := streams.NewActivityStreamsImage()
imgProp := streams.NewActivityStreamsUrlProperty()
imgProp.AppendIRI(userAvatarURL)
image.SetActivityStreamsUrl(imgProp)
icon := streams.NewActivityStreamsIconProperty()
icon.AppendActivityStreamsImage(image)
service.SetActivityStreamsIcon(icon)
return service
}
func TestMain(m *testing.M) {
dbFile, err := ioutil.TempFile(os.TempDir(), "owncast-test-db.db")
if err != nil {
panic(err)
}
data.SetupPersistence(dbFile.Name())
data.SetServerURL("https://my.cool.site.biz")
m.Run()
}
func TestMakeActorFromService(t *testing.T) {
service := makeFakeService()
actor := MakeActorFromService(service)
if actor.ActorIri != service.GetJSONLDId().GetIRI() {
t.Errorf("actor.ID = %v, want %v", actor.ActorIri, service.GetJSONLDId().GetIRI())
}
if actor.Name != service.GetActivityStreamsName().At(0).GetXMLSchemaString() {
t.Errorf("actor.Name = %v, want %v", actor.Name, service.GetActivityStreamsName().At(0).GetXMLSchemaString())
}
if actor.Username != service.GetActivityStreamsPreferredUsername().GetXMLSchemaString() {
t.Errorf("actor.Username = %v, want %v", actor.Username, service.GetActivityStreamsPreferredUsername().GetXMLSchemaString())
}
if actor.Inbox != service.GetActivityStreamsInbox().GetIRI() {
t.Errorf("actor.Inbox = %v, want %v", actor.Inbox.String(), service.GetActivityStreamsInbox().GetIRI())
}
if actor.Image != service.GetActivityStreamsIcon().At(0).GetActivityStreamsImage().GetActivityStreamsUrl().At(0).GetIRI() {
t.Errorf("actor.Image = %v, want %v", actor.Image, service.GetActivityStreamsIcon().At(0).GetActivityStreamsImage().GetActivityStreamsUrl().At(0).GetIRI())
}
}
func TestMakeActorPropertyWithID(t *testing.T) {
iri, _ := url.Parse("https://fake.fediverse.server/user/mrfoo")
actor := MakeActorPropertyWithID(iri)
if actor.Begin().GetIRI() != iri {
t.Errorf("actor.IRI = %v, want %v", actor.Begin().GetIRI(), iri)
}
}
func TestGetFullUsernameFromPerson(t *testing.T) {
expected := "foodawg@fake.fediverse.server"
person := makeFakeService()
username := GetFullUsernameFromService(person)
if username != expected {
t.Errorf("actor.Username = %v, want %v", username, expected)
}
}
func TestAddMetadataLinkToProfile(t *testing.T) {
person := makeFakeService()
addMetadataLinkToProfile(person, "my site", "https://my.cool.site.biz")
attchment := person.GetActivityStreamsAttachment().At(0)
nameValue := attchment.GetActivityStreamsObject().GetActivityStreamsName().At(0).GetXMLSchemaString()
expected := "my site"
if nameValue != expected {
t.Errorf("attachment name = %v, want %v", nameValue, expected)
}
propertyValue := attchment.GetActivityStreamsObject().GetUnknownProperties()["value"]
expected = `<a href="https://my.cool.site.biz" rel="me nofollow noopener noreferrer" target="_blank">https://my.cool.site.biz</a>`
if propertyValue != expected {
t.Errorf("attachment value = %v, want %v", propertyValue, expected)
}
}
func TestMakeServiceForAccount(t *testing.T) {
person := MakeServiceForAccount("accountname")
expectedIRI := "https://my.cool.site.biz/federation/user/accountname"
if person.GetJSONLDId().Get().String() != expectedIRI {
t.Errorf("actor.IRI = %v, want %v", person.GetJSONLDId().Get().String(), expectedIRI)
}
if person.GetActivityStreamsPreferredUsername().GetXMLSchemaString() != "accountname" {
t.Errorf("actor.PreferredUsername = %v, want %v", person.GetActivityStreamsPreferredUsername().GetXMLSchemaString(), expectedIRI)
}
expectedInbox := "https://my.cool.site.biz/federation/user/accountname/inbox"
if person.GetActivityStreamsInbox().GetIRI().String() != expectedInbox {
t.Errorf("actor.Inbox = %v, want %v", person.GetActivityStreamsInbox().GetIRI().String(), expectedInbox)
}
expectedOutbox := "https://my.cool.site.biz/federation/user/accountname/outbox"
if person.GetActivityStreamsOutbox().GetIRI().String() != expectedOutbox {
t.Errorf("actor.Outbox = %v, want %v", person.GetActivityStreamsOutbox().GetIRI().String(), expectedOutbox)
}
expectedFollowers := "https://my.cool.site.biz/federation/user/accountname/followers"
if person.GetActivityStreamsFollowers().GetIRI().String() != expectedFollowers {
t.Errorf("actor.Followers = %v, want %v", person.GetActivityStreamsFollowers().GetIRI().String(), expectedFollowers)
}
expectedName := "Owncast"
if person.GetActivityStreamsName().Begin().GetXMLSchemaString() != expectedName {
t.Errorf("actor.Name = %v, want %v", person.GetActivityStreamsName().Begin().GetXMLSchemaString(), expectedName)
}
expectedAvatar := "https://my.cool.site.biz/logo/external"
if person.GetActivityStreamsIcon().At(0).GetActivityStreamsImage().GetActivityStreamsUrl().Begin().GetIRI().String() != expectedAvatar {
t.Errorf("actor.Avatar = %v, want %v", person.GetActivityStreamsIcon().At(0).GetActivityStreamsImage().GetActivityStreamsUrl().Begin().GetIRI().String(), expectedAvatar)
}
expectedSummary := "Welcome to your new Owncast server! This description can be changed in the admin. Visit https://owncast.online/docs/configuration/ to learn more."
if person.GetActivityStreamsSummary().At(0).GetXMLSchemaString() != expectedSummary {
t.Errorf("actor.Summary = %v, want %v", person.GetActivityStreamsSummary().At(0).GetXMLSchemaString(), expectedSummary)
}
if person.GetActivityStreamsUrl().At(0).GetIRI().String() != expectedIRI {
t.Errorf("actor.URL = %v, want %v", person.GetActivityStreamsUrl().At(0).GetIRI().String(), expectedIRI)
}
}

View file

@ -0,0 +1,24 @@
package apmodels
import (
"net/url"
"github.com/go-fed/activity/streams"
"github.com/go-fed/activity/streams/vocab"
)
// MakeHashtag will create and return a mastodon toot hashtag object with the provided name.
func MakeHashtag(name string) vocab.TootHashtag {
u, _ := url.Parse("https://directory.owncast.online/tags/" + name)
hashtag := streams.NewTootHashtag()
hashtagName := streams.NewActivityStreamsNameProperty()
hashtagName.AppendXMLSchemaString("#" + name)
hashtag.SetActivityStreamsName(hashtagName)
hashtagHref := streams.NewActivityStreamsHrefProperty()
hashtagHref.Set(u)
hashtag.SetActivityStreamsHref(hashtagHref)
return hashtag
}

View file

@ -0,0 +1,10 @@
package apmodels
import "net/http"
// InboxRequest represents an inbound request to the ActivityPub inbox.
type InboxRequest struct {
Request *http.Request
ForLocalAccount string
Body []byte
}

View file

@ -0,0 +1,50 @@
package apmodels
import (
"net/url"
"github.com/go-fed/activity/streams"
"github.com/go-fed/activity/streams/vocab"
)
// CreateCreateActivity will create a new Create Activity model with the provided ID and IRI.
func CreateCreateActivity(id string, localAccountIRI *url.URL) vocab.ActivityStreamsCreate {
objectID := MakeLocalIRIForResource(id)
message := MakeCreateActivity(objectID)
actorProp := streams.NewActivityStreamsActorProperty()
actorProp.AppendIRI(localAccountIRI)
message.SetActivityStreamsActor(actorProp)
return message
}
// AddImageAttachmentToNote will add the provided image URL to the provided note object.
func AddImageAttachmentToNote(note vocab.ActivityStreamsNote, image string) {
imageURL, err := url.Parse(image)
if err != nil {
return
}
attachments := note.GetActivityStreamsAttachment()
if attachments == nil {
attachments = streams.NewActivityStreamsAttachmentProperty()
}
urlProp := streams.NewActivityStreamsUrlProperty()
urlProp.AppendIRI(imageURL)
apImage := streams.NewActivityStreamsImage()
apImage.SetActivityStreamsUrl(urlProp)
imageProp := streams.NewActivityStreamsImageProperty()
imageProp.AppendActivityStreamsImage(apImage)
imageDescription := streams.NewActivityStreamsContentProperty()
imageDescription.AppendXMLSchemaString("Live stream preview")
apImage.SetActivityStreamsContent(imageDescription)
attachments.AppendActivityStreamsImage(apImage)
note.SetActivityStreamsAttachment(attachments)
}

View file

@ -0,0 +1,62 @@
package apmodels
import (
"encoding/json"
"net/url"
"path"
"github.com/go-fed/activity/streams"
"github.com/go-fed/activity/streams/vocab"
"github.com/owncast/owncast/core/data"
log "github.com/sirupsen/logrus"
)
// MakeRemoteIRIForResource will create an IRI for a remote location.
func MakeRemoteIRIForResource(resourcePath string, host string) (*url.URL, error) {
generatedURL := "https://" + host
u, err := url.Parse(generatedURL)
if err != nil {
return nil, err
}
u.Path = path.Join(u.Path, "federation", resourcePath)
return u, nil
}
// MakeLocalIRIForResource will create an IRI for the local server.
func MakeLocalIRIForResource(resourcePath string) *url.URL {
host := data.GetServerURL()
u, err := url.Parse(host)
if err != nil {
log.Errorln("unable to parse local IRI url", host, err)
return nil
}
u.Path = path.Join(u.Path, "federation", resourcePath)
return u
}
// MakeLocalIRIForAccount will return a full IRI for the local server account username.
func MakeLocalIRIForAccount(account string) *url.URL {
host := data.GetServerURL()
u, err := url.Parse(host)
if err != nil {
log.Errorln("unable to parse local IRI account server url", err)
return nil
}
u.Path = path.Join(u.Path, "federation", "user", account)
return u
}
// Serialize will serialize an ActivityPub object to a byte slice.
func Serialize(obj vocab.Type) ([]byte, error) {
var jsonmap map[string]interface{}
jsonmap, _ = streams.Serialize(obj)
b, err := json.Marshal(jsonmap)
return b, err
}

View file

@ -0,0 +1,43 @@
package apmodels
import (
"fmt"
)
// WebfingerResponse represents a Webfinger response.
type WebfingerResponse struct {
Aliases []string `json:"aliases"`
Subject string `json:"subject"`
Links []Link `json:"links"`
}
// Link represents a Webfinger response Link entity.
type Link struct {
Rel string `json:"rel"`
Type string `json:"type"`
Href string `json:"href"`
}
// MakeWebfingerResponse will create a new Webfinger response.
func MakeWebfingerResponse(account string, inbox string, host string) WebfingerResponse {
accountIRI := MakeLocalIRIForAccount(account)
return WebfingerResponse{
Subject: fmt.Sprintf("acct:%s@%s", account, host),
Aliases: []string{
accountIRI.String(),
},
Links: []Link{
{
Rel: "self",
Type: "application/activity+json",
Href: accountIRI.String(),
},
{
Rel: "http://webfinger.net/rel/profile-page",
Type: "text/html",
Href: accountIRI.String(),
},
},
}
}

View file

@ -0,0 +1,58 @@
package controllers
import (
"net/http"
"strings"
log "github.com/sirupsen/logrus"
"github.com/owncast/owncast/activitypub/apmodels"
"github.com/owncast/owncast/activitypub/crypto"
"github.com/owncast/owncast/activitypub/requests"
"github.com/owncast/owncast/core/data"
)
// ActorHandler handles requests for a single actor.
func ActorHandler(w http.ResponseWriter, r *http.Request) {
if !data.GetFederationEnabled() {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
pathComponents := strings.Split(r.URL.Path, "/")
accountName := pathComponents[3]
if _, valid := data.GetFederatedInboxMap()[accountName]; !valid {
// User is not valid
w.WriteHeader(http.StatusNotFound)
return
}
// If this request is for an actor's inbox then pass
// the request to the inbox controller.
if len(pathComponents) == 5 && pathComponents[4] == "inbox" {
InboxHandler(w, r)
return
} else if len(pathComponents) == 5 && pathComponents[4] == "outbox" {
OutboxHandler(w, r)
return
} else if len(pathComponents) == 5 && pathComponents[4] == "followers" {
// followers list
FollowersHandler(w, r)
return
} else if len(pathComponents) == 5 && pathComponents[4] == "following" {
// following list (none)
w.WriteHeader(http.StatusNotFound)
return
}
actorIRI := apmodels.MakeLocalIRIForAccount(accountName)
publicKey := crypto.GetPublicKey(actorIRI)
person := apmodels.MakeServiceForAccount(accountName)
if err := requests.WriteStreamResponse(person, w, publicKey); err != nil {
log.Errorln("unable to write stream response for actor handler", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
}

View file

@ -0,0 +1,166 @@
package controllers
import (
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
"github.com/go-fed/activity/streams"
"github.com/go-fed/activity/streams/vocab"
"github.com/owncast/owncast/activitypub/apmodels"
"github.com/owncast/owncast/activitypub/crypto"
"github.com/owncast/owncast/activitypub/persistence"
"github.com/owncast/owncast/activitypub/requests"
"github.com/owncast/owncast/core/data"
)
const (
followersPageSize = 50
)
// FollowersHandler will return the list of remote followers on the Fediverse.
func FollowersHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
var response interface{}
var err error
if r.URL.Query().Get("page") != "" {
response, err = getFollowersPage(r.URL.Query().Get("page"), r)
} else {
response, err = getInitialFollowersRequest(r)
}
if response == nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
if err != nil {
_, _ = w.Write([]byte(err.Error()))
w.WriteHeader(http.StatusInternalServerError)
return
}
pathComponents := strings.Split(r.URL.Path, "/")
accountName := pathComponents[3]
actorIRI := apmodels.MakeLocalIRIForAccount(accountName)
publicKey := crypto.GetPublicKey(actorIRI)
if err := requests.WriteStreamResponse(response.(vocab.Type), w, publicKey); err != nil {
log.Errorln("unable to write stream response for followers handler", err)
}
}
func getInitialFollowersRequest(r *http.Request) (vocab.ActivityStreamsOrderedCollection, error) {
followerCount, _ := persistence.GetFollowerCount()
collection := streams.NewActivityStreamsOrderedCollection()
idProperty := streams.NewJSONLDIdProperty()
id, err := createPageURL(r, nil)
if err != nil {
return nil, errors.Wrap(err, "unable to create followers page property")
}
idProperty.SetIRI(id)
collection.SetJSONLDId(idProperty)
totalItemsProperty := streams.NewActivityStreamsTotalItemsProperty()
totalItemsProperty.Set(int(followerCount))
collection.SetActivityStreamsTotalItems(totalItemsProperty)
first := streams.NewActivityStreamsFirstProperty()
page := "1"
firstIRI, err := createPageURL(r, &page)
if err != nil {
return nil, errors.Wrap(err, "unable to create first page property")
}
first.SetIRI(firstIRI)
collection.SetActivityStreamsFirst(first)
return collection, nil
}
func getFollowersPage(page string, r *http.Request) (vocab.ActivityStreamsOrderedCollectionPage, error) {
pageInt, err := strconv.Atoi(page)
if err != nil {
return nil, errors.Wrap(err, "unable to parse page number")
}
followerCount, err := persistence.GetFollowerCount()
if err != nil {
return nil, errors.Wrap(err, "unable to get follower count")
}
followers, err := persistence.GetFederationFollowers(followersPageSize, (pageInt-1)*followersPageSize)
if err != nil {
return nil, errors.Wrap(err, "unable to get federation followers")
}
collectionPage := streams.NewActivityStreamsOrderedCollectionPage()
idProperty := streams.NewJSONLDIdProperty()
id, err := createPageURL(r, &page)
if err != nil {
return nil, errors.Wrap(err, "unable to create followers page ID")
}
idProperty.SetIRI(id)
collectionPage.SetJSONLDId(idProperty)
orderedItems := streams.NewActivityStreamsOrderedItemsProperty()
for _, follower := range followers {
u, _ := url.Parse(follower.ActorIRI)
orderedItems.AppendIRI(u)
}
collectionPage.SetActivityStreamsOrderedItems(orderedItems)
partOf := streams.NewActivityStreamsPartOfProperty()
partOfIRI, err := createPageURL(r, nil)
if err != nil {
return nil, errors.Wrap(err, "unable to create partOf property for followers page")
}
partOf.SetIRI(partOfIRI)
collectionPage.SetActivityStreamsPartOf(partOf)
if pageInt*followersPageSize < int(followerCount) {
next := streams.NewActivityStreamsNextProperty()
nextPage := fmt.Sprintf("%d", pageInt+1)
nextIRI, err := createPageURL(r, &nextPage)
if err != nil {
return nil, errors.Wrap(err, "unable to create next page property")
}
next.SetIRI(nextIRI)
collectionPage.SetActivityStreamsNext(next)
}
return collectionPage, nil
}
func createPageURL(r *http.Request, page *string) (*url.URL, error) {
domain := data.GetServerURL()
if domain == "" {
return nil, errors.New("unable to get server URL")
}
pageURL, err := url.Parse(domain)
if err != nil {
return nil, errors.Wrap(err, "unable to parse server URL")
}
if page != nil {
query := pageURL.Query()
query.Add("page", *page)
pageURL.RawQuery = query.Encode()
}
pageURL.Path = r.URL.Path
return pageURL, nil
}

View file

@ -0,0 +1,56 @@
package controllers
import (
"io"
"net/http"
"strings"
"github.com/owncast/owncast/activitypub/apmodels"
"github.com/owncast/owncast/activitypub/inbox"
"github.com/owncast/owncast/core/data"
log "github.com/sirupsen/logrus"
)
// InboxHandler handles inbound federated requests.
func InboxHandler(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
acceptInboxRequest(w, r)
} else {
w.WriteHeader(http.StatusMethodNotAllowed)
}
}
func acceptInboxRequest(w http.ResponseWriter, r *http.Request) {
if !data.GetFederationEnabled() {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
urlPathComponents := strings.Split(r.URL.Path, "/")
var forLocalAccount string
if len(urlPathComponents) == 5 {
forLocalAccount = urlPathComponents[3]
} else {
log.Errorln("Unable to determine username from url path")
w.WriteHeader(http.StatusNotFound)
return
}
// The account this request is for must match the account name we have set
// for federation.
if forLocalAccount != data.GetFederationUsername() {
w.WriteHeader(http.StatusNotFound)
return
}
data, err := io.ReadAll(r.Body)
if err != nil {
log.Errorln("Unable to read inbox request payload", err)
return
}
inboxRequest := apmodels.InboxRequest{Request: r, ForLocalAccount: forLocalAccount, Body: data}
inbox.AddToQueue(inboxRequest)
w.WriteHeader(http.StatusAccepted)
}

View file

@ -0,0 +1,285 @@
package controllers
import (
"fmt"
"net/http"
"net/url"
"github.com/owncast/owncast/activitypub/apmodels"
"github.com/owncast/owncast/activitypub/crypto"
"github.com/owncast/owncast/activitypub/persistence"
"github.com/owncast/owncast/activitypub/requests"
"github.com/owncast/owncast/config"
"github.com/owncast/owncast/core/data"
log "github.com/sirupsen/logrus"
)
// NodeInfoController returns the V1 node info response.
func NodeInfoController(w http.ResponseWriter, r *http.Request) {
type links struct {
Rel string `json:"rel"`
Href string `json:"href"`
}
type response struct {
Links []links `json:"links"`
}
if !data.GetFederationEnabled() {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
serverURL := data.GetServerURL()
if serverURL == "" {
w.WriteHeader(http.StatusNotFound)
return
}
v2, err := url.Parse(serverURL)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
v2.Path = "nodeinfo/2.0"
res := response{
Links: []links{
{
Rel: "http://nodeinfo.diaspora.software/ns/schema/2.0",
Href: v2.String(),
},
},
}
if err := writeResponse(res, w); err != nil {
log.Errorln(err)
}
}
// NodeInfoV2Controller returns the V2 node info response.
func NodeInfoV2Controller(w http.ResponseWriter, r *http.Request) {
type software struct {
Name string `json:"name"`
Version string `json:"version"`
}
type users struct {
Total int `json:"total"`
ActiveMonth int `json:"activeMonth"`
ActiveHalfyear int `json:"activeHalfyear"`
}
type usage struct {
Users users `json:"users"`
LocalPosts int `json:"localPosts"`
}
type response struct {
Version string `json:"version"`
Software software `json:"software"`
Protocols []string `json:"protocols"`
Usage usage `json:"usage"`
OpenRegistrations bool `json:"openRegistrations"`
}
if !data.GetFederationEnabled() {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
localPostCount, _ := persistence.GetLocalPostCount()
res := response{
Version: "2.0",
Software: software{
Name: "Owncast",
Version: config.VersionNumber,
},
Usage: usage{
Users: users{
Total: 1,
ActiveMonth: 1,
ActiveHalfyear: 1,
},
LocalPosts: int(localPostCount),
},
OpenRegistrations: false,
Protocols: []string{"activitypub"},
}
if err := writeResponse(res, w); err != nil {
log.Errorln(err)
}
}
// XNodeInfo2Controller returns the x-nodeinfo2.
func XNodeInfo2Controller(w http.ResponseWriter, r *http.Request) {
type Organization struct {
Name string `json:"name"`
Contact string `json:"contact"`
}
type Server struct {
BaseURL string `json:"baseUrl"`
Version string `json:"version"`
Name string `json:"name"`
Software string `json:"software"`
}
type Services struct {
Outbound []string `json:"outbound"`
Inbound []string `json:"inbound"`
}
type Users struct {
ActiveWeek int `json:"activeWeek"`
Total int `json:"total"`
ActiveMonth int `json:"activeMonth"`
ActiveHalfyear int `json:"activeHalfyear"`
}
type Usage struct {
Users Users `json:"users"`
LocalPosts int `json:"localPosts"`
LocalComments int `json:"localComments"`
}
type response struct {
Organization Organization `json:"organization"`
Server Server `json:"server"`
Services Services `json:"services"`
Protocols []string `json:"protocols"`
Version string `json:"version"`
OpenRegistrations bool `json:"openRegistrations"`
Usage Usage `json:"usage"`
}
if !data.GetFederationEnabled() {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
serverURL := data.GetServerURL()
if serverURL == "" {
w.WriteHeader(http.StatusNotFound)
return
}
localPostCount, _ := persistence.GetLocalPostCount()
res := &response{
Organization: Organization{
Name: data.GetServerName(),
Contact: serverURL,
},
Server: Server{
BaseURL: serverURL,
Version: config.VersionNumber,
Name: "owncast",
Software: "owncast",
},
Services: Services{
Inbound: []string{"activitypub"},
Outbound: []string{"activitypub"},
},
Protocols: []string{"activitypub"},
Version: config.VersionNumber,
Usage: Usage{
Users: Users{
ActiveWeek: 1,
Total: 1,
ActiveMonth: 1,
ActiveHalfyear: 1,
},
LocalPosts: int(localPostCount),
LocalComments: 0,
},
}
if err := writeResponse(res, w); err != nil {
log.Errorln(err)
}
}
// InstanceV1Controller returns the v1 instance details.
func InstanceV1Controller(w http.ResponseWriter, r *http.Request) {
type Stats struct {
UserCount int `json:"user_count"`
StatusCount int `json:"status_count"`
DomainCount int `json:"domain_count"`
}
type response struct {
URI string `json:"uri"`
Title string `json:"title"`
ShortDescription string `json:"short_description"`
Description string `json:"description"`
Version string `json:"version"`
Stats Stats `json:"stats"`
Thumbnail string `json:"thumbnail"`
Languages []string `json:"languages"`
Registrations bool `json:"registrations"`
ApprovalRequired bool `json:"approval_required"`
InvitesEnabled bool `json:"invites_enabled"`
}
if !data.GetFederationEnabled() {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
serverURL := data.GetServerURL()
if serverURL == "" {
w.WriteHeader(http.StatusNotFound)
return
}
thumbnail, err := url.Parse(serverURL)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
thumbnail.Path = "/logo/external"
localPostCount, _ := persistence.GetLocalPostCount()
res := response{
URI: serverURL,
Title: data.GetServerName(),
ShortDescription: data.GetServerSummary(),
Description: data.GetServerSummary(),
Version: config.GetReleaseString(),
Stats: Stats{
UserCount: 1,
StatusCount: int(localPostCount),
DomainCount: 0,
},
Thumbnail: thumbnail.String(),
Registrations: false,
ApprovalRequired: false,
InvitesEnabled: false,
}
if err := writeResponse(res, w); err != nil {
log.Errorln(err)
}
}
func writeResponse(payload interface{}, w http.ResponseWriter) error {
accountName := data.GetDefaultFederationUsername()
actorIRI := apmodels.MakeLocalIRIForAccount(accountName)
publicKey := crypto.GetPublicKey(actorIRI)
return requests.WritePayloadResponse(payload, w, publicKey)
}
// HostMetaController points to webfinger.
func HostMetaController(w http.ResponseWriter, r *http.Request) {
serverURL := data.GetServerURL()
if serverURL == "" {
w.WriteHeader(http.StatusNotFound)
return
}
res := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
<XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0">
<Link rel="lrdd" type="application/json" template="%s/.well-known/webfinger?resource={uri}"/>
</XRD>`, serverURL)
if _, err := w.Write([]byte(res)); err != nil {
log.Errorln(err)
}
}

View file

@ -0,0 +1,42 @@
package controllers
import (
"net/http"
"strings"
"github.com/owncast/owncast/activitypub/apmodels"
"github.com/owncast/owncast/activitypub/crypto"
"github.com/owncast/owncast/activitypub/persistence"
"github.com/owncast/owncast/activitypub/requests"
"github.com/owncast/owncast/core/data"
log "github.com/sirupsen/logrus"
)
// ObjectHandler handles requests for a single federated ActivityPub object.
func ObjectHandler(w http.ResponseWriter, r *http.Request) {
if !data.GetFederationEnabled() {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
// If private federation mode is enabled do not allow access to objects.
if data.GetFederationIsPrivate() {
w.WriteHeader(http.StatusNotFound)
return
}
iri := strings.Join([]string{strings.TrimSuffix(data.GetServerURL(), "/"), r.URL.Path}, "")
object, _, _, err := persistence.GetObjectByIRI(iri)
if err != nil {
w.WriteHeader(http.StatusNotFound)
return
}
accountName := data.GetDefaultFederationUsername()
actorIRI := apmodels.MakeLocalIRIForAccount(accountName)
publicKey := crypto.GetPublicKey(actorIRI)
if err := requests.WriteResponse([]byte(object), w, publicKey); err != nil {
log.Errorln(err)
}
}

View file

@ -0,0 +1,156 @@
package controllers
import (
"fmt"
"net/http"
"strconv"
"strings"
"github.com/go-fed/activity/streams"
"github.com/go-fed/activity/streams/vocab"
"github.com/owncast/owncast/activitypub/apmodels"
"github.com/owncast/owncast/activitypub/crypto"
"github.com/owncast/owncast/activitypub/persistence"
"github.com/owncast/owncast/activitypub/requests"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
)
const (
outboxPageSize = 50
)
// OutboxHandler will handle requests for the local ActivityPub outbox.
func OutboxHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
var response interface{}
var err error
if r.URL.Query().Get("page") != "" {
response, err = getOutboxPage(r.URL.Query().Get("page"), r)
} else {
response, err = getInitialOutboxHandler(r)
}
if response == nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
if err != nil {
_, _ = w.Write([]byte(err.Error()))
w.WriteHeader(http.StatusInternalServerError)
return
}
pathComponents := strings.Split(r.URL.Path, "/")
accountName := pathComponents[3]
actorIRI := apmodels.MakeLocalIRIForAccount(accountName)
publicKey := crypto.GetPublicKey(actorIRI)
if err := requests.WriteStreamResponse(response.(vocab.Type), w, publicKey); err != nil {
log.Errorln("unable to write stream response for outbox handler", err)
}
}
// ActorObjectHandler will handle the request for a single ActivityPub object.
func ActorObjectHandler(w http.ResponseWriter, r *http.Request) {
object, _, _, err := persistence.GetObjectByIRI(r.URL.Path)
if err != nil {
w.WriteHeader(http.StatusNotFound)
return
// controllers.WriteSimpleResponse(w, false, err.Error())
}
if _, err := w.Write([]byte(object)); err != nil {
log.Errorln(err)
}
}
func getInitialOutboxHandler(r *http.Request) (vocab.ActivityStreamsOrderedCollection, error) {
collection := streams.NewActivityStreamsOrderedCollection()
idProperty := streams.NewJSONLDIdProperty()
id, err := createPageURL(r, nil)
if err != nil {
return nil, errors.Wrap(err, "unable to create followers page property")
}
idProperty.SetIRI(id)
collection.SetJSONLDId(idProperty)
totalPosts, err := persistence.GetOutboxPostCount()
if err != nil {
return nil, errors.Wrap(err, "unable to get outbox post count")
}
totalItemsProperty := streams.NewActivityStreamsTotalItemsProperty()
totalItemsProperty.Set(int(totalPosts))
collection.SetActivityStreamsTotalItems(totalItemsProperty)
first := streams.NewActivityStreamsFirstProperty()
page := "1"
firstIRI, err := createPageURL(r, &page)
if err != nil {
return nil, errors.Wrap(err, "unable to create first page property")
}
first.SetIRI(firstIRI)
collection.SetActivityStreamsFirst(first)
return collection, nil
}
func getOutboxPage(page string, r *http.Request) (vocab.ActivityStreamsOrderedCollectionPage, error) {
pageInt, err := strconv.Atoi(page)
if err != nil {
return nil, errors.Wrap(err, "unable to parse page number")
}
postCount, err := persistence.GetOutboxPostCount()
if err != nil {
return nil, errors.Wrap(err, "unable to get outbox post count")
}
collectionPage := streams.NewActivityStreamsOrderedCollectionPage()
idProperty := streams.NewJSONLDIdProperty()
id, err := createPageURL(r, &page)
if err != nil {
return nil, errors.Wrap(err, "unable to create followers page ID")
}
idProperty.SetIRI(id)
collectionPage.SetJSONLDId(idProperty)
orderedItems := streams.NewActivityStreamsOrderedItemsProperty()
outboxItems, err := persistence.GetOutbox(outboxPageSize, (pageInt-1)*outboxPageSize)
if err != nil {
return nil, errors.Wrap(err, "unable to get federation followers")
}
orderedItems.AppendActivityStreamsOrderedCollection(outboxItems)
collectionPage.SetActivityStreamsOrderedItems(orderedItems)
partOf := streams.NewActivityStreamsPartOfProperty()
partOfIRI, err := createPageURL(r, nil)
if err != nil {
return nil, errors.Wrap(err, "unable to create partOf property for outbox page")
}
partOf.SetIRI(partOfIRI)
collectionPage.SetActivityStreamsPartOf(partOf)
if pageInt*followersPageSize < int(postCount) {
next := streams.NewActivityStreamsNextProperty()
nextPage := fmt.Sprintf("%d", pageInt+1)
nextIRI, err := createPageURL(r, &nextPage)
if err != nil {
return nil, errors.Wrap(err, "unable to create next page property")
}
next.SetIRI(nextIRI)
collectionPage.SetActivityStreamsNext(next)
}
return collectionPage, nil
}

View file

@ -0,0 +1,60 @@
package controllers
import (
"encoding/json"
"net/http"
"strings"
"github.com/owncast/owncast/activitypub/apmodels"
"github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/utils"
log "github.com/sirupsen/logrus"
)
// WebfingerHandler will handle webfinger lookup requests.
func WebfingerHandler(w http.ResponseWriter, r *http.Request) {
if !data.GetFederationEnabled() {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
resource := r.URL.Query().Get("resource")
resourceComponents := strings.Split(resource, ":")
account := resourceComponents[1]
userComponents := strings.Split(account, "@")
if len(userComponents) < 2 {
return
}
host := userComponents[1]
user := userComponents[0]
if _, valid := data.GetFederatedInboxMap()[user]; !valid {
// User is not valid
w.WriteHeader(http.StatusNotFound)
log.Println("Webfinger request rejected")
return
}
// If the webfinger request doesn't match our server then it
// should be rejected.
instanceHostString := data.GetServerURL()
if instanceHostString == "" {
w.WriteHeader(http.StatusNotImplemented)
return
}
instanceHostString = utils.GetHostnameFromURLString(instanceHostString)
if instanceHostString == "" || instanceHostString != host {
w.WriteHeader(http.StatusNotImplemented)
return
}
webfingerResponse := apmodels.MakeWebfingerResponse(user, user, host)
w.Header().Set("Content-Type", "application/jrd+json")
if err := json.NewEncoder(w).Encode(webfingerResponse); err != nil {
log.Errorln("unable to write webfinger response", err)
}
}

View file

@ -0,0 +1,78 @@
package crypto
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"errors"
"net/url"
"github.com/owncast/owncast/core/data"
log "github.com/sirupsen/logrus"
)
// GetPublicKey will return the public key for the provided actor.
func GetPublicKey(actorIRI *url.URL) PublicKey {
key := data.GetPublicKey()
idURL, err := url.Parse(actorIRI.String() + "#main-key")
if err != nil {
log.Errorln("unable to parse actor iri string", idURL, err)
}
return PublicKey{
ID: idURL,
Owner: actorIRI,
PublicKeyPem: key,
}
}
// GetPrivateKey will return the internal server private key.
func GetPrivateKey() *rsa.PrivateKey {
key := data.GetPrivateKey()
block, _ := pem.Decode([]byte(key))
if block == nil {
log.Errorln(errors.New("failed to parse PEM block containing the key"))
return nil
}
priv, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
log.Errorln("unable to parse private key", err)
return nil
}
return priv
}
// GenerateKeys will generate the private/public key pair needed for federation.
func GenerateKeys() ([]byte, []byte, error) {
// generate key
privatekey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
log.Errorln("Cannot generate RSA key", err)
return nil, nil, err
}
publickey := &privatekey.PublicKey
privateKeyBytes := x509.MarshalPKCS1PrivateKey(privatekey)
privateKeyBlock := &pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: privateKeyBytes,
}
privatePem := pem.EncodeToMemory(privateKeyBlock)
publicKeyBytes, err := x509.MarshalPKIXPublicKey(publickey)
if err != nil {
log.Errorln("error when dumping publickey:", err)
return nil, nil, err
}
publicKeyBlock := &pem.Block{
Type: "PUBLIC KEY",
Bytes: publicKeyBytes,
}
publicPem := pem.EncodeToMemory(publicKeyBlock)
return privatePem, publicPem, nil
}

View file

@ -0,0 +1,10 @@
package crypto
import "net/url"
// PublicKey represents a public key with associated ownership.
type PublicKey struct {
ID *url.URL `json:"id"`
Owner *url.URL `json:"owner"`
PublicKeyPem string `json:"publicKeyPem"`
}

View file

@ -0,0 +1,70 @@
package crypto
import (
"crypto"
"net/http"
"net/url"
"time"
"github.com/go-fed/httpsig"
)
// SignResponse will sign a response using the provided response body and public key.
func SignResponse(w http.ResponseWriter, body []byte, publicKey PublicKey) error {
privateKey := GetPrivateKey()
return signResponse(privateKey, *publicKey.ID, body, w)
}
func signResponse(privateKey crypto.PrivateKey, pubKeyID url.URL, body []byte, w http.ResponseWriter) error {
prefs := []httpsig.Algorithm{httpsig.RSA_SHA256}
digestAlgorithm := httpsig.DigestSha256
// The "Date" and "Digest" headers must already be set on r, as well as r.URL.
headersToSign := []string{}
if body != nil {
headersToSign = append(headersToSign, "digest")
}
signer, _, err := httpsig.NewSigner(prefs, digestAlgorithm, headersToSign, httpsig.Signature, 0)
if err != nil {
return err
}
// If r were a http.ResponseWriter, call SignResponse instead.
return signer.SignResponse(privateKey, pubKeyID.String(), w, body)
}
// SignRequest will sign an ounbound request given the provided body.
func SignRequest(req *http.Request, body []byte, actorIRI *url.URL) error {
publicKey := GetPublicKey(actorIRI)
privateKey := GetPrivateKey()
return signRequest(privateKey, publicKey.ID.String(), body, req)
}
func signRequest(privateKey crypto.PrivateKey, pubKeyID string, body []byte, r *http.Request) error {
prefs := []httpsig.Algorithm{httpsig.RSA_SHA256}
digestAlgorithm := httpsig.DigestSha256
date := time.Now().UTC().Format("Mon, 02 Jan 2006 15:04:05 GMT")
r.Header["Date"] = []string{date}
r.Header["Host"] = []string{r.URL.Host}
r.Header["Accept"] = []string{`application/ld+json; profile="https://www.w3.org/ns/activitystreams"`}
// The "Date" and "Digest" headers must already be set on r, as well as r.URL.
headersToSign := []string{httpsig.RequestTarget, "host", "date"}
if body != nil {
headersToSign = append(headersToSign, "digest")
}
signer, _, err := httpsig.NewSigner(prefs, digestAlgorithm, headersToSign, httpsig.Signature, 0)
if err != nil {
return err
}
// If r were a http.ResponseWriter, call SignResponse instead.
return signer.SignRequest(privateKey, pubKeyID, r, body)
}

View file

@ -0,0 +1,40 @@
package inbox
import (
"context"
"time"
"github.com/go-fed/activity/streams/vocab"
"github.com/owncast/owncast/activitypub/persistence"
"github.com/owncast/owncast/core/chat/events"
"github.com/pkg/errors"
)
func handleAnnounceRequest(c context.Context, activity vocab.ActivityStreamsAnnounce) error {
object := activity.GetActivityStreamsObject()
actorReference := activity.GetActivityStreamsActor()
objectIRI := object.At(0).GetIRI().String()
actorIRI := actorReference.At(0).GetIRI().String()
if hasPreviouslyhandled, err := persistence.HasPreviouslyHandledInboundActivity(objectIRI, actorIRI, events.FediverseEngagementRepost); hasPreviouslyhandled || err != nil {
return errors.Wrap(err, "inbound activity of share/re-post has already been handled")
}
// Shares need to match a post we had already sent.
_, isLiveNotification, timestamp, err := persistence.GetObjectByIRI(objectIRI)
if err != nil {
return errors.Wrap(err, "Could not find post locally")
}
// Don't allow old activities to be liked
if time.Since(timestamp) > maxAgeForEngagement {
return errors.New("Activity is too old to be shared")
}
// Save as an accepted activity
if err := persistence.SaveInboundFediverseActivity(objectIRI, actorIRI, events.FediverseEngagementRepost, time.Now()); err != nil {
return errors.Wrap(err, "unable to save inbound share/re-post activity")
}
return handleEngagementActivity(events.FediverseEngagementRepost, isLiveNotification, actorReference, events.FediverseEngagementRepost)
}

62
activitypub/inbox/chat.go Normal file
View file

@ -0,0 +1,62 @@
package inbox
import (
"fmt"
"github.com/go-fed/activity/streams/vocab"
"github.com/owncast/owncast/activitypub/resolvers"
"github.com/owncast/owncast/core/chat"
"github.com/owncast/owncast/core/chat/events"
"github.com/owncast/owncast/core/data"
)
func handleEngagementActivity(eventType events.EventType, isLiveNotification bool, actorReference vocab.ActivityStreamsActorProperty, action string) error {
// Do nothing if displaying engagement actions has been turned off.
if !data.GetFederationShowEngagement() {
return nil
}
// Do nothing if chat is disabled
if data.GetChatDisabled() {
return nil
}
// Get actor of the action
actor, _ := resolvers.GetResolvedActorFromActorProperty(actorReference)
// Send chat message
actorName := actor.Name
if actorName == "" {
actorName = actor.Username
}
actorIRI := actorReference.Begin().GetIRI().String()
userPrefix := fmt.Sprintf("%s ", actorName)
var suffix string
if isLiveNotification && action == events.FediverseEngagementLike {
suffix = "liked that this stream went live."
} else if action == events.FediverseEngagementLike {
suffix = fmt.Sprintf("liked a post from %s.", data.GetServerName())
} else if isLiveNotification && action == events.FediverseEngagementRepost {
suffix = "shared this stream with their followers."
} else if action == events.FediverseEngagementRepost {
suffix = fmt.Sprintf("shared a post from %s.", data.GetServerName())
} else if action == events.FediverseEngagementFollow {
suffix = "followed this stream."
} else {
return fmt.Errorf("could not handle event for sending to chat: %s", action)
}
body := fmt.Sprintf("%s %s", userPrefix, suffix)
var image *string
if actor.Image != nil {
s := actor.Image.String()
image = &s
}
if err := chat.SendFediverseAction(eventType, actor.FullUsername, image, body, actorIRI); err != nil {
return err
}
return nil
}

View file

@ -0,0 +1,7 @@
package inbox
import "time"
const (
maxAgeForEngagement = time.Hour * 36
)

View file

@ -0,0 +1,88 @@
package inbox
import (
"context"
"fmt"
"time"
"github.com/go-fed/activity/streams/vocab"
"github.com/owncast/owncast/activitypub/persistence"
"github.com/owncast/owncast/activitypub/requests"
"github.com/owncast/owncast/activitypub/resolvers"
"github.com/owncast/owncast/core/chat/events"
"github.com/owncast/owncast/core/data"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
)
func handleFollowInboxRequest(c context.Context, activity vocab.ActivityStreamsFollow) error {
follow, err := resolvers.MakeFollowRequest(c, activity)
if err != nil {
log.Errorln("unable to create follow inbox request", err)
return err
}
if follow == nil {
return fmt.Errorf("unable to handle request")
}
approved := !data.GetFederationIsPrivate()
followRequest := *follow
if err := persistence.AddFollow(followRequest, approved); err != nil {
log.Errorln("unable to save follow request", err)
return err
}
localAccountName := data.GetDefaultFederationUsername()
if approved {
if err := requests.SendFollowAccept(follow.Inbox, follow.FollowRequestIri, localAccountName); err != nil {
log.Errorln("unable to send follow accept", err)
return err
}
}
// Save as an accepted activity
actorReference := activity.GetActivityStreamsActor()
object := activity.GetActivityStreamsObject()
objectIRI := object.At(0).GetIRI().String()
actorIRI := actorReference.At(0).GetIRI().String()
// If this request is approved and we have not previously sent an action to
// chat due to a previous follow request, then do so.
hasPreviouslyhandled := true // Default so we don't send anything if it fails.
if approved {
hasPreviouslyhandled, err = persistence.HasPreviouslyHandledInboundActivity(objectIRI, actorIRI, events.FediverseEngagementFollow)
if err != nil {
log.Errorln("error checking for previously handled follow activity", err)
}
}
// Save this follow action to our activities table.
if err := persistence.SaveInboundFediverseActivity(objectIRI, actorIRI, events.FediverseEngagementFollow, time.Now()); err != nil {
return errors.Wrap(err, "unable to save inbound share/re-post activity")
}
// Send action to chat if it has not been previously handled.
if !hasPreviouslyhandled {
return handleEngagementActivity(events.FediverseEngagementFollow, false, actorReference, events.FediverseEngagementFollow)
}
return nil
}
func handleUnfollowRequest(c context.Context, activity vocab.ActivityStreamsUndo) error {
request := resolvers.MakeUnFollowRequest(c, activity)
if request == nil {
log.Errorf("unable to handle unfollow request")
return errors.New("unable to handle unfollow request")
}
unfollowRequest := *request
log.Traceln("unfollow request:", unfollowRequest)
return persistence.RemoveFollow(unfollowRequest)
}

40
activitypub/inbox/like.go Normal file
View file

@ -0,0 +1,40 @@
package inbox
import (
"context"
"time"
"github.com/go-fed/activity/streams/vocab"
"github.com/owncast/owncast/activitypub/persistence"
"github.com/owncast/owncast/core/chat/events"
"github.com/pkg/errors"
)
func handleLikeRequest(c context.Context, activity vocab.ActivityStreamsLike) error {
object := activity.GetActivityStreamsObject()
actorReference := activity.GetActivityStreamsActor()
objectIRI := object.At(0).GetIRI().String()
actorIRI := actorReference.At(0).GetIRI().String()
if hasPreviouslyhandled, err := persistence.HasPreviouslyHandledInboundActivity(objectIRI, actorIRI, events.FediverseEngagementLike); hasPreviouslyhandled || err != nil {
return errors.Wrap(err, "inbound activity of like has already been handled")
}
// Likes need to match a post we had already sent.
_, isLiveNotification, timestamp, err := persistence.GetObjectByIRI(objectIRI)
if err != nil {
return errors.Wrap(err, "Could not find post locally")
}
// Don't allow old activities to be liked
if time.Since(timestamp) > maxAgeForEngagement {
return errors.New("Activity is too old to be liked")
}
// Save as an accepted activity
if err := persistence.SaveInboundFediverseActivity(objectIRI, actorIRI, events.FediverseEngagementLike, time.Now()); err != nil {
return errors.Wrap(err, "unable to save inbound like activity")
}
return handleEngagementActivity(events.FediverseEngagementLike, isLiveNotification, actorReference, events.FediverseEngagementLike)
}

27
activitypub/inbox/undo.go Normal file
View file

@ -0,0 +1,27 @@
package inbox
import (
"context"
log "github.com/sirupsen/logrus"
"github.com/go-fed/activity/streams/vocab"
)
func handleUndoInboxRequest(c context.Context, activity vocab.ActivityStreamsUndo) error {
// Determine if this is an undo of a follow, favorite, announce, etc.
o := activity.GetActivityStreamsObject()
for iter := o.Begin(); iter != o.End(); iter = iter.Next() {
if iter.IsActivityStreamsFollow() {
// This is an Unfollow request
if err := handleUnfollowRequest(c, activity); err != nil {
return err
}
} else {
log.Traceln("Undo", iter.GetType().GetTypeName(), "ignored")
return nil
}
}
return nil
}

View file

@ -0,0 +1,25 @@
package inbox
import (
"context"
"github.com/go-fed/activity/streams/vocab"
"github.com/owncast/owncast/activitypub/persistence"
"github.com/owncast/owncast/activitypub/resolvers"
log "github.com/sirupsen/logrus"
)
func handleUpdateRequest(c context.Context, activity vocab.ActivityStreamsUpdate) error {
// We only care about update events to followers.
if !activity.GetActivityStreamsObject().At(0).IsActivityStreamsPerson() {
return nil
}
actor, err := resolvers.GetResolvedActorFromActorProperty(activity.GetActivityStreamsActor())
if err != nil {
log.Errorln(err)
return err
}
return persistence.UpdateFollower(actor.ActorIri.String(), actor.Inbox.String(), actor.Name, actor.FullUsername, actor.Image.String())
}

130
activitypub/inbox/worker.go Normal file
View file

@ -0,0 +1,130 @@
package inbox
import (
"context"
"crypto/x509"
"encoding/pem"
"net/http"
"net/url"
"strings"
"github.com/pkg/errors"
"github.com/go-fed/httpsig"
"github.com/owncast/owncast/activitypub/apmodels"
"github.com/owncast/owncast/activitypub/persistence"
"github.com/owncast/owncast/activitypub/resolvers"
"github.com/owncast/owncast/core/data"
log "github.com/sirupsen/logrus"
)
func handle(request apmodels.InboxRequest) {
if verified, err := Verify(request.Request); err != nil {
log.Debugln("Error in attempting to verify request", err)
return
} else if !verified {
log.Errorln("Request failed verification", err)
return
}
// c := context.WithValue(context.Background(), "account", request.ForLocalAccount) //nolint
if err := resolvers.Resolve(context.Background(), request.Body, handleUpdateRequest, handleFollowInboxRequest, handleLikeRequest, handleAnnounceRequest, handleUndoInboxRequest); err != nil {
log.Errorln("resolver error:", err)
}
}
// Verify will Verify the http signature of an inbound request as well as
// check it against the list of blocked domains.
func Verify(request *http.Request) (bool, error) {
verifier, err := httpsig.NewVerifier(request)
if err != nil {
return false, errors.Wrap(err, "failed to create key verifier for request")
}
pubKeyID, err := url.Parse(verifier.KeyId())
if err != nil {
return false, errors.Wrap(err, "failed to parse key to get key ID")
}
// Force federation only via servers using https.
if pubKeyID.Scheme != "https" {
return false, errors.New("federated servers must use https: " + pubKeyID.String())
}
signature := request.Header.Get("signature")
var algorithmString string
signatureComponents := strings.Split(signature, ",")
for _, component := range signatureComponents {
kv := strings.Split(component, "=")
if kv[0] == "algorithm" {
algorithmString = kv[1]
break
}
}
algorithmString = strings.Trim(algorithmString, "\"")
if algorithmString == "" {
return false, errors.New("Unable to determine algorithm to verify request")
}
actor, err := resolvers.GetResolvedActorFromIRI(pubKeyID.String())
if err != nil {
return false, errors.Wrap(err, "failed to resolve actor from IRI to fetch key")
}
// Test to see if the actor is in the list of blocked federated domains.
if isBlockedDomain(actor.ActorIri.Hostname()) {
return false, errors.New("domain is blocked")
}
// If actor is specifically blocked, then fail validation.
if blocked, err := isBlockedActor(actor.ActorIri); err != nil || blocked {
return false, err
}
key := actor.W3IDSecurityV1PublicKey.Begin().Get().GetW3IDSecurityV1PublicKeyPem().Get()
block, _ := pem.Decode([]byte(key))
if block == nil {
log.Errorln("failed to parse PEM block containing the public key")
return false, errors.New("failed to parse PEM block containing the public key")
}
parsedKey, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
log.Errorln("failed to parse DER encoded public key: " + err.Error())
return false, errors.Wrap(err, "failed to parse DER encoded public key")
}
var algorithm httpsig.Algorithm = httpsig.Algorithm(algorithmString)
// The verifier will verify the Digest in addition to the HTTP signature
if err := verifier.Verify(parsedKey, algorithm); err != nil {
log.Warnln("verification error for", pubKeyID, err)
return false, errors.Wrap(err, "verification error: "+pubKeyID.String())
}
return true, nil
}
func isBlockedDomain(domain string) bool {
blockedDomains := data.GetBlockedFederatedDomains()
for _, blockedDomain := range blockedDomains {
if strings.Contains(domain, blockedDomain) {
return true
}
}
return false
}
func isBlockedActor(actorIRI *url.URL) (bool, error) {
blockedactor, err := persistence.GetFollower(actorIRI.String())
if blockedactor != nil && blockedactor.DisabledAt != nil {
return true, errors.Wrap(err, "remote actor is blocked")
}
return false, nil
}

View file

@ -0,0 +1,100 @@
package inbox
import (
"net/url"
"testing"
"github.com/go-fed/activity/streams"
"github.com/go-fed/activity/streams/vocab"
"github.com/owncast/owncast/activitypub/apmodels"
"github.com/owncast/owncast/activitypub/persistence"
"github.com/owncast/owncast/core/data"
)
func makeFakePerson() vocab.ActivityStreamsPerson {
iri, _ := url.Parse("https://freedom.eagle/user/mrfoo")
name := "Mr Foo"
username := "foodawg"
inbox, _ := url.Parse("https://fake.fediverse.server/user/mrfoo/inbox")
userAvatarURL, _ := url.Parse("https://fake.fediverse.server/user/mrfoo/avatar.png")
person := streams.NewActivityStreamsPerson()
id := streams.NewJSONLDIdProperty()
id.Set(iri)
person.SetJSONLDId(id)
nameProperty := streams.NewActivityStreamsNameProperty()
nameProperty.AppendXMLSchemaString(name)
person.SetActivityStreamsName(nameProperty)
preferredUsernameProperty := streams.NewActivityStreamsPreferredUsernameProperty()
preferredUsernameProperty.SetXMLSchemaString(username)
person.SetActivityStreamsPreferredUsername(preferredUsernameProperty)
inboxProp := streams.NewActivityStreamsInboxProperty()
inboxProp.SetIRI(inbox)
person.SetActivityStreamsInbox(inboxProp)
image := streams.NewActivityStreamsImage()
imgProp := streams.NewActivityStreamsUrlProperty()
imgProp.AppendIRI(userAvatarURL)
image.SetActivityStreamsUrl(imgProp)
icon := streams.NewActivityStreamsIconProperty()
icon.AppendActivityStreamsImage(image)
person.SetActivityStreamsIcon(icon)
return person
}
func TestMain(m *testing.M) {
data.SetupPersistence(":memory:")
data.SetServerURL("https://my.cool.site.biz")
persistence.Setup(data.GetDatastore())
m.Run()
}
func TestBlockedDomains(t *testing.T) {
person := makeFakePerson()
data.SetBlockedFederatedDomains([]string{"freedom.eagle", "guns.life"})
if len(data.GetBlockedFederatedDomains()) != 2 {
t.Error("Blocked federated domains is not set correctly")
}
for _, domain := range data.GetBlockedFederatedDomains() {
if domain == person.GetJSONLDId().GetIRI().Host {
return
}
}
t.Error("Failed to catch blocked domain")
}
func TestBlockedActors(t *testing.T) {
person := makeFakePerson()
persistence.AddFollow(apmodels.ActivityPubActor{
ActorIri: person.GetJSONLDId().GetIRI(),
Inbox: person.GetJSONLDId().GetIRI(),
FollowRequestIri: person.GetJSONLDId().GetIRI(),
}, false)
persistence.BlockOrRejectFollower(person.GetJSONLDId().GetIRI().String())
blocked, err := isBlockedActor(person.GetJSONLDId().GetIRI())
if err != nil {
t.Error(err)
return
}
if !blocked {
t.Error("Failed to block actor")
}
failedBlockIRI, _ := url.Parse("https://freedom.eagle/user/mrbar")
failedBlock, err := isBlockedActor(failedBlockIRI)
if failedBlock {
t.Error("Invalid blocking of unblocked actor IRI")
}
}

View file

@ -0,0 +1,44 @@
package inbox
import (
"github.com/owncast/owncast/activitypub/apmodels"
log "github.com/sirupsen/logrus"
)
const (
// InboxWorkerPoolSize defines the number of concurrent ActivityPub handlers.
InboxWorkerPoolSize = 10
)
// Job struct bundling the ActivityPub and the payload in one struct.
type Job struct {
request apmodels.InboxRequest
}
var queue chan Job
// InitInboxWorkerPool starts n go routines that await ActivityPub jobs.
func InitInboxWorkerPool() {
queue = make(chan Job)
// start workers
for i := 1; i <= InboxWorkerPoolSize; i++ {
go worker(i, queue)
}
}
// AddToQueue will queue up an outbound http request.
func AddToQueue(req apmodels.InboxRequest) {
log.Tracef("Queued request for ActivityPub inbox handler")
queue <- Job{req}
}
func worker(workerID int, queue <-chan Job) {
log.Debugf("Started ActivityPub worker %d", workerID)
for job := range queue {
handle(job.request)
log.Tracef("Done with ActivityPub inbox handler using worker %d", workerID)
}
}

View file

@ -0,0 +1,245 @@
package outbox
import (
"errors"
"fmt"
"net/url"
"path/filepath"
"regexp"
"strings"
"github.com/go-fed/activity/streams"
"github.com/go-fed/activity/streams/vocab"
"github.com/owncast/owncast/activitypub/apmodels"
"github.com/owncast/owncast/activitypub/persistence"
"github.com/owncast/owncast/activitypub/requests"
"github.com/owncast/owncast/activitypub/workerpool"
"github.com/owncast/owncast/config"
"github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/utils"
log "github.com/sirupsen/logrus"
"github.com/teris-io/shortid"
)
// SendLive will send all followers the message saying you started a live stream.
func SendLive() error {
textContent := data.GetFederationGoLiveMessage()
// If the message is empty then do not send it.
if textContent == "" {
return nil
}
tagStrings := []string{}
reg := regexp.MustCompile("[^a-zA-Z0-9]+")
tagProp := streams.NewActivityStreamsTagProperty()
for _, tagString := range data.GetServerMetadataTags() {
tagWithoutSpecialCharacters := reg.ReplaceAllString(tagString, "")
hashtag := apmodels.MakeHashtag(tagWithoutSpecialCharacters)
tagProp.AppendTootHashtag(hashtag)
tagString := getHashtagLinkHTMLFromTagString(tagWithoutSpecialCharacters)
tagStrings = append(tagStrings, tagString)
}
// Manually add Owncast hashtag if it doesn't already exist so it shows up
// in Owncast search results.
// We can remove this down the road, but it'll be nice for now.
if _, exists := utils.FindInSlice(tagStrings, "owncast"); !exists {
hashtag := apmodels.MakeHashtag("owncast")
tagProp.AppendTootHashtag(hashtag)
}
tagsString := strings.Join(tagStrings, " ")
var streamTitle string
if title := data.GetStreamTitle(); title != "" {
streamTitle = fmt.Sprintf("<p>%s</p>", title)
}
textContent = fmt.Sprintf("<p>%s</p><p>%s</p><p>%s</p><a href=\"%s\">%s</a>", textContent, streamTitle, tagsString, data.GetServerURL(), data.GetServerURL())
activity, _, note, noteID := createBaseOutboundMessage(textContent)
note.SetActivityStreamsTag(tagProp)
// Attach an image along with the Federated message.
previewURL, err := url.Parse(data.GetServerURL())
if err == nil {
var imageToAttach string
previewGif := filepath.Join(config.WebRoot, "preview.gif")
thumbnailJpg := filepath.Join(config.WebRoot, "thumbnail.jpg")
if utils.DoesFileExists(previewGif) {
imageToAttach = "preview.gif"
} else if utils.DoesFileExists(thumbnailJpg) {
imageToAttach = "thumbnail.jpg"
}
if imageToAttach != "" {
previewURL.Path = imageToAttach
apmodels.AddImageAttachmentToNote(note, previewURL.String())
}
}
if data.GetNSFW() {
// Mark content as sensitive.
sensitive := streams.NewActivityStreamsSensitiveProperty()
sensitive.AppendXMLSchemaBoolean(true)
note.SetActivityStreamsSensitive(sensitive)
}
b, err := apmodels.Serialize(activity)
if err != nil {
log.Errorln("unable to serialize go live message activity", err)
return errors.New("unable to serialize go live message activity " + err.Error())
}
if err := SendToFollowers(b); err != nil {
return err
}
if err := Add(note, noteID, true); err != nil {
return err
}
return nil
}
// SendPublicMessage will send a public message to all followers.
func SendPublicMessage(textContent string) error {
originalContent := textContent
textContent = utils.RenderSimpleMarkdown(textContent)
tagProp := streams.NewActivityStreamsTagProperty()
// Iterate through the post text and find #Hashtags.
words := strings.Split(originalContent, " ")
for _, word := range words {
if strings.HasPrefix(word, "#") {
tagWithoutHashtag := strings.TrimPrefix(word, "#")
// Replace the instances of the tag with a link to the tag page.
tagHTML := getHashtagLinkHTMLFromTagString(tagWithoutHashtag)
textContent = strings.ReplaceAll(textContent, word, tagHTML)
// Create Hashtag object for the tag.
hashtag := apmodels.MakeHashtag(tagWithoutHashtag)
tagProp.AppendTootHashtag(hashtag)
}
}
activity, _, note, noteID := createBaseOutboundMessage(textContent)
note.SetActivityStreamsTag(tagProp)
b, err := apmodels.Serialize(activity)
if err != nil {
log.Errorln("unable to serialize custom fediverse message activity", err)
return errors.New("unable to serialize custom fediverse message activity " + err.Error())
}
if err := SendToFollowers(b); err != nil {
return err
}
if err := Add(note, noteID, false); err != nil {
return err
}
return nil
}
// nolint: unparam
func createBaseOutboundMessage(textContent string) (vocab.ActivityStreamsCreate, string, vocab.ActivityStreamsNote, string) {
localActor := apmodels.MakeLocalIRIForAccount(data.GetDefaultFederationUsername())
noteID := shortid.MustGenerate()
noteIRI := apmodels.MakeLocalIRIForResource(noteID)
id := shortid.MustGenerate()
activity := apmodels.CreateCreateActivity(id, localActor)
object := streams.NewActivityStreamsObjectProperty()
activity.SetActivityStreamsObject(object)
note := apmodels.MakeNote(textContent, noteIRI, localActor)
object.AppendActivityStreamsNote(note)
return activity, id, note, noteID
}
// Get Hashtag HTML link for a given tag (without # prefix).
func getHashtagLinkHTMLFromTagString(baseHashtag string) string {
return fmt.Sprintf("<a class=\"hashtag\" href=\"https://directory.owncast.online/tags/%s\">#%s</a>", baseHashtag, baseHashtag)
}
// SendToFollowers will send an arbitrary payload to all follower inboxes.
func SendToFollowers(payload []byte) error {
localActor := apmodels.MakeLocalIRIForAccount(data.GetDefaultFederationUsername())
followers, err := persistence.GetFederationFollowers(-1, 0)
if err != nil {
log.Errorln("unable to fetch followers to send to", err)
return errors.New("unable to fetch followers to send payload to")
}
for _, follower := range followers {
inbox, _ := url.Parse(follower.Inbox)
req, err := requests.CreateSignedRequest(payload, inbox, localActor)
if err != nil {
log.Errorln("unable to create outbox request", follower.Inbox, err)
return errors.New("unable to create outbox request: " + follower.Inbox)
}
workerpool.AddToOutboundQueue(req)
}
return nil
}
// UpdateFollowersWithAccountUpdates will send an update to all followers alerting of a profile update.
func UpdateFollowersWithAccountUpdates() error {
// Don't do anything if federation is disabled.
if !data.GetFederationEnabled() {
return nil
}
id := shortid.MustGenerate()
objectID := apmodels.MakeLocalIRIForResource(id)
activity := apmodels.MakeUpdateActivity(objectID)
actor := streams.NewActivityStreamsPerson()
actorID := apmodels.MakeLocalIRIForAccount(data.GetDefaultFederationUsername())
actorIDProperty := streams.NewJSONLDIdProperty()
actorIDProperty.Set(actorID)
actor.SetJSONLDId(actorIDProperty)
actorProperty := streams.NewActivityStreamsActorProperty()
actorProperty.AppendActivityStreamsPerson(actor)
activity.SetActivityStreamsActor(actorProperty)
obj := streams.NewActivityStreamsObjectProperty()
obj.AppendIRI(actorID)
activity.SetActivityStreamsObject(obj)
b, err := apmodels.Serialize(activity)
if err != nil {
log.Errorln("unable to serialize send update actor activity", err)
return errors.New("unable to serialize send update actor activity")
}
return SendToFollowers(b)
}
// Add will save an ActivityPub object to the datastore.
func Add(item vocab.Type, id string, isLiveNotification bool) error {
iri := item.GetJSONLDId().GetIRI().String()
typeString := item.GetTypeName()
if iri == "" {
log.Errorln("Unable to get iri from item")
return errors.New("Unable to get iri from item " + id)
}
b, err := apmodels.Serialize(item)
if err != nil {
log.Errorln("unable to serialize model when saving to outbox", err)
return err
}
return persistence.AddToOutbox(iri, b, typeString, isLiveNotification)
}

View file

@ -0,0 +1,121 @@
package persistence
import (
"context"
"github.com/owncast/owncast/db"
"github.com/owncast/owncast/models"
"github.com/owncast/owncast/utils"
log "github.com/sirupsen/logrus"
)
func createFederationFollowersTable() {
log.Traceln("Creating federation followers table...")
createTableSQL := `CREATE TABLE IF NOT EXISTS ap_followers (
"iri" TEXT NOT NULL,
"inbox" TEXT NOT NULL,
"name" TEXT,
"username" TEXT NOT NULL,
"image" TEXT,
"request" TEXT NOT NULL,
"created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
"approved_at" TIMESTAMP,
"disabled_at" TIMESTAMP,
PRIMARY KEY (iri));
CREATE INDEX iri_index ON ap_followers (iri);
CREATE INDEX approved_at_index ON ap_followers (approved_at);`
stmt, err := _datastore.DB.Prepare(createTableSQL)
if err != nil {
log.Fatal(err)
}
defer stmt.Close()
_, err = stmt.Exec()
if err != nil {
log.Warnln("error executing sql creating followers table", createTableSQL, err)
}
}
// GetFollowerCount will return the number of followers we're keeping track of.
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, error) {
ctx := context.Background()
followersResult, err := _datastore.GetQueries().GetFederationFollowersWithOffset(ctx, db.GetFederationFollowersWithOffsetParams{
Limit: int32(limit),
Offset: int32(offset),
})
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,
Timestamp: utils.NullTime(row.CreatedAt),
}
followers = append(followers, singleFollower)
}
return followers, 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
}

View file

@ -0,0 +1,360 @@
package persistence
import (
"context"
"database/sql"
"fmt"
"net/url"
"time"
"github.com/go-fed/activity/streams"
"github.com/go-fed/activity/streams/vocab"
"github.com/owncast/owncast/activitypub/apmodels"
"github.com/owncast/owncast/activitypub/resolvers"
"github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/db"
"github.com/owncast/owncast/models"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
)
var _datastore *data.Datastore
// Setup will initialize the ActivityPub persistence layer with the provided datastore.
func Setup(datastore *data.Datastore) {
_datastore = datastore
createFederationFollowersTable()
createFederationOutboxTable()
createFederatedActivitiesTable()
}
// AddFollow will save a follow to the datastore.
func AddFollow(follow apmodels.ActivityPubActor, approved bool) error {
log.Traceln("Saving", follow.ActorIri, "as a follower.")
var image string
if follow.Image != nil {
image = follow.Image.String()
}
return createFollow(follow.ActorIri.String(), follow.Inbox.String(), follow.FollowRequestIri.String(), follow.Name, follow.Username, image, 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 string, inbox string, request string, name string, username string, image string, 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,
}); err != nil {
log.Errorln("error creating new federation follow: ", err)
}
return tx.Commit()
}
// UpdateFollower will update the details of a stored follower given an IRI.
func UpdateFollower(actorIRI string, inbox string, name string, username string, image string) error {
_datastore.DbLock.Lock()
defer _datastore.DbLock.Unlock()
tx, err := _datastore.DB.Begin()
if err != nil {
log.Debugln(err)
}
defer func() {
_ = tx.Rollback()
}()
if err = _datastore.GetQueries().WithTx(tx).UpdateFollowerByIRI(context.Background(), db.UpdateFollowerByIRIParams{
Inbox: inbox,
Name: sql.NullString{String: name, Valid: true},
Username: username,
Image: sql.NullString{String: image, Valid: true},
Iri: actorIRI,
}); err != nil {
return fmt.Errorf("error updating follower %s %s", actorIRI, err)
}
return tx.Commit()
}
func removeFollow(actor *url.URL) error {
_datastore.DbLock.Lock()
defer _datastore.DbLock.Unlock()
tx, err := _datastore.DB.Begin()
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
if err := _datastore.GetQueries().WithTx(tx).RemoveFollowerByIRI(context.Background(), actor.String()); err != nil {
return err
}
return tx.Commit()
}
// createFederatedActivitiesTable will create the accepted
// activities table if needed.
func createFederatedActivitiesTable() {
createTableSQL := `CREATE TABLE IF NOT EXISTS ap_accepted_activities (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"iri" TEXT NOT NULL,
"actor" TEXT NOT NULL,
"type" TEXT NOT NULL,
"timestamp" TIMESTAMP NOT NULL
);
CREATE INDEX iri_actor_index ON ap_accepted_activities (iri,actor);`
stmt, err := _datastore.DB.Prepare(createTableSQL)
if err != nil {
log.Fatal("error creating inbox table", err)
}
defer stmt.Close()
if _, err := stmt.Exec(); err != nil {
log.Fatal("error creating inbound federated activities table", err)
}
}
func createFederationOutboxTable() {
log.Traceln("Creating federation outbox table...")
createTableSQL := `CREATE TABLE IF NOT EXISTS ap_outbox (
"iri" TEXT NOT NULL,
"value" BLOB,
"type" TEXT NOT NULL,
"created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
"live_notification" BOOLEAN DEFAULT FALSE,
PRIMARY KEY (iri));
CREATE INDEX iri ON ap_outbox (iri);
CREATE INDEX type ON ap_outbox (type);
CREATE INDEX live_notification ON ap_outbox (live_notification);`
stmt, err := _datastore.DB.Prepare(createTableSQL)
if err != nil {
log.Fatal(err)
}
defer stmt.Close()
_, err = stmt.Exec()
if err != nil {
log.Warnln("error executing sql creating outbox table", createTableSQL, err)
}
}
// GetOutboxPostCount will return the number of posts in the outbox.
func GetOutboxPostCount() (int64, error) {
ctx := context.Background()
return _datastore.GetQueries().GetLocalPostCount(ctx)
}
// GetOutbox will return an instance of the outbox populated by stored items.
func GetOutbox(limit int, offset int) (vocab.ActivityStreamsOrderedCollection, error) {
collection := streams.NewActivityStreamsOrderedCollection()
orderedItems := streams.NewActivityStreamsOrderedItemsProperty()
rows, err := _datastore.GetQueries().GetOutboxWithOffset(
context.Background(),
db.GetOutboxWithOffsetParams{Limit: int32(limit), Offset: int32(offset)},
)
if err != nil {
return collection, err
}
for _, value := range rows {
createCallback := func(c context.Context, activity vocab.ActivityStreamsCreate) error {
orderedItems.AppendActivityStreamsCreate(activity)
return nil
}
if err := resolvers.Resolve(context.Background(), value, createCallback); err != nil {
return collection, err
}
}
return collection, nil
}
// AddToOutbox will store a single payload to the persistence layer.
func AddToOutbox(iri string, itemData []byte, typeString string, isLiveNotification bool) error {
tx, err := _datastore.DB.Begin()
if err != nil {
log.Debugln(err)
}
defer func() {
_ = tx.Rollback()
}()
if err = _datastore.GetQueries().WithTx(tx).AddToOutbox(context.Background(), db.AddToOutboxParams{
Iri: iri,
Value: itemData,
Type: typeString,
LiveNotification: sql.NullBool{Bool: isLiveNotification, Valid: true},
}); err != nil {
return fmt.Errorf("error creating new item in federation outbox %s", err)
}
return tx.Commit()
}
// GetObjectByID will return a string representation of a single object by the ID.
func GetObjectByID(id string) (string, error) {
value, err := _datastore.GetQueries().GetObjectFromOutboxByID(context.Background(), id)
return string(value), err
}
// GetObjectByIRI will return a string representation of a single object by the IRI.
func GetObjectByIRI(iri string) (string, bool, time.Time, error) {
row, err := _datastore.GetQueries().GetObjectFromOutboxByIRI(context.Background(), iri)
return string(row.Value), row.LiveNotification.Bool, row.CreatedAt.Time, err
}
// GetLocalPostCount will return the number of posts existing locally.
func GetLocalPostCount() (int64, error) {
ctx := context.Background()
return _datastore.GetQueries().GetLocalPostCount(ctx)
}
// SaveInboundFediverseActivity will save an event to the ap_inbound_activities table.
func SaveInboundFediverseActivity(objectIRI string, actorIRI string, eventType string, timestamp time.Time) error {
if err := _datastore.GetQueries().AddToAcceptedActivities(context.Background(), db.AddToAcceptedActivitiesParams{
Iri: objectIRI,
Actor: actorIRI,
Type: eventType,
Timestamp: timestamp,
}); err != nil {
return errors.Wrap(err, "error saving event "+objectIRI)
}
return nil
}
// GetInboundActivities will return a collection of saved, federated activities
// limited and offset by the values provided to support pagination.
func GetInboundActivities(limit int, offset int) ([]models.FederatedActivity, error) {
ctx := context.Background()
rows, err := _datastore.GetQueries().GetInboundActivitiesWithOffset(ctx, db.GetInboundActivitiesWithOffsetParams{
Limit: int32(limit),
Offset: int32(offset),
})
if err != nil {
return nil, err
}
activities := make([]models.FederatedActivity, 0)
for _, row := range rows {
singleActivity := models.FederatedActivity{
IRI: row.Iri,
ActorIRI: row.Actor,
Type: row.Type,
Timestamp: row.Timestamp,
}
activities = append(activities, singleActivity)
}
return activities, nil
}
// HasPreviouslyHandledInboundActivity will return if we have previously handled
// an inbound federated activity.
func HasPreviouslyHandledInboundActivity(iri string, actorIRI string, eventType string) (bool, error) {
exists, err := _datastore.GetQueries().DoesInboundActivityExist(context.Background(), db.DoesInboundActivityExistParams{
Iri: iri,
Actor: actorIRI,
Type: eventType,
})
if err != nil {
return false, err
}
return exists > 0, nil
}

View file

@ -0,0 +1,51 @@
package requests
import (
"encoding/json"
"net/url"
"github.com/go-fed/activity/streams"
"github.com/go-fed/activity/streams/vocab"
"github.com/owncast/owncast/activitypub/apmodels"
"github.com/owncast/owncast/activitypub/workerpool"
"github.com/teris-io/shortid"
)
// SendFollowAccept will send an accept activity to a follow request from a specified local user.
func SendFollowAccept(inbox *url.URL, followRequestIRI *url.URL, fromLocalAccountName string) error {
followAccept := makeAcceptFollow(followRequestIRI, fromLocalAccountName)
localAccountIRI := apmodels.MakeLocalIRIForAccount(fromLocalAccountName)
var jsonmap map[string]interface{}
jsonmap, _ = streams.Serialize(followAccept)
b, _ := json.Marshal(jsonmap)
req, err := CreateSignedRequest(b, inbox, localAccountIRI)
if err != nil {
return err
}
workerpool.AddToOutboundQueue(req)
return nil
}
func makeAcceptFollow(followRequestIri *url.URL, fromAccountName string) vocab.ActivityStreamsAccept {
acceptIDString := shortid.MustGenerate()
acceptID := apmodels.MakeLocalIRIForResource(acceptIDString)
actorID := apmodels.MakeLocalIRIForAccount(fromAccountName)
accept := streams.NewActivityStreamsAccept()
idProperty := streams.NewJSONLDIdProperty()
idProperty.SetIRI(acceptID)
accept.SetJSONLDId(idProperty)
actor := apmodels.MakeActorPropertyWithID(actorID)
accept.SetActivityStreamsActor(actor)
object := streams.NewActivityStreamsObjectProperty()
object.AppendIRI(followRequestIri)
accept.SetActivityStreamsObject(object)
return accept
}

View file

@ -0,0 +1,75 @@
package requests
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/url"
"github.com/go-fed/activity/streams"
"github.com/go-fed/activity/streams/vocab"
"github.com/owncast/owncast/activitypub/crypto"
"github.com/owncast/owncast/config"
log "github.com/sirupsen/logrus"
)
// WriteStreamResponse will write a ActivityPub object to the provided ResponseWriter and sign with the provided key.
func WriteStreamResponse(item vocab.Type, w http.ResponseWriter, publicKey crypto.PublicKey) error {
var jsonmap map[string]interface{}
jsonmap, _ = streams.Serialize(item)
b, err := json.Marshal(jsonmap)
if err != nil {
return err
}
return WriteResponse(b, w, publicKey)
}
// WritePayloadResponse will write any arbitrary object to the provided ResponseWriter and sign with the provided key.
func WritePayloadResponse(payload interface{}, w http.ResponseWriter, publicKey crypto.PublicKey) error {
b, err := json.Marshal(payload)
if err != nil {
return err
}
return WriteResponse(b, w, publicKey)
}
// WriteResponse will write any arbitrary payload to the provided ResponseWriter and sign with the provided key.
func WriteResponse(payload []byte, w http.ResponseWriter, publicKey crypto.PublicKey) error {
w.Header().Set("Content-Type", "application/activity+json")
if err := crypto.SignResponse(w, payload, publicKey); err != nil {
w.WriteHeader(http.StatusInternalServerError)
log.Errorln("unable to sign response", err)
return err
}
if _, err := w.Write(payload); err != nil {
w.WriteHeader(http.StatusInternalServerError)
return err
}
return nil
}
// CreateSignedRequest will create a signed POST request of a payload to the provided destination.
func CreateSignedRequest(payload []byte, url *url.URL, fromActorIRI *url.URL) (*http.Request, error) {
log.Debugln("Sending", string(payload), "to", url)
req, _ := http.NewRequest("POST", url.String(), bytes.NewBuffer(payload))
ua := fmt.Sprintf("%s; https://owncast.online", config.GetReleaseString())
req.Header.Set("User-Agent", ua)
req.Header.Set("Content-Type", "application/activity+json")
if err := crypto.SignRequest(req, payload, fromActorIRI); err != nil {
log.Errorln("error signing request:", err)
return nil, err
}
return req, nil
}

View file

@ -0,0 +1,57 @@
package resolvers
import (
"context"
"errors"
"fmt"
"github.com/go-fed/activity/streams/vocab"
"github.com/owncast/owncast/activitypub/apmodels"
log "github.com/sirupsen/logrus"
)
func getPersonFromFollow(activity vocab.ActivityStreamsFollow) (apmodels.ActivityPubActor, error) {
return GetResolvedActorFromActorProperty(activity.GetActivityStreamsActor())
}
// MakeFollowRequest will convert an inbound Follow request to our internal actor model.
func MakeFollowRequest(c context.Context, activity vocab.ActivityStreamsFollow) (*apmodels.ActivityPubActor, error) {
person, err := getPersonFromFollow(activity)
if err != nil {
return nil, errors.New("unable to resolve person from follow request: " + err.Error())
}
hostname := person.ActorIri.Hostname()
username := person.Username
fullUsername := fmt.Sprintf("%s@%s", username, hostname)
followRequest := apmodels.ActivityPubActor{
ActorIri: person.ActorIri,
FollowRequestIri: activity.GetJSONLDId().Get(),
Inbox: person.Inbox,
Name: person.Name,
Username: fullUsername,
Image: person.Image,
}
return &followRequest, nil
}
// MakeUnFollowRequest will convert an inbound Unfollow request to our internal actor model.
func MakeUnFollowRequest(c context.Context, activity vocab.ActivityStreamsUndo) *apmodels.ActivityPubActor {
person, err := GetResolvedActorFromActorProperty(activity.GetActivityStreamsActor())
if err != nil {
log.Errorln("unable to resolve person from actor iri", person.ActorIri, err)
return nil
}
unfollowRequest := apmodels.ActivityPubActor{
ActorIri: person.ActorIri,
FollowRequestIri: activity.GetJSONLDId().Get(),
Inbox: person.Inbox,
Name: person.Name,
Image: person.Image,
}
return &unfollowRequest
}

View file

@ -0,0 +1,125 @@
package resolvers
import (
"context"
"encoding/json"
"io/ioutil"
"net/http"
"github.com/go-fed/activity/streams"
"github.com/go-fed/activity/streams/vocab"
"github.com/owncast/owncast/activitypub/apmodels"
"github.com/owncast/owncast/activitypub/crypto"
"github.com/owncast/owncast/core/data"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
)
// Resolve will translate a raw ActivityPub payload and fire the callback associated with that activity type.
func Resolve(c context.Context, data []byte, callbacks ...interface{}) error {
jsonResolver, err := streams.NewJSONResolver(callbacks...)
if err != nil {
// Something in the setup was wrong. For example, a callback has an
// unsupported signature and would never be called
return err
}
var jsonMap map[string]interface{}
if err = json.Unmarshal(data, &jsonMap); err != nil {
return err
}
log.Debugln("Resolving payload...", string(data))
// The createCallback function will be called.
err = jsonResolver.Resolve(c, jsonMap)
if err != nil && !streams.IsUnmatchedErr(err) {
// Something went wrong
return err
} else if streams.IsUnmatchedErr(err) {
// Everything went right but the callback didn't match or the ActivityStreams
// type is one that wasn't code generated.
log.Debugln("No match: ", err)
}
return nil
}
// ResolveIRI will resolve an IRI ahd call the correct callback for the resolved type.
func ResolveIRI(c context.Context, iri string, callbacks ...interface{}) error {
log.Debugln("Resolving", iri)
req, _ := http.NewRequest("GET", iri, nil)
actor := apmodels.MakeLocalIRIForAccount(data.GetDefaultFederationUsername())
if err := crypto.SignRequest(req, nil, actor); err != nil {
return err
}
response, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer response.Body.Close()
data, err := ioutil.ReadAll(response.Body)
if err != nil {
return err
}
// fmt.Println(string(data))
return Resolve(c, data, callbacks...)
}
// GetResolvedActorFromActorProperty resolve an actor property to a fully populated person.
func GetResolvedActorFromActorProperty(actor vocab.ActivityStreamsActorProperty) (apmodels.ActivityPubActor, error) {
var err error
var apActor apmodels.ActivityPubActor
personCallback := func(c context.Context, person vocab.ActivityStreamsPerson) error {
apActor = apmodels.MakeActorFromPerson(person)
return nil
}
serviceCallback := func(c context.Context, s vocab.ActivityStreamsService) error {
apActor = apmodels.MakeActorFromService(s)
return nil
}
for iter := actor.Begin(); iter != actor.End(); iter = iter.Next() {
if iter.IsIRI() {
iri := iter.GetIRI()
if e := ResolveIRI(context.Background(), iri.String(), personCallback, serviceCallback); e != nil {
err = e
}
} else if iter.IsActivityStreamsPerson() {
person := iter.GetActivityStreamsPerson()
apActor = apmodels.MakeActorFromPerson(person)
}
}
return apActor, errors.Wrap(err, "unable to resolve actor from actor property")
}
// GetResolvedActorFromIRI will resolve an IRI string to a fully populated actor.
func GetResolvedActorFromIRI(personOrServiceIRI string) (apmodels.ActivityPubActor, error) {
var err error
var apActor apmodels.ActivityPubActor
personCallback := func(c context.Context, person vocab.ActivityStreamsPerson) error {
apActor = apmodels.MakeActorFromPerson(person)
return nil
}
serviceCallback := func(c context.Context, s vocab.ActivityStreamsService) error {
apActor = apmodels.MakeActorFromService(s)
return nil
}
if e := ResolveIRI(context.Background(), personOrServiceIRI, personCallback, serviceCallback); e != nil {
err = e
}
return apActor, errors.Wrap(err, "unable to resolve actor from IRI string: "+personOrServiceIRI)
}

35
activitypub/router.go Normal file
View file

@ -0,0 +1,35 @@
package activitypub
import (
"net/http"
"github.com/owncast/owncast/activitypub/controllers"
"github.com/owncast/owncast/router/middleware"
)
// StartRouter will start the federation specific http router.
func StartRouter() {
// WebFinger
http.HandleFunc("/.well-known/webfinger", controllers.WebfingerHandler)
// Host Metadata
http.HandleFunc("/.well-known/host-meta", controllers.HostMetaController)
// Nodeinfo v1
http.HandleFunc("/.well-known/nodeinfo", controllers.NodeInfoController)
// x-nodeinfo v2
http.HandleFunc("/.well-known/x-nodeinfo2", controllers.XNodeInfo2Controller)
// Nodeinfo v2
http.HandleFunc("/nodeinfo/2.0", controllers.NodeInfoV2Controller)
// Instance details
http.HandleFunc("/api/v1/instance", controllers.InstanceV1Controller)
// Single ActivityPub Actor
http.HandleFunc("/federation/user/", middleware.RequireActivityPubOrRedirect(controllers.ActorHandler))
// Single AP object
http.HandleFunc("/federation/", middleware.RequireActivityPubOrRedirect(controllers.ObjectHandler))
}

View file

@ -0,0 +1,66 @@
package workerpool
import (
"net/http"
log "github.com/sirupsen/logrus"
)
const (
// ActivityPubWorkerPoolSize defines the number of concurrent HTTP ActivityPub requests.
ActivityPubWorkerPoolSize = 10
)
// Job struct bundling the ActivityPub and the payload in one struct.
type Job struct {
request *http.Request
}
var queue chan Job
// InitOutboundWorkerPool starts n go routines that await ActivityPub jobs.
func InitOutboundWorkerPool() {
queue = make(chan Job)
// start workers
for i := 1; i <= ActivityPubWorkerPoolSize; i++ {
go worker(i, queue)
}
}
// AddToOutboundQueue will queue up an outbound http request.
func AddToOutboundQueue(req *http.Request) {
log.Tracef("Queued request for ActivityPub destination %s", req.RequestURI)
queue <- Job{req}
}
func worker(workerID int, queue <-chan Job) {
log.Debugf("Started ActivityPub worker %d", workerID)
for job := range queue {
if err := sendActivityPubMessageToInbox(job); err != nil {
log.Errorf("ActivityPub destination %s failed to send Error: %s", job.request.RequestURI, err)
}
log.Tracef("Done with ActivityPub destination %s using worker %d", job.request.RequestURI, workerID)
}
}
func sendActivityPubMessageToInbox(job Job) error {
// req, err := http.NewRequest("POST", job.inbox.String(), bytes.NewReader(job.payload))
// if err != nil {
// return err
// }
// req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, err := client.Do(job.request)
if err != nil {
return err
}
defer resp.Body.Close()
return nil
}

View file

@ -24,6 +24,9 @@ type Defaults struct {
SegmentLengthSeconds int
SegmentsInPlaylist int
StreamVariants []models.StreamOutputVariant
FederationUsername string
FederationGoLiveMessage string
}
// GetDefaults will return default configuration values.
@ -59,5 +62,8 @@ func GetDefaults() Defaults {
CPUUsageLevel: 2,
},
},
FederationUsername: "streamer",
FederationGoLiveMessage: "I've gone live!",
}
}

View file

@ -11,6 +11,7 @@ import (
"reflect"
"strings"
"github.com/owncast/owncast/activitypub/outbox"
"github.com/owncast/owncast/controllers"
"github.com/owncast/owncast/core/chat"
"github.com/owncast/owncast/core/data"
@ -46,6 +47,12 @@ func SetTags(w http.ResponseWriter, r *http.Request) {
return
}
// Update Fediverse followers about this change.
if err := outbox.UpdateFollowersWithAccountUpdates(); err != nil {
controllers.WriteSimpleResponse(w, false, err.Error())
return
}
controllers.WriteSimpleResponse(w, true, "changed")
}
@ -99,6 +106,12 @@ func SetServerName(w http.ResponseWriter, r *http.Request) {
return
}
// Update Fediverse followers about this change.
if err := outbox.UpdateFollowersWithAccountUpdates(); err != nil {
controllers.WriteSimpleResponse(w, false, err.Error())
return
}
controllers.WriteSimpleResponse(w, true, "changed")
}
@ -118,6 +131,12 @@ func SetServerSummary(w http.ResponseWriter, r *http.Request) {
return
}
// Update Fediverse followers about this change.
if err := outbox.UpdateFollowersWithAccountUpdates(); err != nil {
controllers.WriteSimpleResponse(w, false, err.Error())
return
}
controllers.WriteSimpleResponse(w, true, "changed")
}
@ -233,6 +252,12 @@ func SetLogo(w http.ResponseWriter, r *http.Request) {
return
}
// Update Fediverse followers about this change.
if err := outbox.UpdateFollowersWithAccountUpdates(); err != nil {
controllers.WriteSimpleResponse(w, false, err.Error())
return
}
controllers.WriteSimpleResponse(w, true, "changed")
}
@ -504,6 +529,12 @@ func SetSocialHandles(w http.ResponseWriter, r *http.Request) {
return
}
// Update Fediverse followers about this change.
if err := outbox.UpdateFollowersWithAccountUpdates(); err != nil {
controllers.WriteSimpleResponse(w, false, err.Error())
return
}
controllers.WriteSimpleResponse(w, true, "social handles updated")
}

View file

@ -0,0 +1,171 @@
package admin
import (
"net/http"
"github.com/owncast/owncast/activitypub"
"github.com/owncast/owncast/activitypub/outbox"
"github.com/owncast/owncast/activitypub/persistence"
"github.com/owncast/owncast/controllers"
"github.com/owncast/owncast/core/data"
)
// SendFederatedMessage will send a manual message to the fediverse.
func SendFederatedMessage(w http.ResponseWriter, r *http.Request) {
if !requirePOST(w, r) {
return
}
configValue, success := getValueFromRequest(w, r)
if !success {
return
}
message, ok := configValue.Value.(string)
if !ok {
controllers.WriteSimpleResponse(w, false, "unable to send message")
}
if err := activitypub.SendPublicFederatedMessage(message); err != nil {
controllers.WriteSimpleResponse(w, false, err.Error())
return
}
controllers.WriteSimpleResponse(w, true, "sent")
}
// SetFederationEnabled will set if Federation features are enabled.
func SetFederationEnabled(w http.ResponseWriter, r *http.Request) {
if !requirePOST(w, r) {
return
}
configValue, success := getValueFromRequest(w, r)
if !success {
return
}
if err := data.SetFederationEnabled(configValue.Value.(bool)); err != nil {
controllers.WriteSimpleResponse(w, false, err.Error())
return
}
controllers.WriteSimpleResponse(w, true, "federation features saved")
}
// SetFederationActivityPrivate will set if Federation features are private to followers.
func SetFederationActivityPrivate(w http.ResponseWriter, r *http.Request) {
if !requirePOST(w, r) {
return
}
configValue, success := getValueFromRequest(w, r)
if !success {
return
}
if err := data.SetFederationIsPrivate(configValue.Value.(bool)); err != nil {
controllers.WriteSimpleResponse(w, false, err.Error())
return
}
// Update Fediverse followers about this change.
if err := outbox.UpdateFollowersWithAccountUpdates(); err != nil {
controllers.WriteSimpleResponse(w, false, err.Error())
return
}
controllers.WriteSimpleResponse(w, true, "federation private saved")
}
// SetFederationShowEngagement will set if Fedivese engagement shows in chat.
func SetFederationShowEngagement(w http.ResponseWriter, r *http.Request) {
if !requirePOST(w, r) {
return
}
configValue, success := getValueFromRequest(w, r)
if !success {
return
}
if err := data.SetFederationShowEngagement(configValue.Value.(bool)); err != nil {
controllers.WriteSimpleResponse(w, false, err.Error())
return
}
controllers.WriteSimpleResponse(w, true, "federation show engagement saved")
}
// SetFederationUsername will set the local actor username used for federation activities.
func SetFederationUsername(w http.ResponseWriter, r *http.Request) {
if !requirePOST(w, r) {
return
}
configValue, success := getValueFromRequest(w, r)
if !success {
return
}
if err := data.SetFederationUsername(configValue.Value.(string)); err != nil {
controllers.WriteSimpleResponse(w, false, err.Error())
return
}
controllers.WriteSimpleResponse(w, true, "username saved")
}
// SetFederationGoLiveMessage will set the federated message sent when the streamer goes live.
func SetFederationGoLiveMessage(w http.ResponseWriter, r *http.Request) {
if !requirePOST(w, r) {
return
}
configValue, success := getValueFromRequest(w, r)
if !success {
return
}
if err := data.SetFederationGoLiveMessage(configValue.Value.(string)); err != nil {
controllers.WriteSimpleResponse(w, false, err.Error())
return
}
controllers.WriteSimpleResponse(w, true, "message saved")
}
// SetFederationBlockDomains saves a list of domains to block on the Fediverse.
func SetFederationBlockDomains(w http.ResponseWriter, r *http.Request) {
if !requirePOST(w, r) {
return
}
configValues, success := getValuesFromRequest(w, r)
if !success {
controllers.WriteSimpleResponse(w, false, "unable to handle provided domains")
return
}
domainStrings := make([]string, 0)
for _, domain := range configValues {
domainStrings = append(domainStrings, domain.Value.(string))
}
if err := data.SetBlockedFederatedDomains(domainStrings); err != nil {
controllers.WriteSimpleResponse(w, false, err.Error())
return
}
controllers.WriteSimpleResponse(w, true, "saved")
}
// GetFederatedActions will return the saved list of accepted inbound
// federated activities.
func GetFederatedActions(w http.ResponseWriter, r *http.Request) {
activities, err := persistence.GetInboundActivities(100, 0)
if err != nil {
controllers.WriteSimpleResponse(w, false, err.Error())
return
}
controllers.WriteResponse(w, activities)
}

View file

@ -0,0 +1,82 @@
package admin
import (
"encoding/json"
"net/http"
"github.com/owncast/owncast/activitypub/persistence"
"github.com/owncast/owncast/activitypub/requests"
"github.com/owncast/owncast/controllers"
"github.com/owncast/owncast/core/data"
)
// ApproveFollower will approve a federated follow request.
func ApproveFollower(w http.ResponseWriter, r *http.Request) {
if !requirePOST(w, r) {
return
}
type approveFollowerRequest struct {
ActorIRI string `json:"actorIRI"`
Approved bool `json:"approved"`
}
decoder := json.NewDecoder(r.Body)
var approval approveFollowerRequest
if err := decoder.Decode(&approval); err != nil {
controllers.WriteSimpleResponse(w, false, "unable to handle follower state with provided values")
return
}
if approval.Approved {
// Approve a follower
if err := persistence.ApprovePreviousFollowRequest(approval.ActorIRI); err != nil {
controllers.WriteSimpleResponse(w, false, err.Error())
return
}
localAccountName := data.GetDefaultFederationUsername()
follower, err := persistence.GetFollower(approval.ActorIRI)
if err != nil {
controllers.WriteSimpleResponse(w, false, err.Error())
return
}
// Send the approval to the follow requestor.
if err := requests.SendFollowAccept(follower.Inbox, follower.FollowRequestIri, localAccountName); err != nil {
controllers.WriteSimpleResponse(w, false, err.Error())
return
}
} else {
// Remove/block a follower
if err := persistence.BlockOrRejectFollower(approval.ActorIRI); err != nil {
controllers.WriteSimpleResponse(w, false, err.Error())
return
}
}
controllers.WriteSimpleResponse(w, true, "follower updated")
}
// GetPendingFollowRequests will return a list of pending follow requests.
func GetPendingFollowRequests(w http.ResponseWriter, r *http.Request) {
requests, err := persistence.GetPendingFollowRequests()
if err != nil {
controllers.WriteSimpleResponse(w, false, err.Error())
return
}
controllers.WriteResponse(w, requests)
}
// GetBlockedAndRejectedFollowers will return blocked and rejected followers.
func GetBlockedAndRejectedFollowers(w http.ResponseWriter, r *http.Request) {
rejections, err := persistence.GetBlockedAndRejectedFollowers()
if err != nil {
controllers.WriteSimpleResponse(w, false, err.Error())
return
}
controllers.WriteResponse(w, rejections)
}

View file

@ -66,6 +66,14 @@ func GetServerConfig(w http.ResponseWriter, r *http.Request) {
VideoCodec: data.GetVideoCodec(),
ForbiddenUsernames: usernameBlocklist,
SuggestedUsernames: usernameSuggestions,
Federation: federationConfigResponse{
Enabled: data.GetFederationEnabled(),
IsPrivate: data.GetFederationIsPrivate(),
Username: data.GetFederationUsername(),
GoLiveMessage: data.GetFederationGoLiveMessage(),
ShowEngagement: data.GetFederationShowEngagement(),
BlockedDomains: data.GetBlockedFederatedDomains(),
},
}
w.Header().Set("Content-Type", "application/json")
@ -91,6 +99,7 @@ type serverConfigAdminResponse struct {
SupportedCodecs []string `json:"supportedCodecs"`
VideoCodec string `json:"videoCodec"`
ForbiddenUsernames []string `json:"forbiddenUsernames"`
Federation federationConfigResponse `json:"federation"`
SuggestedUsernames []string `json:"suggestedUsernames"`
}
@ -118,3 +127,12 @@ type yp struct {
InstanceURL string `json:"instanceUrl"` // The public URL the directory should link to
YPServiceURL string `json:"-"` // The base URL to the YP API to register with (optional)
}
type federationConfigResponse struct {
Enabled bool `json:"enabled"`
IsPrivate bool `json:"isPrivate"`
Username string `json:"username"`
GoLiveMessage string `json:"goLiveMessage"`
ShowEngagement bool `json:"showEngagement"`
BlockedDomains []string `json:"blockedDomains"`
}

View file

@ -2,8 +2,11 @@ package controllers
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"github.com/owncast/owncast/activitypub"
"github.com/owncast/owncast/config"
"github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/models"
@ -25,6 +28,13 @@ type webConfigResponse struct {
ExternalActions []models.ExternalAction `json:"externalActions"`
CustomStyles string `json:"customStyles"`
MaxSocketPayloadSize int `json:"maxSocketPayloadSize"`
Federation federationConfigResponse `json:"federation"`
}
type federationConfigResponse struct {
Enabled bool `json:"enabled"`
Account string `json:"account,omitempty"`
FollowerCount int `json:"followerCount,omitempty"`
}
// GetWebConfig gets the status of the server.
@ -46,6 +56,21 @@ func GetWebConfig(w http.ResponseWriter, r *http.Request) {
serverSummary := data.GetServerSummary()
serverSummary = utils.RenderPageContentMarkdown(serverSummary)
var federationResponse federationConfigResponse
federationEnabled := data.GetFederationEnabled()
followerCount, _ := activitypub.GetFollowerCount()
if federationEnabled {
serverURLString := data.GetServerURL()
serverURL, _ := url.Parse(serverURLString)
account := fmt.Sprintf("%s@%s", data.GetDefaultFederationUsername(), serverURL.Host)
federationResponse = federationConfigResponse{
Enabled: federationEnabled,
FollowerCount: int(followerCount),
Account: account,
}
}
configuration := webConfigResponse{
Name: data.GetServerName(),
Summary: serverSummary,
@ -60,6 +85,7 @@ func GetWebConfig(w http.ResponseWriter, r *http.Request) {
ExternalActions: data.GetExternalActions(),
CustomStyles: data.GetCustomStyles(),
MaxSocketPayloadSize: config.MaxSocketPayloadSize,
Federation: federationResponse,
}
if err := json.NewEncoder(w).Encode(configuration); err != nil {

18
controllers/followers.go Normal file
View file

@ -0,0 +1,18 @@
package controllers
import (
"net/http"
"github.com/owncast/owncast/activitypub/persistence"
)
// GetFollowers will handle an API request to fetch the list of followers (non-activitypub response).
func GetFollowers(w http.ResponseWriter, r *http.Request) {
followers, err := persistence.GetFederationFollowers(-1, 0)
if err != nil {
WriteSimpleResponse(w, false, "unable to fetch followers")
return
}
WriteResponse(w, followers)
}

View file

@ -35,6 +35,15 @@ type MetadataPage struct {
// IndexHandler handles the default index route.
func IndexHandler(w http.ResponseWriter, r *http.Request) {
middleware.EnableCors(w)
// Treat recordings and schedule as index requests
pathComponents := strings.Split(r.URL.Path, "/")
pathRequest := pathComponents[1]
if pathRequest == "recordings" || pathRequest == "schedule" {
r.URL.Path = "index.html"
}
isIndexRequest := r.URL.Path == "/" || filepath.Base(r.URL.Path) == "index.html" || filepath.Base(r.URL.Path) == ""
// For search engine bots and social scrapers return a special
@ -91,11 +100,11 @@ func handleScraperMetadataPage(w http.ResponseWriter, r *http.Request) {
fullURL, err := url.Parse(fmt.Sprintf("%s://%s%s", scheme, r.Host, r.URL.Path))
if err != nil {
log.Panicln(err)
log.Errorln(err)
}
imageURL, err := url.Parse(fmt.Sprintf("%s://%s%s", scheme, r.Host, "/logo/external"))
if err != nil {
log.Panicln(err)
log.Errorln(err)
}
status := core.GetStatus()
@ -128,6 +137,6 @@ func handleScraperMetadataPage(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
if err := tmpl.Execute(w, metadata); err != nil {
log.Panicln(err)
log.Errorln(err)
}
}

100
controllers/remoteFollow.go Normal file
View file

@ -0,0 +1,100 @@
package controllers
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"github.com/owncast/owncast/core/data"
)
// RemoteFollow handles a request to begin the remote follow redirect flow.
func RemoteFollow(w http.ResponseWriter, r *http.Request) {
type followRequest struct {
Account string `json:"account"`
}
type followResponse struct {
RedirectURL string `json:"redirectUrl"`
}
var request followRequest
decoder := json.NewDecoder(r.Body)
if err := decoder.Decode(&request); err != nil {
WriteSimpleResponse(w, false, "unable to parse request")
return
}
if request.Account == "" {
WriteSimpleResponse(w, false, "Remote Fediverse account is required to follow.")
return
}
localActorPath, _ := url.Parse(data.GetServerURL())
localActorPath.Path = fmt.Sprintf("/federation/user/%s", data.GetDefaultFederationUsername())
var template string
links, err := getWebfingerLinks(request.Account)
if err != nil {
WriteSimpleResponse(w, false, err.Error())
return
}
// Acquire the remote follow redirect template.
for _, link := range links {
for k, v := range link {
if k == "rel" && v == "http://ostatus.org/schema/1.0/subscribe" && link["template"] != nil {
template = link["template"].(string)
}
}
}
if localActorPath.String() == "" || template == "" {
WriteSimpleResponse(w, false, "unable to determine remote follow information for "+request.Account)
return
}
redirectURL := strings.Replace(template, "{uri}", localActorPath.String(), 1)
response := followResponse{
RedirectURL: redirectURL,
}
WriteResponse(w, response)
}
func getWebfingerLinks(account string) ([]map[string]interface{}, error) {
type webfingerResponse struct {
Links []map[string]interface{} `json:"links"`
}
account = strings.TrimLeft(account, "@") // remove any leading @
accountComponents := strings.Split(account, "@")
fediverseServer := accountComponents[1]
// HTTPS is required.
requestURL, err := url.Parse("https://" + fediverseServer)
if err != nil {
return nil, fmt.Errorf("unable to parse fediverse server host %s", fediverseServer)
}
requestURL.Path = "/.well-known/webfinger"
query := requestURL.Query()
query.Add("resource", fmt.Sprintf("acct:%s", account))
requestURL.RawQuery = query.Encode()
response, err := http.DefaultClient.Get(requestURL.String())
if err != nil {
return nil, err
}
defer response.Body.Close()
var links webfingerResponse
decoder := json.NewDecoder(response.Body)
if err := decoder.Decode(&links); err != nil {
return nil, err
}
return links.Links, nil
}

View file

@ -78,12 +78,39 @@ func SendSystemMessage(text string, ephemeral bool) error {
}
if !ephemeral {
saveEvent(message.ID, "system", message.Body, message.GetMessageType(), nil, message.Timestamp)
saveEvent(message.ID, nil, message.Body, message.GetMessageType(), nil, message.Timestamp, nil, nil, nil, nil)
}
return nil
}
// SendFediverseAction will send a message indicating some Fediverse engagement took place.
func SendFediverseAction(eventType string, userAccountName string, image *string, body string, link string) error {
message := events.FediverseEngagementEvent{
Event: events.Event{
Type: eventType,
},
MessageEvent: events.MessageEvent{
Body: body,
},
UserAccountName: userAccountName,
Image: image,
Link: link,
}
message.SetDefaults()
message.RenderBody()
if err := Broadcast(&message); err != nil {
log.Errorln("error sending system message", err)
return err
}
saveFederatedAction(message)
return nil
}
// SendSystemAction will send a system action string as an action event to all clients.
func SendSystemAction(text string, ephemeral bool) error {
message := events.ActionEvent{
@ -100,7 +127,7 @@ func SendSystemAction(text string, ephemeral bool) error {
}
if !ephemeral {
saveEvent(message.ID, "action", message.Body, message.GetMessageType(), nil, message.Timestamp)
saveEvent(message.ID, nil, message.Body, message.GetMessageType(), nil, message.Timestamp, nil, nil, nil, nil)
}
return nil

View file

@ -34,4 +34,10 @@ const (
ErrorMaxConnectionsExceeded EventType = "ERROR_MAX_CONNECTIONS_EXCEEDED"
// ErrorUserDisabled is an error returned when the connecting user has been previously banned/disabled.
ErrorUserDisabled EventType = "ERROR_USER_DISABLED"
// FediverseEngagementFollow is an event representing a follow action that took place on the fediverse.
FediverseEngagementFollow EventType = "FEDIVERSE_ENGAGEMENT_FOLLOW"
// FediverseEngagementLike is an event representing a like action that took place on the fediverse.
FediverseEngagementLike EventType = "FEDIVERSE_ENGAGEMENT_LIKE"
// FediverseEngagementRepost is an event representing a re-post action that took place on the fediverse.
FediverseEngagementRepost EventType = "FEDIVERSE_ENGAGEMENT_REPOST"
)

View file

@ -0,0 +1,32 @@
package events
import "github.com/owncast/owncast/core/data"
// FediverseEngagementEvent is a message displayed in chat on representing an action on the Fediverse.
type FediverseEngagementEvent struct {
Event
MessageEvent
Image *string `json:"image"`
Link string `json:"link"`
UserAccountName string `json:"title"`
}
// GetBroadcastPayload will return the object to send to all chat users.
func (e *FediverseEngagementEvent) GetBroadcastPayload() EventPayload {
return EventPayload{
"id": e.ID,
"timestamp": e.Timestamp,
"body": e.Body,
"image": e.Image,
"type": e.Event.Type,
"title": e.UserAccountName,
"user": EventPayload{
"displayName": data.GetServerName(),
},
}
}
// GetMessageType will return the event type for this message.
func (e *FediverseEngagementEvent) GetMessageType() EventType {
return e.Event.Type
}

View file

@ -34,17 +34,19 @@ 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)
saveEvent(event.ID, &event.User.ID, event.Body, event.Type, event.HiddenAt, event.Timestamp, nil, nil, nil, nil)
}
func saveEvent(id string, userID string, body string, eventType string, hidden *time.Time, timestamp time.Time) {
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
}()
_datastore.DbLock.Lock()
defer _datastore.DbLock.Unlock()
tx, err := _datastore.DB.Begin()
if err != nil {
log.Errorln("error saving", eventType, err)
@ -53,7 +55,7 @@ func saveEvent(id string, userID string, body string, eventType string, hidden *
defer tx.Rollback() // nolint
stmt, err := tx.Prepare("INSERT INTO messages(id, user_id, body, eventType, hidden_at, timestamp) values(?, ?, ?, ?, ?, ?)")
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
@ -61,7 +63,7 @@ func saveEvent(id string, userID string, body string, eventType string, hidden *
defer stmt.Close()
if _, err = stmt.Exec(id, userID, body, eventType, hidden, timestamp); err != nil {
if _, err = stmt.Exec(id, userID, body, eventType, hidden, timestamp, image, link, title, subtitle); err != nil {
log.Errorln("error saving", eventType, err)
return
}
@ -71,8 +73,134 @@ func saveEvent(id string, userID string, body string, eventType string, hidden *
}
}
func getChat(query string) []events.UserMessageEvent {
history := make([]events.UserMessageEvent, 0)
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
}
u := user.User{
ID: *row.userID,
AccessToken: "",
DisplayName: displayName,
DisplayColor: displayColor,
CreatedAt: createdAt,
DisabledAt: row.userDisabledAt,
NameChangedAt: row.userNameChangedAt,
PreviousNames: previousUsernames,
Scopes: strings.Split(scopes, ","),
}
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 {
id string
userID *string
body string
eventType models.EventType
hiddenAt *time.Time
timestamp time.Time
title *string
subtitle *string
image *string
link *string
userDisplayName *string
userDisplayColor *int
userCreatedAt *time.Time
userDisabledAt *time.Time
previousUsernames *string
userNameChangedAt *time.Time
userScopes *string
}
func getChat(query string) []interface{} {
history := make([]interface{}, 0)
rows, err := _datastore.DB.Query(query)
if err != nil || rows.Err() != nil {
log.Errorln("error fetching chat history", err)
@ -81,69 +209,47 @@ func getChat(query string) []events.UserMessageEvent {
defer rows.Close()
for rows.Next() {
var id string
var userID string
var body string
var messageType models.EventType
var hiddenAt *time.Time
var timestamp time.Time
var userDisplayName *string
var userDisplayColor *int
var userCreatedAt *time.Time
var userDisabledAt *time.Time
var previousUsernames *string
var userNameChangedAt *time.Time
row := rowData{}
// Convert a database row into a chat event
err = rows.Scan(&id, &userID, &body, &messageType, &hiddenAt, &timestamp, &userDisplayName, &userDisplayColor, &userCreatedAt, &userDisabledAt, &previousUsernames, &userNameChangedAt)
if err != nil {
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.userScopes,
); err != nil {
log.Errorln("There is a problem converting query to chat objects. Please report this:", query)
break
}
// System messages and chat actions are special and are not from real users
if messageType == events.SystemMessageSent || messageType == events.ChatActionSent {
name := "Owncast"
userDisplayName = &name
color := 200
userDisplayColor = &color
}
var message interface{}
if previousUsernames == nil {
previousUsernames = userDisplayName
}
if userCreatedAt == nil {
now := time.Now()
userCreatedAt = &now
}
user := user.User{
ID: userID,
AccessToken: "",
DisplayName: *userDisplayName,
DisplayColor: *userDisplayColor,
CreatedAt: *userCreatedAt,
DisabledAt: userDisabledAt,
NameChangedAt: userNameChangedAt,
PreviousNames: strings.Split(*previousUsernames, ","),
}
message := events.UserMessageEvent{
Event: events.Event{
Type: messageType,
ID: id,
Timestamp: timestamp,
},
UserEvent: events.UserEvent{
User: &user,
HiddenAt: hiddenAt,
},
MessageEvent: events.MessageEvent{
Body: body,
RawBody: body,
},
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)
@ -152,16 +258,16 @@ func getChat(query string) []events.UserMessageEvent {
return history
}
var _historyCache *[]events.UserMessageEvent
var _historyCache *[]interface{}
// GetChatModerationHistory will return all the chat messages suitable for moderation purposes.
func GetChatModerationHistory() []events.UserMessageEvent {
func GetChatModerationHistory() []interface{} {
if _historyCache != nil {
return *_historyCache
}
// Get all messages regardless of visibility
query := "SELECT messages.id, user_id, body, eventType, hidden_at, timestamp, display_name, display_color, created_at, disabled_at, previous_names, namechanged_at FROM messages INNER JOIN users ON messages.user_id = users.id ORDER BY timestamp DESC"
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, scopes FROM messages INNER JOIN users ON messages.user_id = users.id ORDER BY timestamp DESC"
result := getChat(query)
_historyCache = &result
@ -170,9 +276,9 @@ func GetChatModerationHistory() []events.UserMessageEvent {
}
// GetChatHistory will return all the chat messages suitable for returning as user-facing chat history.
func GetChatHistory() []events.UserMessageEvent {
func GetChatHistory() []interface{} {
// Get all visible messages
query := fmt.Sprintf("SELECT messages.id, user_id, body, eventType, hidden_at, timestamp, display_name, display_color, created_at, disabled_at, previous_names, namechanged_at FROM messages, users WHERE messages.user_id = users.id AND hidden_at IS NULL AND disabled_at IS NULL ORDER BY timestamp DESC LIMIT %d", maxBacklogNumber)
query := fmt.Sprintf("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.scopes FROM messages LEFT JOIN users ON messages.user_id = users.id WHERE hidden_at IS NULL AND disabled_at IS NULL ORDER BY timestamp DESC LIMIT %d", maxBacklogNumber)
m := getChat(query)
// Invert order of messages
@ -200,7 +306,7 @@ func SetMessageVisibilityForUserID(userID string, visible bool) error {
}
for _, message := range messages {
ids = append(ids, message.ID)
ids = append(ids, message.(events.Event).ID)
}
// Tell the clients to hide/show these messages.
@ -250,35 +356,3 @@ func saveMessageVisibility(messageIDs []string, visible bool) error {
return nil
}
// Only keep recent messages so we don't keep more chat data than needed
// for privacy and efficiency reasons.
func runPruner() {
_datastore.DbLock.Lock()
defer _datastore.DbLock.Unlock()
log.Traceln("Removing chat messages older than", maxBacklogHours, "hours")
deleteStatement := `DELETE FROM messages WHERE timestamp <= datetime('now', 'localtime', ?)`
tx, err := _datastore.DB.Begin()
if err != nil {
log.Debugln(err)
return
}
stmt, err := tx.Prepare(deleteStatement)
if err != nil {
log.Debugln(err)
return
}
defer stmt.Close()
if _, err = stmt.Exec(fmt.Sprintf("-%d hours", maxBacklogHours)); err != nil {
log.Debugln(err)
return
}
if err = tx.Commit(); err != nil {
log.Debugln(err)
return
}
}

39
core/chat/pruner.go Normal file
View file

@ -0,0 +1,39 @@
package chat
import (
"fmt"
log "github.com/sirupsen/logrus"
)
// Only keep recent messages so we don't keep more chat data than needed
// for privacy and efficiency reasons.
func runPruner() {
_datastore.DbLock.Lock()
defer _datastore.DbLock.Unlock()
log.Traceln("Removing chat messages older than", maxBacklogHours, "hours")
deleteStatement := `DELETE FROM messages WHERE timestamp <= datetime('now', 'localtime', ?)`
tx, err := _datastore.DB.Begin()
if err != nil {
log.Debugln(err)
return
}
stmt, err := tx.Prepare(deleteStatement)
if err != nil {
log.Debugln(err)
return
}
defer stmt.Close()
if _, err = stmt.Exec(fmt.Sprintf("-%d hours", maxBacklogHours)); err != nil {
log.Debugln(err)
return
}
if err = tx.Commit(); err != nil {
log.Debugln(err)
return
}
}

13
core/data/activitypub.go Normal file
View file

@ -0,0 +1,13 @@
package data
// GetFederatedInboxMap is a mapping between account names and their outbox.
func GetFederatedInboxMap() map[string]string {
return map[string]string{
GetDefaultFederationUsername(): GetDefaultFederationUsername(),
}
}
// GetDefaultFederationUsername will return the username used for sending federation activities.
func GetDefaultFederationUsername() string {
return GetFederationUsername()
}

View file

@ -13,36 +13,47 @@ import (
log "github.com/sirupsen/logrus"
)
const extraContentKey = "extra_page_content"
const streamTitleKey = "stream_title"
const streamKeyKey = "stream_key"
const logoPathKey = "logo_path"
const serverSummaryKey = "server_summary"
const serverWelcomeMessageKey = "server_welcome_message"
const serverNameKey = "server_name"
const serverURLKey = "server_url"
const httpPortNumberKey = "http_port_number"
const httpListenAddressKey = "http_listen_address"
const rtmpPortNumberKey = "rtmp_port_number"
const serverMetadataTagsKey = "server_metadata_tags"
const directoryEnabledKey = "directory_enabled"
const directoryRegistrationKeyKey = "directory_registration_key"
const socialHandlesKey = "social_handles"
const peakViewersSessionKey = "peak_viewers_session"
const peakViewersOverallKey = "peak_viewers_overall"
const lastDisconnectTimeKey = "last_disconnect_time"
const ffmpegPathKey = "ffmpeg_path"
const nsfwKey = "nsfw"
const s3StorageEnabledKey = "s3_storage_enabled"
const s3StorageConfigKey = "s3_storage_config"
const videoLatencyLevel = "video_latency_level"
const videoStreamOutputVariantsKey = "video_stream_output_variants"
const chatDisabledKey = "chat_disabled"
const externalActionsKey = "external_actions"
const customStylesKey = "custom_styles"
const videoCodecKey = "video_codec"
const blockedUsernamesKey = "blocked_usernames"
const suggestedUsernamesKey = "suggested_usernames"
const (
extraContentKey = "extra_page_content"
streamTitleKey = "stream_title"
streamKeyKey = "stream_key"
logoPathKey = "logo_path"
serverSummaryKey = "server_summary"
serverWelcomeMessageKey = "server_welcome_message"
serverNameKey = "server_name"
serverURLKey = "server_url"
httpPortNumberKey = "http_port_number"
httpListenAddressKey = "http_listen_address"
rtmpPortNumberKey = "rtmp_port_number"
serverMetadataTagsKey = "server_metadata_tags"
directoryEnabledKey = "directory_enabled"
directoryRegistrationKeyKey = "directory_registration_key"
socialHandlesKey = "social_handles"
peakViewersSessionKey = "peak_viewers_session"
peakViewersOverallKey = "peak_viewers_overall"
lastDisconnectTimeKey = "last_disconnect_time"
ffmpegPathKey = "ffmpeg_path"
nsfwKey = "nsfw"
s3StorageEnabledKey = "s3_storage_enabled"
s3StorageConfigKey = "s3_storage_config"
videoLatencyLevel = "video_latency_level"
videoStreamOutputVariantsKey = "video_stream_output_variants"
chatDisabledKey = "chat_disabled"
externalActionsKey = "external_actions"
customStylesKey = "custom_styles"
videoCodecKey = "video_codec"
blockedUsernamesKey = "blocked_usernames"
publicKeyKey = "public_key"
privateKeyKey = "private_key"
serverInitDateKey = "server_init_date"
federationEnabledKey = "federation_enabled"
federationUsernameKey = "federation_username"
federationPrivateKey = "federation_private"
federationGoLiveMessageKey = "federation_go_live_message"
federationShowEngagementKey = "federation_show_engagement"
federationBlockedDomainsKey = "federation_blocked_domains"
suggestedUsernamesKey = "suggested_usernames"
)
// GetExtraPageBodyContent will return the user-supplied body content.
func GetExtraPageBodyContent() string {
@ -295,7 +306,7 @@ func GetSocialHandles() []models.SocialHandle {
// SetSocialHandles will set the external social links.
func SetSocialHandles(socialHandles []models.SocialHandle) error {
var configEntry = ConfigEntry{Key: socialHandlesKey, Value: socialHandles}
configEntry := ConfigEntry{Key: socialHandlesKey, Value: socialHandles}
return _datastore.Save(configEntry)
}
@ -350,7 +361,7 @@ func GetLastDisconnectTime() (*utils.NullTime, error) {
// SetLastDisconnectTime will set the time the last stream ended.
func SetLastDisconnectTime(disconnectTime time.Time) error {
savedDisconnectTime := utils.NullTime{Time: disconnectTime, Valid: true}
var configEntry = ConfigEntry{Key: lastDisconnectTimeKey, Value: savedDisconnectTime}
configEntry := ConfigEntry{Key: lastDisconnectTimeKey, Value: savedDisconnectTime}
return _datastore.Save(configEntry)
}
@ -399,7 +410,7 @@ func GetS3Config() models.S3 {
// SetS3Config will set the external storage configuration.
func SetS3Config(config models.S3) error {
var configEntry = ConfigEntry{Key: s3StorageConfigKey, Value: config}
configEntry := ConfigEntry{Key: s3StorageConfigKey, Value: config}
return _datastore.Save(configEntry)
}
@ -457,7 +468,7 @@ func GetStreamOutputVariants() []models.StreamOutputVariant {
// SetStreamOutputVariants will set the stream output variants.
func SetStreamOutputVariants(variants []models.StreamOutputVariant) error {
var configEntry = ConfigEntry{Key: videoStreamOutputVariantsKey, Value: variants}
configEntry := ConfigEntry{Key: videoStreamOutputVariantsKey, Value: variants}
return _datastore.Save(configEntry)
}
@ -493,7 +504,7 @@ func GetExternalActions() []models.ExternalAction {
// SetExternalActions will save external actions.
func SetExternalActions(actions []models.ExternalAction) error {
var configEntry = ConfigEntry{Key: externalActionsKey, Value: actions}
configEntry := ConfigEntry{Key: externalActionsKey, Value: actions}
return _datastore.Save(configEntry)
}
@ -583,7 +594,6 @@ func FindHighestVideoQualityIndex(qualities []models.StreamOutputVariant) int {
// GetForbiddenUsernameList will return the blocked usernames as a comma separated string.
func GetForbiddenUsernameList() []string {
usernameString, err := _datastore.GetString(blockedUsernamesKey)
if err != nil {
return config.DefaultForbiddenUsernames
}
@ -622,3 +632,125 @@ func SetSuggestedUsernamesList(usernames []string) error {
usernameListString := strings.Join(usernames, ",")
return _datastore.SetString(suggestedUsernamesKey, usernameListString)
}
// GetServerInitTime will return when the server was first setup.
func GetServerInitTime() (*utils.NullTime, error) {
var t utils.NullTime
configEntry, err := _datastore.Get(serverInitDateKey)
if err != nil {
return nil, err
}
if err := configEntry.getObject(&t); err != nil {
return nil, err
}
if !t.Valid {
return nil, err
}
return &t, nil
}
// SetServerInitTime will set when the server was first created.
func SetServerInitTime(t time.Time) error {
nt := utils.NullTime{Time: t, Valid: true}
configEntry := ConfigEntry{Key: serverInitDateKey, Value: nt}
return _datastore.Save(configEntry)
}
// SetFederationEnabled will enable federation if set to true.
func SetFederationEnabled(enabled bool) error {
return _datastore.SetBool(federationEnabledKey, enabled)
}
// GetFederationEnabled will return if federation is enabled.
func GetFederationEnabled() bool {
enabled, err := _datastore.GetBool(federationEnabledKey)
if err == nil {
return enabled
}
return false
}
// SetFederationUsername will set the username used in federated activities.
func SetFederationUsername(username string) error {
return _datastore.SetString(federationUsernameKey, username)
}
// GetFederationUsername will return the username used in federated activities.
func GetFederationUsername() string {
username, err := _datastore.GetString(federationUsernameKey)
if username == "" || err != nil {
return config.GetDefaults().FederationUsername
}
return username
}
// SetFederationGoLiveMessage will set the message sent when going live.
func SetFederationGoLiveMessage(message string) error {
return _datastore.SetString(federationGoLiveMessageKey, message)
}
// GetFederationGoLiveMessage will return the message sent when going live.
func GetFederationGoLiveMessage() string {
// Empty message means it's disabled.
message, err := _datastore.GetString(federationGoLiveMessageKey)
if err != nil {
log.Traceln("unable to fetch go live message.", err)
}
return message
}
// SetFederationIsPrivate will set if federation activity is private.
func SetFederationIsPrivate(isPrivate bool) error {
return _datastore.SetBool(federationPrivateKey, isPrivate)
}
// GetFederationIsPrivate will return if federation is private.
func GetFederationIsPrivate() bool {
isPrivate, err := _datastore.GetBool(federationPrivateKey)
if err == nil {
return isPrivate
}
return false
}
// SetFederationShowEngagement will set if fediverse engagement shows in chat.
func SetFederationShowEngagement(showEngagement bool) error {
return _datastore.SetBool(federationShowEngagementKey, showEngagement)
}
// GetFederationShowEngagement will return if fediverse engagement shows in chat.
func GetFederationShowEngagement() bool {
showEngagement, err := _datastore.GetBool(federationShowEngagementKey)
if err == nil {
return showEngagement
}
return true
}
// SetBlockedFederatedDomains will set the blocked federated domains.
func SetBlockedFederatedDomains(domains []string) error {
return _datastore.SetString(federationBlockedDomainsKey, strings.Join(domains, ","))
}
// GetBlockedFederatedDomains will return a list of blocked federated domains.
func GetBlockedFederatedDomains() []string {
domains, err := _datastore.GetString(federationBlockedDomainsKey)
if err != nil {
return []string{}
}
if domains == "" {
return []string{}
}
return strings.Split(domains, ",")
}

23
core/data/crypto.go Normal file
View file

@ -0,0 +1,23 @@
package data
// GetPublicKey will return the public key.
func GetPublicKey() string {
value, _ := _datastore.GetString(publicKeyKey)
return value
}
// SetPublicKey will save the public key.
func SetPublicKey(key string) error {
return _datastore.SetString(publicKeyKey, key)
}
// GetPrivateKey will return the private key.
func GetPrivateKey() string {
value, _ := _datastore.GetString(privateKeyKey)
return value
}
// SetPrivateKey will save the private key.
func SetPrivateKey(key string) error {
return _datastore.SetString(privateKeyKey, key)
}

View file

@ -17,11 +17,13 @@ import (
)
const (
schemaVersion = 1
schemaVersion = 3
)
var _db *sql.DB
var _datastore *Datastore
var (
_db *sql.DB
_datastore *Datastore
)
// GetDatabase will return the shared instance of the actual database.
func GetDatabase() *sql.DB {
@ -35,6 +37,17 @@ func GetStore() *Datastore {
// SetupPersistence will open the datastore and make it available.
func SetupPersistence(file string) error {
// Allow support for in-memory databases for tests.
var db *sql.DB
if file == ":memory:" {
inMemoryDb, err := sql.Open("sqlite3", file)
if err != nil {
log.Fatal(err.Error())
}
db = inMemoryDb
} else {
// Create empty DB file if it doesn't exist.
if !utils.DoesFileExists(file) {
log.Traceln("Creating new database at", file)
@ -45,8 +58,13 @@ func SetupPersistence(file string) error {
}
}
db, err := sql.Open("sqlite3", fmt.Sprintf("file:%s?_cache_size=10000&cache=shared&_journal_mode=WAL", file))
onDiskDb, err := sql.Open("sqlite3", fmt.Sprintf("file:%s?_cache_size=10000&cache=shared&_journal_mode=WAL", file))
if err != nil {
return err
}
db = onDiskDb
db.SetMaxOpenConns(1)
}
_db = db
// Some SQLite optimizations
@ -58,10 +76,6 @@ func SetupPersistence(file string) error {
createWebhooksTable()
createUsersTable(db)
if err != nil {
return err
}
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS config (
"key" string NOT NULL PRIMARY KEY,
"value" TEXT
@ -70,7 +84,7 @@ func SetupPersistence(file string) error {
}
var version int
err = db.QueryRow("SELECT value FROM config WHERE key='version'").
err := db.QueryRow("SELECT value FROM config WHERE key='version'").
Scan(&version)
if err != nil {
if err != sql.ErrNoRows {
@ -117,10 +131,14 @@ func migrateDatabase(db *sql.DB, from, to int) error {
dbBackupFile := filepath.Join(config.BackupDirectory, fmt.Sprintf("owncast-v%d.bak", from))
utils.Backup(db, dbBackupFile)
for v := from; v < to; v++ {
log.Tracef("Migration step from %d to %d\n", v, v+1)
switch v {
case 0:
log.Tracef("Migration step from %d to %d\n", v, v+1)
migrateToSchema1(db)
case 1:
migrateToSchema2(db)
case 2:
migrateToSchema3(db)
default:
log.Fatalln("missing database migration step")
}

View file

@ -14,6 +14,14 @@ func HasPopulatedDefaults() bool {
return hasPopulated
}
func hasPopulatedFederationDefaults() bool {
hasPopulated, err := _datastore.GetBool("HAS_POPULATED_FEDERATION_DEFAULTS")
if err != nil {
return false
}
return hasPopulated
}
// PopulateDefaults will set default values in the database.
func PopulateDefaults() {
defaults := config.GetDefaults()
@ -32,6 +40,7 @@ func PopulateDefaults() {
_ = SetServerName("Owncast")
_ = SetStreamKey(defaults.StreamKey)
_ = SetExtraPageBodyContent("This is your page's content that can be edited in the admin.")
_ = SetFederationGoLiveMessage(defaults.FederationGoLiveMessage)
_ = SetSocialHandles([]models.SocialHandle{
{
Platform: "github",

View file

@ -15,6 +15,10 @@ func CreateMessagesTable(db *sql.DB) {
"eventType" TEXT,
"hidden_at" DATETIME,
"timestamp" DATETIME,
"title" TEXT,
"subtitle" TEXT,
"image" TEXT,
"link" TEXT,
PRIMARY KEY (id)
);CREATE INDEX index ON messages (id, user_id, hidden_at, timestamp);
CREATE INDEX id ON messages (id);

View file

@ -9,6 +9,44 @@ import (
"github.com/teris-io/shortid"
)
func migrateToSchema3(db *sql.DB) {
// Since it's just a backlog of chat messages let's wipe the old messages
// and recreate the table.
// Drop the old messages table
stmt, err := db.Prepare("DROP TABLE messages")
if err != nil {
log.Fatal(err)
}
defer stmt.Close()
_, err = stmt.Exec()
if err != nil {
log.Warnln(err)
}
// Recreate it
CreateMessagesTable(db)
}
func migrateToSchema2(db *sql.DB) {
// Since it's just a backlog of chat messages let's wipe the old messages
// and recreate the table.
// Drop the old messages table
stmt, err := db.Prepare("DROP TABLE messages")
if err != nil {
log.Fatal(err)
}
defer stmt.Close()
_, err = stmt.Exec()
if err != nil {
log.Warnln(err)
}
// Recreate it
CreateMessagesTable(db)
}
func migrateToSchema1(db *sql.DB) {
// Since it's just a backlog of chat messages let's wipe the old messages
// and recreate the table.
@ -100,7 +138,6 @@ func insertAPIToken(db *sql.DB, token string, name string, color int, scopes str
return err
}
stmt, err := tx.Prepare("INSERT INTO users(id, access_token, display_name, display_color, scopes, type) values(?, ?, ?, ?, ?, ?)")
if err != nil {
return err
}

View file

@ -5,9 +5,12 @@ import (
"database/sql"
"encoding/gob"
"sync"
"time"
// sqlite requires a blank import.
_ "github.com/mattn/go-sqlite3"
"github.com/owncast/owncast/config"
"github.com/owncast/owncast/db"
log "github.com/sirupsen/logrus"
)
@ -37,6 +40,11 @@ func (ds *Datastore) warmCache() {
}
}
// GetQueries will return the shared instance of the SQL query generator.
func (ds *Datastore) GetQueries() *db.Queries {
return db.New(ds.DB)
}
// Get will query the database for the key and return the entry.
func (ds *Datastore) Get(key string) (ConfigEntry, error) {
cachedValue, err := ds.GetCachedValue(key)
@ -125,6 +133,20 @@ func (ds *Datastore) Setup() {
if !HasPopulatedDefaults() {
PopulateDefaults()
}
if !hasPopulatedFederationDefaults() {
if err := SetFederationGoLiveMessage(config.GetDefaults().FederationGoLiveMessage); err != nil {
log.Errorln(err)
}
if err := _datastore.SetBool("HAS_POPULATED_FEDERATION_DEFAULTS", true); err != nil {
log.Errorln(err)
}
}
// Set the server initialization date if needed.
if hasSetInitDate, _ := GetServerInitTime(); hasSetInitDate == nil || !hasSetInitDate.Valid {
_ = SetServerInitTime(time.Now())
}
}
// Reset will delete all config entries in the datastore and start over.

View file

@ -24,6 +24,7 @@ func createUsersTable(db *sql.DB) {
PRIMARY KEY (id)
);CREATE INDEX index ON users (id, access_token, disabled_at);
CREATE INDEX id ON users (id);
CREATE INDEX id_disabled ON users (id, disabled_at);
CREATE INDEX access_token ON users (access_token);
CREATE INDEX disabled_at ON USERS (disabled_at);`

View file

@ -1,11 +1,13 @@
package core
import (
"context"
"io"
"time"
log "github.com/sirupsen/logrus"
"github.com/owncast/owncast/activitypub"
"github.com/owncast/owncast/config"
"github.com/owncast/owncast/core/chat"
"github.com/owncast/owncast/core/data"
@ -24,6 +26,8 @@ var _onlineCleanupTicker *time.Ticker
var _currentBroadcast *models.CurrentBroadcast
var _onlineTimerCancelFunc context.CancelFunc
// setStreamAsConnected sets the stream as connected.
func setStreamAsConnected(rtmpOut *io.PipeReader) {
now := utils.NullTime{Time: time.Now(), Valid: true}
@ -66,6 +70,11 @@ func setStreamAsConnected(rtmpOut *io.PipeReader) {
_ = chat.SendSystemAction("Stay tuned, the stream is **starting**!", true)
chat.SendAllWelcomeMessage()
// Send a delayed live Federated message.
if data.GetFederationEnabled() {
_onlineTimerCancelFunc = startFederatedLiveStreamMessageTimer()
}
}
// SetStreamAsDisconnected sets the stream as disconnected.
@ -73,6 +82,10 @@ func SetStreamAsDisconnected() {
_ = chat.SendSystemAction("The stream is ending.", true)
now := utils.NullTime{Time: time.Now(), Valid: true}
if _onlineTimerCancelFunc != nil {
_onlineTimerCancelFunc()
}
_stats.StreamConnected = false
_stats.LastDisconnectTime = &now
_stats.LastConnectTime = nil
@ -147,3 +160,20 @@ func stopOnlineCleanupTimer() {
_onlineCleanupTicker.Stop()
}
}
func startFederatedLiveStreamMessageTimer() context.CancelFunc {
// Send a delayed live Federated message.
c, cancelFunc := context.WithCancel(context.Background())
_onlineTimerCancelFunc = cancelFunc
go func(c context.Context) {
select {
case <-time.After(time.Minute * 2.0):
log.Traceln("Sending Federated Go Live message.")
if err := activitypub.SendLive(); err != nil {
log.Errorln(err)
}
case <-c.Done():
}
}(c)
return cancelFunc
}

View file

@ -110,10 +110,7 @@ func fireThumbnailGenerator(segmentPath string, variantIndex int) error {
log.Errorln(err)
}
// If YP support is enabled also create an animated GIF preview
if data.GetDirectoryEnabled() {
makeAnimatedGifPreview(mostRecentFile, previewGifFile)
}
return nil
}

28
db/README.md Normal file
View file

@ -0,0 +1,28 @@
# SQL Queries
sqlc generates **type-safe code** from SQL. Here's how it works:
1. You define the schema in `schema.sql`.
1. You write your queries in `query.sql` using regular SQL.
1. You run `sqlc generate` to generate Go code with type-safe interfaces to those queries.
1. You write application code that calls the generated code.
Only those who need to create or update SQL queries will need to have `sqlc` installed on their system. **It is not a dependency required to build the codebase.**
## Install sqlc
### Snap
`sudo snap install sqlc`
### Go install
`go install github.com/kyleconroy/sqlc/cmd/sqlc@latest`
### macOS
`brew install sqlc`
### Download a release
Visit <https://github.com/kyleconroy/sqlc/releases> to download a release for your environment.

29
db/db.go Normal file
View file

@ -0,0 +1,29 @@
// Code generated by sqlc. DO NOT EDIT.
package db
import (
"context"
"database/sql"
)
type DBTX interface {
ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
PrepareContext(context.Context, string) (*sql.Stmt, error)
QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
QueryRowContext(context.Context, string, ...interface{}) *sql.Row
}
func New(db DBTX) *Queries {
return &Queries{db: db}
}
type Queries struct {
db DBTX
}
func (q *Queries) WithTx(tx *sql.Tx) *Queries {
return &Queries{
db: tx,
}
}

36
db/models.go Normal file
View file

@ -0,0 +1,36 @@
// Code generated by sqlc. DO NOT EDIT.
package db
import (
"database/sql"
"time"
)
type ApAcceptedActivity struct {
ID int32
Iri string
Actor string
Type string
Timestamp time.Time
}
type ApFollower struct {
Iri string
Inbox string
Name sql.NullString
Username string
Image sql.NullString
Request string
CreatedAt sql.NullTime
ApprovedAt sql.NullTime
DisabledAt sql.NullTime
}
type ApOutbox struct {
Iri string
Value []byte
Type string
CreatedAt sql.NullTime
LiveNotification sql.NullBool
}

57
db/query.sql Normal file
View file

@ -0,0 +1,57 @@
-- Queries added to query.sql must be compiled into Go code with sqlc. Read README.md for details.
-- Federation related queries.
-- name: GetFollowerCount :one
SElECT count(*) FROM ap_followers WHERE approved_at is not null;
-- name: GetLocalPostCount :one
SElECT count(*) FROM ap_outbox;
-- name: GetFederationFollowersWithOffset :many
SELECT iri, inbox, name, username, image, created_at FROM ap_followers WHERE approved_at is not null LIMIT $1 OFFSET $2;
-- name: GetRejectedAndBlockedFollowers :many
SELECT iri, name, username, image, created_at, disabled_at FROM ap_followers WHERE disabled_at is not null;
-- name: GetFederationFollowerApprovalRequests :many
SELECT iri, inbox, name, username, image, created_at FROM ap_followers WHERE approved_at IS null AND disabled_at is null;
-- name: ApproveFederationFollower :exec
UPDATE ap_followers SET approved_at = $1, disabled_at = null WHERE iri = $2;
-- name: RejectFederationFollower :exec
UPDATE ap_followers SET approved_at = null, disabled_at = $1 WHERE iri = $2;
-- name: GetFollowerByIRI :one
SELECT iri, inbox, name, username, image, request, created_at, approved_at, disabled_at FROM ap_followers WHERE iri = $1;
-- name: GetOutboxWithOffset :many
SELECT value FROM ap_outbox LIMIT $1 OFFSET $2;
-- name: GetObjectFromOutboxByID :one
SELECT value FROM ap_outbox WHERE iri = $1;
-- name: GetObjectFromOutboxByIRI :one
SELECT value, live_notification, created_at FROM ap_outbox WHERE iri = $1;
-- name: RemoveFollowerByIRI :exec
DELETE FROM ap_followers WHERE iri = $1;
-- name: AddFollower :exec
INSERT INTO ap_followers(iri, inbox, request, name, username, image, approved_at) values($1, $2, $3, $4, $5, $6, $7);
-- name: AddToOutbox :exec
INSERT INTO ap_outbox(iri, value, type, live_notification) values($1, $2, $3, $4);
-- name: AddToAcceptedActivities :exec
INSERT INTO ap_accepted_activities(iri, actor, type, timestamp) values($1, $2, $3, $4);
-- name: GetInboundActivitiesWithOffset :many
SELECT iri, actor, type, timestamp FROM ap_accepted_activities ORDER BY timestamp DESC LIMIT $1 OFFSET $2;
-- name: DoesInboundActivityExist :one
SELECT count(*) FROM ap_accepted_activities WHERE iri = $1 AND actor = $2 AND TYPE = $3;
-- name: UpdateFollowerByIRI :exec
UPDATE ap_followers SET inbox = $1, name = $2, username = $3, image = $4 WHERE iri = $5;

441
db/query.sql.go Normal file
View file

@ -0,0 +1,441 @@
// Code generated by sqlc. DO NOT EDIT.
// source: query.sql
package db
import (
"context"
"database/sql"
"time"
)
const addFollower = `-- name: AddFollower :exec
INSERT INTO ap_followers(iri, inbox, request, name, username, image, approved_at) values($1, $2, $3, $4, $5, $6, $7)
`
type AddFollowerParams struct {
Iri string
Inbox string
Request string
Name sql.NullString
Username string
Image sql.NullString
ApprovedAt sql.NullTime
}
func (q *Queries) AddFollower(ctx context.Context, arg AddFollowerParams) error {
_, err := q.db.ExecContext(ctx, addFollower,
arg.Iri,
arg.Inbox,
arg.Request,
arg.Name,
arg.Username,
arg.Image,
arg.ApprovedAt,
)
return err
}
const addToAcceptedActivities = `-- name: AddToAcceptedActivities :exec
INSERT INTO ap_accepted_activities(iri, actor, type, timestamp) values($1, $2, $3, $4)
`
type AddToAcceptedActivitiesParams struct {
Iri string
Actor string
Type string
Timestamp time.Time
}
func (q *Queries) AddToAcceptedActivities(ctx context.Context, arg AddToAcceptedActivitiesParams) error {
_, err := q.db.ExecContext(ctx, addToAcceptedActivities,
arg.Iri,
arg.Actor,
arg.Type,
arg.Timestamp,
)
return err
}
const addToOutbox = `-- name: AddToOutbox :exec
INSERT INTO ap_outbox(iri, value, type, live_notification) values($1, $2, $3, $4)
`
type AddToOutboxParams struct {
Iri string
Value []byte
Type string
LiveNotification sql.NullBool
}
func (q *Queries) AddToOutbox(ctx context.Context, arg AddToOutboxParams) error {
_, err := q.db.ExecContext(ctx, addToOutbox,
arg.Iri,
arg.Value,
arg.Type,
arg.LiveNotification,
)
return err
}
const approveFederationFollower = `-- name: ApproveFederationFollower :exec
UPDATE ap_followers SET approved_at = $1, disabled_at = null WHERE iri = $2
`
type ApproveFederationFollowerParams struct {
ApprovedAt sql.NullTime
Iri string
}
func (q *Queries) ApproveFederationFollower(ctx context.Context, arg ApproveFederationFollowerParams) error {
_, err := q.db.ExecContext(ctx, approveFederationFollower, arg.ApprovedAt, arg.Iri)
return err
}
const doesInboundActivityExist = `-- name: DoesInboundActivityExist :one
SELECT count(*) FROM ap_accepted_activities WHERE iri = $1 AND actor = $2 AND TYPE = $3
`
type DoesInboundActivityExistParams struct {
Iri string
Actor string
Type string
}
func (q *Queries) DoesInboundActivityExist(ctx context.Context, arg DoesInboundActivityExistParams) (int64, error) {
row := q.db.QueryRowContext(ctx, doesInboundActivityExist, arg.Iri, arg.Actor, arg.Type)
var count int64
err := row.Scan(&count)
return count, err
}
const getFederationFollowerApprovalRequests = `-- name: GetFederationFollowerApprovalRequests :many
SELECT iri, inbox, name, username, image, created_at FROM ap_followers WHERE approved_at IS null AND disabled_at is null
`
type GetFederationFollowerApprovalRequestsRow struct {
Iri string
Inbox string
Name sql.NullString
Username string
Image sql.NullString
CreatedAt sql.NullTime
}
func (q *Queries) GetFederationFollowerApprovalRequests(ctx context.Context) ([]GetFederationFollowerApprovalRequestsRow, error) {
rows, err := q.db.QueryContext(ctx, getFederationFollowerApprovalRequests)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetFederationFollowerApprovalRequestsRow
for rows.Next() {
var i GetFederationFollowerApprovalRequestsRow
if err := rows.Scan(
&i.Iri,
&i.Inbox,
&i.Name,
&i.Username,
&i.Image,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getFederationFollowersWithOffset = `-- name: GetFederationFollowersWithOffset :many
SELECT iri, inbox, name, username, image, created_at FROM ap_followers WHERE approved_at is not null LIMIT $1 OFFSET $2
`
type GetFederationFollowersWithOffsetParams struct {
Limit int32
Offset int32
}
type GetFederationFollowersWithOffsetRow struct {
Iri string
Inbox string
Name sql.NullString
Username string
Image sql.NullString
CreatedAt sql.NullTime
}
func (q *Queries) GetFederationFollowersWithOffset(ctx context.Context, arg GetFederationFollowersWithOffsetParams) ([]GetFederationFollowersWithOffsetRow, error) {
rows, err := q.db.QueryContext(ctx, getFederationFollowersWithOffset, arg.Limit, arg.Offset)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetFederationFollowersWithOffsetRow
for rows.Next() {
var i GetFederationFollowersWithOffsetRow
if err := rows.Scan(
&i.Iri,
&i.Inbox,
&i.Name,
&i.Username,
&i.Image,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getFollowerByIRI = `-- name: GetFollowerByIRI :one
SELECT iri, inbox, name, username, image, request, created_at, approved_at, disabled_at FROM ap_followers WHERE iri = $1
`
func (q *Queries) GetFollowerByIRI(ctx context.Context, iri string) (ApFollower, error) {
row := q.db.QueryRowContext(ctx, getFollowerByIRI, iri)
var i ApFollower
err := row.Scan(
&i.Iri,
&i.Inbox,
&i.Name,
&i.Username,
&i.Image,
&i.Request,
&i.CreatedAt,
&i.ApprovedAt,
&i.DisabledAt,
)
return i, err
}
const getFollowerCount = `-- name: GetFollowerCount :one
SElECT count(*) FROM ap_followers WHERE approved_at is not null
`
// Queries added to query.sql must be compiled into Go code with sqlc. Read README.md for details.
// Federation related queries.
func (q *Queries) GetFollowerCount(ctx context.Context) (int64, error) {
row := q.db.QueryRowContext(ctx, getFollowerCount)
var count int64
err := row.Scan(&count)
return count, err
}
const getInboundActivitiesWithOffset = `-- name: GetInboundActivitiesWithOffset :many
SELECT iri, actor, type, timestamp FROM ap_accepted_activities ORDER BY timestamp DESC LIMIT $1 OFFSET $2
`
type GetInboundActivitiesWithOffsetParams struct {
Limit int32
Offset int32
}
type GetInboundActivitiesWithOffsetRow struct {
Iri string
Actor string
Type string
Timestamp time.Time
}
func (q *Queries) GetInboundActivitiesWithOffset(ctx context.Context, arg GetInboundActivitiesWithOffsetParams) ([]GetInboundActivitiesWithOffsetRow, error) {
rows, err := q.db.QueryContext(ctx, getInboundActivitiesWithOffset, arg.Limit, arg.Offset)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetInboundActivitiesWithOffsetRow
for rows.Next() {
var i GetInboundActivitiesWithOffsetRow
if err := rows.Scan(
&i.Iri,
&i.Actor,
&i.Type,
&i.Timestamp,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getLocalPostCount = `-- name: GetLocalPostCount :one
SElECT count(*) FROM ap_outbox
`
func (q *Queries) GetLocalPostCount(ctx context.Context) (int64, error) {
row := q.db.QueryRowContext(ctx, getLocalPostCount)
var count int64
err := row.Scan(&count)
return count, err
}
const getObjectFromOutboxByID = `-- name: GetObjectFromOutboxByID :one
SELECT value FROM ap_outbox WHERE iri = $1
`
func (q *Queries) GetObjectFromOutboxByID(ctx context.Context, iri string) ([]byte, error) {
row := q.db.QueryRowContext(ctx, getObjectFromOutboxByID, iri)
var value []byte
err := row.Scan(&value)
return value, err
}
const getObjectFromOutboxByIRI = `-- name: GetObjectFromOutboxByIRI :one
SELECT value, live_notification, created_at FROM ap_outbox WHERE iri = $1
`
type GetObjectFromOutboxByIRIRow struct {
Value []byte
LiveNotification sql.NullBool
CreatedAt sql.NullTime
}
func (q *Queries) GetObjectFromOutboxByIRI(ctx context.Context, iri string) (GetObjectFromOutboxByIRIRow, error) {
row := q.db.QueryRowContext(ctx, getObjectFromOutboxByIRI, iri)
var i GetObjectFromOutboxByIRIRow
err := row.Scan(&i.Value, &i.LiveNotification, &i.CreatedAt)
return i, err
}
const getOutboxWithOffset = `-- name: GetOutboxWithOffset :many
SELECT value FROM ap_outbox LIMIT $1 OFFSET $2
`
type GetOutboxWithOffsetParams struct {
Limit int32
Offset int32
}
func (q *Queries) GetOutboxWithOffset(ctx context.Context, arg GetOutboxWithOffsetParams) ([][]byte, error) {
rows, err := q.db.QueryContext(ctx, getOutboxWithOffset, arg.Limit, arg.Offset)
if err != nil {
return nil, err
}
defer rows.Close()
var items [][]byte
for rows.Next() {
var value []byte
if err := rows.Scan(&value); err != nil {
return nil, err
}
items = append(items, value)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getRejectedAndBlockedFollowers = `-- name: GetRejectedAndBlockedFollowers :many
SELECT iri, name, username, image, created_at, disabled_at FROM ap_followers WHERE disabled_at is not null
`
type GetRejectedAndBlockedFollowersRow struct {
Iri string
Name sql.NullString
Username string
Image sql.NullString
CreatedAt sql.NullTime
DisabledAt sql.NullTime
}
func (q *Queries) GetRejectedAndBlockedFollowers(ctx context.Context) ([]GetRejectedAndBlockedFollowersRow, error) {
rows, err := q.db.QueryContext(ctx, getRejectedAndBlockedFollowers)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetRejectedAndBlockedFollowersRow
for rows.Next() {
var i GetRejectedAndBlockedFollowersRow
if err := rows.Scan(
&i.Iri,
&i.Name,
&i.Username,
&i.Image,
&i.CreatedAt,
&i.DisabledAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const rejectFederationFollower = `-- name: RejectFederationFollower :exec
UPDATE ap_followers SET approved_at = null, disabled_at = $1 WHERE iri = $2
`
type RejectFederationFollowerParams struct {
DisabledAt sql.NullTime
Iri string
}
func (q *Queries) RejectFederationFollower(ctx context.Context, arg RejectFederationFollowerParams) error {
_, err := q.db.ExecContext(ctx, rejectFederationFollower, arg.DisabledAt, arg.Iri)
return err
}
const removeFollowerByIRI = `-- name: RemoveFollowerByIRI :exec
DELETE FROM ap_followers WHERE iri = $1
`
func (q *Queries) RemoveFollowerByIRI(ctx context.Context, iri string) error {
_, err := q.db.ExecContext(ctx, removeFollowerByIRI, iri)
return err
}
const updateFollowerByIRI = `-- name: UpdateFollowerByIRI :exec
UPDATE ap_followers SET inbox = $1, name = $2, username = $3, image = $4 WHERE iri = $5
`
type UpdateFollowerByIRIParams struct {
Inbox string
Name sql.NullString
Username string
Image sql.NullString
Iri string
}
func (q *Queries) UpdateFollowerByIRI(ctx context.Context, arg UpdateFollowerByIRIParams) error {
_, err := q.db.ExecContext(ctx, updateFollowerByIRI,
arg.Inbox,
arg.Name,
arg.Username,
arg.Image,
arg.Iri,
)
return err
}

37
db/schema.sql Normal file
View file

@ -0,0 +1,37 @@
-- Schema update to query.sql must be referenced in queries located in query.sql
-- and compiled into code with sqlc. Read README.md for details.
CREATE TABLE IF NOT EXISTS ap_followers (
"iri" TEXT NOT NULL,
"inbox" TEXT NOT NULL,
"name" TEXT,
"username" TEXT NOT NULL,
"image" TEXT,
"request" TEXT NOT NULL,
"created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
"approved_at" TIMESTAMP,
"disabled_at" TIMESTAMP,
PRIMARY KEY (iri));
CREATE INDEX iri_index ON ap_followers (iri);
CREATE INDEX approved_at_index ON ap_followers (approved_at);
CREATE TABLE IF NOT EXISTS ap_outbox (
"iri" TEXT NOT NULL,
"value" BLOB,
"type" TEXT NOT NULL,
"created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
"live_notification" BOOLEAN DEFAULT FALSE,
PRIMARY KEY (iri));
CREATE INDEX iri ON ap_outbox (iri);
CREATE INDEX type ON ap_outbox (type);
CREATE INDEX live_notification ON ap_outbox (live_notification);
CREATE TABLE IF NOT EXISTS ap_accepted_activities (
"id" INTEGER NOT NULL PRIMARY KEY,
"iri" TEXT NOT NULL,
"actor" TEXT NOT NULL,
"type" TEXT NOT NULL,
"timestamp" TIMESTAMP NOT NULL
);
CREATE INDEX iri_actor_index ON ap_accepted_activities (iri,actor);

9
go.mod
View file

@ -5,6 +5,9 @@ go 1.17
require (
github.com/amalfra/etag v0.0.0-20190921100247-cafc8de96bc5
github.com/aws/aws-sdk-go v1.42.0
github.com/go-fed/activity v1.0.1-0.20210803212804-d866ba75dd0f
github.com/go-fed/httpsig v1.1.0
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/gorilla/websocket v1.4.2
github.com/grafov/m3u8 v0.11.1
github.com/lestrrat-go/file-rotatelogs v2.4.0+incompatible
@ -26,18 +29,20 @@ require (
require (
github.com/aymerick/douceur v0.2.0 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/gorilla/css v1.0.0 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/jonboulle/clockwork v0.2.2 // indirect
github.com/lestrrat-go/strftime v1.0.4 // indirect
github.com/mvdan/xurls v1.1.0 // indirect
github.com/oschwald/maxminddb-golang v1.8.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pkg/errors v0.9.1
github.com/tklauser/go-sysconf v0.3.5 // indirect
github.com/tklauser/numcpus v0.2.2 // indirect
github.com/yusufpapurcu/wmi v1.2.2 // indirect
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 // indirect
golang.org/x/net v0.0.0-20210614182718-04defd469f4e // indirect
golang.org/x/sys v0.0.0-20210514084401-e8d321eab015 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
)
replace github.com/go-fed/activity => github.com/owncast/activity v1.0.1-0.20211229051252-7821289d4026

15
go.sum
View file

@ -4,11 +4,18 @@ github.com/aws/aws-sdk-go v1.42.0 h1:BMZws0t8NAhHFsfnT3B40IwD13jVDG5KerlRksctVIw
github.com/aws/aws-sdk-go v1.42.0/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/dave/jennifer v1.3.0/go.mod h1:fIb+770HOpJ2fmN9EPPKOqm1vMGhB+TwXKMZhrIygKg=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-fed/httpsig v0.1.1-0.20190914113940-c2de3672e5b5/go.mod h1:T56HUNYZUQ1AGUzhAYPugZfp36sKApVnGBgKlIY+aIE=
github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=
github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-test/deep v1.0.1 h1:UQhStjbkDClarlmv0am7OXXO4/GaPdCGiUiMTvi28sg=
github.com/go-test/deep v1.0.1/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
@ -42,6 +49,10 @@ github.com/oschwald/geoip2-golang v1.5.0 h1:igg2yQIrrcRccB1ytFXqBfOHCjXWIoMv85lV
github.com/oschwald/geoip2-golang v1.5.0/go.mod h1:xdvYt5xQzB8ORWFqPnqMwZpCpgNagttWdoZLlJQzg7s=
github.com/oschwald/maxminddb-golang v1.8.0 h1:Uh/DSnGoxsyp/KYbY1AuP0tYEwfs0sCph9p/UMXK/Hk=
github.com/oschwald/maxminddb-golang v1.8.0/go.mod h1:RXZtst0N6+FY/3qCNmZMBApR19cdQj43/NM9VkrNAis=
github.com/owncast/activity v1.0.1-0.20210908225327-e46ee45ec09c h1:lk78BK8sLYn8nwy4ZZdQqcRdkagxQI//wF/DXuxsg1Y=
github.com/owncast/activity v1.0.1-0.20210908225327-e46ee45ec09c/go.mod h1:v4QoPaAzjWZ8zN2VFVGL5ep9C02mst0hQYHUpQwso4Q=
github.com/owncast/activity v1.0.1-0.20211229051252-7821289d4026 h1:E1nxiX44BcMQTSSs8MHLm2rXnqXNedYZkFI31gXMsJc=
github.com/owncast/activity v1.0.1-0.20211229051252-7821289d4026/go.mod h1:v4QoPaAzjWZ8zN2VFVGL5ep9C02mst0hQYHUpQwso4Q=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@ -73,8 +84,11 @@ github.com/yuin/goldmark v1.4.4 h1:zNWRjYUW32G9KirMXYHQHVNFkXvMI7LpgNW2AgYAoIs=
github.com/yuin/goldmark v1.4.4/go.mod h1:rmuwmfZ0+bvzB24eSC//bk1R1Zp3hM0OXYv/G2LIilg=
github.com/yusufpapurcu/wmi v1.2.2 h1:KBNDSne4vP5mbSWnJbO+51IMOXJB67QiYCSBrubbPRg=
github.com/yusufpapurcu/wmi v1.2.2/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
golang.org/x/crypto v0.0.0-20180527072434-ab813273cd59/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/mod v0.5.1 h1:OJxoQ/rynoF0dcCdI7cLPktw/hR2cueqYfjm43oqK38=
golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
@ -83,6 +97,7 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL
golang.org/x/net v0.0.0-20210614182718-04defd469f4e h1:XpT3nA5TvE525Ne3hInMh6+GETgn27Zfm9dxsThnX2Q=
golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180525142821-c11f84a56e43/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=

View file

@ -0,0 +1,12 @@
package models
import "time"
// FederatedActivity is an internal representation of an activity that was
// accepted and stored.
type FederatedActivity struct {
IRI string `json:"iri"`
ActorIRI string `json:"actorIRI"`
Type string `json:"type"`
Timestamp time.Time `json:"timestamp"`
}

21
models/follower.go Normal file
View file

@ -0,0 +1,21 @@
package models
import "github.com/owncast/owncast/utils"
// Follower is our internal representation of a single follower within Owncast.
type Follower struct {
// ActorIRI is the IRI of the remote actor.
ActorIRI string `json:"link"`
// Inbox is the inbox URL of the remote follower
Inbox string `json:"-"`
// Name is the display name of the follower.
Name string `json:"name"`
// Username is the account username of the remote actor.
Username string `json:"username"`
// Image is the avatar image of the follower.
Image string `json:"image"`
// Timestamp is when this follow request was created.
Timestamp utils.NullTime `json:"timestamp,omitempty"`
// DisabledAt is when this follower was rejected or disabled.
DisabledAt utils.NullTime `json:"disabledAt,omitempty"`
}

View file

@ -0,0 +1,46 @@
package middleware
import (
"net/http"
"strings"
"github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/utils"
)
// RequireActivityPubOrRedirect will validate the requested content types and
// redirect to the main Owncast page if it doesn't match.
func RequireActivityPubOrRedirect(handler http.HandlerFunc) http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !data.GetFederationEnabled() {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
handleAccepted := func() {
handler(w, r)
}
acceptedContentTypes := []string{"application/json", "application/json+ld", "application/activity+json"}
acceptString := r.Header.Get("Accept")
accept := strings.Split(acceptString, ",")
for _, singleType := range accept {
if _, accepted := utils.FindInSlice(acceptedContentTypes, strings.TrimSpace(singleType)); accepted {
handleAccepted()
return
}
}
contentTypeString := r.Header.Get("Content-Type")
contentTypes := strings.Split(contentTypeString, ",")
for _, singleType := range contentTypes {
if _, accepted := utils.FindInSlice(acceptedContentTypes, strings.TrimSpace(singleType)); accepted {
handleAccepted()
return
}
}
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
})
}

View file

@ -21,7 +21,7 @@ func SetHeaders(w http.ResponseWriter) {
}
// Content security policy
csp := []string{
fmt.Sprintf("script-src 'self' %s 'sha256-2HPCfJIJHnY0NrRDPTOdC7AOSJIcQyNxzUuut3TsYRY=' 'sha256-PzXGlTLvNFZ7et6GkP2nD3XuSaAKQVBSYiHzU2ZKm8o=' 'sha256-/wqazZOqIpFSIrNVseblbKCXrezG73X7CMqRSTf+8zw=' 'sha256-jCj2f+ICtd8fvdb0ngc+Hkr/ZnZOMvNkikno/XR6VZs='", unsafeEval),
fmt.Sprintf("script-src 'self' %s 'sha256-rnxPrBaD0OuYxsCdrll4QJwtDLcBJqFh0u27CoX5jZ8=' 'sha256-PzXGlTLvNFZ7et6GkP2nD3XuSaAKQVBSYiHzU2ZKm8o=' 'sha256-/wqazZOqIpFSIrNVseblbKCXrezG73X7CMqRSTf+8zw=' 'sha256-jCj2f+ICtd8fvdb0ngc+Hkr/ZnZOMvNkikno/XR6VZs='", unsafeEval),
"worker-src 'self' blob:", // No single quotes around blob:
}
w.Header().Set("Content-Security-Policy", strings.Join(csp, "; "))

View file

@ -6,10 +6,12 @@ import (
log "github.com/sirupsen/logrus"
"github.com/owncast/owncast/activitypub"
"github.com/owncast/owncast/config"
"github.com/owncast/owncast/controllers"
"github.com/owncast/owncast/controllers/admin"
"github.com/owncast/owncast/core/chat"
"github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/core/user"
"github.com/owncast/owncast/router/middleware"
"github.com/owncast/owncast/utils"
@ -20,6 +22,8 @@ import (
func Start() error {
// static files
http.HandleFunc("/", controllers.IndexHandler)
http.HandleFunc("/recordings", controllers.IndexHandler)
http.HandleFunc("/schedule", controllers.IndexHandler)
// admin static files
http.HandleFunc("/admin/", middleware.RequireAdminAuth(admin.ServeAdmin))
@ -69,6 +73,12 @@ func Start() error {
// register a new chat user
http.HandleFunc("/api/chat/register", controllers.RegisterAnonymousChatUser)
// return remote follow details
http.HandleFunc("/api/remotefollow", controllers.RemoteFollow)
// return followers
http.HandleFunc("/api/followers", controllers.GetFollowers)
// Authenticated admin requests
// Current inbound broadcaster
@ -116,6 +126,18 @@ func Start() error {
// Get a list of moderator users
http.HandleFunc("/api/admin/chat/users/moderators", middleware.RequireAdminAuth(admin.GetModerators))
// return followers
http.HandleFunc("/api/admin/followers", middleware.RequireAdminAuth(controllers.GetFollowers))
// Get a list of pending follow requests
http.HandleFunc("/api/admin/followers/pending", middleware.RequireAdminAuth(admin.GetPendingFollowRequests))
// Get a list of rejected or blocked follows
http.HandleFunc("/api/admin/followers/blocked", middleware.RequireAdminAuth(admin.GetBlockedAndRejectedFollowers))
// Set the following state of a follower or follow request.
http.HandleFunc("/api/admin/followers/approve", middleware.RequireAdminAuth(admin.ApproveFollower))
// Update config values
// Change the current streaming key in memory
@ -257,6 +279,34 @@ func Start() error {
// Enable/disable a user
http.HandleFunc("/api/chat/users/setenabled", middleware.RequireUserModerationScopeAccesstoken(admin.UpdateUserEnabled))
// Configure Federation features
// enable/disable federation features
http.HandleFunc("/api/admin/config/federation/enable", middleware.RequireAdminAuth(admin.SetFederationEnabled))
// set if federation activities are private
http.HandleFunc("/api/admin/config/federation/private", middleware.RequireAdminAuth(admin.SetFederationActivityPrivate))
// set if fediverse engagement appears in chat
http.HandleFunc("/api/admin/config/federation/showengagement", middleware.RequireAdminAuth(admin.SetFederationShowEngagement))
// set local federated username
http.HandleFunc("/api/admin/config/federation/username", middleware.RequireAdminAuth(admin.SetFederationUsername))
// set federated go live message
http.HandleFunc("/api/admin/config/federation/livemessage", middleware.RequireAdminAuth(admin.SetFederationGoLiveMessage))
// Federation blocked domains
http.HandleFunc("/api/admin/config/federation/blockdomains", middleware.RequireAdminAuth(admin.SetFederationBlockDomains))
// send a public message to the Fediverse from the server's user
http.HandleFunc("/api/admin/federation/send", middleware.RequireAdminAuth(admin.SendFederatedMessage))
// Return federated activities
http.HandleFunc("/api/admin/federation/actions", middleware.RequireAdminAuth(admin.GetFederatedActions))
// ActivityPub has its own router
activitypub.Start(data.GetDatastore())
// websocket
http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {

9
sqlc.json Normal file
View file

@ -0,0 +1,9 @@
{
"version": "1",
"packages": [{
"schema": "db/schema.sql",
"queries": "db/query.sql",
"name": "db",
"path": "db"
}]
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
self.__BUILD_MANIFEST=function(s,c,e,a,t,i,n,f,d,o,h,b,g,u){return{__rewrites:{beforeFiles:[],afterFiles:[],fallback:[]},"/":[s,c,e,a,t,i,f,"static/chunks/59-bb3486f7473684cf.js","static/chunks/pages/index-e81f0bed001d207c.js"],"/_error":["static/chunks/pages/_error-a3f18418a2205cb8.js"],"/access-tokens":[s,c,"static/chunks/pages/access-tokens-d3e9e11b79321dbb.js"],"/actions":[s,"static/chunks/pages/actions-6889c0d5d40aa70e.js"],"/chat/messages":[d,s,c,i,o,"static/chunks/pages/chat/messages-dc2695d5bac28933.js"],"/chat/users":[d,s,c,e,i,o,"static/chunks/pages/chat/users-4426bd981b718014.js"],"/config-chat":["static/chunks/pages/config-chat-3f47c2e436acea43.js"],"/config-federation":["static/chunks/674-fd7f35cd345c7a4b.js","static/chunks/pages/config-federation-ddff59205ab33383.js"],"/config-public-details":[s,n,"static/css/4da23ced01517a16.css","static/chunks/589-57c6e66ff27bec68.js",h,"static/chunks/pages/config-public-details-02c80e54bdb91852.js"],"/config-server-details":[b,"static/chunks/pages/config-server-details-40fd225da49d9b45.js"],"/config-social-items":[s,h,"static/chunks/pages/config-social-items-9ecbdce4f557ac9b.js"],"/config-storage":["static/chunks/473-2f8a49a631089460.js","static/chunks/pages/config-storage-4b8f9ff84ca4aa30.js"],"/config-video":[s,b,"static/chunks/556-4bf62bd783267914.js","static/chunks/pages/config-video-c250bf8f88dd1d1b.js"],"/federation/actions":[s,c,"static/chunks/pages/federation/actions-a817c8d84eb2e1bf.js"],"/federation/followers":[s,c,e,"static/chunks/pages/federation/followers-73207b872f42b7a6.js"],"/hardware-info":[g,c,e,a,t,n,u,"static/chunks/pages/hardware-info-4dcdf4aa6510006e.js"],"/help":[e,a,"static/chunks/132-69ec1de6a8e27de6.js","static/chunks/pages/help-3dd6da50dde27e48.js"],"/logs":[s,c,f,"static/chunks/pages/logs-d10676db469afea0.js"],"/upgrade":[s,"static/chunks/275-35d1a6aef8ecf26a.js","static/chunks/pages/upgrade-6c4ec4a032ab7232.js"],"/viewer-info":[g,c,e,a,t,n,u,"static/chunks/pages/viewer-info-a9586b5d2ecea7e8.js"],"/webhooks":[s,"static/chunks/pages/webhooks-8b96f4afcc72aba4.js"],sortedPages:["/","/_app","/_error","/access-tokens","/actions","/chat/messages","/chat/users","/config-chat","/config-federation","/config-public-details","/config-server-details","/config-social-items","/config-storage","/config-video","/federation/actions","/federation/followers","/hardware-info","/help","/logs","/upgrade","/viewer-info","/webhooks"]}}("static/chunks/829-c8d1f3db438c210b.js","static/chunks/91-5f5f536776e2d9c6.js","static/chunks/961-1db4468ca0742ea4.js","static/chunks/751-f932ff7ec3e1342a.js","static/chunks/763-6084d4b3c149b8f4.js","static/chunks/533-2f63c37b8986cca1.js","static/chunks/910-ed07ccf32f311d03.js","static/chunks/429-613793ce22468b22.js","static/chunks/29107295-2c3ce868677a27a4.js","static/chunks/464-deae2b2f674a34de.js","static/chunks/17-8c3836887f4f3962.js","static/chunks/578-b2fdca9619a3031e.js","static/chunks/36bcf0ca-c1f70baa5cd8cbbf.js","static/chunks/958-d85597c88a5651f8.js"),self.__BUILD_MANIFEST_CB&&self.__BUILD_MANIFEST_CB();

View file

@ -0,0 +1 @@
self.__MIDDLEWARE_MANIFEST=[];self.__MIDDLEWARE_MANIFEST_CB&&self.__MIDDLEWARE_MANIFEST_CB()

View file

@ -0,0 +1 @@
self.__SSG_MANIFEST=new Set,self.__SSG_MANIFEST_CB&&self.__SSG_MANIFEST_CB();

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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