mirror of
https://codeberg.org/PLG-Development/PLG-MuDiCS
synced 2026-07-06 00:47:09 +00:00
refactor(display): move web logic into own package
This commit is contained in:
@@ -0,0 +1,324 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/micmonay/keybd_event"
|
||||
|
||||
"plg-mudics-display/pkg"
|
||||
)
|
||||
|
||||
type ErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
var version string
|
||||
var sseConnection chan string
|
||||
var supportedExtensions = map[string]bool{
|
||||
".mp4": true,
|
||||
".jpg": true,
|
||||
".jpeg": true,
|
||||
".png": true,
|
||||
".gif": true,
|
||||
".pptx": true,
|
||||
".odp": true,
|
||||
}
|
||||
|
||||
func StartWebServer(v string) {
|
||||
version = v
|
||||
|
||||
e := echo.New()
|
||||
|
||||
e.GET("/", indexRoute)
|
||||
e.GET("/sse", sseRoute)
|
||||
|
||||
apiGroup := e.Group("/api")
|
||||
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)
|
||||
|
||||
err := e.Start(":1323")
|
||||
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)
|
||||
}
|
||||
var status bytes.Buffer
|
||||
deviceInfoTemplate(ip, mac).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 extractFilePathMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(ctx echo.Context) error {
|
||||
pathParam := ctx.Param("path")
|
||||
fullPath, exists, err := pkg.ResolveStorageFilePath(pathParam)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to validate file path", "path", pathParam, "error", err)
|
||||
return ctx.JSON(http.StatusBadRequest, ErrorResponse{Error: "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, ErrorResponse{Error: "Invalid JSON request"})
|
||||
}
|
||||
|
||||
cmd := exec.Command("bash", "-c", "-r", commandInput.Command)
|
||||
storagePath, err := pkg.GetStoragePath()
|
||||
if err != nil {
|
||||
slog.Error("Failed to get storage path", "error", err)
|
||||
return ctx.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Internal server error"})
|
||||
}
|
||||
cmd.Dir = storagePath
|
||||
|
||||
commandOutput := pkg.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 {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
if err := ctx.Bind(&request); err != nil {
|
||||
slog.Error("Failed to parse keyboard input", "error", err)
|
||||
return ctx.JSON(http.StatusBadRequest, ErrorResponse{Error: "Invalid JSON request"})
|
||||
}
|
||||
|
||||
code, ok := pkg.KeyboardEvents[request.Key]
|
||||
if !ok {
|
||||
slog.Error("Unsupported key", "key", request.Key)
|
||||
return ctx.JSON(http.StatusBadRequest, ErrorResponse{Error: fmt.Sprintf("Unsupported key: %s", request.Key)})
|
||||
}
|
||||
|
||||
err := pkg.KeyboardInput(code)
|
||||
if err != nil {
|
||||
slog.Error("Failed to send keyboard input", "key", request.Key, "error", err)
|
||||
return ctx.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Failed to send keyboard input"})
|
||||
}
|
||||
|
||||
slog.Info("Keyboard input sent", "key", request.Key)
|
||||
return ctx.NoContent(http.StatusOK)
|
||||
}
|
||||
|
||||
func uploadFileRoute(ctx echo.Context) error {
|
||||
fullPath := ctx.Get("fullPath").(string)
|
||||
|
||||
// Ensure parent directories exist
|
||||
if err := os.MkdirAll(filepath.Dir(fullPath), os.ModePerm); err != nil {
|
||||
return ctx.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Failed to prepare storage directory"})
|
||||
}
|
||||
|
||||
if ctx.Get("fileExists").(bool) {
|
||||
return ctx.JSON(http.StatusConflict, ErrorResponse{Error: "File already exists"})
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(fullPath))
|
||||
if !supportedExtensions[ext] {
|
||||
return ctx.JSON(http.StatusBadRequest, ErrorResponse{Error: fmt.Sprintf("Unsupported file extension: %s", ext)})
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(ctx.Request().Body)
|
||||
if err != nil {
|
||||
return ctx.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Failed to read file body"})
|
||||
}
|
||||
|
||||
if err := os.WriteFile(fullPath, data, os.ModePerm); err != nil {
|
||||
return ctx.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Failed to save file"})
|
||||
}
|
||||
|
||||
slog.Info("File uploaded successfully", "path", fullPath)
|
||||
return ctx.JSON(http.StatusCreated, struct{ Message string }{Message: "File uploaded successfully"})
|
||||
}
|
||||
|
||||
func downloadFileRoute(ctx echo.Context) error {
|
||||
fullPath := ctx.Get("fullPath").(string)
|
||||
|
||||
if !ctx.Get("fileExists").(bool) {
|
||||
return ctx.JSON(http.StatusNotFound, ErrorResponse{Error: "File not found"})
|
||||
}
|
||||
|
||||
err := ctx.File(fullPath)
|
||||
if err != nil {
|
||||
slog.Error("Failed to serve file", "file", fullPath, "error", err)
|
||||
return ctx.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Internal server error"})
|
||||
}
|
||||
|
||||
slog.Info("File downloaded successfully", "path", fullPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
func openFileRoute(ctx echo.Context) error {
|
||||
pathParam := ctx.Param("path")
|
||||
fullPath := ctx.Get("fullPath").(string)
|
||||
|
||||
if !ctx.Get("fileExists").(bool) {
|
||||
return ctx.JSON(http.StatusNotFound, ErrorResponse{Error: "File not found"})
|
||||
}
|
||||
|
||||
if sseConnection == nil {
|
||||
return ctx.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Cant connect to display browser client"})
|
||||
}
|
||||
|
||||
err := resetView()
|
||||
if err != nil {
|
||||
slog.Error("Failed to reset view", "error", err)
|
||||
return ctx.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Failed to reset view"})
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(fullPath))
|
||||
switch ext {
|
||||
case ".mp4":
|
||||
var templateBuffer bytes.Buffer
|
||||
videoTemplate(pathParam).Render(context.Background(), &templateBuffer)
|
||||
|
||||
sseConnection <- templateBuffer.String()
|
||||
case ".jpg", ".jpeg", ".png", ".gif":
|
||||
var templateBuffer bytes.Buffer
|
||||
imageTemplate(pathParam).Render(context.Background(), &templateBuffer)
|
||||
sseConnection <- templateBuffer.String()
|
||||
case ".pptx", ".odp":
|
||||
pkg.OpenPresentation(fullPath)
|
||||
default:
|
||||
return ctx.JSON(http.StatusBadRequest, ErrorResponse{Error: "Unsupported file type"})
|
||||
}
|
||||
|
||||
slog.Info("Successfully run file", "file", pathParam)
|
||||
return ctx.NoContent(http.StatusOK)
|
||||
}
|
||||
|
||||
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, ErrorResponse{Error: "Invalid JSON request"})
|
||||
}
|
||||
|
||||
err := resetView()
|
||||
if err != nil {
|
||||
slog.Error("Failed to reset view", "error", err)
|
||||
return ctx.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Failed to reset view"})
|
||||
}
|
||||
|
||||
sseConnection <- request.HTML
|
||||
|
||||
slog.Info("HTML content sent to client")
|
||||
return ctx.NoContent(http.StatusOK)
|
||||
}
|
||||
|
||||
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, ErrorResponse{Error: "Internal server error"})
|
||||
}
|
||||
|
||||
err = ctx.File(screenshotPath)
|
||||
if err != nil {
|
||||
slog.Error("Failed to serve file", "file", screenshotPath, "error", err)
|
||||
return ctx.JSON(http.StatusInternalServerError, ErrorResponse{Error: "Internal server error"})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reset previous file views so they dont collide with the new one
|
||||
func resetView() error {
|
||||
err := pkg.KeyboardInput(keybd_event.VK_ESC)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send ESC key: %w", err)
|
||||
}
|
||||
sseConnection <- ""
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package web
|
||||
|
||||
templ indexTemplate() {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>PLG Connect Display</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.6/dist/htmx.min.js" integrity="sha384-Akqfrbj/HpNVo8k11SXBb6TlBWmXXlYQrCSqEWmyKJe+hDm3Z/B2WVG4smwBkRVm" crossorigin="anonymous"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/htmx-ext-sse@2.2.2" crossorigin="anonymous"></script>
|
||||
<style>
|
||||
body {
|
||||
display: flex;
|
||||
justify-content: center; /* centers horizontally */
|
||||
align-items: center; /* centers vertically */
|
||||
width: 100vw; /* Viewport width */
|
||||
height: 100vh; /* Viewport height */
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
background-color: hsl(256, 10%, 10%);
|
||||
color: hsl(256, 90%, 95%);
|
||||
}
|
||||
|
||||
video, img, iframe {
|
||||
width: 100vw; /* Viewport width */
|
||||
height: 100vh; /* Viewport height */
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 6rem;
|
||||
line-height: 1;
|
||||
font-family: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
text-wrap: balance;
|
||||
max-width: 90vw;
|
||||
word-break: break-word;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
document.addEventListener('keydown', function(event) {
|
||||
if (event.code === 'Space') {
|
||||
event.preventDefault();
|
||||
var video = document.querySelector('video');
|
||||
if (video) {
|
||||
if (video.paused) {
|
||||
video.play();
|
||||
} else {
|
||||
video.pause();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</head>
|
||||
<body hx-ext="sse" sse-connect="/sse" sse-swap="message"></body>
|
||||
</html>
|
||||
}
|
||||
|
||||
templ videoTemplate(path string) {
|
||||
<video autoplay>
|
||||
<source src={ "/api/file/" + path } type="video/mp4"/>
|
||||
</video>
|
||||
}
|
||||
|
||||
templ imageTemplate(path string) {
|
||||
<img src={ "/api/file/" + path }/>
|
||||
}
|
||||
|
||||
templ deviceInfoTemplate(ip string, mac string) {
|
||||
<p>
|
||||
{ ip }
|
||||
<br/>
|
||||
<span style="text-transform: uppercase;">{ mac }</span>
|
||||
</p>
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
// Event represents Server-Sent Event.
|
||||
// SSE explanation: https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#event_stream_format
|
||||
type Event struct {
|
||||
// ID is used to set the EventSource object's last event ID value.
|
||||
ID []byte
|
||||
// Data field is for the message. When the EventSource receives multiple consecutive lines
|
||||
// that begin with data:, it concatenates them, inserting a newline character between each one.
|
||||
// Trailing newlines are removed.
|
||||
Data []byte
|
||||
// Event is a string identifying the type of event described. If this is specified, an event
|
||||
// will be dispatched on the browser to the listener for the specified event name; the website
|
||||
// source code should use addEventListener() to listen for named events. The onmessage handler
|
||||
// is called if no event name is specified for a message.
|
||||
Event []byte
|
||||
// Retry is the reconnection time. If the connection to the server is lost, the browser will
|
||||
// wait for the specified time before attempting to reconnect. This must be an integer, specifying
|
||||
// the reconnection time in milliseconds. If a non-integer value is specified, the field is ignored.
|
||||
Retry []byte
|
||||
// Comment line can be used to prevent connections from timing out; a server can send a comment
|
||||
// periodically to keep the connection alive.
|
||||
Comment []byte
|
||||
}
|
||||
|
||||
// MarshalTo marshals Event to given Writer
|
||||
func (ev *Event) MarshalTo(w io.Writer) error {
|
||||
// Marshalling part is taken from: https://github.com/r3labs/sse/blob/c6d5381ee3ca63828b321c16baa008fd6c0b4564/http.go#L16
|
||||
if len(ev.Data) == 0 && len(ev.Comment) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(ev.Data) > 0 {
|
||||
if _, err := fmt.Fprintf(w, "id: %s\n", ev.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sd := bytes.Split(ev.Data, []byte("\n"))
|
||||
for i := range sd {
|
||||
if _, err := fmt.Fprintf(w, "data: %s\n", sd[i]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if len(ev.Event) > 0 {
|
||||
if _, err := fmt.Fprintf(w, "event: %s\n", ev.Event); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if len(ev.Retry) > 0 {
|
||||
if _, err := fmt.Fprintf(w, "retry: %s\n", ev.Retry); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(ev.Comment) > 0 {
|
||||
if _, err := fmt.Fprintf(w, ": %s\n", ev.Comment); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := fmt.Fprint(w, "\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user