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

View file

@ -16,22 +16,23 @@ import (
) )
type webConfigResponse struct { type webConfigResponse struct {
Name string `json:"name"` Name string `json:"name"`
Summary string `json:"summary"` Summary string `json:"summary"`
Logo string `json:"logo"` Logo string `json:"logo"`
Tags []string `json:"tags"` Tags []string `json:"tags"`
Version string `json:"version"` Version string `json:"version"`
NSFW bool `json:"nsfw"` NSFW bool `json:"nsfw"`
SocketHostOverride string `json:"socketHostOverride,omitempty"` SocketHostOverride string `json:"socketHostOverride,omitempty"`
ExtraPageContent string `json:"extraPageContent"` ExtraPageContent string `json:"extraPageContent"`
StreamTitle string `json:"streamTitle,omitempty"` // What's going on with the current stream StreamTitle string `json:"streamTitle,omitempty"` // What's going on with the current stream
SocialHandles []models.SocialHandle `json:"socialHandles"` SocialHandles []models.SocialHandle `json:"socialHandles"`
ChatDisabled bool `json:"chatDisabled"` ChatDisabled bool `json:"chatDisabled"`
ExternalActions []models.ExternalAction `json:"externalActions"` ExternalActions []models.ExternalAction `json:"externalActions"`
CustomStyles string `json:"customStyles"` CustomStyles string `json:"customStyles"`
MaxSocketPayloadSize int `json:"maxSocketPayloadSize"` MaxSocketPayloadSize int `json:"maxSocketPayloadSize"`
Federation federationConfigResponse `json:"federation"` Federation federationConfigResponse `json:"federation"`
Notifications notificationsConfigResponse `json:"notifications"` Notifications notificationsConfigResponse `json:"notifications"`
Authentication authenticationConfigResponse `json:"authentication"`
} }
type federationConfigResponse struct { type federationConfigResponse struct {
@ -49,6 +50,10 @@ type notificationsConfigResponse struct {
Browser browserNotificationsConfigResponse `json:"browser"` Browser browserNotificationsConfigResponse `json:"browser"`
} }
type authenticationConfigResponse struct {
IndieAuthEnabled bool `json:"indieAuthEnabled"`
}
// GetWebConfig gets the status of the server. // GetWebConfig gets the status of the server.
func GetWebConfig(w http.ResponseWriter, r *http.Request) { func GetWebConfig(w http.ResponseWriter, r *http.Request) {
middleware.EnableCors(w) middleware.EnableCors(w)
@ -97,6 +102,10 @@ func GetWebConfig(w http.ResponseWriter, r *http.Request) {
}, },
} }
authenticationResponse := authenticationConfigResponse{
IndieAuthEnabled: data.GetServerURL() != "",
}
configuration := webConfigResponse{ configuration := webConfigResponse{
Name: data.GetServerName(), Name: data.GetServerName(),
Summary: serverSummary, Summary: serverSummary,
@ -114,6 +123,7 @@ func GetWebConfig(w http.ResponseWriter, r *http.Request) {
MaxSocketPayloadSize: config.MaxSocketPayloadSize, MaxSocketPayloadSize: config.MaxSocketPayloadSize,
Federation: federationResponse, Federation: federationResponse,
Notifications: notificationsResponse, Notifications: notificationsResponse,
Authentication: authenticationResponse,
} }
if err := json.NewEncoder(w).Encode(configuration); err != nil { 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) 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" "encoding/json"
"net/http" "net/http"
"github.com/owncast/owncast/core/user"
"github.com/owncast/owncast/notifications" "github.com/owncast/owncast/notifications"
"github.com/owncast/owncast/utils" "github.com/owncast/owncast/utils"
@ -13,7 +14,7 @@ import (
// RegisterForLiveNotifications will register a channel + destination to be // RegisterForLiveNotifications will register a channel + destination to be
// notified when a stream goes live. // 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 { if r.Method != POST {
WriteSimpleResponse(w, false, r.Method+" not supported") WriteSimpleResponse(w, false, r.Method+" not supported")
return return

View file

@ -21,6 +21,8 @@ func (s *Server) userNameChanged(eventData chatClientEvent) {
} }
proposedUsername := receivedEvent.NewName proposedUsername := receivedEvent.NewName
// Check if name is on the blocklist
blocklist := data.GetForbiddenUsernameList() blocklist := data.GetForbiddenUsernameList()
for _, blockedName := range blocklist { 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) savedUser := user.GetUserByToken(eventData.client.accessToken)
oldName := savedUser.DisplayName oldName := savedUser.DisplayName
// Save the new name // 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 // Update the connected clients associated user with the new name
now := time.Now() now := time.Now()

View file

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

View file

@ -104,16 +104,17 @@ func makeUserMessageEventFromRowData(row rowData) events.UserMessageEvent {
scopeSlice := strings.Split(scopes, ",") scopeSlice := strings.Split(scopes, ",")
u := user.User{ u := user.User{
ID: *row.userID, ID: *row.userID,
AccessToken: "", DisplayName: displayName,
DisplayName: displayName, DisplayColor: displayColor,
DisplayColor: displayColor, CreatedAt: createdAt,
CreatedAt: createdAt, DisabledAt: row.userDisabledAt,
DisabledAt: row.userDisabledAt, NameChangedAt: row.userNameChangedAt,
NameChangedAt: row.userNameChangedAt, PreviousNames: previousUsernames,
PreviousNames: previousUsernames, AuthenticatedAt: row.userAuthenticatedAt,
Scopes: scopeSlice, Authenticated: row.userAuthenticatedAt != nil,
IsBot: isBot, Scopes: scopeSlice,
IsBot: isBot,
} }
message := events.UserMessageEvent{ message := events.UserMessageEvent{
@ -195,14 +196,15 @@ type rowData struct {
image *string image *string
link *string link *string
userDisplayName *string userDisplayName *string
userDisplayColor *int userDisplayColor *int
userCreatedAt *time.Time userCreatedAt *time.Time
userDisabledAt *time.Time userDisabledAt *time.Time
previousUsernames *string previousUsernames *string
userNameChangedAt *time.Time userNameChangedAt *time.Time
userScopes *string userAuthenticatedAt *time.Time
userType *string userScopes *string
userType *string
} }
func getChat(query string) []interface{} { func getChat(query string) []interface{} {
@ -235,9 +237,11 @@ func getChat(query string) []interface{} {
&row.userDisabledAt, &row.userDisabledAt,
&row.previousUsernames, &row.previousUsernames,
&row.userNameChangedAt, &row.userNameChangedAt,
&row.userAuthenticatedAt,
&row.userScopes, &row.userScopes,
&row.userType, &row.userType,
); err != nil { ); err != nil {
log.Errorln(err)
log.Errorln("There is a problem converting query to chat objects. Please report this:", query) log.Errorln("There is a problem converting query to chat objects. Please report this:", query)
break break
} }
@ -274,7 +278,7 @@ func GetChatModerationHistory() []interface{} {
} }
// Get all messages regardless of visibility // 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) result := getChat(query)
_historyCache = &result _historyCache = &result
@ -285,7 +289,7 @@ func GetChatModerationHistory() []interface{} {
// GetChatHistory will return all the chat messages suitable for returning as user-facing chat history. // GetChatHistory will return all the chat messages suitable for returning as user-facing chat history.
func GetChatHistory() []interface{} { func GetChatHistory() []interface{} {
// Get all visible messages // 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) m := getChat(query)
// Invert order of messages // 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 // Get a list of IDs to send to the connected clients to hide
ids := make([]string, 0) 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) messages := getChat(query)
if len(messages) == 0 { 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 // A user is required to use the websocket
user := user.GetUserByToken(accessToken) user := user.GetUserByToken(accessToken)
if user == nil { if user == nil {
// Send error that registration is required
_ = conn.WriteJSON(events.EventPayload{ _ = conn.WriteJSON(events.EventPayload{
"type": events.ErrorNeedsRegistration, "type": events.ErrorNeedsRegistration,
}) })
// Send error that registration is required
_ = conn.Close() _ = conn.Close()
return return
} }

View file

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

View file

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

View file

@ -2,6 +2,8 @@ package data
import ( import (
"database/sql" "database/sql"
"fmt"
"strings"
"time" "time"
"github.com/owncast/owncast/utils" "github.com/owncast/owncast/utils"
@ -9,7 +11,75 @@ import (
"github.com/teris-io/shortid" "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) { 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") stmt, err := db.Prepare("ALTER TABLE ap_followers ADD COLUMN request_object BLOB")
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)

View file

@ -6,18 +6,37 @@ import (
log "github.com/sirupsen/logrus" 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) { func createUsersTable(db *sql.DB) {
log.Traceln("Creating users table...") log.Traceln("Creating users table...")
createTableSQL := `CREATE TABLE IF NOT EXISTS users ( createTableSQL := `CREATE TABLE IF NOT EXISTS users (
"id" TEXT, "id" TEXT,
"access_token" string NOT NULL,
"display_name" TEXT NOT NULL, "display_name" TEXT NOT NULL,
"display_color" NUMBER NOT NULL, "display_color" NUMBER NOT NULL,
"created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
"disabled_at" TIMESTAMP, "disabled_at" TIMESTAMP,
"previous_names" TEXT DEFAULT '', "previous_names" TEXT DEFAULT '',
"namechanged_at" TIMESTAMP, "namechanged_at" TIMESTAMP,
"authenticated_at" TIMESTAMP,
"scopes" TEXT, "scopes" TEXT,
"type" TEXT DEFAULT 'STANDARD', "type" TEXT DEFAULT 'STANDARD',
"last_used" DATETIME DEFAULT CURRENT_TIMESTAMP, "last_used" DATETIME DEFAULT CURRENT_TIMESTAMP,

View file

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

View file

@ -1,6 +1,7 @@
package user package user
import ( import (
"context"
"database/sql" "database/sql"
"fmt" "fmt"
"sort" "sort"
@ -8,7 +9,9 @@ import (
"time" "time"
"github.com/owncast/owncast/core/data" "github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/db"
"github.com/owncast/owncast/utils" "github.com/owncast/owncast/utils"
"github.com/pkg/errors"
"github.com/teris-io/shortid" "github.com/teris-io/shortid"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
@ -23,16 +26,17 @@ const (
// User represents a single chat user. // User represents a single chat user.
type User struct { type User struct {
ID string `json:"id"` ID string `json:"id"`
AccessToken string `json:"-"` DisplayName string `json:"displayName"`
DisplayName string `json:"displayName"` DisplayColor int `json:"displayColor"`
DisplayColor int `json:"displayColor"` CreatedAt time.Time `json:"createdAt"`
CreatedAt time.Time `json:"createdAt"` DisabledAt *time.Time `json:"disabledAt,omitempty"`
DisabledAt *time.Time `json:"disabledAt,omitempty"` PreviousNames []string `json:"previousNames"`
PreviousNames []string `json:"previousNames"` NameChangedAt *time.Time `json:"nameChangedAt,omitempty"`
NameChangedAt *time.Time `json:"nameChangedAt,omitempty"` Scopes []string `json:"scopes,omitempty"`
Scopes []string `json:"scopes,omitempty"` IsBot bool `json:"isBot"`
IsBot bool `json:"isBot"` AuthenticatedAt *time.Time `json:"-"`
Authenticated bool `json:"authenticated"`
} }
// IsEnabled will return if this single user is enabled. // 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. // 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() 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 == "" { if displayName == "" {
suggestedUsernamesList := data.GetSuggestedUsernamesList() suggestedUsernamesList := data.GetSuggestedUsernamesList()
@ -75,48 +74,62 @@ func CreateAnonymousUser(displayName string) (*User, error) {
user := &User{ user := &User{
ID: id, ID: id,
AccessToken: accessToken,
DisplayName: displayName, DisplayName: displayName,
DisplayColor: displayColor, DisplayColor: displayColor,
CreatedAt: time.Now(), CreatedAt: time.Now(),
} }
// Create new user.
if err := create(user); err != nil { 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. // 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() _datastore.DbLock.Lock()
defer _datastore.DbLock.Unlock() defer _datastore.DbLock.Unlock()
tx, err := _datastore.DB.Begin() if err := _datastore.GetQueries().ChangeDisplayName(context.Background(), db.ChangeDisplayNameParams{
if err != nil { DisplayName: username,
log.Debugln(err) ID: userID,
} PreviousNames: sql.NullString{String: fmt.Sprintf(",%s", username), Valid: true},
defer func() { NamechangedAt: sql.NullTime{Time: time.Now(), Valid: true},
if err := tx.Rollback(); err != nil { }); err != nil {
log.Debugln(err) return errors.Wrap(err, "unable to change display name")
}
}()
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 := tx.Commit(); err != nil { return nil
log.Errorln("error changing display name of user", userID, err) }
}
func addAccessTokenForUser(accessToken, userID string) error {
return _datastore.GetQueries().AddAccessTokenForUser(context.Background(), db.AddAccessTokenForUserParams{
Token: accessToken,
UserID: userID,
})
} }
func create(user *User) error { func create(user *User) error {
@ -131,15 +144,16 @@ func create(user *User) error {
_ = tx.Rollback() _ = 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 { if err != nil {
log.Debugln(err) log.Debugln(err)
} }
defer stmt.Close() 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 { if err != nil {
log.Errorln("error creating new user", err) log.Errorln("error creating new user", err)
return err
} }
return tx.Commit() return tx.Commit()
@ -179,13 +193,53 @@ func SetEnabled(userID string, enabled bool) error {
// GetUserByToken will return a user by an access token. // GetUserByToken will return a user by an access token.
func GetUserByToken(token string) *User { func GetUserByToken(token string) *User {
_datastore.DbLock.Lock() u, err := _datastore.GetQueries().GetUserByAccessToken(context.Background(), token)
defer _datastore.DbLock.Unlock() 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 = ?" var scopes []string
row := _datastore.DB.QueryRow(query, token) 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. // 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 { func addScopeToUser(userID string, scope string) error {
u := GetUserByID(userID) u := GetUserByID(userID)
if u == nil {
return errors.New("user not found when modifying scope")
}
scopesString := u.Scopes scopesString := u.Scopes
scopes := utils.StringSliceToMap(scopesString) scopes := utils.StringSliceToMap(scopesString)
scopes[scope] = true scopes[scope] = true

View file

@ -38,6 +38,14 @@ type ApOutbox struct {
LiveNotification sql.NullBool LiveNotification sql.NullBool
} }
type Auth struct {
ID int32
UserID string
Token string
Type string
Timestamp time.Time
}
type IpBan struct { type IpBan struct {
IpAddress string IpAddress string
Notes sql.NullString Notes sql.NullString
@ -50,3 +58,23 @@ type Notification struct {
Destination string Destination string
CreatedAt sql.NullTime 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 -- name: RemoveNotificationDestinationForChannel :exec
DELETE FROM notifications WHERE channel = $1 AND destination = $2; 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" "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 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) 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 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 const doesInboundActivityExist = `-- name: DoesInboundActivityExist :one
SELECT count(*) FROM ap_accepted_activities WHERE iri = $1 AND actor = $2 AND TYPE = $3 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 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 const isIPAddressBlocked = `-- name: IsIPAddressBlocked :one
SELECT count(*) FROM ip_bans WHERE ip_address = $1 SELECT count(*) FROM ip_bans WHERE ip_address = $1
` `
@ -549,6 +692,29 @@ func (q *Queries) RemoveNotificationDestinationForChannel(ctx context.Context, a
return err 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 const updateFollowerByIRI = `-- name: UpdateFollowerByIRI :exec
UPDATE ap_followers SET inbox = $1, name = $2, username = $3, image = $4 WHERE iri = $5 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, "destination" TEXT NOT NULL,
"created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP); "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
CREATE INDEX channel_index ON notifications (channel); 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 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 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/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 h1:3PNsV45JS4C8SaQ97jxCoUZv22tGlSRF7dgsP9C7yww=
github.com/amalfra/etag v1.0.0/go.mod h1:NROjmbfRufDsrJFWcnYxGJSlCtTKn4tXTp2zwyqdSbU= github.com/amalfra/etag v1.0.0/go.mod h1:NROjmbfRufDsrJFWcnYxGJSlCtTKn4tXTp2zwyqdSbU=
github.com/aws/aws-sdk-go v1.43.31 h1:yJZIr8nMV1hXjAvvOLUFqZRJcHV7udPQBfhJqawDzI0= github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c=
github.com/aws/aws-sdk-go v1.43.31/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo= github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
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/aws/aws-sdk-go v1.43.43 h1:1L06qzQvl4aC3Skfh5rV7xVhGHjIZoHcqy16NoyQ1o4= 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/aws/aws-sdk-go v1.43.43/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= 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/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 h1:QXizJ7XEJ7hggjqjZ3YRtF3+javm8zKtzNByYtEkPRA=
github.com/schollz/sqlite3dump v1.3.1/go.mod h1:mzSTjZpJH4zAb1FN3iNlhWPbbdyeBpOaTW0hukyMHyI= 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 h1:UebRzEomgMpv61e3hgD1tGooqX5trFbdU/ehphbHd00=
github.com/shirou/gopsutil/v3 v3.22.3/go.mod h1:D01hZJ4pVHPpCTZ3m3T2+wDF2YAGfd+H4ifUguaQzHM= 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= 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/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 h1:3SNcvBmEPE1YlB1JpVZouslJpI3GBNoiqW7+wb0Rz7w=
github.com/teris-io/shortid v0.0.0-20201117134242-e59966efd125/go.mod h1:M8agBzgqHIhgj7wEn9/0hJUZcrvt9VY+Ln+S1I5Mha0= 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 h1:IJ1AZGZRWbY8T5Vfk04D9WOA5WSejdflXxP03OUqALw=
github.com/tklauser/go-sysconf v0.3.10/go.mod h1:C8XykCvCb+Gn0oNCWPIlcb0RuglQTYaQ2hGm7jmxEFk= 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 h1:E53Dm1HjH1/R2/aoCtXtPgzmElmn51aOkhCFSuZq//o=
github.com/tklauser/numcpus v0.4.0/go.mod h1:1+UI3pD8NW14VMwdgJNJ1ESk2UnwhAnz5hMwiKKqXCQ= github.com/tklauser/numcpus v0.4.0/go.mod h1:1+UI3pD8NW14VMwdgJNJ1ESk2UnwhAnz5hMwiKKqXCQ=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 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-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-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-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-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-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 h1:yx80W7nvY5ySWpaU8UWaj5o9e23YgO9BRhQol7Lc+JI=
golang.org/x/net v0.0.0-20220420153159-1850ba15e1be/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= 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= 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-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-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-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-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-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-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220325203850-36772127a21f h1:TrmogKRsSOxRMJbLYGrB4SBbW+LJcEllYBLME5Zk5pU= 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-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-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-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 h1:+gHMid33q6pen7kv9xvT+JRinntgeXO2AeZVd0AWD3w=
golang.org/x/time v0.0.0-20220411224347-583f2d630306/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 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= 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. // ExternalAccessTokenHandlerFunc is a function that is called after validing access.
type ExternalAccessTokenHandlerFunc func(user.ExternalAPIUser, http.ResponseWriter, *http.Request) 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 // 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. // the stream key as the password and and a hardcoded "admin" for username.
func RequireAdminAuth(handler http.HandlerFunc) http.HandlerFunc { 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. // 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. // 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) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
accessToken := r.URL.Query().Get("accessToken") accessToken := r.URL.Query().Get("accessToken")
if accessToken == "" { if accessToken == "" {
@ -119,7 +122,7 @@ func RequireUserAccessToken(handler http.HandlerFunc) http.HandlerFunc {
return return
} }
handler(w, r) handler(*user, w, r)
}) })
} }

View file

@ -21,7 +21,7 @@ func SetHeaders(w http.ResponseWriter) {
} }
// Content security policy // Content security policy
csp := []string{ 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: "worker-src 'self' blob:", // No single quotes around blob:
} }
w.Header().Set("Content-Security-Policy", strings.Join(csp, "; ")) 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/config"
"github.com/owncast/owncast/controllers" "github.com/owncast/owncast/controllers"
"github.com/owncast/owncast/controllers/admin" "github.com/owncast/owncast/controllers/admin"
"github.com/owncast/owncast/controllers/auth/indieauth"
"github.com/owncast/owncast/core/chat" "github.com/owncast/owncast/core/chat"
"github.com/owncast/owncast/core/data" "github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/core/user" "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/browser", middleware.RequireAdminAuth(admin.SetBrowserNotificationConfiguration))
http.HandleFunc("/api/admin/config/notifications/twitter", middleware.RequireAdminAuth(admin.SetTwitterConfiguration)) 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 has its own router
activitypub.Start(data.GetDatastore()) 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="icon" type="image/png" sizes="16x16" href="/img/favicon/favicon-16x16.png">
<link rel="manifest" href="/manifest.json"> <link rel="manifest" href="/manifest.json">
<link rel="authorization_endpoint" href="/api/auth/provider/indieauth">
<meta name="msapplication-TileColor" content="#ffffff"> <meta name="msapplication-TileColor" content="#ffffff">
<meta name="msapplication-TileImage" content="/img/favicon/ms-icon-144x144.png"> <meta name="msapplication-TileImage" content="/img/favicon/ms-icon-144x144.png">
<meta name="theme-color" content="#ffffff"> <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 = { const payload = {
body: 'This is a test external action from the automated integration test', body: 'This is a test external action from the automated integration test',
}; };
const res = await request await request
.post('/api/integrations/chat/action') .post('/api/integrations/chat/action')
.set('Authorization', 'Bearer ' + accessToken) .set('Authorization', 'Bearer ' + accessToken)
.send(payload) .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> <!DOCTYPE html>
<html lang="en"> <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> <link
<title>Owncast</title> rel="apple-touch-icon"
<base target="_blank" /> sizes="57x57"
<meta charset="UTF-8" /> href="/img/favicon/apple-icon-57x57.png"
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0" /> />
<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="authorization_endpoint" href="/api/auth/provider/indieauth" />
<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">
<meta name="msapplication-TileColor" content="#ffffff"> <meta name="msapplication-TileColor" content="#ffffff" />
<meta name="msapplication-TileImage" content="/img/favicon/ms-icon-144x144.png"> <meta
<meta name="theme-color" content="#ffffff"> 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/video-js.min.css" rel="stylesheet" />
<link href="/js/web_modules/@videojs/themes/fantasy/index.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/video.css" rel="stylesheet" />
<link href="/styles/chat.css" rel="stylesheet" /> <link href="/styles/chat.css" rel="stylesheet" />
<link href="/styles/user-content.css" rel="stylesheet" /> <link href="/styles/user-content.css" rel="stylesheet" />
<link href="/styles/app.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. 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 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/platform-logos-list.js"></script>
<script type="preload" src="/js/components/chat/chat-input.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/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.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/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/player.js"></script>
<script type="preload" src="/js/components/video-poster.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/app.js"></script>
<script type="preload" src="/js/web_modules/preact.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
<script type="preload" src="/js/web_modules/common/_commonjsHelpers-8c19dec8.js"></script> type="preload"
<script type="preload" src="/js/web_modules/markjs/dist/mark.es6.min.js"></script> src="/js/web_modules/micromodal/dist/micromodal.min.js"
<script type="preload" src="/js/web_modules/@joeattardi/emoji-button.js"></script> ></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/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/chat/register.js"></script>
<script type="preload" src="/js/utils/helpers.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/user-colors.js"></script>
<script type="preload" src="/js/utils/constants.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/chat.js"></script>
<script type="preload" src="/js/utils/websocket.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">
<body id="app-body" class="scrollbar-hidden bg-gray-300 text-gray-800"> <div id="loading-logo-container">
<div id="app"> <img id="loading-logo" src="/logo" />
<div id="loading-logo-container"> </div>
<img id="loading-logo" src="/logo">
</div> </div>
</div>
<script type="module"> <script type="module">
import { h, render } from '/js/web_modules/preact.js'; import { h, render } from '/js/web_modules/preact.js';
import htm from '/js/web_modules/htm.js'; import htm from '/js/web_modules/htm.js';
const html = htm.bind(h); const html = htm.bind(h);
import App from '/js/app.js'; import App from '/js/app.js';
render(html`<${App} />`, document.getElementById("app"), document.getElementById("loading-logo-container")); render(
</script> html`<${App} />`,
document.getElementById('app'),
document.getElementById('loading-logo-container')
);
</script>
<noscript> <noscript>
<style> <style>
.noscript { .noscript {
text-align: center; text-align: center;
padding: 30px; padding: 30px;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
flex-direction: column; flex-direction: column;
font-size: large; font-size: large;
} }
.noscript a { .noscript a {
display: inline; display: inline;
color: blue; color: blue;
text-decoration: underline; text-decoration: underline;
} }
#app { #app {
display: none; display: none;
} }
.logo { .logo {
height: 200px; height: 200px;
margin: 30px; 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>
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> </html>

View file

@ -27,6 +27,8 @@ import FediverseFollowModal, {
import { NotifyButton, NotifyModal } from './components/notification.js'; import { NotifyButton, NotifyModal } from './components/notification.js';
import { isPushNotificationSupported } from './notification/registerWeb.js'; import { isPushNotificationSupported } from './notification/registerWeb.js';
import ChatSettingsModal from './components/chat-settings-modal.js';
import { import {
addNewlines, addNewlines,
checkUrlPathForDisplay, checkUrlPathForDisplay,
@ -110,6 +112,9 @@ export default class App extends Component {
externalActionModalData: null, externalActionModalData: null,
fediverseModalData: null, fediverseModalData: null,
// authentication options
indieAuthEnabled: false,
// routing & tabbing // routing & tabbing
section: '', section: '',
sectionId: '', sectionId: '',
@ -144,6 +149,8 @@ export default class App extends Component {
this.closeFediverseFollowModal = this.closeFediverseFollowModal.bind(this); this.closeFediverseFollowModal = this.closeFediverseFollowModal.bind(this);
this.displayNotificationModal = this.displayNotificationModal.bind(this); this.displayNotificationModal = this.displayNotificationModal.bind(this);
this.closeNotificationModal = this.closeNotificationModal.bind(this); this.closeNotificationModal = this.closeNotificationModal.bind(this);
this.showAuthModal = this.showAuthModal.bind(this);
this.closeAuthModal = this.closeAuthModal.bind(this);
// player events // player events
this.handlePlayerReady = this.handlePlayerReady.bind(this); this.handlePlayerReady = this.handlePlayerReady.bind(this);
@ -268,8 +275,14 @@ export default class App extends Component {
} }
setConfigData(data = {}) { setConfigData(data = {}) {
const { name, summary, chatDisabled, socketHostOverride, notifications } = const {
data; name,
summary,
chatDisabled,
socketHostOverride,
notifications,
authentication,
} = data;
window.document.title = name; window.document.title = name;
this.socketHostOverride = socketHostOverride; this.socketHostOverride = socketHostOverride;
@ -281,10 +294,12 @@ export default class App extends Component {
} }
this.hasConfiguredChat = true; this.hasConfiguredChat = true;
const { indieAuthEnabled } = authentication;
this.setState({ this.setState({
canChat: !chatDisabled, canChat: !chatDisabled,
notifications, notifications,
indieAuthEnabled,
configData: { configData: {
...data, ...data,
summary: summary && addNewlines(summary), 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) { handleWebsocketMessage(e) {
if (e.type === SOCKET_MESSAGE_TYPES.ERROR_USER_DISABLED) { if (e.type === SOCKET_MESSAGE_TYPES.ERROR_USER_DISABLED) {
// User has been actively disabled on the backend. Turn off chat for them. // 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 // When connected the user will return an event letting us know what our
// user details are so we can display them properly. // user details are so we can display them properly.
const { user } = e; const { user } = e;
const { displayName } = user; const { displayName, authenticated } = user;
this.setState({ this.setState({
username: displayName, username: displayName,
authenticated,
isModerator: checkIsModerator(e), isModerator: checkIsModerator(e),
}); });
} }
@ -724,17 +750,20 @@ export default class App extends Component {
streamTitle, streamTitle,
touchKeyboardActive, touchKeyboardActive,
username, username,
authenticated,
viewerCount, viewerCount,
websocket, websocket,
windowHeight, windowHeight,
windowWidth, windowWidth,
fediverseModalData, fediverseModalData,
authModalData,
externalActionModalData, externalActionModalData,
notificationModalData, notificationModalData,
notifications, notifications,
lastDisconnectTime, lastDisconnectTime,
section, section,
sectionId, sectionId,
indieAuthEnabled,
} = state; } = state;
const { 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 const chat = this.state.websocket
? html` ? html`
<${Chat} <${Chat}
websocket=${websocket} websocket=${websocket}
username=${username} username=${username}
authenticated=${authenticated}
chatInputEnabled=${chatInputEnabled && !chatDisabled} chatInputEnabled=${chatInputEnabled && !chatDisabled}
instanceTitle=${name} instanceTitle=${name}
accessToken=${accessToken} accessToken=${accessToken}
@ -911,6 +961,8 @@ export default class App extends Component {
}); });
} }
const authIcon = '/img/user-settings.svg';
return html` return html`
<div <div
id="app-container" id="app-container"
@ -942,9 +994,11 @@ export default class App extends Component {
> >
</h1> </h1>
<${ChatMenu} username=${username} isModerator=${isModerator} onUsernameChange=${ <${ChatMenu} username=${username} isModerator=${isModerator} showAuthModal=${
this.handleUsernameChange indieAuthEnabled && this.showAuthModal
} onFocus=${this.handleFormFocus} onBlur=${ } onUsernameChange=${this.handleUsernameChange} onFocus=${
this.handleFormFocus
} onBlur=${
this.handleFormBlur this.handleFormBlur
} chatDisabled=${chatDisabled} noVideoContent=${noVideoContent} handleChatPanelToggle=${ } chatDisabled=${chatDisabled} noVideoContent=${noVideoContent} handleChatPanelToggle=${
this.handleChatPanelToggle this.handleChatPanelToggle
@ -1027,7 +1081,7 @@ export default class App extends Component {
</footer> </footer>
${chat} ${externalActionModal} ${fediverseFollowModal} ${chat} ${externalActionModal} ${fediverseFollowModal}
${notificationModal} ${notificationModal} ${authModal}
</div> </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, noVideoContent,
handleChatPanelToggle, handleChatPanelToggle,
onUsernameChange, onUsernameChange,
showAuthModal,
onFocus, onFocus,
onBlur, onBlur,
} = props; } = props;
@ -34,6 +35,15 @@ export const ChatMenu = (props) => {
if (chatMenuOpen) setView('main'); if (chatMenuOpen) setView('main');
}, [chatMenuOpen]); }, [chatMenuOpen]);
const authMenuItem =
showAuthModal &&
html`<li>
<button type="button" id="chat-auth" onClick=${showAuthModal}>
Authenticate
<span><${ChatIcon} /></span>
</button>
</li>`;
return html` return html`
<${Context.Provider} value=${props}> <${Context.Provider} value=${props}>
<div class="chat-menu p-2 relative shadow-lg" ref=${chatMenuRef}> <div class="chat-menu p-2 relative shadow-lg" ref=${chatMenuRef}>
@ -55,7 +65,7 @@ export const ChatMenu = (props) => {
> >
${username} ${username}
</span> </span>
<${CaretDownIcon} className="w-8 h-8"/> <${CaretDownIcon} className="w-8 h-8"/>
</button> </button>
${ ${
chatMenuOpen && chatMenuOpen &&
@ -74,6 +84,7 @@ export const ChatMenu = (props) => {
onBlur=${onBlur} onBlur=${onBlur}
/> />
</li> </li>
${authMenuItem}
<li> <li>
<button <button
type="button" type="button"

View file

@ -49,7 +49,8 @@ export default class ChatMessageView extends Component {
if (!user) { if (!user) {
return null; return null;
} }
const { displayName, displayColor, createdAt, isBot } = user;
const { displayName, displayColor, createdAt, isBot, authenticated } = user;
const isAuthorModerator = checkIsModerator(message); const isAuthorModerator = checkIsModerator(message);
const isMessageModeratable = const isMessageModeratable =
@ -78,7 +79,7 @@ export default class ChatMessageView extends Component {
isMessageModeratable ? 'moderatable' : '' isMessageModeratable ? 'moderatable' : ''
}`; }`;
const messageAuthorFlair = isAuthorModerator const isModeratorFlair = isAuthorModerator
? html`<img ? html`<img
class="flair" class="flair"
title="Moderator" title="Moderator"
@ -95,6 +96,14 @@ export default class ChatMessageView extends Component {
/>` />`
: null; : null;
const authorAuthenticatedFlair = authenticated
? html`<img
class="flair"
title="Authenticated"
src="/img/authenticated.svg"
/>`
: null;
return html` return html`
<div <div
style=${backgroundStyle} style=${backgroundStyle}
@ -107,7 +116,8 @@ export default class ChatMessageView extends Component {
class="message-author font-bold" class="message-author font-bold"
title=${userMetadata} title=${userMetadata}
> >
${isBotFlair} ${messageAuthorFlair} ${displayName} ${isBotFlair} ${authorAuthenticatedFlair} ${isModeratorFlair}
${displayName}
</div> </div>
${isMessageModeratable && ${isMessageModeratable &&
html`<${ModeratorActions} 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` return html`
<div id="user-info"> <div id="user-info">
<button <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_NOTIFICATION = `/api/notifications/register`;
export const URL_REGISTER_EMAIL_NOTIFICATION = `/api/notifications/register/email`; 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_STATUS_UPDATE = 5000; // ms
export const TIMER_DISABLE_CHAT_AFTER_OFFLINE = 5 * 60 * 1000; // 5 mins export const TIMER_DISABLE_CHAT_AFTER_OFFLINE = 5 * 60 * 1000; // 5 mins