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.
|
||||
func ExternalGetChatMessages(integration user.ExternalAPIUser, w http.ResponseWriter, r *http.Request) {
|
||||
middleware.EnableCors(w)
|
||||
GetChatMessages(w, r)
|
||||
getChatMessages(w, r)
|
||||
}
|
||||
|
||||
// GetChatMessages gets all of the chat messages.
|
||||
func GetChatMessages(w http.ResponseWriter, r *http.Request) {
|
||||
func GetChatMessages(u user.User, w http.ResponseWriter, r *http.Request) {
|
||||
getChatMessages(w, r)
|
||||
}
|
||||
|
||||
func getChatMessages(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
switch r.Method {
|
||||
|
@ -62,7 +66,7 @@ func RegisterAnonymousChatUser(w http.ResponseWriter, r *http.Request) {
|
|||
request.DisplayName = r.Header.Get("X-Forwarded-User")
|
||||
}
|
||||
|
||||
newUser, err := user.CreateAnonymousUser(request.DisplayName)
|
||||
newUser, accessToken, err := user.CreateAnonymousUser(request.DisplayName)
|
||||
if err != nil {
|
||||
WriteSimpleResponse(w, false, err.Error())
|
||||
return
|
||||
|
@ -70,7 +74,7 @@ func RegisterAnonymousChatUser(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
response := registerAnonymousUserResponse{
|
||||
ID: newUser.ID,
|
||||
AccessToken: newUser.AccessToken,
|
||||
AccessToken: accessToken,
|
||||
DisplayName: newUser.DisplayName,
|
||||
}
|
||||
|
||||
|
|
|
@ -16,22 +16,23 @@ import (
|
|||
)
|
||||
|
||||
type webConfigResponse struct {
|
||||
Name string `json:"name"`
|
||||
Summary string `json:"summary"`
|
||||
Logo string `json:"logo"`
|
||||
Tags []string `json:"tags"`
|
||||
Version string `json:"version"`
|
||||
NSFW bool `json:"nsfw"`
|
||||
SocketHostOverride string `json:"socketHostOverride,omitempty"`
|
||||
ExtraPageContent string `json:"extraPageContent"`
|
||||
StreamTitle string `json:"streamTitle,omitempty"` // What's going on with the current stream
|
||||
SocialHandles []models.SocialHandle `json:"socialHandles"`
|
||||
ChatDisabled bool `json:"chatDisabled"`
|
||||
ExternalActions []models.ExternalAction `json:"externalActions"`
|
||||
CustomStyles string `json:"customStyles"`
|
||||
MaxSocketPayloadSize int `json:"maxSocketPayloadSize"`
|
||||
Federation federationConfigResponse `json:"federation"`
|
||||
Notifications notificationsConfigResponse `json:"notifications"`
|
||||
Name string `json:"name"`
|
||||
Summary string `json:"summary"`
|
||||
Logo string `json:"logo"`
|
||||
Tags []string `json:"tags"`
|
||||
Version string `json:"version"`
|
||||
NSFW bool `json:"nsfw"`
|
||||
SocketHostOverride string `json:"socketHostOverride,omitempty"`
|
||||
ExtraPageContent string `json:"extraPageContent"`
|
||||
StreamTitle string `json:"streamTitle,omitempty"` // What's going on with the current stream
|
||||
SocialHandles []models.SocialHandle `json:"socialHandles"`
|
||||
ChatDisabled bool `json:"chatDisabled"`
|
||||
ExternalActions []models.ExternalAction `json:"externalActions"`
|
||||
CustomStyles string `json:"customStyles"`
|
||||
MaxSocketPayloadSize int `json:"maxSocketPayloadSize"`
|
||||
Federation federationConfigResponse `json:"federation"`
|
||||
Notifications notificationsConfigResponse `json:"notifications"`
|
||||
Authentication authenticationConfigResponse `json:"authentication"`
|
||||
}
|
||||
|
||||
type federationConfigResponse struct {
|
||||
|
@ -49,6 +50,10 @@ type notificationsConfigResponse struct {
|
|||
Browser browserNotificationsConfigResponse `json:"browser"`
|
||||
}
|
||||
|
||||
type authenticationConfigResponse struct {
|
||||
IndieAuthEnabled bool `json:"indieAuthEnabled"`
|
||||
}
|
||||
|
||||
// GetWebConfig gets the status of the server.
|
||||
func GetWebConfig(w http.ResponseWriter, r *http.Request) {
|
||||
middleware.EnableCors(w)
|
||||
|
@ -97,6 +102,10 @@ func GetWebConfig(w http.ResponseWriter, r *http.Request) {
|
|||
},
|
||||
}
|
||||
|
||||
authenticationResponse := authenticationConfigResponse{
|
||||
IndieAuthEnabled: data.GetServerURL() != "",
|
||||
}
|
||||
|
||||
configuration := webConfigResponse{
|
||||
Name: data.GetServerName(),
|
||||
Summary: serverSummary,
|
||||
|
@ -114,6 +123,7 @@ func GetWebConfig(w http.ResponseWriter, r *http.Request) {
|
|||
MaxSocketPayloadSize: config.MaxSocketPayloadSize,
|
||||
Federation: federationResponse,
|
||||
Notifications: notificationsResponse,
|
||||
Authentication: authenticationResponse,
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(configuration); err != nil {
|
||||
|
|
|
@ -65,3 +65,11 @@ func WriteResponse(w http.ResponseWriter, response interface{}) {
|
|||
InternalErrorHandler(w, err)
|
||||
}
|
||||
}
|
||||
|
||||
// WriteString will return a basic string and a status code to the client.
|
||||
func WriteString(w http.ResponseWriter, text string, status int) error {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.WriteHeader(status)
|
||||
_, err := w.Write([]byte(text))
|
||||
return err
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@ import (
|
|||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/owncast/owncast/core/user"
|
||||
"github.com/owncast/owncast/notifications"
|
||||
|
||||
"github.com/owncast/owncast/utils"
|
||||
|
@ -13,7 +14,7 @@ import (
|
|||
|
||||
// RegisterForLiveNotifications will register a channel + destination to be
|
||||
// notified when a stream goes live.
|
||||
func RegisterForLiveNotifications(w http.ResponseWriter, r *http.Request) {
|
||||
func RegisterForLiveNotifications(u user.User, w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != POST {
|
||||
WriteSimpleResponse(w, false, r.Method+" not supported")
|
||||
return
|
||||
|
|
|
@ -21,6 +21,8 @@ func (s *Server) userNameChanged(eventData chatClientEvent) {
|
|||
}
|
||||
|
||||
proposedUsername := receivedEvent.NewName
|
||||
|
||||
// Check if name is on the blocklist
|
||||
blocklist := data.GetForbiddenUsernameList()
|
||||
|
||||
for _, blockedName := range blocklist {
|
||||
|
@ -39,11 +41,27 @@ func (s *Server) userNameChanged(eventData chatClientEvent) {
|
|||
}
|
||||
}
|
||||
|
||||
// Check if the name is not already assigned to a registered user.
|
||||
if available, err := user.IsDisplayNameAvailable(proposedUsername); err != nil {
|
||||
log.Errorln("error checking if name is available", err)
|
||||
return
|
||||
} else if !available {
|
||||
message := fmt.Sprintf("You cannot change your name to **%s**, it is already in use.", proposedUsername)
|
||||
s.sendActionToClient(eventData.client, message)
|
||||
|
||||
// Resend the client's user so their username is in sync.
|
||||
eventData.client.sendConnectedClientInfo()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
savedUser := user.GetUserByToken(eventData.client.accessToken)
|
||||
oldName := savedUser.DisplayName
|
||||
|
||||
// Save the new name
|
||||
user.ChangeUsername(eventData.client.User.ID, receivedEvent.NewName)
|
||||
if err := user.ChangeUsername(eventData.client.User.ID, receivedEvent.NewName); err != nil {
|
||||
log.Errorln("error changing username", err)
|
||||
}
|
||||
|
||||
// Update the connected clients associated user with the new name
|
||||
now := time.Now()
|
||||
|
|
|
@ -10,6 +10,7 @@ type NameChangeEvent struct {
|
|||
// NameChangeBroadcast represents a user changing their chat display name.
|
||||
type NameChangeBroadcast struct {
|
||||
Event
|
||||
OutboundEvent
|
||||
UserEvent
|
||||
Oldname string `json:"oldName"`
|
||||
}
|
||||
|
|
|
@ -104,16 +104,17 @@ func makeUserMessageEventFromRowData(row rowData) events.UserMessageEvent {
|
|||
scopeSlice := strings.Split(scopes, ",")
|
||||
|
||||
u := user.User{
|
||||
ID: *row.userID,
|
||||
AccessToken: "",
|
||||
DisplayName: displayName,
|
||||
DisplayColor: displayColor,
|
||||
CreatedAt: createdAt,
|
||||
DisabledAt: row.userDisabledAt,
|
||||
NameChangedAt: row.userNameChangedAt,
|
||||
PreviousNames: previousUsernames,
|
||||
Scopes: scopeSlice,
|
||||
IsBot: isBot,
|
||||
ID: *row.userID,
|
||||
DisplayName: displayName,
|
||||
DisplayColor: displayColor,
|
||||
CreatedAt: createdAt,
|
||||
DisabledAt: row.userDisabledAt,
|
||||
NameChangedAt: row.userNameChangedAt,
|
||||
PreviousNames: previousUsernames,
|
||||
AuthenticatedAt: row.userAuthenticatedAt,
|
||||
Authenticated: row.userAuthenticatedAt != nil,
|
||||
Scopes: scopeSlice,
|
||||
IsBot: isBot,
|
||||
}
|
||||
|
||||
message := events.UserMessageEvent{
|
||||
|
@ -195,14 +196,15 @@ type rowData struct {
|
|||
image *string
|
||||
link *string
|
||||
|
||||
userDisplayName *string
|
||||
userDisplayColor *int
|
||||
userCreatedAt *time.Time
|
||||
userDisabledAt *time.Time
|
||||
previousUsernames *string
|
||||
userNameChangedAt *time.Time
|
||||
userScopes *string
|
||||
userType *string
|
||||
userDisplayName *string
|
||||
userDisplayColor *int
|
||||
userCreatedAt *time.Time
|
||||
userDisabledAt *time.Time
|
||||
previousUsernames *string
|
||||
userNameChangedAt *time.Time
|
||||
userAuthenticatedAt *time.Time
|
||||
userScopes *string
|
||||
userType *string
|
||||
}
|
||||
|
||||
func getChat(query string) []interface{} {
|
||||
|
@ -235,9 +237,11 @@ func getChat(query string) []interface{} {
|
|||
&row.userDisabledAt,
|
||||
&row.previousUsernames,
|
||||
&row.userNameChangedAt,
|
||||
&row.userAuthenticatedAt,
|
||||
&row.userScopes,
|
||||
&row.userType,
|
||||
); err != nil {
|
||||
log.Errorln(err)
|
||||
log.Errorln("There is a problem converting query to chat objects. Please report this:", query)
|
||||
break
|
||||
}
|
||||
|
@ -274,7 +278,7 @@ func GetChatModerationHistory() []interface{} {
|
|||
}
|
||||
|
||||
// Get all messages regardless of visibility
|
||||
query := "SELECT messages.id, user_id, body, title, subtitle, image, link, eventType, hidden_at, timestamp, display_name, display_color, created_at, disabled_at, previous_names, namechanged_at, scopes, users.type FROM messages INNER JOIN users ON messages.user_id = users.id ORDER BY timestamp DESC"
|
||||
query := "SELECT messages.id, user_id, body, title, subtitle, image, link, eventType, hidden_at, timestamp, display_name, display_color, created_at, disabled_at, previous_names, namechanged_at, authenticated_at, scopes, type FROM messages INNER JOIN users ON messages.user_id = users.id ORDER BY timestamp DESC"
|
||||
result := getChat(query)
|
||||
|
||||
_historyCache = &result
|
||||
|
@ -285,7 +289,7 @@ func GetChatModerationHistory() []interface{} {
|
|||
// GetChatHistory will return all the chat messages suitable for returning as user-facing chat history.
|
||||
func GetChatHistory() []interface{} {
|
||||
// Get all visible messages
|
||||
query := fmt.Sprintf("SELECT messages.id,messages.user_id, messages.body, messages.title, messages.subtitle, messages.image, messages.link, messages.eventType, messages.hidden_at, messages.timestamp, users.display_name, users.display_color, users.created_at, users.disabled_at, users.previous_names, users.namechanged_at, users.scopes, users.type FROM messages LEFT JOIN users ON messages.user_id = users.id WHERE hidden_at IS NULL AND disabled_at IS NULL ORDER BY timestamp DESC LIMIT %d", maxBacklogNumber)
|
||||
query := fmt.Sprintf("SELECT messages.id,messages.user_id, messages.body, messages.title, messages.subtitle, messages.image, messages.link, messages.eventType, messages.hidden_at, messages.timestamp, users.display_name, users.display_color, users.created_at, users.disabled_at, users.previous_names, users.namechanged_at, users.authenticated_at, users.scopes, users.type FROM messages LEFT JOIN users ON messages.user_id = users.id WHERE hidden_at IS NULL AND disabled_at IS NULL ORDER BY timestamp DESC LIMIT %d", maxBacklogNumber)
|
||||
m := getChat(query)
|
||||
|
||||
// Invert order of messages
|
||||
|
@ -305,7 +309,7 @@ func SetMessageVisibilityForUserID(userID string, visible bool) error {
|
|||
|
||||
// Get a list of IDs to send to the connected clients to hide
|
||||
ids := make([]string, 0)
|
||||
query := fmt.Sprintf("SELECT messages.id, user_id, body, title, subtitle, image, link, eventType, hidden_at, timestamp, display_name, display_color, created_at, disabled_at, previous_names, namechanged_at, scopes, type FROM messages INNER JOIN users ON messages.user_id = users.id WHERE user_id IS '%s'", userID)
|
||||
query := fmt.Sprintf("SELECT messages.id, user_id, body, title, subtitle, image, link, eventType, hidden_at, timestamp, display_name, display_color, created_at, disabled_at, previous_names, namechanged_at, authenticated, scopes, type FROM messages INNER JOIN users ON messages.user_id = users.id WHERE user_id IS '%s'", userID)
|
||||
messages := getChat(query)
|
||||
|
||||
if len(messages) == 0 {
|
||||
|
|
|
@ -201,10 +201,10 @@ func (s *Server) HandleClientConnection(w http.ResponseWriter, r *http.Request)
|
|||
// A user is required to use the websocket
|
||||
user := user.GetUserByToken(accessToken)
|
||||
if user == nil {
|
||||
// Send error that registration is required
|
||||
_ = conn.WriteJSON(events.EventPayload{
|
||||
"type": events.ErrorNeedsRegistration,
|
||||
})
|
||||
// Send error that registration is required
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@ import (
|
|||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/owncast/owncast/auth"
|
||||
"github.com/owncast/owncast/config"
|
||||
"github.com/owncast/owncast/core/chat"
|
||||
"github.com/owncast/owncast/core/data"
|
||||
|
@ -56,6 +57,7 @@ func Start() error {
|
|||
}
|
||||
|
||||
user.SetupUsers()
|
||||
auth.Setup(data.GetDatastore())
|
||||
|
||||
fileWriter.SetupFileWriterReceiverService(&handler)
|
||||
|
||||
|
|
|
@ -17,7 +17,7 @@ import (
|
|||
)
|
||||
|
||||
const (
|
||||
schemaVersion = 4
|
||||
schemaVersion = 5
|
||||
)
|
||||
|
||||
var (
|
||||
|
@ -75,6 +75,7 @@ func SetupPersistence(file string) error {
|
|||
|
||||
createWebhooksTable()
|
||||
createUsersTable(db)
|
||||
createAccessTokenTable(db)
|
||||
|
||||
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS config (
|
||||
"key" string NOT NULL PRIMARY KEY,
|
||||
|
@ -141,6 +142,8 @@ func migrateDatabase(db *sql.DB, from, to int) error {
|
|||
migrateToSchema3(db)
|
||||
case 3:
|
||||
migrateToSchema4(db)
|
||||
case 4:
|
||||
migrateToSchema5(db)
|
||||
default:
|
||||
log.Fatalln("missing database migration step")
|
||||
}
|
||||
|
|
|
@ -2,6 +2,8 @@ package data
|
|||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/owncast/owncast/utils"
|
||||
|
@ -9,7 +11,75 @@ import (
|
|||
"github.com/teris-io/shortid"
|
||||
)
|
||||
|
||||
func migrateToSchema5(db *sql.DB) {
|
||||
// Access tokens have been broken into its own table.
|
||||
|
||||
// Authenticated bool added to the users table.
|
||||
stmt, err := db.Prepare("ALTER TABLE users ADD authenticated_at timestamp DEFAULT null ")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer stmt.Close()
|
||||
_, err = stmt.Exec()
|
||||
if err != nil {
|
||||
log.Warnln(err)
|
||||
}
|
||||
|
||||
// Migrate the access tokens from the users table to the access tokens table.
|
||||
query := `SELECT id, access_token, created_at FROM users`
|
||||
rows, err := db.Query(query)
|
||||
if err != nil || rows.Err() != nil {
|
||||
log.Errorln("error migrating access tokens to schema v5", err, rows.Err())
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
valueStrings := []string{}
|
||||
valueArgs := []interface{}{}
|
||||
|
||||
var token string
|
||||
var userID string
|
||||
var timestamp time.Time
|
||||
for rows.Next() {
|
||||
if err := rows.Scan(&userID, &token, ×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) {
|
||||
// Access tokens have been broken into its own table.
|
||||
stmt, err := db.Prepare("ALTER TABLE ap_followers ADD COLUMN request_object BLOB")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
|
|
|
@ -6,18 +6,37 @@ import (
|
|||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func createAccessTokenTable(db *sql.DB) {
|
||||
createTableSQL := `CREATE TABLE IF NOT EXISTS user_access_tokens (
|
||||
"token" TEXT NOT NULL PRIMARY KEY,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"timestamp" DATE DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id)
|
||||
);`
|
||||
|
||||
stmt, err := db.Prepare(createTableSQL)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer stmt.Close()
|
||||
_, err = stmt.Exec()
|
||||
if err != nil {
|
||||
log.Warnln(err)
|
||||
}
|
||||
}
|
||||
|
||||
func createUsersTable(db *sql.DB) {
|
||||
log.Traceln("Creating users table...")
|
||||
|
||||
createTableSQL := `CREATE TABLE IF NOT EXISTS users (
|
||||
"id" TEXT,
|
||||
"access_token" string NOT NULL,
|
||||
"display_name" TEXT NOT NULL,
|
||||
"display_color" NUMBER NOT NULL,
|
||||
"created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
"disabled_at" TIMESTAMP,
|
||||
"previous_names" TEXT DEFAULT '',
|
||||
"namechanged_at" TIMESTAMP,
|
||||
"authenticated_at" TIMESTAMP,
|
||||
"scopes" TEXT,
|
||||
"type" TEXT DEFAULT 'STANDARD',
|
||||
"last_used" DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
|
|
|
@ -1,12 +1,13 @@
|
|||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/owncast/owncast/utils"
|
||||
"github.com/pkg/errors"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/teris-io/shortid"
|
||||
)
|
||||
|
@ -55,13 +56,13 @@ func InsertExternalAPIUser(token string, name string, color int, scopes []string
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stmt, err := tx.Prepare("INSERT INTO users(id, access_token, display_name, display_color, scopes, type, previous_names) values(?, ?, ?, ?, ?, ?, ?)")
|
||||
stmt, err := tx.Prepare("INSERT INTO users(id, display_name, display_color, scopes, type, previous_names) values(?, ?, ?, ?, ?, ?)")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
if _, err = stmt.Exec(id, token, name, color, scopesString, "API", name); err != nil {
|
||||
if _, err = stmt.Exec(id, name, color, scopesString, "API", name); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
@ -69,6 +70,10 @@ func InsertExternalAPIUser(token string, name string, color int, scopes []string
|
|||
return err
|
||||
}
|
||||
|
||||
if err := addAccessTokenForUser(token, id); err != nil {
|
||||
return errors.Wrap(err, "unable to save access token for new external api user")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@ -83,13 +88,13 @@ func DeleteExternalAPIUser(token string) error {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stmt, err := tx.Prepare("UPDATE users SET disabled_at = ? WHERE access_token = ?")
|
||||
stmt, err := tx.Prepare("UPDATE users SET disabled_at = CURRENT_TIMESTAMP WHERE id = (SELECT user_id FROM user_access_tokens WHERE token = ?)")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
result, err := stmt.Exec(time.Now(), token)
|
||||
result, err := stmt.Exec(token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -112,20 +117,20 @@ func GetExternalAPIUserForAccessTokenAndScope(token string, scope string) (*Exte
|
|||
// so we can efficiently find if a token supports a single scope.
|
||||
// This is SQLite specific, so if we ever support other database
|
||||
// backends we need to support other methods.
|
||||
query := `SELECT id, access_token, scopes, display_name, display_color, created_at, last_used FROM (
|
||||
WITH RECURSIVE split(id, access_token, scopes, display_name, display_color, created_at, last_used, disabled_at, scope, rest) AS (
|
||||
SELECT id, access_token, scopes, display_name, display_color, created_at, last_used, disabled_at, '', scopes || ',' FROM users
|
||||
query := `SELECT id, scopes, display_name, display_color, created_at, last_used FROM user_access_tokens, (
|
||||
WITH RECURSIVE split(id, scopes, display_name, display_color, created_at, last_used, disabled_at, scope, rest) AS (
|
||||
SELECT id, scopes, display_name, display_color, created_at, last_used, disabled_at, '', scopes || ',' FROM users
|
||||
UNION ALL
|
||||
SELECT id, access_token, scopes, display_name, display_color, created_at, last_used, disabled_at,
|
||||
SELECT id, scopes, display_name, display_color, created_at, last_used, disabled_at,
|
||||
substr(rest, 0, instr(rest, ',')),
|
||||
substr(rest, instr(rest, ',')+1)
|
||||
FROM split
|
||||
WHERE rest <> '')
|
||||
SELECT id, access_token, scopes, display_name, display_color, created_at, last_used, disabled_at, scope
|
||||
SELECT id, scopes, display_name, display_color, created_at, last_used, disabled_at, scope
|
||||
FROM split
|
||||
WHERE scope <> ''
|
||||
ORDER BY access_token, scope
|
||||
) AS token WHERE token.access_token = ? AND token.scope = ?`
|
||||
ORDER BY scope
|
||||
) AS token WHERE user_access_tokens.token = ? AND token.scope = ?`
|
||||
|
||||
row := _datastore.DB.QueryRow(query, token, scope)
|
||||
integration, err := makeExternalAPIUserFromRow(row)
|
||||
|
@ -135,23 +140,18 @@ func GetExternalAPIUserForAccessTokenAndScope(token string, scope string) (*Exte
|
|||
|
||||
// GetIntegrationNameForAccessToken will return the integration name associated with a specific access token.
|
||||
func GetIntegrationNameForAccessToken(token string) *string {
|
||||
query := "SELECT display_name FROM users WHERE access_token IS ? AND disabled_at IS NULL"
|
||||
row := _datastore.DB.QueryRow(query, token)
|
||||
|
||||
var name string
|
||||
err := row.Scan(&name)
|
||||
name, err := _datastore.GetQueries().GetUserDisplayNameByToken(context.Background(), token)
|
||||
if err != nil {
|
||||
log.Warnln(err)
|
||||
return nil
|
||||
}
|
||||
|
||||
return &name
|
||||
}
|
||||
|
||||
// GetExternalAPIUser will return all access tokens.
|
||||
// GetExternalAPIUser will return all API users with access tokens.
|
||||
func GetExternalAPIUser() ([]ExternalAPIUser, error) { //nolint
|
||||
// Get all messages sent within the past day
|
||||
query := "SELECT id, access_token, display_name, display_color, scopes, created_at, last_used FROM users WHERE type IS 'API' AND disabled_at IS NULL"
|
||||
query := "SELECT id, token, display_name, display_color, scopes, created_at, last_used FROM users, user_access_tokens WHERE user_access_tokens.user_id = id AND type IS 'API' AND disabled_at IS NULL"
|
||||
|
||||
rows, err := _datastore.DB.Query(query)
|
||||
if err != nil {
|
||||
|
@ -170,7 +170,8 @@ func SetExternalAPIUserAccessTokenAsUsed(token string) error {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stmt, err := tx.Prepare("UPDATE users SET last_used = CURRENT_TIMESTAMP WHERE access_token = ?")
|
||||
// stmt, err := tx.Prepare("UPDATE users SET last_used = CURRENT_TIMESTAMP WHERE access_token = ?")
|
||||
stmt, err := tx.Prepare("UPDATE users SET last_used = CURRENT_TIMESTAMP WHERE id = (SELECT user_id FROM user_access_tokens WHERE token = ?)")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -189,14 +190,13 @@ func SetExternalAPIUserAccessTokenAsUsed(token string) error {
|
|||
|
||||
func makeExternalAPIUserFromRow(row *sql.Row) (*ExternalAPIUser, error) {
|
||||
var id string
|
||||
var accessToken string
|
||||
var displayName string
|
||||
var displayColor int
|
||||
var scopes string
|
||||
var createdAt time.Time
|
||||
var lastUsedAt *time.Time
|
||||
|
||||
err := row.Scan(&id, &accessToken, &scopes, &displayName, &displayColor, &createdAt, &lastUsedAt)
|
||||
err := row.Scan(&id, &scopes, &displayName, &displayColor, &createdAt, &lastUsedAt)
|
||||
if err != nil {
|
||||
log.Debugln("unable to convert row to api user", err)
|
||||
return nil, err
|
||||
|
@ -204,7 +204,6 @@ func makeExternalAPIUserFromRow(row *sql.Row) (*ExternalAPIUser, error) {
|
|||
|
||||
integration := ExternalAPIUser{
|
||||
ID: id,
|
||||
AccessToken: accessToken,
|
||||
DisplayName: displayName,
|
||||
DisplayColor: displayColor,
|
||||
CreatedAt: createdAt,
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
@ -8,7 +9,9 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/owncast/owncast/core/data"
|
||||
"github.com/owncast/owncast/db"
|
||||
"github.com/owncast/owncast/utils"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/teris-io/shortid"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
@ -23,16 +26,17 @@ const (
|
|||
|
||||
// User represents a single chat user.
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
AccessToken string `json:"-"`
|
||||
DisplayName string `json:"displayName"`
|
||||
DisplayColor int `json:"displayColor"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
DisabledAt *time.Time `json:"disabledAt,omitempty"`
|
||||
PreviousNames []string `json:"previousNames"`
|
||||
NameChangedAt *time.Time `json:"nameChangedAt,omitempty"`
|
||||
Scopes []string `json:"scopes,omitempty"`
|
||||
IsBot bool `json:"isBot"`
|
||||
ID string `json:"id"`
|
||||
DisplayName string `json:"displayName"`
|
||||
DisplayColor int `json:"displayColor"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
DisabledAt *time.Time `json:"disabledAt,omitempty"`
|
||||
PreviousNames []string `json:"previousNames"`
|
||||
NameChangedAt *time.Time `json:"nameChangedAt,omitempty"`
|
||||
Scopes []string `json:"scopes,omitempty"`
|
||||
IsBot bool `json:"isBot"`
|
||||
AuthenticatedAt *time.Time `json:"-"`
|
||||
Authenticated bool `json:"authenticated"`
|
||||
}
|
||||
|
||||
// IsEnabled will return if this single user is enabled.
|
||||
|
@ -52,13 +56,8 @@ func SetupUsers() {
|
|||
}
|
||||
|
||||
// CreateAnonymousUser will create a new anonymous user with the provided display name.
|
||||
func CreateAnonymousUser(displayName string) (*User, error) {
|
||||
func CreateAnonymousUser(displayName string) (*User, string, error) {
|
||||
id := shortid.MustGenerate()
|
||||
accessToken, err := utils.GenerateAccessToken()
|
||||
if err != nil {
|
||||
log.Errorln("Unable to create access token for new user")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if displayName == "" {
|
||||
suggestedUsernamesList := data.GetSuggestedUsernamesList()
|
||||
|
@ -75,48 +74,62 @@ func CreateAnonymousUser(displayName string) (*User, error) {
|
|||
|
||||
user := &User{
|
||||
ID: id,
|
||||
AccessToken: accessToken,
|
||||
DisplayName: displayName,
|
||||
DisplayColor: displayColor,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
// Create new user.
|
||||
if err := create(user); err != nil {
|
||||
return nil, err
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
return user, nil
|
||||
// Assign it an access token.
|
||||
accessToken, err := utils.GenerateAccessToken()
|
||||
if err != nil {
|
||||
log.Errorln("Unable to create access token for new user")
|
||||
return nil, "", err
|
||||
}
|
||||
if err := addAccessTokenForUser(accessToken, id); err != nil {
|
||||
return nil, "", errors.Wrap(err, "unable to save access token for new user")
|
||||
}
|
||||
|
||||
return user, accessToken, nil
|
||||
}
|
||||
|
||||
// IsDisplayNameAvailable will check if the proposed name is available for use.
|
||||
func IsDisplayNameAvailable(displayName string) (bool, error) {
|
||||
if available, err := _datastore.GetQueries().IsDisplayNameAvailable(context.Background(), displayName); err != nil {
|
||||
return false, errors.Wrap(err, "unable to check if display name is available")
|
||||
} else if available != 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// ChangeUsername will change the user associated to userID from one display name to another.
|
||||
func ChangeUsername(userID string, username string) {
|
||||
func ChangeUsername(userID string, username string) error {
|
||||
_datastore.DbLock.Lock()
|
||||
defer _datastore.DbLock.Unlock()
|
||||
|
||||
tx, err := _datastore.DB.Begin()
|
||||
if err != nil {
|
||||
log.Debugln(err)
|
||||
}
|
||||
defer func() {
|
||||
if err := tx.Rollback(); err != nil {
|
||||
log.Debugln(err)
|
||||
}
|
||||
}()
|
||||
|
||||
stmt, err := tx.Prepare("UPDATE users SET display_name = ?, previous_names = previous_names || ?, namechanged_at = ? WHERE id = ?")
|
||||
if err != nil {
|
||||
log.Debugln(err)
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
_, err = stmt.Exec(username, fmt.Sprintf(",%s", username), time.Now(), userID)
|
||||
if err != nil {
|
||||
log.Errorln(err)
|
||||
if err := _datastore.GetQueries().ChangeDisplayName(context.Background(), db.ChangeDisplayNameParams{
|
||||
DisplayName: username,
|
||||
ID: userID,
|
||||
PreviousNames: sql.NullString{String: fmt.Sprintf(",%s", username), Valid: true},
|
||||
NamechangedAt: sql.NullTime{Time: time.Now(), Valid: true},
|
||||
}); err != nil {
|
||||
return errors.Wrap(err, "unable to change display name")
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
log.Errorln("error changing display name of user", userID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func addAccessTokenForUser(accessToken, userID string) error {
|
||||
return _datastore.GetQueries().AddAccessTokenForUser(context.Background(), db.AddAccessTokenForUserParams{
|
||||
Token: accessToken,
|
||||
UserID: userID,
|
||||
})
|
||||
}
|
||||
|
||||
func create(user *User) error {
|
||||
|
@ -131,15 +144,16 @@ func create(user *User) error {
|
|||
_ = tx.Rollback()
|
||||
}()
|
||||
|
||||
stmt, err := tx.Prepare("INSERT INTO users(id, access_token, display_name, display_color, previous_names, created_at) values(?, ?, ?, ?, ?, ?)")
|
||||
stmt, err := tx.Prepare("INSERT INTO users(id, display_name, display_color, previous_names, created_at) values(?, ?, ?, ?, ?)")
|
||||
if err != nil {
|
||||
log.Debugln(err)
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
_, err = stmt.Exec(user.ID, user.AccessToken, user.DisplayName, user.DisplayColor, user.DisplayName, user.CreatedAt)
|
||||
_, err = stmt.Exec(user.ID, user.DisplayName, user.DisplayColor, user.DisplayName, user.CreatedAt)
|
||||
if err != nil {
|
||||
log.Errorln("error creating new user", err)
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
|
@ -179,13 +193,53 @@ func SetEnabled(userID string, enabled bool) error {
|
|||
|
||||
// GetUserByToken will return a user by an access token.
|
||||
func GetUserByToken(token string) *User {
|
||||
_datastore.DbLock.Lock()
|
||||
defer _datastore.DbLock.Unlock()
|
||||
u, err := _datastore.GetQueries().GetUserByAccessToken(context.Background(), token)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
query := "SELECT id, display_name, display_color, created_at, disabled_at, previous_names, namechanged_at, scopes FROM users WHERE access_token = ?"
|
||||
row := _datastore.DB.QueryRow(query, token)
|
||||
var scopes []string
|
||||
if u.Scopes.Valid {
|
||||
scopes = strings.Split(u.Scopes.String, ",")
|
||||
}
|
||||
|
||||
return getUserFromRow(row)
|
||||
var disabledAt *time.Time
|
||||
if u.DisabledAt.Valid {
|
||||
disabledAt = &u.DisabledAt.Time
|
||||
}
|
||||
|
||||
var authenticatedAt *time.Time
|
||||
if u.AuthenticatedAt.Valid {
|
||||
authenticatedAt = &u.AuthenticatedAt.Time
|
||||
}
|
||||
|
||||
return &User{
|
||||
ID: u.ID,
|
||||
DisplayName: u.DisplayName,
|
||||
DisplayColor: int(u.DisplayColor),
|
||||
CreatedAt: u.CreatedAt.Time,
|
||||
DisabledAt: disabledAt,
|
||||
PreviousNames: strings.Split(u.PreviousNames.String, ","),
|
||||
NameChangedAt: &u.NamechangedAt.Time,
|
||||
AuthenticatedAt: authenticatedAt,
|
||||
Authenticated: authenticatedAt != nil,
|
||||
Scopes: scopes,
|
||||
}
|
||||
}
|
||||
|
||||
// SetAccessTokenToOwner will reassign an access token to be owned by a
|
||||
// different user. Used for logging in with external auth.
|
||||
func SetAccessTokenToOwner(token, userID string) error {
|
||||
return _datastore.GetQueries().SetAccessTokenToOwner(context.Background(), db.SetAccessTokenToOwnerParams{
|
||||
UserID: userID,
|
||||
Token: token,
|
||||
})
|
||||
}
|
||||
|
||||
// SetUserAsAuthenticated will mark that a user has been authenticated
|
||||
// in some way.
|
||||
func SetUserAsAuthenticated(userID string) error {
|
||||
return errors.Wrap(_datastore.GetQueries().SetUserAsAuthenticated(context.Background(), userID), "unable to set user as authenticated")
|
||||
}
|
||||
|
||||
// SetModerator will add or remove moderator status for a single user by ID.
|
||||
|
@ -199,6 +253,10 @@ func SetModerator(userID string, isModerator bool) error {
|
|||
|
||||
func addScopeToUser(userID string, scope string) error {
|
||||
u := GetUserByID(userID)
|
||||
if u == nil {
|
||||
return errors.New("user not found when modifying scope")
|
||||
}
|
||||
|
||||
scopesString := u.Scopes
|
||||
scopes := utils.StringSliceToMap(scopesString)
|
||||
scopes[scope] = true
|
||||
|
|
28
db/models.go
28
db/models.go
|
@ -38,6 +38,14 @@ type ApOutbox struct {
|
|||
LiveNotification sql.NullBool
|
||||
}
|
||||
|
||||
type Auth struct {
|
||||
ID int32
|
||||
UserID string
|
||||
Token string
|
||||
Type string
|
||||
Timestamp time.Time
|
||||
}
|
||||
|
||||
type IpBan struct {
|
||||
IpAddress string
|
||||
Notes sql.NullString
|
||||
|
@ -50,3 +58,23 @@ type Notification struct {
|
|||
Destination string
|
||||
CreatedAt sql.NullTime
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID string
|
||||
DisplayName string
|
||||
DisplayColor int32
|
||||
CreatedAt sql.NullTime
|
||||
DisabledAt sql.NullTime
|
||||
PreviousNames sql.NullString
|
||||
NamechangedAt sql.NullTime
|
||||
Scopes sql.NullString
|
||||
AuthenticatedAt sql.NullTime
|
||||
Type sql.NullString
|
||||
LastUsed interface{}
|
||||
}
|
||||
|
||||
type UserAccessToken struct {
|
||||
Token string
|
||||
UserID string
|
||||
Timestamp time.Time
|
||||
}
|
||||
|
|
26
db/query.sql
26
db/query.sql
|
@ -78,3 +78,29 @@ SELECT destination FROM notifications WHERE channel = $1;
|
|||
|
||||
-- name: RemoveNotificationDestinationForChannel :exec
|
||||
DELETE FROM notifications WHERE channel = $1 AND destination = $2;
|
||||
-- name: AddAuthForUser :exec
|
||||
INSERT INTO auth(user_id, token, type) values($1, $2, $3);
|
||||
|
||||
-- name: GetUserByAuth :one
|
||||
SELECT users.id, display_name, display_color, users.created_at, disabled_at, previous_names, namechanged_at, authenticated_at, scopes FROM auth, users WHERE token = $1 AND auth.type = $2 AND users.id = auth.user_id;
|
||||
|
||||
-- name: AddAccessTokenForUser :exec
|
||||
INSERT INTO user_access_tokens(token, user_id) values($1, $2);
|
||||
|
||||
-- name: GetUserByAccessToken :one
|
||||
SELECT users.id, display_name, display_color, users.created_at, disabled_at, previous_names, namechanged_at, authenticated_at, scopes FROM users, user_access_tokens WHERE token = $1 AND users.id = user_id;
|
||||
|
||||
-- name: GetUserDisplayNameByToken :one
|
||||
SELECT display_name FROM users, user_access_tokens WHERE token = $1 AND users.id = user_id AND disabled_at = NULL;
|
||||
|
||||
-- name: SetAccessTokenToOwner :exec
|
||||
UPDATE user_access_tokens SET user_id = $1 WHERE token = $2;
|
||||
|
||||
-- name: SetUserAsAuthenticated :exec
|
||||
UPDATE users SET authenticated_at = CURRENT_TIMESTAMP WHERE id = $1;
|
||||
|
||||
-- name: IsDisplayNameAvailable :one
|
||||
SELECT count(*) FROM users WHERE display_name = $1 AND authenticated_at is not null AND disabled_at is NULL;
|
||||
|
||||
-- name: ChangeDisplayName :exec
|
||||
UPDATE users SET display_name = $1, previous_names = previous_names || $2, namechanged_at = $3 WHERE id = $4;
|
||||
|
|
166
db/query.sql.go
166
db/query.sql.go
|
@ -11,6 +11,35 @@ import (
|
|||
"time"
|
||||
)
|
||||
|
||||
const addAccessTokenForUser = `-- name: AddAccessTokenForUser :exec
|
||||
INSERT INTO user_access_tokens(token, user_id) values($1, $2)
|
||||
`
|
||||
|
||||
type AddAccessTokenForUserParams struct {
|
||||
Token string
|
||||
UserID string
|
||||
}
|
||||
|
||||
func (q *Queries) AddAccessTokenForUser(ctx context.Context, arg AddAccessTokenForUserParams) error {
|
||||
_, err := q.db.ExecContext(ctx, addAccessTokenForUser, arg.Token, arg.UserID)
|
||||
return err
|
||||
}
|
||||
|
||||
const addAuthForUser = `-- name: AddAuthForUser :exec
|
||||
INSERT INTO auth(user_id, token, type) values($1, $2, $3)
|
||||
`
|
||||
|
||||
type AddAuthForUserParams struct {
|
||||
UserID string
|
||||
Token string
|
||||
Type string
|
||||
}
|
||||
|
||||
func (q *Queries) AddAuthForUser(ctx context.Context, arg AddAuthForUserParams) error {
|
||||
_, err := q.db.ExecContext(ctx, addAuthForUser, arg.UserID, arg.Token, arg.Type)
|
||||
return err
|
||||
}
|
||||
|
||||
const addFollower = `-- name: AddFollower :exec
|
||||
INSERT INTO ap_followers(iri, inbox, request, request_object, name, username, image, approved_at) values($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
`
|
||||
|
@ -124,6 +153,27 @@ func (q *Queries) BanIPAddress(ctx context.Context, arg BanIPAddressParams) erro
|
|||
return err
|
||||
}
|
||||
|
||||
const changeDisplayName = `-- name: ChangeDisplayName :exec
|
||||
UPDATE users SET display_name = $1, previous_names = previous_names || $2, namechanged_at = $3 WHERE id = $4
|
||||
`
|
||||
|
||||
type ChangeDisplayNameParams struct {
|
||||
DisplayName string
|
||||
PreviousNames sql.NullString
|
||||
NamechangedAt sql.NullTime
|
||||
ID string
|
||||
}
|
||||
|
||||
func (q *Queries) ChangeDisplayName(ctx context.Context, arg ChangeDisplayNameParams) error {
|
||||
_, err := q.db.ExecContext(ctx, changeDisplayName,
|
||||
arg.DisplayName,
|
||||
arg.PreviousNames,
|
||||
arg.NamechangedAt,
|
||||
arg.ID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const doesInboundActivityExist = `-- name: DoesInboundActivityExist :one
|
||||
SELECT count(*) FROM ap_accepted_activities WHERE iri = $1 AND actor = $2 AND TYPE = $3
|
||||
`
|
||||
|
@ -492,6 +542,99 @@ func (q *Queries) GetRejectedAndBlockedFollowers(ctx context.Context) ([]GetReje
|
|||
return items, nil
|
||||
}
|
||||
|
||||
const getUserByAccessToken = `-- name: GetUserByAccessToken :one
|
||||
SELECT users.id, display_name, display_color, users.created_at, disabled_at, previous_names, namechanged_at, authenticated_at, scopes FROM users, user_access_tokens WHERE token = $1 AND users.id = user_id
|
||||
`
|
||||
|
||||
type GetUserByAccessTokenRow struct {
|
||||
ID string
|
||||
DisplayName string
|
||||
DisplayColor int32
|
||||
CreatedAt sql.NullTime
|
||||
DisabledAt sql.NullTime
|
||||
PreviousNames sql.NullString
|
||||
NamechangedAt sql.NullTime
|
||||
AuthenticatedAt sql.NullTime
|
||||
Scopes sql.NullString
|
||||
}
|
||||
|
||||
func (q *Queries) GetUserByAccessToken(ctx context.Context, token string) (GetUserByAccessTokenRow, error) {
|
||||
row := q.db.QueryRowContext(ctx, getUserByAccessToken, token)
|
||||
var i GetUserByAccessTokenRow
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.DisplayName,
|
||||
&i.DisplayColor,
|
||||
&i.CreatedAt,
|
||||
&i.DisabledAt,
|
||||
&i.PreviousNames,
|
||||
&i.NamechangedAt,
|
||||
&i.AuthenticatedAt,
|
||||
&i.Scopes,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getUserByAuth = `-- name: GetUserByAuth :one
|
||||
SELECT users.id, display_name, display_color, users.created_at, disabled_at, previous_names, namechanged_at, authenticated_at, scopes FROM auth, users WHERE token = $1 AND auth.type = $2 AND users.id = auth.user_id
|
||||
`
|
||||
|
||||
type GetUserByAuthParams struct {
|
||||
Token string
|
||||
Type string
|
||||
}
|
||||
|
||||
type GetUserByAuthRow struct {
|
||||
ID string
|
||||
DisplayName string
|
||||
DisplayColor int32
|
||||
CreatedAt sql.NullTime
|
||||
DisabledAt sql.NullTime
|
||||
PreviousNames sql.NullString
|
||||
NamechangedAt sql.NullTime
|
||||
AuthenticatedAt sql.NullTime
|
||||
Scopes sql.NullString
|
||||
}
|
||||
|
||||
func (q *Queries) GetUserByAuth(ctx context.Context, arg GetUserByAuthParams) (GetUserByAuthRow, error) {
|
||||
row := q.db.QueryRowContext(ctx, getUserByAuth, arg.Token, arg.Type)
|
||||
var i GetUserByAuthRow
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.DisplayName,
|
||||
&i.DisplayColor,
|
||||
&i.CreatedAt,
|
||||
&i.DisabledAt,
|
||||
&i.PreviousNames,
|
||||
&i.NamechangedAt,
|
||||
&i.AuthenticatedAt,
|
||||
&i.Scopes,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getUserDisplayNameByToken = `-- name: GetUserDisplayNameByToken :one
|
||||
SELECT display_name FROM users, user_access_tokens WHERE token = $1 AND users.id = user_id AND disabled_at = NULL
|
||||
`
|
||||
|
||||
func (q *Queries) GetUserDisplayNameByToken(ctx context.Context, token string) (string, error) {
|
||||
row := q.db.QueryRowContext(ctx, getUserDisplayNameByToken, token)
|
||||
var display_name string
|
||||
err := row.Scan(&display_name)
|
||||
return display_name, err
|
||||
}
|
||||
|
||||
const isDisplayNameAvailable = `-- name: IsDisplayNameAvailable :one
|
||||
SELECT count(*) FROM users WHERE display_name = $1 AND authenticated_at is not null AND disabled_at is NULL
|
||||
`
|
||||
|
||||
func (q *Queries) IsDisplayNameAvailable(ctx context.Context, displayName string) (int64, error) {
|
||||
row := q.db.QueryRowContext(ctx, isDisplayNameAvailable, displayName)
|
||||
var count int64
|
||||
err := row.Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
const isIPAddressBlocked = `-- name: IsIPAddressBlocked :one
|
||||
SELECT count(*) FROM ip_bans WHERE ip_address = $1
|
||||
`
|
||||
|
@ -549,6 +692,29 @@ func (q *Queries) RemoveNotificationDestinationForChannel(ctx context.Context, a
|
|||
return err
|
||||
}
|
||||
|
||||
const setAccessTokenToOwner = `-- name: SetAccessTokenToOwner :exec
|
||||
UPDATE user_access_tokens SET user_id = $1 WHERE token = $2
|
||||
`
|
||||
|
||||
type SetAccessTokenToOwnerParams struct {
|
||||
UserID string
|
||||
Token string
|
||||
}
|
||||
|
||||
func (q *Queries) SetAccessTokenToOwner(ctx context.Context, arg SetAccessTokenToOwnerParams) error {
|
||||
_, err := q.db.ExecContext(ctx, setAccessTokenToOwner, arg.UserID, arg.Token)
|
||||
return err
|
||||
}
|
||||
|
||||
const setUserAsAuthenticated = `-- name: SetUserAsAuthenticated :exec
|
||||
UPDATE users SET authenticated_at = CURRENT_TIMESTAMP WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) SetUserAsAuthenticated(ctx context.Context, id string) error {
|
||||
_, err := q.db.ExecContext(ctx, setUserAsAuthenticated, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const updateFollowerByIRI = `-- name: UpdateFollowerByIRI :exec
|
||||
UPDATE ap_followers SET inbox = $1, name = $2, username = $3, image = $4 WHERE iri = $5
|
||||
`
|
||||
|
|
|
@ -49,3 +49,33 @@ CREATE TABLE IF NOT EXISTS notifications (
|
|||
"destination" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
|
||||
CREATE INDEX channel_index ON notifications (channel);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
"id" TEXT,
|
||||
"display_name" TEXT NOT NULL,
|
||||
"display_color" INTEGER NOT NULL,
|
||||
"created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
"disabled_at" TIMESTAMP,
|
||||
"previous_names" TEXT DEFAULT '',
|
||||
"namechanged_at" TIMESTAMP,
|
||||
"scopes" TEXT,
|
||||
"authenticated_at" TIMESTAMP,
|
||||
"type" TEXT DEFAULT 'STANDARD',
|
||||
"last_used" DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_access_tokens (
|
||||
"token" TEXT NOT NULL PRIMARY KEY,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"timestamp" DATE DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS auth (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"timestamp" DATE DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||
);
|
||||
CREATE INDEX auth_token ON auth (token);
|
||||
|
|
2
go.mod
2
go.mod
|
@ -73,4 +73,6 @@ require (
|
|||
github.com/oschwald/maxminddb-golang v1.9.0 // indirect
|
||||
)
|
||||
|
||||
require github.com/andybalholm/cascadia v1.3.1
|
||||
|
||||
replace github.com/go-fed/activity => github.com/owncast/activity v1.0.1-0.20211229051252-7821289d4026
|
||||
|
|
33
go.sum
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/amalfra/etag v1.0.0 h1:3PNsV45JS4C8SaQ97jxCoUZv22tGlSRF7dgsP9C7yww=
|
||||
github.com/amalfra/etag v1.0.0/go.mod h1:NROjmbfRufDsrJFWcnYxGJSlCtTKn4tXTp2zwyqdSbU=
|
||||
github.com/aws/aws-sdk-go v1.43.31 h1:yJZIr8nMV1hXjAvvOLUFqZRJcHV7udPQBfhJqawDzI0=
|
||||
github.com/aws/aws-sdk-go v1.43.31/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo=
|
||||
github.com/aws/aws-sdk-go v1.43.36 h1:8a+pYKNT7wSxUy3fi5dSqKQdfmit7SYGg5fv4zf+WuA=
|
||||
github.com/aws/aws-sdk-go v1.43.36/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo=
|
||||
github.com/aws/aws-sdk-go v1.43.37 h1:kyZ7UjaPZaCik+asF33UFOOYSwr9liDRr/UM/vuw8yY=
|
||||
github.com/aws/aws-sdk-go v1.43.37/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo=
|
||||
github.com/aws/aws-sdk-go v1.43.38 h1:TDRjsUIsx2aeSuKkyzbwgltIRTbIKH6YCZbZ27JYhPk=
|
||||
github.com/aws/aws-sdk-go v1.43.38/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo=
|
||||
github.com/aws/aws-sdk-go v1.43.39 h1:5W8pton/8OuS5hpbAkzfr7e+meAAFkK7LsUehB39L3I=
|
||||
github.com/aws/aws-sdk-go v1.43.39/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo=
|
||||
github.com/aws/aws-sdk-go v1.43.41 h1:HaazVplP8/t6SOfybQlNUmjAxLWDKdLdX8BSEHFlJdY=
|
||||
github.com/aws/aws-sdk-go v1.43.41/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo=
|
||||
github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c=
|
||||
github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
|
||||
github.com/aws/aws-sdk-go v1.43.43 h1:1L06qzQvl4aC3Skfh5rV7xVhGHjIZoHcqy16NoyQ1o4=
|
||||
github.com/aws/aws-sdk-go v1.43.43/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo=
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
|
@ -261,8 +251,6 @@ github.com/rifflock/lfshook v0.0.0-20180920164130-b9218ef580f5/go.mod h1:GEXHk5H
|
|||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||
github.com/schollz/sqlite3dump v1.3.1 h1:QXizJ7XEJ7hggjqjZ3YRtF3+javm8zKtzNByYtEkPRA=
|
||||
github.com/schollz/sqlite3dump v1.3.1/go.mod h1:mzSTjZpJH4zAb1FN3iNlhWPbbdyeBpOaTW0hukyMHyI=
|
||||
github.com/shirou/gopsutil/v3 v3.22.2 h1:wCrArWFkHYIdDxx/FSfF5RB4dpJYW6t7rcp3+zL8uks=
|
||||
github.com/shirou/gopsutil/v3 v3.22.2/go.mod h1:WapW1AOOPlHyXr+yOyw3uYx36enocrtSoSBy0L5vUHY=
|
||||
github.com/shirou/gopsutil/v3 v3.22.3 h1:UebRzEomgMpv61e3hgD1tGooqX5trFbdU/ehphbHd00=
|
||||
github.com/shirou/gopsutil/v3 v3.22.3/go.mod h1:D01hZJ4pVHPpCTZ3m3T2+wDF2YAGfd+H4ifUguaQzHM=
|
||||
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
|
||||
|
@ -282,12 +270,8 @@ github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMT
|
|||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/teris-io/shortid v0.0.0-20201117134242-e59966efd125 h1:3SNcvBmEPE1YlB1JpVZouslJpI3GBNoiqW7+wb0Rz7w=
|
||||
github.com/teris-io/shortid v0.0.0-20201117134242-e59966efd125/go.mod h1:M8agBzgqHIhgj7wEn9/0hJUZcrvt9VY+Ln+S1I5Mha0=
|
||||
github.com/tklauser/go-sysconf v0.3.9 h1:JeUVdAOWhhxVcU6Eqr/ATFHgXk/mmiItdKeJPev3vTo=
|
||||
github.com/tklauser/go-sysconf v0.3.9/go.mod h1:11DU/5sG7UexIrp/O6g35hrWzu0JxlwQ3LSFUzyeuhs=
|
||||
github.com/tklauser/go-sysconf v0.3.10 h1:IJ1AZGZRWbY8T5Vfk04D9WOA5WSejdflXxP03OUqALw=
|
||||
github.com/tklauser/go-sysconf v0.3.10/go.mod h1:C8XykCvCb+Gn0oNCWPIlcb0RuglQTYaQ2hGm7jmxEFk=
|
||||
github.com/tklauser/numcpus v0.3.0 h1:ILuRUQBtssgnxw0XXIjKUC56fgnOrFoQQ/4+DeU2biQ=
|
||||
github.com/tklauser/numcpus v0.3.0/go.mod h1:yFGUr7TUHQRAhyqBcEg0Ge34zDBAsIvJJcyE6boqnA8=
|
||||
github.com/tklauser/numcpus v0.4.0 h1:E53Dm1HjH1/R2/aoCtXtPgzmElmn51aOkhCFSuZq//o=
|
||||
github.com/tklauser/numcpus v0.4.0/go.mod h1:1+UI3pD8NW14VMwdgJNJ1ESk2UnwhAnz5hMwiKKqXCQ=
|
||||
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
|
@ -375,16 +359,9 @@ golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81R
|
|||
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||
golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
golang.org/x/net v0.0.0-20220403103023-749bd193bc2b h1:vI32FkLJNAWtGD4BwkThwEy6XS7ZLLMHkSkYfF8M0W0=
|
||||
golang.org/x/net v0.0.0-20220403103023-749bd193bc2b/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
golang.org/x/net v0.0.0-20220407224826-aac1ed45d8e3 h1:EN5+DfgmRMvRUrMGERW2gQl3Vc+Z7ZMnI/xdEpPSf0c=
|
||||
golang.org/x/net v0.0.0-20220407224826-aac1ed45d8e3/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
golang.org/x/net v0.0.0-20220412020605-290c469a71a5 h1:bRb386wvrE+oBNdF1d/Xh9mQrfQ4ecYhW5qJ5GvTGT4=
|
||||
golang.org/x/net v0.0.0-20220412020605-290c469a71a5/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
golang.org/x/net v0.0.0-20220418201149-a630d4f3e7a2 h1:6mzvA99KwZxbOrxww4EvWVQUnN1+xEu9tafK5ZxkYeA=
|
||||
golang.org/x/net v0.0.0-20220418201149-a630d4f3e7a2/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
golang.org/x/net v0.0.0-20220420153159-1850ba15e1be h1:yx80W7nvY5ySWpaU8UWaj5o9e23YgO9BRhQol7Lc+JI=
|
||||
golang.org/x/net v0.0.0-20220420153159-1850ba15e1be/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
|
@ -442,9 +419,7 @@ golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7w
|
|||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210816074244-15123e1e1f71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220111092808-5a964db01320/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220325203850-36772127a21f h1:TrmogKRsSOxRMJbLYGrB4SBbW+LJcEllYBLME5Zk5pU=
|
||||
|
@ -463,8 +438,6 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
|||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20220224211638-0e9765cccd65 h1:M73Iuj3xbbb9Uk1DYhzydthsj6oOd6l9bpuFcNoUvTs=
|
||||
golang.org/x/time v0.0.0-20220224211638-0e9765cccd65/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20220411224347-583f2d630306 h1:+gHMid33q6pen7kv9xvT+JRinntgeXO2AeZVd0AWD3w=
|
||||
golang.org/x/time v0.0.0-20220411224347-583f2d630306/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
|
|
|
@ -14,6 +14,9 @@ import (
|
|||
// ExternalAccessTokenHandlerFunc is a function that is called after validing access.
|
||||
type ExternalAccessTokenHandlerFunc func(user.ExternalAPIUser, http.ResponseWriter, *http.Request)
|
||||
|
||||
// UserAccessTokenHandlerFunc is a function that is called after validing user access.
|
||||
type UserAccessTokenHandlerFunc func(user.User, http.ResponseWriter, *http.Request)
|
||||
|
||||
// RequireAdminAuth wraps a handler requiring HTTP basic auth for it using the given
|
||||
// the stream key as the password and and a hardcoded "admin" for username.
|
||||
func RequireAdminAuth(handler http.HandlerFunc) http.HandlerFunc {
|
||||
|
@ -94,7 +97,7 @@ func RequireExternalAPIAccessToken(scope string, handler ExternalAccessTokenHand
|
|||
|
||||
// RequireUserAccessToken will validate a provided user's access token and make sure the associated user is enabled.
|
||||
// Not to be used for validating 3rd party access.
|
||||
func RequireUserAccessToken(handler http.HandlerFunc) http.HandlerFunc {
|
||||
func RequireUserAccessToken(handler UserAccessTokenHandlerFunc) http.HandlerFunc {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
accessToken := r.URL.Query().Get("accessToken")
|
||||
if accessToken == "" {
|
||||
|
@ -119,7 +122,7 @@ func RequireUserAccessToken(handler http.HandlerFunc) http.HandlerFunc {
|
|||
return
|
||||
}
|
||||
|
||||
handler(w, r)
|
||||
handler(*user, w, r)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -21,7 +21,7 @@ func SetHeaders(w http.ResponseWriter) {
|
|||
}
|
||||
// Content security policy
|
||||
csp := []string{
|
||||
fmt.Sprintf("script-src 'self' %s 'sha256-rnxPrBaD0OuYxsCdrll4QJwtDLcBJqFh0u27CoX5jZ8=' 'sha256-PzXGlTLvNFZ7et6GkP2nD3XuSaAKQVBSYiHzU2ZKm8o=' 'sha256-/wqazZOqIpFSIrNVseblbKCXrezG73X7CMqRSTf+8zw=' 'sha256-jCj2f+ICtd8fvdb0ngc+Hkr/ZnZOMvNkikno/XR6VZs='", unsafeEval),
|
||||
fmt.Sprintf("script-src 'self' %s 'sha256-B5bOgtE39ax4J6RqDE93TVYrJeLAdxDOJFtF3hoWYDw=' 'sha256-PzXGlTLvNFZ7et6GkP2nD3XuSaAKQVBSYiHzU2ZKm8o=' 'sha256-/wqazZOqIpFSIrNVseblbKCXrezG73X7CMqRSTf+8zw=' 'sha256-jCj2f+ICtd8fvdb0ngc+Hkr/ZnZOMvNkikno/XR6VZs='", unsafeEval),
|
||||
"worker-src 'self' blob:", // No single quotes around blob:
|
||||
}
|
||||
w.Header().Set("Content-Security-Policy", strings.Join(csp, "; "))
|
||||
|
|
|
@ -13,6 +13,7 @@ import (
|
|||
"github.com/owncast/owncast/config"
|
||||
"github.com/owncast/owncast/controllers"
|
||||
"github.com/owncast/owncast/controllers/admin"
|
||||
"github.com/owncast/owncast/controllers/auth/indieauth"
|
||||
"github.com/owncast/owncast/core/chat"
|
||||
"github.com/owncast/owncast/core/data"
|
||||
"github.com/owncast/owncast/core/user"
|
||||
|
@ -349,6 +350,15 @@ func Start() error {
|
|||
http.HandleFunc("/api/admin/config/notifications/browser", middleware.RequireAdminAuth(admin.SetBrowserNotificationConfiguration))
|
||||
http.HandleFunc("/api/admin/config/notifications/twitter", middleware.RequireAdminAuth(admin.SetTwitterConfiguration))
|
||||
|
||||
// Auth
|
||||
|
||||
// Start auth flow
|
||||
http.HandleFunc("/api/auth/indieauth", middleware.RequireUserAccessToken(indieauth.StartAuthFlow))
|
||||
http.HandleFunc("/api/auth/indieauth/callback", indieauth.HandleRedirect)
|
||||
|
||||
// Handle auth provider requests
|
||||
http.HandleFunc("/api/auth/provider/indieauth", indieauth.HandleAuthEndpoint)
|
||||
|
||||
// ActivityPub has its own router
|
||||
activitypub.Start(data.GetDatastore())
|
||||
|
||||
|
|
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="manifest" href="/manifest.json">
|
||||
|
||||
<link rel="authorization_endpoint" href="/api/auth/provider/indieauth">
|
||||
|
||||
<meta name="msapplication-TileColor" content="#ffffff">
|
||||
<meta name="msapplication-TileImage" content="/img/favicon/ms-icon-144x144.png">
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
|
|
|
@ -113,7 +113,7 @@ test('send an external integration action using access token', async (done) => {
|
|||
const payload = {
|
||||
body: 'This is a test external action from the automated integration test',
|
||||
};
|
||||
const res = await request
|
||||
await request
|
||||
.post('/api/integrations/chat/action')
|
||||
.set('Authorization', 'Bearer ' + accessToken)
|
||||
.send(payload)
|
||||
|
|
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>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Owncast</title>
|
||||
<base target="_blank" />
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0"
|
||||
/>
|
||||
|
||||
<head>
|
||||
<title>Owncast</title>
|
||||
<base target="_blank" />
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0" />
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="57x57"
|
||||
href="/img/favicon/apple-icon-57x57.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="60x60"
|
||||
href="/img/favicon/apple-icon-60x60.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="72x72"
|
||||
href="/img/favicon/apple-icon-72x72.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="76x76"
|
||||
href="/img/favicon/apple-icon-76x76.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="114x114"
|
||||
href="/img/favicon/apple-icon-114x114.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="120x120"
|
||||
href="/img/favicon/apple-icon-120x120.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="144x144"
|
||||
href="/img/favicon/apple-icon-144x144.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="152x152"
|
||||
href="/img/favicon/apple-icon-152x152.png"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="180x180"
|
||||
href="/img/favicon/apple-icon-180x180.png"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="192x192"
|
||||
href="/img/favicon/android-icon-192x192.png"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="32x32"
|
||||
href="/img/favicon/favicon-32x32.png"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="96x96"
|
||||
href="/img/favicon/favicon-96x96.png"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="16x16"
|
||||
href="/img/favicon/favicon-16x16.png"
|
||||
/>
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
|
||||
<link rel="apple-touch-icon" sizes="57x57" href="/img/favicon/apple-icon-57x57.png">
|
||||
<link rel="apple-touch-icon" sizes="60x60" href="/img/favicon/apple-icon-60x60.png">
|
||||
<link rel="apple-touch-icon" sizes="72x72" href="/img/favicon/apple-icon-72x72.png">
|
||||
<link rel="apple-touch-icon" sizes="76x76" href="/img/favicon/apple-icon-76x76.png">
|
||||
<link rel="apple-touch-icon" sizes="114x114" href="/img/favicon/apple-icon-114x114.png">
|
||||
<link rel="apple-touch-icon" sizes="120x120" href="/img/favicon/apple-icon-120x120.png">
|
||||
<link rel="apple-touch-icon" sizes="144x144" href="/img/favicon/apple-icon-144x144.png">
|
||||
<link rel="apple-touch-icon" sizes="152x152" href="/img/favicon/apple-icon-152x152.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/img/favicon/apple-icon-180x180.png">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/img/favicon/android-icon-192x192.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/img/favicon/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="/img/favicon/favicon-96x96.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/img/favicon/favicon-16x16.png">
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<link rel="authorization_endpoint" href="/api/auth/provider/indieauth" />
|
||||
|
||||
<meta name="msapplication-TileColor" content="#ffffff">
|
||||
<meta name="msapplication-TileImage" content="/img/favicon/ms-icon-144x144.png">
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
<meta name="msapplication-TileColor" content="#ffffff" />
|
||||
<meta
|
||||
name="msapplication-TileImage"
|
||||
content="/img/favicon/ms-icon-144x144.png"
|
||||
/>
|
||||
<meta name="theme-color" content="#ffffff" />
|
||||
|
||||
<link href="/js/web_modules/tailwindcss/dist/tailwind.min.css" rel="stylesheet" />
|
||||
<link
|
||||
href="/js/web_modules/tailwindcss/dist/tailwind.min.css"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
|
||||
<link href="/js/web_modules/videojs/video-js.min.css" rel="stylesheet" />
|
||||
<link href="/js/web_modules/@videojs/themes/fantasy/index.css" rel="stylesheet" />
|
||||
<link href="/js/web_modules/videojs/video-js.min.css" rel="stylesheet" />
|
||||
<link
|
||||
href="/js/web_modules/@videojs/themes/fantasy/index.css"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
|
||||
<link href="/styles/video.css" rel="stylesheet" />
|
||||
<link href="/styles/chat.css" rel="stylesheet" />
|
||||
<link href="/styles/user-content.css" rel="stylesheet" />
|
||||
<link href="/styles/app.css" rel="stylesheet" />
|
||||
<link href="/styles/video.css" rel="stylesheet" />
|
||||
<link href="/styles/chat.css" rel="stylesheet" />
|
||||
<link href="/styles/user-content.css" rel="stylesheet" />
|
||||
<link href="/styles/app.css" rel="stylesheet" />
|
||||
|
||||
<!-- The following script tags are not required for the app to run,
|
||||
<!-- The following script tags are not required for the app to run,
|
||||
however they will make it load a lot faster (fewer round trips) when HTTP/2 is used.
|
||||
|
||||
If you wish to re-generate this list, run the following shell command
|
||||
|
@ -48,105 +117,145 @@
|
|||
<script type="preload" src="/js/components/platform-logos-list.js"></script>
|
||||
<script type="preload" src="/js/components/chat/chat-input.js"></script>
|
||||
<script type="preload" src="/js/components/chat/message.js"></script>
|
||||
<script type="preload" src="/js/components/chat/content-editable.js"></script>
|
||||
<script
|
||||
type="preload"
|
||||
src="/js/components/chat/content-editable.js"
|
||||
></script>
|
||||
<script type="preload" src="/js/components/chat/chat.js"></script>
|
||||
<script type="preload" src="/js/components/chat/chat-message-view.js"></script>
|
||||
<script
|
||||
type="preload"
|
||||
src="/js/components/chat/chat-message-view.js"
|
||||
></script>
|
||||
<script type="preload" src="/js/components/chat/username.js"></script>
|
||||
<script type="preload" src="/js/components/external-action-modal.js"></script>
|
||||
<script
|
||||
type="preload"
|
||||
src="/js/components/external-action-modal.js"
|
||||
></script>
|
||||
<script type="preload" src="/js/components/player.js"></script>
|
||||
<script type="preload" src="/js/components/video-poster.js"></script>
|
||||
<script type="preload" src="/js/app.js"></script>
|
||||
<script type="preload" src="/js/web_modules/preact.js"></script>
|
||||
<script type="preload" src="/js/web_modules/micromodal/dist/micromodal.min.js"></script>
|
||||
<script type="preload" src="/js/web_modules/common/_commonjsHelpers-8c19dec8.js"></script>
|
||||
<script type="preload" src="/js/web_modules/markjs/dist/mark.es6.min.js"></script>
|
||||
<script type="preload" src="/js/web_modules/@joeattardi/emoji-button.js"></script>
|
||||
<script
|
||||
type="preload"
|
||||
src="/js/web_modules/micromodal/dist/micromodal.min.js"
|
||||
></script>
|
||||
<script
|
||||
type="preload"
|
||||
src="/js/web_modules/common/_commonjsHelpers-8c19dec8.js"
|
||||
></script>
|
||||
<script
|
||||
type="preload"
|
||||
src="/js/web_modules/markjs/dist/mark.es6.min.js"
|
||||
></script>
|
||||
<script
|
||||
type="preload"
|
||||
src="/js/web_modules/@joeattardi/emoji-button.js"
|
||||
></script>
|
||||
<script type="preload" src="/js/web_modules/htm.js"></script>
|
||||
<script type="preload" src="/js/web_modules/videojs/dist/video.min.js"></script>
|
||||
<script
|
||||
type="preload"
|
||||
src="/js/web_modules/videojs/dist/video.min.js"
|
||||
></script>
|
||||
<script type="preload" src="/js/chat/register.js"></script>
|
||||
<script type="preload" src="/js/utils/helpers.js"></script>
|
||||
<script type="preload" src="/js/utils/user-colors.js"></script>
|
||||
<script type="preload" src="/js/utils/constants.js"></script>
|
||||
<script type="preload" src="/js/utils/chat.js"></script>
|
||||
<script type="preload" src="/js/utils/websocket.js"></script>
|
||||
</head>
|
||||
|
||||
</head>
|
||||
|
||||
<body id="app-body" class="scrollbar-hidden bg-gray-300 text-gray-800">
|
||||
<div id="app">
|
||||
<div id="loading-logo-container">
|
||||
<img id="loading-logo" src="/logo">
|
||||
<body id="app-body" class="scrollbar-hidden bg-gray-300 text-gray-800">
|
||||
<div id="app">
|
||||
<div id="loading-logo-container">
|
||||
<img id="loading-logo" src="/logo" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
import { h, render } from '/js/web_modules/preact.js';
|
||||
import htm from '/js/web_modules/htm.js';
|
||||
const html = htm.bind(h);
|
||||
<script type="module">
|
||||
import { h, render } from '/js/web_modules/preact.js';
|
||||
import htm from '/js/web_modules/htm.js';
|
||||
const html = htm.bind(h);
|
||||
|
||||
import App from '/js/app.js';
|
||||
render(html`<${App} />`, document.getElementById("app"), document.getElementById("loading-logo-container"));
|
||||
</script>
|
||||
import App from '/js/app.js';
|
||||
render(
|
||||
html`<${App} />`,
|
||||
document.getElementById('app'),
|
||||
document.getElementById('loading-logo-container')
|
||||
);
|
||||
</script>
|
||||
|
||||
<noscript>
|
||||
<style>
|
||||
.noscript {
|
||||
text-align: center;
|
||||
padding: 30px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
font-size: large;
|
||||
}
|
||||
<noscript>
|
||||
<style>
|
||||
.noscript {
|
||||
text-align: center;
|
||||
padding: 30px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
font-size: large;
|
||||
}
|
||||
|
||||
.noscript a {
|
||||
display: inline;
|
||||
color: blue;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.noscript a {
|
||||
display: inline;
|
||||
color: blue;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
#app {
|
||||
display: none;
|
||||
}
|
||||
#app {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 200px;
|
||||
margin: 30px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-top: 25px;
|
||||
margin-bottom: 5px;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
<div class="noscript">
|
||||
<img class="logo" src="/logo" />
|
||||
<br />
|
||||
<p>
|
||||
This website is powered by <a href="https://owncast.online" rel="noopener noreferrer" target="_blank">Owncast</a>.
|
||||
</p>
|
||||
<p>
|
||||
Owncast uses JavaScript for playing the HTTP Live Streaming (HLS) video, and its chat client. But your web browser does not seem to support JavaScript, or you have it disabled.
|
||||
</p>
|
||||
<p>
|
||||
For the best experience, you should use a different browser with JavaScript support. If you have disabled JavaScript in your browser, you can re-enable it.
|
||||
</p>
|
||||
<h2>
|
||||
How can I watch this stream without JavaScript?
|
||||
</h2>
|
||||
<p>
|
||||
You can open the URL of this website in your media player (such as <a href="https://mpv.io" rel="noopener noreferrer" target="_blank">mpv</a> or <a href="https://www.videolan.org/vlc/" rel="noopener noreferrer" target="_blank">VLC</a>) to watch the stream.
|
||||
</p>
|
||||
<h2>
|
||||
How can I chat with the others without JavaScript?
|
||||
</h2>
|
||||
<p>
|
||||
Currently, there is no option to use the chat without JavaScript.
|
||||
</p>
|
||||
</div>
|
||||
</noscript>
|
||||
</body>
|
||||
.logo {
|
||||
height: 200px;
|
||||
margin: 30px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-top: 25px;
|
||||
margin-bottom: 5px;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
<div class="noscript">
|
||||
<img class="logo" src="/logo" />
|
||||
<br />
|
||||
<p>
|
||||
This website is powered by
|
||||
<a
|
||||
href="https://owncast.online"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>Owncast</a
|
||||
>.
|
||||
</p>
|
||||
<p>
|
||||
Owncast uses JavaScript for playing the HTTP Live Streaming (HLS)
|
||||
video, and its chat client. But your web browser does not seem to
|
||||
support JavaScript, or you have it disabled.
|
||||
</p>
|
||||
<p>
|
||||
For the best experience, you should use a different browser with
|
||||
JavaScript support. If you have disabled JavaScript in your browser,
|
||||
you can re-enable it.
|
||||
</p>
|
||||
<h2>How can I watch this stream without JavaScript?</h2>
|
||||
<p>
|
||||
You can open the URL of this website in your media player (such as
|
||||
<a href="https://mpv.io" rel="noopener noreferrer" target="_blank"
|
||||
>mpv</a
|
||||
>
|
||||
or
|
||||
<a
|
||||
href="https://www.videolan.org/vlc/"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>VLC</a
|
||||
>) to watch the stream.
|
||||
</p>
|
||||
<h2>How can I chat with the others without JavaScript?</h2>
|
||||
<p>Currently, there is no option to use the chat without JavaScript.</p>
|
||||
</div>
|
||||
</noscript>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -27,6 +27,8 @@ import FediverseFollowModal, {
|
|||
|
||||
import { NotifyButton, NotifyModal } from './components/notification.js';
|
||||
import { isPushNotificationSupported } from './notification/registerWeb.js';
|
||||
import ChatSettingsModal from './components/chat-settings-modal.js';
|
||||
|
||||
import {
|
||||
addNewlines,
|
||||
checkUrlPathForDisplay,
|
||||
|
@ -110,6 +112,9 @@ export default class App extends Component {
|
|||
externalActionModalData: null,
|
||||
fediverseModalData: null,
|
||||
|
||||
// authentication options
|
||||
indieAuthEnabled: false,
|
||||
|
||||
// routing & tabbing
|
||||
section: '',
|
||||
sectionId: '',
|
||||
|
@ -144,6 +149,8 @@ export default class App extends Component {
|
|||
this.closeFediverseFollowModal = this.closeFediverseFollowModal.bind(this);
|
||||
this.displayNotificationModal = this.displayNotificationModal.bind(this);
|
||||
this.closeNotificationModal = this.closeNotificationModal.bind(this);
|
||||
this.showAuthModal = this.showAuthModal.bind(this);
|
||||
this.closeAuthModal = this.closeAuthModal.bind(this);
|
||||
|
||||
// player events
|
||||
this.handlePlayerReady = this.handlePlayerReady.bind(this);
|
||||
|
@ -268,8 +275,14 @@ export default class App extends Component {
|
|||
}
|
||||
|
||||
setConfigData(data = {}) {
|
||||
const { name, summary, chatDisabled, socketHostOverride, notifications } =
|
||||
data;
|
||||
const {
|
||||
name,
|
||||
summary,
|
||||
chatDisabled,
|
||||
socketHostOverride,
|
||||
notifications,
|
||||
authentication,
|
||||
} = data;
|
||||
window.document.title = name;
|
||||
|
||||
this.socketHostOverride = socketHostOverride;
|
||||
|
@ -281,10 +294,12 @@ export default class App extends Component {
|
|||
}
|
||||
|
||||
this.hasConfiguredChat = true;
|
||||
const { indieAuthEnabled } = authentication;
|
||||
|
||||
this.setState({
|
||||
canChat: !chatDisabled,
|
||||
notifications,
|
||||
indieAuthEnabled,
|
||||
configData: {
|
||||
...data,
|
||||
summary: summary && addNewlines(summary),
|
||||
|
@ -618,6 +633,17 @@ export default class App extends Component {
|
|||
}
|
||||
}
|
||||
|
||||
showAuthModal() {
|
||||
const data = {
|
||||
title: 'Chat',
|
||||
};
|
||||
this.setState({ authModalData: data });
|
||||
}
|
||||
|
||||
closeAuthModal() {
|
||||
this.setState({ authModalData: null });
|
||||
}
|
||||
|
||||
handleWebsocketMessage(e) {
|
||||
if (e.type === SOCKET_MESSAGE_TYPES.ERROR_USER_DISABLED) {
|
||||
// User has been actively disabled on the backend. Turn off chat for them.
|
||||
|
@ -637,10 +663,10 @@ export default class App extends Component {
|
|||
// When connected the user will return an event letting us know what our
|
||||
// user details are so we can display them properly.
|
||||
const { user } = e;
|
||||
const { displayName } = user;
|
||||
|
||||
const { displayName, authenticated } = user;
|
||||
this.setState({
|
||||
username: displayName,
|
||||
authenticated,
|
||||
isModerator: checkIsModerator(e),
|
||||
});
|
||||
}
|
||||
|
@ -724,17 +750,20 @@ export default class App extends Component {
|
|||
streamTitle,
|
||||
touchKeyboardActive,
|
||||
username,
|
||||
authenticated,
|
||||
viewerCount,
|
||||
websocket,
|
||||
windowHeight,
|
||||
windowWidth,
|
||||
fediverseModalData,
|
||||
authModalData,
|
||||
externalActionModalData,
|
||||
notificationModalData,
|
||||
notifications,
|
||||
lastDisconnectTime,
|
||||
section,
|
||||
sectionId,
|
||||
indieAuthEnabled,
|
||||
} = state;
|
||||
|
||||
const {
|
||||
|
@ -864,11 +893,32 @@ export default class App extends Component {
|
|||
/>`}
|
||||
/>`;
|
||||
|
||||
const authModal =
|
||||
authModalData &&
|
||||
html`
|
||||
<${ExternalActionModal}
|
||||
onClose=${this.closeAuthModal}
|
||||
action=${authModalData}
|
||||
useIframe=${false}
|
||||
customContent=${html`<${ChatSettingsModal}
|
||||
name=${name}
|
||||
logo=${logo}
|
||||
onUsernameChange=${this.handleUsernameChange}
|
||||
username=${username}
|
||||
accessToken=${this.state.accessToken}
|
||||
authenticated=${authenticated}
|
||||
onClose=${this.closeAuthModal}
|
||||
indieAuthEnabled=${indieAuthEnabled}
|
||||
/>`}
|
||||
/>
|
||||
`;
|
||||
|
||||
const chat = this.state.websocket
|
||||
? html`
|
||||
<${Chat}
|
||||
websocket=${websocket}
|
||||
username=${username}
|
||||
authenticated=${authenticated}
|
||||
chatInputEnabled=${chatInputEnabled && !chatDisabled}
|
||||
instanceTitle=${name}
|
||||
accessToken=${accessToken}
|
||||
|
@ -911,6 +961,8 @@ export default class App extends Component {
|
|||
});
|
||||
}
|
||||
|
||||
const authIcon = '/img/user-settings.svg';
|
||||
|
||||
return html`
|
||||
<div
|
||||
id="app-container"
|
||||
|
@ -942,9 +994,11 @@ export default class App extends Component {
|
|||
>
|
||||
</h1>
|
||||
|
||||
<${ChatMenu} username=${username} isModerator=${isModerator} onUsernameChange=${
|
||||
this.handleUsernameChange
|
||||
} onFocus=${this.handleFormFocus} onBlur=${
|
||||
<${ChatMenu} username=${username} isModerator=${isModerator} showAuthModal=${
|
||||
indieAuthEnabled && this.showAuthModal
|
||||
} onUsernameChange=${this.handleUsernameChange} onFocus=${
|
||||
this.handleFormFocus
|
||||
} onBlur=${
|
||||
this.handleFormBlur
|
||||
} chatDisabled=${chatDisabled} noVideoContent=${noVideoContent} handleChatPanelToggle=${
|
||||
this.handleChatPanelToggle
|
||||
|
@ -1027,7 +1081,7 @@ export default class App extends Component {
|
|||
</footer>
|
||||
|
||||
${chat} ${externalActionModal} ${fediverseFollowModal}
|
||||
${notificationModal}
|
||||
${notificationModal} ${authModal}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
|
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,
|
||||
handleChatPanelToggle,
|
||||
onUsernameChange,
|
||||
showAuthModal,
|
||||
onFocus,
|
||||
onBlur,
|
||||
} = props;
|
||||
|
@ -34,6 +35,15 @@ export const ChatMenu = (props) => {
|
|||
if (chatMenuOpen) setView('main');
|
||||
}, [chatMenuOpen]);
|
||||
|
||||
const authMenuItem =
|
||||
showAuthModal &&
|
||||
html`<li>
|
||||
<button type="button" id="chat-auth" onClick=${showAuthModal}>
|
||||
Authenticate
|
||||
<span><${ChatIcon} /></span>
|
||||
</button>
|
||||
</li>`;
|
||||
|
||||
return html`
|
||||
<${Context.Provider} value=${props}>
|
||||
<div class="chat-menu p-2 relative shadow-lg" ref=${chatMenuRef}>
|
||||
|
@ -74,6 +84,7 @@ export const ChatMenu = (props) => {
|
|||
onBlur=${onBlur}
|
||||
/>
|
||||
</li>
|
||||
${authMenuItem}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
@ -49,7 +49,8 @@ export default class ChatMessageView extends Component {
|
|||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
const { displayName, displayColor, createdAt, isBot } = user;
|
||||
|
||||
const { displayName, displayColor, createdAt, isBot, authenticated } = user;
|
||||
const isAuthorModerator = checkIsModerator(message);
|
||||
|
||||
const isMessageModeratable =
|
||||
|
@ -78,7 +79,7 @@ export default class ChatMessageView extends Component {
|
|||
isMessageModeratable ? 'moderatable' : ''
|
||||
}`;
|
||||
|
||||
const messageAuthorFlair = isAuthorModerator
|
||||
const isModeratorFlair = isAuthorModerator
|
||||
? html`<img
|
||||
class="flair"
|
||||
title="Moderator"
|
||||
|
@ -95,6 +96,14 @@ export default class ChatMessageView extends Component {
|
|||
/>`
|
||||
: null;
|
||||
|
||||
const authorAuthenticatedFlair = authenticated
|
||||
? html`<img
|
||||
class="flair"
|
||||
title="Authenticated"
|
||||
src="/img/authenticated.svg"
|
||||
/>`
|
||||
: null;
|
||||
|
||||
return html`
|
||||
<div
|
||||
style=${backgroundStyle}
|
||||
|
@ -107,7 +116,8 @@ export default class ChatMessageView extends Component {
|
|||
class="message-author font-bold"
|
||||
title=${userMetadata}
|
||||
>
|
||||
${isBotFlair} ${messageAuthorFlair} ${displayName}
|
||||
${isBotFlair} ${authorAuthenticatedFlair} ${isModeratorFlair}
|
||||
${displayName}
|
||||
</div>
|
||||
${isMessageModeratable &&
|
||||
html`<${ModeratorActions}
|
||||
|
|
|
@ -102,6 +102,10 @@ export default class UsernameForm extends Component {
|
|||
},
|
||||
};
|
||||
|
||||
const moderatorFlag = html`
|
||||
<img src="/img/moderator-nobackground.svg" class="moderator-flag" />
|
||||
`;
|
||||
|
||||
return html`
|
||||
<div id="user-info">
|
||||
<button
|
||||
|
|
|
@ -21,6 +21,7 @@ export const URL_PLAYBACK_METRICS = `/api/metrics/playback`;
|
|||
|
||||
export const URL_REGISTER_NOTIFICATION = `/api/notifications/register`;
|
||||
export const URL_REGISTER_EMAIL_NOTIFICATION = `/api/notifications/register/email`;
|
||||
export const URL_CHAT_INDIEAUTH_BEGIN = `/api/auth/indieauth`;
|
||||
|
||||
export const TIMER_STATUS_UPDATE = 5000; // ms
|
||||
export const TIMER_DISABLE_CHAT_AFTER_OFFLINE = 5 * 60 * 1000; // 5 mins
|
||||
|
|
Loading…
Reference in a new issue