Skip to content

Commit

Permalink
Merge branch 'main' into verification-email-improvements
Browse files Browse the repository at this point in the history
  • Loading branch information
huwshimi authored Oct 9, 2024
2 parents 3f9726b + 2edeee9 commit ba80f57
Show file tree
Hide file tree
Showing 4 changed files with 82 additions and 50 deletions.
61 changes: 52 additions & 9 deletions pkg/ui/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package ui

import (
"encoding/json"
"html/template"
"io/fs"
"net/http"
"net/url"
Expand All @@ -18,16 +19,20 @@ import (
"github.com/canonical/identity-platform-admin-ui/internal/tracing"
)

const UIPrefix = "/ui"
const (
UIPrefix = "/ui"
indexTemplate = "index.html"
)

type Config struct {
DistFS fs.FS
ContextPath string
}

type API struct {
fileServer http.Handler
contextPath string
fileServer http.Handler
distFS fs.FS

tracer tracing.TracingInterface
monitor monitoring.MonitorInterface
Expand All @@ -39,13 +44,7 @@ func (a *API) RegisterEndpoints(mux *chi.Mux) {
path, err := url.JoinPath("/", a.contextPath, UIPrefix, "/")
if err != nil {
a.logger.Error("Failed to construct path: ", err)
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(
types.Response{
Status: http.StatusInternalServerError,
Message: err.Error(),
},
)
a.internalServerErrorResponse(w, err)
return
}
http.Redirect(w, r, path, http.StatusMovedPermanently)
Expand All @@ -54,6 +53,16 @@ func (a *API) RegisterEndpoints(mux *chi.Mux) {
mux.Get(UIPrefix+"/*", a.uiFiles)
}

func (a *API) internalServerErrorResponse(w http.ResponseWriter, err error) {
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(
types.Response{
Status: http.StatusInternalServerError,
Message: err.Error(),
},
)
}

func (a *API) uiFiles(w http.ResponseWriter, r *http.Request) {
r.URL.Path = strings.TrimPrefix(r.URL.Path, UIPrefix)
// This is a SPA, every HTML page serves the same `index.html`
Expand All @@ -76,12 +85,46 @@ func (a *API) uiFiles(w http.ResponseWriter, r *http.Request) {
// The policy allows loading resources (scripts, styles, images, etc.) only from the same origin ('self'), data URLs, and all subdomains of ubuntu.com.
w.Header().Set("Content-Security-Policy", "default-src 'self' data: https://*.ubuntu.com; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'")

// return html with processed template
if r.URL.Path == "/" {
t, err := template.New(indexTemplate).ParseFS(a.distFS, indexTemplate)
if err != nil {
a.logger.Error("Failed to load %s template: ", indexTemplate, err)
a.internalServerErrorResponse(w, err)
return
}

// disable cache only for index.html response, with no issues if we return an error
// `no-store`: This will tell any cache system not to cache the index.html file
// `no-cache`: This will tell any cache system to check if there is a newer version in the server
// `must-revalidate`: This will tell any cache system to check for newer version of the file
// this is considered best practice with SPAs
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
w.WriteHeader(http.StatusOK)

normContextPath := a.contextPath
if !strings.HasSuffix(normContextPath, "/") {
normContextPath += "/"
}

err = t.Execute(w, normContextPath)
if err != nil {
a.logger.Error("Failed to process %s template: ", indexTemplate, err)
a.internalServerErrorResponse(w, err)
return
}

return
}

// return requested assets
a.fileServer.ServeHTTP(w, r)
}

func NewAPI(config *Config, tracer tracing.TracingInterface, monitor monitoring.MonitorInterface, logger logging.LoggerInterface) *API {
a := new(API)

a.distFS = config.DistFS
a.fileServer = http.FileServer(http.FS(config.DistFS))
a.contextPath = config.ContextPath

Expand Down
6 changes: 5 additions & 1 deletion ui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@
<title>Identity platform</title>
<link rel="shortcut icon" href="https://assets.ubuntu.com/v1/49a1a858-favicon-32x32.png" type="image/x-icon" />

<script>const global = globalThis;</script>
<script>
const global = globalThis;
const base = "{{ . }}";
</script>
<base href="{{ . }}ui/" />
</head>
<body>

Expand Down
46 changes: 12 additions & 34 deletions ui/src/util/basePaths.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,54 +5,32 @@ import {
} from "./basePaths";

vi.mock("./basePaths", async () => {
vi.stubGlobal("location", { pathname: "/example/ui/" });
window.base = "/example";
const actual = await vi.importActual("./basePaths");
return {
...actual,
basePath: "/example/ui/",
apiBasePath: "/example/ui/../api/v0/",
apiBasePath: "/example/api/v0/",
};
});

describe("calculateBasePath", () => {
it("resolves with ui path", () => {
vi.stubGlobal("location", { pathname: "/ui/" });
window.base = "/test/";
const result = calculateBasePath();
expect(result).toBe("/ui/");
expect(result).toBe("/test/");
});

it("resolves with ui path without trailing slash", () => {
vi.stubGlobal("location", { pathname: "/ui" });
window.base = "/test";
const result = calculateBasePath();
expect(result).toBe("/ui/");
expect(result).toBe("/test/");
});

it("resolves with ui path and discards detail page location", () => {
vi.stubGlobal("location", { pathname: "/ui/foo/bar" });
const result = calculateBasePath();
expect(result).toBe("/ui/");
});

it("resolves with prefixed ui path", () => {
vi.stubGlobal("location", { pathname: "/prefix/ui/" });
const result = calculateBasePath();
expect(result).toBe("/prefix/ui/");
});

it("resolves with prefixed ui path on a detail page", () => {
vi.stubGlobal("location", { pathname: "/prefix/ui/foo/bar/baz" });
const result = calculateBasePath();
expect(result).toBe("/prefix/ui/");
});

it("resolves with root path if /ui/ is not part of the pathname", () => {
vi.stubGlobal("location", { pathname: "/foo/bar/baz" });
const result = calculateBasePath();
expect(result).toBe("/");
});

it("resolves with root path for partial ui substrings", () => {
vi.stubGlobal("location", { pathname: "/prefix/uipartial" });
it("resolves with root path if the base is not provided", () => {
if (window.base) {
delete window.base;
}
const result = calculateBasePath();
expect(result).toBe("/");
});
Expand All @@ -70,10 +48,10 @@ describe("appendBasePath", () => {

describe("appendAPIBasePath", () => {
it("handles paths with a leading slash", () => {
expect(appendAPIBasePath("/test")).toBe("/example/ui/../api/v0/test");
expect(appendAPIBasePath("/test")).toBe("/example/api/v0/test");
});

it("handles paths without a leading slash", () => {
expect(appendAPIBasePath("test")).toBe("/example/ui/../api/v0/test");
expect(appendAPIBasePath("test")).toBe("/example/api/v0/test");
});
});
19 changes: 13 additions & 6 deletions ui/src/util/basePaths.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,25 @@
import { removeTrailingSlash } from "util/removeTrailingSlash";
type BasePath = `/${string}`;

declare global {
interface Window {
base?: string;
}
}

export const calculateBasePath = (): BasePath => {
const path = window.location.pathname;
// find first occurrence of /ui/ and return the string before it
const basePath = path.match(/(.*\/ui(?:\/|$))/);
let basePath = "";
if ("base" in window && typeof window.base === "string") {
basePath = window.base;
}
if (basePath) {
return `${removeTrailingSlash(basePath[0])}/` as BasePath;
return `${removeTrailingSlash(basePath)}/` as BasePath;
}
return "/";
};

export const basePath: BasePath = calculateBasePath();
export const apiBasePath: BasePath = `${basePath}../api/v0/`;
export const basePath: BasePath = `${calculateBasePath()}ui`;
export const apiBasePath: BasePath = `${calculateBasePath()}api/v0/`;

export const appendBasePath = (path: string) =>
`${removeTrailingSlash(basePath)}/${path.replace(/^\//, "")}`;
Expand Down

0 comments on commit ba80f57

Please sign in to comment.