web: Integrate BasePath support into frontend and fix web route serving

This commit is contained in:
nemunaire 2026-02-19 18:33:36 +07:00
commit 945b916d55
35 changed files with 341 additions and 255 deletions

View file

@ -58,7 +58,7 @@ func NewLoginController(authService happydns.AuthenticationUsecase, captchaVerif
// @Security securitydefinitions.basic
// @Success 200 {object} happydns.User
// @Failure 401 {object} happydns.ErrorResponse "Authentication failure"
// @Router /auth/user [get]
// @Router /auth [get]
func (lc *LoginController) GetLoggedUser(c *gin.Context) {
c.JSON(http.StatusOK, c.MustGet("LoggedUser"))
}

View file

@ -98,6 +98,10 @@ func ConsolidateConfig() (opts *happydns.Options, err error) {
} else {
opts.BasePath = ""
}
if opts.DevProxy != "" && opts.BasePath != "" {
err = fmt.Errorf("-base-path is not supported in -dev mode")
return
}
if opts.NoMail && opts.MailSMTPHost != "" {
err = fmt.Errorf("-no-mail and -mail-smtp-* cannot be defined at the same time")

View file

@ -22,6 +22,7 @@
package web
import (
"bytes"
"encoding/json"
"flag"
"io"
@ -38,7 +39,6 @@ import (
)
var (
indexTpl *template.Template
CustomHeadHTML = ""
CustomBodyHTML = ""
HideVoxPeople = false
@ -99,107 +99,136 @@ func DeclareRoutes(cfg *happydns.Options, router *gin.RouterGroup, captchaVerifi
CustomHeadHTML += `<script id="app-config" type="application/json">` + string(appcfg) + `</script>`
}
if cfg.DevProxy != "" {
router.GET("/.svelte-kit/*_", serveOrReverse("", cfg))
router.GET("/node_modules/*_", serveOrReverse("", cfg))
router.GET("/@vite/*_", serveOrReverse("", cfg))
router.GET("/@id/*_", serveOrReverse("", cfg))
router.GET("/@fs/*_", serveOrReverse("", cfg))
router.GET("/src/*_", serveOrReverse("", cfg))
router.GET("/home/*_", serveOrReverse("", cfg))
}
router.GET("/_app/*_", func(c *gin.Context) { c.Writer.Header().Set("Cache-Control", "public, max-age=604800, immutable") }, serveOrReverse("", cfg))
serveFile := serveOrReverse("", cfg)
serveIndex := serveOrReverse("/", cfg)
serveManifest := serveOrReverse("/manifest.json", cfg)
immutable := func(c *gin.Context) { c.Writer.Header().Set("Cache-Control", "public, max-age=604800, immutable") }
router.GET("/", serveOrReverse("/", cfg))
router.GET("/index.html", serveOrReverse("/", cfg))
if cfg.DevProxy != "" {
router.GET("/.svelte-kit/*_", serveFile)
router.GET("/node_modules/*_", serveFile)
router.GET("/@vite/*_", serveFile)
router.GET("/@id/*_", serveFile)
router.GET("/@fs/*_", serveFile)
router.GET("/src/*_", serveFile)
router.GET("/home/*_", serveFile)
}
router.GET("/_app/*_", immutable, serveFile)
router.GET("/", serveIndex)
router.GET("/index.html", serveIndex)
// Routes handled by the showcase
router.GET("/en/*_", serveOrReverse("/", cfg))
router.GET("/fr/*_", serveOrReverse("/", cfg))
router.GET("/en/*_", serveIndex)
router.GET("/fr/*_", serveIndex)
// Routes for real existings files
router.GET("/fonts/*path", func(c *gin.Context) { c.Writer.Header().Set("Cache-Control", "public, max-age=604800, immutable") }, serveOrReverse("", cfg))
router.GET("/img/*path", func(c *gin.Context) { c.Writer.Header().Set("Cache-Control", "public, max-age=604800, immutable") }, serveOrReverse("", cfg))
router.GET("/favicon.ico", func(c *gin.Context) { c.Writer.Header().Set("Cache-Control", "public, max-age=604800, immutable") }, serveOrReverse("", cfg))
router.GET("/manifest.json", serveOrReverse("/manifest.json", cfg))
router.GET("/robots.txt", serveOrReverse("", cfg))
router.GET("/service-worker.js", serveOrReverse("", cfg))
router.GET("/fonts/*path", immutable, serveFile)
router.GET("/img/*path", immutable, serveFile)
router.GET("/favicon.ico", immutable, serveFile)
router.GET("/manifest.json", serveManifest)
router.GET("/robots.txt", serveFile)
router.GET("/service-worker.js", serveFile)
// Routes to virtual content
router.GET("/domains/*_", serveOrReverse("/", cfg))
router.GET("/email-validation", serveOrReverse("/", cfg))
router.GET("/forgotten-password", serveOrReverse("/", cfg))
router.GET("/join", serveOrReverse("/", cfg))
router.GET("/login", serveOrReverse("/", cfg))
router.GET("/me", serveOrReverse("/", cfg))
router.GET("/onboarding/*_", serveOrReverse("/", cfg))
router.GET("/providers/*_", serveOrReverse("/", cfg))
router.GET("/services/*_", serveOrReverse("/", cfg))
router.GET("/tools/*_", serveOrReverse("/", cfg))
router.GET("/resolver/*_", serveOrReverse("/", cfg))
router.GET("/zones/*_", serveOrReverse("/", cfg))
router.GET("/domains/*_", serveIndex)
router.GET("/email-validation", serveIndex)
router.GET("/forgotten-password", serveIndex)
router.GET("/join", serveIndex)
router.GET("/login", serveIndex)
router.GET("/me", serveIndex)
router.GET("/onboarding/*_", serveIndex)
router.GET("/providers/*_", serveIndex)
router.GET("/services/*_", serveIndex)
router.GET("/tools/*_", serveIndex)
router.GET("/resolver/*_", serveIndex)
router.GET("/zones/*_", serveIndex)
}
func NoRoute(cfg *happydns.Options, router *gin.Engine) {
serveIndex := serveOrReverse("/", cfg)
router.NoRoute(func(c *gin.Context) {
if strings.HasPrefix(c.Request.URL.Path, cfg.BasePath+"/api") || strings.Contains(c.Request.Header.Get("Accept"), "application/json") {
c.JSON(404, gin.H{"code": "PAGE_NOT_FOUND", "errmsg": "Page not found"})
} else if cfg.BasePath != "" && !strings.HasPrefix(c.Request.URL.Path, cfg.BasePath) {
c.Redirect(http.StatusFound, cfg.BasePath+c.Request.URL.Path)
} else {
serveOrReverse("/", cfg)(c)
serveIndex(c)
}
})
}
func serveOrReverse(forced_url string, cfg *happydns.Options) gin.HandlerFunc {
if cfg.DevProxy != "" {
// Parse once at creation time, not per request
devURL, err := url.Parse(cfg.DevProxy)
if err != nil {
return func(c *gin.Context) {
http.Error(c.Writer, "invalid dev proxy URL: "+err.Error(), http.StatusInternalServerError)
}
}
// Forward to the Vue dev proxy
return func(c *gin.Context) {
if u, err := url.Parse(cfg.DevProxy); err != nil {
http.Error(c.Writer, err.Error(), http.StatusInternalServerError)
u := *devURL // copy to avoid mutating shared state across requests
if forced_url != "" {
u.Path = path.Join(u.Path, forced_url)
} else {
if forced_url != "" {
u.Path = path.Join(u.Path, forced_url)
} else {
u.Path = path.Join(u.Path, c.Request.URL.Path)
u.Path = path.Join(u.Path, c.Request.URL.Path)
}
u.RawQuery = c.Request.URL.RawQuery
r, err := http.NewRequest(c.Request.Method, u.String(), c.Request.Body)
if err != nil {
http.Error(c.Writer, err.Error(), http.StatusInternalServerError)
return
}
resp, err := http.DefaultClient.Do(r)
if err != nil {
http.Error(c.Writer, err.Error(), http.StatusBadGateway)
return
}
defer resp.Body.Close()
if u.Path != "/" || resp.StatusCode != 200 {
for key, vals := range resp.Header {
for _, v := range vals {
c.Writer.Header().Add(key, v)
}
}
u.RawQuery = c.Request.URL.RawQuery
if r, err := http.NewRequest(c.Request.Method, u.String(), c.Request.Body); err != nil {
http.Error(c.Writer, err.Error(), http.StatusInternalServerError)
} else if resp, err := http.DefaultClient.Do(r); err != nil {
http.Error(c.Writer, err.Error(), http.StatusBadGateway)
} else {
defer resp.Body.Close()
if u.Path != "/" || resp.StatusCode != 200 {
for key := range resp.Header {
c.Writer.Header().Add(key, resp.Header.Get(key))
}
c.Writer.WriteHeader(resp.StatusCode)
io.Copy(c.Writer, resp.Body)
} else {
for key := range resp.Header {
if strings.ToLower(key) != "content-length" {
c.Writer.Header().Add(key, resp.Header.Get(key))
}
}
v, _ := io.ReadAll(resp.Body)
v2 := strings.Replace(strings.Replace(string(v), "</head>", "{{ .Head }}</head>", 1), "</body>", "{{ .Body }}</body>", 1)
indexTpl = template.Must(template.New("index.html").Parse(v2))
if err := indexTpl.ExecuteTemplate(c.Writer, "index.html", map[string]string{
"Body": CustomBodyHTML,
"Head": CustomHeadHTML,
}); err != nil {
log.Println("Unable to return index.html:", err.Error())
c.Writer.WriteHeader(resp.StatusCode)
io.Copy(c.Writer, resp.Body)
} else {
for key, vals := range resp.Header {
if !strings.EqualFold(key, "content-length") {
for _, v := range vals {
c.Writer.Header().Add(key, v)
}
}
}
v, err := io.ReadAll(resp.Body)
if err != nil {
http.Error(c.Writer, err.Error(), http.StatusBadGateway)
return
}
// Local template per request — no race condition on a package-level var
tpl, err := template.New("index.html").Parse(
strings.Replace(strings.Replace(string(v),
"</head>", "{{ .Head }}</head>", 1),
"</body>", "{{ .Body }}</body>", 1),
)
if err != nil {
http.Error(c.Writer, err.Error(), http.StatusInternalServerError)
return
}
if err := tpl.Execute(c.Writer, map[string]string{
"Body": CustomBodyHTML,
"Head": CustomHeadHTML,
}); err != nil {
log.Println("Unable to return index.html:", err.Error())
}
}
}
} else if Assets == nil {
@ -207,31 +236,60 @@ func serveOrReverse(forced_url string, cfg *happydns.Options) gin.HandlerFunc {
c.String(http.StatusNotFound, "404 Page not found - interface not embedded in binary, please compile with -tags web")
}
} else if forced_url == "/" {
// Serve altered index.html
// Pre-render index.html once at handler creation time
f, err := Assets.Open("index.html")
if err != nil {
log.Println("Unable to open embedded index.html:", err)
return func(c *gin.Context) {
c.String(http.StatusInternalServerError, "index.html not found in embedded assets")
}
}
v, err := io.ReadAll(f)
if err != nil {
log.Println("Unable to read embedded index.html:", err)
return func(c *gin.Context) {
c.String(http.StatusInternalServerError, "failed to read embedded index.html")
}
}
rendered := []byte(strings.Replace(strings.Replace(string(v), "</head>", CustomHeadHTML+"</head>", 1), "</body>", CustomBodyHTML+"</body>", 1))
if cfg.BasePath != "" {
rendered = bytes.ReplaceAll(
bytes.ReplaceAll(
bytes.ReplaceAll(
bytes.ReplaceAll(
rendered,
[]byte(`href="/`),
append([]byte(`href="`), append([]byte(cfg.BasePath), '/')...),
),
[]byte(`import("/`),
append([]byte(`import("`), append([]byte(cfg.BasePath), '/')...),
),
[]byte(`base: "`),
append([]byte(`base: "`), []byte(cfg.BasePath)...),
),
[]byte("</head>"),
[]byte(`<base href="`+cfg.BasePath+`"></head>`),
)
}
return func(c *gin.Context) {
if indexTpl == nil {
// Create template from file
f, _ := Assets.Open("index.html")
v, _ := io.ReadAll(f)
v2 := strings.Replace(strings.Replace(string(v), "</head>", "{{ .Head }}</head>", 1), "</body>", "{{ .Body }}</body>", 1)
indexTpl = template.Must(template.New("index.html").Parse(v2))
}
// Serve template
if err := indexTpl.ExecuteTemplate(c.Writer, "index.html", map[string]string{
"Body": CustomBodyHTML,
"Head": CustomHeadHTML,
}); err != nil {
log.Println("Unable to return index.html:", err.Error())
}
c.Data(http.StatusOK, "text/html; charset=utf-8", rendered)
}
} else if forced_url == "/manifest.json" {
// Serve altered manifest.json
return func(c *gin.Context) {
f, _ := Assets.Open("manifest.json")
v, _ := io.ReadAll(f)
f, err := Assets.Open("manifest.json")
if err != nil {
c.String(http.StatusInternalServerError, "manifest.json not found in embedded assets")
return
}
v, err := io.ReadAll(f)
if err != nil {
c.String(http.StatusInternalServerError, "failed to read manifest.json")
return
}
v2 := strings.Replace(strings.Replace(string(v), "\"id\": \"/\"", "\"id\": \""+cfg.BasePath+"\"", 1), "\"start_url\": \"/\"", "\"start_url\": \""+cfg.BasePath+"\"", 1)
c.Data(http.StatusOK, "application/manifest+json", []byte(v2))

27
web/src/lib/api/auth.ts Normal file
View file

@ -0,0 +1,27 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2022-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import { base } from "$lib/stores/config";
export async function getOidcProvider(): Promise<{ provider: string }> {
const res = await fetch(`${base}/auth/has_oidc`);
return res.json();
}

View file

@ -32,6 +32,7 @@ import {
postUsersByUserIdSettings,
getUsersByUserId,
} from "$lib/api-base/sdk.gen";
import { client } from "$lib/api-base/client.gen";
import type { UserSettings } from "$lib/model/usersettings";
import type { User, SignUpForm, LoginForm } from "$lib/model/user";
import { unwrapSdkResponse, unwrapEmptyResponse } from "./errors";
@ -44,43 +45,6 @@ export async function authUser(form: LoginForm): Promise<User> {
return unwrapSdkResponse(await postAuth({ body: form })) as unknown as User;
}
export class CaptchaRequiredError extends Error {
constructor(message: string) {
super(message);
this.name = "CaptchaRequiredError";
}
}
export async function authUserWithCaptcha(form: LoginForm): Promise<User> {
// Use raw fetch to bypass the customFetch session-refresh wrapper,
// which would consume the 401 response before we can inspect captcha_required.
const baseUrl = typeof window !== 'undefined' ? "/api/" : "http://localhost/api/";
const response = await fetch(`${baseUrl}auth`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
if (!response.ok) {
let body: Record<string, unknown> = {};
try {
body = await response.json();
} catch {
// ignore
}
if (body.captcha_required) {
throw new CaptchaRequiredError(
typeof body.errmsg === 'string' ? body.errmsg : "Captcha verification required."
);
}
throw new Error(typeof body.errmsg === 'string' ? body.errmsg : "Invalid username or password.");
}
return response.json() as Promise<User>;
}
export async function logout(): Promise<boolean> {
return unwrapEmptyResponse(await postAuthLogout());
}
@ -185,6 +149,14 @@ export function cleanUserSession(): void {
}
}
export async function isAuthUser(user: User): Promise<boolean> {
const result = await client.get({
url: "/users/{userId}/is_auth_user",
path: { userId: user.id },
} as any);
return result.response?.status === 204;
}
export async function getUser(id: string): Promise<User> {
return unwrapSdkResponse(
await getUsersByUserId({

View file

@ -22,7 +22,6 @@
-->
<script lang="ts">
import { goto } from "$app/navigation";
import { page } from "$app/state";
import type { ClassValue } from "svelte/elements";
@ -41,7 +40,7 @@
import { logout as APILogout } from "$lib/api/user";
import HelpButton from "$lib/components/Help.svelte";
import Logo from "$lib/components/Logo.svelte";
import { appConfig } from "$lib/stores/config";
import { appConfig, navigate } from "$lib/stores/config";
import { providersSpecs } from "$lib/stores/providers";
import { userSession, refreshUserSession } from "$lib/stores/usersession";
import { toasts } from "$lib/stores/toasts";
@ -104,7 +103,7 @@
refreshUserSession().then(
() => {},
() => {
goto("/login");
navigate("/login");
},
);
},

View file

@ -22,7 +22,7 @@
-->
<script lang="ts">
import { goto } from "$app/navigation";
import { navigate } from "$lib/stores/config";
import { createEventDispatcher } from "svelte";
import {
@ -76,7 +76,7 @@
}
if (!provider) {
goto("/domains/new/" + encodeURIComponent(value));
navigate("/domains/new/" + encodeURIComponent(value));
} else {
addDomain(value, provider).then(
(domain) => {

View file

@ -22,7 +22,7 @@
-->
<script lang="ts">
import { goto } from "$app/navigation";
import { navigate } from "$lib/stores/config";
import { Button, Col, Container, Row, Spinner } from "@sveltestrap/sveltestrap";
@ -58,14 +58,14 @@
function createProviderForm(ptype: string, providerId: string | null, value: ProviderSettingsState | null, edit: boolean): ProviderForm {
const pf = new ProviderForm(
ptype,
() => refreshProviders().then(() => goto("/?newProvider")),
() => refreshProviders().then(() => navigate("/?newProvider")),
providerId,
value,
() => {
if (edit) {
goto("/providers");
navigate("/providers");
} else {
goto("/providers/new");
navigate("/providers/new");
}
},
);

View file

@ -22,7 +22,7 @@
-->
<script lang="ts">
import { goto } from "$app/navigation";
import { navigate } from "$lib/stores/config";
import {
Button,
@ -56,7 +56,7 @@
if (actionAddDomain) {
addDomainToProvider();
} else if (filteredDomains.length > 0) {
goto("/domains/" + encodeURIComponent(filteredDomains[0].id));
navigate("/domains/" + encodeURIComponent(filteredDomains[0].id));
}
}
@ -88,7 +88,7 @@
},
);
} else {
goto("/domains/new/" + encodeURIComponent($filteredName));
navigate("/domains/new/" + encodeURIComponent($filteredName));
}
}

View file

@ -22,8 +22,6 @@
-->
<script lang="ts">
import { goto } from "$app/navigation";
import {
Button,
Card,
@ -33,7 +31,7 @@
import ProviderList from "$lib/components/providers/List.svelte";
import type { Provider } from "$lib/model/provider";
import { appConfig } from "$lib/stores/config";
import { appConfig, navigate } from "$lib/stores/config";
import {
providers,
providersSpecs,
@ -67,7 +65,7 @@
items={$providers}
noLabel
bind:selectedProvider={filteredProvider}
on:new-provider={() => goto("/providers/new")}
on:new-provider={() => navigate("/providers/new")}
/>
{/if}
</Card>

View file

@ -22,7 +22,7 @@
-->
<script lang="ts">
import { goto } from "$app/navigation";
import { navigate } from "$lib/stores/config";
import { createEventDispatcher } from "svelte";
import {
@ -108,7 +108,7 @@
function updateProvider(event: Event, item: Provider) {
event.stopPropagation();
goto("/providers/" + encodeURIComponent(item._id));
navigate("/providers/" + encodeURIComponent(item._id));
}
async function delProvider(event: Event, item: Provider) {

View file

@ -19,6 +19,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import { base } from "$lib/stores/config";
import { refreshUserSession } from "$lib/stores/usersession";
import type { CreateClientConfig } from "./api-base/client.gen";
@ -29,6 +30,13 @@ export class NotAuthorizedError extends Error {
}
}
export class CaptchaRequiredError extends NotAuthorizedError {
constructor(message: string) {
super(message);
this.name = "CaptchaRequiredError";
}
}
export class ProviderNoDomainListingSupport extends Error {
constructor(message: string) {
super(message);
@ -39,8 +47,24 @@ export class ProviderNoDomainListingSupport extends Error {
async function customFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
const response = await fetch(input, init);
// Handle 401 Unauthorized - attempt session refresh and retry
// Handle 401 Unauthorized - check for captcha requirement first, then attempt session refresh
if (response.status === 401) {
if (response.headers.get("content-type")?.includes("application/json")) {
const clone = response.clone();
try {
const json = await clone.json();
if (json.captcha_required) {
throw new CaptchaRequiredError(
typeof json.errmsg === "string"
? json.errmsg
: "Captcha verification required.",
);
}
} catch (err) {
if (err instanceof CaptchaRequiredError) throw err;
// ignore JSON parsing errors
}
}
try {
await refreshUserSession();
// Retry the original request after successful session refresh
@ -66,7 +90,7 @@ async function customFetch(input: RequestInfo | URL, init?: RequestInit): Promis
if (
response.status === 400 &&
json.error ===
"error in openapi3filter.SecurityRequirementsError: security requirements failed: invalid session"
"error in openapi3filter.SecurityRequirementsError: security requirements failed: invalid session"
) {
throw new NotAuthorizedError(json.error.substring(80));
}
@ -79,7 +103,10 @@ async function customFetch(input: RequestInfo | URL, init?: RequestInit): Promis
}
} catch (err) {
// If it's one of our custom errors, re-throw it
if (err instanceof NotAuthorizedError || err instanceof ProviderNoDomainListingSupport) {
if (
err instanceof NotAuthorizedError ||
err instanceof ProviderNoDomainListingSupport
) {
throw err;
}
// Otherwise, ignore JSON parsing errors and return the original response
@ -92,7 +119,7 @@ async function customFetch(input: RequestInfo | URL, init?: RequestInit): Promis
export const createClientConfig: CreateClientConfig = (config) => {
// In test environments (Node.js), we need a full URL with protocol and host
// In browser environments, relative URLs work fine
const baseUrl = typeof window !== 'undefined' ? "/api/" : "http://localhost/api/";
const baseUrl = typeof window !== "undefined" ? base + "/api/" : "http://localhost/api/";
return {
...config,

View file

@ -19,6 +19,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import { goto } from '$app/navigation';
import { writable } from 'svelte/store';
import type { Color } from "@sveltestrap/sveltestrap";
@ -65,3 +66,11 @@ function getConfigFromScriptTag(): AppConfig | null {
const initialConfig = getConfigFromScriptTag() || defaultConfig;
export const appConfig = writable<AppConfig>(initialConfig);
export const base: string = typeof document !== 'undefined'
? (document.querySelector('base')?.getAttribute('href') ?? '')
: '';
export function navigate(url: string, opts?: Parameters<typeof goto>[1]) {
return goto(base + url, opts);
}

View file

@ -19,6 +19,8 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import { getProvidersSpecs } from "$lib/api-base/sdk.gen";
import { unwrapSdkResponse } from "$lib/api/errors";
import { listProviders } from "$lib/api/provider";
import type { Provider, ProviderInfos } from "$lib/model/provider";
import { filteredProvider } from "$lib/stores/home";
@ -62,12 +64,7 @@ export const providers_idx = derived(providers, ($providers: Array<Provider> | u
});
export async function refreshProvidersSpecs() {
const res = await fetch("/api/providers/_specs", { headers: { Accept: "application/json" } });
if (res.status == 200) {
const map = await res.json();
providersSpecs.set(map);
return map;
} else {
throw new Error((await res.json()).errmsg);
}
const map = unwrapSdkResponse(await getProvidersSpecs()) as Record<string, ProviderInfos>;
providersSpecs.set(map);
return map;
}

View file

@ -19,6 +19,8 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import { getServiceSpecs } from "$lib/api-base/sdk.gen";
import { unwrapSdkResponse } from "$lib/api/errors";
import { derived, writable, type Writable } from "svelte/store";
import type { ServiceInfos } from "$lib/model/service_specs.svelte";
@ -30,16 +32,15 @@ export async function refreshServicesSpecs() {
servicesSpecsLoaded.set(false);
servicesSpecsError.set(null);
const res = await fetch("/api/service_specs", { headers: { Accept: "application/json" } });
if (res.status == 200) {
const map = await res.json();
try {
const map = unwrapSdkResponse(await getServiceSpecs()) as Record<string, ServiceInfos>;
servicesSpecs.set(map);
servicesSpecsLoaded.set(true);
return map;
} else {
const errmsg = (await res.json()).errmsg;
} catch (err) {
const errmsg = err instanceof Error ? err.message : String(err);
servicesSpecsError.set(errmsg);
throw new Error(errmsg);
throw err;
}
}

View file

@ -20,23 +20,19 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import { writable, type Writable } from "svelte/store";
import { getAuth } from "$lib/api-base/sdk.gen";
import { unwrapSdkResponse } from "$lib/api/errors";
import type { User } from "$lib/model/user";
export const userSession: Writable<User> = writable({} as User);
export async function refreshUserSession(
fetch: (
input: RequestInfo | URL,
init?: RequestInit | undefined,
) => Promise<Response> = window.fetch,
) {
const res = await fetch("/api/auth", { headers: { Accept: "application/json" } });
if (res.status == 200) {
const user = (await res.json()) as User;
export async function refreshUserSession() {
try {
const user = unwrapSdkResponse(await getAuth()) as unknown as User;
userSession.set(user);
return user;
} else {
} catch (err) {
userSession.set({} as User);
throw new Error((await res.json()).errmsg);
throw err;
}
}

View file

@ -25,12 +25,10 @@
import "bootstrap-icons/font/bootstrap-icons.css";
import "../app.scss";
import { goto } from "$app/navigation";
import Header from "$lib/components/Header.svelte";
import Toaster from "$lib/components/Toaster.svelte";
import VoxPeople from "$lib/components/VoxPeople.svelte";
import { appConfig } from "$lib/stores/config";
import { appConfig, navigate } from "$lib/stores/config";
import { providers } from "$lib/stores/providers";
import { toasts } from "$lib/stores/toasts";
import { locale, t } from "$lib/translations";
@ -53,7 +51,7 @@
window.onunhandledrejection = (e) => {
if (e.reason.name == "NotAuthorizedError") {
goto("/login");
navigate("/login");
providers.set(undefined);
toasts.addErrorToast({
title: $t("errors.session.title"),

View file

@ -41,7 +41,7 @@ function onSWupdate(sw_state: { hasUpdate: boolean }, installingWorker: ServiceW
sw_state.hasUpdate = true;
}
export const load: Load = async ({ fetch, route, url }) => {
export const load: Load = async ({ route, url }) => {
const { MODE } = import.meta.env;
const initLocale =
@ -94,7 +94,7 @@ export const load: Load = async ({ fetch, route, url }) => {
// Load user session if any
try {
const user = await refreshUserSession(fetch);
const user = await refreshUserSession();
if (!url.searchParams.has("lang") && get(locale) != user.settings.language) {
locale.set(user.settings.language);
}

View file

@ -22,7 +22,8 @@
-->
<script lang="ts">
import { goto, invalidateAll } from "$app/navigation";
import { invalidateAll } from "$app/navigation";
import { navigate } from "$lib/stores/config";
import { page } from "$app/state";
import {
@ -73,7 +74,7 @@
let selectedDomain = $derived(data.domain.id);
function domainChange(dn: string) {
if (dn != data.domain.id) {
goto(
navigate(
"/domains/" +
encodeURIComponent(domainLink(dn)) +
(page.route.id
@ -102,7 +103,7 @@
retrievalInProgress = false;
if (page.data.definedhistory) {
refreshDomains().then(() => {
goto(
navigate(
"/domains/" +
encodeURIComponent(domainLink(selectedDomain)) +
"/" +
@ -130,11 +131,11 @@
refreshDomains().then(
() => {
deleteInProgress = false;
goto("/domains");
navigate("/domains");
},
() => {
deleteInProgress = false;
goto("/domains");
navigate("/domains");
},
);
},

View file

@ -22,7 +22,7 @@
-->
<script lang="ts">
import { goto } from "$app/navigation";
import { navigate } from "$lib/stores/config";
import { Spinner } from "@sveltestrap/sveltestrap";
@ -44,7 +44,7 @@
let selectedHistory: string = $derived(data.history);
function historyChange(history: string) {
if (data.history != history) {
goto(
navigate(
"/domains/" +
encodeURIComponent(
$domains_idx[data.domain.domain]

View file

@ -22,7 +22,7 @@
-->
<script lang="ts">
import { goto } from "$app/navigation";
import { navigate } from "$lib/stores/config";
import { Alert, Icon, Spinner } from "@sveltestrap/sveltestrap";
@ -43,7 +43,7 @@
() => {
refreshDomains().then(
() => {
goto(
navigate(
`/domains/${encodeURIComponent($domains_idx[data.domain.domain] ? data.domain.domain : data.domain.id)}`,
);
},

View file

@ -22,7 +22,7 @@
-->
<script lang="ts">
import { goto } from "$app/navigation";
import { navigate } from "$lib/stores/config";
import { Button, Col, Container, Icon, Row, Spinner } from "@sveltestrap/sveltestrap";
@ -69,7 +69,7 @@
});
refreshDomains();
goto("/domains/");
navigate("/domains/");
},
(error) => {
addingNewDomain = false;

View file

@ -22,12 +22,10 @@
-->
<script lang="ts">
import { goto } from "$app/navigation";
import { Alert, Col, Container, Row, Spinner } from "@sveltestrap/sveltestrap";
import { validateEmail } from "$lib/api/user";
import { appConfig } from "$lib/stores/config";
import { appConfig, navigate } from "$lib/stores/config";
import { toasts } from "$lib/stores/toasts";
import { t } from "$lib/translations";
import EmailConfirmationForm from "./EmailConfirmationForm.svelte";
@ -50,7 +48,7 @@
timeout: 5000,
type: "success",
});
goto("/login");
navigate("/login");
},
(err) => {
error = err;

View file

@ -22,7 +22,7 @@
-->
<script lang="ts">
import { goto } from "$app/navigation";
import { navigate } from "$lib/stores/config";
import { Button, Col, Input, Row, Spinner } from "@sveltestrap/sveltestrap";
@ -59,7 +59,7 @@
timeout: 5000,
type: "success",
});
goto("/");
navigate("/");
},
(error) => {
formSent = false;

View file

@ -22,7 +22,7 @@
-->
<script lang="ts">
import { goto } from "$app/navigation";
import { navigate } from "$lib/stores/config";
import { Button, Col, Input, Row, Spinner } from "@sveltestrap/sveltestrap";
@ -55,7 +55,7 @@
timeout: 20000,
color: "success",
});
goto("/login");
navigate("/login");
},
(error) => {
formSent = false;

View file

@ -22,7 +22,7 @@
-->
<script lang="ts">
import { goto } from "$app/navigation";
import { navigate } from "$lib/stores/config";
import { Button, Col, Input, Row, Spinner } from "@sveltestrap/sveltestrap";
@ -63,7 +63,7 @@
type: "success",
timeout: 5000,
});
goto("/login");
navigate("/login");
},
(error) => {
formSent = false;

View file

@ -22,15 +22,13 @@
-->
<script lang="ts">
import { goto } from "$app/navigation";
import { Button, FormGroup, Icon, Input, Label, Spinner } from "@sveltestrap/sveltestrap";
import { t, locale } from "$lib/translations";
import { registerUser } from "$lib/api/user";
import type { SignUpForm } from "$lib/model/user";
import { checkWeakPassword, checkPasswordConfirmation } from "$lib/password";
import { appConfig } from "$lib/stores/config";
import { appConfig, navigate } from "$lib/stores/config";
import { toasts } from "$lib/stores/toasts";
import CaptchaWidget from "$lib/components/CaptchaWidget.svelte";
@ -73,7 +71,7 @@
type: "success",
timeout: 5000,
});
goto("/login");
navigate("/login");
},
(error) => {
formSent = false;

View file

@ -22,15 +22,16 @@
-->
<script lang="ts">
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { Button, FormGroup, Input, Label, Spinner } from "@sveltestrap/sveltestrap";
import { t } from "$lib/translations";
import { authUserWithCaptcha, CaptchaRequiredError, cleanUserSession } from "$lib/api/user";
import { getOidcProvider } from "$lib/api/auth";
import { authUser, cleanUserSession } from "$lib/api/user";
import { CaptchaRequiredError } from "$lib/hey-api";
import type { LoginForm } from "$lib/model/user";
import { appConfig } from "$lib/stores/config";
import { appConfig, navigate } from "$lib/stores/config";
import { providers } from "$lib/stores/providers";
import { toasts } from "$lib/stores/toasts";
import { refreshUserSession } from "$lib/stores/usersession";
@ -65,7 +66,7 @@
? { ...loginForm, captcha_token: captchaToken }
: loginForm;
authUserWithCaptcha(formWithCaptcha).then(
authUser(formWithCaptcha).then(
() => {
cleanUserSession();
providers.set(undefined);
@ -76,9 +77,9 @@
const nextParam = page.url.searchParams.get("next");
if (nextParam) {
goto(decodeURIComponent(nextParam));
navigate(decodeURIComponent(nextParam));
} else {
goto("/");
navigate("/");
}
},
(error) => {
@ -114,7 +115,7 @@
timeout: 10000,
});
} else {
goto("/forgotten-password");
navigate("/forgotten-password");
}
}
</script>
@ -161,23 +162,21 @@
{$t("common.go")}
</Button>
{#if $appConfig.oidc_configured}
{#await fetch("/auth/has_oidc") then res}
{#await res.json() then oidc}
<Button href="/auth/oidc" color="secondary">
{#if oidc.provider == "google.com"}
<i class="bi bi-google"></i>
{:else if oidc.provider == "gitlab.com" || oidc.provider == "framagit.org"}
<i class="bi bi-gitlab"></i>
{:else if oidc.provider == "github.com"}
<i class="bi bi-github"></i>
{:else if oidc.provider == "microsoft.com"}
<i class="bi bi-microsoft"></i>
{:else if oidc.provider == "apple.com"}
<i class="bi bi-apple"></i>
{/if}
{$t("account.oidc-login", { provider: oidc.provider })}
</Button>
{/await}
{#await getOidcProvider() then oidc}
<Button href="/auth/oidc" color="secondary">
{#if oidc.provider == "google.com"}
<i class="bi bi-google"></i>
{:else if oidc.provider == "gitlab.com" || oidc.provider == "framagit.org"}
<i class="bi bi-gitlab"></i>
{:else if oidc.provider == "github.com"}
<i class="bi bi-github"></i>
{:else if oidc.provider == "microsoft.com"}
<i class="bi bi-microsoft"></i>
{:else if oidc.provider == "apple.com"}
<i class="bi bi-apple"></i>
{/if}
{$t("account.oidc-login", { provider: oidc.provider })}
</Button>
{/await}
{/if}
<Button type="button" on:click={handleForgottenPassword} outline color="dark">

View file

@ -24,6 +24,8 @@
<script lang="ts">
import { Container, ListGroup, Spinner } from "@sveltestrap/sveltestrap";
import { getOidcProvider } from "$lib/api/auth";
import { isAuthUser } from "$lib/api/user";
import { userSession } from "$lib/stores/usersession";
import { t } from "$lib/translations";
@ -32,9 +34,7 @@
import SessionsManager from "./SessionsManager.svelte";
import UserSettingsForm from "./UserSettingsForm.svelte";
let is_auth_user_req = $userSession.id
? fetch(`/api/users/${$userSession.id}/is_auth_user`)
: false;
let is_auth_user_req = $userSession.id ? isAuthUser($userSession) : false;
</script>
<svelte:head>
@ -87,15 +87,13 @@
<Spinner />
</div>
{:then res}
{#if res && res.status === 204}
{#if res}
<ChangePasswordForm />
{:else}
{#await fetch("/auth/has_oidc") then res}
{#await res.json() then oidc}
<div class="alert alert-secondary">
{$t("account.no-password-change", { provider: oidc.provider })}
</div>
{/await}
{#await getOidcProvider() then oidc}
<div class="alert alert-secondary">
{$t("account.no-password-change", { provider: oidc.provider })}
</div>
{/await}
{/if}
{/await}
@ -113,7 +111,7 @@
</div>
{:then res}
<ListGroup>
<DeleteAccountCard externalAuth={res && res.status !== 204} />
<DeleteAccountCard externalAuth={!res} />
</ListGroup>
{/await}
{:else}

View file

@ -22,7 +22,7 @@
-->
<script lang="ts">
import { goto } from "$app/navigation";
import { navigate } from "$lib/stores/config";
import { Button, Input, Spinner } from "@sveltestrap/sveltestrap";
@ -61,7 +61,7 @@
timeout: 5000,
color: "success",
});
goto("/login");
navigate("/login");
},
(error) => {
formSent = false;

View file

@ -28,7 +28,7 @@
</script>
<script lang="ts">
import { goto } from "$app/navigation";
import { navigate } from "$lib/stores/config";
import {
Button,
@ -65,7 +65,7 @@
message: $t("account.delete.success"),
type: "primary",
});
goto("/login");
navigate("/login");
}
function deletionError(err: Error): void {

View file

@ -22,7 +22,7 @@
-->
<script lang="ts">
import { goto } from "$app/navigation";
import { navigate } from "$lib/stores/config";
import {
Button,
@ -63,7 +63,7 @@
is_closing_sessions = true;
await deleteSessions();
is_closing_sessions = false;
goto("/login");
navigate("/login");
}
let newSessionModalOpen = $state(false);

View file

@ -22,7 +22,7 @@
-->
<script lang="ts">
import { goto } from "$app/navigation";
import { navigate } from "$lib/stores/config";
import { Button, ButtonGroup, Icon, Input, Spinner } from "@sveltestrap/sveltestrap";
@ -57,7 +57,7 @@
color: "success",
});
goto("/");
navigate("/");
});
},
(error) => {

View file

@ -22,7 +22,7 @@
-->
<script lang="ts">
import { goto } from "$app/navigation";
import { navigate } from "$lib/stores/config";
import { page } from "$app/state";
import { untrack } from "svelte";
@ -137,7 +137,7 @@
if (form.domain === domain) {
resolve(form);
} else {
goto("/resolver/" + encodeURIComponent(form.domain), {
navigate("/resolver/" + encodeURIComponent(form.domain), {
state: { form, showDNSSEC },
noScroll: true,
});

View file

@ -3,6 +3,9 @@ import { build, files, version } from '$service-worker';
// Create a unique cache name for this deployment
const CACHE = `cache-${version}`;
// Derive base path from SW scope (e.g. "https://example.com/subpath/" → "/subpath")
const BASE = new URL(self.registration.scope).pathname.replace(/\/$/, '');
const ASSETS = [
...build, // the app itself
...files // everything in `static`
@ -50,9 +53,12 @@ self.addEventListener('fetch', (event) => {
async function respond() {
const url = new URL(event.request.url);
const cache = await caches.open(CACHE);
const pathname = BASE && url.pathname.startsWith(BASE)
? url.pathname.slice(BASE.length)
: url.pathname;
// `build`/`files` can always be served from the cache
if (ASSETS.includes(url.pathname)) {
if (ASSETS.includes(pathname)) {
// Strip query string for asset cache lookup (assets are keyed by pathname only)
const cacheKey = url.search.length
? new Request(url.origin + url.pathname)
@ -66,8 +72,8 @@ self.addEventListener('fetch', (event) => {
}
if (
url.pathname.startsWith("/api/providers/_specs") ||
url.pathname.startsWith("/api/service_specs/")
pathname.startsWith("/api/providers/_specs") ||
pathname.startsWith("/api/service_specs/")
) {
// cache first
const responseFromCache = await caches.match(event.request);
@ -88,7 +94,7 @@ self.addEventListener('fetch', (event) => {
try {
const response = await fetch(event.request);
if (response.status === 200 && !url.pathname.startsWith("/api/auth")) {
if (response.status === 200 && !pathname.startsWith("/api/auth")) {
await cache.put(event.request, response.clone());
}