From 264fa15fb2ba5f0b9636cda44b64deb3c56aa99d Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sun, 4 Aug 2024 14:50:30 +0900 Subject: feat(backend): serve /admin/* pages from api-server --- backend/admin/handlers.go | 240 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100644 backend/admin/handlers.go (limited to 'backend/admin/handlers.go') diff --git a/backend/admin/handlers.go b/backend/admin/handlers.go new file mode 100644 index 0000000..1c7995d --- /dev/null +++ b/backend/admin/handlers.go @@ -0,0 +1,240 @@ +package admin + +import ( + "errors" + "net/http" + "strconv" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + "github.com/labstack/echo/v4" + + "github.com/nsfisis/iosdc-japan-2024-albatross/backend/db" +) + +var jst = time.FixedZone("Asia/Tokyo", 9*60*60) + +type AdminHandler struct { + q *db.Queries + hubs GameHubsInterface +} + +type GameHubsInterface interface { + StartGame(gameID int) error +} + +func NewAdminHandler(q *db.Queries, hubs GameHubsInterface) *AdminHandler { + return &AdminHandler{ + q: q, + hubs: hubs, + } +} + +func (h *AdminHandler) RegisterHandlers(g *echo.Group) { + g.Use(newAssetsMiddleware()) + + g.GET("/dashboard", h.getDashboard) + g.GET("/users", h.getUsers) + g.GET("/users/:userID", h.getUserEdit) + g.GET("/games", h.getGames) + g.GET("/games/:gameID", h.getGameEdit) + g.POST("/games/:gameID", h.postGameEdit) +} + +func (h *AdminHandler) getDashboard(c echo.Context) error { + return c.Render(http.StatusOK, "dashboard", echo.Map{ + "Title": "Dashboard", + }) +} + +func (h *AdminHandler) getUsers(c echo.Context) error { + rows, err := h.q.ListUsers(c.Request().Context()) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + users := make([]echo.Map, len(rows)) + for i, u := range rows { + users[i] = echo.Map{ + "UserID": u.UserID, + "Username": u.Username, + "DisplayName": u.DisplayName, + "IconPath": u.IconPath, + "IsAdmin": u.IsAdmin, + } + } + + return c.Render(http.StatusOK, "users", echo.Map{ + "Title": "Users", + "Users": users, + }) +} + +func (h *AdminHandler) getUserEdit(c echo.Context) error { + userID, err := strconv.Atoi(c.Param("userID")) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Invalid user id") + } + row, err := h.q.GetUserByID(c.Request().Context(), int32(userID)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return echo.NewHTTPError(http.StatusNotFound) + } else { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + } + + return c.Render(http.StatusOK, "user_edit", echo.Map{ + "Title": "User Edit", + "User": echo.Map{ + "UserID": row.UserID, + "Username": row.Username, + "DisplayName": row.DisplayName, + "IconPath": row.IconPath, + "IsAdmin": row.IsAdmin, + }, + }) +} + +func (h *AdminHandler) getGames(c echo.Context) error { + rows, err := h.q.ListGames(c.Request().Context()) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + games := make([]echo.Map, len(rows)) + for i, g := range rows { + var startedAt string + if !g.StartedAt.Valid { + startedAt = g.StartedAt.Time.In(jst).Format("2006-01-02T15:04:05") + } + games[i] = echo.Map{ + "GameID": g.GameID, + "State": g.State, + "DisplayName": g.DisplayName, + "DurationSeconds": g.DurationSeconds, + "StartedAt": startedAt, + "ProblemID": g.ProblemID, + } + } + + return c.Render(http.StatusOK, "games", echo.Map{ + "Title": "Games", + "Games": games, + }) +} + +func (h *AdminHandler) getGameEdit(c echo.Context) error { + gameID, err := strconv.Atoi(c.Param("gameID")) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Invalid game id") + } + row, err := h.q.GetGameByID(c.Request().Context(), int32(gameID)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return echo.NewHTTPError(http.StatusNotFound) + } else { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + } + + var startedAt string + if !row.StartedAt.Valid { + startedAt = row.StartedAt.Time.In(jst).Format("2006-01-02T15:04:05") + } + + return c.Render(http.StatusOK, "game_edit", echo.Map{ + "Title": "Game Edit", + "Game": echo.Map{ + "GameID": row.GameID, + "State": row.State, + "DisplayName": row.DisplayName, + "DurationSeconds": row.DurationSeconds, + "StartedAt": startedAt, + "ProblemID": row.ProblemID, + }, + }) +} + +func (h *AdminHandler) postGameEdit(c echo.Context) error { + gameID, err := strconv.Atoi(c.Param("gameID")) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Invalid game id") + } + row, err := h.q.GetGameByID(c.Request().Context(), int32(gameID)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return echo.NewHTTPError(http.StatusNotFound) + } else { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + } + + state := c.FormValue("state") + displayName := c.FormValue("display_name") + durationSeconds, err := strconv.Atoi(c.FormValue("duration_seconds")) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Invalid duration_seconds") + } + var problemID *int + { + problemIDRaw := c.FormValue("problem_id") + if problemIDRaw != "" { + problemIDInt, err := strconv.Atoi(problemIDRaw) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Invalid problem_id") + } + problemID = &problemIDInt + } + } + var startedAt *time.Time + { + startedAtRaw := c.FormValue("started_at") + if startedAtRaw != "" { + startedAtTime, err := time.Parse("2006-01-02T15:04:05", startedAtRaw) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Invalid started_at") + } + startedAt = &startedAtTime + } + } + + var changedStartedAt pgtype.Timestamp + if startedAt == nil { + changedStartedAt = pgtype.Timestamp{ + Valid: false, + } + } else { + changedStartedAt = pgtype.Timestamp{ + Time: *startedAt, + Valid: true, + } + } + var changedProblemID *int32 + if problemID == nil { + changedProblemID = nil + } else { + changedProblemID = new(int32) + *changedProblemID = int32(*problemID) + } + + { + // TODO: + if state != row.State && state == "prepare" { + h.hubs.StartGame(int(gameID)) + } + } + + err = h.q.UpdateGame(c.Request().Context(), db.UpdateGameParams{ + GameID: int32(gameID), + State: state, + DisplayName: displayName, + DurationSeconds: int32(durationSeconds), + StartedAt: changedStartedAt, + ProblemID: changedProblemID, + }) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + + return c.String(http.StatusNoContent, "") +} -- cgit v1.2.3-70-g09d2 From d87507918f33b289ac4fc4dece8a54fa3aa34923 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sun, 4 Aug 2024 17:24:36 +0900 Subject: feat(backend): add /logout to /admin/dashboard --- backend/admin/handlers.go | 2 +- backend/admin/templates/dashboard.html | 3 +++ backend/main.go | 7 +++++++ 3 files changed, 11 insertions(+), 1 deletion(-) (limited to 'backend/admin/handlers.go') diff --git a/backend/admin/handlers.go b/backend/admin/handlers.go index 1c7995d..f81856c 100644 --- a/backend/admin/handlers.go +++ b/backend/admin/handlers.go @@ -236,5 +236,5 @@ func (h *AdminHandler) postGameEdit(c echo.Context) error { return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } - return c.String(http.StatusNoContent, "") + return c.NoContent(http.StatusNoContent) } diff --git a/backend/admin/templates/dashboard.html b/backend/admin/templates/dashboard.html index 2d7e8ad..cdb8ba1 100644 --- a/backend/admin/templates/dashboard.html +++ b/backend/admin/templates/dashboard.html @@ -7,4 +7,7 @@

Games

+
+ +
{{ end }} diff --git a/backend/main.go b/backend/main.go index 7330109..2d38ee5 100644 --- a/backend/main.go +++ b/backend/main.go @@ -83,6 +83,13 @@ func main() { adminGroup := e.Group("/admin") adminHandler.RegisterHandlers(adminGroup) + // For local dev: + // This is never used in production because the reverse proxy sends /logout + // to the app server. + e.POST("/logout", func(c echo.Context) error { + return c.Redirect(http.StatusPermanentRedirect, "http://localhost:5173/logout") + }) + gameHubs.Run() if err := e.Start(":80"); err != http.ErrServerClosed { -- cgit v1.2.3-70-g09d2 From 0f0324b396f3eab53606c8f770d26337dd0e291a Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sun, 4 Aug 2024 20:33:37 +0900 Subject: feat: authenticate users in admin pages --- backend/admin/handlers.go | 21 +++++++++++++++++++++ backend/main.go | 8 +++++--- frontend/app/.server/auth.ts | 35 +++++++++++++++++++++++++++++++---- frontend/app/.server/cookie.ts | 41 +++++++++++++++++++++++++++++++++++++++++ frontend/app/.server/session.ts | 16 ++++++++++------ frontend/package-lock.json | 1 + frontend/package.json | 1 + 7 files changed, 110 insertions(+), 13 deletions(-) create mode 100644 frontend/app/.server/cookie.ts (limited to 'backend/admin/handlers.go') diff --git a/backend/admin/handlers.go b/backend/admin/handlers.go index f81856c..14523e6 100644 --- a/backend/admin/handlers.go +++ b/backend/admin/handlers.go @@ -10,6 +10,7 @@ import ( "github.com/jackc/pgx/v5/pgtype" "github.com/labstack/echo/v4" + "github.com/nsfisis/iosdc-japan-2024-albatross/backend/auth" "github.com/nsfisis/iosdc-japan-2024-albatross/backend/db" ) @@ -31,8 +32,28 @@ func NewAdminHandler(q *db.Queries, hubs GameHubsInterface) *AdminHandler { } } +func newAdminMiddleware() echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + jwt, err := c.Cookie("albatross_token") + if err != nil { + return c.Redirect(http.StatusSeeOther, "/login") + } + claims, err := auth.ParseJWT(jwt.Value) + if err != nil { + return c.Redirect(http.StatusSeeOther, "/login") + } + if !claims.IsAdmin { + return echo.NewHTTPError(http.StatusForbidden) + } + return next(c) + } + } +} + func (h *AdminHandler) RegisterHandlers(g *echo.Group) { g.Use(newAssetsMiddleware()) + g.Use(newAdminMiddleware()) g.GET("/dashboard", h.getDashboard) g.GET("/users", h.getUsers) diff --git a/backend/main.go b/backend/main.go index 2d38ee5..e2e4bbd 100644 --- a/backend/main.go +++ b/backend/main.go @@ -83,9 +83,11 @@ func main() { adminGroup := e.Group("/admin") adminHandler.RegisterHandlers(adminGroup) - // For local dev: - // This is never used in production because the reverse proxy sends /logout - // to the app server. + // For local dev: This is never used in production because the reverse + // proxy sends /login and /logout to the app server. + e.GET("/login", func(c echo.Context) error { + return c.Redirect(http.StatusPermanentRedirect, "http://localhost:5173/login") + }) e.POST("/logout", func(c echo.Context) error { return c.Redirect(http.StatusPermanentRedirect, "http://localhost:5173/logout") }) diff --git a/frontend/app/.server/auth.ts b/frontend/app/.server/auth.ts index d5ffe0f..2c9d23c 100644 --- a/frontend/app/.server/auth.ts +++ b/frontend/app/.server/auth.ts @@ -1,10 +1,12 @@ +import { redirect } from "@remix-run/node"; import type { Session } from "@remix-run/server-runtime"; import { jwtDecode } from "jwt-decode"; import { Authenticator } from "remix-auth"; import { FormStrategy } from "remix-auth-form"; import { apiPostLogin } from "./api/client"; import { components } from "./api/schema"; -import { sessionStorage } from "./session"; +import { createUnstructuredCookie } from "./cookie"; +import { cookieOptions, sessionStorage } from "./session"; const authenticator = new Authenticator(sessionStorage); @@ -19,15 +21,40 @@ authenticator.use( export type User = components["schemas"]["User"]; +// This cookie is used to directly store the JWT for the API server. +// Remix's createCookie() returns "structured" cookies, which cannot be reused directly by non-Remix servers. +const tokenCookie = createUnstructuredCookie("albatross_token", cookieOptions); + export async function login(request: Request): Promise { - return await authenticator.authenticate("default", request, { - successRedirect: "/dashboard", + const jwt = await authenticator.authenticate("default", request, { failureRedirect: "/login", }); + + const session = await sessionStorage.getSession( + request.headers.get("cookie"), + ); + session.set(authenticator.sessionKey, jwt); + + throw redirect("/dashboard", { + headers: [ + ["Set-Cookie", await sessionStorage.commitSession(session)], + ["Set-Cookie", await tokenCookie.serialize(jwt)], + ], + }); } export async function logout(request: Request | Session): Promise { - return await authenticator.logout(request, { redirectTo: "/" }); + try { + return await authenticator.logout(request, { redirectTo: "/" }); + } catch (response) { + if (response instanceof Response) { + response.headers.append( + "Set-Cookie", + await tokenCookie.serialize("", { maxAge: 0, expires: new Date(0) }), + ); + } + throw response; + } } export async function ensureUserLoggedIn( diff --git a/frontend/app/.server/cookie.ts b/frontend/app/.server/cookie.ts new file mode 100644 index 0000000..cccbe78 --- /dev/null +++ b/frontend/app/.server/cookie.ts @@ -0,0 +1,41 @@ +import { Cookie, CookieOptions } from "@remix-run/server-runtime"; +import { parse, serialize } from "cookie"; + +// Remix's createCookie() returns "structured" cookies, which are cookies that hold a JSON-encoded object. +// This is not suitable for interoperation with other systems that expect a simple string value. +// This function creates an "unstructured" cookie, a simple plain text. +export function createUnstructuredCookie( + name: string, + cookieOptions?: CookieOptions, +): Cookie { + const { secrets = [], ...options } = { + path: "/", + sameSite: "lax" as const, + ...cookieOptions, + }; + + return { + get name() { + return name; + }, + get isSigned() { + return secrets.length > 0; + }, + get expires() { + return typeof options.maxAge !== "undefined" + ? new Date(Date.now() + options.maxAge * 1000) + : options.expires; + }, + async parse(cookieHeader, parseOptions) { + if (!cookieHeader) return null; + const cookies = parse(cookieHeader, { ...options, ...parseOptions }); + return name in cookies ? cookies[name] : null; + }, + async serialize(value, serializeOptions) { + return serialize(name, value, { + ...options, + ...serializeOptions, + }); + }, + }; +} diff --git a/frontend/app/.server/session.ts b/frontend/app/.server/session.ts index 79810f4..102bcd2 100644 --- a/frontend/app/.server/session.ts +++ b/frontend/app/.server/session.ts @@ -1,13 +1,17 @@ import { createCookieSessionStorage } from "@remix-run/node"; +export const cookieOptions = { + sameSite: "lax" as const, + path: "/", + httpOnly: true, + // secure: process.env.NODE_ENV === "production", + secure: false, // TODO + secrets: ["TODO"], +}; + export const sessionStorage = createCookieSessionStorage({ cookie: { name: "albatross_session", - sameSite: "lax", - path: "/", - httpOnly: true, - secrets: ["TODO"], - // secure: process.env.NODE_ENV === "production", - secure: false, // TODO + ...cookieOptions, }, }); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a49235c..0e6f7bf 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,7 @@ "@remix-run/node": "^2.10.3", "@remix-run/react": "^2.10.3", "@remix-run/serve": "^2.10.3", + "cookie": "^0.6.0", "isbot": "^5.1.13", "jwt-decode": "^4.0.0", "openapi-fetch": "^0.10.2", diff --git a/frontend/package.json b/frontend/package.json index 30d385a..e4eefac 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,6 +17,7 @@ "@remix-run/node": "^2.10.3", "@remix-run/react": "^2.10.3", "@remix-run/serve": "^2.10.3", + "cookie": "^0.6.0", "isbot": "^5.1.13", "jwt-decode": "^4.0.0", "openapi-fetch": "^0.10.2", -- cgit v1.2.3-70-g09d2