-
+
diff --git a/site/components/Redirect.test.tsx b/site/components/Redirect.test.tsx
deleted file mode 100644
index 4b53889f39..0000000000
--- a/site/components/Redirect.test.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-import { render, waitFor } from "@testing-library/react"
-import singletonRouter from "next/router"
-import mockRouter from "next-router-mock"
-import React from "react"
-import { Redirect } from "./Redirect"
-
-describe("Redirect", () => {
- // Reset the router to '/' before every test
- beforeEach(() => {
- mockRouter.setCurrentUrl("/")
- })
-
- it("performs client-side redirect on render", async () => {
- // When
- render(
)
-
- // Then
- await waitFor(() => {
- expect(singletonRouter).toMatchObject({ asPath: "/workspaces/v2" })
- })
- })
-})
diff --git a/site/components/Redirect.tsx b/site/components/Redirect.tsx
deleted file mode 100644
index 1429421d2a..0000000000
--- a/site/components/Redirect.tsx
+++ /dev/null
@@ -1,23 +0,0 @@
-import React, { useEffect } from "react"
-import { useRouter } from "next/router"
-
-export interface RedirectProps {
- /**
- * The path to redirect to
- * @example '/projects'
- */
- to: string
-}
-
-/**
- * Helper component to perform a client-side redirect
- */
-export const Redirect: React.FC
= ({ to }) => {
- const { replace } = useRouter()
-
- useEffect(() => {
- void replace(to)
- }, [replace, to])
-
- return null
-}
diff --git a/site/components/SignIn/SignInForm.test.tsx b/site/components/SignIn/SignInForm.test.tsx
index 1306fd43fa..83694b50fc 100644
--- a/site/components/SignIn/SignInForm.test.tsx
+++ b/site/components/SignIn/SignInForm.test.tsx
@@ -1,13 +1,12 @@
import React from "react"
-import singletonRouter from "next/router"
-import mockRouter from "next-router-mock"
-import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"
+import { act, fireEvent, screen, waitFor } from "@testing-library/react"
+import { history, render } from "../../test_helpers"
import { SignInForm } from "./SignInForm"
describe("SignInForm", () => {
beforeEach(() => {
- mockRouter.setCurrentUrl("/login")
+ history.replace("/")
})
it("renders content", async () => {
@@ -56,14 +55,14 @@ describe("SignInForm", () => {
// Then
// Should redirect because login was successful
- await waitFor(() => expect(singletonRouter).toMatchObject({ asPath: "/" }))
+ await waitFor(() => expect(history.location.pathname).toEqual("/"))
})
it("respects ?redirect query parameter when complete", async () => {
// Given
const loginHandler = (_email: string, _password: string) => Promise.resolve()
// Set a path to redirect to after login is successful
- mockRouter.setCurrentUrl("/login?redirect=%2Fsome%2Fother%2Fpath")
+ history.replace("/login?redirect=%2Fsome%2Fother%2Fpath")
// When
// Render the component
@@ -78,6 +77,6 @@ describe("SignInForm", () => {
// Then
// Should redirect to /some/other/path because ?redirect was specified and login was successful
- await waitFor(() => expect(singletonRouter).toMatchObject({ asPath: "/some/other/path" }))
+ await waitFor(() => expect(history.location.pathname).toEqual("/some/other/path"))
})
})
diff --git a/site/components/SignIn/SignInForm.tsx b/site/components/SignIn/SignInForm.tsx
index 3216e53ba4..6f48f61058 100644
--- a/site/components/SignIn/SignInForm.tsx
+++ b/site/components/SignIn/SignInForm.tsx
@@ -1,6 +1,7 @@
import { makeStyles } from "@material-ui/core/styles"
import { FormikContextType, useFormik } from "formik"
-import { NextRouter, useRouter } from "next/router"
+import { Location } from "history"
+import { useNavigate, useLocation } from "react-router-dom"
import React from "react"
import { useSWRConfig } from "swr"
import * as Yup from "yup"
@@ -9,7 +10,6 @@ import { Welcome } from "./Welcome"
import { FormTextField } from "../Form"
import * as API from "./../../api"
import { LoadingButton } from "./../Button"
-import { firstOrItem } from "../../util/array"
/**
* BuiltInAuthFormValues describes a form using built-in (email/password)
@@ -47,7 +47,8 @@ export interface SignInProps {
export const SignInForm: React.FC = ({
loginHandler = (email: string, password: string) => API.login(email, password),
}) => {
- const router = useRouter()
+ const navigate = useNavigate()
+ const location = useLocation()
const styles = useStyles()
const { mutate } = useSWRConfig()
@@ -63,8 +64,8 @@ export const SignInForm: React.FC = ({
// Tell SWR to invalidate the cache for the user endpoint
await mutate("/api/v2/users/me")
- const redirect = getRedirectFromRouter(router)
- await router.push(redirect)
+ const redirect = getRedirectFromLocation(location)
+ await navigate(redirect)
} catch (err) {
helpers.setFieldError("password", "The username or password is incorrect.")
}
@@ -121,11 +122,10 @@ export const SignInForm: React.FC = ({
)
}
-const getRedirectFromRouter = (router: NextRouter) => {
+const getRedirectFromLocation = (location: Location) => {
const defaultRedirect = "/"
- if (router.query.redirect) {
- return firstOrItem(router.query.redirect, defaultRedirect)
- } else {
- return defaultRedirect
- }
+
+ const searchParams = new URLSearchParams(location.search)
+ const redirect = searchParams.get("redirect")
+ return redirect ? redirect : defaultRedirect
}
diff --git a/site/components/Workspace/Workspace.test.tsx b/site/components/Workspace/Workspace.test.tsx
index d0a2279c58..e085171070 100644
--- a/site/components/Workspace/Workspace.test.tsx
+++ b/site/components/Workspace/Workspace.test.tsx
@@ -1,7 +1,7 @@
-import { render, screen } from "@testing-library/react"
+import { screen } from "@testing-library/react"
import React from "react"
import { Workspace } from "./Workspace"
-import { MockOrganization, MockProject, MockWorkspace } from "../../test_helpers"
+import { MockOrganization, MockProject, MockWorkspace, render } from "../../test_helpers"
describe("Workspace", () => {
it("renders", async () => {
diff --git a/site/components/Workspace/Workspace.tsx b/site/components/Workspace/Workspace.tsx
index 68c64a467a..8eb1887881 100644
--- a/site/components/Workspace/Workspace.tsx
+++ b/site/components/Workspace/Workspace.tsx
@@ -3,7 +3,7 @@ import Paper from "@material-ui/core/Paper"
import Typography from "@material-ui/core/Typography"
import { makeStyles } from "@material-ui/core/styles"
import CloudCircleIcon from "@material-ui/icons/CloudCircle"
-import Link from "next/link"
+import { Link } from "react-router-dom"
import React from "react"
import * as Constants from "./constants"
import * as API from "../../api"
@@ -68,7 +68,7 @@ export const WorkspaceHeader: React.FC = ({ organization, projec
{workspace.name}
- {project.name}
+ {project.name}
diff --git a/site/components/index.tsx b/site/components/index.tsx
index ebb1a90188..5fd2a75122 100644
--- a/site/components/index.tsx
+++ b/site/components/index.tsx
@@ -1,4 +1,3 @@
export * from "./Button"
export * from "./EmptyState"
export * from "./Page"
-export * from "./Redirect"
diff --git a/site/contexts/UserContext.test.tsx b/site/contexts/UserContext.test.tsx
index 22685b6399..58af022aa0 100644
--- a/site/contexts/UserContext.test.tsx
+++ b/site/contexts/UserContext.test.tsx
@@ -1,11 +1,8 @@
-import singletonRouter from "next/router"
-import mockRouter from "next-router-mock"
import React from "react"
import { SWRConfig } from "swr"
-import { render, screen, waitFor } from "@testing-library/react"
-
+import { screen, waitFor } from "@testing-library/react"
import { User, UserProvider, useUser } from "./UserContext"
-import { MockUser } from "../test_helpers"
+import { history, MockUser, render } from "../test_helpers"
namespace Helpers {
// Helper component that renders out the state of the `useUser` hook.
@@ -54,7 +51,7 @@ describe("UserContext", () => {
// Reset the router to '/' before every test
beforeEach(() => {
- mockRouter.setCurrentUrl("/")
+ history.replace("/")
})
it("shouldn't redirect if user fails to load and redirectOnFailure is false", async () => {
@@ -67,7 +64,8 @@ describe("UserContext", () => {
expect(screen.queryByText("Error:", { exact: false })).toBeDefined()
})
// ...and the route should be unchanged
- expect(singletonRouter).toMatchObject({ asPath: "/" })
+ expect(history.location.pathname).toEqual("/")
+ expect(history.location.search).toEqual("")
})
it("should redirect if user fails to load and redirectOnFailure is true", async () => {
@@ -76,7 +74,8 @@ describe("UserContext", () => {
// Then
// Verify we route to the login page
- await waitFor(() => expect(singletonRouter).toMatchObject({ asPath: "/login?redirect=%2F" }))
+ await waitFor(() => expect(history.location.pathname).toEqual("/login"))
+ await waitFor(() => expect(history.location.search).toEqual("?redirect=%2F"))
})
it("should not redirect if user loads and redirectOnFailure is true", async () => {
@@ -89,6 +88,7 @@ describe("UserContext", () => {
expect(screen.queryByText("Me:", { exact: false })).toBeDefined()
})
// ...and the route should be unchanged
- expect(singletonRouter).toMatchObject({ asPath: "/" })
+ expect(history.location.pathname).toEqual("/")
+ expect(history.location.search).toEqual("")
})
})
diff --git a/site/contexts/UserContext.tsx b/site/contexts/UserContext.tsx
index 7db4169140..459af26595 100644
--- a/site/contexts/UserContext.tsx
+++ b/site/contexts/UserContext.tsx
@@ -1,4 +1,4 @@
-import { useRouter } from "next/router"
+import { useLocation, useNavigate } from "react-router-dom"
import React, { useContext, useEffect } from "react"
import useSWR from "swr"
@@ -25,18 +25,15 @@ const UserContext = React.createContext
({
export const useUser = (redirectOnError = false): UserContext => {
const ctx = useContext(UserContext)
- const { push, asPath } = useRouter()
+ const navigate = useNavigate()
+ const { pathname } = useLocation()
const requestError = ctx.error
useEffect(() => {
if (redirectOnError && requestError) {
- // 'void' means we are ignoring handling the promise returned
- // from router.push (and lets the linter know we're OK with that!)
- void push({
+ navigate({
pathname: "/login",
- query: {
- redirect: asPath,
- },
+ search: "?redirect=" + encodeURIComponent(pathname),
})
}
// Disabling exhaustive deps here because it can cause an
@@ -48,18 +45,17 @@ export const useUser = (redirectOnError = false): UserContext => {
}
export const UserProvider: React.FC = (props) => {
- const router = useRouter()
+ const navigate = useNavigate()
+ const location = useLocation()
const { data, error, mutate } = useSWR("/api/v2/users/me")
const signOut = async () => {
await API.logout()
// Tell SWR to invalidate the cache for the user endpoint
await mutate("/api/v2/users/me")
- await router.push({
+ navigate({
pathname: "/login",
- query: {
- redirect: router.asPath,
- },
+ search: "?redirect=" + encodeURIComponent(location.pathname),
})
}
diff --git a/site/dev.ts b/site/dev.ts
deleted file mode 100644
index 19a39f58d5..0000000000
--- a/site/dev.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-import express from "express"
-import { createProxyMiddleware } from "http-proxy-middleware"
-import next from "next"
-
-const port = process.env.PORT || 8080
-const dev = process.env.NODE_ENV !== "production"
-
-let coderV2Host = "http://127.0.0.1:3000"
-
-if (process.env.CODERV2_HOST) {
- if (!/^http(s)?:\/\//.test(process.env.CODERV2_HOST)) {
- throw new Error("CODERV2_HOST must be http(s)")
- } else {
- coderV2Host = process.env.CODERV2_HOST
- }
-}
-
-console.log(`Using CODERV2_HOST: ${coderV2Host}`)
-
-const app = next({ dev, dir: "." })
-const handle = app.getRequestHandler()
-
-app
- .prepare()
- .then(() => {
- const server = express()
- server.use(
- "/api",
- createProxyMiddleware("/api", {
- target: coderV2Host,
- ws: true,
- secure: false,
- changeOrigin: true,
- }),
- )
- server.all("*", (req, res) => handle(req, res))
- server.listen(port)
- })
- .catch((err) => {
- console.error(err)
- process.exit(1)
- })
diff --git a/site/e2e/tests/login.spec.ts b/site/e2e/tests/login.spec.ts
index 02a51cd699..b751aecd9d 100644
--- a/site/e2e/tests/login.spec.ts
+++ b/site/e2e/tests/login.spec.ts
@@ -1,6 +1,7 @@
import { test } from "@playwright/test"
import { ProjectsPage, SignInPage } from "../pom"
import { email, password } from "../constants"
+import { waitForClientSideNavigation } from "./../util"
test("Login takes user to /projects", async ({ baseURL, page }) => {
await page.goto(baseURL + "/", { waitUntil: "networkidle" })
@@ -10,7 +11,7 @@ test("Login takes user to /projects", async ({ baseURL, page }) => {
await signInPage.submitBuiltInAuthentication(email, password)
const projectsPage = new ProjectsPage(baseURL, page)
- await page.waitForNavigation({ url: projectsPage.url, waitUntil: "networkidle" })
+ await waitForClientSideNavigation(page, { to: projectsPage.url })
await page.waitForSelector("text=Projects")
})
diff --git a/site/e2e/util.ts b/site/e2e/util.ts
new file mode 100644
index 0000000000..c51455db84
--- /dev/null
+++ b/site/e2e/util.ts
@@ -0,0 +1,82 @@
+import { Page } from "@playwright/test"
+
+/**
+ * `timeout(x)` is a helper function to create a promise that resolves after `x` milliseconds.
+ *
+ * @param timeoutInMilliseconds Time to wait for promise to resolve
+ * @returns `Promise`
+ */
+export const timeout = (timeoutInMilliseconds: number): Promise => {
+ return new Promise((resolve) => {
+ setTimeout(resolve, timeoutInMilliseconds)
+ })
+}
+
+/**
+ * `waitFor(f, timeout?)` waits for a predicate to return `true`, running it periodically until it returns `true`.
+ *
+ * If `f` never returns `true`, the function will simply return. In other words, the burden is on the consumer
+ * to check that the predicate is passing (`waitFor` does no validation).
+ *
+ * @param f A predicate that returns a `Promise`
+ * @param timeToWaitInMilliseconds The total time to wait for the condition to be `true`.
+ * @returns
+ */
+export const waitFor = async (f: () => Promise, timeToWaitInMilliseconds = 30000): Promise => {
+ let elapsedTime = 0
+ const timeToWaitPerIteration = 1000
+
+ while (elapsedTime < timeToWaitInMilliseconds) {
+ const condition = await f()
+
+ if (condition) {
+ return
+ }
+
+ await timeout(timeToWaitPerIteration)
+ elapsedTime += timeToWaitPerIteration
+ }
+}
+
+interface WaitForClientSideNavigationOpts {
+ /**
+ * from is the page before navigation (the 'current' page)
+ */
+ from?: string
+ /**
+ * to is the page after navigation (the 'next' page)
+ */
+ to?: string
+}
+
+/**
+ * waitForClientSideNavigation waits for the url to change from opts.from to
+ * opts.to (if specified), as well as a network idle load state. This enhances
+ * a native playwright check for navigation or loadstate.
+ *
+ * @remark This is necessary in a client-side SPA world since playwright
+ * waitForNavigation waits for load events on the DOM (ex: after a page load
+ * from the server).
+ *
+ * @todo Better logging for this.
+ */
+export const waitForClientSideNavigation = async (page: Page, opts: WaitForClientSideNavigationOpts): Promise => {
+ await Promise.all([
+ waitFor(() => {
+ const conditions: boolean[] = []
+
+ if (opts.from) {
+ conditions.push(page.url() !== opts.from)
+ }
+
+ if (opts.to) {
+ conditions.push(page.url() === opts.to)
+ }
+
+ const unmetConditions = conditions.filter((condition) => !condition)
+
+ return Promise.resolve(unmetConditions.length === 0)
+ }),
+ page.waitForLoadState("networkidle"),
+ ])
+}
diff --git a/site/embed.go b/site/embed.go
index 6d750b8855..98982234b3 100644
--- a/site/embed.go
+++ b/site/embed.go
@@ -4,57 +4,89 @@
package site
import (
+ "bytes"
"embed"
"fmt"
+ "io"
"io/fs"
"net/http"
+ "path"
+ "path/filepath"
"strings"
+ "text/template" // html/template escapes some nonces
+ "time"
"github.com/justinas/nosurf"
"github.com/unrolled/secure"
-
- "cdr.dev/slog"
-
- "github.com/coder/coder/site/nextrouter"
+ "golang.org/x/xerrors"
)
// The `embed` package ignores recursively including directories
// that prefix with `_`. Wildcarding nested is janky, but seems to
// work quite well for edge-cases.
-//go:embed out/_next/*/*/*/*
-//go:embed out/_next/*/*/*
-//go:embed out/bin/*
//go:embed out
+//go:embed out/bin/*
var site embed.FS
-// Handler returns an HTTP handler for serving the static site.
-func Handler(logger slog.Logger) http.Handler {
- filesystem, err := fs.Sub(site, "out")
+func DefaultHandler() http.Handler {
+ // the out directory is where webpack builds are created. It is in the same
+ // directory as this file (package site).
+ siteFS, err := fs.Sub(site, "out")
+
if err != nil {
// This can't happen... Go would throw a compilation error.
panic(err)
}
- // Render CSP and CSRF in the served pages
- templateFunc := func(r *http.Request) interface{} {
- return htmlState{
- // Nonce is the CSP nonce for the given request (if there is one present)
- CSP: cspState{Nonce: secure.CSPNonce(r.Context())},
- // Token is the CSRF token for the given request
- CSRF: csrfState{Token: nosurf.Token(r)},
- }
+ return Handler(siteFS)
+}
+
+// Handler returns an HTTP handler for serving the static site.
+func Handler(fileSystem fs.FS) http.Handler {
+ // html files are handled by a text/template. Non-html files
+ // are served by the default file server.
+ //
+ // REMARK: text/template is needed to inject values on each request like
+ // CSRF.
+ files, err := htmlFiles(fileSystem)
+
+ if err != nil {
+ panic(xerrors.Errorf("Failed to return handler for static files. Html files failed to load: %w", err))
}
- nextRouterHandler, err := nextrouter.Handler(filesystem, &nextrouter.Options{
- Logger: logger,
- TemplateDataFunc: templateFunc,
+ return secureHeaders(&handler{
+ fs: fileSystem,
+ htmlFiles: files,
+ h: http.FileServer(http.FS(fileSystem)), // All other non-html static files
})
- if err != nil {
- // There was an error setting up our file system handler.
- // This likely means a problem with our embedded file system.
- panic(err)
+}
+
+type handler struct {
+ fs fs.FS
+ // htmlFiles is the text/template for all *.html files.
+ // This is needed to support Content Security Policy headers.
+ // Due to material UI, we are forced to use a nonce to allow inline
+ // scripts, and that nonce is passed through a template.
+ // We only do this for html files to reduce the amount of in memory caching
+ // of duplicate files as `fs`.
+ htmlFiles *htmlTemplates
+ h http.Handler
+}
+
+// filePath returns the filepath of the requested file.
+func filePath(p string) string {
+ if !strings.HasPrefix(p, "/") {
+ p = "/" + p
}
- return secureHeaders(nextRouterHandler)
+ return strings.TrimPrefix(path.Clean(p), "/")
+}
+
+func (h *handler) exists(filePath string) bool {
+ f, err := h.fs.Open(filePath)
+ if err == nil {
+ _ = f.Close()
+ }
+ return err == nil
}
type htmlState struct {
@@ -70,30 +102,141 @@ type csrfState struct {
Token string
}
-// cspDirectives is a map of all csp fetch directives to their values.
+func ShouldCacheFile(reqFile string) bool {
+ // Images, favicons and uniquely content hashed bundle assets should be
+ // cached. By default, we cache everything in the site/out directory except
+ // for deny-listed items enumerated here. The reason for this approach is
+ // that cache invalidation techniques should be used by default for all
+ // webpack-processed assets. The scenarios where we don't use cache
+ // invalidation techniques are one-offs or things that should have
+ // invalidation in the future.
+ denyListedSuffixes := []string{
+ // ALL *.html files
+ ".html",
+
+ // ALL *worker.js files (including service-worker.js)
+ //
+ // REMARK(Grey): I'm unsure if there's a desired setting in Workbox for
+ // content hashing these, or if doing so is a risk for
+ // users that have a PWA installed.
+ "worker.js",
+ }
+
+ for _, suffix := range denyListedSuffixes {
+ if strings.HasSuffix(reqFile, suffix) {
+ return false
+ }
+ }
+
+ return true
+}
+
+func (h *handler) ServeHTTP(resp http.ResponseWriter, req *http.Request) {
+ // reqFile is the static file requested
+ reqFile := filePath(req.URL.Path)
+ state := htmlState{
+ // Token is the CSRF token for the given request
+ CSRF: csrfState{Token: nosurf.Token(req)},
+ }
+
+ // First check if it's a file we have in our templates
+ if h.serveHTML(resp, req, reqFile, state) {
+ return
+ }
+
+ // If the original file path exists we serve it.
+ if h.exists(reqFile) {
+ if ShouldCacheFile(reqFile) {
+ resp.Header().Add("Cache-Control", "public, max-age=31536000, immutable")
+ }
+ h.h.ServeHTTP(resp, req)
+ return
+ }
+
+ // Serve the file assuming it's an html file
+ // This matches paths like `/app/terminal.html`
+ req.URL.Path = strings.TrimSuffix(req.URL.Path, "/")
+ req.URL.Path += ".html"
+
+ reqFile = filePath(req.URL.Path)
+ // All html files should be served by the htmlFile templates
+ if h.serveHTML(resp, req, reqFile, state) {
+ return
+ }
+
+ // If we don't have the file... we should redirect to `/`
+ // for our single-page-app.
+ req.URL.Path = "/"
+ if h.serveHTML(resp, req, "", state) {
+ return
+ }
+
+ // This will send a correct 404
+ h.h.ServeHTTP(resp, req)
+}
+
+func (h *handler) serveHTML(resp http.ResponseWriter, request *http.Request, reqPath string, state htmlState) bool {
+ if data, err := h.htmlFiles.renderWithState(reqPath, state); err == nil {
+ if reqPath == "" {
+ // Pass "index.html" to the ServeContent so the ServeContent sets the right content headers.
+ reqPath = "index.html"
+ }
+ http.ServeContent(resp, request, reqPath, time.Time{}, bytes.NewReader(data))
+ return true
+ }
+ return false
+}
+
+type htmlTemplates struct {
+ tpls *template.Template
+}
+
+// renderWithState will render the file using the given nonce if the file exists
+// as a template. If it does not, it will return an error.
+func (t *htmlTemplates) renderWithState(filePath string, state htmlState) ([]byte, error) {
+ var buf bytes.Buffer
+ if filePath == "" {
+ filePath = "index.html"
+ }
+ err := t.tpls.ExecuteTemplate(&buf, filePath, state)
+ if err != nil {
+ return nil, err
+ }
+
+ return buf.Bytes(), nil
+}
+
+// CSPDirectives is a map of all csp fetch directives to their values.
// Each directive is a set of values that is joined by a space (' ').
// All directives are semi-colon separated as a single string for the csp header.
-type cspDirectives map[cspFetchDirective][]string
+type CSPDirectives map[CSPFetchDirective][]string
-// cspFetchDirective is the list of all constant fetch directives that
+func (s CSPDirectives) Append(d CSPFetchDirective, values ...string) {
+ if _, ok := s[d]; !ok {
+ s[d] = make([]string, 0)
+ }
+ s[d] = append(s[d], values...)
+}
+
+// CSPFetchDirective is the list of all constant fetch directives that
// can be used/appended to.
-type cspFetchDirective string
+type CSPFetchDirective string
const (
- cspDirectiveDefaultSrc = "default-src"
- cspDirectiveConnectSrc = "connect-src"
- cspDirectiveChildSrc = "child-src"
- cspDirectiveScriptSrc = "script-src"
- cspDirectiveFontSrc = "font-src"
- cspDirectiveStyleSrc = "style-src"
- cspDirectiveObjectSrc = "object-src"
- cspDirectiveManifestSrc = "manifest-src"
- cspDirectiveFrameSrc = "frame-src"
- cspDirectiveImgSrc = "img-src"
- cspDirectiveReportURI = "report-uri"
- cspDirectiveFormAction = "form-action"
- cspDirectiveMediaSrc = "media-src"
- cspFrameAncestors = "frame-ancestors"
+ CSPDirectiveDefaultSrc = "default-src"
+ CSPDirectiveConnectSrc = "connect-src"
+ CSPDirectiveChildSrc = "child-src"
+ CSPDirectiveScriptSrc = "script-src"
+ CSPDirectiveFontSrc = "font-src"
+ CSPDirectiveStyleSrc = "style-src"
+ CSPDirectiveObjectSrc = "object-src"
+ CSPDirectiveManifestSrc = "manifest-src"
+ CSPDirectiveFrameSrc = "frame-src"
+ CSPDirectiveImgSrc = "img-src"
+ CSPDirectiveReportURI = "report-uri"
+ CSPDirectiveFormAction = "form-action"
+ CSPDirectiveMediaSrc = "media-src"
+ CSPFrameAncestors = "frame-ancestors"
)
// secureHeaders is only needed for statically served files. We do not need this for api endpoints.
@@ -104,26 +247,26 @@ func secureHeaders(next http.Handler) http.Handler {
// If we ever want to render something like a PDF, we need to adjust "object-src"
//
// The list of CSP options: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/default-src
- cspSrcs := cspDirectives{
+ cspSrcs := CSPDirectives{
// All omitted fetch csp srcs default to this.
- cspDirectiveDefaultSrc: {"'self'"},
- cspDirectiveConnectSrc: {"'self' ws: wss:"},
- cspDirectiveChildSrc: {"'self'"},
- cspDirectiveScriptSrc: {"'self'"},
- cspDirectiveFontSrc: {"'self'"},
- cspDirectiveStyleSrc: {"'self' 'unsafe-inline'"},
+ CSPDirectiveDefaultSrc: {"'self'"},
+ CSPDirectiveConnectSrc: {"'self' ws: wss:"},
+ CSPDirectiveChildSrc: {"'self'"},
+ CSPDirectiveScriptSrc: {"'self'"},
+ CSPDirectiveFontSrc: {"'self'"},
+ CSPDirectiveStyleSrc: {"'self' 'unsafe-inline'"},
// object-src is needed to support code-server
- cspDirectiveObjectSrc: {"'self'"},
+ CSPDirectiveObjectSrc: {"'self'"},
// blob: for loading the pwa manifest for code-server
- cspDirectiveManifestSrc: {"'self' blob:"},
- cspDirectiveFrameSrc: {"'self'"},
+ CSPDirectiveManifestSrc: {"'self' blob:"},
+ CSPDirectiveFrameSrc: {"'self'"},
// data: for loading base64 encoded icons for generic applications.
- cspDirectiveImgSrc: {"'self' https://cdn.coder.com data:"},
- cspDirectiveFormAction: {"'self'"},
- cspDirectiveMediaSrc: {"'self'"},
+ CSPDirectiveImgSrc: {"'self' https://cdn.coder.com data:"},
+ CSPDirectiveFormAction: {"'self'"},
+ CSPDirectiveMediaSrc: {"'self'"},
// Report all violations back to the server to log
- cspDirectiveReportURI: {"/api/private/csp/reports"},
- cspFrameAncestors: {"'none'"},
+ CSPDirectiveReportURI: {"/api/private/csp/reports"},
+ CSPFrameAncestors: {"'none'"},
// Only scripts can manipulate the dom. This prevents someone from
// naming themselves something like ''.
@@ -171,3 +314,52 @@ func secureHeaders(next http.Handler) http.Handler {
ReferrerPolicy: "no-referrer",
}).Handler(next)
}
+
+// htmlFiles recursively walks the file system passed finding all *.html files.
+// The template returned has all html files parsed.
+func htmlFiles(files fs.FS) (*htmlTemplates, error) {
+ // root is the collection of html templates. All templates are named by their pathing.
+ // So './404.html' is named '404.html'. './subdir/index.html' is 'subdir/index.html'
+ root := template.New("")
+
+ rootPath := "."
+ err := fs.WalkDir(files, rootPath, func(filePath string, directory fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+
+ if directory.IsDir() {
+ return nil
+ }
+
+ if filepath.Ext(directory.Name()) != ".html" {
+ return nil
+ }
+
+ file, err := files.Open(filePath)
+ if err != nil {
+ return err
+ }
+
+ data, err := io.ReadAll(file)
+ if err != nil {
+ return err
+ }
+
+ tPath := strings.TrimPrefix(filePath, rootPath+string(filepath.Separator))
+ _, err = root.New(tPath).Parse(string(data))
+ if err != nil {
+ return err
+ }
+
+ return nil
+ })
+
+ if err != nil {
+ return nil, err
+ }
+
+ return &htmlTemplates{
+ tpls: root,
+ }, nil
+}
diff --git a/site/embed_slim.go b/site/embed_slim.go
index 7a91f163c2..7960769e94 100644
--- a/site/embed_slim.go
+++ b/site/embed_slim.go
@@ -5,10 +5,8 @@ package site
import (
"net/http"
-
- "cdr.dev/slog"
)
-func Handler(logger slog.Logger) http.Handler {
+func DefaultHandler() http.Handler {
return http.NotFoundHandler()
}
diff --git a/site/embed_test.go b/site/embed_test.go
index 8dee1ca925..2907da01e9 100644
--- a/site/embed_test.go
+++ b/site/embed_test.go
@@ -5,28 +5,170 @@ package site_test
import (
"context"
+ "fmt"
"io"
"net/http"
"net/http/httptest"
"testing"
+ "testing/fstest"
+ "time"
"github.com/stretchr/testify/require"
- "cdr.dev/slog"
-
"github.com/coder/coder/site"
)
-func TestIndexPageRenders(t *testing.T) {
+func TestCaching(t *testing.T) {
t.Parallel()
- srv := httptest.NewServer(site.Handler(slog.Logger{}))
+ // Create a test server
+ rootFS := fstest.MapFS{
+ "bundle.js": &fstest.MapFile{},
+ "image.png": &fstest.MapFile{},
+ "static/image.png": &fstest.MapFile{},
+ "favicon.ico": &fstest.MapFile{
+ Data: []byte("folderFile"),
+ },
- req, err := http.NewRequestWithContext(context.Background(), "GET", srv.URL, nil)
- require.NoError(t, err)
- resp, err := http.DefaultClient.Do(req)
- require.NoError(t, err, "get index")
- defer resp.Body.Close()
- data, _ := io.ReadAll(resp.Body)
- require.NotEmpty(t, data, "index should have contents")
+ "service-worker.js": &fstest.MapFile{},
+ "index.html": &fstest.MapFile{
+ Data: []byte("folderFile"),
+ },
+ "terminal.html": &fstest.MapFile{
+ Data: []byte("folderFile"),
+ },
+ }
+
+ srv := httptest.NewServer(site.Handler(rootFS))
+ defer srv.Close()
+
+ // Create a context
+ ctx, cancelFunc := context.WithTimeout(context.Background(), 1*time.Second)
+ defer cancelFunc()
+
+ testCases := []struct {
+ path string
+ isExpectingCache bool
+ }{
+ {"/bundle.js", true},
+ {"/image.png", true},
+ {"/static/image.png", true},
+ {"/favicon.ico", true},
+
+ {"/", false},
+ {"/service-worker.js", false},
+ {"/index.html", false},
+ {"/double/nested/terminal.html", false},
+ }
+
+ for _, testCase := range testCases {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+testCase.path, nil)
+ require.NoError(t, err, "create request")
+
+ res, err := srv.Client().Do(req)
+ require.NoError(t, err, "get index")
+
+ cache := res.Header.Get("Cache-Control")
+ if testCase.isExpectingCache {
+ require.Equalf(t, "public, max-age=31536000, immutable", cache, "expected %w file to have immutable cache", testCase.path)
+ } else {
+ require.Equalf(t, "", cache, "expected %w file to not have immutable cache header", testCase.path)
+ }
+
+ require.NoError(t, res.Body.Close(), "closing response")
+ }
+}
+
+func TestServingFiles(t *testing.T) {
+ t.Parallel()
+
+ // Create a test server
+ rootFS := fstest.MapFS{
+ "index.html": &fstest.MapFile{
+ Data: []byte("index-bytes"),
+ },
+ "favicon.ico": &fstest.MapFile{
+ Data: []byte("favicon-bytes"),
+ },
+ "dashboard.js": &fstest.MapFile{
+ Data: []byte("dashboard-js-bytes"),
+ },
+ "dashboard.css": &fstest.MapFile{
+ Data: []byte("dashboard-css-bytes"),
+ },
+ }
+
+ srv := httptest.NewServer(site.Handler(rootFS))
+ defer srv.Close()
+
+ // Create a context
+ ctx, cancelFunc := context.WithTimeout(context.Background(), 1*time.Second)
+ defer cancelFunc()
+
+ var testCases = []struct {
+ path string
+ expected string
+ }{
+ // Index cases
+ {"/", "index-bytes"},
+ {"/index.html", "index-bytes"},
+ {"/nested", "index-bytes"},
+ {"/nested/", "index-bytes"},
+ {"/nested/index.html", "index-bytes"},
+
+ // These are nested paths that should lead back to index. We don't
+ // allow nested JS or CSS files.
+ {"/double/nested", "index-bytes"},
+ {"/double/nested/", "index-bytes"},
+ {"/double/nested/index.html", "index-bytes"},
+ {"/nested/dashboard.js", "index-bytes"},
+ {"/nested/dashboard.css", "index-bytes"},
+ {"/double/nested/dashboard.js", "index-bytes"},
+ {"/double/nested/dashboard.css", "index-bytes"},
+
+ // Favicon cases
+ // The favicon is always root-referenced in index.html:
+ {"/favicon.ico", "favicon-bytes"},
+
+ // JS, CSS cases
+ {"/dashboard.js", "dashboard-js-bytes"},
+ {"/dashboard.css", "dashboard-css-bytes"},
+ }
+
+ for _, testCase := range testCases {
+ path := srv.URL + testCase.path
+
+ req, err := http.NewRequestWithContext(ctx, "GET", path, nil)
+ require.NoError(t, err)
+ resp, err := http.DefaultClient.Do(req)
+ require.NoError(t, err, "get file")
+ data, _ := io.ReadAll(resp.Body)
+ require.Equal(t, string(data), testCase.expected, "Verify file: "+testCase.path)
+ err = resp.Body.Close()
+ require.NoError(t, err)
+ }
+}
+
+func TestShouldCacheFile(t *testing.T) {
+ t.Parallel()
+
+ var testCases = []struct {
+ reqFile string
+ expected bool
+ }{
+ {"123456789.js", true},
+ {"apps/app/code/terminal.css", true},
+ {"image.png", true},
+ {"static/image.png", true},
+ {"static/images/section-a/image.jpeg", true},
+
+ {"service-worker.js", false},
+ {"dashboard.html", false},
+ {"apps/app/code/terminal.html", false},
+ }
+
+ for _, testCase := range testCases {
+ got := site.ShouldCacheFile(testCase.reqFile)
+ require.Equal(t, testCase.expected, got, fmt.Sprintf("Expected ShouldCacheFile(%s) to be %t", testCase.reqFile, testCase.expected))
+ }
}
diff --git a/site/html_templates/index.html b/site/html_templates/index.html
new file mode 100644
index 0000000000..ad38a815c0
--- /dev/null
+++ b/site/html_templates/index.html
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Coder
+
+
+
+
+
+
+
diff --git a/site/jest.config.js b/site/jest.config.js
index f22ea399ee..17e6e73fd1 100644
--- a/site/jest.config.js
+++ b/site/jest.config.js
@@ -11,7 +11,6 @@ module.exports = {
preset: "ts-jest",
roots: [""],
- setupFilesAfterEnv: ["/_jest/setupTests.ts"],
transform: {
"^.+\\.tsx?$": "ts-jest",
},
@@ -24,15 +23,7 @@ module.exports = {
displayName: "lint",
runner: "jest-runner-eslint",
testMatch: ["/**/*.js", "/**/*.ts", "/**/*.tsx"],
- testPathIgnorePatterns: [
- "/.next/",
- "/out/",
- "/_jest/",
- "dev.ts",
- "jest.config.js",
- "jest-runner.*.js",
- "next.config.js",
- ],
+ testPathIgnorePatterns: ["/out/", "/_jest/", "jest.config.js", "jest-runner.*.js"],
},
],
collectCoverageFrom: [
@@ -41,15 +32,12 @@ module.exports = {
"/**/*.tsx",
"!/**/*.stories.tsx",
"!/_jest/**/*.*",
- "!/.next/**/*.*",
"!/api.ts",
"!/coverage/**/*.*",
- "!/dev.ts",
"!/e2e/**/*.*",
"!/jest-runner.eslint.config.js",
"!/jest.config.js",
- "!/next-env.d.ts",
- "!/next.config.js",
+ "!/webpack.*.ts",
"!/out/**/*.*",
"!/storybook-static/**/*.*",
],
diff --git a/site/next-env.d.ts b/site/next-env.d.ts
deleted file mode 100644
index 4f11a03dc6..0000000000
--- a/site/next-env.d.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-///
-///
-
-// NOTE: This file should not be edited
-// see https://nextjs.org/docs/basic-features/typescript for more information.
diff --git a/site/next.config.js b/site/next.config.js
deleted file mode 100644
index 4c9546001c..0000000000
--- a/site/next.config.js
+++ /dev/null
@@ -1,25 +0,0 @@
-/* eslint-disable @typescript-eslint/no-unsafe-assignment */
-/* eslint-disable @typescript-eslint/no-unsafe-return */
-/* eslint-disable @typescript-eslint/no-unsafe-call */
-/* eslint-disable @typescript-eslint/no-unsafe-member-access */
-/* eslint-disable @typescript-eslint/no-var-requires */
-
-module.exports = {
- env: {},
- experimental: {
- // Allows us to import TS files from outside product/coder/site.
- externalDir: true,
- },
- webpack: (config, { isServer, webpack }) => {
- // Inject CODERD_HOST environment variable for clients
- if (!isServer) {
- config.plugins.push(
- new webpack.DefinePlugin({
- "process.env.CODERD_HOST": JSON.stringify(process.env.CODERD_HOST),
- }),
- )
- }
-
- return config
- },
-}
diff --git a/site/nextrouter/nextrouter.go b/site/nextrouter/nextrouter.go
deleted file mode 100644
index caa84c4065..0000000000
--- a/site/nextrouter/nextrouter.go
+++ /dev/null
@@ -1,260 +0,0 @@
-package nextrouter
-
-import (
- "bytes"
- "context"
- "html/template"
- "io/fs"
- "net/http"
- "path/filepath"
- "strings"
- "time"
-
- "github.com/go-chi/chi/v5"
-
- "cdr.dev/slog"
-)
-
-// Options for configuring a nextrouter
-type Options struct {
- Logger slog.Logger
- TemplateDataFunc HTMLTemplateHandler
-}
-
-// HTMLTemplateHandler is a function that lets the consumer of `nextrouter`
-// inject arbitrary template parameters, based on the request. This is useful
-// if the Request object is carrying CSRF tokens, session tokens, etc -
-// they can be emitted in the page.
-type HTMLTemplateHandler func(*http.Request) interface{}
-
-// Handler returns an HTTP handler for serving a next-based static site
-// This handler respects NextJS-based routing rules:
-// https://nextjs.org/docs/routing/dynamic-routes
-//
-// 1) If a file is of the form `[org]`, it's a dynamic route for a single-parameter
-// 2) If a file is of the form `[[...any]]`, it's a dynamic route for any parameters
-func Handler(fileSystem fs.FS, options *Options) (http.Handler, error) {
- if options == nil {
- options = &Options{
- Logger: slog.Logger{},
- TemplateDataFunc: nil,
- }
- }
- router := chi.NewRouter()
-
- // Build up a router that matches NextJS routing rules, for HTML files
- err := registerRoutes(router, fileSystem, *options)
- if err != nil {
- return nil, err
- }
-
- // Fallback to static file server for non-HTML files
- router.NotFound(FileHandler(fileSystem))
-
- // Finally, if there is a 404.html available, serve that
- err = register404(fileSystem, router, *options)
- if err != nil {
- // An error may be expected if a 404.html is not present
- options.Logger.Warn(context.Background(), "Unable to find 404.html", slog.Error(err))
- }
-
- return router, nil
-}
-
-// FileHandler serves static content, additionally adding immutable
-// cache-control headers for Next.js content
-func FileHandler(fileSystem fs.FS) func(writer http.ResponseWriter, request *http.Request) {
- // Non-HTML files don't have special routing rules, so we can just leverage
- // the built-in http.FileServer for it.
- fileHandler := http.FileServer(http.FS(fileSystem))
-
- return func(writer http.ResponseWriter, request *http.Request) {
- // From the Next.js documentation:
- //
- // "Caching improves response times and reduces the number
- // of requests to external services. Next.js automatically
- // adds caching headers to immutable assets served from
- // /_next/static including JavaScript, CSS, static images,
- // and other media."
- //
- // See: https://nextjs.org/docs/going-to-production
- if strings.HasPrefix(request.URL.Path, "/_next/static/") {
- writer.Header().Add("Cache-Control", "public, max-age=31536000, immutable")
- }
-
- fileHandler.ServeHTTP(writer, request)
- }
-}
-
-// registerRoutes recursively traverses the file-system, building routes
-// as appropriate for respecting NextJS dynamic rules.
-func registerRoutes(rtr chi.Router, fileSystem fs.FS, options Options) error {
- files, err := fs.ReadDir(fileSystem, ".")
- if err != nil {
- return err
- }
-
- // Loop through everything in the current directory...
- for _, file := range files {
- name := file.Name()
-
- // If we're working with a file - just serve it up
- if !file.IsDir() {
- serveFile(rtr, fileSystem, name, options)
- continue
- }
-
- // ...otherwise, if it's a directory, create a sub-route by
- // recursively calling `buildRouter`
- sub, err := fs.Sub(fileSystem, name)
- if err != nil {
- return err
- }
-
- // In the special case where the folder is dynamic,
- // like `[org]`, we can convert to a chi-style dynamic route
- // (which uses `{` instead of `[`)
- routeName := name
- if isDynamicRoute(name) {
- routeName = "{dynamic}"
- }
-
- options.Logger.Debug(context.Background(), "Registering route", slog.F("name", name), slog.F("routeName", routeName))
- rtr.Route("/"+routeName, func(r chi.Router) {
- err := registerRoutes(r, sub, options)
- if err != nil {
- options.Logger.Error(context.Background(), "Error registering route", slog.F("name", routeName), slog.Error(err))
- }
- })
- }
-
- return nil
-}
-
-// serveFile is responsible for serving up HTML files in our next router
-// It handles various special cases, like trailing-slashes or handling routes w/o the .html suffix.
-func serveFile(router chi.Router, fileSystem fs.FS, fileName string, options Options) {
- // We only handle .html files for now
- ext := filepath.Ext(fileName)
- if ext != ".html" {
- return
- }
-
- options.Logger.Debug(context.Background(), "Reading file", slog.F("fileName", fileName))
-
- data, err := fs.ReadFile(fileSystem, fileName)
- if err != nil {
- options.Logger.Error(context.Background(), "Unable to read file", slog.F("fileName", fileName))
- return
- }
-
- // Create a template from the data - we can inject custom parameters like CSRF here
- tpls, err := template.New(fileName).Parse(string(data))
- if err != nil {
- options.Logger.Error(context.Background(), "Unable to create template for file", slog.F("fileName", fileName))
- return
- }
-
- handler := func(writer http.ResponseWriter, request *http.Request) {
- var buf bytes.Buffer
-
- // See if there are any template parameters we need to inject!
- // Things like CSRF tokens, etc...
- //templateData := struct{}{}
- var templateData interface{}
- templateData = nil
- if options.TemplateDataFunc != nil {
- templateData = options.TemplateDataFunc(request)
- }
-
- options.Logger.Debug(context.Background(), "Applying template parameters", slog.F("fileName", fileName), slog.F("templateData", templateData))
- err := tpls.ExecuteTemplate(&buf, fileName, templateData)
-
- if err != nil {
- options.Logger.Error(request.Context(), "Error executing template", slog.F("template_parameters", templateData))
- http.Error(writer, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
- return
- }
-
- http.ServeContent(writer, request, fileName, time.Time{}, bytes.NewReader(buf.Bytes()))
- }
-
- fileNameWithoutExtension := removeFileExtension(fileName)
-
- // Handle the `[[...any]]` catch-all case
- if isCatchAllRoute(fileNameWithoutExtension) {
- options.Logger.Info(context.Background(), "Registering catch-all route", slog.F("fileName", fileName))
- router.NotFound(handler)
- return
- }
-
- // Handle the `[org]` dynamic route case
- if isDynamicRoute(fileNameWithoutExtension) {
- options.Logger.Debug(context.Background(), "Registering dynamic route", slog.F("fileName", fileName))
- router.Get("/{dynamic}", handler)
- return
- }
-
- // Handle the basic file cases
- // Directly accessing a file, ie `/providers.html`
- router.Get("/"+fileName, handler)
- // Accessing a file without an extension, ie `/providers`
- router.Get("/"+fileNameWithoutExtension, handler)
-
- // Special case: '/' should serve index.html
- if fileName == "index.html" {
- router.Get("/", handler)
- return
- }
-
- // Otherwise, handling the trailing slash case -
- // for examples, `providers.html` should serve `/providers/`
- router.Get("/"+fileNameWithoutExtension+"/", handler)
-}
-
-func register404(fileSystem fs.FS, router chi.Router, options Options) error {
- // Get the file contents
- fileBytes, err := fs.ReadFile(fileSystem, "404.html")
- if err != nil {
- // An error is expected if the file doesn't exist
- return err
- }
-
- router.NotFound(func(writer http.ResponseWriter, request *http.Request) {
- writer.WriteHeader(http.StatusNotFound)
- _, err = writer.Write(fileBytes)
- if err != nil {
- options.Logger.Error(request.Context(), "Unable to write bytes for 404")
- return
- }
- })
-
- return nil
-}
-
-// isDynamicRoute returns true if the file is a NextJS dynamic route, like `[orgs]`
-// Returns false if the file is not a dynamic route, or if it is a catch-all route (`[[...any]]`)
-// NOTE: The extension should be removed from the file name
-func isDynamicRoute(fileWithoutExtension string) bool {
- // Assuming ASCII encoding - `len` in go works on bytes
- byteLen := len(fileWithoutExtension)
- if byteLen < 2 {
- return false
- }
-
- return fileWithoutExtension[0] == '[' && fileWithoutExtension[1] != '[' && fileWithoutExtension[byteLen-1] == ']'
-}
-
-// isCatchAllRoute returns true if the file is a catch-all route, like `[[...any]]`
-// Return false otherwise
-// NOTE: The extension should be removed from the file name
-func isCatchAllRoute(fileWithoutExtension string) bool {
- ret := strings.HasPrefix(fileWithoutExtension, "[[.")
- return ret
-}
-
-// removeFileExtension removes the extension from a file
-// For example, removeFileExtension("index.html") would return "index"
-func removeFileExtension(fileName string) string {
- return strings.TrimSuffix(fileName, filepath.Ext(fileName))
-}
diff --git a/site/nextrouter/nextrouter_test.go b/site/nextrouter/nextrouter_test.go
deleted file mode 100644
index 2f7c9c14d9..0000000000
--- a/site/nextrouter/nextrouter_test.go
+++ /dev/null
@@ -1,476 +0,0 @@
-package nextrouter_test
-
-import (
- "context"
- "io"
- "net/http"
- "net/http/httptest"
- "testing"
- "testing/fstest"
- "time"
-
- "github.com/stretchr/testify/require"
-
- "cdr.dev/slog"
-
- "github.com/coder/coder/site/nextrouter"
-)
-
-func TestNextRouter(t *testing.T) {
- t.Parallel()
-
- t.Run("Serves file at root", func(t *testing.T) {
- t.Parallel()
-
- rootFS := fstest.MapFS{
- "test.html": &fstest.MapFile{
- Data: []byte("test123"),
- },
- }
-
- router, err := nextrouter.Handler(rootFS, nil)
- require.NoError(t, err)
-
- server := httptest.NewServer(router)
- t.Cleanup(server.Close)
-
- res, err := request(server, "/test.html")
- require.NoError(t, err)
-
- body, err := io.ReadAll(res.Body)
- require.NoError(t, err)
- require.NoError(t, res.Body.Close())
- require.EqualValues(t, "test123", body)
- require.Equal(t, http.StatusOK, res.StatusCode)
- })
-
- // This is a test case for the issue we hit in V1 w/ NextJS migration
- t.Run("Prefer file over folder w/ trailing slash", func(t *testing.T) {
- t.Parallel()
-
- rootFS := fstest.MapFS{
- "folder/test.html": &fstest.MapFile{},
- "folder.html": &fstest.MapFile{
- Data: []byte("folderFile"),
- },
- }
-
- router, err := nextrouter.Handler(rootFS, nil)
- require.NoError(t, err)
- server := httptest.NewServer(router)
- t.Cleanup(server.Close)
-
- res, err := request(server, "/folder/")
- require.NoError(t, err)
-
- body, err := io.ReadAll(res.Body)
- require.NoError(t, err)
- require.NoError(t, res.Body.Close())
- require.EqualValues(t, "folderFile", body)
- require.Equal(t, http.StatusOK, res.StatusCode)
- })
-
- t.Run("Serves non-html files at root", func(t *testing.T) {
- t.Parallel()
-
- rootFS := fstest.MapFS{
- "test.png": &fstest.MapFile{
- Data: []byte("png-bytes"),
- },
- }
-
- router, err := nextrouter.Handler(rootFS, nil)
- require.NoError(t, err)
-
- server := httptest.NewServer(router)
- t.Cleanup(server.Close)
-
- res, err := request(server, "/test.png")
- require.NoError(t, err)
-
- body, err := io.ReadAll(res.Body)
- require.NoError(t, err)
- require.NoError(t, res.Body.Close())
- require.Equal(t, "image/png", res.Header.Get("Content-Type"))
- require.EqualValues(t, "png-bytes", body)
- require.Equal(t, http.StatusOK, res.StatusCode)
- })
-
- t.Run("Serves html file without extension", func(t *testing.T) {
- t.Parallel()
-
- rootFS := fstest.MapFS{
- "test.html": &fstest.MapFile{
- Data: []byte("test-no-extension"),
- },
- }
-
- router, err := nextrouter.Handler(rootFS, nil)
- require.NoError(t, err)
-
- server := httptest.NewServer(router)
- t.Cleanup(server.Close)
-
- res, err := request(server, "/test")
- require.NoError(t, err)
-
- body, err := io.ReadAll(res.Body)
- require.NoError(t, err)
- require.NoError(t, res.Body.Close())
- require.EqualValues(t, "test-no-extension", body)
- require.Equal(t, http.StatusOK, res.StatusCode)
- })
-
- t.Run("Defaults to index.html at root", func(t *testing.T) {
- t.Parallel()
-
- rootFS := fstest.MapFS{
- "index.html": &fstest.MapFile{
- Data: []byte("test-root-index"),
- },
- }
-
- router, err := nextrouter.Handler(rootFS, nil)
- require.NoError(t, err)
-
- server := httptest.NewServer(router)
- t.Cleanup(server.Close)
-
- res, err := request(server, "/")
- require.NoError(t, err)
-
- body, err := io.ReadAll(res.Body)
- require.NoError(t, err)
- require.NoError(t, res.Body.Close())
- require.Equal(t, "text/html; charset=utf-8", res.Header.Get("Content-Type"))
- require.EqualValues(t, "test-root-index", body)
- require.Equal(t, http.StatusOK, res.StatusCode)
- })
-
- t.Run("Serves nested file", func(t *testing.T) {
- t.Parallel()
-
- rootFS := fstest.MapFS{
- "test/a/b/c.html": &fstest.MapFile{
- Data: []byte("test123"),
- },
- }
-
- router, err := nextrouter.Handler(rootFS, nil)
- require.NoError(t, err)
-
- server := httptest.NewServer(router)
- t.Cleanup(server.Close)
-
- res, err := request(server, "/test/a/b/c.html")
- require.NoError(t, err)
- require.NoError(t, res.Body.Close())
-
- res, err = request(server, "/test/a/b/c.html")
- require.NoError(t, err)
-
- body, err := io.ReadAll(res.Body)
- require.NoError(t, err)
- require.NoError(t, res.Body.Close())
- require.EqualValues(t, "test123", body)
- require.Equal(t, http.StatusOK, res.StatusCode)
- })
-
- t.Run("Uses index.html in nested path", func(t *testing.T) {
- t.Parallel()
-
- rootFS := fstest.MapFS{
- "test/a/b/c/index.html": &fstest.MapFile{
- Data: []byte("test-abc-index"),
- },
- }
-
- router, err := nextrouter.Handler(rootFS, nil)
- require.NoError(t, err)
-
- server := httptest.NewServer(router)
- t.Cleanup(server.Close)
-
- res, err := request(server, "/test/a/b/c")
- require.NoError(t, err)
-
- body, err := io.ReadAll(res.Body)
- require.NoError(t, err)
- require.NoError(t, res.Body.Close())
- require.EqualValues(t, "test-abc-index", body)
- require.Equal(t, http.StatusOK, res.StatusCode)
- })
-
- t.Run("404 if file at root is not found", func(t *testing.T) {
- t.Parallel()
-
- rootFS := fstest.MapFS{
- "test.html": &fstest.MapFile{
- Data: []byte("test123"),
- },
- }
-
- router, err := nextrouter.Handler(rootFS, nil)
- require.NoError(t, err)
-
- server := httptest.NewServer(router)
- t.Cleanup(server.Close)
-
- res, err := request(server, "/test-non-existent.html")
- require.NoError(t, err)
- require.NoError(t, res.Body.Close())
- require.Equal(t, http.StatusNotFound, res.StatusCode)
- })
-
- t.Run("404 if file at root is not found", func(t *testing.T) {
- t.Parallel()
-
- rootFS := fstest.MapFS{
- "test.html": &fstest.MapFile{
- Data: []byte("test123"),
- },
- }
-
- router, err := nextrouter.Handler(rootFS, nil)
- require.NoError(t, err)
-
- server := httptest.NewServer(router)
- t.Cleanup(server.Close)
-
- res, err := request(server, "/test-non-existent.html")
- require.NoError(t, err)
- require.NoError(t, res.Body.Close())
- require.Equal(t, http.StatusNotFound, res.StatusCode)
- })
-
- t.Run("Serve custom 404.html if available", func(t *testing.T) {
- t.Parallel()
-
- rootFS := fstest.MapFS{
- "404.html": &fstest.MapFile{
- Data: []byte("404 custom content"),
- },
- }
-
- router, err := nextrouter.Handler(rootFS, nil)
- require.NoError(t, err)
-
- server := httptest.NewServer(router)
- t.Cleanup(server.Close)
-
- res, err := request(server, "/test-non-existent.html")
- require.NoError(t, err)
-
- body, err := io.ReadAll(res.Body)
- require.NoError(t, err)
- require.NoError(t, res.Body.Close())
-
- require.Equal(t, http.StatusNotFound, res.StatusCode)
- require.EqualValues(t, "404 custom content", body)
- })
-
- t.Run("Serves dynamic-routed file", func(t *testing.T) {
- t.Parallel()
-
- rootFS := fstest.MapFS{
- "folder/[orgs].html": &fstest.MapFile{
- Data: []byte("test-dynamic-path"),
- },
- }
-
- router, err := nextrouter.Handler(rootFS, nil)
- require.NoError(t, err)
-
- server := httptest.NewServer(router)
- t.Cleanup(server.Close)
-
- res, err := request(server, "/folder/org-1")
- require.NoError(t, err)
-
- body, err := io.ReadAll(res.Body)
- require.NoError(t, err)
- require.NoError(t, res.Body.Close())
-
- require.Equal(t, http.StatusOK, res.StatusCode)
- require.EqualValues(t, "test-dynamic-path", body)
- })
-
- t.Run("Handles dynamic-routed folders", func(t *testing.T) {
- t.Parallel()
-
- rootFS := fstest.MapFS{
- "folder/[org]/[project]/create.html": &fstest.MapFile{
- Data: []byte("test-create"),
- },
- }
-
- router, err := nextrouter.Handler(rootFS, nil)
- require.NoError(t, err)
-
- server := httptest.NewServer(router)
- t.Cleanup(server.Close)
-
- res, err := request(server, "/folder/org-1/project-1/create")
- require.NoError(t, err)
-
- body, err := io.ReadAll(res.Body)
- require.NoError(t, err)
- require.NoError(t, res.Body.Close())
-
- require.Equal(t, http.StatusOK, res.StatusCode)
- require.EqualValues(t, "test-create", body)
- })
-
- t.Run("Handles catch-all routes", func(t *testing.T) {
- t.Parallel()
-
- rootFS := fstest.MapFS{
- "folder/[[...any]].html": &fstest.MapFile{
- Data: []byte("test-catch-all"),
- },
- }
-
- router, err := nextrouter.Handler(rootFS, nil)
- require.NoError(t, err)
-
- server := httptest.NewServer(router)
- t.Cleanup(server.Close)
-
- res, err := request(server, "/folder/org-1/project-1/random")
- require.NoError(t, err)
-
- body, err := io.ReadAll(res.Body)
- require.NoError(t, err)
- require.NoError(t, res.Body.Close())
-
- require.Equal(t, http.StatusOK, res.StatusCode)
- require.EqualValues(t, "test-catch-all", body)
- })
-
- t.Run("Static routes should be preferred to dynamic routes", func(t *testing.T) {
- t.Parallel()
-
- rootFS := fstest.MapFS{
- "folder/[orgs].html": &fstest.MapFile{
- Data: []byte("test-dynamic-path"),
- },
- "folder/create.html": &fstest.MapFile{
- Data: []byte("test-create"),
- },
- }
-
- router, err := nextrouter.Handler(rootFS, nil)
- require.NoError(t, err)
-
- server := httptest.NewServer(router)
- t.Cleanup(server.Close)
-
- res, err := request(server, "/folder/create")
- require.NoError(t, err)
-
- body, err := io.ReadAll(res.Body)
- require.NoError(t, err)
- require.NoError(t, res.Body.Close())
-
- require.Equal(t, http.StatusOK, res.StatusCode)
- require.EqualValues(t, "test-create", body)
- })
-
- t.Run("Caching headers for _next resources", func(t *testing.T) {
- t.Parallel()
-
- rootFS := fstest.MapFS{
- "index.html": &fstest.MapFile{
- Data: []byte("test-root"),
- },
- "_next/static/test.js": &fstest.MapFile{
- Data: []byte("test.js cached forever"),
- },
- "_next/static/chunks/app/test.css": &fstest.MapFile{
- Data: []byte("test.css cached forever"),
- },
- }
-
- router, err := nextrouter.Handler(rootFS, nil)
- require.NoError(t, err)
-
- server := httptest.NewServer(router)
- t.Cleanup(server.Close)
-
- res, err := request(server, "/index.html")
- require.NoError(t, err)
- require.NoError(t, res.Body.Close())
-
- require.Equal(t, http.StatusOK, res.StatusCode)
- require.Empty(t, res.Header.Get("Cache-Control"))
-
- res, err = request(server, "/_next/static/test.js")
- require.NoError(t, err)
- require.NoError(t, res.Body.Close())
-
- require.Equal(t, http.StatusOK, res.StatusCode)
- require.Equal(t, "public, max-age=31536000, immutable", res.Header.Get("Cache-Control"))
- })
-
- t.Run("Injects template parameters", func(t *testing.T) {
- t.Parallel()
-
- rootFS := fstest.MapFS{
- "test.html": &fstest.MapFile{
- Data: []byte("{{ .CSRF.Token }}"),
- },
- }
-
- type csrfState struct {
- Token string
- }
-
- type template struct {
- CSRF csrfState
- }
-
- // Add custom template function
- templateFunc := func(request *http.Request) interface{} {
- return template{
- CSRF: csrfState{
- Token: "hello-csrf",
- },
- }
- }
-
- router, err := nextrouter.Handler(rootFS, &nextrouter.Options{
- Logger: slog.Logger{},
- TemplateDataFunc: templateFunc,
- })
- require.NoError(t, err)
-
- server := httptest.NewServer(router)
- t.Cleanup(server.Close)
-
- res, err := request(server, "/test.html")
- require.NoError(t, err)
-
- body, err := io.ReadAll(res.Body)
- require.NoError(t, err)
- require.NoError(t, res.Body.Close())
-
- require.Equal(t, http.StatusOK, res.StatusCode)
- require.EqualValues(t, "hello-csrf", body)
- })
-}
-
-func request(server *httptest.Server, path string) (*http.Response, error) {
- ctx, cancelFunc := context.WithTimeout(context.Background(), 1*time.Second)
- defer cancelFunc()
-
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL+path, nil)
- if err != nil {
- return nil, err
- }
-
- res, err := server.Client().Do(req)
- if err != nil {
- return nil, err
- }
- return res, err
-}
diff --git a/site/out/_next/chunks/pages/app/GITKEEP b/site/out/_next/chunks/pages/app/GITKEEP
deleted file mode 100644
index e69de29bb2..0000000000
diff --git a/site/package.json b/site/package.json
index a9fb9e1899..22492fb50e 100644
--- a/site/package.json
+++ b/site/package.json
@@ -4,10 +4,9 @@
"repository": "https://github.com/coder/coder",
"private": true,
"scripts": {
- "build": "NODE_ENV=production next build",
- "build:dev": "next build",
- "dev": "ts-node dev.ts",
- "export": "next export",
+ "build": "NODE_ENV=production webpack build --config=webpack.prod.ts",
+ "build:analyze": "NODE_ENV=production webpack --profile --progress --json --config=webpack.prod.ts > out/stats.json && webpack-bundle-analyzer out/stats.json out",
+ "dev": "webpack-dev-server --config=webpack.dev.ts",
"format:check": "prettier --check '**/*.{css,html,js,json,jsx,md,ts,tsx,yaml,yml}'",
"format:write": "prettier --write '**/*.{css,html,js,json,jsx,md,ts,tsx,yaml,yml}' && sql-formatter -l postgresql ./database/query.sql -o ./database/query.sql",
"lint": "jest --selectProjects lint",
@@ -25,6 +24,7 @@
"@material-ui/icons": "4.5.1",
"@material-ui/lab": "4.0.0-alpha.42",
"@playwright/test": "1.19.2",
+ "@pmmmwh/react-refresh-webpack-plugin": "0.5.4",
"@react-theming/storybook-addon": "1.1.5",
"@storybook/addon-actions": "6.4.19",
"@storybook/addon-essentials": "6.4.19",
@@ -39,6 +39,7 @@
"@types/superagent": "4.1.15",
"@typescript-eslint/eslint-plugin": "4.33.0",
"@typescript-eslint/parser": "4.33.0",
+ "copy-webpack-plugin": "10.2.4",
"eslint": "7.32.0",
"eslint-config-prettier": "8.5.0",
"eslint-import-resolver-alias": "1.1.2",
@@ -52,24 +53,29 @@
"eslint-plugin-react-hooks": "4.3.0",
"express": "4.17.3",
"formik": "2.2.9",
+ "history": "5.3.0",
+ "html-webpack-plugin": "5.5.0",
"http-proxy-middleware": "2.0.3",
"jest": "27.5.1",
"jest-junit": "13.0.0",
"jest-runner-eslint": "1.0.0",
- "next": "12.1.0",
- "next-router-mock": "^0.6.5",
"prettier": "2.5.1",
"react": "17.0.2",
"react-dom": "17.0.2",
- "sql-formatter": "^4.0.2",
+ "react-hot-loader": "4.13.0",
+ "react-router-dom": "6.2.1",
+ "sql-formatter": "4.0.2",
"swr": "1.2.2",
"ts-jest": "27.1.3",
"ts-loader": "9.2.8",
"ts-node": "10.7.0",
"typescript": "4.6.2",
+ "webpack": "5.65.0",
+ "webpack-bundle-analyzer": "4.5.0",
+ "webpack-cli": "4.9.1",
+ "webpack-dev-server": "4.7.4",
"yup": "0.32.11"
},
- "dependencies": {},
"browserslist": [
"chrome 66",
"firefox 63",
diff --git a/site/pages/404.tsx b/site/pages/404.tsx
new file mode 100644
index 0000000000..facd13c1b2
--- /dev/null
+++ b/site/pages/404.tsx
@@ -0,0 +1,33 @@
+import { makeStyles } from "@material-ui/core/styles"
+import React from "react"
+
+import Typography from "@material-ui/core/Typography"
+
+export const NotFoundPage: React.FC = () => {
+ const styles = useStyles()
+
+ return (
+
+
+ 404
+
+
This page could not be found.
+
+ )
+}
+
+const useStyles = makeStyles((theme) => ({
+ root: {
+ width: "100vw",
+ height: "100vh",
+ display: "flex",
+ flexDirection: "row",
+ justifyContent: "center",
+ alignItems: "center",
+ },
+ headingContainer: {
+ margin: theme.spacing(1),
+ padding: theme.spacing(1),
+ borderRight: theme.palette.divider,
+ },
+}))
diff --git a/site/pages/_app.tsx b/site/pages/_app.tsx
deleted file mode 100644
index 6c69513919..0000000000
--- a/site/pages/_app.tsx
+++ /dev/null
@@ -1,48 +0,0 @@
-import React from "react"
-import CssBaseline from "@material-ui/core/CssBaseline"
-import ThemeProvider from "@material-ui/styles/ThemeProvider"
-import { SWRConfig } from "swr"
-import { AppProps } from "next/app"
-import { UserProvider } from "../contexts/UserContext"
-import { light } from "../theme"
-
-/**
- * ClientRender is a component that only allows its children to be rendered
- * client-side. This check is performed by querying the existence of the window
- * global.
- */
-const ClientRender: React.FC = ({ children }) => (
- {typeof window === "undefined" ? null : children}
-)
-
-/**
- * is the root rendering logic of the application - setting up our router
- * and any contexts / global state management.
- */
-const MyApp: React.FC = ({ Component, pageProps }) => {
- return (
-
- {
- const res = await fetch(url)
- if (!res.ok) {
- const err = new Error((await res.json()).error?.message || res.statusText)
- throw err
- }
- return res.json()
- },
- }}
- >
-
-
-
-
-
-
-
-
- )
-}
-
-export default MyApp
diff --git a/site/pages/_document.tsx b/site/pages/_document.tsx
deleted file mode 100644
index 12a08ff3f8..0000000000
--- a/site/pages/_document.tsx
+++ /dev/null
@@ -1,33 +0,0 @@
-import Document, { DocumentContext, Head, Html, Main, NextScript } from "next/document"
-import React from "react"
-
-class MyDocument extends Document {
- // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
- static async getInitialProps(ctx: DocumentContext) {
- const initialProps = await Document.getInitialProps(ctx)
- return { ...initialProps }
- }
-
- render(): JSX.Element {
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- )
- }
-}
-
-export default MyDocument
diff --git a/site/pages/cli-auth.tsx b/site/pages/cli-auth.tsx
index a31c682d93..d4a080affd 100644
--- a/site/pages/cli-auth.tsx
+++ b/site/pages/cli-auth.tsx
@@ -6,7 +6,7 @@ import { CliAuthToken } from "../components/SignIn"
import { FullScreenLoader } from "../components/Loader/FullScreenLoader"
import { useUser } from "../contexts/UserContext"
-const CliAuthenticationPage: React.FC = () => {
+export const CliAuthenticationPage: React.FC = () => {
const { me } = useUser(true)
const styles = useStyles()
@@ -40,5 +40,3 @@ const useStyles = makeStyles(() => ({
alignItems: "center",
},
}))
-
-export default CliAuthenticationPage
diff --git a/site/pages/index.tsx b/site/pages/index.tsx
index 905fef83e8..e6ae6e51ff 100644
--- a/site/pages/index.tsx
+++ b/site/pages/index.tsx
@@ -1,18 +1,16 @@
import React from "react"
-import { Redirect } from "../components"
+import { Navigate } from "react-router-dom"
import { FullScreenLoader } from "../components/Loader/FullScreenLoader"
import { useUser } from "../contexts/UserContext"
-const IndexPage: React.FC = () => {
+export const IndexPage: React.FC = () => {
const { me } = useUser(/* redirectOnError */ true)
if (me) {
// Once the user is logged in, just redirect them to /projects as the landing page
- return
+ return
}
return
}
-
-export default IndexPage
diff --git a/site/pages/login.tsx b/site/pages/login.tsx
index c8103fe811..eb404f04f5 100644
--- a/site/pages/login.tsx
+++ b/site/pages/login.tsx
@@ -26,5 +26,3 @@ export const SignInPage: React.FC = () => {
)
}
-
-export default SignInPage
diff --git a/site/pages/projects/[organization]/[project]/create.tsx b/site/pages/projects/[organization]/[project]/create.tsx
index 8baa5b0c29..545c9913c6 100644
--- a/site/pages/projects/[organization]/[project]/create.tsx
+++ b/site/pages/projects/[organization]/[project]/create.tsx
@@ -1,6 +1,6 @@
import React, { useCallback } from "react"
import { makeStyles } from "@material-ui/core/styles"
-import { useRouter } from "next/router"
+import { useNavigate, useParams } from "react-router-dom"
import useSWR from "swr"
import * as API from "../../../../api"
@@ -10,11 +10,11 @@ import { FullScreenLoader } from "../../../../components/Loader/FullScreenLoader
import { CreateWorkspaceForm } from "../../../../forms/CreateWorkspaceForm"
import { unsafeSWRArgument } from "../../../../util"
-const CreateWorkspacePage: React.FC = () => {
- const { push, query } = useRouter()
+export const CreateWorkspacePage: React.FC = () => {
+ const { organization: organizationName, project: projectName } = useParams()
+ const navigate = useNavigate()
const styles = useStyles()
const { me } = useUser(/* redirectOnError */ true)
- const { organization: organizationName, project: projectName } = query
const { data: organizationInfo, error: organizationError } = useSWR