Files
PLG-MuDiCS/display/web/main.go
T

439 lines
13 KiB
Go

package web
import (
"bytes"
"context"
"errors"
"fmt"
"image/color"
"io"
"log/slog"
"net"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
shared "plg-mudics/shared"
"strings"
"github.com/gabriel-vasile/mimetype"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
"github.com/skip2/go-qrcode"
"plg-mudics/display/pkg"
)
var version string
var sseConnection chan string
func StartWebServer(v string, port string) {
version = v
e := echo.New()
e.GET("/", indexRoute)
e.GET("/sse", sseRoute)
e.GET("/splash", func(ctx echo.Context) error {
html := shared.SplashScreenTemplate
html = strings.ReplaceAll(html, "%%APP-VERSION%%", version)
return ctx.HTML(http.StatusOK, html)
})
e.GET("/qr", qrRoute)
staticGroup := e.Group("/static")
staticGroup.Use(middleware.StaticWithConfig(middleware.StaticConfig{
Filesystem: http.FS(StaticDirFS),
HTML5: true,
}))
apiGroup := e.Group("/api")
apiGroup.Use(middleware.CORS())
apiGroup.GET("/ping", pingRoute)
apiGroup.PATCH("/shellCommand", shellCommandRoute)
apiGroup.PATCH("/keyboardInput", keyboardInputRoute)
apiGroup.PATCH("/showHTML", showHTMLRoute)
apiGroup.PATCH("/takeScreenshot", takeScreenshotRoute)
fileGroup := apiGroup.Group("/file")
fileGroup.Use(extractFilePathMiddleware)
fileGroup.POST("/:path", uploadFileRoute)
fileGroup.GET("/:path", downloadFileRoute)
fileGroup.PATCH("/:path", openFileRoute)
fileGroup.GET("/preview/:path", previewRoute)
err := e.Start(":" + port)
if err != nil {
slog.Error("Failed to start server", "error", err)
}
}
func indexRoute(ctx echo.Context) error {
return indexTemplate().Render(ctx.Request().Context(), ctx.Response().Writer)
}
func sseRoute(ctx echo.Context) error {
slog.Info("SSE client connected")
w := ctx.Response()
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.WriteHeader(http.StatusOK)
flusher, _ := w.Writer.(http.Flusher)
sseConnection = make(chan string)
// init display
ip, err := pkg.GetDeviceIp()
if err != nil {
slog.Error("Failed to get device IP address", "error", err)
}
mac, err := pkg.GetDeviceMac()
if err != nil {
slog.Error("Failed to get device MAC address", "error", err)
}
showQR := !isPortFree(8080)
var status bytes.Buffer
deviceInfoTemplate(ip, mac, showQR).Render(context.Background(), &status)
connectedEvent := Event{
Data: status.Bytes(),
}
connectedEvent.MarshalTo(w)
flusher.Flush()
for {
select {
case <-ctx.Request().Context().Done():
slog.Info("SSE client disconnected")
sseConnection = nil
return nil
case event := <-sseConnection:
rawEvent := Event{
Event: []byte(""),
Data: []byte(event),
}
if err := rawEvent.MarshalTo(w); err != nil {
slog.Warn("Error writing to client", "error", err)
return err
}
flusher.Flush()
}
}
}
func qrRoute(c echo.Context) error {
data := c.QueryParam("data")
if data == "" {
return c.String(http.StatusBadRequest, "missing data")
}
qr, err := qrcode.New(data, qrcode.Medium)
if err != nil {
return c.String(http.StatusInternalServerError, "could not generate qr")
}
qr.DisableBorder = true
qr.ForegroundColor = color.RGBA{R: 0x1c, G: 0x19, B: 0x17, A: 0xff}
qr.BackgroundColor = color.RGBA{R: 0xe7, G: 0xe5, B: 0xe4, A: 0xff}
png, err := qr.PNG(-1)
if err != nil {
return c.String(http.StatusInternalServerError, "could not encode png")
}
return c.Blob(http.StatusOK, "image/png", png)
}
func extractFilePathMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
return func(ctx echo.Context) error {
raw := ctx.Param("path")
decoded, err := url.PathUnescape(raw)
if err != nil {
slog.Warn("Invalid path encoding", "path", raw, "error", err)
return ctx.JSON(http.StatusBadRequest, shared.ErrorResponse{Description: "Invalid file path"})
}
fullPath, exists, err := pkg.ResolveStorageFilePath(decoded)
if err != nil {
slog.Warn("Failed to validate file path", "path", decoded, "error", err)
return ctx.JSON(http.StatusBadRequest, shared.ErrorResponse{Description: "Invalid file path"})
}
ctx.Set("fullPath", fullPath)
ctx.Set("fileExists", exists)
return next(ctx)
}
}
func shellCommandRoute(ctx echo.Context) error {
var commandInput struct {
Command string `json:"command"`
}
if err := ctx.Bind(&commandInput); err != nil {
slog.Error("Failed to parse shell command", "error", err)
return ctx.JSON(http.StatusBadRequest, shared.ErrorResponse{Description: shared.BadRequestDescription})
}
cmd := exec.Command("bash", "-c", commandInput.Command)
storagePath, err := pkg.GetStoragePath()
if err != nil {
slog.Error("Failed to get storage path", "error", err)
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to get storage path"})
}
cmd.Dir = storagePath
commandOutput := shared.RunShellCommand(cmd)
if commandOutput.ExitCode != 0 {
slog.Error("Shell command execution error", "error", commandOutput.Stderr)
}
slog.Info("Shell command executed successfully", "command", commandInput.Command, "exitCode", commandOutput.ExitCode)
return ctx.JSON(http.StatusOK, commandOutput)
}
func keyboardInputRoute(ctx echo.Context) error {
var request struct {
Inputs []struct {
Key string `json:"key"`
Action string `json:"action"`
} `json:"inputs"`
}
if err := ctx.Bind(&request); err != nil {
slog.Error("Failed to parse keyboard input", "error", err)
return ctx.JSON(http.StatusBadRequest, shared.ErrorResponse{Description: shared.BadRequestDescription})
}
var inputs []pkg.Input
for _, input := range request.Inputs {
if input.Action != "press" && input.Action != "release" {
slog.Error("Invalid keyboard action", "action", input.Action)
return ctx.JSON(http.StatusBadRequest, shared.ErrorResponse{Description: fmt.Sprintf("Invalid action: %s", input.Action)})
}
var action pkg.KeyAction
if input.Action == "press" {
action = pkg.KeyPress
}
if input.Action == "release" {
action = pkg.KeyRelease
}
inputs = append(inputs, pkg.Input{
Key: input.Key,
Action: action,
})
}
err := pkg.KeyboardInput(inputs)
if err != nil {
slog.Error("Failed to send keyboard input", "inputs", inputs, "error", err)
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to send keyboard input"})
}
slog.Info("Keyboard input sent")
return ctx.JSON(http.StatusOK, struct{}{})
}
func uploadFileRoute(ctx echo.Context) error {
var err error
fullPath := ctx.Get("fullPath").(string)
// Ensure parent directories exist
if err := os.MkdirAll(filepath.Dir(fullPath), os.ModePerm); err != nil {
slog.Error("Failed to create storage path", "error", err, "path", fullPath)
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to prepare storage directory"})
}
if ctx.Get("fileExists").(bool) {
return ctx.JSON(http.StatusConflict, shared.ErrorResponse{Description: "File already exists"})
}
file, err := os.Create(fullPath)
if err != nil {
slog.Error("Failed to create file", "file", fullPath, "error", err)
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to create file"})
}
defer func() {
fileCloseErr := file.Close()
if fileCloseErr != nil {
slog.Error("Failed to close file", "file", fullPath, "error", fileCloseErr)
}
if err != nil {
os.Remove(fullPath)
}
}()
_, err = io.Copy(file, ctx.Request().Body)
if err != nil {
slog.Error("Failed to write file", "file", fullPath, "error", err)
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to write file"})
}
err = file.Sync() // ensure data is flushed to disk
if err != nil {
slog.Error("Failed to sync file to disk", "file", fullPath, "error", err)
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to save file"})
}
slog.Info("File uploaded successfully", "path", fullPath)
return ctx.JSON(http.StatusOK, struct{}{})
}
func downloadFileRoute(ctx echo.Context) error {
fullPath := ctx.Get("fullPath").(string)
if !ctx.Get("fileExists").(bool) {
return ctx.JSON(http.StatusNotFound, shared.ErrorResponse{Description: "File not found"})
}
slog.Info("Serving file for download", "path", fullPath)
return ctx.File(fullPath)
}
func openFileRoute(ctx echo.Context) error {
var err error
pathParam := ctx.Param("path")
fullPath := ctx.Get("fullPath").(string)
if !ctx.Get("fileExists").(bool) {
return ctx.JSON(http.StatusNotFound, shared.ErrorResponse{Description: "File not found"})
}
if sseConnection == nil {
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Cant connect to display browser client"})
}
err = resetView()
if err != nil {
slog.Error("Failed to reset view", "error", err)
}
mType, err := mimetype.DetectFile(fullPath)
if err != nil {
slog.Error("Failed to detect mime type", "file", pathParam, "error", err)
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to detect file type"})
}
switch mType.String() {
case "video/mp4":
var templateBuffer bytes.Buffer
videoTemplate(pathParam).Render(context.Background(), &templateBuffer)
sseConnection <- templateBuffer.String()
case "image/jpeg", "image/png", "image/gif":
var templateBuffer bytes.Buffer
imageTemplate(pathParam).Render(context.Background(), &templateBuffer)
sseConnection <- templateBuffer.String()
case "application/pdf", "application/vnd.openxmlformats-officedocument.presentationml.presentation", "application/vnd.oasis.opendocument.presentation":
err = pkg.FileHandler.OpenFile(fullPath)
default:
slog.Info("Unsupported file type", "type", mType)
return ctx.JSON(http.StatusUnsupportedMediaType, shared.ErrorResponse{Description: "Unsupported file type: " + mType.String()})
}
if err != nil {
slog.Error("Failed to open file", "file", pathParam, "error", err)
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to open file"})
}
slog.Info("Successfully run file", "file", pathParam)
return ctx.JSON(http.StatusOK, struct{}{})
}
func showHTMLRoute(ctx echo.Context) error {
var request struct {
HTML string `json:"html"`
}
if err := ctx.Bind(&request); err != nil {
slog.Error("Failed to parse request", "error", err)
return ctx.JSON(http.StatusBadRequest, shared.ErrorResponse{Description: shared.BadRequestDescription})
}
err := resetView()
if err != nil {
slog.Error("Failed to reset view", "error", err)
}
sseConnection <- request.HTML
slog.Info("HTML content sent to client")
return ctx.JSON(http.StatusOK, struct{}{})
}
func pingRoute(ctx echo.Context) error {
return ctx.JSON(http.StatusOK, struct {
Version string `json:"version"`
}{Version: version})
}
func takeScreenshotRoute(ctx echo.Context) error {
var err error
screenshotPath, err := pkg.TakeScreenshot()
if err != nil {
slog.Error("Failed to take screenshot", "error", err)
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to take screenshot"})
}
err = ctx.File(screenshotPath)
if err != nil {
slog.Error("Failed to serve file", "file", screenshotPath, "error", err)
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to serve file"})
}
return nil
}
func previewRoute(ctx echo.Context) error {
fullPath := ctx.Get("fullPath").(string)
exists := ctx.Get("fileExists").(bool)
if !exists {
return ctx.JSON(http.StatusNotFound, shared.ErrorResponse{Description: "File not found"})
}
outputFilePath, err := pkg.GenerateFilePreview(fullPath)
if err != nil {
slog.Error("Failed to generate preview", "file", fullPath, "error", err)
if errors.Is(err, pkg.ErrFileTypePreviewNotSupported) {
return ctx.JSON(http.StatusUnsupportedMediaType, shared.ErrorResponse{Description: "File type not supported for preview"})
}
if errors.Is(err, pkg.ErrFilePreviewToolsMissing) {
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Required tools for file preview are missing"})
}
return ctx.JSON(http.StatusInternalServerError, shared.ErrorResponse{Description: "Failed to generate preview"})
}
return ctx.File(outputFilePath)
}
// Reset previous file views so they dont collide with the new one
func resetView() error {
err := pkg.FileHandler.CloseRunningProgram()
if err != nil {
return fmt.Errorf("failed to close running program: %w", err)
}
sseConnection <- ""
return nil
}
func isPortFree(port int) bool {
addr := fmt.Sprintf("127.0.0.1:%d", port)
l, err := net.Listen("tcp", addr)
if err != nil {
return false
}
_ = l.Close()
return true
}