mirror of
https://github.com/henrygd/beszel.git
synced 2026-04-22 04:31:50 +02:00
feat(hub): add OAUTH_DISABLE_POPUP env var (#1900)
Co-authored-by: henrygd <hank@henrygd.me>
This commit is contained in:
42
internal/hub/server.go
Normal file
42
internal/hub/server.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package hub
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel"
|
||||||
|
"github.com/henrygd/beszel/internal/hub/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PublicAppInfo defines the structure of the public app information that will be injected into the HTML
|
||||||
|
type PublicAppInfo struct {
|
||||||
|
BASE_PATH string
|
||||||
|
HUB_VERSION string
|
||||||
|
HUB_URL string
|
||||||
|
OAUTH_DISABLE_POPUP bool `json:"OAUTH_DISABLE_POPUP,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// modifyIndexHTML injects the public app information into the index.html content
|
||||||
|
func modifyIndexHTML(hub *Hub, html []byte) string {
|
||||||
|
info := getPublicAppInfo(hub)
|
||||||
|
content, err := json.Marshal(info)
|
||||||
|
if err != nil {
|
||||||
|
return string(html)
|
||||||
|
}
|
||||||
|
htmlContent := strings.ReplaceAll(string(html), "./", info.BASE_PATH)
|
||||||
|
return strings.Replace(htmlContent, "\"{info}\"", string(content), 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getPublicAppInfo(hub *Hub) PublicAppInfo {
|
||||||
|
parsedURL, _ := url.Parse(hub.appURL)
|
||||||
|
info := PublicAppInfo{
|
||||||
|
BASE_PATH: strings.TrimSuffix(parsedURL.Path, "/") + "/",
|
||||||
|
HUB_VERSION: beszel.Version,
|
||||||
|
HUB_URL: hub.appURL,
|
||||||
|
}
|
||||||
|
if val, _ := utils.GetEnv("OAUTH_DISABLE_POPUP"); val == "true" {
|
||||||
|
info.OAUTH_DISABLE_POPUP = true
|
||||||
|
}
|
||||||
|
return info
|
||||||
|
}
|
||||||
@@ -10,8 +10,6 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/henrygd/beszel"
|
|
||||||
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"github.com/pocketbase/pocketbase/tools/osutils"
|
"github.com/pocketbase/pocketbase/tools/osutils"
|
||||||
)
|
)
|
||||||
@@ -38,7 +36,7 @@ func (rm *responseModifier) RoundTrip(req *http.Request) (*http.Response, error)
|
|||||||
}
|
}
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
// Create a new response with the modified body
|
// Create a new response with the modified body
|
||||||
modifiedBody := rm.modifyHTML(string(body))
|
modifiedBody := modifyIndexHTML(rm.hub, body)
|
||||||
resp.Body = io.NopCloser(strings.NewReader(modifiedBody))
|
resp.Body = io.NopCloser(strings.NewReader(modifiedBody))
|
||||||
resp.ContentLength = int64(len(modifiedBody))
|
resp.ContentLength = int64(len(modifiedBody))
|
||||||
resp.Header.Set("Content-Length", fmt.Sprintf("%d", len(modifiedBody)))
|
resp.Header.Set("Content-Length", fmt.Sprintf("%d", len(modifiedBody)))
|
||||||
@@ -46,19 +44,6 @@ func (rm *responseModifier) RoundTrip(req *http.Request) (*http.Response, error)
|
|||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rm *responseModifier) modifyHTML(html string) string {
|
|
||||||
parsedURL, err := url.Parse(rm.hub.appURL)
|
|
||||||
if err != nil {
|
|
||||||
return html
|
|
||||||
}
|
|
||||||
// fix base paths in html if using subpath
|
|
||||||
basePath := strings.TrimSuffix(parsedURL.Path, "/") + "/"
|
|
||||||
html = strings.ReplaceAll(html, "./", basePath)
|
|
||||||
html = strings.Replace(html, "{{V}}", beszel.Version, 1)
|
|
||||||
html = strings.Replace(html, "{{HUB_URL}}", rm.hub.appURL, 1)
|
|
||||||
return html
|
|
||||||
}
|
|
||||||
|
|
||||||
// startServer sets up the development server for Beszel
|
// startServer sets up the development server for Beszel
|
||||||
func (h *Hub) startServer(se *core.ServeEvent) error {
|
func (h *Hub) startServer(se *core.ServeEvent) error {
|
||||||
proxy := httputil.NewSingleHostReverseProxy(&url.URL{
|
proxy := httputil.NewSingleHostReverseProxy(&url.URL{
|
||||||
|
|||||||
@@ -5,10 +5,8 @@ package hub
|
|||||||
import (
|
import (
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/henrygd/beszel"
|
|
||||||
"github.com/henrygd/beszel/internal/hub/utils"
|
"github.com/henrygd/beszel/internal/hub/utils"
|
||||||
"github.com/henrygd/beszel/internal/site"
|
"github.com/henrygd/beszel/internal/site"
|
||||||
|
|
||||||
@@ -18,17 +16,8 @@ import (
|
|||||||
|
|
||||||
// startServer sets up the production server for Beszel
|
// startServer sets up the production server for Beszel
|
||||||
func (h *Hub) startServer(se *core.ServeEvent) error {
|
func (h *Hub) startServer(se *core.ServeEvent) error {
|
||||||
// parse app url
|
|
||||||
parsedURL, err := url.Parse(h.appURL)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
// fix base paths in html if using subpath
|
|
||||||
basePath := strings.TrimSuffix(parsedURL.Path, "/") + "/"
|
|
||||||
indexFile, _ := fs.ReadFile(site.DistDirFS, "index.html")
|
indexFile, _ := fs.ReadFile(site.DistDirFS, "index.html")
|
||||||
html := strings.ReplaceAll(string(indexFile), "./", basePath)
|
html := modifyIndexHTML(h, indexFile)
|
||||||
html = strings.Replace(html, "{{V}}", beszel.Version, 1)
|
|
||||||
html = strings.Replace(html, "{{HUB_URL}}", h.appURL, 1)
|
|
||||||
// set up static asset serving
|
// set up static asset serving
|
||||||
staticPaths := [2]string{"/static/", "/assets/"}
|
staticPaths := [2]string{"/static/", "/assets/"}
|
||||||
serveStatic := apis.Static(site.DistDirFS, false)
|
serveStatic := apis.Static(site.DistDirFS, false)
|
||||||
|
|||||||
@@ -22,11 +22,7 @@
|
|||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<script>
|
<script>
|
||||||
globalThis.BESZEL = {
|
globalThis.BESZEL = "{info}"
|
||||||
BASE_PATH: "%BASE_URL%",
|
|
||||||
HUB_VERSION: "{{V}}",
|
|
||||||
HUB_URL: "{{HUB_URL}}"
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { Label } from "@/components/ui/label"
|
|||||||
import { pb } from "@/lib/api"
|
import { pb } from "@/lib/api"
|
||||||
import { $authenticated } from "@/lib/stores"
|
import { $authenticated } from "@/lib/stores"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { $router, Link, prependBasePath } from "../router"
|
import { $router, Link, basePath, prependBasePath } from "../router"
|
||||||
import { toast } from "../ui/use-toast"
|
import { toast } from "../ui/use-toast"
|
||||||
import { OtpInputForm } from "./otp-forms"
|
import { OtpInputForm } from "./otp-forms"
|
||||||
|
|
||||||
@@ -37,8 +37,7 @@ const RegisterSchema = v.looseObject({
|
|||||||
passwordConfirm: passwordSchema,
|
passwordConfirm: passwordSchema,
|
||||||
})
|
})
|
||||||
|
|
||||||
export const showLoginFaliedToast = (description?: string) => {
|
export const showLoginFaliedToast = (description = t`Please check your credentials and try again`) => {
|
||||||
description ||= t`Please check your credentials and try again`
|
|
||||||
toast({
|
toast({
|
||||||
title: t`Login attempt failed`,
|
title: t`Login attempt failed`,
|
||||||
description,
|
description,
|
||||||
@@ -130,10 +129,6 @@ export function UserAuthForm({
|
|||||||
[isFirstRun]
|
[isFirstRun]
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!authMethods) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const authProviders = authMethods.oauth2.providers ?? []
|
const authProviders = authMethods.oauth2.providers ?? []
|
||||||
const oauthEnabled = authMethods.oauth2.enabled && authProviders.length > 0
|
const oauthEnabled = authMethods.oauth2.enabled && authProviders.length > 0
|
||||||
const passwordEnabled = authMethods.password.enabled
|
const passwordEnabled = authMethods.password.enabled
|
||||||
@@ -142,6 +137,12 @@ export function UserAuthForm({
|
|||||||
|
|
||||||
function loginWithOauth(provider: AuthProviderInfo, forcePopup = false) {
|
function loginWithOauth(provider: AuthProviderInfo, forcePopup = false) {
|
||||||
setIsOauthLoading(true)
|
setIsOauthLoading(true)
|
||||||
|
|
||||||
|
if (globalThis.BESZEL.OAUTH_DISABLE_POPUP) {
|
||||||
|
redirectToOauthProvider(provider)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const oAuthOpts: OAuth2AuthConfig = {
|
const oAuthOpts: OAuth2AuthConfig = {
|
||||||
provider: provider.name,
|
provider: provider.name,
|
||||||
}
|
}
|
||||||
@@ -150,10 +151,7 @@ export function UserAuthForm({
|
|||||||
const authWindow = window.open()
|
const authWindow = window.open()
|
||||||
if (!authWindow) {
|
if (!authWindow) {
|
||||||
setIsOauthLoading(false)
|
setIsOauthLoading(false)
|
||||||
toast({
|
showLoginFaliedToast(t`Please enable pop-ups for this site`)
|
||||||
title: t`Error`,
|
|
||||||
description: t`Please enable pop-ups for this site`,
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
oAuthOpts.urlCallback = (url) => {
|
oAuthOpts.urlCallback = (url) => {
|
||||||
@@ -171,16 +169,57 @@ export function UserAuthForm({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redirects the user to the OAuth provider's authentication page in the same window.
|
||||||
|
* Requires the app's base URL to be registered as a redirect URI with the OAuth provider.
|
||||||
|
*/
|
||||||
|
function redirectToOauthProvider(provider: AuthProviderInfo) {
|
||||||
|
const url = new URL(provider.authURL)
|
||||||
|
// url.searchParams.set("redirect_uri", `${window.location.origin}${basePath}`)
|
||||||
|
sessionStorage.setItem("provider", JSON.stringify(provider))
|
||||||
|
window.location.href = url.toString()
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// auto login if password disabled and only one auth provider
|
// handle redirect-based OAuth callback if we have a code
|
||||||
if (!passwordEnabled && authProviders.length === 1 && !sessionStorage.getItem("lo")) {
|
const params = new URLSearchParams(window.location.search)
|
||||||
// Add a small timeout to ensure browser is ready to handle popups
|
const code = params.get("code")
|
||||||
setTimeout(() => {
|
if (code) {
|
||||||
loginWithOauth(authProviders[0], true)
|
const state = params.get("state")
|
||||||
}, 300)
|
const provider: AuthProviderInfo = JSON.parse(sessionStorage.getItem("provider") ?? "{}")
|
||||||
|
if (!state || provider.state !== state) {
|
||||||
|
showLoginFaliedToast()
|
||||||
|
} else {
|
||||||
|
setIsOauthLoading(true)
|
||||||
|
window.history.replaceState({}, "", window.location.pathname)
|
||||||
|
pb.collection("users")
|
||||||
|
.authWithOAuth2Code(provider.name, code, provider.codeVerifier, `${window.location.origin}${basePath}`)
|
||||||
|
.then(() => $authenticated.set(pb.authStore.isValid))
|
||||||
|
.catch((e: unknown) => showLoginFaliedToast((e as Error).message))
|
||||||
|
.finally(() => setIsOauthLoading(false))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// auto login if password disabled and only one auth provider
|
||||||
|
if (!code && !passwordEnabled && authProviders.length === 1 && !sessionStorage.getItem("lo")) {
|
||||||
|
// Add a small timeout to ensure browser is ready to handle popups
|
||||||
|
setTimeout(() => loginWithOauth(authProviders[0], false), 300)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// refresh auth if not in above states (required for trusted auth header)
|
||||||
|
pb.collection("users")
|
||||||
|
.authRefresh()
|
||||||
|
.then((res) => {
|
||||||
|
pb.authStore.save(res.token, res.record)
|
||||||
|
$authenticated.set(!!pb.authStore.isValid)
|
||||||
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
if (!authMethods) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
if (otpId && mfaId) {
|
if (otpId && mfaId) {
|
||||||
return <OtpInputForm otpId={otpId} mfaId={mfaId} />
|
return <OtpInputForm otpId={otpId} mfaId={mfaId} />
|
||||||
}
|
}
|
||||||
@@ -248,7 +287,7 @@ export function UserAuthForm({
|
|||||||
)}
|
)}
|
||||||
<div className="sr-only">
|
<div className="sr-only">
|
||||||
{/* honeypot */}
|
{/* honeypot */}
|
||||||
<label htmlFor="website"></label>
|
<label htmlFor="website">Website</label>
|
||||||
<input
|
<input
|
||||||
id="website"
|
id="website"
|
||||||
type="text"
|
type="text"
|
||||||
|
|||||||
@@ -94,18 +94,6 @@ const Layout = () => {
|
|||||||
document.documentElement.dir = direction
|
document.documentElement.dir = direction
|
||||||
}, [direction])
|
}, [direction])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// refresh auth if not authenticated (required for trusted auth header)
|
|
||||||
if (!authenticated) {
|
|
||||||
pb.collection("users")
|
|
||||||
.authRefresh()
|
|
||||||
.then((res) => {
|
|
||||||
pb.authStore.save(res.token, res.record)
|
|
||||||
$authenticated.set(!!pb.authStore.isValid)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DirectionProvider dir={direction}>
|
<DirectionProvider dir={direction}>
|
||||||
{!authenticated ? (
|
{!authenticated ? (
|
||||||
|
|||||||
1
internal/site/src/types.d.ts
vendored
1
internal/site/src/types.d.ts
vendored
@@ -7,6 +7,7 @@ declare global {
|
|||||||
BASE_PATH: string
|
BASE_PATH: string
|
||||||
HUB_VERSION: string
|
HUB_VERSION: string
|
||||||
HUB_URL: string
|
HUB_URL: string
|
||||||
|
OAUTH_DISABLE_POPUP: boolean
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user