312 lines
11 KiB
Go
312 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"regexp"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
|
)
|
|
|
|
// pagePattern is a page of the admin site a caller may ask for: an absolute path under the site ending in
|
|
// .html, with no query, no dot-dot and no second slash at its start (which would name another host).
|
|
var pagePattern = regexp.MustCompile(`^/[A-Za-z0-9_./-]*\.html$`)
|
|
|
|
// DefaultPage is the page printer.settings reads when none is asked for.
|
|
const DefaultPage = "/home/status.html"
|
|
|
|
// The asks the verbs put to the device process (ADR 0468 clause 1; the shape of ADR 0436's mail-sends): the tools
|
|
// never hold a credential, so each verb queues an ask on the work-queue stream device-asks, and the device process,
|
|
// an account of its own, answers under the ask's id in the state device-answers.
|
|
const (
|
|
// EventAsked is the event on the work queue; the stream device-asks carries it.
|
|
EventAsked = "device.asked"
|
|
// AsksStream is the work-queue stream's name on the bus: the manifest's local name device-asks, capitalised
|
|
// with its hyphen an underscore, as the controller composes a stream's own bus name.
|
|
AsksStream = "DEVICE_ASKS"
|
|
// AnswersState is the state the device process writes its answers in.
|
|
AnswersState = "device-answers"
|
|
// DeviceProcessID is the device process's id as module.json's resource "device" names it: the controller
|
|
// delivers its bus account as the own secret broker-device.
|
|
DeviceProcessID = "device"
|
|
// AnswerWait is how long a verb waits for the device process; ScanWait how long a scan does.
|
|
AnswerWait = 60 * time.Second
|
|
ScanWait = 180 * time.Second
|
|
// StaleAfter is how old an ask may be when the device process takes it: older than twice the longest wait,
|
|
// nobody waits for it any more, so the device is not touched for it.
|
|
StaleAfter = 2 * ScanWait
|
|
)
|
|
|
|
// The operations of an ask.
|
|
const (
|
|
OpSettings = "settings"
|
|
OpScan = "scan"
|
|
OpDocument = "document"
|
|
OpUpload = "upload"
|
|
OpPrint = "print"
|
|
)
|
|
|
|
// Ask is what a verb queues: the operation, when it was asked, and the operation's own arguments.
|
|
type Ask struct {
|
|
ID string `json:"id"`
|
|
Op string `json:"op"`
|
|
At time.Time `json:"asked-at"`
|
|
|
|
Page string `json:"page,omitempty"` // settings
|
|
Object string `json:"object,omitempty"` // document, print: a scan's key
|
|
Upload string `json:"upload,omitempty"` // upload, print: an upload's id
|
|
Part int `json:"part,omitempty"` // document, upload: counted from 1
|
|
Parts int `json:"parts,omitempty"` // upload
|
|
SHA256 string `json:"sha256,omitempty"` // upload: the whole document's
|
|
Data string `json:"data,omitempty"` // upload: the part, base64
|
|
Copies int `json:"copies,omitempty"` // print
|
|
Sides string `json:"sides,omitempty"` // print
|
|
Media string `json:"media,omitempty"` // print
|
|
}
|
|
|
|
// AskBus is how the tools reach the device process: they queue an ask and read an answer back.
|
|
type AskBus interface {
|
|
Publish(id string, body any) error
|
|
Answer(id string) (json.RawMessage, error)
|
|
}
|
|
|
|
type sdkAskBus struct{}
|
|
|
|
func (sdkAskBus) Publish(id string, body any) error {
|
|
return stdio.Emit(EventAsked, body, stdio.EmitOptions{MessageID: id})
|
|
}
|
|
|
|
func (sdkAskBus) Answer(id string) (json.RawMessage, error) {
|
|
e, err := stdio.State(AnswersState).Get(id)
|
|
if err != nil || e == nil {
|
|
return nil, err
|
|
}
|
|
return e.Value, nil
|
|
}
|
|
|
|
// answerWait, scanWait and answerPoll are what the tools wait with; a test shortens them.
|
|
var (
|
|
answerWait = AnswerWait
|
|
scanWait = ScanWait
|
|
answerPoll = 500 * time.Millisecond
|
|
)
|
|
|
|
// askBus is the bus the tools use; a test puts a bus of its own in its place.
|
|
var askBus AskBus = sdkAskBus{}
|
|
|
|
func newAskID() string {
|
|
b := make([]byte, 12)
|
|
rand.Read(b)
|
|
return hex.EncodeToString(b)
|
|
}
|
|
|
|
// call queues the ask and waits for the device process's answer, at most wait. An answer that is an error is
|
|
// the error. Nothing here reads, opens or sends a credential (ADR 0436 §12).
|
|
func (s *Service) call(ctx context.Context, ask Ask, wait time.Duration) (map[string]any, error) {
|
|
poll := s.AnswerPoll
|
|
if wait <= 0 {
|
|
wait = AnswerWait
|
|
}
|
|
if poll <= 0 {
|
|
poll = 500 * time.Millisecond
|
|
}
|
|
ask.ID, ask.At = newAskID(), s.Now().UTC()
|
|
if err := s.Asks.Publish(ask.ID, ask); err != nil {
|
|
return nil, fmt.Errorf("the ask for the device process could not be queued: %w", err)
|
|
}
|
|
ctx, cancel := context.WithTimeout(ctx, wait)
|
|
defer cancel()
|
|
for {
|
|
raw, err := s.Asks.Answer(ask.ID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("the device process's answers cannot be read: %w", err)
|
|
}
|
|
if raw != nil {
|
|
var out map[string]any
|
|
if err := json.Unmarshal(raw, &out); err != nil {
|
|
return nil, fmt.Errorf("the device process's answer is not JSON: %w", err)
|
|
}
|
|
if msg, bad := out["error"].(string); bad {
|
|
return nil, fmt.Errorf("%s", msg)
|
|
}
|
|
return out, nil
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, fmt.Errorf("the device process did not answer within %s: it may not be running, or the device is slow (ask %s)",
|
|
wait, ask.ID)
|
|
case <-time.After(poll):
|
|
}
|
|
}
|
|
}
|
|
|
|
// PrinterSettings asks the device process to log in, read one page of the admin site and log out, and waits for
|
|
// its answer. Nothing is queued before the page is checked and printer and printer-certificate are known to be set.
|
|
func (s *Service) PrinterSettings(ctx context.Context, page string) (map[string]any, error) {
|
|
if page == "" {
|
|
page = DefaultPage
|
|
}
|
|
if !validPage(page) {
|
|
return nil, fmt.Errorf("page %q is not a page of the admin site: it starts with / and ends in .html", page)
|
|
}
|
|
if err := s.Settings.MissingPrinter(); err != nil {
|
|
return nil, err
|
|
}
|
|
return s.call(ctx, Ask{Op: OpSettings, Page: page}, s.AnswerWait)
|
|
}
|
|
|
|
func validPage(page string) bool {
|
|
return pagePattern.MatchString(page) && !strings.Contains(page, "..") && !strings.Contains(page, "//")
|
|
}
|
|
|
|
var (
|
|
scanKeyPattern = regexp.MustCompile(`^[0-9a-f]{64}\.pdf$`)
|
|
uploadPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
|
|
mediaPattern = regexp.MustCompile(`^[a-z0-9_.-]{1,64}$`)
|
|
sha256Pattern = regexp.MustCompile(`^[0-9a-f]{64}$`)
|
|
)
|
|
|
|
// Sides are the values the printer's sides take.
|
|
var sidesValues = []string{"one-sided", "two-sided-long-edge", "two-sided-short-edge"}
|
|
|
|
// DefaultMedia is the paper printer.print takes when none is named.
|
|
const DefaultMedia = "iso_a4_210x297mm"
|
|
|
|
// checkDocument is what is wrong with an ask for a scan's part, or nil.
|
|
func checkDocument(a Ask) error {
|
|
if !scanKeyPattern.MatchString(a.Object) {
|
|
return fmt.Errorf("object %q is not a scan's key: 64 hex digits and .pdf", a.Object)
|
|
}
|
|
if a.Part < 1 {
|
|
return fmt.Errorf("part is a whole number from 1")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// checkUpload is what is wrong with an ask for an upload's part, or nil: the numbers, the SHA-256, the size.
|
|
func checkUpload(a Ask) error {
|
|
if !uploadPattern.MatchString(a.Upload) {
|
|
return fmt.Errorf("upload %q is not an upload id: letters, digits, - and _, at most 64", a.Upload)
|
|
}
|
|
if a.Parts < 1 || a.Parts > MostParts {
|
|
return fmt.Errorf("parts is a whole number from 1 to %d, not %d", MostParts, a.Parts)
|
|
}
|
|
if a.Part < 1 || a.Part > a.Parts {
|
|
return fmt.Errorf("part %d is out of range: the upload has parts 1 to %d", a.Part, a.Parts)
|
|
}
|
|
if !sha256Pattern.MatchString(a.SHA256) {
|
|
return fmt.Errorf("sha256 is the whole document's SHA-256, 64 lower-case hex digits")
|
|
}
|
|
raw, err := base64.StdEncoding.DecodeString(a.Data)
|
|
if err != nil {
|
|
return fmt.Errorf("data is not base64: %v", err)
|
|
}
|
|
if len(raw) > MaxPart {
|
|
return fmt.Errorf("a part is at most %d bytes (700 KiB), not %d", MaxPart, len(raw))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// checkPrint is what is wrong with an ask to print, or nil, after the defaults are laid in.
|
|
func checkPrint(a *Ask) error {
|
|
if (a.Object == "") == (a.Upload == "") {
|
|
return fmt.Errorf("name either object (a scan's key) or upload (a completed upload's id), not both and not neither")
|
|
}
|
|
if a.Object != "" && !scanKeyPattern.MatchString(a.Object) {
|
|
return fmt.Errorf("object %q is not a scan's key: 64 hex digits and .pdf", a.Object)
|
|
}
|
|
if a.Upload != "" && !uploadPattern.MatchString(a.Upload) {
|
|
return fmt.Errorf("upload %q is not an upload id: letters, digits, - and _, at most 64", a.Upload)
|
|
}
|
|
if a.Copies == 0 {
|
|
a.Copies = 1
|
|
}
|
|
if a.Copies < 1 || a.Copies > 99 {
|
|
return fmt.Errorf("copies is a whole number from 1 to 99, not %d", a.Copies)
|
|
}
|
|
if a.Sides == "" {
|
|
a.Sides = "one-sided"
|
|
}
|
|
if !slices.Contains(sidesValues, a.Sides) {
|
|
return fmt.Errorf("sides is one of %s, not %q", strings.Join(sidesValues, ", "), a.Sides)
|
|
}
|
|
if a.Media == "" {
|
|
a.Media = DefaultMedia
|
|
}
|
|
if !mediaPattern.MatchString(a.Media) {
|
|
return fmt.Errorf("media %q is not a media size name such as %s", a.Media, DefaultMedia)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ScanDocument asks for one part of a scan kept in the bucket.
|
|
func (s *Service) ScanDocument(ctx context.Context, object string, part int) (map[string]any, error) {
|
|
a := Ask{Op: OpDocument, Object: object, Part: part}
|
|
if err := checkDocument(a); err != nil {
|
|
return nil, err
|
|
}
|
|
return s.call(ctx, a, s.AnswerWait)
|
|
}
|
|
|
|
// PrinterUpload sends one part of a document to be printed later, and says whether the upload is complete.
|
|
func (s *Service) PrinterUpload(ctx context.Context, a Ask) (map[string]any, error) {
|
|
a.Op = OpUpload
|
|
if err := checkUpload(a); err != nil {
|
|
return nil, err
|
|
}
|
|
return s.call(ctx, a, s.AnswerWait)
|
|
}
|
|
|
|
// PrinterPrint asks the device process to print a kept scan or a completed upload.
|
|
func (s *Service) PrinterPrint(ctx context.Context, a Ask) (map[string]any, error) {
|
|
a.Op = OpPrint
|
|
if err := checkPrint(&a); err != nil {
|
|
return nil, err
|
|
}
|
|
if s.Settings.Printer == "" {
|
|
return nil, unsetError("printer")
|
|
}
|
|
return s.call(ctx, a, s.AnswerWait)
|
|
}
|
|
|
|
// PrinterStatus is the printer's state over IPP and the scanner's over eSCL, none of it behind a login
|
|
// (ADR 0467 clause 5). A part that does not answer is an answer, with why, not an error. Which of printer and
|
|
// scanner is not set is said by name, and nothing is contacted.
|
|
func (s *Service) PrinterStatus(ctx context.Context) (map[string]any, error) {
|
|
var unset []string
|
|
if s.Settings.Printer == "" {
|
|
unset = append(unset, "printer")
|
|
}
|
|
if s.Settings.Scanner == "" {
|
|
unset = append(unset, "scanner")
|
|
}
|
|
if len(unset) > 0 {
|
|
return nil, unsetError(unset...)
|
|
}
|
|
out := map[string]any{}
|
|
if st, err := PrinterStateOf(ctx, s.Settings.Printer); err != nil {
|
|
out["answers"] = false
|
|
out["why"] = err.Error()
|
|
} else {
|
|
out["answers"] = true
|
|
out["state"] = st.State
|
|
out["state-code"] = st.StateCode
|
|
out["state-reasons"] = st.Reasons
|
|
out["make-and-model"] = st.MakeAndModel
|
|
out["markers"] = st.Markers
|
|
}
|
|
if es, err := NewClient(s.Settings.Scanner).Status(ctx); err != nil {
|
|
out["scanner"] = map[string]any{"answers": false, "why": err.Error()}
|
|
} else {
|
|
out["scanner"] = map[string]any{"answers": true, "state": es.State, "adf-state": es.AdfState}
|
|
}
|
|
return out, nil
|
|
}
|