IndieAuth support (#1811)

* Able to authenticate user against IndieAuth. For #1273

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

* Add migration to remove access tokens from user

* Add authenticated bool to user for display purposes

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

* Validate URLs and display errors

* Renames, cleanups

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

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

* Verify code verifier via code challenge on callback

* Use relative path to authorization_endpoint

* Post-rebase fixes

* Use a timestamp instead of a bool for authenticated

* Propertly handle and display error in modal

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

* don't redirect unless a URL is present

avoids redirecting to `undefined` if there was an error

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

* fix IndieAuth PKCE implementation

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

* return real profile data for IndieAuth response

* check the code verifier in the IndieAuth server

* Linting

* Add new chat settings modal anad split up indieauth ui

* Remove logging error

* Update the IndieAuth modal UI. For #1273

* Add IndieAuth repsonse error checking

* Disable IndieAuth client if server URL is not set.

* Add explicit error messages for specific error types

* Fix bad logic

* Return OAuth-keyed error responses for indieauth server

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

* Remove redundant check

* Add additional detail to error

* Hide IndieAuth details behind disclosure details

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

* Add auth option to user dropdown

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

11
auth/auth.go Normal file
View file

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

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

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

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

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

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

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

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

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

View file

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

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

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

77
auth/persistence.go Normal file
View file

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

View file

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

View file

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

View file

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

View file

@ -32,6 +32,7 @@ type webConfigResponse struct {
MaxSocketPayloadSize int `json:"maxSocketPayloadSize"`
Federation federationConfigResponse `json:"federation"`
Notifications notificationsConfigResponse `json:"notifications"`
Authentication authenticationConfigResponse `json:"authentication"`
}
type federationConfigResponse struct {
@ -49,6 +50,10 @@ type notificationsConfigResponse struct {
Browser browserNotificationsConfigResponse `json:"browser"`
}
type authenticationConfigResponse struct {
IndieAuthEnabled bool `json:"indieAuthEnabled"`
}
// GetWebConfig gets the status of the server.
func GetWebConfig(w http.ResponseWriter, r *http.Request) {
middleware.EnableCors(w)
@ -97,6 +102,10 @@ func GetWebConfig(w http.ResponseWriter, r *http.Request) {
},
}
authenticationResponse := authenticationConfigResponse{
IndieAuthEnabled: data.GetServerURL() != "",
}
configuration := webConfigResponse{
Name: data.GetServerName(),
Summary: serverSummary,
@ -114,6 +123,7 @@ func GetWebConfig(w http.ResponseWriter, r *http.Request) {
MaxSocketPayloadSize: config.MaxSocketPayloadSize,
Federation: federationResponse,
Notifications: notificationsResponse,
Authentication: authenticationResponse,
}
if err := json.NewEncoder(w).Encode(configuration); err != nil {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

2
go.mod
View file

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

33
go.sum
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
webroot/img/indieauth.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

View file

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

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -1,35 +1,104 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Owncast</title>
<base target="_blank" />
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0"
/>
<link rel="apple-touch-icon" sizes="57x57" href="/img/favicon/apple-icon-57x57.png">
<link rel="apple-touch-icon" sizes="60x60" href="/img/favicon/apple-icon-60x60.png">
<link rel="apple-touch-icon" sizes="72x72" href="/img/favicon/apple-icon-72x72.png">
<link rel="apple-touch-icon" sizes="76x76" href="/img/favicon/apple-icon-76x76.png">
<link rel="apple-touch-icon" sizes="114x114" href="/img/favicon/apple-icon-114x114.png">
<link rel="apple-touch-icon" sizes="120x120" href="/img/favicon/apple-icon-120x120.png">
<link rel="apple-touch-icon" sizes="144x144" href="/img/favicon/apple-icon-144x144.png">
<link rel="apple-touch-icon" sizes="152x152" href="/img/favicon/apple-icon-152x152.png">
<link rel="apple-touch-icon" sizes="180x180" href="/img/favicon/apple-icon-180x180.png">
<link rel="icon" type="image/png" sizes="192x192" href="/img/favicon/android-icon-192x192.png">
<link rel="icon" type="image/png" sizes="32x32" href="/img/favicon/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="96x96" href="/img/favicon/favicon-96x96.png">
<link rel="icon" type="image/png" sizes="16x16" href="/img/favicon/favicon-16x16.png">
<link rel="manifest" href="/manifest.json">
<link
rel="apple-touch-icon"
sizes="57x57"
href="/img/favicon/apple-icon-57x57.png"
/>
<link
rel="apple-touch-icon"
sizes="60x60"
href="/img/favicon/apple-icon-60x60.png"
/>
<link
rel="apple-touch-icon"
sizes="72x72"
href="/img/favicon/apple-icon-72x72.png"
/>
<link
rel="apple-touch-icon"
sizes="76x76"
href="/img/favicon/apple-icon-76x76.png"
/>
<link
rel="apple-touch-icon"
sizes="114x114"
href="/img/favicon/apple-icon-114x114.png"
/>
<link
rel="apple-touch-icon"
sizes="120x120"
href="/img/favicon/apple-icon-120x120.png"
/>
<link
rel="apple-touch-icon"
sizes="144x144"
href="/img/favicon/apple-icon-144x144.png"
/>
<link
rel="apple-touch-icon"
sizes="152x152"
href="/img/favicon/apple-icon-152x152.png"
/>
<link
rel="apple-touch-icon"
sizes="180x180"
href="/img/favicon/apple-icon-180x180.png"
/>
<link
rel="icon"
type="image/png"
sizes="192x192"
href="/img/favicon/android-icon-192x192.png"
/>
<link
rel="icon"
type="image/png"
sizes="32x32"
href="/img/favicon/favicon-32x32.png"
/>
<link
rel="icon"
type="image/png"
sizes="96x96"
href="/img/favicon/favicon-96x96.png"
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href="/img/favicon/favicon-16x16.png"
/>
<link rel="manifest" href="/manifest.json" />
<meta name="msapplication-TileColor" content="#ffffff">
<meta name="msapplication-TileImage" content="/img/favicon/ms-icon-144x144.png">
<meta name="theme-color" content="#ffffff">
<link rel="authorization_endpoint" href="/api/auth/provider/indieauth" />
<link href="/js/web_modules/tailwindcss/dist/tailwind.min.css" rel="stylesheet" />
<meta name="msapplication-TileColor" content="#ffffff" />
<meta
name="msapplication-TileImage"
content="/img/favicon/ms-icon-144x144.png"
/>
<meta name="theme-color" content="#ffffff" />
<link
href="/js/web_modules/tailwindcss/dist/tailwind.min.css"
rel="stylesheet"
/>
<link href="/js/web_modules/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/chat.css" rel="stylesheet" />
@ -48,34 +117,57 @@
<script type="preload" src="/js/components/platform-logos-list.js"></script>
<script type="preload" src="/js/components/chat/chat-input.js"></script>
<script type="preload" src="/js/components/chat/message.js"></script>
<script type="preload" src="/js/components/chat/content-editable.js"></script>
<script
type="preload"
src="/js/components/chat/content-editable.js"
></script>
<script type="preload" src="/js/components/chat/chat.js"></script>
<script type="preload" src="/js/components/chat/chat-message-view.js"></script>
<script
type="preload"
src="/js/components/chat/chat-message-view.js"
></script>
<script type="preload" src="/js/components/chat/username.js"></script>
<script type="preload" src="/js/components/external-action-modal.js"></script>
<script
type="preload"
src="/js/components/external-action-modal.js"
></script>
<script type="preload" src="/js/components/player.js"></script>
<script type="preload" src="/js/components/video-poster.js"></script>
<script type="preload" src="/js/app.js"></script>
<script type="preload" src="/js/web_modules/preact.js"></script>
<script type="preload" src="/js/web_modules/micromodal/dist/micromodal.min.js"></script>
<script type="preload" src="/js/web_modules/common/_commonjsHelpers-8c19dec8.js"></script>
<script type="preload" src="/js/web_modules/markjs/dist/mark.es6.min.js"></script>
<script type="preload" src="/js/web_modules/@joeattardi/emoji-button.js"></script>
<script
type="preload"
src="/js/web_modules/micromodal/dist/micromodal.min.js"
></script>
<script
type="preload"
src="/js/web_modules/common/_commonjsHelpers-8c19dec8.js"
></script>
<script
type="preload"
src="/js/web_modules/markjs/dist/mark.es6.min.js"
></script>
<script
type="preload"
src="/js/web_modules/@joeattardi/emoji-button.js"
></script>
<script type="preload" src="/js/web_modules/htm.js"></script>
<script type="preload" src="/js/web_modules/videojs/dist/video.min.js"></script>
<script
type="preload"
src="/js/web_modules/videojs/dist/video.min.js"
></script>
<script type="preload" src="/js/chat/register.js"></script>
<script type="preload" src="/js/utils/helpers.js"></script>
<script type="preload" src="/js/utils/user-colors.js"></script>
<script type="preload" src="/js/utils/constants.js"></script>
<script type="preload" src="/js/utils/chat.js"></script>
<script type="preload" src="/js/utils/websocket.js"></script>
</head>
<body id="app-body" class="scrollbar-hidden bg-gray-300 text-gray-800">
<div id="app">
<div id="loading-logo-container">
<img id="loading-logo" src="/logo">
<img id="loading-logo" src="/logo" />
</div>
</div>
@ -85,7 +177,11 @@
const html = htm.bind(h);
import App from '/js/app.js';
render(html`<${App} />`, document.getElementById("app"), document.getElementById("loading-logo-container"));
render(
html`<${App} />`,
document.getElementById('app'),
document.getElementById('loading-logo-container')
);
</script>
<noscript>
@ -125,28 +221,41 @@
<img class="logo" src="/logo" />
<br />
<p>
This website is powered by <a href="https://owncast.online" rel="noopener noreferrer" target="_blank">Owncast</a>.
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.
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.
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>
<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.
You can open the URL of this website in your media player (such as
<a href="https://mpv.io" rel="noopener noreferrer" target="_blank"
>mpv</a
>
or
<a
href="https://www.videolan.org/vlc/"
rel="noopener noreferrer"
target="_blank"
>VLC</a
>) to watch the stream.
</p>
<h2>How can I chat with the others without JavaScript?</h2>
<p>Currently, there is no option to use the chat without JavaScript.</p>
</div>
</noscript>
</body>
</html>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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