Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

IAM-951 fix broken href for non-empty context-path #426

Merged
merged 2 commits into from
Oct 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading