IndieAuth support (#1811)

* Able to authenticate user against IndieAuth. For #1273

* WIP server indieauth endpoint. For https://github.com/owncast/owncast/issues/1272

* Add migration to remove access tokens from user

* Add authenticated bool to user for display purposes

* Add indieauth modal and auth flair to display names. For #1273

* Validate URLs and display errors

* Renames, cleanups

* Handle relative auth endpoint paths. Add error handling for missing redirects.

* Disallow using display names in use by registered users. Closes #1810

* Verify code verifier via code challenge on callback

* Use relative path to authorization_endpoint

* Post-rebase fixes

* Use a timestamp instead of a bool for authenticated

* Propertly handle and display error in modal

* Use auth'ed timestamp to derive authenticated flag to display in chat

* don't redirect unless a URL is present

avoids redirecting to `undefined` if there was an error

* improve error message if owncast server URL isn't set

* fix IndieAuth PKCE implementation

use SHA256 instead of SHA1, generates a longer code verifier (must be 43-128 chars long), fixes URL-safe SHA256 encoding

* return real profile data for IndieAuth response

* check the code verifier in the IndieAuth server

* Linting

* Add new chat settings modal anad split up indieauth ui

* Remove logging error

* Update the IndieAuth modal UI. For #1273

* Add IndieAuth repsonse error checking

* Disable IndieAuth client if server URL is not set.

* Add explicit error messages for specific error types

* Fix bad logic

* Return OAuth-keyed error responses for indieauth server

* Display IndieAuth error in plain text with link to return to main page

* Remove redundant check

* Add additional detail to error

* Hide IndieAuth details behind disclosure details

* Break out migration into two steps because some people have been runing dev in production

* Add auth option to user dropdown

Co-authored-by: Aaron Parecki <aaron@parecki.com>
This commit is contained in:
Gabe Kangas 2022-04-21 14:55:26 -07:00 committed by GitHub
parent b86537fa91
commit b835de2dc4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
47 changed files with 1844 additions and 274 deletions

11
auth/auth.go Normal file
View file

@ -0,0 +1,11 @@
package auth
// Type represents a form of authentication.
type Type string
// The different auth types we support.
// Currently only IndieAuth.
const (
// IndieAuth https://indieauth.spec.indieweb.org/.
IndieAuth Type = "indieauth"
)

112
auth/indieauth/client.go Normal file
View file

@ -0,0 +1,112 @@
package indieauth
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"github.com/owncast/owncast/core/data"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
)
var pendingAuthRequests = make(map[string]*Request)
// StartAuthFlow will begin the IndieAuth flow by generating an auth request.
func StartAuthFlow(authHost, userID, accessToken, displayName string) (*url.URL, error) {
serverURL := data.GetServerURL()
if serverURL == "" {
return nil, errors.New("Owncast server URL must be set when using auth")
}
r, err := createAuthRequest(authHost, userID, displayName, accessToken, serverURL)
if err != nil {
return nil, errors.Wrap(err, "unable to generate IndieAuth request")
}
pendingAuthRequests[r.State] = r
return r.Redirect, nil
}
// HandleCallbackCode will handle the callback from the IndieAuth server
// to continue the next step of the auth flow.
func HandleCallbackCode(code, state string) (*Request, *Response, error) {
request, exists := pendingAuthRequests[state]
if !exists {
return nil, nil, errors.New("no auth requests pending")
}
data := url.Values{}
data.Set("grant_type", "authorization_code")
data.Set("code", code)
data.Set("client_id", request.ClientID)
data.Set("redirect_uri", request.Callback.String())
data.Set("code_verifier", request.CodeVerifier)
client := &http.Client{}
r, err := http.NewRequest("POST", request.Endpoint.String(), strings.NewReader(data.Encode())) // URL-encoded payload
if err != nil {
return nil, nil, err
}
r.Header.Add("Content-Type", "application/x-www-form-urlencoded")
r.Header.Add("Content-Length", strconv.Itoa(len(data.Encode())))
res, err := client.Do(r)
if err != nil {
return nil, nil, err
}
defer res.Body.Close()
body, err := io.ReadAll(res.Body)
if err != nil {
return nil, nil, err
}
var response Response
if err := json.Unmarshal(body, &response); err != nil {
return nil, nil, errors.Wrap(err, "unable to parse IndieAuth response")
}
if response.Error != "" || response.ErrorDescription != "" {
errorText := makeIndieAuthClientErrorText(response.Error)
log.Debugln("IndieAuth error:", response.Error, response.ErrorDescription)
return nil, nil, fmt.Errorf("IndieAuth error: %s - %s", errorText, response.ErrorDescription)
}
// In case this IndieAuth server does not use OAuth error keys or has internal
// issues resulting in unstructured errors.
if res.StatusCode < 200 || res.StatusCode > 299 {
log.Debugln("IndieAuth error. status code:", res.StatusCode, "body:", string(body))
return nil, nil, errors.New("there was an error authenticating against IndieAuth server")
}
// Trim any trailing slash so we can accurately compare the two "me" values
meResponseVerifier := strings.TrimRight(response.Me, "/")
meRequestVerifier := strings.TrimRight(request.Me.String(), "/")
// What we sent and what we got back must match
if meRequestVerifier != meResponseVerifier {
return nil, nil, errors.New("indieauth response does not match the initial anticipated auth destination")
}
return request, &response, nil
}
// Error value should be from this list:
// https://datatracker.ietf.org/doc/html/rfc6749#section-5.2
func makeIndieAuthClientErrorText(err string) string {
switch err {
case "invalid_request", "invalid_client":
return "The authentication request was invalid. Please report this to the Owncast project."
case "invalid_grant", "unauthorized_client":
return "This authorization request is unauthorized."
case "unsupported_grant_type":
return "The authorization grant type is not supported by the authorization server."
default:
return err
}
}

120
auth/indieauth/helpers.go Normal file
View file

@ -0,0 +1,120 @@
package indieauth
import (
"crypto/sha256"
"encoding/base64"
"fmt"
"net/http"
"net/url"
"strings"
"github.com/andybalholm/cascadia"
"github.com/pkg/errors"
"golang.org/x/net/html"
)
func createAuthRequest(authDestination, userID, displayName, accessToken, baseServer string) (*Request, error) {
authURL, err := url.Parse(authDestination)
if err != nil {
return nil, errors.Wrap(err, "unable to parse IndieAuth destination")
}
authEndpointURL, err := getAuthEndpointFromURL(authURL.String())
if err != nil {
return nil, errors.Wrap(err, "unable to get IndieAuth endpoint from destination URL")
}
baseServerURL, err := url.Parse(baseServer)
if err != nil {
return nil, errors.Wrap(err, "unable to parse local owncast base server URL")
}
callbackURL := *baseServerURL
callbackURL.Path = "/api/auth/indieauth/callback"
codeVerifier := randString(50)
codeChallenge := createCodeChallenge(codeVerifier)
state := randString(20)
responseType := "code"
clientID := baseServerURL.String() // Our local URL
codeChallengeMethod := "S256"
redirect := *authEndpointURL
q := authURL.Query()
q.Add("response_type", responseType)
q.Add("client_id", clientID)
q.Add("state", state)
q.Add("code_challenge_method", codeChallengeMethod)
q.Add("code_challenge", codeChallenge)
q.Add("me", authURL.String())
q.Add("redirect_uri", callbackURL.String())
redirect.RawQuery = q.Encode()
return &Request{
Me: authURL,
UserID: userID,
DisplayName: displayName,
CurrentAccessToken: accessToken,
Endpoint: authEndpointURL,
ClientID: baseServer,
CodeVerifier: codeVerifier,
CodeChallenge: codeChallenge,
State: state,
Redirect: &redirect,
Callback: &callbackURL,
}, nil
}
func getAuthEndpointFromURL(urlstring string) (*url.URL, error) {
htmlDocScrapeURL, err := url.Parse(urlstring)
if err != nil {
return nil, errors.Wrap(err, "unable to parse URL")
}
r, err := http.Get(htmlDocScrapeURL.String()) // nolint:gosec
if err != nil {
return nil, err
}
defer r.Body.Close()
scrapedHTMLDocument, err := html.Parse(r.Body)
if err != nil {
return nil, errors.Wrap(err, "unable to parse html at remote auth host")
}
authorizationEndpointTag := cascadia.MustCompile("link[rel=authorization_endpoint]").MatchAll(scrapedHTMLDocument)
if len(authorizationEndpointTag) == 0 {
return nil, fmt.Errorf("url does not support indieauth")
}
for _, attr := range authorizationEndpointTag[len(authorizationEndpointTag)-1].Attr {
if attr.Key == "href" {
u, err := url.Parse(attr.Val)
if err != nil {
return nil, errors.Wrap(err, "unable to parse authorization endpoint")
}
// If it is a relative URL we an fill in the missing components
// by using the original URL we scraped, since it is the same host.
if u.Scheme == "" {
u.Scheme = htmlDocScrapeURL.Scheme
}
if u.Host == "" {
u.Host = htmlDocScrapeURL.Host
}
return u, nil
}
}
return nil, fmt.Errorf("unable to find href value for authorization_endpoint")
}
func createCodeChallenge(codeVerifier string) string {
sha256hash := sha256.Sum256([]byte(codeVerifier))
encodedHashedCode := strings.TrimRight(base64.URLEncoding.EncodeToString(sha256hash[:]), "=")
return encodedHashedCode
}

34
auth/indieauth/random.go Normal file
View file

@ -0,0 +1,34 @@
package indieauth
import (
"math/rand"
"time"
"unsafe"
)
const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
const (
letterIdxBits = 6 // 6 bits to represent a letter index
letterIdxMask = 1<<letterIdxBits - 1 // All 1-bits, as many as letterIdxBits
letterIdxMax = 63 / letterIdxBits // # of letter indices fitting in 63 bits
)
var src = rand.NewSource(time.Now().UnixNano())
func randString(n int) string {
b := make([]byte, n)
// A src.Int63() generates 63 random bits, enough for letterIdxMax characters!
for i, cache, remain := n-1, src.Int63(), letterIdxMax; i >= 0; {
if remain == 0 {
cache, remain = src.Int63(), letterIdxMax
}
if idx := int(cache & letterIdxMask); idx < len(letterBytes) {
b[i] = letterBytes[idx]
i--
}
cache >>= letterIdxBits
remain--
}
return *(*string)(unsafe.Pointer(&b)) // nolint:gosec
}

18
auth/indieauth/request.go Normal file
View file

@ -0,0 +1,18 @@
package indieauth
import "net/url"
// Request represents a single in-flight IndieAuth request.
type Request struct {
UserID string
DisplayName string
CurrentAccessToken string
Endpoint *url.URL
Redirect *url.URL // Outbound redirect URL to continue auth flow
Callback *url.URL // Inbound URL to get auth flow results
ClientID string
CodeVerifier string
CodeChallenge string
State string
Me *url.URL
}

View file

@ -0,0 +1,18 @@
package indieauth
// Profile represents optional profile data that is returned
// when completing the IndieAuth flow.
type Profile struct {
Name string `json:"name"`
URL string `json:"url"`
Photo string `json:"photo"`
}
// Response the response returned when completing
// the IndieAuth flow.
type Response struct {
Me string `json:"me,omitempty"`
Profile Profile `json:"profile,omitempty"`
Error string `json:"error,omitempty"`
ErrorDescription string `json:"error_description,omitempty"`
}

92
auth/indieauth/server.go Normal file
View file

@ -0,0 +1,92 @@
package indieauth
import (
"fmt"
"github.com/owncast/owncast/core/data"
"github.com/pkg/errors"
"github.com/teris-io/shortid"
)
// ServerAuthRequest is n inbound request to authenticate against
// this Owncast instance.
type ServerAuthRequest struct {
ClientID string
RedirectURI string
CodeChallenge string
State string
Me string
Code string
}
// ServerProfile represents basic user-provided data about this Owncast instance.
type ServerProfile struct {
Name string `json:"name"`
URL string `json:"url"`
Photo string `json:"photo"`
}
// ServerProfileResponse is returned when an auth flow requests the final
// confirmation of the IndieAuth flow.
type ServerProfileResponse struct {
Me string `json:"me,omitempty"`
Profile ServerProfile `json:"profile,omitempty"`
// Error keys need to match the OAuth spec.
Error string `json:"error,omitempty"`
ErrorDescription string `json:"error_description,omitempty"`
}
var pendingServerAuthRequests = map[string]ServerAuthRequest{}
// StartServerAuth will handle the authentication for the admin user of this
// Owncast server. Initiated via a GET of the auth endpoint.
// https://indieweb.org/authorization-endpoint
func StartServerAuth(clientID, redirectURI, codeChallenge, state, me string) (*ServerAuthRequest, error) {
code := shortid.MustGenerate()
r := ServerAuthRequest{
ClientID: clientID,
RedirectURI: redirectURI,
CodeChallenge: codeChallenge,
State: state,
Me: me,
Code: code,
}
pendingServerAuthRequests[code] = r
return &r, nil
}
// CompleteServerAuth will verify that the values provided in the final step
// of the IndieAuth flow are correct, and return some basic profile info.
func CompleteServerAuth(code, redirectURI, clientID string, codeVerifier string) (*ServerProfileResponse, error) {
request, pending := pendingServerAuthRequests[code]
if !pending {
return nil, errors.New("no pending authentication request")
}
if request.RedirectURI != redirectURI {
return nil, errors.New("redirect URI does not match")
}
if request.ClientID != clientID {
return nil, errors.New("client ID does not match")
}
codeChallengeFromRequest := createCodeChallenge(codeVerifier)
if request.CodeChallenge != codeChallengeFromRequest {
return nil, errors.New("code verifier is incorrect")
}
response := ServerProfileResponse{
Me: data.GetServerURL(),
Profile: ServerProfile{
Name: data.GetServerName(),
URL: data.GetServerURL(),
Photo: fmt.Sprintf("%s/%s", data.GetServerURL(), data.GetLogoPath()),
},
}
return &response, nil
}

77
auth/persistence.go Normal file
View file

@ -0,0 +1,77 @@
package auth
import (
"context"
"strings"
"github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/core/user"
log "github.com/sirupsen/logrus"
"github.com/owncast/owncast/db"
)
var _datastore *data.Datastore
// Setup will initialize auth persistence.
func Setup(db *data.Datastore) {
_datastore = db
createTableSQL := `CREATE TABLE IF NOT EXISTS auth (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"user_id" TEXT NOT NULL,
"token" TEXT NOT NULL,
"type" TEXT NOT NULL,
"timestamp" DATE DEFAULT CURRENT_TIMESTAMP NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id)
);CREATE INDEX auth_token ON auth (token);`
stmt, err := db.DB.Prepare(createTableSQL)
if err != nil {
log.Fatal(err)
}
defer stmt.Close()
_, err = stmt.Exec()
if err != nil {
log.Fatalln(err)
}
}
// AddAuth will add an external authentication token and type for a user.
func AddAuth(userID, authToken string, authType Type) error {
return _datastore.GetQueries().AddAuthForUser(context.Background(), db.AddAuthForUserParams{
UserID: userID,
Token: authToken,
Type: string(authType),
})
}
// GetUserByAuth will return an existing user given auth details if a user
// has previously authenticated with that method.
func GetUserByAuth(authToken string, authType Type) *user.User {
u, err := _datastore.GetQueries().GetUserByAuth(context.Background(), db.GetUserByAuthParams{
Token: authToken,
Type: string(authType),
})
if err != nil {
return nil
}
var scopes []string
if u.Scopes.Valid {
scopes = strings.Split(u.Scopes.String, ",")
}
return &user.User{
ID: u.ID,
DisplayName: u.DisplayName,
DisplayColor: int(u.DisplayColor),
CreatedAt: u.CreatedAt.Time,
DisabledAt: &u.DisabledAt.Time,
PreviousNames: strings.Split(u.PreviousNames.String, ","),
NameChangedAt: &u.NamechangedAt.Time,
AuthenticatedAt: &u.AuthenticatedAt.Time,
Scopes: scopes,
}
}

View file

@ -0,0 +1,103 @@
package indieauth
import (
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/owncast/owncast/auth"
ia "github.com/owncast/owncast/auth/indieauth"
"github.com/owncast/owncast/controllers"
"github.com/owncast/owncast/core/chat"
"github.com/owncast/owncast/core/user"
log "github.com/sirupsen/logrus"
)
// StartAuthFlow will begin the IndieAuth flow for the current user.
func StartAuthFlow(u user.User, w http.ResponseWriter, r *http.Request) {
type request struct {
AuthHost string `json:"authHost"`
}
type response struct {
Redirect string `json:"redirect"`
}
var authRequest request
p, err := io.ReadAll(r.Body)
if err != nil {
controllers.WriteSimpleResponse(w, false, err.Error())
return
}
if err := json.Unmarshal(p, &authRequest); err != nil {
controllers.WriteSimpleResponse(w, false, err.Error())
return
}
accessToken := r.URL.Query().Get("accessToken")
redirectURL, err := ia.StartAuthFlow(authRequest.AuthHost, u.ID, accessToken, u.DisplayName)
if err != nil {
controllers.WriteSimpleResponse(w, false, err.Error())
return
}
redirectResponse := response{
Redirect: redirectURL.String(),
}
controllers.WriteResponse(w, redirectResponse)
}
// HandleRedirect will handle the redirect from an IndieAuth server to
// continue the auth flow.
func HandleRedirect(w http.ResponseWriter, r *http.Request) {
state := r.URL.Query().Get("state")
code := r.URL.Query().Get("code")
request, response, err := ia.HandleCallbackCode(code, state)
if err != nil {
log.Debugln(err)
msg := fmt.Sprintf("Unable to complete authentication. <a href=\"/\">Go back.</a><hr/> %s", err.Error())
_ = controllers.WriteString(w, msg, http.StatusBadRequest)
return
}
// Check if a user with this auth already exists, if so, log them in.
if u := auth.GetUserByAuth(response.Me, auth.IndieAuth); u != nil {
// Handle existing auth.
log.Debugln("user with provided indieauth already exists, logging them in")
// Update the current user's access token to point to the existing user id.
accessToken := request.CurrentAccessToken
userID := u.ID
if err := user.SetAccessTokenToOwner(accessToken, userID); err != nil {
controllers.WriteSimpleResponse(w, false, err.Error())
return
}
loginMessage := fmt.Sprintf("**%s** is now authenticated as **%s**", request.DisplayName, u.DisplayName)
if err := chat.SendSystemAction(loginMessage, true); err != nil {
log.Errorln(err)
}
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
return
}
// Otherwise, save this as new auth.
log.Debug("indieauth token does not already exist, saving it as a new one for the current user")
if err := auth.AddAuth(request.UserID, response.Me, auth.IndieAuth); err != nil {
controllers.WriteSimpleResponse(w, false, err.Error())
return
}
// Update the current user's authenticated flag so we can show it in
// the chat UI.
if err := user.SetUserAsAuthenticated(request.UserID); err != nil {
log.Errorln(err)
}
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
}

View file

@ -0,0 +1,80 @@
package indieauth
import (
"net/http"
"net/url"
ia "github.com/owncast/owncast/auth/indieauth"
"github.com/owncast/owncast/controllers"
)
// HandleAuthEndpoint will handle the IndieAuth auth endpoint.
func HandleAuthEndpoint(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
// Require the GET request for IndieAuth to be behind admin login.
handleAuthEndpointGet(w, r)
} else if r.Method == http.MethodPost {
handleAuthEndpointPost(w, r)
} else {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
}
func handleAuthEndpointGet(w http.ResponseWriter, r *http.Request) {
clientID := r.URL.Query().Get("client_id")
redirectURI := r.URL.Query().Get("redirect_uri")
codeChallenge := r.URL.Query().Get("code_challenge")
state := r.URL.Query().Get("state")
me := r.URL.Query().Get("me")
request, err := ia.StartServerAuth(clientID, redirectURI, codeChallenge, state, me)
if err != nil {
// Return a human readable, HTML page as an error. JSON is no use here.
return
}
// Redirect the client browser with the values we generated to continue
// the IndieAuth flow.
// If the URL is invalid then return with specific "invalid_request" error.
u, err := url.Parse(redirectURI)
if err != nil {
controllers.WriteResponse(w, ia.Response{
Error: "invalid_request",
ErrorDescription: err.Error(),
})
return
}
redirectParams := u.Query()
redirectParams.Set("code", request.Code)
redirectParams.Set("state", request.State)
u.RawQuery = redirectParams.Encode()
http.Redirect(w, r, u.String(), http.StatusTemporaryRedirect)
}
func handleAuthEndpointPost(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
controllers.WriteSimpleResponse(w, false, err.Error())
return
}
code := r.PostForm.Get("code")
redirectURI := r.PostForm.Get("redirect_uri")
clientID := r.PostForm.Get("client_id")
codeVerifier := r.PostForm.Get("code_verifier")
// If the server auth flow cannot be completed then return with specific
// "invalid_client" error.
response, err := ia.CompleteServerAuth(code, redirectURI, clientID, codeVerifier)
if err != nil {
controllers.WriteResponse(w, ia.Response{
Error: "invalid_client",
ErrorDescription: err.Error(),
})
return
}
controllers.WriteResponse(w, response)
}

View file

@ -13,11 +13,15 @@ import (
// ExternalGetChatMessages gets all of the chat messages.
func ExternalGetChatMessages(integration user.ExternalAPIUser, w http.ResponseWriter, r *http.Request) {
middleware.EnableCors(w)
GetChatMessages(w, r)
getChatMessages(w, r)
}
// GetChatMessages gets all of the chat messages.
func GetChatMessages(w http.ResponseWriter, r *http.Request) {
func GetChatMessages(u user.User, w http.ResponseWriter, r *http.Request) {
getChatMessages(w, r)
}
func getChatMessages(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.Method {
@ -62,7 +66,7 @@ func RegisterAnonymousChatUser(w http.ResponseWriter, r *http.Request) {
request.DisplayName = r.Header.Get("X-Forwarded-User")
}
newUser, err := user.CreateAnonymousUser(request.DisplayName)
newUser, accessToken, err := user.CreateAnonymousUser(request.DisplayName)
if err != nil {
WriteSimpleResponse(w, false, err.Error())
return
@ -70,7 +74,7 @@ func RegisterAnonymousChatUser(w http.ResponseWriter, r *http.Request) {
response := registerAnonymousUserResponse{
ID: newUser.ID,
AccessToken: newUser.AccessToken,
AccessToken: accessToken,
DisplayName: newUser.DisplayName,
}

View file

@ -16,22 +16,23 @@ 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"`
SocketHostOverride string `json:"socketHostOverride,omitempty"`
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"`
Notifications notificationsConfigResponse `json:"notifications"`
Name string `json:"name"`
Summary string `json:"summary"`
Logo string `json:"logo"`
Tags []string `json:"tags"`
Version string `json:"version"`
NSFW bool `json:"nsfw"`
SocketHostOverride string `json:"socketHostOverride,omitempty"`
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"`
Notifications notificationsConfigResponse `json:"notifications"`
Authentication authenticationConfigResponse `json:"authentication"`
}
type federationConfigResponse struct {
@ -49,6 +50,10 @@ type notificationsConfigResponse struct {
Browser browserNotificationsConfigResponse `json:"browser"`
}
type authenticationConfigResponse struct {
IndieAuthEnabled bool `json:"indieAuthEnabled"`
}
// GetWebConfig gets the status of the server.
func GetWebConfig(w http.ResponseWriter, r *http.Request) {
middleware.EnableCors(w)
@ -97,6 +102,10 @@ func GetWebConfig(w http.ResponseWriter, r *http.Request) {
},
}
authenticationResponse := authenticationConfigResponse{
IndieAuthEnabled: data.GetServerURL() != "",
}
configuration := webConfigResponse{
Name: data.GetServerName(),
Summary: serverSummary,
@ -114,6 +123,7 @@ func GetWebConfig(w http.ResponseWriter, r *http.Request) {
MaxSocketPayloadSize: config.MaxSocketPayloadSize,
Federation: federationResponse,
Notifications: notificationsResponse,
Authentication: authenticationResponse,
}
if err := json.NewEncoder(w).Encode(configuration); err != nil {

View file

@ -65,3 +65,11 @@ func WriteResponse(w http.ResponseWriter, response interface{}) {
InternalErrorHandler(w, err)
}
}
// WriteString will return a basic string and a status code to the client.
func WriteString(w http.ResponseWriter, text string, status int) error {
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(status)
_, err := w.Write([]byte(text))
return err
}

View file

@ -4,6 +4,7 @@ import (
"encoding/json"
"net/http"
"github.com/owncast/owncast/core/user"
"github.com/owncast/owncast/notifications"
"github.com/owncast/owncast/utils"
@ -13,7 +14,7 @@ import (
// RegisterForLiveNotifications will register a channel + destination to be
// notified when a stream goes live.
func RegisterForLiveNotifications(w http.ResponseWriter, r *http.Request) {
func RegisterForLiveNotifications(u user.User, w http.ResponseWriter, r *http.Request) {
if r.Method != POST {
WriteSimpleResponse(w, false, r.Method+" not supported")
return

View file

@ -21,6 +21,8 @@ func (s *Server) userNameChanged(eventData chatClientEvent) {
}
proposedUsername := receivedEvent.NewName
// Check if name is on the blocklist
blocklist := data.GetForbiddenUsernameList()
for _, blockedName := range blocklist {
@ -39,11 +41,27 @@ func (s *Server) userNameChanged(eventData chatClientEvent) {
}
}
// Check if the name is not already assigned to a registered user.
if available, err := user.IsDisplayNameAvailable(proposedUsername); err != nil {
log.Errorln("error checking if name is available", err)
return
} else if !available {
message := fmt.Sprintf("You cannot change your name to **%s**, it is already in use.", proposedUsername)
s.sendActionToClient(eventData.client, message)
// Resend the client's user so their username is in sync.
eventData.client.sendConnectedClientInfo()
return
}
savedUser := user.GetUserByToken(eventData.client.accessToken)
oldName := savedUser.DisplayName
// Save the new name
user.ChangeUsername(eventData.client.User.ID, receivedEvent.NewName)
if err := user.ChangeUsername(eventData.client.User.ID, receivedEvent.NewName); err != nil {
log.Errorln("error changing username", err)
}
// Update the connected clients associated user with the new name
now := time.Now()

View file

@ -10,6 +10,7 @@ type NameChangeEvent struct {
// NameChangeBroadcast represents a user changing their chat display name.
type NameChangeBroadcast struct {
Event
OutboundEvent
UserEvent
Oldname string `json:"oldName"`
}

View file

@ -104,16 +104,17 @@ func makeUserMessageEventFromRowData(row rowData) events.UserMessageEvent {
scopeSlice := strings.Split(scopes, ",")
u := user.User{
ID: *row.userID,
AccessToken: "",
DisplayName: displayName,
DisplayColor: displayColor,
CreatedAt: createdAt,
DisabledAt: row.userDisabledAt,
NameChangedAt: row.userNameChangedAt,
PreviousNames: previousUsernames,
Scopes: scopeSlice,
IsBot: isBot,
ID: *row.userID,
DisplayName: displayName,
DisplayColor: displayColor,
CreatedAt: createdAt,
DisabledAt: row.userDisabledAt,
NameChangedAt: row.userNameChangedAt,
PreviousNames: previousUsernames,
AuthenticatedAt: row.userAuthenticatedAt,
Authenticated: row.userAuthenticatedAt != nil,
Scopes: scopeSlice,
IsBot: isBot,
}
message := events.UserMessageEvent{
@ -195,14 +196,15 @@ type rowData struct {
image *string
link *string
userDisplayName *string
userDisplayColor *int
userCreatedAt *time.Time
userDisabledAt *time.Time
previousUsernames *string
userNameChangedAt *time.Time
userScopes *string
userType *string
userDisplayName *string
userDisplayColor *int
userCreatedAt *time.Time
userDisabledAt *time.Time
previousUsernames *string
userNameChangedAt *time.Time
userAuthenticatedAt *time.Time
userScopes *string
userType *string
}
func getChat(query string) []interface{} {
@ -235,9 +237,11 @@ func getChat(query string) []interface{} {
&row.userDisabledAt,
&row.previousUsernames,
&row.userNameChangedAt,
&row.userAuthenticatedAt,
&row.userScopes,
&row.userType,
); err != nil {
log.Errorln(err)
log.Errorln("There is a problem converting query to chat objects. Please report this:", query)
break
}
@ -274,7 +278,7 @@ func GetChatModerationHistory() []interface{} {
}
// Get all messages regardless of visibility
query := "SELECT messages.id, user_id, body, title, subtitle, image, link, eventType, hidden_at, timestamp, display_name, display_color, created_at, disabled_at, previous_names, namechanged_at, scopes, users.type 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, authenticated_at, scopes, type FROM messages INNER JOIN users ON messages.user_id = users.id ORDER BY timestamp DESC"
result := getChat(query)
_historyCache = &result
@ -285,7 +289,7 @@ func GetChatModerationHistory() []interface{} {
// GetChatHistory will return all the chat messages suitable for returning as user-facing chat history.
func GetChatHistory() []interface{} {
// Get all visible messages
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, users.type 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)
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.authenticated_at, users.scopes, users.type 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
@ -305,7 +309,7 @@ func SetMessageVisibilityForUserID(userID string, visible bool) error {
// Get a list of IDs to send to the connected clients to hide
ids := make([]string, 0)
query := fmt.Sprintf("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, type FROM messages INNER JOIN users ON messages.user_id = users.id WHERE user_id IS '%s'", userID)
query := fmt.Sprintf("SELECT messages.id, user_id, body, title, subtitle, image, link, eventType, hidden_at, timestamp, display_name, display_color, created_at, disabled_at, previous_names, namechanged_at, authenticated, scopes, type FROM messages INNER JOIN users ON messages.user_id = users.id WHERE user_id IS '%s'", userID)
messages := getChat(query)
if len(messages) == 0 {

View file

@ -201,10 +201,10 @@ func (s *Server) HandleClientConnection(w http.ResponseWriter, r *http.Request)
// A user is required to use the websocket
user := user.GetUserByToken(accessToken)
if user == nil {
// Send error that registration is required
_ = conn.WriteJSON(events.EventPayload{
"type": events.ErrorNeedsRegistration,
})
// Send error that registration is required
_ = conn.Close()
return
}

View file

@ -7,6 +7,7 @@ import (
log "github.com/sirupsen/logrus"
"github.com/owncast/owncast/auth"
"github.com/owncast/owncast/config"
"github.com/owncast/owncast/core/chat"
"github.com/owncast/owncast/core/data"
@ -56,6 +57,7 @@ func Start() error {
}
user.SetupUsers()
auth.Setup(data.GetDatastore())
fileWriter.SetupFileWriterReceiverService(&handler)

View file

@ -17,7 +17,7 @@ import (
)
const (
schemaVersion = 4
schemaVersion = 5
)
var (
@ -75,6 +75,7 @@ func SetupPersistence(file string) error {
createWebhooksTable()
createUsersTable(db)
createAccessTokenTable(db)
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS config (
"key" string NOT NULL PRIMARY KEY,
@ -141,6 +142,8 @@ func migrateDatabase(db *sql.DB, from, to int) error {
migrateToSchema3(db)
case 3:
migrateToSchema4(db)
case 4:
migrateToSchema5(db)
default:
log.Fatalln("missing database migration step")
}

View file

@ -2,6 +2,8 @@ package data
import (
"database/sql"
"fmt"
"strings"
"time"
"github.com/owncast/owncast/utils"
@ -9,7 +11,75 @@ import (
"github.com/teris-io/shortid"
)
func migrateToSchema5(db *sql.DB) {
// Access tokens have been broken into its own table.
// Authenticated bool added to the users table.
stmt, err := db.Prepare("ALTER TABLE users ADD authenticated_at timestamp DEFAULT null ")
if err != nil {
log.Fatal(err)
}
defer stmt.Close()
_, err = stmt.Exec()
if err != nil {
log.Warnln(err)
}
// Migrate the access tokens from the users table to the access tokens table.
query := `SELECT id, access_token, created_at FROM users`
rows, err := db.Query(query)
if err != nil || rows.Err() != nil {
log.Errorln("error migrating access tokens to schema v5", err, rows.Err())
return
}
defer rows.Close()
valueStrings := []string{}
valueArgs := []interface{}{}
var token string
var userID string
var timestamp time.Time
for rows.Next() {
if err := rows.Scan(&userID, &token, &timestamp); err != nil {
log.Error("There is a problem reading the database.", err)
return
}
valueStrings = append(valueStrings, "(?, ?, ?)")
valueArgs = append(valueArgs, userID, token, timestamp)
}
smt := `INSERT INTO user_access_tokens(token, user_id, timestamp) VALUES %s ON CONFLICT DO NOTHING`
smt = fmt.Sprintf(smt, strings.Join(valueStrings, ","))
// fmt.Println(smt)
tx, err := db.Begin()
if err != nil {
log.Fatalln("Error starting transaction", err)
}
_, err = tx.Exec(smt, valueArgs...)
if err != nil {
_ = tx.Rollback()
log.Fatalln("Error inserting access tokens", err)
}
if err := tx.Commit(); err != nil {
log.Fatalln("Error committing transaction", err)
}
// Remove old access token column from the users table.
stmt, err = db.Prepare("ALTER TABLE users DROP COLUMN access_token;")
if err != nil {
log.Fatal(err)
}
defer stmt.Close()
_, err = stmt.Exec()
if err != nil {
log.Warnln(err)
}
}
func migrateToSchema4(db *sql.DB) {
// Access tokens have been broken into its own table.
stmt, err := db.Prepare("ALTER TABLE ap_followers ADD COLUMN request_object BLOB")
if err != nil {
log.Fatal(err)

View file

@ -6,18 +6,37 @@ import (
log "github.com/sirupsen/logrus"
)
func createAccessTokenTable(db *sql.DB) {
createTableSQL := `CREATE TABLE IF NOT EXISTS user_access_tokens (
"token" TEXT NOT NULL PRIMARY KEY,
"user_id" TEXT NOT NULL,
"timestamp" DATE DEFAULT CURRENT_TIMESTAMP NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id)
);`
stmt, err := db.Prepare(createTableSQL)
if err != nil {
log.Fatal(err)
}
defer stmt.Close()
_, err = stmt.Exec()
if err != nil {
log.Warnln(err)
}
}
func createUsersTable(db *sql.DB) {
log.Traceln("Creating users table...")
createTableSQL := `CREATE TABLE IF NOT EXISTS users (
"id" TEXT,
"access_token" string NOT NULL,
"display_name" TEXT NOT NULL,
"display_color" NUMBER NOT NULL,
"created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
"disabled_at" TIMESTAMP,
"previous_names" TEXT DEFAULT '',
"namechanged_at" TIMESTAMP,
"authenticated_at" TIMESTAMP,
"scopes" TEXT,
"type" TEXT DEFAULT 'STANDARD',
"last_used" DATETIME DEFAULT CURRENT_TIMESTAMP,

View file

@ -1,12 +1,13 @@
package user
import (
"context"
"database/sql"
"errors"
"strings"
"time"
"github.com/owncast/owncast/utils"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
"github.com/teris-io/shortid"
)
@ -55,13 +56,13 @@ func InsertExternalAPIUser(token string, name string, color int, scopes []string
if err != nil {
return err
}
stmt, err := tx.Prepare("INSERT INTO users(id, access_token, display_name, display_color, scopes, type, previous_names) values(?, ?, ?, ?, ?, ?, ?)")
stmt, err := tx.Prepare("INSERT INTO users(id, display_name, display_color, scopes, type, previous_names) values(?, ?, ?, ?, ?, ?)")
if err != nil {
return err
}
defer stmt.Close()
if _, err = stmt.Exec(id, token, name, color, scopesString, "API", name); err != nil {
if _, err = stmt.Exec(id, name, color, scopesString, "API", name); err != nil {
return err
}
@ -69,6 +70,10 @@ func InsertExternalAPIUser(token string, name string, color int, scopes []string
return err
}
if err := addAccessTokenForUser(token, id); err != nil {
return errors.Wrap(err, "unable to save access token for new external api user")
}
return nil
}
@ -83,13 +88,13 @@ func DeleteExternalAPIUser(token string) error {
if err != nil {
return err
}
stmt, err := tx.Prepare("UPDATE users SET disabled_at = ? WHERE access_token = ?")
stmt, err := tx.Prepare("UPDATE users SET disabled_at = CURRENT_TIMESTAMP WHERE id = (SELECT user_id FROM user_access_tokens WHERE token = ?)")
if err != nil {
return err
}
defer stmt.Close()
result, err := stmt.Exec(time.Now(), token)
result, err := stmt.Exec(token)
if err != nil {
return err
}
@ -112,20 +117,20 @@ func GetExternalAPIUserForAccessTokenAndScope(token string, scope string) (*Exte
// so we can efficiently find if a token supports a single scope.
// This is SQLite specific, so if we ever support other database
// backends we need to support other methods.
query := `SELECT id, access_token, scopes, display_name, display_color, created_at, last_used FROM (
WITH RECURSIVE split(id, access_token, scopes, display_name, display_color, created_at, last_used, disabled_at, scope, rest) AS (
SELECT id, access_token, scopes, display_name, display_color, created_at, last_used, disabled_at, '', scopes || ',' FROM users
query := `SELECT id, scopes, display_name, display_color, created_at, last_used FROM user_access_tokens, (
WITH RECURSIVE split(id, scopes, display_name, display_color, created_at, last_used, disabled_at, scope, rest) AS (
SELECT id, scopes, display_name, display_color, created_at, last_used, disabled_at, '', scopes || ',' FROM users
UNION ALL
SELECT id, access_token, scopes, display_name, display_color, created_at, last_used, disabled_at,
SELECT id, scopes, display_name, display_color, created_at, last_used, disabled_at,
substr(rest, 0, instr(rest, ',')),
substr(rest, instr(rest, ',')+1)
FROM split
WHERE rest <> '')
SELECT id, access_token, scopes, display_name, display_color, created_at, last_used, disabled_at, scope
SELECT id, scopes, display_name, display_color, created_at, last_used, disabled_at, scope
FROM split
WHERE scope <> ''
ORDER BY access_token, scope
) AS token WHERE token.access_token = ? AND token.scope = ?`
ORDER BY scope
) AS token WHERE user_access_tokens.token = ? AND token.scope = ?`
row := _datastore.DB.QueryRow(query, token, scope)
integration, err := makeExternalAPIUserFromRow(row)
@ -135,23 +140,18 @@ func GetExternalAPIUserForAccessTokenAndScope(token string, scope string) (*Exte
// GetIntegrationNameForAccessToken will return the integration name associated with a specific access token.
func GetIntegrationNameForAccessToken(token string) *string {
query := "SELECT display_name FROM users WHERE access_token IS ? AND disabled_at IS NULL"
row := _datastore.DB.QueryRow(query, token)
var name string
err := row.Scan(&name)
name, err := _datastore.GetQueries().GetUserDisplayNameByToken(context.Background(), token)
if err != nil {
log.Warnln(err)
return nil
}
return &name
}
// GetExternalAPIUser will return all access tokens.
// GetExternalAPIUser will return all API users with access tokens.
func GetExternalAPIUser() ([]ExternalAPIUser, error) { //nolint
// Get all messages sent within the past day
query := "SELECT id, access_token, display_name, display_color, scopes, created_at, last_used FROM users WHERE type IS 'API' AND disabled_at IS NULL"
query := "SELECT id, token, display_name, display_color, scopes, created_at, last_used FROM users, user_access_tokens WHERE user_access_tokens.user_id = id AND type IS 'API' AND disabled_at IS NULL"
rows, err := _datastore.DB.Query(query)
if err != nil {
@ -170,7 +170,8 @@ func SetExternalAPIUserAccessTokenAsUsed(token string) error {
if err != nil {
return err
}
stmt, err := tx.Prepare("UPDATE users SET last_used = CURRENT_TIMESTAMP WHERE access_token = ?")
// stmt, err := tx.Prepare("UPDATE users SET last_used = CURRENT_TIMESTAMP WHERE access_token = ?")
stmt, err := tx.Prepare("UPDATE users SET last_used = CURRENT_TIMESTAMP WHERE id = (SELECT user_id FROM user_access_tokens WHERE token = ?)")
if err != nil {
return err
}
@ -189,14 +190,13 @@ func SetExternalAPIUserAccessTokenAsUsed(token string) error {
func makeExternalAPIUserFromRow(row *sql.Row) (*ExternalAPIUser, error) {
var id string
var accessToken string
var displayName string
var displayColor int
var scopes string
var createdAt time.Time
var lastUsedAt *time.Time
err := row.Scan(&id, &accessToken, &scopes, &displayName, &displayColor, &createdAt, &lastUsedAt)
err := row.Scan(&id, &scopes, &displayName, &displayColor, &createdAt, &lastUsedAt)
if err != nil {
log.Debugln("unable to convert row to api user", err)
return nil, err
@ -204,7 +204,6 @@ func makeExternalAPIUserFromRow(row *sql.Row) (*ExternalAPIUser, error) {
integration := ExternalAPIUser{
ID: id,
AccessToken: accessToken,
DisplayName: displayName,
DisplayColor: displayColor,
CreatedAt: createdAt,

View file

@ -1,6 +1,7 @@
package user
import (
"context"
"database/sql"
"fmt"
"sort"
@ -8,7 +9,9 @@ import (
"time"
"github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/db"
"github.com/owncast/owncast/utils"
"github.com/pkg/errors"
"github.com/teris-io/shortid"
log "github.com/sirupsen/logrus"
@ -23,16 +26,17 @@ const (
// User represents a single chat user.
type User struct {
ID string `json:"id"`
AccessToken string `json:"-"`
DisplayName string `json:"displayName"`
DisplayColor int `json:"displayColor"`
CreatedAt time.Time `json:"createdAt"`
DisabledAt *time.Time `json:"disabledAt,omitempty"`
PreviousNames []string `json:"previousNames"`
NameChangedAt *time.Time `json:"nameChangedAt,omitempty"`
Scopes []string `json:"scopes,omitempty"`
IsBot bool `json:"isBot"`
ID string `json:"id"`
DisplayName string `json:"displayName"`
DisplayColor int `json:"displayColor"`
CreatedAt time.Time `json:"createdAt"`
DisabledAt *time.Time `json:"disabledAt,omitempty"`
PreviousNames []string `json:"previousNames"`
NameChangedAt *time.Time `json:"nameChangedAt,omitempty"`
Scopes []string `json:"scopes,omitempty"`
IsBot bool `json:"isBot"`
AuthenticatedAt *time.Time `json:"-"`
Authenticated bool `json:"authenticated"`
}
// IsEnabled will return if this single user is enabled.
@ -52,13 +56,8 @@ func SetupUsers() {
}
// CreateAnonymousUser will create a new anonymous user with the provided display name.
func CreateAnonymousUser(displayName string) (*User, error) {
func CreateAnonymousUser(displayName string) (*User, string, error) {
id := shortid.MustGenerate()
accessToken, err := utils.GenerateAccessToken()
if err != nil {
log.Errorln("Unable to create access token for new user")
return nil, err
}
if displayName == "" {
suggestedUsernamesList := data.GetSuggestedUsernamesList()
@ -75,48 +74,62 @@ func CreateAnonymousUser(displayName string) (*User, error) {
user := &User{
ID: id,
AccessToken: accessToken,
DisplayName: displayName,
DisplayColor: displayColor,
CreatedAt: time.Now(),
}
// Create new user.
if err := create(user); err != nil {
return nil, err
return nil, "", err
}
return user, nil
// Assign it an access token.
accessToken, err := utils.GenerateAccessToken()
if err != nil {
log.Errorln("Unable to create access token for new user")
return nil, "", err
}
if err := addAccessTokenForUser(accessToken, id); err != nil {
return nil, "", errors.Wrap(err, "unable to save access token for new user")
}
return user, accessToken, nil
}
// IsDisplayNameAvailable will check if the proposed name is available for use.
func IsDisplayNameAvailable(displayName string) (bool, error) {
if available, err := _datastore.GetQueries().IsDisplayNameAvailable(context.Background(), displayName); err != nil {
return false, errors.Wrap(err, "unable to check if display name is available")
} else if available != 0 {
return false, nil
}
return true, nil
}
// ChangeUsername will change the user associated to userID from one display name to another.
func ChangeUsername(userID string, username string) {
func ChangeUsername(userID string, username string) error {
_datastore.DbLock.Lock()
defer _datastore.DbLock.Unlock()
tx, err := _datastore.DB.Begin()
if err != nil {
log.Debugln(err)
}
defer func() {
if err := tx.Rollback(); err != nil {
log.Debugln(err)
}
}()
stmt, err := tx.Prepare("UPDATE users SET display_name = ?, previous_names = previous_names || ?, namechanged_at = ? WHERE id = ?")
if err != nil {
log.Debugln(err)
}
defer stmt.Close()
_, err = stmt.Exec(username, fmt.Sprintf(",%s", username), time.Now(), userID)
if err != nil {
log.Errorln(err)
if err := _datastore.GetQueries().ChangeDisplayName(context.Background(), db.ChangeDisplayNameParams{
DisplayName: username,
ID: userID,
PreviousNames: sql.NullString{String: fmt.Sprintf(",%s", username), Valid: true},
NamechangedAt: sql.NullTime{Time: time.Now(), Valid: true},
}); err != nil {
return errors.Wrap(err, "unable to change display name")
}
if err := tx.Commit(); err != nil {
log.Errorln("error changing display name of user", userID, err)
}
return nil
}
func addAccessTokenForUser(accessToken, userID string) error {
return _datastore.GetQueries().AddAccessTokenForUser(context.Background(), db.AddAccessTokenForUserParams{
Token: accessToken,
UserID: userID,
})
}
func create(user *User) error {
@ -131,15 +144,16 @@ func create(user *User) error {
_ = tx.Rollback()
}()
stmt, err := tx.Prepare("INSERT INTO users(id, access_token, display_name, display_color, previous_names, created_at) values(?, ?, ?, ?, ?, ?)")
stmt, err := tx.Prepare("INSERT INTO users(id, display_name, display_color, previous_names, created_at) values(?, ?, ?, ?, ?)")
if err != nil {
log.Debugln(err)
}
defer stmt.Close()
_, err = stmt.Exec(user.ID, user.AccessToken, user.DisplayName, user.DisplayColor, user.DisplayName, user.CreatedAt)
_, err = stmt.Exec(user.ID, user.DisplayName, user.DisplayColor, user.DisplayName, user.CreatedAt)
if err != nil {
log.Errorln("error creating new user", err)
return err
}
return tx.Commit()
@ -179,13 +193,53 @@ func SetEnabled(userID string, enabled bool) error {
// GetUserByToken will return a user by an access token.
func GetUserByToken(token string) *User {
_datastore.DbLock.Lock()
defer _datastore.DbLock.Unlock()
u, err := _datastore.GetQueries().GetUserByAccessToken(context.Background(), token)
if err != nil {
return nil
}
query := "SELECT id, display_name, display_color, created_at, disabled_at, previous_names, namechanged_at, scopes FROM users WHERE access_token = ?"
row := _datastore.DB.QueryRow(query, token)
var scopes []string
if u.Scopes.Valid {
scopes = strings.Split(u.Scopes.String, ",")
}
return getUserFromRow(row)
var disabledAt *time.Time
if u.DisabledAt.Valid {
disabledAt = &u.DisabledAt.Time
}
var authenticatedAt *time.Time
if u.AuthenticatedAt.Valid {
authenticatedAt = &u.AuthenticatedAt.Time
}
return &User{
ID: u.ID,
DisplayName: u.DisplayName,
DisplayColor: int(u.DisplayColor),
CreatedAt: u.CreatedAt.Time,
DisabledAt: disabledAt,
PreviousNames: strings.Split(u.PreviousNames.String, ","),
NameChangedAt: &u.NamechangedAt.Time,
AuthenticatedAt: authenticatedAt,
Authenticated: authenticatedAt != nil,
Scopes: scopes,
}
}
// SetAccessTokenToOwner will reassign an access token to be owned by a
// different user. Used for logging in with external auth.
func SetAccessTokenToOwner(token, userID string) error {
return _datastore.GetQueries().SetAccessTokenToOwner(context.Background(), db.SetAccessTokenToOwnerParams{
UserID: userID,
Token: token,
})
}
// SetUserAsAuthenticated will mark that a user has been authenticated
// in some way.
func SetUserAsAuthenticated(userID string) error {
return errors.Wrap(_datastore.GetQueries().SetUserAsAuthenticated(context.Background(), userID), "unable to set user as authenticated")
}
// SetModerator will add or remove moderator status for a single user by ID.
@ -199,6 +253,10 @@ func SetModerator(userID string, isModerator bool) error {
func addScopeToUser(userID string, scope string) error {
u := GetUserByID(userID)
if u == nil {
return errors.New("user not found when modifying scope")
}
scopesString := u.Scopes
scopes := utils.StringSliceToMap(scopesString)
scopes[scope] = true

View file

@ -38,6 +38,14 @@ type ApOutbox struct {
LiveNotification sql.NullBool
}
type Auth struct {
ID int32
UserID string
Token string
Type string
Timestamp time.Time
}
type IpBan struct {
IpAddress string
Notes sql.NullString
@ -50,3 +58,23 @@ type Notification struct {
Destination string
CreatedAt sql.NullTime
}
type User struct {
ID string
DisplayName string
DisplayColor int32
CreatedAt sql.NullTime
DisabledAt sql.NullTime
PreviousNames sql.NullString
NamechangedAt sql.NullTime
Scopes sql.NullString
AuthenticatedAt sql.NullTime
Type sql.NullString
LastUsed interface{}
}
type UserAccessToken struct {
Token string
UserID string
Timestamp time.Time
}

View file

@ -78,3 +78,29 @@ SELECT destination FROM notifications WHERE channel = $1;
-- name: RemoveNotificationDestinationForChannel :exec
DELETE FROM notifications WHERE channel = $1 AND destination = $2;
-- name: AddAuthForUser :exec
INSERT INTO auth(user_id, token, type) values($1, $2, $3);
-- name: GetUserByAuth :one
SELECT users.id, display_name, display_color, users.created_at, disabled_at, previous_names, namechanged_at, authenticated_at, scopes FROM auth, users WHERE token = $1 AND auth.type = $2 AND users.id = auth.user_id;
-- name: AddAccessTokenForUser :exec
INSERT INTO user_access_tokens(token, user_id) values($1, $2);
-- name: GetUserByAccessToken :one
SELECT users.id, display_name, display_color, users.created_at, disabled_at, previous_names, namechanged_at, authenticated_at, scopes FROM users, user_access_tokens WHERE token = $1 AND users.id = user_id;
-- name: GetUserDisplayNameByToken :one
SELECT display_name FROM users, user_access_tokens WHERE token = $1 AND users.id = user_id AND disabled_at = NULL;
-- name: SetAccessTokenToOwner :exec
UPDATE user_access_tokens SET user_id = $1 WHERE token = $2;
-- name: SetUserAsAuthenticated :exec
UPDATE users SET authenticated_at = CURRENT_TIMESTAMP WHERE id = $1;
-- name: IsDisplayNameAvailable :one
SELECT count(*) FROM users WHERE display_name = $1 AND authenticated_at is not null AND disabled_at is NULL;
-- name: ChangeDisplayName :exec
UPDATE users SET display_name = $1, previous_names = previous_names || $2, namechanged_at = $3 WHERE id = $4;

View file

@ -11,6 +11,35 @@ import (
"time"
)
const addAccessTokenForUser = `-- name: AddAccessTokenForUser :exec
INSERT INTO user_access_tokens(token, user_id) values($1, $2)
`
type AddAccessTokenForUserParams struct {
Token string
UserID string
}
func (q *Queries) AddAccessTokenForUser(ctx context.Context, arg AddAccessTokenForUserParams) error {
_, err := q.db.ExecContext(ctx, addAccessTokenForUser, arg.Token, arg.UserID)
return err
}
const addAuthForUser = `-- name: AddAuthForUser :exec
INSERT INTO auth(user_id, token, type) values($1, $2, $3)
`
type AddAuthForUserParams struct {
UserID string
Token string
Type string
}
func (q *Queries) AddAuthForUser(ctx context.Context, arg AddAuthForUserParams) error {
_, err := q.db.ExecContext(ctx, addAuthForUser, arg.UserID, arg.Token, arg.Type)
return err
}
const addFollower = `-- name: AddFollower :exec
INSERT INTO ap_followers(iri, inbox, request, request_object, name, username, image, approved_at) values($1, $2, $3, $4, $5, $6, $7, $8)
`
@ -124,6 +153,27 @@ func (q *Queries) BanIPAddress(ctx context.Context, arg BanIPAddressParams) erro
return err
}
const changeDisplayName = `-- name: ChangeDisplayName :exec
UPDATE users SET display_name = $1, previous_names = previous_names || $2, namechanged_at = $3 WHERE id = $4
`
type ChangeDisplayNameParams struct {
DisplayName string
PreviousNames sql.NullString
NamechangedAt sql.NullTime
ID string
}
func (q *Queries) ChangeDisplayName(ctx context.Context, arg ChangeDisplayNameParams) error {
_, err := q.db.ExecContext(ctx, changeDisplayName,
arg.DisplayName,
arg.PreviousNames,
arg.NamechangedAt,
arg.ID,
)
return err
}
const doesInboundActivityExist = `-- name: DoesInboundActivityExist :one
SELECT count(*) FROM ap_accepted_activities WHERE iri = $1 AND actor = $2 AND TYPE = $3
`
@ -492,6 +542,99 @@ func (q *Queries) GetRejectedAndBlockedFollowers(ctx context.Context) ([]GetReje
return items, nil
}
const getUserByAccessToken = `-- name: GetUserByAccessToken :one
SELECT users.id, display_name, display_color, users.created_at, disabled_at, previous_names, namechanged_at, authenticated_at, scopes FROM users, user_access_tokens WHERE token = $1 AND users.id = user_id
`
type GetUserByAccessTokenRow struct {
ID string
DisplayName string
DisplayColor int32
CreatedAt sql.NullTime
DisabledAt sql.NullTime
PreviousNames sql.NullString
NamechangedAt sql.NullTime
AuthenticatedAt sql.NullTime
Scopes sql.NullString
}
func (q *Queries) GetUserByAccessToken(ctx context.Context, token string) (GetUserByAccessTokenRow, error) {
row := q.db.QueryRowContext(ctx, getUserByAccessToken, token)
var i GetUserByAccessTokenRow
err := row.Scan(
&i.ID,
&i.DisplayName,
&i.DisplayColor,
&i.CreatedAt,
&i.DisabledAt,
&i.PreviousNames,
&i.NamechangedAt,
&i.AuthenticatedAt,
&i.Scopes,
)
return i, err
}
const getUserByAuth = `-- name: GetUserByAuth :one
SELECT users.id, display_name, display_color, users.created_at, disabled_at, previous_names, namechanged_at, authenticated_at, scopes FROM auth, users WHERE token = $1 AND auth.type = $2 AND users.id = auth.user_id
`
type GetUserByAuthParams struct {
Token string
Type string
}
type GetUserByAuthRow struct {
ID string
DisplayName string
DisplayColor int32
CreatedAt sql.NullTime
DisabledAt sql.NullTime
PreviousNames sql.NullString
NamechangedAt sql.NullTime
AuthenticatedAt sql.NullTime
Scopes sql.NullString
}
func (q *Queries) GetUserByAuth(ctx context.Context, arg GetUserByAuthParams) (GetUserByAuthRow, error) {
row := q.db.QueryRowContext(ctx, getUserByAuth, arg.Token, arg.Type)
var i GetUserByAuthRow
err := row.Scan(
&i.ID,
&i.DisplayName,
&i.DisplayColor,
&i.CreatedAt,
&i.DisabledAt,
&i.PreviousNames,
&i.NamechangedAt,
&i.AuthenticatedAt,
&i.Scopes,
)
return i, err
}
const getUserDisplayNameByToken = `-- name: GetUserDisplayNameByToken :one
SELECT display_name FROM users, user_access_tokens WHERE token = $1 AND users.id = user_id AND disabled_at = NULL
`
func (q *Queries) GetUserDisplayNameByToken(ctx context.Context, token string) (string, error) {
row := q.db.QueryRowContext(ctx, getUserDisplayNameByToken, token)
var display_name string
err := row.Scan(&display_name)
return display_name, err
}
const isDisplayNameAvailable = `-- name: IsDisplayNameAvailable :one
SELECT count(*) FROM users WHERE display_name = $1 AND authenticated_at is not null AND disabled_at is NULL
`
func (q *Queries) IsDisplayNameAvailable(ctx context.Context, displayName string) (int64, error) {
row := q.db.QueryRowContext(ctx, isDisplayNameAvailable, displayName)
var count int64
err := row.Scan(&count)
return count, err
}
const isIPAddressBlocked = `-- name: IsIPAddressBlocked :one
SELECT count(*) FROM ip_bans WHERE ip_address = $1
`
@ -549,6 +692,29 @@ func (q *Queries) RemoveNotificationDestinationForChannel(ctx context.Context, a
return err
}
const setAccessTokenToOwner = `-- name: SetAccessTokenToOwner :exec
UPDATE user_access_tokens SET user_id = $1 WHERE token = $2
`
type SetAccessTokenToOwnerParams struct {
UserID string
Token string
}
func (q *Queries) SetAccessTokenToOwner(ctx context.Context, arg SetAccessTokenToOwnerParams) error {
_, err := q.db.ExecContext(ctx, setAccessTokenToOwner, arg.UserID, arg.Token)
return err
}
const setUserAsAuthenticated = `-- name: SetUserAsAuthenticated :exec
UPDATE users SET authenticated_at = CURRENT_TIMESTAMP WHERE id = $1
`
func (q *Queries) SetUserAsAuthenticated(ctx context.Context, id string) error {
_, err := q.db.ExecContext(ctx, setUserAsAuthenticated, id)
return err
}
const updateFollowerByIRI = `-- name: UpdateFollowerByIRI :exec
UPDATE ap_followers SET inbox = $1, name = $2, username = $3, image = $4 WHERE iri = $5
`

View file

@ -49,3 +49,33 @@ CREATE TABLE IF NOT EXISTS notifications (
"destination" TEXT NOT NULL,
"created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
CREATE INDEX channel_index ON notifications (channel);
CREATE TABLE IF NOT EXISTS users (
"id" TEXT,
"display_name" TEXT NOT NULL,
"display_color" INTEGER NOT NULL,
"created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
"disabled_at" TIMESTAMP,
"previous_names" TEXT DEFAULT '',
"namechanged_at" TIMESTAMP,
"scopes" TEXT,
"authenticated_at" TIMESTAMP,
"type" TEXT DEFAULT 'STANDARD',
"last_used" DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id)
);
CREATE TABLE IF NOT EXISTS user_access_tokens (
"token" TEXT NOT NULL PRIMARY KEY,
"user_id" TEXT NOT NULL,
"timestamp" DATE DEFAULT CURRENT_TIMESTAMP NOT NULL
);
CREATE TABLE IF NOT EXISTS auth (
"id" INTEGER NOT NULL PRIMARY KEY,
"user_id" TEXT NOT NULL,
"token" TEXT NOT NULL,
"type" TEXT NOT NULL,
"timestamp" DATE DEFAULT CURRENT_TIMESTAMP NOT NULL
);
CREATE INDEX auth_token ON auth (token);

2
go.mod
View file

@ -73,4 +73,6 @@ require (
github.com/oschwald/maxminddb-golang v1.9.0 // indirect
)
require github.com/andybalholm/cascadia v1.3.1
replace github.com/go-fed/activity => github.com/owncast/activity v1.0.1-0.20211229051252-7821289d4026

33
go.sum
View file

@ -42,18 +42,8 @@ github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRF
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
github.com/amalfra/etag v1.0.0 h1:3PNsV45JS4C8SaQ97jxCoUZv22tGlSRF7dgsP9C7yww=
github.com/amalfra/etag v1.0.0/go.mod h1:NROjmbfRufDsrJFWcnYxGJSlCtTKn4tXTp2zwyqdSbU=
github.com/aws/aws-sdk-go v1.43.31 h1:yJZIr8nMV1hXjAvvOLUFqZRJcHV7udPQBfhJqawDzI0=
github.com/aws/aws-sdk-go v1.43.31/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo=
github.com/aws/aws-sdk-go v1.43.36 h1:8a+pYKNT7wSxUy3fi5dSqKQdfmit7SYGg5fv4zf+WuA=
github.com/aws/aws-sdk-go v1.43.36/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo=
github.com/aws/aws-sdk-go v1.43.37 h1:kyZ7UjaPZaCik+asF33UFOOYSwr9liDRr/UM/vuw8yY=
github.com/aws/aws-sdk-go v1.43.37/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo=
github.com/aws/aws-sdk-go v1.43.38 h1:TDRjsUIsx2aeSuKkyzbwgltIRTbIKH6YCZbZ27JYhPk=
github.com/aws/aws-sdk-go v1.43.38/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo=
github.com/aws/aws-sdk-go v1.43.39 h1:5W8pton/8OuS5hpbAkzfr7e+meAAFkK7LsUehB39L3I=
github.com/aws/aws-sdk-go v1.43.39/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo=
github.com/aws/aws-sdk-go v1.43.41 h1:HaazVplP8/t6SOfybQlNUmjAxLWDKdLdX8BSEHFlJdY=
github.com/aws/aws-sdk-go v1.43.41/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo=
github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c=
github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
github.com/aws/aws-sdk-go v1.43.43 h1:1L06qzQvl4aC3Skfh5rV7xVhGHjIZoHcqy16NoyQ1o4=
github.com/aws/aws-sdk-go v1.43.43/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
@ -261,8 +251,6 @@ github.com/rifflock/lfshook v0.0.0-20180920164130-b9218ef580f5/go.mod h1:GEXHk5H
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/schollz/sqlite3dump v1.3.1 h1:QXizJ7XEJ7hggjqjZ3YRtF3+javm8zKtzNByYtEkPRA=
github.com/schollz/sqlite3dump v1.3.1/go.mod h1:mzSTjZpJH4zAb1FN3iNlhWPbbdyeBpOaTW0hukyMHyI=
github.com/shirou/gopsutil/v3 v3.22.2 h1:wCrArWFkHYIdDxx/FSfF5RB4dpJYW6t7rcp3+zL8uks=
github.com/shirou/gopsutil/v3 v3.22.2/go.mod h1:WapW1AOOPlHyXr+yOyw3uYx36enocrtSoSBy0L5vUHY=
github.com/shirou/gopsutil/v3 v3.22.3 h1:UebRzEomgMpv61e3hgD1tGooqX5trFbdU/ehphbHd00=
github.com/shirou/gopsutil/v3 v3.22.3/go.mod h1:D01hZJ4pVHPpCTZ3m3T2+wDF2YAGfd+H4ifUguaQzHM=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
@ -282,12 +270,8 @@ github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMT
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/teris-io/shortid v0.0.0-20201117134242-e59966efd125 h1:3SNcvBmEPE1YlB1JpVZouslJpI3GBNoiqW7+wb0Rz7w=
github.com/teris-io/shortid v0.0.0-20201117134242-e59966efd125/go.mod h1:M8agBzgqHIhgj7wEn9/0hJUZcrvt9VY+Ln+S1I5Mha0=
github.com/tklauser/go-sysconf v0.3.9 h1:JeUVdAOWhhxVcU6Eqr/ATFHgXk/mmiItdKeJPev3vTo=
github.com/tklauser/go-sysconf v0.3.9/go.mod h1:11DU/5sG7UexIrp/O6g35hrWzu0JxlwQ3LSFUzyeuhs=
github.com/tklauser/go-sysconf v0.3.10 h1:IJ1AZGZRWbY8T5Vfk04D9WOA5WSejdflXxP03OUqALw=
github.com/tklauser/go-sysconf v0.3.10/go.mod h1:C8XykCvCb+Gn0oNCWPIlcb0RuglQTYaQ2hGm7jmxEFk=
github.com/tklauser/numcpus v0.3.0 h1:ILuRUQBtssgnxw0XXIjKUC56fgnOrFoQQ/4+DeU2biQ=
github.com/tklauser/numcpus v0.3.0/go.mod h1:yFGUr7TUHQRAhyqBcEg0Ge34zDBAsIvJJcyE6boqnA8=
github.com/tklauser/numcpus v0.4.0 h1:E53Dm1HjH1/R2/aoCtXtPgzmElmn51aOkhCFSuZq//o=
github.com/tklauser/numcpus v0.4.0/go.mod h1:1+UI3pD8NW14VMwdgJNJ1ESk2UnwhAnz5hMwiKKqXCQ=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
@ -375,16 +359,9 @@ golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81R
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220403103023-749bd193bc2b h1:vI32FkLJNAWtGD4BwkThwEy6XS7ZLLMHkSkYfF8M0W0=
golang.org/x/net v0.0.0-20220403103023-749bd193bc2b/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220407224826-aac1ed45d8e3 h1:EN5+DfgmRMvRUrMGERW2gQl3Vc+Z7ZMnI/xdEpPSf0c=
golang.org/x/net v0.0.0-20220407224826-aac1ed45d8e3/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220412020605-290c469a71a5 h1:bRb386wvrE+oBNdF1d/Xh9mQrfQ4ecYhW5qJ5GvTGT4=
golang.org/x/net v0.0.0-20220412020605-290c469a71a5/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220418201149-a630d4f3e7a2 h1:6mzvA99KwZxbOrxww4EvWVQUnN1+xEu9tafK5ZxkYeA=
golang.org/x/net v0.0.0-20220418201149-a630d4f3e7a2/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220420153159-1850ba15e1be h1:yx80W7nvY5ySWpaU8UWaj5o9e23YgO9BRhQol7Lc+JI=
golang.org/x/net v0.0.0-20220420153159-1850ba15e1be/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
@ -442,9 +419,7 @@ golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210816074244-15123e1e1f71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220111092808-5a964db01320/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220325203850-36772127a21f h1:TrmogKRsSOxRMJbLYGrB4SBbW+LJcEllYBLME5Zk5pU=
@ -463,8 +438,6 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20220224211638-0e9765cccd65 h1:M73Iuj3xbbb9Uk1DYhzydthsj6oOd6l9bpuFcNoUvTs=
golang.org/x/time v0.0.0-20220224211638-0e9765cccd65/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20220411224347-583f2d630306 h1:+gHMid33q6pen7kv9xvT+JRinntgeXO2AeZVd0AWD3w=
golang.org/x/time v0.0.0-20220411224347-583f2d630306/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=

View file

@ -14,6 +14,9 @@ import (
// ExternalAccessTokenHandlerFunc is a function that is called after validing access.
type ExternalAccessTokenHandlerFunc func(user.ExternalAPIUser, http.ResponseWriter, *http.Request)
// UserAccessTokenHandlerFunc is a function that is called after validing user access.
type UserAccessTokenHandlerFunc func(user.User, http.ResponseWriter, *http.Request)
// RequireAdminAuth wraps a handler requiring HTTP basic auth for it using the given
// the stream key as the password and and a hardcoded "admin" for username.
func RequireAdminAuth(handler http.HandlerFunc) http.HandlerFunc {
@ -94,7 +97,7 @@ func RequireExternalAPIAccessToken(scope string, handler ExternalAccessTokenHand
// RequireUserAccessToken will validate a provided user's access token and make sure the associated user is enabled.
// Not to be used for validating 3rd party access.
func RequireUserAccessToken(handler http.HandlerFunc) http.HandlerFunc {
func RequireUserAccessToken(handler UserAccessTokenHandlerFunc) http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
accessToken := r.URL.Query().Get("accessToken")
if accessToken == "" {
@ -119,7 +122,7 @@ func RequireUserAccessToken(handler http.HandlerFunc) http.HandlerFunc {
return
}
handler(w, r)
handler(*user, w, r)
})
}

View file

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

View file

@ -13,6 +13,7 @@ import (
"github.com/owncast/owncast/config"
"github.com/owncast/owncast/controllers"
"github.com/owncast/owncast/controllers/admin"
"github.com/owncast/owncast/controllers/auth/indieauth"
"github.com/owncast/owncast/core/chat"
"github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/core/user"
@ -349,6 +350,15 @@ func Start() error {
http.HandleFunc("/api/admin/config/notifications/browser", middleware.RequireAdminAuth(admin.SetBrowserNotificationConfiguration))
http.HandleFunc("/api/admin/config/notifications/twitter", middleware.RequireAdminAuth(admin.SetTwitterConfiguration))
// Auth
// Start auth flow
http.HandleFunc("/api/auth/indieauth", middleware.RequireUserAccessToken(indieauth.StartAuthFlow))
http.HandleFunc("/api/auth/indieauth/callback", indieauth.HandleRedirect)
// Handle auth provider requests
http.HandleFunc("/api/auth/provider/indieauth", indieauth.HandleAuthEndpoint)
// ActivityPub has its own router
activitypub.Start(data.GetDatastore())

View file

@ -49,6 +49,8 @@
<link rel="icon" type="image/png" sizes="16x16" href="/img/favicon/favicon-16x16.png">
<link rel="manifest" href="/manifest.json">
<link rel="authorization_endpoint" href="/api/auth/provider/indieauth">
<meta name="msapplication-TileColor" content="#ffffff">
<meta name="msapplication-TileImage" content="/img/favicon/ms-icon-144x144.png">
<meta name="theme-color" content="#ffffff">

View file

@ -113,7 +113,7 @@ test('send an external integration action using access token', async (done) => {
const payload = {
body: 'This is a test external action from the automated integration test',
};
const res = await request
await request
.post('/api/integrations/chat/action')
.set('Authorization', 'Bearer ' + accessToken)
.send(payload)

View file

@ -0,0 +1,38 @@
<?xml version="1.0"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.com/svgjs" version="1.1" width="512" height="512" x="0" y="0" viewBox="0 0 32 32" style="enable-background:new 0 0 512 512" xml:space="preserve" class=""><g><title xmlns="http://www.w3.org/2000/svg"/>
<g xmlns="http://www.w3.org/2000/svg">
<g id="check_x5F_alt">
<path style="" d="M16,0C7.164,0,0,7.164,0,16s7.164,16,16,16s16-7.164,16-16S24.836,0,16,0z M13.52,23.383 L6.158,16.02l2.828-2.828l4.533,4.535l9.617-9.617l2.828,2.828L13.52,23.383z" fill="#ffffff" data-original="#030104" class=""/>
</g>
</g>
<g xmlns="http://www.w3.org/2000/svg">
</g>
<g xmlns="http://www.w3.org/2000/svg">
</g>
<g xmlns="http://www.w3.org/2000/svg">
</g>
<g xmlns="http://www.w3.org/2000/svg">
</g>
<g xmlns="http://www.w3.org/2000/svg">
</g>
<g xmlns="http://www.w3.org/2000/svg">
</g>
<g xmlns="http://www.w3.org/2000/svg">
</g>
<g xmlns="http://www.w3.org/2000/svg">
</g>
<g xmlns="http://www.w3.org/2000/svg">
</g>
<g xmlns="http://www.w3.org/2000/svg">
</g>
<g xmlns="http://www.w3.org/2000/svg">
</g>
<g xmlns="http://www.w3.org/2000/svg">
</g>
<g xmlns="http://www.w3.org/2000/svg">
</g>
<g xmlns="http://www.w3.org/2000/svg">
</g>
<g xmlns="http://www.w3.org/2000/svg">
</g>
</g></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
webroot/img/indieauth.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

View file

@ -0,0 +1,2 @@
<?xml version="1.0"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.com/svgjs" version="1.1" width="512" height="512" x="0" y="0" viewBox="0 0 24 24" style="enable-background:new 0 0 512 512" xml:space="preserve"><g><title xmlns="http://www.w3.org/2000/svg"/><circle xmlns="http://www.w3.org/2000/svg" cx="9" cy="5" r="5" fill="#ffffff" data-original="#000000"/><path xmlns="http://www.w3.org/2000/svg" d="m11.534 20.8c-.521-.902-.417-2.013.203-2.8-.62-.787-.724-1.897-.203-2.8l.809-1.4c.445-.771 1.275-1.25 2.166-1.25.122 0 .242.009.361.026.033-.082.075-.159.116-.237-.54-.213-1.123-.339-1.736-.339h-8.5c-2.619 0-4.75 2.131-4.75 4.75v3.5c0 .414.336.75.75.75h10.899z" fill="#ffffff" data-original="#000000"/><path xmlns="http://www.w3.org/2000/svg" d="m21.703 18.469c.02-.155.047-.309.047-.469 0-.161-.028-.314-.047-.469l.901-.682c.201-.152.257-.43.131-.649l-.809-1.4c-.126-.218-.395-.309-.627-.211l-1.037.437c-.253-.193-.522-.363-.819-.487l-.138-1.101c-.032-.25-.244-.438-.496-.438h-1.617c-.252 0-.465.188-.496.438l-.138 1.101c-.297.124-.567.295-.819.487l-1.037-.437c-.232-.098-.501-.008-.627.211l-.809 1.4c-.126.218-.07.496.131.649l.901.682c-.02.155-.047.309-.047.469 0 .161.028.314.047.469l-.901.682c-.201.152-.257.43-.131.649l.809 1.401c.126.218.395.309.627.211l1.037-.438c.253.193.522.363.819.487l.138 1.101c.031.25.243.438.495.438h1.617c.252 0 .465-.188.496-.438l.138-1.101c.297-.124.567-.295.819-.487l1.037.437c.232.098.501.008.627-.211l.809-1.401c.126-.218.07-.496-.131-.649zm-3.703 1.531c-1.105 0-2-.895-2-2s.895-2 2-2 2 .895 2 2-.895 2-2 2z" fill="#ffffff" data-original="#000000"/></g></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -1,42 +1,111 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Owncast</title>
<base target="_blank" />
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0"
/>
<head>
<title>Owncast</title>
<base target="_blank" />
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0" />
<link
rel="apple-touch-icon"
sizes="57x57"
href="/img/favicon/apple-icon-57x57.png"
/>
<link
rel="apple-touch-icon"
sizes="60x60"
href="/img/favicon/apple-icon-60x60.png"
/>
<link
rel="apple-touch-icon"
sizes="72x72"
href="/img/favicon/apple-icon-72x72.png"
/>
<link
rel="apple-touch-icon"
sizes="76x76"
href="/img/favicon/apple-icon-76x76.png"
/>
<link
rel="apple-touch-icon"
sizes="114x114"
href="/img/favicon/apple-icon-114x114.png"
/>
<link
rel="apple-touch-icon"
sizes="120x120"
href="/img/favicon/apple-icon-120x120.png"
/>
<link
rel="apple-touch-icon"
sizes="144x144"
href="/img/favicon/apple-icon-144x144.png"
/>
<link
rel="apple-touch-icon"
sizes="152x152"
href="/img/favicon/apple-icon-152x152.png"
/>
<link
rel="apple-touch-icon"
sizes="180x180"
href="/img/favicon/apple-icon-180x180.png"
/>
<link
rel="icon"
type="image/png"
sizes="192x192"
href="/img/favicon/android-icon-192x192.png"
/>
<link
rel="icon"
type="image/png"
sizes="32x32"
href="/img/favicon/favicon-32x32.png"
/>
<link
rel="icon"
type="image/png"
sizes="96x96"
href="/img/favicon/favicon-96x96.png"
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href="/img/favicon/favicon-16x16.png"
/>
<link rel="manifest" href="/manifest.json" />
<link rel="apple-touch-icon" sizes="57x57" href="/img/favicon/apple-icon-57x57.png">
<link rel="apple-touch-icon" sizes="60x60" href="/img/favicon/apple-icon-60x60.png">
<link rel="apple-touch-icon" sizes="72x72" href="/img/favicon/apple-icon-72x72.png">
<link rel="apple-touch-icon" sizes="76x76" href="/img/favicon/apple-icon-76x76.png">
<link rel="apple-touch-icon" sizes="114x114" href="/img/favicon/apple-icon-114x114.png">
<link rel="apple-touch-icon" sizes="120x120" href="/img/favicon/apple-icon-120x120.png">
<link rel="apple-touch-icon" sizes="144x144" href="/img/favicon/apple-icon-144x144.png">
<link rel="apple-touch-icon" sizes="152x152" href="/img/favicon/apple-icon-152x152.png">
<link rel="apple-touch-icon" sizes="180x180" href="/img/favicon/apple-icon-180x180.png">
<link rel="icon" type="image/png" sizes="192x192" href="/img/favicon/android-icon-192x192.png">
<link rel="icon" type="image/png" sizes="32x32" href="/img/favicon/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="96x96" href="/img/favicon/favicon-96x96.png">
<link rel="icon" type="image/png" sizes="16x16" href="/img/favicon/favicon-16x16.png">
<link rel="manifest" href="/manifest.json">
<link rel="authorization_endpoint" href="/api/auth/provider/indieauth" />
<meta name="msapplication-TileColor" content="#ffffff">
<meta name="msapplication-TileImage" content="/img/favicon/ms-icon-144x144.png">
<meta name="theme-color" content="#ffffff">
<meta name="msapplication-TileColor" content="#ffffff" />
<meta
name="msapplication-TileImage"
content="/img/favicon/ms-icon-144x144.png"
/>
<meta name="theme-color" content="#ffffff" />
<link href="/js/web_modules/tailwindcss/dist/tailwind.min.css" rel="stylesheet" />
<link
href="/js/web_modules/tailwindcss/dist/tailwind.min.css"
rel="stylesheet"
/>
<link href="/js/web_modules/videojs/video-js.min.css" rel="stylesheet" />
<link href="/js/web_modules/@videojs/themes/fantasy/index.css" rel="stylesheet" />
<link href="/js/web_modules/videojs/video-js.min.css" rel="stylesheet" />
<link
href="/js/web_modules/@videojs/themes/fantasy/index.css"
rel="stylesheet"
/>
<link href="/styles/video.css" rel="stylesheet" />
<link href="/styles/chat.css" rel="stylesheet" />
<link href="/styles/user-content.css" rel="stylesheet" />
<link href="/styles/app.css" rel="stylesheet" />
<link href="/styles/video.css" rel="stylesheet" />
<link href="/styles/chat.css" rel="stylesheet" />
<link href="/styles/user-content.css" rel="stylesheet" />
<link href="/styles/app.css" rel="stylesheet" />
<!-- The following script tags are not required for the app to run,
<!-- The following script tags are not required for the app to run,
however they will make it load a lot faster (fewer round trips) when HTTP/2 is used.
If you wish to re-generate this list, run the following shell command
@ -48,105 +117,145 @@
<script type="preload" src="/js/components/platform-logos-list.js"></script>
<script type="preload" src="/js/components/chat/chat-input.js"></script>
<script type="preload" src="/js/components/chat/message.js"></script>
<script type="preload" src="/js/components/chat/content-editable.js"></script>
<script
type="preload"
src="/js/components/chat/content-editable.js"
></script>
<script type="preload" src="/js/components/chat/chat.js"></script>
<script type="preload" src="/js/components/chat/chat-message-view.js"></script>
<script
type="preload"
src="/js/components/chat/chat-message-view.js"
></script>
<script type="preload" src="/js/components/chat/username.js"></script>
<script type="preload" src="/js/components/external-action-modal.js"></script>
<script
type="preload"
src="/js/components/external-action-modal.js"
></script>
<script type="preload" src="/js/components/player.js"></script>
<script type="preload" src="/js/components/video-poster.js"></script>
<script type="preload" src="/js/app.js"></script>
<script type="preload" src="/js/web_modules/preact.js"></script>
<script type="preload" src="/js/web_modules/micromodal/dist/micromodal.min.js"></script>
<script type="preload" src="/js/web_modules/common/_commonjsHelpers-8c19dec8.js"></script>
<script type="preload" src="/js/web_modules/markjs/dist/mark.es6.min.js"></script>
<script type="preload" src="/js/web_modules/@joeattardi/emoji-button.js"></script>
<script
type="preload"
src="/js/web_modules/micromodal/dist/micromodal.min.js"
></script>
<script
type="preload"
src="/js/web_modules/common/_commonjsHelpers-8c19dec8.js"
></script>
<script
type="preload"
src="/js/web_modules/markjs/dist/mark.es6.min.js"
></script>
<script
type="preload"
src="/js/web_modules/@joeattardi/emoji-button.js"
></script>
<script type="preload" src="/js/web_modules/htm.js"></script>
<script type="preload" src="/js/web_modules/videojs/dist/video.min.js"></script>
<script
type="preload"
src="/js/web_modules/videojs/dist/video.min.js"
></script>
<script type="preload" src="/js/chat/register.js"></script>
<script type="preload" src="/js/utils/helpers.js"></script>
<script type="preload" src="/js/utils/user-colors.js"></script>
<script type="preload" src="/js/utils/constants.js"></script>
<script type="preload" src="/js/utils/chat.js"></script>
<script type="preload" src="/js/utils/websocket.js"></script>
</head>
</head>
<body id="app-body" class="scrollbar-hidden bg-gray-300 text-gray-800">
<div id="app">
<div id="loading-logo-container">
<img id="loading-logo" src="/logo">
<body id="app-body" class="scrollbar-hidden bg-gray-300 text-gray-800">
<div id="app">
<div id="loading-logo-container">
<img id="loading-logo" src="/logo" />
</div>
</div>
</div>
<script type="module">
import { h, render } from '/js/web_modules/preact.js';
import htm from '/js/web_modules/htm.js';
const html = htm.bind(h);
<script type="module">
import { h, render } from '/js/web_modules/preact.js';
import htm from '/js/web_modules/htm.js';
const html = htm.bind(h);
import App from '/js/app.js';
render(html`<${App} />`, document.getElementById("app"), document.getElementById("loading-logo-container"));
</script>
import App from '/js/app.js';
render(
html`<${App} />`,
document.getElementById('app'),
document.getElementById('loading-logo-container')
);
</script>
<noscript>
<style>
.noscript {
text-align: center;
padding: 30px;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
font-size: large;
}
<noscript>
<style>
.noscript {
text-align: center;
padding: 30px;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
font-size: large;
}
.noscript a {
display: inline;
color: blue;
text-decoration: underline;
}
.noscript a {
display: inline;
color: blue;
text-decoration: underline;
}
#app {
display: none;
}
#app {
display: none;
}
.logo {
height: 200px;
margin: 30px;
}
h2 {
margin-top: 25px;
margin-bottom: 5px;
font-weight: bold;
}
</style>
<div class="noscript">
<img class="logo" src="/logo" />
<br />
<p>
This website is powered by <a href="https://owncast.online" rel="noopener noreferrer" target="_blank">Owncast</a>.
</p>
<p>
Owncast uses JavaScript for playing the HTTP Live Streaming (HLS) video, and its chat client. But your web browser does not seem to support JavaScript, or you have it disabled.
</p>
<p>
For the best experience, you should use a different browser with JavaScript support. If you have disabled JavaScript in your browser, you can re-enable it.
</p>
<h2>
How can I watch this stream without JavaScript?
</h2>
<p>
You can open the URL of this website in your media player (such as <a href="https://mpv.io" rel="noopener noreferrer" target="_blank">mpv</a> or <a href="https://www.videolan.org/vlc/" rel="noopener noreferrer" target="_blank">VLC</a>) to watch the stream.
</p>
<h2>
How can I chat with the others without JavaScript?
</h2>
<p>
Currently, there is no option to use the chat without JavaScript.
</p>
</div>
</noscript>
</body>
.logo {
height: 200px;
margin: 30px;
}
h2 {
margin-top: 25px;
margin-bottom: 5px;
font-weight: bold;
}
</style>
<div class="noscript">
<img class="logo" src="/logo" />
<br />
<p>
This website is powered by
<a
href="https://owncast.online"
rel="noopener noreferrer"
target="_blank"
>Owncast</a
>.
</p>
<p>
Owncast uses JavaScript for playing the HTTP Live Streaming (HLS)
video, and its chat client. But your web browser does not seem to
support JavaScript, or you have it disabled.
</p>
<p>
For the best experience, you should use a different browser with
JavaScript support. If you have disabled JavaScript in your browser,
you can re-enable it.
</p>
<h2>How can I watch this stream without JavaScript?</h2>
<p>
You can open the URL of this website in your media player (such as
<a href="https://mpv.io" rel="noopener noreferrer" target="_blank"
>mpv</a
>
or
<a
href="https://www.videolan.org/vlc/"
rel="noopener noreferrer"
target="_blank"
>VLC</a
>) to watch the stream.
</p>
<h2>How can I chat with the others without JavaScript?</h2>
<p>Currently, there is no option to use the chat without JavaScript.</p>
</div>
</noscript>
</body>
</html>

View file

@ -27,6 +27,8 @@ import FediverseFollowModal, {
import { NotifyButton, NotifyModal } from './components/notification.js';
import { isPushNotificationSupported } from './notification/registerWeb.js';
import ChatSettingsModal from './components/chat-settings-modal.js';
import {
addNewlines,
checkUrlPathForDisplay,
@ -110,6 +112,9 @@ export default class App extends Component {
externalActionModalData: null,
fediverseModalData: null,
// authentication options
indieAuthEnabled: false,
// routing & tabbing
section: '',
sectionId: '',
@ -144,6 +149,8 @@ export default class App extends Component {
this.closeFediverseFollowModal = this.closeFediverseFollowModal.bind(this);
this.displayNotificationModal = this.displayNotificationModal.bind(this);
this.closeNotificationModal = this.closeNotificationModal.bind(this);
this.showAuthModal = this.showAuthModal.bind(this);
this.closeAuthModal = this.closeAuthModal.bind(this);
// player events
this.handlePlayerReady = this.handlePlayerReady.bind(this);
@ -268,8 +275,14 @@ export default class App extends Component {
}
setConfigData(data = {}) {
const { name, summary, chatDisabled, socketHostOverride, notifications } =
data;
const {
name,
summary,
chatDisabled,
socketHostOverride,
notifications,
authentication,
} = data;
window.document.title = name;
this.socketHostOverride = socketHostOverride;
@ -281,10 +294,12 @@ export default class App extends Component {
}
this.hasConfiguredChat = true;
const { indieAuthEnabled } = authentication;
this.setState({
canChat: !chatDisabled,
notifications,
indieAuthEnabled,
configData: {
...data,
summary: summary && addNewlines(summary),
@ -618,6 +633,17 @@ export default class App extends Component {
}
}
showAuthModal() {
const data = {
title: 'Chat',
};
this.setState({ authModalData: data });
}
closeAuthModal() {
this.setState({ authModalData: null });
}
handleWebsocketMessage(e) {
if (e.type === SOCKET_MESSAGE_TYPES.ERROR_USER_DISABLED) {
// User has been actively disabled on the backend. Turn off chat for them.
@ -637,10 +663,10 @@ export default class App extends Component {
// When connected the user will return an event letting us know what our
// user details are so we can display them properly.
const { user } = e;
const { displayName } = user;
const { displayName, authenticated } = user;
this.setState({
username: displayName,
authenticated,
isModerator: checkIsModerator(e),
});
}
@ -724,17 +750,20 @@ export default class App extends Component {
streamTitle,
touchKeyboardActive,
username,
authenticated,
viewerCount,
websocket,
windowHeight,
windowWidth,
fediverseModalData,
authModalData,
externalActionModalData,
notificationModalData,
notifications,
lastDisconnectTime,
section,
sectionId,
indieAuthEnabled,
} = state;
const {
@ -864,11 +893,32 @@ export default class App extends Component {
/>`}
/>`;
const authModal =
authModalData &&
html`
<${ExternalActionModal}
onClose=${this.closeAuthModal}
action=${authModalData}
useIframe=${false}
customContent=${html`<${ChatSettingsModal}
name=${name}
logo=${logo}
onUsernameChange=${this.handleUsernameChange}
username=${username}
accessToken=${this.state.accessToken}
authenticated=${authenticated}
onClose=${this.closeAuthModal}
indieAuthEnabled=${indieAuthEnabled}
/>`}
/>
`;
const chat = this.state.websocket
? html`
<${Chat}
websocket=${websocket}
username=${username}
authenticated=${authenticated}
chatInputEnabled=${chatInputEnabled && !chatDisabled}
instanceTitle=${name}
accessToken=${accessToken}
@ -911,6 +961,8 @@ export default class App extends Component {
});
}
const authIcon = '/img/user-settings.svg';
return html`
<div
id="app-container"
@ -942,9 +994,11 @@ export default class App extends Component {
>
</h1>
<${ChatMenu} username=${username} isModerator=${isModerator} onUsernameChange=${
this.handleUsernameChange
} onFocus=${this.handleFormFocus} onBlur=${
<${ChatMenu} username=${username} isModerator=${isModerator} showAuthModal=${
indieAuthEnabled && this.showAuthModal
} onUsernameChange=${this.handleUsernameChange} onFocus=${
this.handleFormFocus
} onBlur=${
this.handleFormBlur
} chatDisabled=${chatDisabled} noVideoContent=${noVideoContent} handleChatPanelToggle=${
this.handleChatPanelToggle
@ -1027,7 +1081,7 @@ export default class App extends Component {
</footer>
${chat} ${externalActionModal} ${fediverseFollowModal}
${notificationModal}
${notificationModal} ${authModal}
</div>
`;
}

View file

@ -0,0 +1,3 @@
import { URL_CHAT_INDIEAUTH_BEGIN } from '../utils/constants.js';
export async function beginIndieAuthFlow() {}

View file

@ -0,0 +1,192 @@
import { h, Component } from '/js/web_modules/preact.js';
import htm from '/js/web_modules/htm.js';
const html = htm.bind(h);
export default class IndieAuthForm extends Component {
constructor(props) {
super(props);
this.submitButtonPressed = this.submitButtonPressed.bind(this);
this.state = {
errorMessage: null,
loading: false,
valid: false,
};
}
async submitButtonPressed() {
const { accessToken, authenticated } = this.props;
const { host, valid } = this.state;
if (!valid) {
return;
}
const url = `/api/auth/indieauth?accessToken=${accessToken}`;
const data = { authHost: host };
this.setState({ loading: true });
try {
const rawResponse = await fetch(url, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
const content = await rawResponse.json();
if (content.message) {
this.setState({ errorMessage: content.message, loading: false });
return;
} else if (!content.redirect) {
this.setState({
errorMessage: 'Auth provider did not return a redirect URL.',
loading: false,
});
return;
}
if (content.redirect) {
const redirect = content.redirect;
window.location = redirect;
}
} catch (e) {
console.error(e);
this.setState({ errorMessage: e, loading: false });
}
}
onInput = (e) => {
const { value } = e.target;
let valid = validateURL(value);
this.setState({ host: value, valid });
};
render() {
const { errorMessage, loading, host, valid } = this.state;
const { authenticated } = this.props;
const buttonState = valid ? '' : 'cursor-not-allowed opacity-50';
const loaderStyle = loading ? 'flex' : 'none';
const message = !authenticated
? `While you can chat completely anonymously you can also add
authentication so you can rejoin with the same chat persona from any
device or browser.`
: html`<span
><b>You are already authenticated</b>. However, you can add other
external sites or log in as a different user.</span
>`;
let errorMessageText = errorMessage;
if (!!errorMessageText) {
if (errorMessageText.includes('url does not support indieauth')) {
errorMessageText =
'The provided URL is either invalid or does not support IndieAuth.';
}
}
const error = errorMessage
? html` <div
class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative"
role="alert"
>
<div class="font-bold mb-2">There was an error.</div>
<div class="block mt-2">
<div>${errorMessageText}</div>
</div>
</div>`
: null;
return html` <div>
<p class="text-gray-700">${message}</p>
<p>${error}</p>
<div class="mb34">
<label
class="block text-gray-700 text-sm font-semibold mt-6"
for="username"
>
Your domain
</label>
<input
onInput=${this.onInput}
type="url"
value=${host}
class="border bg-white rounded w-full py-2 px-3 mb-2 mt-2 text-indigo-700 leading-tight focus:outline-none focus:shadow-outline"
id="username"
type="text"
placeholder="https://yoursite.com"
/>
<button
class="bg-indigo-500 hover:bg-indigo-600 text-white font-bold py-2 mt-6 px-4 rounded focus:outline-none focus:shadow-outline ${buttonState}"
type="button"
onClick=${this.submitButtonPressed}
>
Authenticate with your domain
</button>
</div>
<p class="mt-4">
<details>
<summary class="cursor-pointer">
Learn more about <span class="text-blue-500">IndieAuth</span>
</summary>
<div class="inline">
<p class="mt-4">
IndieAuth allows for a completely independent and decentralized
way of identifying yourself using your own domain.
</p>
<p class="mt-4">
If you run an Owncast instance, you can use that domain here.
Otherwise, ${' '}
<a class="underline" href="https://indieauth.net/#providers"
>learn more about how you can support IndieAuth</a
>.
</p>
</div>
</details>
</p>
<p class="mt-4">
<b>Note:</b> This is for authentication purposes only, and no personal
information will be accessed or stored.
</p>
<div
id="follow-loading-spinner-container"
style="display: ${loaderStyle}"
>
<img id="follow-loading-spinner" src="/img/loading.gif" />
<p class="text-gray-700 text-lg">Authenticating.</p>
<p class="text-gray-600 text-lg">Please wait...</p>
</div>
</div>`;
}
}
function validateURL(url) {
if (!url) {
return false;
}
try {
const u = new URL(url);
if (!u) {
return false;
}
if (u.protocol !== 'https:') {
return false;
}
} catch (e) {
return false;
}
return true;
}

View file

@ -0,0 +1,44 @@
import { h, Component } from '/js/web_modules/preact.js';
import htm from '/js/web_modules/htm.js';
import TabBar from './tab-bar.js';
import IndieAuthForm from './auth-indieauth.js';
const html = htm.bind(h);
export default class ChatSettingsModal extends Component {
render() {
const {
accessToken,
authenticated,
username,
onUsernameChange,
indieAuthEnabled,
} = this.props;
const TAB_CONTENT = [
{
label: html`<span style=${{ display: 'flex', alignItems: 'center' }}
><img
style=${{
display: 'inline',
height: '0.8em',
marginRight: '5px',
}}
src="/img/indieauth.png"
/>
IndieAuth</span
>`,
content: html`<${IndieAuthForm}}
accessToken=${accessToken}
authenticated=${authenticated}
/>`,
},
];
return html`
<div class="bg-gray-100 bg-center bg-no-repeat p-5">
<${TabBar} tabs=${TAB_CONTENT} ariaLabel="Chat settings" />
</div>
`;
}
}

View file

@ -20,6 +20,7 @@ export const ChatMenu = (props) => {
noVideoContent,
handleChatPanelToggle,
onUsernameChange,
showAuthModal,
onFocus,
onBlur,
} = props;
@ -34,6 +35,15 @@ export const ChatMenu = (props) => {
if (chatMenuOpen) setView('main');
}, [chatMenuOpen]);
const authMenuItem =
showAuthModal &&
html`<li>
<button type="button" id="chat-auth" onClick=${showAuthModal}>
Authenticate
<span><${ChatIcon} /></span>
</button>
</li>`;
return html`
<${Context.Provider} value=${props}>
<div class="chat-menu p-2 relative shadow-lg" ref=${chatMenuRef}>
@ -74,6 +84,7 @@ export const ChatMenu = (props) => {
onBlur=${onBlur}
/>
</li>
${authMenuItem}
<li>
<button
type="button"

View file

@ -49,7 +49,8 @@ export default class ChatMessageView extends Component {
if (!user) {
return null;
}
const { displayName, displayColor, createdAt, isBot } = user;
const { displayName, displayColor, createdAt, isBot, authenticated } = user;
const isAuthorModerator = checkIsModerator(message);
const isMessageModeratable =
@ -78,7 +79,7 @@ export default class ChatMessageView extends Component {
isMessageModeratable ? 'moderatable' : ''
}`;
const messageAuthorFlair = isAuthorModerator
const isModeratorFlair = isAuthorModerator
? html`<img
class="flair"
title="Moderator"
@ -95,6 +96,14 @@ export default class ChatMessageView extends Component {
/>`
: null;
const authorAuthenticatedFlair = authenticated
? html`<img
class="flair"
title="Authenticated"
src="/img/authenticated.svg"
/>`
: null;
return html`
<div
style=${backgroundStyle}
@ -107,7 +116,8 @@ export default class ChatMessageView extends Component {
class="message-author font-bold"
title=${userMetadata}
>
${isBotFlair} ${messageAuthorFlair} ${displayName}
${isBotFlair} ${authorAuthenticatedFlair} ${isModeratorFlair}
${displayName}
</div>
${isMessageModeratable &&
html`<${ModeratorActions}

View file

@ -102,6 +102,10 @@ export default class UsernameForm extends Component {
},
};
const moderatorFlag = html`
<img src="/img/moderator-nobackground.svg" class="moderator-flag" />
`;
return html`
<div id="user-info">
<button

View file

@ -21,6 +21,7 @@ export const URL_PLAYBACK_METRICS = `/api/metrics/playback`;
export const URL_REGISTER_NOTIFICATION = `/api/notifications/register`;
export const URL_REGISTER_EMAIL_NOTIFICATION = `/api/notifications/register/email`;
export const URL_CHAT_INDIEAUTH_BEGIN = `/api/auth/indieauth`;
export const TIMER_STATUS_UPDATE = 5000; // ms
export const TIMER_DISABLE_CHAT_AFTER_OFFLINE = 5 * 60 * 1000; // 5 mins