package main import ( "errors" "fmt" "io" "net/http" "os" "path" "regexp" "strconv" "syscall" "time" "github.com/google/uuid" "github.com/gorilla/websocket" "github.com/joho/godotenv" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" "github.com/labstack/gommon/log" ) const TimeFormat = "2006-01-02 15:04:05" const DateFormat = "2006-01-02" var LogRegex = regexp.MustCompile(`(?m)^.*/\d{4}-\d{2}-\d{2}_\d+\.txt$`) type Client struct { ID uuid.UUID `json:"id"` Name string `json:"name"` Connected bool `json:"connected"` ConnectedAt time.Time `json:"connectedAt"` DisconnectedAt time.Time `json:"disconnectedAt"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` ExitWanted bool `json:"exitWanted"` conn *websocket.Conn `json:"-"` } var clients = []*Client{} var dataDir = "." var token = "" var upgrader = websocket.Upgrader{} type IDParam struct { ID string `param:"id"` } func authed(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { if c.Request().Header.Get("Authorization") != token { return c.NoContent(http.StatusUnauthorized) } return next(c) } } func idparam(next func(echo.Context, uuid.UUID) error) echo.HandlerFunc { return func(c echo.Context) error { var data IDParam err := c.Bind(&data) if err != nil || data.ID == "" { return c.JSON(http.StatusBadRequest, echo.Map{"message": "missing field 'id'"}) } id, err := uuid.Parse(data.ID) if err != nil { return c.JSON(http.StatusBadRequest, echo.Map{"message": "field 'id' is not a uuid"}) } return next(c, id) } } func filename(dataDir string, id string, date string) string { return fmt.Sprintf("%s/%s_%d.txt", dataDir, date, id) } func main() { _ = godotenv.Load() dataDir = os.Getenv("DATA_DIR") if dataDir == "" { log.Fatal("DATA_DIR is not defined") } token = os.Getenv("TOKEN") if token == "" { log.Fatal("TOKEN is not defined") } f, err := os.OpenFile(path.Join(dataDir, "startup"), syscall.O_CREAT|syscall.O_APPEND|syscall.O_WRONLY, 0644) if err != nil { log.Fatalf("DATA_DIR %s is not writable (%v)\n", dataDir, err) } _, err = f.Write([]byte(fmt.Sprintf("%s\n", time.Now().Format(TimeFormat)))) if err != nil { log.Fatalf("DATA_DIR test file couldn't be written to (%v)\n", err) } err = f.Close() if err != nil { log.Fatalf("DATA_DIR test file couldn't be closed (%v)\n", err) } app := echo.New() app.Logger.SetLevel(log.INFO) app.Use(middleware.RecoverWithConfig(middleware.RecoverConfig{ StackSize: 1 << 10, LogLevel: log.ERROR, })) app.Use(middleware.Secure()) app.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{ Format: "${time_custom} ${method} ${uri} ---> ${status} in ${latency_human} (${bytes_out} bytes)\n", CustomTimeFormat: TimeFormat, })) app.Use(middleware.RemoveTrailingSlash()) app.OnAddRouteHandler = func(host string, route echo.Route, handler echo.HandlerFunc, middleware []echo.MiddlewareFunc) { now := time.Now() fmt.Printf("%s registered %-6s %s\n", now.Format(TimeFormat), route.Method, route.Path) } app.HTTPErrorHandler = func(err error, c echo.Context) { if c.Response().Committed { log.Error(err) return } code := http.StatusInternalServerError var response any = err.Error() var httpErr *echo.HTTPError if errors.As(err, &httpErr) { code = httpErr.Code response = httpErr } if code >= 500 { log.Error(err) } err2 := c.JSON(code, response) if err2 != nil { log.Error(err2) } } indent := "\t" // client connection app.GET("/", func(c echo.Context) error { return c.JSONPretty(http.StatusOK, "Hello World!", indent) }) app.GET("/keys", keys) // administration app.GET("/admin/clients", authed(func(c echo.Context) error { return c.JSONPretty(http.StatusOK, clients, indent) })) app.GET("/admin/logs", authed(func(c echo.Context) error { files, err := os.ReadDir(dataDir) if err != nil { return c.NoContent(http.StatusInternalServerError) } filenames := []string{} for _, file := range files { if LogRegex.MatchString(file.Name()) { filenames = append(filenames, file.Name()) } } return c.JSONPretty(http.StatusOK, filenames, indent) })) app.GET("/admin/:id", authed(idparam(func(c echo.Context, id uuid.UUID) error { for _, client := range clients { if client.ID == id { return c.JSONPretty(http.StatusOK, client, indent) } } return c.NoContent(http.StatusNotFound) }))) app.GET("/admin/:id/logs", authed(idparam(func(c echo.Context, id uuid.UUID) error { date := c.Param("date") if date == "" { date = time.Now().Format(DateFormat) } skipRaw := c.Param("skip") skip := 0 if skipRaw != "" { skip, err = strconv.Atoi(skipRaw) if err != nil { return c.JSON(http.StatusBadRequest, echo.Map{"message": "skip is not a number"}) } } takeRaw := c.Param("take") take := 1024 if takeRaw != "" { take, err = strconv.Atoi(takeRaw) if err != nil { return c.JSON(http.StatusBadRequest, echo.Map{"message": "take is not a number"}) } } f, err := os.OpenFile(filename(dataDir, id.String(), date), syscall.O_CREAT|syscall.O_APPEND|syscall.O_WRONLY, 0644) if err != nil { return c.JSON(http.StatusNotFound, echo.Map{"message": "file not found"}) } if skip != 0 { _, err = f.Seek(int64(skip), io.SeekStart) if err != nil { return c.NoContent(http.StatusInternalServerError) } } bytes := make([]byte, take) _, err = f.Read(bytes) if err != nil { return c.NoContent(http.StatusInternalServerError) } return c.JSONPretty(http.StatusOK, bytes, indent) }))) app.POST("/admin/:id/name", authed(idparam(func(c echo.Context, id uuid.UUID) error { name := c.Param("name") if name == "" { return c.JSON(http.StatusBadRequest, "missing field 'name'") } for _, client := range clients { if client.ID == id { client.Name = name client.UpdatedAt = time.Now() return c.JSON(http.StatusOK, echo.Map{"message": "ok"}) } } return c.JSON(http.StatusNotFound, echo.Map{"message": "not found"}) }))) app.POST("/admin/:id/exit", authed(idparam(func(c echo.Context, id uuid.UUID) error { for _, client := range clients { if client.ID == id { client.ExitWanted = true return c.JSON(http.StatusOK, echo.Map{"message": "ok"}) } } return c.JSON(http.StatusNotFound, echo.Map{"message": "not found"}) }))) log.Fatal(app.Start(":8080")) } func keys(c echo.Context) error { ws, err := upgrader.Upgrade(c.Response(), c.Request(), nil) if err != nil { return err } defer ws.Close() now := time.Now() client := Client{ ID: uuid.New(), Name: "Dummy", Connected: true, ConnectedAt: now, DisconnectedAt: now, CreatedAt: now, UpdatedAt: now, ExitWanted: false, conn: ws, } clients = append(clients, &client) fmt.Printf("%s client %s connected\n", time.Now().Format(TimeFormat), client.ID.String()) err = ws.WriteMessage(websocket.TextMessage, []byte("welcome")) if err != nil { log.Error(err) } f, err := os.OpenFile(filename(dataDir, client.ID.String(), now.Format(DateFormat)), syscall.O_CREAT|syscall.O_APPEND|syscall.O_WRONLY, 0644) if err != nil { log.Error(err) fmt.Printf("%s client %s crashed (couldn't open file)\n", time.Now().Format(TimeFormat), client.ID) now = time.Now() client.ExitWanted = true client.Connected = false client.DisconnectedAt = now client.UpdatedAt = now return c.NoContent(http.StatusInternalServerError) } defer f.Close() close := func() { _ = ws.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, "")) _ = ws.Close() now = time.Now() client.ExitWanted = true client.Connected = false client.DisconnectedAt = now client.UpdatedAt = now } for { if client.ExitWanted { close() fmt.Printf("%s client %s disconnected\n", time.Now().Format(TimeFormat), client.ID.String()) break } _, msg, err := ws.ReadMessage() if err != nil { log.Error(err) close() fmt.Printf("%s client %s crashed (websocket error)\n", time.Now().Format(TimeFormat), client.ID.String()) return c.NoContent(http.StatusInternalServerError) } _, err = f.Write(msg) if err != nil { log.Error(err) close() fmt.Printf("%s client %s crashed (file write error)\n", time.Now().Format(TimeFormat), client.ID.String()) return c.NoContent(http.StatusInternalServerError) } } return c.NoContent(http.StatusOK) }