mirror of
https://github.com/owncast/owncast.git
synced 2024-11-21 12:18:02 +03:00
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:
parent
c51d9cdbf4
commit
045a0a2afd
174 changed files with 7295 additions and 404 deletions
|
@ -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
|
||||
|
|
51
activitypub/activitypub.go
Normal file
51
activitypub/activitypub.go
Normal 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()
|
||||
}
|
88
activitypub/apmodels/activity.go
Normal file
88
activitypub/apmodels/activity.go
Normal 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
|
||||
}
|
263
activitypub/apmodels/actor.go
Normal file
263
activitypub/apmodels/actor.go
Normal 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)
|
||||
}
|
168
activitypub/apmodels/actor_test.go
Normal file
168
activitypub/apmodels/actor_test.go
Normal 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)
|
||||
}
|
||||
}
|
24
activitypub/apmodels/hashtag.go
Normal file
24
activitypub/apmodels/hashtag.go
Normal 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
|
||||
}
|
10
activitypub/apmodels/inboxRequest.go
Normal file
10
activitypub/apmodels/inboxRequest.go
Normal 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
|
||||
}
|
50
activitypub/apmodels/message.go
Normal file
50
activitypub/apmodels/message.go
Normal 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)
|
||||
}
|
62
activitypub/apmodels/utils.go
Normal file
62
activitypub/apmodels/utils.go
Normal 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
|
||||
}
|
43
activitypub/apmodels/webfinger.go
Normal file
43
activitypub/apmodels/webfinger.go
Normal 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(),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
58
activitypub/controllers/actors.go
Normal file
58
activitypub/controllers/actors.go
Normal 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
|
||||
}
|
||||
}
|
166
activitypub/controllers/followers.go
Normal file
166
activitypub/controllers/followers.go
Normal 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
|
||||
}
|
56
activitypub/controllers/inbox.go
Normal file
56
activitypub/controllers/inbox.go
Normal 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)
|
||||
}
|
285
activitypub/controllers/nodeinfo.go
Normal file
285
activitypub/controllers/nodeinfo.go
Normal 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)
|
||||
}
|
||||
}
|
42
activitypub/controllers/object.go
Normal file
42
activitypub/controllers/object.go
Normal 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)
|
||||
}
|
||||
}
|
156
activitypub/controllers/outbox.go
Normal file
156
activitypub/controllers/outbox.go
Normal 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
|
||||
}
|
60
activitypub/controllers/webfinger.go
Normal file
60
activitypub/controllers/webfinger.go
Normal 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)
|
||||
}
|
||||
}
|
78
activitypub/crypto/keys.go
Normal file
78
activitypub/crypto/keys.go
Normal 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
|
||||
}
|
10
activitypub/crypto/publicKey.go
Normal file
10
activitypub/crypto/publicKey.go
Normal 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"`
|
||||
}
|
70
activitypub/crypto/sign.go
Normal file
70
activitypub/crypto/sign.go
Normal 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)
|
||||
}
|
40
activitypub/inbox/announce.go
Normal file
40
activitypub/inbox/announce.go
Normal 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
62
activitypub/inbox/chat.go
Normal 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
|
||||
}
|
7
activitypub/inbox/constants.go
Normal file
7
activitypub/inbox/constants.go
Normal file
|
@ -0,0 +1,7 @@
|
|||
package inbox
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
maxAgeForEngagement = time.Hour * 36
|
||||
)
|
88
activitypub/inbox/follow.go
Normal file
88
activitypub/inbox/follow.go
Normal 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
40
activitypub/inbox/like.go
Normal 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
27
activitypub/inbox/undo.go
Normal 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
|
||||
}
|
25
activitypub/inbox/update.go
Normal file
25
activitypub/inbox/update.go
Normal 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
130
activitypub/inbox/worker.go
Normal 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
|
||||
}
|
100
activitypub/inbox/worker_test.go
Normal file
100
activitypub/inbox/worker_test.go
Normal 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")
|
||||
}
|
||||
}
|
44
activitypub/inbox/workerpool.go
Normal file
44
activitypub/inbox/workerpool.go
Normal 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)
|
||||
}
|
||||
}
|
245
activitypub/outbox/outbox.go
Normal file
245
activitypub/outbox/outbox.go
Normal 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)
|
||||
}
|
121
activitypub/persistence/followers.go
Normal file
121
activitypub/persistence/followers.go
Normal 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
|
||||
}
|
360
activitypub/persistence/persistence.go
Normal file
360
activitypub/persistence/persistence.go
Normal 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
|
||||
}
|
51
activitypub/requests/acceptFollow.go
Normal file
51
activitypub/requests/acceptFollow.go
Normal 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
|
||||
}
|
75
activitypub/requests/http.go
Normal file
75
activitypub/requests/http.go
Normal 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
|
||||
}
|
57
activitypub/resolvers/follow.go
Normal file
57
activitypub/resolvers/follow.go
Normal 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
|
||||
}
|
125
activitypub/resolvers/resolve.go
Normal file
125
activitypub/resolvers/resolve.go
Normal 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
35
activitypub/router.go
Normal 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))
|
||||
}
|
66
activitypub/workerpool/outbound.go
Normal file
66
activitypub/workerpool/outbound.go
Normal 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
|
||||
}
|
|
@ -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!",
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
|
||||
|
|
171
controllers/admin/federation.go
Normal file
171
controllers/admin/federation.go
Normal 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)
|
||||
}
|
82
controllers/admin/followers.go
Normal file
82
controllers/admin/followers.go
Normal 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)
|
||||
}
|
|
@ -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")
|
||||
|
@ -77,21 +85,22 @@ func GetServerConfig(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
type serverConfigAdminResponse struct {
|
||||
InstanceDetails webConfigResponse `json:"instanceDetails"`
|
||||
FFmpegPath string `json:"ffmpegPath"`
|
||||
StreamKey string `json:"streamKey"`
|
||||
WebServerPort int `json:"webServerPort"`
|
||||
WebServerIP string `json:"webServerIP"`
|
||||
RTMPServerPort int `json:"rtmpServerPort"`
|
||||
S3 models.S3 `json:"s3"`
|
||||
VideoSettings videoSettings `json:"videoSettings"`
|
||||
YP yp `json:"yp"`
|
||||
ChatDisabled bool `json:"chatDisabled"`
|
||||
ExternalActions []models.ExternalAction `json:"externalActions"`
|
||||
SupportedCodecs []string `json:"supportedCodecs"`
|
||||
VideoCodec string `json:"videoCodec"`
|
||||
ForbiddenUsernames []string `json:"forbiddenUsernames"`
|
||||
SuggestedUsernames []string `json:"suggestedUsernames"`
|
||||
InstanceDetails webConfigResponse `json:"instanceDetails"`
|
||||
FFmpegPath string `json:"ffmpegPath"`
|
||||
StreamKey string `json:"streamKey"`
|
||||
WebServerPort int `json:"webServerPort"`
|
||||
WebServerIP string `json:"webServerIP"`
|
||||
RTMPServerPort int `json:"rtmpServerPort"`
|
||||
S3 models.S3 `json:"s3"`
|
||||
VideoSettings videoSettings `json:"videoSettings"`
|
||||
YP yp `json:"yp"`
|
||||
ChatDisabled bool `json:"chatDisabled"`
|
||||
ExternalActions []models.ExternalAction `json:"externalActions"`
|
||||
SupportedCodecs []string `json:"supportedCodecs"`
|
||||
VideoCodec string `json:"videoCodec"`
|
||||
ForbiddenUsernames []string `json:"forbiddenUsernames"`
|
||||
Federation federationConfigResponse `json:"federation"`
|
||||
SuggestedUsernames []string `json:"suggestedUsernames"`
|
||||
}
|
||||
|
||||
type videoSettings struct {
|
||||
|
@ -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"`
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
@ -12,19 +15,26 @@ import (
|
|||
)
|
||||
|
||||
type webConfigResponse struct {
|
||||
Name string `json:"name"`
|
||||
Summary string `json:"summary"`
|
||||
Logo string `json:"logo"`
|
||||
Tags []string `json:"tags"`
|
||||
Version string `json:"version"`
|
||||
NSFW bool `json:"nsfw"`
|
||||
ExtraPageContent string `json:"extraPageContent"`
|
||||
StreamTitle string `json:"streamTitle,omitempty"` // What's going on with the current stream
|
||||
SocialHandles []models.SocialHandle `json:"socialHandles"`
|
||||
ChatDisabled bool `json:"chatDisabled"`
|
||||
ExternalActions []models.ExternalAction `json:"externalActions"`
|
||||
CustomStyles string `json:"customStyles"`
|
||||
MaxSocketPayloadSize int `json:"maxSocketPayloadSize"`
|
||||
Name string `json:"name"`
|
||||
Summary string `json:"summary"`
|
||||
Logo string `json:"logo"`
|
||||
Tags []string `json:"tags"`
|
||||
Version string `json:"version"`
|
||||
NSFW bool `json:"nsfw"`
|
||||
ExtraPageContent string `json:"extraPageContent"`
|
||||
StreamTitle string `json:"streamTitle,omitempty"` // What's going on with the current stream
|
||||
SocialHandles []models.SocialHandle `json:"socialHandles"`
|
||||
ChatDisabled bool `json:"chatDisabled"`
|
||||
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
18
controllers/followers.go
Normal 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)
|
||||
}
|
|
@ -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
100
controllers/remoteFollow.go
Normal 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
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
)
|
||||
|
|
32
core/chat/events/fediverseEngagementEvent.go
Normal file
32
core/chat/events/fediverseEngagementEvent.go
Normal 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
|
||||
}
|
|
@ -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, ×tamp, &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
39
core/chat/pruner.go
Normal 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
13
core/data/activitypub.go
Normal 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()
|
||||
}
|
|
@ -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
23
core/data/crypto.go
Normal 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)
|
||||
}
|
|
@ -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,18 +37,34 @@ func GetStore() *Datastore {
|
|||
|
||||
// SetupPersistence will open the datastore and make it available.
|
||||
func SetupPersistence(file string) error {
|
||||
// Create empty DB file if it doesn't exist.
|
||||
if !utils.DoesFileExists(file) {
|
||||
log.Traceln("Creating new database at", file)
|
||||
// Allow support for in-memory databases for tests.
|
||||
|
||||
_, err := os.Create(file)
|
||||
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)
|
||||
|
||||
db, err := sql.Open("sqlite3", fmt.Sprintf("file:%s?_cache_size=10000&cache=shared&_journal_mode=WAL", file))
|
||||
db.SetMaxOpenConns(1)
|
||||
_, err := os.Create(file)
|
||||
if err != nil {
|
||||
log.Fatal(err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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);`
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
makeAnimatedGifPreview(mostRecentFile, previewGifFile)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
28
db/README.md
Normal file
28
db/README.md
Normal 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
29
db/db.go
Normal 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
36
db/models.go
Normal 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
57
db/query.sql
Normal 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
441
db/query.sql.go
Normal 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
37
db/schema.sql
Normal 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
9
go.mod
|
@ -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
15
go.sum
|
@ -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=
|
||||
|
|
12
models/federatedActivity.go
Normal file
12
models/federatedActivity.go
Normal 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
21
models/follower.go
Normal 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"`
|
||||
}
|
46
router/middleware/activityPub.go
Normal file
46
router/middleware/activityPub.go
Normal 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)
|
||||
})
|
||||
}
|
|
@ -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, "; "))
|
||||
|
|
|
@ -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
9
sqlc.json
Normal file
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"version": "1",
|
||||
"packages": [{
|
||||
"schema": "db/schema.sql",
|
||||
"queries": "db/query.sql",
|
||||
"name": "db",
|
||||
"path": "db"
|
||||
}]
|
||||
}
|
2
static/admin/404/index.html
vendored
2
static/admin/404/index.html
vendored
File diff suppressed because one or more lines are too long
|
@ -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();
|
|
@ -0,0 +1 @@
|
|||
self.__MIDDLEWARE_MANIFEST=[];self.__MIDDLEWARE_MANIFEST_CB&&self.__MIDDLEWARE_MANIFEST_CB()
|
|
@ -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
1
static/admin/_next/static/chunks/132-69ec1de6a8e27de6.js
vendored
Normal file
1
static/admin/_next/static/chunks/132-69ec1de6a8e27de6.js
vendored
Normal file
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
1
static/admin/_next/static/chunks/464-deae2b2f674a34de.js
vendored
Normal file
1
static/admin/_next/static/chunks/464-deae2b2f674a34de.js
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
static/admin/_next/static/chunks/556-4bf62bd783267914.js
vendored
Normal file
1
static/admin/_next/static/chunks/556-4bf62bd783267914.js
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
static/admin/_next/static/chunks/674-fd7f35cd345c7a4b.js
vendored
Normal file
1
static/admin/_next/static/chunks/674-fd7f35cd345c7a4b.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
static/admin/_next/static/chunks/751-f932ff7ec3e1342a.js
vendored
Normal file
1
static/admin/_next/static/chunks/751-f932ff7ec3e1342a.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
static/admin/_next/static/chunks/763-6084d4b3c149b8f4.js
vendored
Normal file
1
static/admin/_next/static/chunks/763-6084d4b3c149b8f4.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
static/admin/_next/static/chunks/829-c8d1f3db438c210b.js
vendored
Normal file
1
static/admin/_next/static/chunks/829-c8d1f3db438c210b.js
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
static/admin/_next/static/chunks/961-1db4468ca0742ea4.js
vendored
Normal file
1
static/admin/_next/static/chunks/961-1db4468ca0742ea4.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
static/admin/_next/static/chunks/pages/_app-31024cc89d3736f1.js
vendored
Normal file
1
static/admin/_next/static/chunks/pages/_app-31024cc89d3736f1.js
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
static/admin/_next/static/chunks/pages/access-tokens-d3e9e11b79321dbb.js
vendored
Normal file
1
static/admin/_next/static/chunks/pages/access-tokens-d3e9e11b79321dbb.js
vendored
Normal file
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
Loading…
Reference in a new issue