mirror of
https://github.com/owncast/owncast.git
synced 2024-11-21 20:28:15 +03:00
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:
parent
b86537fa91
commit
b835de2dc4
47 changed files with 1844 additions and 274 deletions
11
auth/auth.go
Normal file
11
auth/auth.go
Normal 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
112
auth/indieauth/client.go
Normal 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
120
auth/indieauth/helpers.go
Normal 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
34
auth/indieauth/random.go
Normal 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
18
auth/indieauth/request.go
Normal 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
|
||||||
|
}
|
18
auth/indieauth/response.go
Normal file
18
auth/indieauth/response.go
Normal 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
92
auth/indieauth/server.go
Normal 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
77
auth/persistence.go
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
103
controllers/auth/indieauth/client.go
Normal file
103
controllers/auth/indieauth/client.go
Normal 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)
|
||||||
|
}
|
80
controllers/auth/indieauth/server.go
Normal file
80
controllers/auth/indieauth/server.go
Normal 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)
|
||||||
|
}
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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"`
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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")
|
||||||
}
|
}
|
||||||
|
|
|
@ -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, ×tamp); 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)
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
|
|
28
db/models.go
28
db/models.go
|
@ -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
|
||||||
|
}
|
||||||
|
|
26
db/query.sql
26
db/query.sql
|
@ -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;
|
||||||
|
|
166
db/query.sql.go
166
db/query.sql.go
|
@ -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
|
||||||
`
|
`
|
||||||
|
|
|
@ -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
2
go.mod
|
@ -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
33
go.sum
|
@ -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=
|
||||||
|
|
|
@ -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)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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, "; "))
|
||||||
|
|
|
@ -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())
|
||||||
|
|
||||||
|
|
2
static/metadata.html.tmpl
vendored
2
static/metadata.html.tmpl
vendored
|
@ -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">
|
||||||
|
|
|
@ -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)
|
||||||
|
|
38
webroot/img/authenticated.svg
Normal file
38
webroot/img/authenticated.svg
Normal 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
BIN
webroot/img/indieauth.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 6.5 KiB |
2
webroot/img/user-settings.svg
Normal file
2
webroot/img/user-settings.svg
Normal 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 |
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
3
webroot/js/chat/indieauth.js
Normal file
3
webroot/js/chat/indieauth.js
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
import { URL_CHAT_INDIEAUTH_BEGIN } from '../utils/constants.js';
|
||||||
|
|
||||||
|
export async function beginIndieAuthFlow() {}
|
192
webroot/js/components/auth-indieauth.js
Normal file
192
webroot/js/components/auth-indieauth.js
Normal 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;
|
||||||
|
}
|
44
webroot/js/components/chat-settings-modal.js
Normal file
44
webroot/js/components/chat-settings-modal.js
Normal 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>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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}>
|
||||||
|
@ -74,6 +84,7 @@ export const ChatMenu = (props) => {
|
||||||
onBlur=${onBlur}
|
onBlur=${onBlur}
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
|
${authMenuItem}
|
||||||
<li>
|
<li>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in a new issue