Move commands under cmd/

This commit is contained in:
2019-04-14 22:30:40 +02:00
parent 7d9410c6b3
commit 1331f3b564
14 changed files with 0 additions and 0 deletions

68
cmd/sklad/base.tmpl Normal file
View File

@@ -0,0 +1,68 @@
<!DOCTYPE html>
<html>
<head>
<title>{{ template "Title" . }} - sklad</title>
<meta http-equiv=Content-Type content="text/html; charset=utf-8">
<meta name=viewport content="width=device-width, initial-scale=1">
<style>
html, body { min-height: 100vh; }
body { padding: 1rem; box-sizing: border-box;
margin: 0 auto; max-width: 50rem;
border-left: 1px solid #ccc; border-right: 1px solid #ccc;
font-family: sans-serif; }
section { border: 1px outset #ccc; padding: 0 .5rem; margin: 1rem 0; }
section > p { margin: 0 0 .5rem 0; }
header, footer { display: flex; justify-content: space-between;
align-items: center; flex-wrap: wrap; padding-top: .5em; }
header { margin: 0 -.5rem; padding: .5rem .5rem 0 .5rem;
background: linear-gradient(0deg, transparent, #f8f8f8); }
body > header { margin: -1rem -1rem 0 -1rem; padding: 1rem 1rem 0 1rem;
background: linear-gradient(0deg, transparent, #eeeeee); }
header *,
footer * { display: inline-block; }
header > *,
footer > * { margin: 0 0 .5rem 0; }
header > *:not(:last-child),
footer > *:not(:last-child) { margin-right: .5rem; }
header > h2,
header > h3 { flex-grow: 1; }
/* Don't ask me why this is an improvement on mobile browsers. */
input[type=submit], input[type=text], input[type=password],
select, textarea { border: 1px inset #ccc; padding: .25rem; }
input[type=submit] { border-style: outset; }
select { border-style: solid; }
a { color: inherit; }
textarea { padding: .5rem; box-sizing: border-box; width: 100%;
font-family: inherit; resize: vertical; }
select { max-width: 15rem; }
</style>
</head>
<body>
<header>
<h1>sklad</h1>
{{ block "HeaderControls" . }}
<a href=/>Obaly</a>
<a href=/series>Řady</a>
<form method=get action=/search>
<input type=text name=q autofocus><input type=submit value="Hledat">
</form>
<form method=post action=/logout>
<input type=submit value="Odhlásit">
</form>
{{ end }}
</header>
{{ template "Content" . }}
</body>
</html>

99
cmd/sklad/container.tmpl Normal file
View File

@@ -0,0 +1,99 @@
{{ define "Title" }}{{/*
*/}}{{ if .Container }}{{ .Container.Id }}{{ else }}Obaly{{ end }}{{ end }}
{{ define "Content" }}
{{ if .Container }}
<section>
<header>
<h2>{{ .Container.Id }}</h2>
<form method=post action="/label?id={{ .Container.Id }}">
<input type=submit value="Vytisknout štítek">
</form>
<form method=post action="/?id={{ .Container.Id }}&amp;remove">
<input type=submit value="Odstranit">
</form>
</header>
<form method=post action="/?id={{ .Container.Id }}">
<textarea name=description rows=5>
{{ .Container.Description }}
</textarea>
<footer>
<div>
<label for=series>Řada:</label>
<select name=series id=series>
{{ range $prefix, $desc := .AllSeries }}
<option value="{{ $prefix }}"
{{ if eq $prefix $.Container.Series }}selected{{ end }}
>{{ $prefix }} &mdash; {{ $desc }}</option>
{{ end }}
</select>
</div>
<div>
<label for=parent>Nadobal:</label>
<input type=text name=parent id=parent value="{{ .Container.Parent }}">
</div>
<input type=submit value="Uložit">
</footer>
</form>
</section>
<h2>Podobaly</h3>
{{ else }}
<section>
<header>
<h2>Nový obal</h2>
</header>
<form method=post action="/">
<textarea name=description rows=5
placeholder="Popis obalu nebo jeho obsahu"></textarea>
<footer>
<div>
<label for=series>Řada:</label>
<select name=series id=series>
{{ range $prefix, $desc := .AllSeries }}
<option value="{{ $prefix }}"
>{{ $prefix }} &mdash; {{ $desc }}</option>
{{ end }}
</select>
</div>
<div>
<label for=parent>Nadobal:</label>
<input type=text name=parent id=parent value="">
</div>
<input type=submit value="Uložit">
</footer>
</form>
</section>
<h2>Obaly nejvyšší úrovně</h2>
{{ end }}
{{ range .Children }}
<section>
<header>
<h3><a href="/?id={{ .Id }}">{{ .Id }}</a></h3>
<form method=post action="/label?id={{ .Id }}">
<input type=submit value="Vytisknout štítek">
</form>
<form method=post action="/?id={{ .Id }}&amp;remove">
<input type=submit value="Odstranit">
</form>
</header>
{{ if .Description }}
<p>{{ .Description }}
{{ end }}
{{ if .Children }}
<p>
{{ range .Children }}
<a href="/?id={{ .Id }}">{{ .Id }}</a>
{{ end }}
{{ end }}
</section>
{{ else }}
<p>Obal je prázdný.
{{ end }}
{{ end }}

225
cmd/sklad/db.go Normal file
View File

@@ -0,0 +1,225 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"os"
"strings"
"time"
"janouch.name/sklad/bdf"
)
type Series struct {
Prefix string // PK: prefix
Description string // what kind of containers this is for
}
type ContainerId string
type Container struct {
Series string // PK: what series does this belong to
Number uint // PK: order within the series
Parent ContainerId // the container we're in, if any, otherwise ""
Description string // description and/or contents of this container
}
func (c *Container) Id() ContainerId {
return ContainerId(fmt.Sprintf("%s%s%d", db.Prefix, c.Series, c.Number))
}
func (c *Container) Children() []*Container {
// TODO: Sort this by Id, or maybe even return a map[string]*Container,
// text/template would sort that automatically.
return indexChildren[c.Id()]
}
func (c *Container) Path() (result []ContainerId) {
for c != nil && c.Parent != "" {
c = indexContainer[c.Parent]
result = append(result, c.Id())
}
return
}
type Database struct {
Password string // password for web users
Prefix string // prefix for all container IDs
Series []*Series // all known series
Containers []*Container // all known containers
BDFPath string // path to bitmap font file
BDFScale int // integer scaling for the bitmap font
}
var (
dbPath string
db Database
dbLast Database
dbLog *os.File
indexSeries = map[string]*Series{}
indexContainer = map[ContainerId]*Container{}
indexChildren = map[ContainerId][]*Container{}
labelFont *bdf.Font
)
// TODO: Some functions to add, remove and change things in the database.
// Indexes must be kept valid, just like any invariants.
func dbSearchSeries(query string) (result []*Series) {
query = strings.ToLower(query)
added := map[string]bool{}
for _, s := range db.Series {
if query == strings.ToLower(s.Prefix) {
result = append(result, s)
added[s.Prefix] = true
}
}
for _, s := range db.Series {
if strings.Contains(
strings.ToLower(s.Description), query) && !added[s.Prefix] {
result = append(result, s)
}
}
return
}
func dbSearchContainers(query string) (result []*Container) {
query = strings.ToLower(query)
added := map[ContainerId]bool{}
for id, c := range indexContainer {
if query == strings.ToLower(string(id)) {
result = append(result, c)
added[id] = true
}
}
for id, c := range indexContainer {
if strings.Contains(
strings.ToLower(c.Description), query) && !added[id] {
result = append(result, c)
}
}
return
}
func dbCommit() error {
// Write a timestamp.
e := json.NewEncoder(dbLog)
e.SetIndent("", " ")
if err := e.Encode(time.Now().Format(time.RFC3339)); err != nil {
return err
}
// Back up the current database contents.
if err := e.Encode(&dbLast); err != nil {
return err
}
if err := dbLog.Sync(); err != nil {
return err
}
// Atomically replace the current database file.
tempPath := dbPath + ".new"
temp, err := os.OpenFile(tempPath, os.O_WRONLY|os.O_CREATE, 0644)
if err != nil {
return err
}
defer temp.Close()
e = json.NewEncoder(temp)
e.SetIndent("", " ")
if err := e.Encode(&db); err != nil {
return err
}
if err := os.Rename(tempPath, dbPath); err != nil {
return err
}
dbLast = db
return nil
}
// loadDatabase loads the database from a simple JSON file. We do not use
// any SQL stuff or even external KV storage because there is no real need
// for our trivial use case, with our general amount of data.
func loadDatabase() error {
dbFile, err := os.Open(dbPath)
if err != nil {
return err
}
if err := json.NewDecoder(dbFile).Decode(&db); err != nil {
return err
}
// Further validate the database.
if db.Prefix == "" {
return errors.New("misconfigured prefix")
}
// Construct indexes for primary keys, validate against duplicates.
for _, pv := range db.Series {
if _, ok := indexSeries[pv.Prefix]; ok {
return fmt.Errorf("duplicate series: %s", pv.Prefix)
}
indexSeries[pv.Prefix] = pv
}
for _, pv := range db.Containers {
id := pv.Id()
if _, ok := indexContainer[id]; ok {
return fmt.Errorf("duplicate container: %s", id)
}
indexContainer[id] = pv
}
// Construct an index that goes from parent containers to their children.
for _, pv := range db.Containers {
if pv.Parent != "" {
if _, ok := indexContainer[pv.Parent]; !ok {
return fmt.Errorf("container %s has a nonexistent parent %s",
pv.Id(), pv.Parent)
}
}
indexChildren[pv.Parent] = append(indexChildren[pv.Parent], pv)
}
// Validate that no container is a parent of itself on any level.
// This could probably be optimized but it would stop being obvious.
for _, pv := range db.Containers {
parents := map[ContainerId]bool{pv.Id(): true}
for pv.Parent != "" {
if parents[pv.Parent] {
return fmt.Errorf("%s contains itself", pv.Parent)
}
parents[pv.Parent] = true
pv = indexContainer[pv.Parent]
}
}
// Prepare label printing.
if db.BDFScale <= 0 {
db.BDFScale = 1
}
if f, err := os.Open(db.BDFPath); err != nil {
return fmt.Errorf("cannot load label font: %s", err)
} else {
defer f.Close()
if labelFont, err = bdf.NewFromBDF(f); err != nil {
return fmt.Errorf("cannot load label font: %s", err)
}
}
// Open database log file for appending.
if dbLog, err = os.OpenFile(dbPath+".log",
os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644); err != nil {
return err
}
// Remember the current state of the database.
dbLast = db
return nil
}

13
cmd/sklad/label.tmpl Normal file
View File

@@ -0,0 +1,13 @@
{{ define "Title" }}Tisk štítku{{ end }}
{{ define "Content" }}
<h2>Tisk štítku pro {{ .Id }}</h2>
{{ if .UnknownId }}
<p>Neznámý obal.
{{ else if .Error }}
<p>Tisk selhal: {{ .Error }}
{{ else }}
<p>Tisk proběhl úspěšně.
{{ end }}
{{ end }}

17
cmd/sklad/login.tmpl Normal file
View File

@@ -0,0 +1,17 @@
{{ define "Title" }}Přihlášení{{ end }}
{{ define "HeaderControls" }}<!-- text/template requires content -->{{ end }}
{{ define "Content" }}
<h2>Přihlášení</h2>
<form method=post>
<label for=password>Heslo:</label>
<input type=password name=password id=password autofocus
><input type=submit value="Přihlásit">
</form>
{{ if .IncorrectPassword }}
<p>Bylo zadáno nesprávné heslo.
{{ end }}
{{ end }}

271
cmd/sklad/main.go Normal file
View File

@@ -0,0 +1,271 @@
package main
import (
"errors"
"html/template"
"io"
"log"
"math/rand"
"net/http"
"os"
"path/filepath"
"time"
"janouch.name/sklad/imgutil"
"janouch.name/sklad/label"
"janouch.name/sklad/ql"
)
var templates = map[string]*template.Template{}
func executeTemplate(name string, w io.Writer, data interface{}) {
if err := templates[name].Execute(w, data); err != nil {
panic(err)
}
}
func wrap(inner func(http.ResponseWriter, *http.Request)) func(
http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if r.Method == http.MethodGet {
w.Header().Set("Cache-Control", "no-store")
}
inner(w, r)
}
}
func handleLogin(w http.ResponseWriter, r *http.Request) {
redirect := r.FormValue("redirect")
if redirect == "" {
redirect = "/"
}
session := sessionGet(w, r)
if session.LoggedIn {
http.Redirect(w, r, redirect, http.StatusSeeOther)
return
}
params := struct {
IncorrectPassword bool
}{}
switch r.Method {
case http.MethodGet:
// We're just going to render the template.
case http.MethodPost:
if r.FormValue("password") == db.Password {
session.LoggedIn = true
http.Redirect(w, r, redirect, http.StatusSeeOther)
return
}
params.IncorrectPassword = true
default:
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
executeTemplate("login.tmpl", w, &params)
}
func handleLogout(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
session := r.Context().Value(sessionContextKey{}).(*Session)
session.LoggedIn = false
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func handleContainer(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
// TODO
}
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
allSeries := map[string]string{}
for _, s := range indexSeries {
allSeries[s.Prefix] = s.Description
}
var container *Container
children := []*Container{}
if id := ContainerId(r.FormValue("id")); id == "" {
children = indexChildren[""]
} else if c, ok := indexContainer[id]; ok {
children = c.Children()
container = c
}
params := struct {
Container *Container
Children []*Container
AllSeries map[string]string
}{
Container: container,
Children: children,
AllSeries: allSeries,
}
executeTemplate("container.tmpl", w, &params)
}
func handleSeries(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
// TODO
}
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
allSeries := map[string]string{}
for _, s := range indexSeries {
allSeries[s.Prefix] = s.Description
}
prefix := r.FormValue("prefix")
description := ""
if prefix == "" {
} else if series, ok := indexSeries[prefix]; ok {
description = series.Description
}
params := struct {
Prefix string
Description string
AllSeries map[string]string
}{
Prefix: prefix,
Description: description,
AllSeries: allSeries,
}
executeTemplate("series.tmpl", w, &params)
}
func handleSearch(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
query := r.FormValue("q")
params := struct {
Query string
Series []*Series
Containers []*Container
}{
Query: query,
Series: dbSearchSeries(query),
Containers: dbSearchContainers(query),
}
executeTemplate("search.tmpl", w, &params)
}
func printLabel(id string) error {
printer, err := ql.Open()
if err != nil {
return err
}
if printer == nil {
return errors.New("no suitable printer found")
}
defer printer.Close()
/*
printer.StatusNotify = func(status *ql.Status) {
log.Printf("\x1b[1mreceived status\x1b[m\n%+v\n%s",
status[:], status)
}
*/
if err := printer.Initialize(); err != nil {
return err
}
if err := printer.UpdateStatus(); err != nil {
return err
}
mediaInfo := ql.GetMediaInfo(
printer.LastStatus.MediaWidthMM(),
printer.LastStatus.MediaLengthMM(),
)
if mediaInfo == nil {
return errors.New("unknown media")
}
return printer.Print(&imgutil.LeftRotate{Image: label.GenLabelForHeight(
labelFont, id, mediaInfo.PrintAreaPins, db.BDFScale)})
}
func handleLabel(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
params := struct {
Id string
UnknownId bool
Error error
}{
Id: r.FormValue("id"),
}
if c := indexContainer[ContainerId(params.Id)]; c == nil {
params.UnknownId = true
} else {
params.Error = printLabel(params.Id)
}
executeTemplate("label.tmpl", w, &params)
}
func main() {
// Randomize the RNG for session string generation.
rand.Seed(time.Now().UnixNano())
if len(os.Args) != 3 {
log.Fatalf("Usage: %s ADDRESS DATABASE-FILE\n", os.Args[0])
}
var address string
address, dbPath = os.Args[1], os.Args[2]
// Load database.
if err := loadDatabase(); err != nil {
log.Fatalln(err)
}
// Load HTML templates from the current working directory.
m, err := filepath.Glob("*.tmpl")
if err != nil {
log.Fatalln(err)
}
for _, name := range m {
templates[name] = template.Must(template.ParseFiles("base.tmpl", name))
}
http.HandleFunc("/login", wrap(handleLogin))
http.HandleFunc("/logout", sessionWrap(wrap(handleLogout)))
http.HandleFunc("/", sessionWrap(wrap(handleContainer)))
http.HandleFunc("/series", sessionWrap(wrap(handleSeries)))
http.HandleFunc("/search", sessionWrap(wrap(handleSearch)))
http.HandleFunc("/label", sessionWrap(wrap(handleLabel)))
log.Fatalln(http.ListenAndServe(address, nil))
}

38
cmd/sklad/search.tmpl Normal file
View File

@@ -0,0 +1,38 @@
{{ define "Title" }}&bdquo;{{ .Query }}&ldquo; &mdash; Vyhledávání{{ end }}
{{ define "Content" }}
<h2>Vyhledávání: &bdquo;{{ .Query }}&ldquo;<h2>
<h3>Řady</h3>
{{ range .Series }}
<section>
<header>
<h3><a href="/series?prefix={{ .Prefix }}">{{ .Prefix }}</a></h3>
<p>{{ .Description }}
</header>
</section>
{{ else }}
<p>Neodpovídají žádné řady.
{{ end }}
<h3>Obaly</h3>
{{ range .Containers }}
<section>
<header>
<h3><a href="/?id={{ .Id }}">{{ .Id }}</a>
{{ range .Path }}
<small>&laquo; <a href="/?id={{ . }}">{{ . }}</a></small>
{{ end }}
</h3>
</header>
{{ if .Description }}
<p>{{ .Description }}
{{ end }}
</section>
{{ else }}
<p>Neodpovídají žádné obaly.
{{ end }}
{{ end }}

43
cmd/sklad/series.tmpl Normal file
View File

@@ -0,0 +1,43 @@
{{ define "Title" }}{{ or .Prefix "Řady" }}{{ end }}
{{ define "Content" }}
{{ if .Prefix }}
<h2>{{ .Prefix }}</h2>
{{ if .Description }}
<p>{{ .Description }}
{{ end }}
{{ else }}
<section>
<form method=post action="/series">
<header>
<h3>Nová řada</h3>
<input type=text name=prefix placeholder="Prefix řady">
<input type=text name=description placeholder="Popis řady"
><input type=submit value="Uložit">
</form>
</header>
</form>
</section>
{{ range $prefix, $desc := .AllSeries }}
<section>
<header>
<h3><a href="/series?prefix={{ $prefix }}">{{ $prefix }}</a></h3>
<form method=post action="/series?prefix={{ $prefix }}">
<input type=text name=description value="{{ $desc }}"
><input type=submit value="Uložit">
</form>
<form method=post action="/series?prefix={{ $prefix }}&amp;remove">
<input type=submit value="Odstranit">
</form>
</header>
</section>
{{ else }}
<p>Nejsou žádné řady.
{{ end }}
{{ end }}
{{ end }}

66
cmd/sklad/session.go Normal file
View File

@@ -0,0 +1,66 @@
package main
import (
"context"
"encoding/hex"
"math/rand"
"net/http"
"net/url"
)
// session storage indexed by a random UUID
var sessions = map[string]*Session{}
type Session struct {
LoggedIn bool // may access the DB
}
type sessionContextKey struct{}
func sessionGenId() string {
u := make([]byte, 16)
if _, err := rand.Read(u); err != nil {
panic("cannot generate random bytes")
}
return hex.EncodeToString(u)
}
// TODO: We don't want to keep an unlimited amount of cookies in the storage.
// - The essential question is: how do we avoid DoS?
// - Which cookies are worth keeping?
// - Definitely logged-in users, only one person should know the password.
// - Evict by FIFO? LRU?
func sessionGet(w http.ResponseWriter, r *http.Request) (session *Session) {
if c, _ := r.Cookie("sessionid"); c != nil {
session, _ = sessions[c.Value]
}
if session == nil {
id := sessionGenId()
session = &Session{LoggedIn: false}
sessions[id] = session
http.SetCookie(w, &http.Cookie{Name: "sessionid", Value: id})
}
return
}
func sessionWrap(inner func(http.ResponseWriter, *http.Request)) func(
http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
// We might also try no-cache with an ETag for the whole database,
// though I don't expect any substantial improvements of anything.
w.Header().Set("Cache-Control", "no-store")
redirect := "/login"
if r.RequestURI != "/" && r.Method == http.MethodGet {
redirect += "?redirect=" + url.QueryEscape(r.RequestURI)
}
session := sessionGet(w, r)
if !session.LoggedIn {
http.Redirect(w, r, redirect, http.StatusSeeOther)
return
}
inner(w, r.WithContext(
context.WithValue(r.Context(), sessionContextKey{}, session)))
}
}