PrintToVeraeTimeStamp/internal/netprint/control.go
Cursor Agent 5ed87da68f
Some checks failed
installers / netprint (push) Has been cancelled
installers / macos-pkg (push) Has been cancelled
installers / publish (push) Has been cancelled
Keep the macOS receipts SMB share across reboots.
Save the installer choice as shareReceiptsSMB, recreate the share when it is missing, and show the live smb:// address on the receipts page.

Co-authored-by: George Lambert <marchon@gmail.com>
2026-10-05 16:34:51 +00:00

762 lines
26 KiB
Go

package netprint
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net"
"net/http"
"net/url"
"runtime"
"strings"
"time"
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/auth"
)
// Status is the tray's view of the printer. The service fills it; the tray does not read config files.
type Status struct {
Running bool `json:"running"`
Name string `json:"name"`
Port int `json:"port"`
Addresses []string `json:"addresses"`
AdminURL string `json:"adminUrl"`
JobsToday int `json:"jobsToday"`
Recent []Record `json:"recent"`
ForwardPrinter string `json:"forwardPrinter"`
LaunchAtLogin bool `json:"launchAtLogin"`
TrayAtLogin bool `json:"trayAtLogin"`
KeyPresent bool `json:"keyPresent"`
ControlURL string `json:"controlUrl"`
// MDNSAdvertising is true only when an announcement was accepted or sent.
MDNSAdvertising bool `json:"mdnsAdvertising"`
MDNSMode string `json:"mdnsMode"`
MDNSDetail string `json:"mdnsDetail"`
MDNSError string `json:"mdnsError"`
MDNSLastSent string `json:"mdnsLastSent,omitempty"`
AllowPublic bool `json:"allowPublic"`
NetworkPublic bool `json:"networkPublic"`
SupportsPublic bool `json:"supportsPublicFirewall"`
ReceiptsURL string `json:"receiptsUrl"`
EmailEnabled bool `json:"emailEnabled"`
EmailTo string `json:"emailTo"`
EmailError string `json:"emailError"`
SMTPHost string `json:"smtpHost"`
SMTPUser string `json:"smtpUser"`
SMTPPasswordSet bool `json:"smtpPasswordSet"`
ReceiptsPINSet bool `json:"receiptsPinSet"`
Advertise bool `json:"advertise"`
AirDropEnabled bool `json:"airDropEnabled"`
SupportsAirDrop bool `json:"supportsAirDrop"`
// ShareReceiptsSMB is the saved choice to publish the receipts folder.
ShareReceiptsSMB bool `json:"shareReceiptsSMB"`
// SupportsSMBShare is true on macOS, where sharing(8) can create the point.
SupportsSMBShare bool `json:"supportsSMBShare"`
// SMBURL is the live address, for example
// smb://Georges-MacBook-Pro.local/Verae%20Receipts. Empty when the share is off.
SMBURL string `json:"smbUrl,omitempty"`
// SMBError is set when the share could not be created or refreshed.
SMBError string `json:"smbError,omitempty"`
// ReceiptsDir is the folder of job files. On macOS it is separate from
// the program so it can be shared read-only.
ReceiptsDir string `json:"receiptsDir"`
// IPPBind is "lan" when macOS bound 631 on LAN addresses only, or "all"
// when the listener is on every interface (including the 8632 fallback).
IPPBind string `json:"ippBind"`
}
// ServiceControl is how the service asks the OS to start, stop, or launch at login.
// The tray calls the control API, which calls this. It does not edit the printer config.
type ServiceControl interface {
Start() error
Stop() error
Restart() error
LaunchAtLogin() (bool, error)
SetLaunchAtLogin(bool) error
TrayAtLogin() (bool, error)
SetTrayAtLogin(bool) error
}
// Client talks only to the loopback control API.
type Client struct {
BaseURL string
HTTP *http.Client
}
// NewClient returns a client for 127.0.0.1 and the given control port.
func NewClient(port int) *Client {
if port <= 0 {
port = 8630
}
return &Client{BaseURL: fmt.Sprintf("http://127.0.0.1:%d", port)}
}
func (c *Client) http() *http.Client {
if c.HTTP != nil {
return c.HTTP
}
return &http.Client{Timeout: 4 * time.Second}
}
// Status fetches the service status. A connection error means the service is stopped.
func (c *Client) Status() (Status, error) {
var st Status
res, err := c.http().Get(strings.TrimRight(c.BaseURL, "/") + "/api/v1/status")
if err != nil {
return st, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return st, fmt.Errorf("status HTTP %d", res.StatusCode)
}
err = json.NewDecoder(res.Body).Decode(&st)
return st, err
}
func (c *Client) Service(action string) error {
return c.post("/api/v1/service", map[string]string{"action": action}, nil)
}
func (c *Client) SetLaunchAtLogin(enabled bool) error {
return c.post("/api/v1/launch", map[string]bool{"enabled": enabled}, nil)
}
func (c *Client) SetTrayAtLogin(enabled bool) error {
return c.post("/api/v1/tray", map[string]bool{"enabled": enabled}, nil)
}
// SetAllowPublic asks the service to open or close the Public firewall profile.
func (c *Client) SetAllowPublic(on bool) error {
return c.post("/api/v1/firewall", map[string]bool{"allowPublic": on}, nil)
}
// Settings asks the service to store printer name, forward target, and port.
func (c *Client) Settings(name, forward string, port int) error {
body := map[string]any{"name": name, "forwardPrinter": forward}
if port > 0 {
body["port"] = port
}
return c.put("/api/v1/settings", body, nil)
}
// SetEmailEnabled turns receipt email on or off without changing other settings.
func (c *Client) SetEmailEnabled(on bool) error {
return c.put("/api/v1/settings", map[string]bool{"emailEnabled": on}, nil)
}
// SetAdvertise turns Bonjour advertising on or off without restarting IPP.
func (c *Client) SetAdvertise(on bool) error {
return c.put("/api/v1/settings", map[string]bool{"advertise": on}, nil)
}
// SetAirDropEnabled turns the macOS AirDrop-after-job option on or off.
func (c *Client) SetAirDropEnabled(on bool) error {
return c.put("/api/v1/settings", map[string]bool{"airDropEnabled": on}, nil)
}
// SetShareReceiptsSMB turns the macOS receipts SMB share on or off.
func (c *Client) SetShareReceiptsSMB(on bool) error {
return c.put("/api/v1/settings", map[string]bool{"shareReceiptsSMB": on}, nil)
}
// EnsureReceiptsShare asks the daemon to recreate the share when the saved choice is on.
func (c *Client) EnsureReceiptsShare() error {
return c.post("/api/v1/receipts-share/ensure", map[string]bool{}, nil)
}
// Auth asks the service to store an xVerae- signing key.
func (c *Client) Auth(apiKey string) error {
return c.post("/api/v1/auth", map[string]string{"apiKey": apiKey}, nil)
}
func (c *Client) post(path string, body, out any) error {
return c.do(http.MethodPost, path, body, out)
}
func (c *Client) put(path string, body, out any) error {
return c.do(http.MethodPut, path, body, out)
}
func (c *Client) do(method, path string, body, out any) error {
var rdr io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return err
}
rdr = bytes.NewReader(b)
}
req, err := http.NewRequest(method, strings.TrimRight(c.BaseURL, "/")+path, rdr)
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
res, err := c.http().Do(req)
if err != nil {
return err
}
defer res.Body.Close()
b, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20))
if res.StatusCode >= 300 {
return fmt.Errorf("%s %s: %s", method, path, strings.TrimSpace(string(b)))
}
if out != nil && len(b) > 0 {
return json.Unmarshal(b, out)
}
return nil
}
func (s *Server) status() Status {
cfg := s.snapshot()
addrs := lanHosts()
admin := s.receiptsURL()
list := s.store.List()
today := 0
now := time.Now()
recent := []Record{}
for i := len(list) - 1; i >= 0; i-- {
if sameDay(list[i].Received, now) {
today++
}
if len(recent) < 8 {
recent = append(recent, list[i])
}
}
launch, _ := s.ctrl.LaunchAtLogin()
tray, _ := s.ctrl.TrayAtLogin()
s.mu.Lock()
mdns := s.mdns
emailErr := s.emailErr
smbErr := s.smbErr
s.mu.Unlock()
last := ""
if !mdns.LastSent.IsZero() {
last = mdns.LastSent.UTC().Format(time.RFC3339)
}
sec, secErr := loadSecrets(secretsPath(s.configPath, cfg.DataDir))
passwordSet := secErr == nil && strings.TrimSpace(sec.SMTPPassword) != ""
pinSet := secErr == nil && strings.TrimSpace(sec.ReceiptsPIN) != ""
if emailErr == "" && cfg.EmailOn() && !passwordSet {
emailErr = "SMTP password is not set"
}
return Status{
Running: true,
Name: cfg.Name,
Port: s.port,
Addresses: addrs,
AdminURL: admin,
JobsToday: today,
Recent: recent,
ForwardPrinter: cfg.ForwardPrinter,
LaunchAtLogin: launch,
TrayAtLogin: tray,
KeyPresent: auth.HasXVeraeToken(),
ControlURL: s.controlURL,
MDNSAdvertising: mdns.Advertising,
MDNSMode: mdns.Mode,
MDNSDetail: mdns.Detail,
MDNSError: mdns.Err,
MDNSLastSent: last,
AllowPublic: cfg.PublicNetworks(),
NetworkPublic: cachedNetworkPublic(),
SupportsPublic: supportsPublicFirewall(),
ReceiptsURL: admin,
EmailEnabled: cfg.EmailOn(),
EmailTo: cfg.EmailTo,
EmailError: emailErr,
SMTPHost: cfg.SMTPHost,
SMTPUser: cfg.SMTPUser,
SMTPPasswordSet: passwordSet,
ReceiptsPINSet: pinSet,
Advertise: cfg.MDNS(),
AirDropEnabled: cfg.AirDropOn(),
SupportsAirDrop: runtime.GOOS == "darwin",
ShareReceiptsSMB: cfg.ShareReceiptsOn(),
SupportsSMBShare: supportsSMBShare(),
SMBURL: smbURLIfOn(cfg),
SMBError: smbErr,
ReceiptsDir: cfg.DataDir,
IPPBind: s.ippBind,
}
}
func sameDay(a, b time.Time) bool {
aa, bb := a.Local(), b.Local()
return aa.Year() == bb.Year() && aa.YearDay() == bb.YearDay()
}
func lanHosts() []string {
var out []string
for _, ip := range lanIPv4() {
out = append(out, ip.String())
}
if len(out) == 0 {
out = []string{"127.0.0.1"}
}
return out
}
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
writeJSON(w, s.status())
}
func (s *Server) handleService(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var body struct {
Action string `json:"action"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
var err error
switch strings.ToLower(strings.TrimSpace(body.Action)) {
case "start":
err = s.ctrl.Start()
case "stop":
err = s.ctrl.Stop()
case "restart":
err = s.ctrl.Restart()
default:
http.Error(w, "action must be start, stop, or restart", http.StatusBadRequest)
return
}
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]string{"ok": body.Action})
}
func (s *Server) handleLaunch(w http.ResponseWriter, r *http.Request) {
s.handleToggle(w, r, s.ctrl.SetLaunchAtLogin)
}
func (s *Server) handleTray(w http.ResponseWriter, r *http.Request) {
s.handleToggle(w, r, s.ctrl.SetTrayAtLogin)
}
func (s *Server) handleFirewall(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var body struct {
AllowPublic bool `json:"allowPublic"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
s.mu.Lock()
s.cfg.AllowPublic = &body.AllowPublic
cfg := s.cfg
path := s.configPath
s.mu.Unlock()
if err := Save(path, cfg); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
applyFirewall(body.AllowPublic)
log.Printf("netprint: firewall public networks=%v", body.AllowPublic)
writeJSON(w, map[string]bool{"allowPublic": body.AllowPublic})
}
func (s *Server) handleToggle(w http.ResponseWriter, r *http.Request, set func(bool) error) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var body struct {
Enabled bool `json:"enabled"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := set(body.Enabled); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]bool{"enabled": body.Enabled})
}
func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut && r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var body struct {
Name *string `json:"name"`
ForwardPrinter *string `json:"forwardPrinter"`
Port *int `json:"port"`
AllowPublic *bool `json:"allowPublic"`
EmailEnabled *bool `json:"emailEnabled"`
EmailTo *string `json:"emailTo"`
SMTPHost *string `json:"smtpHost"`
SMTPPort *int `json:"smtpPort"`
SMTPTLS *string `json:"smtpTLS"`
SMTPUser *string `json:"smtpUser"`
SMTPPassword *string `json:"smtpPassword"`
ClearPassword *bool `json:"clearPassword"`
ReceiptsPIN *string `json:"receiptsPin"`
ClearPIN *bool `json:"clearPin"`
Advertise *bool `json:"advertise"`
AirDropEnabled *bool `json:"airDropEnabled"`
ShareReceiptsSMB *bool `json:"shareReceiptsSMB"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil {
http.Error(w, "bad settings", http.StatusBadRequest)
return
}
s.mu.Lock()
cfg := s.cfg
path := s.configPath
if body.Name != nil && strings.TrimSpace(*body.Name) != "" {
cfg.Name = strings.TrimSpace(*body.Name)
}
if body.ForwardPrinter != nil {
cfg.ForwardPrinter = strings.TrimSpace(*body.ForwardPrinter)
}
if body.Port != nil && *body.Port > 0 && *body.Port < 65536 {
cfg.Port = *body.Port
}
if body.AllowPublic != nil {
v := *body.AllowPublic
cfg.AllowPublic = &v
}
if body.EmailEnabled != nil {
v := *body.EmailEnabled
cfg.EmailEnabled = &v
}
if body.Advertise != nil {
v := *body.Advertise
cfg.Advertise = &v
}
if body.AirDropEnabled != nil {
v := *body.AirDropEnabled
cfg.AirDropEnabled = &v
}
shareChanged := false
if body.ShareReceiptsSMB != nil {
v := *body.ShareReceiptsSMB
cfg.ShareReceiptsSMB = &v
shareChanged = true
}
if body.EmailTo != nil {
cfg.EmailTo = strings.TrimSpace(*body.EmailTo)
}
if body.SMTPHost != nil {
cfg.SMTPHost = strings.TrimSpace(*body.SMTPHost)
}
if body.SMTPPort != nil && *body.SMTPPort > 0 && *body.SMTPPort < 65536 {
cfg.SMTPPort = *body.SMTPPort
}
if body.SMTPTLS != nil {
cfg.SMTPTLS = strings.TrimSpace(*body.SMTPTLS)
}
if body.SMTPUser != nil {
cfg.SMTPUser = strings.TrimSpace(*body.SMTPUser)
}
if err := cfg.normalize(); err != nil {
s.mu.Unlock()
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
s.cfg = cfg
if s.printer != nil {
s.printer.Name = cfg.Name
}
s.mu.Unlock()
if err := Save(path, cfg); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := applySecretFields(path, cfg.DataDir, secretFields{
password: body.SMTPPassword, clearPassword: body.ClearPassword,
pin: body.ReceiptsPIN, clearPIN: body.ClearPIN,
}); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if body.AllowPublic != nil {
applyFirewall(*body.AllowPublic)
log.Printf("netprint: firewall public networks=%v", *body.AllowPublic)
}
log.Printf("netprint: settings saved name=%q email=%v advertise=%v airdrop=%v share=%v", cfg.Name, cfg.EmailOn(), cfg.MDNS(), cfg.AirDropOn(), cfg.ShareReceiptsOn())
s.syncAdvertiser()
if shareChanged {
s.noteShare(cfg.ShareReceiptsOn())
}
writeJSON(w, map[string]any{"ok": true, "restart": "port changes apply after restart"})
}
func (s *Server) handleAuth(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var body struct {
APIKey string `json:"apiKey"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
key := strings.TrimSpace(body.APIKey)
if err := auth.SaveXVeraeToken(key); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := auth.SaveMachineToken(key); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]bool{"ok": true})
}
type secretFields struct {
password *string
clearPassword *bool
pin *string
clearPIN *bool
}
func applySecretFields(configPath, dataDir string, f secretFields) error {
if f.password == nil && f.clearPassword == nil && f.pin == nil && f.clearPIN == nil {
return nil
}
path := secretsPath(configPath, dataDir)
sec, err := loadSecrets(path)
if err != nil {
return err
}
if f.clearPassword != nil && *f.clearPassword {
sec.SMTPPassword = ""
} else if f.password != nil && *f.password != "" {
sec.SMTPPassword = *f.password
}
if f.clearPIN != nil && *f.clearPIN {
sec.ReceiptsPIN = ""
} else if f.pin != nil && strings.TrimSpace(*f.pin) != "" {
sec.ReceiptsPIN = strings.TrimSpace(*f.pin)
}
return saveSecrets(path, sec)
}
func (s *Server) handleSettingsPage(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
port := 0
fmt.Sscan(r.FormValue("port"), &port)
smtpPort := 0
fmt.Sscan(r.FormValue("smtpPort"), &smtpPort)
s.mu.Lock()
cfg := s.cfg
path := s.configPath
if n := strings.TrimSpace(r.FormValue("name")); n != "" {
cfg.Name = n
}
cfg.ForwardPrinter = strings.TrimSpace(r.FormValue("forwardPrinter"))
if port > 0 && port < 65536 {
cfg.Port = port
}
pub := r.FormValue("allowPublic") == "1"
cfg.AllowPublic = &pub
emailOn := r.FormValue("emailEnabled") == "1"
cfg.EmailEnabled = &emailOn
shareChanged := false
if runtime.GOOS == "darwin" {
adv := r.FormValue("advertise") == "1"
cfg.Advertise = &adv
air := r.FormValue("airDropEnabled") == "1"
cfg.AirDropEnabled = &air
share := r.FormValue("shareReceiptsSMB") == "1"
cfg.ShareReceiptsSMB = &share
shareChanged = true
}
cfg.EmailTo = strings.TrimSpace(r.FormValue("emailTo"))
cfg.SMTPHost = strings.TrimSpace(r.FormValue("smtpHost"))
if smtpPort > 0 && smtpPort < 65536 {
cfg.SMTPPort = smtpPort
}
cfg.SMTPTLS = strings.TrimSpace(r.FormValue("smtpTLS"))
cfg.SMTPUser = strings.TrimSpace(r.FormValue("smtpUser"))
if err := cfg.normalize(); err != nil {
s.mu.Unlock()
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
s.cfg = cfg
if s.printer != nil {
s.printer.Name = cfg.Name
}
s.mu.Unlock()
applyFirewall(pub)
if err := Save(path, cfg); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
pw := r.FormValue("smtpPassword")
pin := r.FormValue("receiptsPin")
clearPW := r.FormValue("clearPassword") == "1"
clearPIN := r.FormValue("clearPin") == "1"
if err := applySecretFields(path, cfg.DataDir, secretFields{
password: &pw, clearPassword: &clearPW, pin: &pin, clearPIN: &clearPIN,
}); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if key := strings.TrimSpace(r.FormValue("apiKey")); key != "" {
if err := auth.SaveXVeraeToken(key); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := auth.SaveMachineToken(key); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
log.Printf("netprint: settings saved name=%q email=%v advertise=%v airdrop=%v share=%v", cfg.Name, cfg.EmailOn(), cfg.MDNS(), cfg.AirDropOn(), cfg.ShareReceiptsOn())
s.syncAdvertiser()
if shareChanged {
s.noteShare(cfg.ShareReceiptsOn())
}
http.Redirect(w, r, "/settings?saved=1", http.StatusSeeOther)
return
}
cfg := s.snapshot()
saved := r.URL.Query().Get("saved") == "1"
sec, _ := loadSecrets(secretsPath(s.configPath, cfg.DataDir))
pwNote := "Password is not set."
if strings.TrimSpace(sec.SMTPPassword) != "" {
pwNote = "A password is saved. Leave the field blank to keep it."
}
pinNote := "No access PIN. The page is open on Private and Domain networks."
if strings.TrimSpace(sec.ReceiptsPIN) != "" {
pinNote = "A PIN is saved. Leave the field blank to keep it."
}
mac := ""
if runtime.GOOS == "darwin" {
shareNote := ""
if u := smbURLIfOn(cfg); u != "" {
shareNote = fmt.Sprintf(`<p>Files: <a href="%s">%s</a>. On iPhone, open the Files app and connect to that server. The first connection needs a username and password from this Mac. Guest access is off.</p>`, htmlEscape(u), htmlEscape(u))
}
mac = fmt.Sprintf(`<h2>Mac</h2>
<label class="check"><input name="advertise" type="checkbox" value="1" %s> Advertise on the network</label>
<p>mDNSResponder registers <code>_ipp._tcp</code> with the AirPrint <code>_universal</code> and IPP Everywhere <code>_print</code> subtypes. macOS asks once for Local Network permission for verae-print. Allow it.</p>
<label class="check"><input name="airDropEnabled" type="checkbox" value="1" %s> AirDrop stamped PDF after each job</label>
<p>The menu-bar app, running in your login session, opens the AirDrop share sheet with the stamped certificate and the receipt JSON. The root daemon does not.</p>
<label class="check"><input name="shareReceiptsSMB" type="checkbox" value="1" %s> Share receipts folder over the network (SMB)</label>
<p>Shares <code>/Users/Shared/Verae Receipts</code> as Verae Receipts. SMB only, guest access off, read-only. File Sharing stays on across reboots. The folder does not contain the program.</p>
%s`,
checkedAttr(cfg.MDNS()), checkedAttr(cfg.AirDropOn()), checkedAttr(cfg.ShareReceiptsOn()), shareNote)
}
var b strings.Builder
b.WriteString("<!doctype html><meta charset=utf-8><meta name=viewport content=\"width=device-width, initial-scale=1\"><title>Verae network printer</title>")
b.WriteString("<style>body{font:16px/1.45 system-ui,sans-serif;max-width:36rem;margin:2rem auto;padding:0 1rem}label{display:block;margin-top:1rem}input,select{width:100%;padding:.4rem;box-sizing:border-box}label.check input{width:auto}</style>")
b.WriteString("<h1>Verae network printer</h1>")
if saved {
b.WriteString("<p>Saved. A port change applies after the service restarts.</p>")
}
receipts := htmlEscape(s.receiptsURL())
fmt.Fprintf(&b, `<p>Phone receipts: <a href="%s">%s</a></p>`, receipts, receipts)
fmt.Fprintf(&b, `<p>Receipt files: <code>%s</code>. Share this folder read-only. It does not contain the program.</p>`, htmlEscape(cfg.DataDir))
fmt.Fprintf(&b, `<form method="post" action="/settings">
<label>Printer name<input name="name" value="%s"></label>
<p>The default is Verae Timestamp plus this computer's name, so two machines are not both called Verae Timestamp. A name that is already on the network is renamed. Any other name you type here is kept.</p>
<label>Forward to printer <input name="forwardPrinter" value="%s" placeholder="queue name or ipp://host/ipp/print"></label>
<label>IPP port<input name="port" value="%d"></label>
<label class="check"><input name="allowPublic" type="checkbox" value="1" %s> Allow Public networks (off by default). Private and Domain stay allowed. On a cafe or guest Wi-Fi this exposes the printer to everyone on that network.</label>
<label>Signing key (xVerae-)<input name="apiKey" type="password" autocomplete="off" placeholder="leave blank to keep the current key"></label>
<h2>Email the stamped PDF</h2>
<p>After each completed job, mail the certificate, the original document, and the receipt summary. A mail failure is logged and does not fail the print. For Gmail use smtp.gmail.com, port 587 with STARTTLS (or 465 with TLS). The username is the Gmail address and the password is a 16-character app password, not the account password. The password is stored with Windows DPAPI, or in a root-only file on macOS and Linux. It is not written to netprint.json or the log.</p>
<label class="check"><input name="emailEnabled" type="checkbox" value="1" %s> Email stamped PDFs</label>
<label>Recipients<input name="emailTo" value="%s" placeholder="name@example.com, other@example.com"></label>
<label>SMTP host<input name="smtpHost" value="%s"></label>
<label>SMTP port<input name="smtpPort" value="%d"></label>
<label>Security<select name="smtpTLS"><option value="starttls" %s>STARTTLS</option><option value="tls" %s>TLS (implicit)</option></select></label>
<label>SMTP username<input name="smtpUser" value="%s" autocomplete="off"></label>
<label>SMTP password<input name="smtpPassword" type="password" autocomplete="off" placeholder="leave blank to keep the saved password"></label>
<p>%s</p>
<label class="check"><input name="clearPassword" type="checkbox" value="1"> Clear the saved SMTP password</label>
<h2>Receipts page</h2>
<p>The page is on the printer port, which stays limited to Private and Domain networks unless Public networks are allowed. An optional PIN is required on the LAN. This computer's tray does not need it.</p>
<label>Access PIN<input name="receiptsPin" type="password" autocomplete="off" placeholder="leave blank to keep the saved PIN"></label>
<p>%s</p>
<label class="check"><input name="clearPin" type="checkbox" value="1"> Clear the access PIN</label>
%s
<p><button type="submit">Save</button> <a href="/receipts">Receipts on this computer</a></p>
</form>`,
htmlEscape(cfg.Name), htmlEscape(cfg.ForwardPrinter), cfg.Port, checkedAttr(cfg.PublicNetworks()),
checkedAttr(cfg.EmailOn()), htmlEscape(cfg.EmailTo), htmlEscape(cfg.SMTPHost), cfg.SMTPPort,
selectedAttr(cfg.SMTPTLS != "tls"), selectedAttr(cfg.SMTPTLS == "tls"), htmlEscape(cfg.SMTPUser),
htmlEscape(pwNote), htmlEscape(pinNote), mac)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(b.String()))
}
func selectedAttr(on bool) string {
if on {
return "selected"
}
return ""
}
func checkedAttr(on bool) string {
if on {
return "checked"
}
return ""
}
func htmlEscape(s string) string {
return strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", `"`, "&#34;").Replace(s)
}
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
_ = enc.Encode(v)
}
func loopback(r *http.Request) bool {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
ip := net.ParseIP(host)
return ip != nil && ip.IsLoopback()
}
// StoppedStatus is what the tray shows when the control API is down.
func StoppedStatus() Status {
return Status{
Running: false,
Name: defaultServiceName(),
Port: 631,
SupportsPublic: supportsPublicFirewall(),
}
}
// OpenURL opens a local admin or receipt page in the user's browser.
func OpenURL(raw string) error {
u, err := url.Parse(raw)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
return fmt.Errorf("refusing to open %q", raw)
}
return openURL(raw)
}