Claim document-converter and serve convert: queue the ask for the converter and read its answer, holding no credential (issue 1595)
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// The ask the verb queues for the converter process (ADR 0468 §7; the shape of brother's device asks, ADR 0436 §6):
|
||||
// on the work queue convert-asks, whose one consumer is the module's process `converter`, which writes its answer
|
||||
// under the ask's id in the state convert-answers.
|
||||
const (
|
||||
// EventAsked is the event on the work queue; the stream convert-asks carries it.
|
||||
EventAsked = "convert.asked"
|
||||
// AnswersState is the state the converter writes its answers in.
|
||||
AnswersState = "convert-answers"
|
||||
// ConvertWait is how long the verb waits for the converter: a document server converts a large document in
|
||||
// seconds to a minute or two.
|
||||
ConvertWait = 120 * time.Second
|
||||
)
|
||||
|
||||
// Types are the document types the converter takes (ADR 0468 §7). Any other is refused by name.
|
||||
var Types = []string{"docx", "doc", "xlsx", "xls", "pptx", "ppt", "odt", "ods", "odp", "rtf", "txt", "html"}
|
||||
|
||||
// Ask is what the verb queues: the operation, when it was asked, and the operation's arguments.
|
||||
type Ask struct {
|
||||
ID string `json:"id"`
|
||||
Op string `json:"op"`
|
||||
At time.Time `json:"asked-at"`
|
||||
Deadline time.Time `json:"deadline"`
|
||||
// Source is the signed GET link of the document, Type its type, Target the signed PUT link of the PDF.
|
||||
Source string `json:"source"`
|
||||
Type string `json:"type"`
|
||||
Target string `json:"target"`
|
||||
}
|
||||
|
||||
// OpConvert is the one operation.
|
||||
const OpConvert = "convert"
|
||||
|
||||
// AskBus is how the tool reaches the converter: it queues an ask and reads 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
|
||||
}
|
||||
|
||||
// Service is the tool side.
|
||||
type Service struct {
|
||||
Asks AskBus
|
||||
Now func() time.Time
|
||||
// Wait is how long an answer is waited for, Poll how often it is looked for.
|
||||
Wait, Poll time.Duration
|
||||
}
|
||||
|
||||
func newService() *Service {
|
||||
return &Service{Asks: sdkAskBus{}, Now: time.Now, Wait: ConvertWait, Poll: 500 * time.Millisecond}
|
||||
}
|
||||
|
||||
func newAskID() string {
|
||||
b := make([]byte, 12)
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// cleanType is a type as asked, lower-case and without a leading dot, when it is one the converter takes.
|
||||
func cleanType(raw string) (string, error) {
|
||||
t := strings.ToLower(strings.TrimPrefix(strings.TrimSpace(raw), "."))
|
||||
for _, ok := range Types {
|
||||
if t == ok {
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
taken := append([]string(nil), Types...)
|
||||
sort.Strings(taken)
|
||||
return "", fmt.Errorf("type %q is not one the converter takes: %s", raw, strings.Join(taken, ", "))
|
||||
}
|
||||
|
||||
func checkLink(name, raw string) error {
|
||||
u, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
|
||||
return fmt.Errorf("%s is a signed http(s) link, such as one object-store.link gives", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Convert queues one ask and waits for the converter's answer {size, sha256} (the PDF it put to target). Nothing is
|
||||
// queued for a type the converter does not take or a link that is not one.
|
||||
func (s *Service) Convert(ctx context.Context, source, typ, target string) (map[string]any, error) {
|
||||
t, err := cleanType(typ)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := checkLink("source", source); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := checkLink("target", target); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
at := s.Now().UTC()
|
||||
ask := Ask{ID: newAskID(), Op: OpConvert, At: at, Deadline: at.Add(s.Wait), Source: strings.TrimSpace(source), Type: t, Target: strings.TrimSpace(target)}
|
||||
if err := s.Asks.Publish(ask.ID, ask); err != nil {
|
||||
return nil, fmt.Errorf("the ask for the converter could not be queued: %w", err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, s.Wait)
|
||||
defer cancel()
|
||||
for {
|
||||
raw, err := s.Asks.Answer(ask.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the converter's answers cannot be read (ask %s): %w", ask.ID, err)
|
||||
}
|
||||
if raw != nil {
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return nil, fmt.Errorf("the converter's answer is not JSON (ask %s): %w", ask.ID, err)
|
||||
}
|
||||
if msg, bad := out["error"].(string); bad {
|
||||
return nil, fmt.Errorf("%s (ask %s)", msg, ask.ID)
|
||||
}
|
||||
out["ask"] = ask.ID
|
||||
return out, nil
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, fmt.Errorf("timed out: the converter did not answer within %s; it may not be running (ask %s)", s.Wait, ask.ID)
|
||||
case <-time.After(s.Poll):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func tools(s *Service) []stdio.Tool {
|
||||
return []stdio.Tool{{
|
||||
Name: "document-converter.convert",
|
||||
Description: "Turn a Word, spreadsheet, slide, text or web document into a PDF: the document is read from a signed GET " +
|
||||
"link and the PDF is put to a signed PUT link, so no document crosses the bus. Takes " + strings.Join(Types, ", ") +
|
||||
"; any other type is refused by name. Answers the PDF's size and SHA-256, or the document server's error.",
|
||||
Input: map[string]any{
|
||||
"source": map[string]any{"type": "string", "description": "a signed GET link of the document"},
|
||||
"type": map[string]any{"type": "string", "description": "the document's type: " + strings.Join(Types, ", ")},
|
||||
"target": map[string]any{"type": "string", "description": "a signed PUT link for the PDF"},
|
||||
},
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
str := func(k string) string { v, _ := a[k].(string); return v }
|
||||
return s.Convert(context.Background(), str("source"), str("type"), str("target"))
|
||||
},
|
||||
}}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeBus is the work queue and the state: it records each ask and lets a test write the answer, as the converter
|
||||
// process would under the ask's id.
|
||||
type fakeBus struct {
|
||||
mu sync.Mutex
|
||||
asks []Ask
|
||||
answers map[string]json.RawMessage
|
||||
answerOn func(Ask) json.RawMessage // answers an ask as it is published
|
||||
}
|
||||
|
||||
func (b *fakeBus) Publish(id string, body any) error {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
raw, _ := json.Marshal(body)
|
||||
var a Ask
|
||||
_ = json.Unmarshal(raw, &a)
|
||||
b.asks = append(b.asks, a)
|
||||
if b.answerOn != nil {
|
||||
if ans := b.answerOn(a); ans != nil {
|
||||
b.answers[id] = ans
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *fakeBus) Answer(id string) (json.RawMessage, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.answers[id], nil
|
||||
}
|
||||
|
||||
func rig(wait time.Duration) (*Service, *fakeBus) {
|
||||
b := &fakeBus{answers: map[string]json.RawMessage{}}
|
||||
return &Service{Asks: b, Now: time.Now, Wait: wait, Poll: time.Millisecond}, b
|
||||
}
|
||||
|
||||
const get, put = "http://novox.internal:20001/b/doc.docx?X-Amz-Signature=a", "http://novox.internal:20001/b/doc.pdf?X-Amz-Signature=b"
|
||||
|
||||
// 1595/AC1: one ask with those fields is published on convert-asks, and the verb answers what the converter wrote under
|
||||
// the ask's id.
|
||||
func TestConvertQueuesOneAskAndAnswersWhatTheConverterWrote(t *testing.T) {
|
||||
s, b := rig(time.Second)
|
||||
b.answerOn = func(a Ask) json.RawMessage { return json.RawMessage(`{"size": 4096, "sha256": "ab12"}`) }
|
||||
got, err := s.Convert(context.Background(), get, "DOCX", put)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(b.asks) != 1 {
|
||||
t.Fatalf("%d asks queued", len(b.asks))
|
||||
}
|
||||
a := b.asks[0]
|
||||
if a.Op != OpConvert || a.Source != get || a.Type != "docx" || a.Target != put || a.ID == "" {
|
||||
t.Fatalf("ask %+v", a)
|
||||
}
|
||||
if got["size"] != float64(4096) || got["sha256"] != "ab12" || got["ask"] != a.ID {
|
||||
t.Fatalf("answer %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 1595/AC1: an error the converter wrote is the verb's error.
|
||||
func TestConvertAnswersTheConvertersError(t *testing.T) {
|
||||
s, b := rig(time.Second)
|
||||
b.answerOn = func(a Ask) json.RawMessage {
|
||||
return json.RawMessage(`{"error": "-4: the document server could not download the file"}`)
|
||||
}
|
||||
if _, err := s.Convert(context.Background(), get, "doc", put); err == nil || !strings.Contains(err.Error(), "-4: the document server") {
|
||||
t.Fatalf("error %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1595/AC2: a type outside the twelve is refused naming the types taken, and nothing is queued.
|
||||
func TestConvertRefusesOtherTypes(t *testing.T) {
|
||||
s, b := rig(time.Second)
|
||||
for _, typ := range []string{"exe", "pdf", "", "docm", "jpg"} {
|
||||
_, err := s.Convert(context.Background(), get, typ, put)
|
||||
if err == nil || !strings.Contains(err.Error(), "docx") || !strings.Contains(err.Error(), "txt") {
|
||||
t.Errorf("type %q: %v", typ, err)
|
||||
}
|
||||
}
|
||||
if _, err := s.Convert(context.Background(), "file:///etc/passwd", "docx", put); err == nil {
|
||||
t.Error("a file link was taken")
|
||||
}
|
||||
if len(b.asks) != 0 {
|
||||
t.Fatalf("%d asks queued", len(b.asks))
|
||||
}
|
||||
if len(Types) != 12 {
|
||||
t.Fatalf("%d types", len(Types))
|
||||
}
|
||||
}
|
||||
|
||||
// 1595/AC3: with no answer within the bound the verb says it timed out, with the ask id.
|
||||
func TestConvertTimesOutNamingTheAsk(t *testing.T) {
|
||||
s, b := rig(30 * time.Millisecond)
|
||||
_, err := s.Convert(context.Background(), get, "docx", put)
|
||||
if err == nil || !strings.Contains(err.Error(), "timed out") || !strings.Contains(err.Error(), b.asks[0].ID) {
|
||||
t.Fatalf("error %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1595/AC4: the tool side opens no credential: its code never names the JWT or a secret file, and the manifest's
|
||||
// tools bundle is given no env naming one.
|
||||
func TestTheToolSideHoldsNoCredential(t *testing.T) {
|
||||
// The tool side is these two files; the converter process (issue 1596) is code of its own, which alone reads the token.
|
||||
for _, f := range []string{"main.go", "convert.go"} {
|
||||
raw, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, word := range []string{"jwt", "JWT", ".secret", "os.ReadFile", "os.Open"} {
|
||||
if strings.Contains(string(raw), word) {
|
||||
t.Errorf("%s mentions %q", f, word)
|
||||
}
|
||||
}
|
||||
}
|
||||
m := manifest(t)
|
||||
for _, a := range m["build"].(map[string]any)["artifacts"].([]any) {
|
||||
if env, ok := a.(map[string]any)["env"]; ok {
|
||||
t.Errorf("an artifact is given env %v", env)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func manifest(t *testing.T) map[string]any {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile("../../module.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// 1595/AC5: only-office claims document-converter (scope mesh) serving convert, declares the stream convert-asks as a
|
||||
// work queue and the state convert-answers (the shape of brother's device-asks and device-answers).
|
||||
func TestManifestClaimsTheSeat(t *testing.T) {
|
||||
m := manifest(t)
|
||||
claims := m["claims"].([]any)
|
||||
c := claims[0].(map[string]any)
|
||||
if len(claims) != 1 || c["name"] != "document-converter" || c["scope"] != "mesh" ||
|
||||
len(c["serves"].([]any)) != 1 || c["serves"].([]any)[0] != "convert" {
|
||||
t.Fatalf("claims %v", claims)
|
||||
}
|
||||
streams := m["streams"].([]any)
|
||||
s := streams[0].(map[string]any)
|
||||
if len(streams) != 1 || s["name"] != "convert-asks" || s["retention"] != "work-queue" || s["max-deliveries"] != float64(3) ||
|
||||
s["events"].([]any)[0] != EventAsked {
|
||||
t.Fatalf("streams %v", streams)
|
||||
}
|
||||
state := m["state"].([]any)
|
||||
st := state[0].(map[string]any)
|
||||
if len(state) != 1 || st["name"] != AnswersState || st["ttl-seconds"] != float64(600) {
|
||||
t.Fatalf("state %v", state)
|
||||
}
|
||||
found := false
|
||||
for _, a := range m["build"].(map[string]any)["artifacts"].([]any) {
|
||||
found = found || a.(map[string]any)["from"] == "cmd/only-office"
|
||||
}
|
||||
if !found {
|
||||
t.Error("no Go bundle")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheVerbIsListedUnderTheSeat(t *testing.T) {
|
||||
s, _ := rig(time.Second)
|
||||
ts := tools(s)
|
||||
if len(ts) != 1 || ts[0].Name != "document-converter.convert" {
|
||||
t.Fatalf("tools %v", ts)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// only-office's Go part (novox/hq ADR 0468 §7): the tool side of the seat `document-converter`. A caller asks to
|
||||
// have a document turned into a PDF, by a signed link to read it from and a signed link to put the PDF to; the
|
||||
// tool side queues the ask for the module's `converter` process (issue 1596) and reads its answer. It holds no
|
||||
// credential: the document server's token is the process's alone (ADR 0436 §12).
|
||||
//
|
||||
// stdout is the MCP channel; what this module says, it says on stderr.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := stdio.Serve("", tools(newService())); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[only-office] %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module only-office
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021122348-6c857e1c025f
|
||||
@@ -0,0 +1,2 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021122348-6c857e1c025f h1:7J/9jOfSfjfYpf8MOj96HwQRViQUkBNMb71RHP2mjn8=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021122348-6c857e1c025f/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
@@ -14,6 +14,34 @@
|
||||
"endpoint": "web"
|
||||
}
|
||||
},
|
||||
"claims": [
|
||||
{
|
||||
"name": "document-converter",
|
||||
"scope": "mesh",
|
||||
"serves": [
|
||||
"convert"
|
||||
]
|
||||
}
|
||||
],
|
||||
"streams": [
|
||||
{
|
||||
"name": "convert-asks",
|
||||
"events": [
|
||||
"convert.asked"
|
||||
],
|
||||
"max-age-days": 1,
|
||||
"max-mib": 8,
|
||||
"retention": "work-queue",
|
||||
"max-deliveries": 3,
|
||||
"duplicates": 24
|
||||
}
|
||||
],
|
||||
"state": [
|
||||
{
|
||||
"name": "convert-answers",
|
||||
"ttl-seconds": 600
|
||||
}
|
||||
],
|
||||
"binds": {
|
||||
"route": "${dir:state}/route.json"
|
||||
},
|
||||
@@ -163,6 +191,17 @@
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "tools",
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/only-office",
|
||||
"binary": "only-office",
|
||||
"loads": [
|
||||
"only-office"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "files",
|
||||
"kind": "archive",
|
||||
|
||||
Reference in New Issue
Block a user