Features, Polish, Improvements

- Added logout, add/remove devices
- Page structure w/ dark theme
- Template files now use target extension
- Accessibility improvements
- Semantic improvements
This commit is contained in:
adro 2022-02-21 15:16:06 +01:00
parent 63906235a4
commit 2485c7ab7a
12 changed files with 303 additions and 90 deletions

View File

@ -8,16 +8,16 @@ import (
) )
type Device struct { type Device struct {
Alias string Alias string `form:"Alias"`
MAC string MAC string `form:"MAC"`
IP string IP string `form:"IP"`
} }
type config struct { type config struct {
Server string Server string
PassHash string PassHash string
SessionTTL float64 SessionTTL float64
Device []Device Devices []Device
} }
var Config config var Config config

View File

@ -2,12 +2,12 @@ Server = ":8080" # The address the webserver should bind to
PassHash = "$2a$10$I.26oCzkjZ8qwfhbmeYM3.kppBjxtPsxkeE1Y.ULjVvA1IBPcQP42" # "password" PassHash = "$2a$10$I.26oCzkjZ8qwfhbmeYM3.kppBjxtPsxkeE1Y.ULjVvA1IBPcQP42" # "password"
SessionTTL = 60 # How many minutes sessions last for SessionTTL = 60 # How many minutes sessions last for
[[Device]] [[Devices]]
Alias = "SomeDevice" Alias = "SomeDevice"
MAC = "DE-AD-BE-EF-F0-05" # Delimiter dashes/colons, upper/lowercase MAC = "DE-AD-BE-EF-F0-05" # Delimiter dashes/colons, upper/lowercase
IP = "192.168.178.255" # Broadcast for most home networks IP = "192.168.178.255" # Broadcast for most home networks
[[Device]] [[Devices]]
Alias = "Another Device" Alias = "Another Device"
MAC = "" MAC = ""
IP = "" IP = ""

View File

@ -5,7 +5,11 @@ import (
"net/http" "net/http"
"time" "time"
"git.ulra.eu/adro/miniwol/config"
"github.com/google/uuid"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"golang.org/x/crypto/bcrypt"
) )
var sessions map[string]time.Time var sessions map[string]time.Time
@ -39,3 +43,33 @@ func withAuth(handler echo.HandlerFunc) echo.HandlerFunc {
return handler(c) return handler(c)
} }
} }
// Handlers
func auth(c echo.Context) error {
password := c.FormValue("Password")
if bcrypt.CompareHashAndPassword([]byte(config.Config.PassHash), []byte(password)) != nil {
return c.String(401, "Wrong Password")
}
token := uuid.New().String()
sessions[token] = time.Now().Add(time.Second * time.Duration(config.Config.SessionTTL*60))
c.SetCookie(&http.Cookie{
Name: "session",
Value: token,
Path: "/",
Secure: true,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
Expires: sessions[token],
})
return c.Redirect(http.StatusSeeOther, "/")
}
func deauth(c echo.Context) error {
session, err := c.Cookie("session")
if err != nil {
return err
}
delete(sessions, session.Value)
return c.Redirect(http.StatusSeeOther, "/")
}

68
web/device.go Normal file
View File

@ -0,0 +1,68 @@
package web
import (
"errors"
"net/http"
"strings"
"git.ulra.eu/adro/miniwol/config"
"git.ulra.eu/adro/miniwol/lib"
"github.com/labstack/echo/v4"
)
func add(c echo.Context) error {
device := config.Device{}
err := c.Bind(&device)
if err != nil {
return err
}
config.Config.Devices = append(config.Config.Devices, device)
config.Save()
if err != nil {
return err
}
return c.Redirect(http.StatusSeeOther, "/")
}
func wake(c echo.Context) error {
_device := config.Device{}
err := c.Bind(&_device)
if err != nil {
return err
}
for _, device := range config.Config.Devices {
if device == _device {
if !strings.Contains(device.IP, ":") {
device.IP += ":9"
}
err := lib.SendPacket(":0", device.IP, device.MAC)
if err != nil {
return err
}
return c.Redirect(http.StatusSeeOther, "/")
}
}
return errors.New("device not found")
}
func remove(c echo.Context) error {
_device := config.Device{}
err := c.Bind(&_device)
if err != nil {
return err
}
for i, device := range config.Config.Devices {
if device == _device {
config.Config.Devices = append(config.Config.Devices[:i], config.Config.Devices[i+1:]...)
err := config.Save()
if err != nil {
return err
}
return c.Redirect(http.StatusSeeOther, "/")
}
}
return errors.New("device not found")
}

51
web/template/device.html Normal file
View File

@ -0,0 +1,51 @@
<form action="/add" method="post">
<fieldset>
<legend>Add Device</legend>
<label for="alias">Alias</label><input id="alias" name="Alias">
<label for="mac">MAC</label><input id="mac" name="MAC">
<label for="ip">IP</label><input id="ip" name="IP">
<input type="submit" value="Submit">
</fieldset>
</form>
<fieldset>
<legend>Devices</legend>
<table>
<tr>
<th>Alias</th>
<th>MAC</th>
<th>IP/Broadcast</th>
<th>Actions</th>
</tr>
{{range $i, $d := .Devices}}
<tr>
<td>{{$d.Alias}}</td>
<td>{{$d.MAC}}</td>
<td>{{$d.IP}}</td>
<td class="actions">
<form action="/wake" method="post">
<input type="text" name="Alias" value="{{$d.Alias}}" hidden>
<input type="text" name="MAC" value="{{$d.MAC}}" hidden>
<input type="text" name="IP" value="{{$d.IP}}" hidden>
<input type="submit" value="Wake">
</form>
<form action="/remove" method="post">
<input type="text" name="Alias" value="{{$d.Alias}}" hidden>
<input type="text" name="MAC" value="{{$d.MAC}}" hidden>
<input type="text" name="IP" value="{{$d.IP}}" hidden>
<input type="submit" value="Remove">
</form>
</td>
</tr>
{{end}}
</table>
</fieldset>
<style>
body > div {
flex-flow: row wrap;
align-items: flex-start;
}
</style>

View File

@ -1,22 +0,0 @@
<table>
<tr>
<th>Device Alias</th>
<th>MAC Address</th>
<th>IP/Broadcast</th>
<th>Actions</th>
</tr>
{{range $i, $d := .Device}}
<tr>
<td>{{$d.Alias}}</td>
<td>{{$d.MAC}}</td>
<td>{{$d.IP}}</td>
<td>
<form action="/wake" method="post">
<input type="text" name="index" value="{{$i}}" hidden>
<input type="text" name="alias" value="{{$d.Alias}}" hidden>
<input type="submit" value="Wake">
</form>
</td>
</tr>
{{end}}
</table>

8
web/template/login.html Normal file
View File

@ -0,0 +1,8 @@
<form action="/auth" method="post">
<fieldset>
<legend>Login</legend>
<label for="pw">Password</label><input type="password" id="pw" name="Password">
<input type="submit" value="Submit">
</fieldset>
</form>

View File

@ -1,3 +0,0 @@
<form action="/auth" method="post">
<label for="pw">Password: </label><input type="password" name="password">
</form>

24
web/template/page.html Normal file
View File

@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Small web server to send Wake-on-LAN requests to its local network">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<link rel="icon" href="data:,">
<link rel="stylesheet" href="/style.css">
<title>{{ .Title }} - miniwol</title>
</head>
<body>
<nav>
<h1>miniwol</h1>
<div style="flex-grow: 1;"></div>
{{ if .Auth }}
<form action="/deauth" method="post">
<input type="submit" value="Logout">
</form>
{{ end }}
</nav>
<div>{{ .Content }}</div>
</body>
</html>

View File

@ -1,13 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<link rel="icon" href="data:,">
<title>{{ .Title }}</title>
</head>
<body>
{{ .Content }}
</body>
</html>

96
web/template/style.css Normal file
View File

@ -0,0 +1,96 @@
* {
padding: 0;
margin: 0;
box-sizing: border-box;
color: inherit;
line-height: 1;
}
nav, div, fieldset {
display: flex;
flex-flow: column nowrap;
padding: 0.5rem;
gap: 0.5rem;
}
nav {
flex-flow: row nowrap;
}
fieldset {
display: grid;
grid-template-columns: auto 1fr;
flex-grow: 1;
overflow-x: auto;
}
fieldset > label:after {
content: ":";
pointer-events: none;
}
form {
max-width: max-content;
}
input[type=submit] {
grid-column: 1 / 3;
padding: 0.25rem;
cursor: pointer;
}
.actions {
text-align: center;
}
.actions > form {
display: contents;
}
table {
grid-column: 1 / 3;
border-collapse: collapse;
}
legend {
font-weight: bold;
}
/* Colors */
:root {
--cl-fg: #222;
--cl-bg-page: #eee;
--cl-bg-block: #ddd;
--cl-bg-input: #ccc;
}
@media (prefers-color-scheme: dark) {
:root {
--cl-fg: #ddd;
--cl-bg-page: #333;
--cl-bg-block: #222;
--cl-bg-input: #444;
}
}
:root {
font-family: sans-serif;
background: var(--cl-bg-page);
color: var(--cl-fg);
}
nav, fieldset, table {
background: var(--cl-bg-block);
}
input {
border: 1px solid var(--cl-fg);
background: var(--cl-bg-input);
}
input[type=submit]:hover,
tr:nth-child(even) {
background: var(--cl-bg-page);
}

View File

@ -3,19 +3,11 @@ package web
import ( import (
"bytes" "bytes"
"embed" "embed"
"errors"
"fmt"
"html/template" "html/template"
"net/http"
"strings"
"time"
"git.ulra.eu/adro/miniwol/config" "git.ulra.eu/adro/miniwol/config"
"git.ulra.eu/adro/miniwol/lib"
"github.com/google/uuid"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"golang.org/x/crypto/bcrypt"
) )
//go:embed template/* //go:embed template/*
@ -25,7 +17,7 @@ var templates *template.Template
func init() { func init() {
var err error var err error
templates, err = template.ParseFS(templateFS, "template/*.html.tmpl") templates, err = template.ParseFS(templateFS, "template/*.html")
if err != nil { if err != nil {
panic(err) panic(err)
} }
@ -35,25 +27,31 @@ func Run() error {
e := echo.New() e := echo.New()
e.GET("/", index) e.GET("/", index)
e.GET("/style.css", style)
e.POST("/auth", auth) e.POST("/auth", auth)
e.POST("/deauth", withAuth(deauth))
e.POST("/add", withAuth(add))
e.POST("/wake", withAuth(wake)) e.POST("/wake", withAuth(wake))
e.POST("/remove", withAuth(remove))
return e.Start(config.Config.Server) return e.Start(config.Config.Server)
} }
func Page(c echo.Context, code int, title string, page string, data interface{}) error { func Page(c echo.Context, code int, title string, page string, auth bool, data interface{}) error {
var contentBuffer bytes.Buffer var contentBuffer bytes.Buffer
err := templates.ExecuteTemplate(&contentBuffer, page, data) err := templates.ExecuteTemplate(&contentBuffer, page, data)
if err != nil { if err != nil {
return err return err
} }
var pageBuffer bytes.Buffer var pageBuffer bytes.Buffer
err = templates.ExecuteTemplate(&pageBuffer, "page.html.tmpl", struct { err = templates.ExecuteTemplate(&pageBuffer, "page.html", struct {
Title string Title string
Content template.HTML Content template.HTML
Auth bool
}{ }{
Title: title, Title: title,
Content: template.HTML(contentBuffer.String()), Content: template.HTML(contentBuffer.String()),
Auth: auth,
}) })
if err != nil { if err != nil {
return err return err
@ -65,44 +63,16 @@ func Page(c echo.Context, code int, title string, page string, data interface{})
func index(c echo.Context) error { func index(c echo.Context) error {
session, err := c.Cookie("session") session, err := c.Cookie("session")
if err != nil || checkAuth(session.Value) != nil { if err != nil || checkAuth(session.Value) != nil {
return Page(c, 200, "Login", "login.html.tmpl", nil) return Page(c, 200, "Login", "login.html", false, nil)
} else { } else {
return Page(c, 200, "Device", "device.html.tmpl", config.Config) return Page(c, 200, "Devices", "device.html", true, config.Config)
} }
} }
func auth(c echo.Context) error { func style(c echo.Context) error {
password := c.FormValue("password") styleData, err := templateFS.ReadFile("template/style.css")
if bcrypt.CompareHashAndPassword([]byte(config.Config.PassHash), []byte(password)) != nil { if err != nil {
return c.String(401, "Wrong Password") return err
} }
token := uuid.New().String() return c.Blob(200, "text/css", styleData)
sessions[token] = time.Now().Add(time.Second * time.Duration(config.Config.SessionTTL*60))
c.SetCookie(&http.Cookie{
Name: "session",
Value: token,
Path: "/",
Secure: true,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
Expires: sessions[token],
})
return c.Redirect(http.StatusSeeOther, "/")
}
func wake(c echo.Context) error {
for i, device := range config.Config.Device {
if c.FormValue("alias") == device.Alias && c.FormValue("index") == fmt.Sprint(i) {
if !strings.Contains(device.IP, ":") {
device.IP += ":9"
}
err := lib.SendPacket(":0", device.IP, device.MAC)
if err != nil {
return err
}
return c.Redirect(http.StatusSeeOther, "/")
}
}
return errors.New("device not found")
} }