Explorar o código

Support shared docs and HTML media assets

Extend Office container handling to embed and resolve asset references inside Docs HTML (`src`/`poster`), not just whole-string fields, with safer asset extension handling and new Go/JS tests for pack/unpack symmetry. Add standalone Office support for `?request=<share link>` by introducing `common/share.js`, wiring share fetch/open flow through home/platform pages, and loading the helper in app entrypoints. Update share preview responses to expose an inline `Content-Disposition` filename for cross-origin consumers, and refresh related docs plus Office web build/launch path references.
Toby Chui hai 1 semana
pai
achega
2e4144dd15

+ 7 - 6
.claude/launch.json

@@ -53,13 +53,14 @@
     },
     {
       "name": "office-web-static",
-      "runtimeExecutable": "python",
+      "runtimeExecutable": "go",
       "runtimeArgs": [
-        "-m",
-        "http.server",
-        "8127",
-        "--directory",
-        "apps/ArozOS Office Web/dist"
+        "run",
+        "apps/arozos_office/serve.go",
+        "-dir",
+        "apps/arozos_office/dist",
+        "-port",
+        "8127"
       ],
       "port": 8127
     }

+ 110 - 25
src/mod/office/packed.go

@@ -14,6 +14,10 @@ package office
 
 	PackEnvelope also resolves legacy "media?file=<vpath>" links through the
 	supplied reader so older documents become portable on their next save.
+	That covers a link that is a whole JSON value (a Slides / Sheets image
+	src) and one inside an HTML string (a Docs body's <img src="..."> or
+	<video poster="...">), where the attribute value becomes the asset ref.
+	The unpackers resolve "asset://<name>" in both positions the same way.
 	UnpackEnvelope transparently passes through legacy plain-JSON files, so
 	old documents keep opening without migration.
 */
@@ -26,9 +30,11 @@ import (
 	"encoding/hex"
 	"encoding/json"
 	"errors"
+	"html"
 	"io"
 	"net/url"
 	"path"
+	"regexp"
 	"strings"
 )
 
@@ -111,6 +117,74 @@ func mediaLinkVpath(s string) string {
 	return vals.Get("file")
 }
 
+// htmlMediaAttrRe matches a src or poster attribute in an HTML fragment -
+// the places a Docs body keeps its pictures. href is deliberately left out:
+// a hyperlink to a file is a link, not content to copy into the document.
+var htmlMediaAttrRe = regexp.MustCompile(`(?i)(\s(?:src|poster)\s*=\s*)("[^"]*"|'[^']*')`)
+
+// embedHTMLMediaLinks rewrites every media?file= link held in a src/poster
+// attribute of an HTML string through embed, which returns the asset name
+// (and false to leave that link as it is).
+func embedHTMLMediaLinks(s string, embed func(vpath string) (string, bool)) string {
+	if !strings.Contains(s, "media") {
+		return s
+	}
+	return htmlMediaAttrRe.ReplaceAllStringFunc(s, func(m string) string {
+		sub := htmlMediaAttrRe.FindStringSubmatch(m)
+		quoted := sub[2]
+		quote := quoted[:1]
+		vp := mediaLinkVpath(html.UnescapeString(quoted[1 : len(quoted)-1]))
+		if vp == "" {
+			return m
+		}
+		name, ok := embed(vp)
+		if !ok {
+			return m
+		}
+		return sub[1] + quote + "asset://" + name + quote
+	})
+}
+
+// assetRefRe matches an asset reference inside a larger string. Asset names
+// are written by the packers as <hash>.<ext>, so this character set covers
+// every name either of them produces.
+var assetRefRe = regexp.MustCompile(`asset://([A-Za-z0-9._-]+)`)
+
+// resolveAssetRefs replaces asset references in s through resolve: the
+// whole string when it is one reference (the original form, which accepts
+// any name), and every reference embedded in it otherwise.
+func resolveAssetRefs(s string, resolve func(name string) (string, bool)) string {
+	if !strings.Contains(s, "asset://") {
+		return s
+	}
+	if strings.HasPrefix(s, "asset://") {
+		if out, ok := resolve(strings.TrimPrefix(s, "asset://")); ok {
+			return out
+		}
+	}
+	return assetRefRe.ReplaceAllStringFunc(s, func(m string) string {
+		if out, ok := resolve(strings.TrimPrefix(m, "asset://")); ok {
+			return out
+		}
+		return m
+	})
+}
+
+// assetExt is the extension an embedded file is stored under: the source
+// path's own, lower-cased, when it is a plain one, and "bin" otherwise.
+func assetExt(vpath string) string {
+	ext := strings.TrimPrefix(strings.ToLower(path.Ext(vpath)), ".")
+	if ext == "" || len(ext) > 10 {
+		return "bin"
+	}
+	for _, r := range ext {
+		if (r < 'a' || r > 'z') && (r < '0' || r > '9') {
+			return "bin"
+		}
+	}
+	return ext
+}
+
 // transformStrings walks every string value in decoded JSON
 func transformStrings(v interface{}, fn func(string) string) interface{} {
 	switch t := v.(type) {
@@ -152,20 +226,35 @@ func PackEnvelope(envelope string, readVpath func(vpath string) ([]byte, error))
 		return name
 	}
 
+	// a linked file, read through the caller; each vpath is read once
+	embedded := map[string]string{}
+	embedVpath := func(vp string) (string, bool) {
+		if readVpath == nil {
+			return "", false
+		}
+		if name, ok := embedded[vp]; ok {
+			return name, true
+		}
+		data, err := readVpath(vp)
+		if err != nil || len(data) == 0 {
+			return "", false
+		}
+		name := add(data, assetExt(vp))
+		embedded[vp] = name
+		return name, true
+	}
+
 	root = transformStrings(root, func(s string) string {
 		if data, ext, ok := parseAnyDataURL(s); ok {
 			return "asset://" + add(data, ext)
 		}
-		if vp := mediaLinkVpath(s); vp != "" && readVpath != nil {
-			if data, err := readVpath(vp); err == nil && len(data) > 0 {
-				ext := strings.TrimPrefix(strings.ToLower(path.Ext(vp)), ".")
-				if ext == "" {
-					ext = "bin"
-				}
-				return "asset://" + add(data, ext)
+		if vp := mediaLinkVpath(s); vp != "" {
+			if name, ok := embedVpath(vp); ok {
+				return "asset://" + name
 			}
+			return s
 		}
-		return s
+		return embedHTMLMediaLinks(s, embedVpath)
 	})
 
 	doc, err := json.Marshal(root)
@@ -243,14 +332,12 @@ func UnpackEnvelopeToLinks(data []byte, saveAsset func(name string, content []by
 		return "", errors.New("corrupted document.json: " + err.Error())
 	}
 	root = transformStrings(root, func(s string) string {
-		if !strings.HasPrefix(s, "asset://") {
-			return s
-		}
-		name := strings.TrimPrefix(s, "asset://")
-		if !written[name] {
-			return s
-		}
-		return linkFor(name)
+		return resolveAssetRefs(s, func(name string) (string, bool) {
+			if !written[name] {
+				return "", false
+			}
+			return linkFor(name), true
+		})
 	})
 	out, err := json.Marshal(root)
 	if err != nil {
@@ -297,15 +384,13 @@ func UnpackEnvelope(data []byte) (string, error) {
 		return "", errors.New("corrupted document.json: " + err.Error())
 	}
 	root = transformStrings(root, func(s string) string {
-		if !strings.HasPrefix(s, "asset://") {
-			return s
-		}
-		name := strings.TrimPrefix(s, "asset://")
-		data, ok := assets[name]
-		if !ok {
-			return s
-		}
-		return dataURLOf(data, strings.TrimPrefix(path.Ext(name), "."))
+		return resolveAssetRefs(s, func(name string) (string, bool) {
+			data, ok := assets[name]
+			if !ok {
+				return "", false
+			}
+			return dataURLOf(data, strings.TrimPrefix(path.Ext(name), ".")), true
+		})
 	})
 	out, err := json.Marshal(root)
 	if err != nil {

+ 153 - 0
src/mod/office/packed_test.go

@@ -1,6 +1,11 @@
 package office
 
 import (
+	"archive/zip"
+	"bytes"
+	"encoding/json"
+	"errors"
+	"io"
 	"strings"
 	"testing"
 )
@@ -133,3 +138,151 @@ func TestMediaLinkVpath(t *testing.T) {
 		}
 	}
 }
+
+// A Docs body keeps its pictures inside one HTML string. Links in src and
+// poster attributes are embedded; hyperlinks and unreadable files are not.
+func TestPackEmbedsHTMLMediaLinks(t *testing.T) {
+	body := `<h1>Hi</h1>` +
+		`<p><img src="../../media?file=user%3A%2FPhoto%2Fcat.png" alt="cat"></p>` +
+		`<p><img class="x" src='../../media?file=user:/Photo/cat.png&amp;nocache=1'></p>` +
+		`<video poster="../../media?file=user%3A%2FPhoto%2Fdog.JPG"></video>` +
+		`<a href="../../media?file=user%3A%2FDocs%2Fbig.zip">link</a>` +
+		`<img src="../../media?file=user%3A%2Fsecret.png">` +
+		`<img src="https://example.com/remote.png">` +
+		`<p>ask about media?file=user:/x.png in text</p>`
+	envelope, _ := json.Marshal(map[string]interface{}{
+		"type": "arozos/office", "app": "document",
+		"body": map[string]interface{}{"html": body},
+	})
+
+	reads := map[string]int{}
+	readVpath := func(vp string) ([]byte, error) {
+		reads[vp]++
+		if vp == "user:/secret.png" {
+			return nil, errors.New("read access denied")
+		}
+		return []byte("bytes of " + vp), nil
+	}
+	packed, err := PackEnvelope(string(envelope), readVpath)
+	if err != nil {
+		t.Fatalf("PackEnvelope: %v", err)
+	}
+
+	tests := []struct {
+		name  string
+		vpath string
+		reads int
+	}{
+		{"same file read once for two links", "user:/Photo/cat.png", 1},
+		{"poster embedded", "user:/Photo/dog.JPG", 1},
+		{"href never read", "user:/Docs/big.zip", 0},
+		{"unreadable file tried", "user:/secret.png", 1},
+	}
+	for _, tc := range tests {
+		if got := reads[tc.vpath]; got != tc.reads {
+			t.Errorf("%s: %s read %d times, want %d", tc.name, tc.vpath, got, tc.reads)
+		}
+	}
+
+	// the stored document.json carries asset refs in the attributes
+	doc := packedDocument(t, packed)
+	if n := strings.Count(doc, "asset://"); n != 3 {
+		t.Errorf("document.json has %d asset refs, want 3 (two cats + poster): %s", n, doc)
+	}
+	for _, keep := range []string{
+		`href=\"../../media?file=user%3A%2FDocs%2Fbig.zip\"`,
+		`src=\"../../media?file=user%3A%2Fsecret.png\"`,
+		`src=\"https://example.com/remote.png\"`,
+		`ask about media?file=user:/x.png in text`,
+		`alt=\"cat\"`,
+	} {
+		if !strings.Contains(doc, keep) {
+			t.Errorf("document.json lost %s: %s", keep, doc)
+		}
+	}
+
+	// unpacking inlines them again, in place
+	out, err := UnpackEnvelope(packed)
+	if err != nil {
+		t.Fatalf("UnpackEnvelope: %v", err)
+	}
+	if strings.Contains(out, "asset://") {
+		t.Errorf("UnpackEnvelope left asset refs: %s", out)
+	}
+	if n := strings.Count(out, "data:image/png;base64,"); n != 2 {
+		t.Errorf("UnpackEnvelope restored %d png data URLs, want 2", n)
+	}
+	if !strings.Contains(out, `poster=\"data:image/jpeg;base64,`) {
+		t.Errorf("poster not restored as a jpeg data URL: %s", out)
+	}
+
+	// and the ArozOS load path turns them into cache links
+	links, err := UnpackEnvelopeToLinks(packed,
+		func(string, []byte) error { return nil },
+		func(name string) string { return "../../media?file=cache%2F" + name })
+	if err != nil {
+		t.Fatalf("UnpackEnvelopeToLinks: %v", err)
+	}
+	if strings.Contains(links, "asset://") || strings.Count(links, "media?file=cache%2F") != 3 {
+		t.Errorf("UnpackEnvelopeToLinks did not relink the attributes: %s", links)
+	}
+}
+
+func TestResolveAssetRefs(t *testing.T) {
+	known := map[string]string{"abc.png": "A", "x y.png": "SPACED"}
+	resolve := func(name string) (string, bool) { v, ok := known[name]; return v, ok }
+	tests := []struct{ in, want string }{
+		{"asset://abc.png", "A"},
+		{"asset://x y.png", "SPACED"}, // whole-string refs keep accepting any name
+		{"asset://missing.png", "asset://missing.png"},
+		{`<img src="asset://abc.png"><img src='asset://abc.png'>`, `<img src="A"><img src='A'>`},
+		{`<img src="asset://missing.png">`, `<img src="asset://missing.png">`},
+		{"no refs here", "no refs here"},
+	}
+	for _, tc := range tests {
+		if got := resolveAssetRefs(tc.in, resolve); got != tc.want {
+			t.Errorf("resolveAssetRefs(%q) = %q, want %q", tc.in, got, tc.want)
+		}
+	}
+}
+
+func TestAssetExt(t *testing.T) {
+	tests := []struct{ in, want string }{
+		{"user:/a/cat.PNG", "png"},
+		{"user:/a/clip.mp4", "mp4"},
+		{"user:/a/noext", "bin"},
+		{"user:/a/odd.j pg", "bin"},
+		{"user:/a/x.verylongextension", "bin"},
+	}
+	for _, tc := range tests {
+		if got := assetExt(tc.in); got != tc.want {
+			t.Errorf("assetExt(%q) = %q, want %q", tc.in, got, tc.want)
+		}
+	}
+}
+
+// packedDocument reads document.json out of a container, as stored
+func packedDocument(t *testing.T, packed []byte) string {
+	t.Helper()
+	zr, err := zip.NewReader(bytes.NewReader(packed), int64(len(packed)))
+	if err != nil {
+		t.Fatalf("container is not a zip: %v", err)
+	}
+	for _, f := range zr.File {
+		if f.Name != packedDocName {
+			continue
+		}
+		rc, err := f.Open()
+		if err != nil {
+			t.Fatalf("open %s: %v", f.Name, err)
+		}
+		defer rc.Close()
+		b, err := io.ReadAll(rc)
+		if err != nil {
+			t.Fatalf("read %s: %v", f.Name, err)
+		}
+		return string(b)
+	}
+	t.Fatalf("container has no %s", packedDocName)
+	return ""
+}

+ 17 - 0
src/mod/share/share.go

@@ -901,6 +901,13 @@ func (s *Manager) HandleShareAccess(w http.ResponseWriter, r *http.Request) {
 			} else if directServe {
 				w.Header().Set("Access-Control-Allow-Origin", "*")
 				w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
+				// Name the file for cross-origin readers (e.g. the standalone
+				// Office web edition opening a shared .doca), which cannot see
+				// the download page. inline keeps in-browser previews working.
+				if disposition := previewDisposition(arozfs.Base(shareOption.FileVirtualPath)); disposition != "" {
+					w.Header().Set("Content-Disposition", disposition)
+					w.Header().Set("Access-Control-Expose-Headers", "Content-Disposition")
+				}
 				if metadata.IsRawImageFile(fileRuntimeAbsPath) {
 					// Convert RAW image to JPEG for browser display
 					jpegData, err := metadata.RenderRAWImage(targetFsh, fileRuntimeAbsPath)
@@ -1613,3 +1620,13 @@ func (s *Manager) handleZipDownload(w http.ResponseWriter, r *http.Request, jobI
 	w.Header().Set("Content-Type", "application/zip")
 	http.ServeFile(w, r, outputPath)
 }
+
+// previewDisposition returns the Content-Disposition header for a file served
+// by /share/preview: inline, with the file name (RFC 2231 encoded when it is
+// not plain ASCII). It returns "" when no valid header can be built.
+func previewDisposition(filename string) string {
+	if filename == "" || filename == "." || filename == "/" {
+		return ""
+	}
+	return mime.FormatMediaType("inline", map[string]string{"filename": filename})
+}

+ 42 - 0
src/mod/share/share_test.go

@@ -0,0 +1,42 @@
+package share
+
+import (
+	"mime"
+	"testing"
+)
+
+func TestPreviewDisposition(t *testing.T) {
+	tests := []struct {
+		name     string
+		filename string
+		want     string // expected filename after parsing the header back; "" = no header
+	}{
+		{"plain ascii", "HelloWorld.doca", "HelloWorld.doca"},
+		{"spaces", "Q3 Report.xlsa", "Q3 Report.xlsa"},
+		{"quotes", `say "hi".ppta`, `say "hi".ppta`},
+		{"non ascii", "報告書.doca", "報告書.doca"},
+		{"empty", "", ""},
+		{"dot", ".", ""},
+	}
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			header := previewDisposition(tc.filename)
+			if tc.want == "" {
+				if header != "" {
+					t.Errorf("previewDisposition(%q) = %q, want no header", tc.filename, header)
+				}
+				return
+			}
+			disposition, params, err := mime.ParseMediaType(header)
+			if err != nil {
+				t.Fatalf("previewDisposition(%q) = %q, which does not parse: %v", tc.filename, header, err)
+			}
+			if disposition != "inline" {
+				t.Errorf("disposition = %q, want inline", disposition)
+			}
+			if params["filename"] != tc.want {
+				t.Errorf("filename = %q, want %q (header %q)", params["filename"], tc.want, header)
+			}
+		})
+	}
+}

+ 1 - 1
src/wasm/office/convert.go

@@ -16,7 +16,7 @@ no file I/O and keeps no globals, which is what makes running it in a
 browser possible at all.
 
 PDF is deliberately absent. The web edition renders PDF in the front end
-(see apps/ArozOS Office Web/README.md); pulling BuildDocPdf and friends
+(see apps/arozos_office/README.md); pulling BuildDocPdf and friends
 in here would only add fpdf to the module for nothing.
 */
 package main

+ 1 - 1
src/wasm/office/main.go

@@ -31,7 +31,7 @@ overlay and yields a frame before calling in (see common/wasm.js).
 
 Build:  cd src && GOOS=js GOARCH=wasm go build -o office.wasm ./wasm/office
 
-	(apps/ArozOS Office Web/generate.go -wasm does this for you)
+	(apps/arozos_office/generate.go -wasm does this for you)
 */
 package main
 

+ 1 - 1
src/wasm/office/stub.go

@@ -19,6 +19,6 @@ func main() {
 	fmt.Println("This package is only useful as a WebAssembly module. Build it with:")
 	fmt.Println("    cd src && GOOS=js GOARCH=wasm go build -o office.wasm ./wasm/office")
 	fmt.Println("or let the web-viewer generator do it:")
-	fmt.Println("    cd \"apps/ArozOS Office Web\" && ./update_viewer.sh -wasm")
+	fmt.Println("    cd apps/arozos_office && ./update_viewer.sh -wasm")
 	fmt.Printf("Converters compiled in: %d import, %d export\n", len(in), len(out))
 }

+ 11 - 1
src/web/Office/README.md

@@ -81,7 +81,7 @@ both host implementations and picks one from the flags in
 [`common/mode.js`](common/mode.js);
 [`common/container.js`](common/container.js) is the browser-side twin of
 [`packed.go`](../../mod/office/packed.go). The generator
-[`apps/ArozOS Office Web/generate.go`](../../../apps/ArozOS%20Office%20Web/generate.go)
+[`apps/arozos_office/generate.go`](../../../apps/arozos_office/generate.go)
 copies the tree, drops the `.agi` backends, and flips those flags — that is
 the whole build.
 
@@ -177,6 +177,16 @@ Handled by [`packed.go`](../../mod/office/packed.go) +
   `media?file=<vpath>` links, so multi-MB media never rides the JSON body.
   On **save**, `office.packToFile` re-resolves those links (server-side,
   via a permission-checked vpath reader) and embeds them back.
+- A link is embedded wherever it sits: as a whole value (a Slides / Sheets
+  image `src`) or inside an HTML string (a Docs body's `<img src="…">` /
+  `<video poster="…">`, where the attribute value becomes
+  `asset://<name>`). Only `src` and `poster` are embedded - an `href` to a
+  file stays a link. Every unpacker (`UnpackEnvelope`,
+  `UnpackEnvelopeToLinks`, and `common/container.js` in the browser)
+  resolves `asset://` refs in both positions. This is what makes a `.doca`
+  with pictures from the user's storage open with its pictures anywhere
+  else - the standalone web edition included. Documents saved before this
+  still hold links, and become portable on their next save.
 
 ## Import / export — how each path works and why
 

+ 1 - 1
src/web/Office/common/CONTRACT.md

@@ -216,7 +216,7 @@ The suite runs in two hosts from one code base, and this is the seam:
 | `standalone` | any static web server ("ArozOS Office Web") | `<input type=file>` / drag and drop / `?open=<relative path>` | the visitor's device; `Save` downloads the file back | the same `mod/office` code compiled to WebAssembly — **when the build shipped it** |
 
 The mode is one line in `common/mode.js` (`window.OFFICE_STANDALONE`, plus
-`window.OFFICE_WASM`), which `apps/ArozOS Office Web/generate.go` rewrites in
+`window.OFFICE_WASM`), which `apps/arozos_office/generate.go` rewrites in
 its output tree. Never test those flags — ask `OfficePlatform`.
 
 ### Two capability questions, deliberately separate

+ 20 - 6
src/web/Office/common/container.js

@@ -15,8 +15,11 @@
     Symmetry with Go:
       - unpack() mirrors office.UnpackEnvelope: assets come back as data
         URLs, so the document is self-contained in memory and survives a
-        localStorage draft round trip. Legacy plain-JSON documents (written
-        before the container existed) pass through untouched.
+        localStorage draft round trip. A reference is resolved both as a
+        whole value (a Slides / Sheets image src) and inside a string (a
+        Docs body's <img src="asset://...">, which Go writes when it embeds
+        pictures linked from ArozOS storage). Legacy plain-JSON documents
+        (written before the container existed) pass through untouched.
       - pack() mirrors office.PackEnvelope's data-URL branch: every data URL
         in the body becomes a deduplicated asset entry. It cannot resolve
         "media?file=" links - those are ArozOS storage references and there
@@ -431,11 +434,22 @@ var OfficeContainer = (function () {
         var doc = files[DOC_NAME];
         if (!doc) throw new Error("document container is missing " + DOC_NAME);
         var root = JSON.parse(utf8Decode(doc));
-        root = transformStrings(root, function (s) {
-            if (s.substring(0, 8) !== "asset://") return s;
-            var name = s.substring(8);
+        var inline = function (name) {
             var data = files["assets/" + name];
-            return data ? dataURLOf(data, extOfName(name)) : s;
+            return data ? dataURLOf(data, extOfName(name)) : null;
+        };
+        root = transformStrings(root, function (s) {
+            if (s.indexOf("asset://") < 0) return s;
+            // a whole-value reference keeps accepting any name, as before
+            if (s.substring(0, 8) === "asset://") {
+                var whole = inline(s.substring(8));
+                if (whole) return whole;
+            }
+            // references inside a string use the names the packers write,
+            // the same set as packed.go's assetRefRe
+            return s.replace(/asset:\/\/([A-Za-z0-9._-]+)/g, function (m, name) {
+                return inline(name) || m;
+            });
         });
         return JSON.stringify(root);
     }

+ 1 - 1
src/web/Office/common/mode.js

@@ -9,7 +9,7 @@
                   the Go converters in mod/office are all available.
 
       standalone  the file the web-viewer generator writes over this one in
-                  its output tree (see apps/ArozOS Office Web/generate.go)
+                  its output tree (see apps/arozos_office/generate.go)
                   the suite is served by any dumb static file server, with
                   no ArozOS behind it: documents are opened from and saved
                   back to the visitor's own device, and every server-side

+ 1 - 1
src/web/Office/common/office.js

@@ -1561,7 +1561,7 @@ var OfficeApp = (function () {
 
         // load input file (embedded / open-with) or start blank
         meta = { createdAt: now(), revision: 0 };
-        var inputs = OfficePlatform.loadInputFiles();
+        var inputs = OfficePlatform.loadInputFiles(cfg.extension);
         if (inputs && inputs.length > 0) {
             cfg.create();
             openPath(inputs[0].filepath, inputs[0].filename,

+ 45 - 6
src/web/Office/common/platform.js

@@ -51,9 +51,11 @@
       ?open=<relative path>      open that document
       ?template=<relative path>  start a new unsaved document from it
       ?recent=<id>               reopen one of this browser's recent documents
+      ?request=<share link>      (standalone) open a public ArozOS share,
+                                 optionally with &name=<file name>
 
-    Requires: jquery, ../common/mode.js, ../common/container.js and (in
-    ArozOS mode) ../../script/ao_module.js
+    Requires: jquery, ../common/mode.js, ../common/container.js, (for
+    ?request=) ../common/share.js and (in ArozOS mode) ../../script/ao_module.js
 */
 var OfficePlatform = (function () {
     "use strict";
@@ -368,10 +370,10 @@ var OfficePlatform = (function () {
             }, function () { asDataURL(); });
         },
 
-        loadInputFiles: function () {
+        loadInputFiles: function (ext) {
             // a ?template= / ?open= / ?recent= link is answered the same way
             // in both hosts; otherwise ask the desktop what it opened us with
-            var entry = entryPointFiles();
+            var entry = entryPointFiles(ext);
             if (entry) return entry;
             try { return ao_module_loadInputFiles(); } catch (e) { return null; }
         },
@@ -409,6 +411,7 @@ var OfficePlatform = (function () {
     var STANDALONE_INLINE_MAX = 24 * 1024 * 1024;
     var SESSION_MAX = 4 * 1024 * 1024;   // localStorage is ~5 MB per origin
     var localFiles = {};                 // "local:/<name>" -> File
+    var sharedFiles = {};                // "share:/<name>" -> preview URL
 
     function readAsDataURL(blob, cb, errcb) {
         var reader = new FileReader();
@@ -491,6 +494,7 @@ var OfficePlatform = (function () {
     }
     var NO_CONVERTER = "this build has no converter for that format";
     var RECENT_PREFIX = "recent:/";
+    var SHARE_PREFIX = "share:/";
 
     /*
         A virtual path names something a host owns - "user:/Desktop/a.doca" in
@@ -517,8 +521,25 @@ var OfficePlatform = (function () {
 
         Relative paths only - fetchRelative refuses anything with a scheme, so
         none of these can be turned into a fetch of another site.
+
+        The one deliberate exception, standalone only:
+
+          ?request=<share link>[&name=<file name>]
+                                     open a document from a public ArozOS
+                                     share. OfficeShare.parse only accepts a
+                                     share path and rewrites it to that
+                                     server's preview endpoint, so this is
+                                     not a general fetch either. The home
+                                     page normally takes these links itself
+                                     (it can tell which app a document is
+                                     for) and only falls back to sending one
+                                     here when it cannot keep the file.
+
+        ext is the calling app's own extension; a share link does not carry
+        the file name, and the container is only opened as one when the name
+        says it is.
     */
-    function entryPointFiles() {
+    function entryPointFiles(ext) {
         var q = window.location.search || "";
         var param = function (name) {
             var m = new RegExp("[?&]" + name + "=([^&]+)").exec(q);
@@ -541,6 +562,18 @@ var OfficePlatform = (function () {
         var tpl = param("template");
         if (tpl) return [{ filepath: tpl, filename: basename(tpl), asTemplate: true }];
 
+        var request = param("request");
+        if (request && STANDALONE && window.OfficeShare) {
+            var info;
+            try { info = OfficeShare.parse(request); }
+            catch (e) { toast(e.message, "error"); return null; }
+            var app = { ".doca": "document", ".xlsa": "spreadsheet", ".ppta": "presentation" }[ext] || "";
+            var shareName = OfficeShare.fileName(app, [param("name"), info.nameHint]);
+            var sharePath = SHARE_PREFIX + shareName;
+            sharedFiles[sharePath] = info.previewUrl;
+            return [{ filepath: sharePath, filename: shareName }];
+        }
+
         var open = param("open");
         if (open) return [{ filepath: open, filename: basename(open) }];
         return null;
@@ -573,6 +606,10 @@ var OfficePlatform = (function () {
             OfficeRecents.load(path.substring(RECENT_PREFIX.length), cb, errcb);
             return;
         }
+        if (sharedFiles[path]) {
+            OfficeShare.fetch(sharedFiles[path], function (bytes) { cb(bytes); }, errcb);
+            return;
+        }
         fetchRelative(path, "arraybuffer", cb, errcb);
     }
     /* Read a native container that is not a host's own file - a template, a
@@ -925,7 +962,9 @@ var OfficePlatform = (function () {
         mediaUrl: function (v) { return host.mediaUrl(v); },
         blobToSrc: function (b, n, cb, errcb) { host.blobToSrc(b, n, cb, errcb); },
 
-        loadInputFiles: function () { return host.loadInputFiles(); },
+        // ext: the calling app's native extension, which names a document
+        // opened from a share link that did not say what it is called
+        loadInputFiles: function (ext) { return host.loadInputFiles(ext); },
         // open a document in a second window of this app; false = this host
         // has nowhere to open it from (the standalone build saves by download)
         openDocument: function (fp, fn, o) { return !!host.openDocument(fp, fn, o || {}); },

+ 262 - 0
src/web/Office/common/share.js

@@ -0,0 +1,262 @@
+/*
+    ArozOS Office Suite - documents requested from an ArozOS share link
+    ===================================================================
+
+    What lets the standalone web edition open a document that lives on some
+    ArozOS server, by link:
+
+        index.html?request=https://my.aroz.host/share/<uuid>/
+
+    The link is the ordinary share link an ArozOS user copies from the share
+    dialog. It points at an HTML download page, which is useless to a script
+    on another origin, so it is rewritten to the share's preview endpoint,
+
+        https://my.aroz.host/share/preview/<uuid>/
+
+    which serves the raw file with Access-Control-Allow-Origin: * (see
+    mod/share). Only a share that is open to everyone ("anyone with the
+    link") answers a cross-origin request: a share limited to signed-in users
+    or groups needs an ArozOS login this page does not have, and reports so.
+
+    The preview endpoint sends no usable Content-Type for .doca / .xlsa /
+    .ppta, and older servers no filename either, so which app a document
+    belongs to is read from the document itself (the envelope's "app"), and
+    the name comes from, in order: an explicit &name=, the server's
+    Content-Disposition, a /share/download/<uuid>/<name> link, or a default.
+
+    Nothing here is allowed to become a general "fetch any URL" primitive:
+    parse() only accepts http(s) URLs whose path is an ArozOS share path with
+    a well-formed id, and the request is always rewritten to the preview
+    endpoint of that same origin, without credentials.
+
+    Usage:
+        var info = OfficeShare.parse(link);          // throws Error(message)
+        OfficeShare.fetch(info.previewUrl, function (bytes, serverName) { },
+                          function (message) { });
+        OfficeShare.appOf(bytes)                     // "document" | ... | null
+        OfficeShare.fileName(app, [candidates...])   // "Report.doca"
+
+    Requires container.js (OfficeContainer) for appOf(). Has no DOM
+    dependencies beyond XMLHttpRequest, so it also loads in Node for
+    test_share.js.
+*/
+var OfficeShare = (function () {
+    "use strict";
+
+    var EXT = { document: ".doca", spreadsheet: ".xlsa", presentation: ".ppta" };
+    var DEFAULT_NAME = {
+        document: "Shared document",
+        spreadsheet: "Shared spreadsheet",
+        presentation: "Shared presentation"
+    };
+    // share ids are UUIDs; accept the general shape rather than one version
+    var ID_RE = /^[A-Za-z0-9-]{8,64}$/;
+    var PARSE_URL = (typeof URL === "function") ? URL : null;
+
+    function fail(msg) { throw new Error(msg); }
+
+    /* ---------------- link -> preview endpoint ---------------- */
+
+    /*
+        Accepted shapes, each optionally under a path prefix (an ArozOS
+        behind a reverse proxy at /aroz/ keeps that prefix):
+
+          /share/<id>[/]                    the link the share dialog copies
+          /share/preview/<id>[/]            already the preview endpoint
+          /share/download/<id>[/<name>]     the download link
+          /share?id=<id>                    the legacy link form
+    */
+    function parse(input) {
+        var raw = String(input == null ? "" : input).trim();
+        if (!raw) fail("No share link was given.");
+        if (!PARSE_URL) fail("This browser cannot read links.");
+
+        var u;
+        try { u = new PARSE_URL(raw); } catch (e) { fail("That is not a valid link: " + raw); }
+        if (u.protocol !== "http:" && u.protocol !== "https:") {
+            fail("Only http and https share links can be opened.");
+        }
+        if (u.username || u.password) fail("Share links with a user name in them are not accepted.");
+
+        var segs = u.pathname.split("/").filter(function (s) { return s !== ""; });
+        var decoded = segs.map(function (s) {
+            try { return decodeURIComponent(s); } catch (e) { return s; }
+        });
+
+        // walk back from the end, so a prefix that happens to contain a
+        // "share" folder does not get mistaken for the share path itself
+        for (var i = decoded.length - 1; i >= 0; i--) {
+            if (decoded[i] !== "share") continue;
+            var rest = decoded.slice(i + 1);
+            var id = null, nameHint = "";
+            if (rest.length === 0) {
+                id = u.searchParams.get("id");
+            } else if (rest[0] === "preview" || rest[0] === "download") {
+                id = rest[1] || null;
+                if (rest[0] === "download" && rest.length > 2) nameHint = rest[rest.length - 1];
+            } else if (rest.length <= 2) {
+                // /share/<id>/ - a trailing file name segment is tolerated,
+                // the download page redirects it the same way
+                id = rest[0];
+            }
+            if (!id || !ID_RE.test(id)) continue;
+            var prefix = segs.slice(0, i).join("/");
+            return {
+                id: id,
+                origin: u.origin,
+                previewUrl: u.origin + "/" + (prefix ? prefix + "/" : "") +
+                    "share/preview/" + encodeURIComponent(id) + "/",
+                nameHint: cleanName(nameHint)
+            };
+        }
+        fail("That link is not an ArozOS share link (expected .../share/<id>/).");
+    }
+
+    /* ---------------- file names ---------------- */
+
+    // a name is only ever used for the download a Save produces, but keep
+    // it to a plain file name all the same
+    function cleanName(name) {
+        var s = String(name == null ? "" : name);
+        s = s.replace(/[\u0000-\u001f\u007f]/g, "");
+        s = s.substring(Math.max(s.lastIndexOf("/"), s.lastIndexOf("\\")) + 1);
+        s = s.trim();
+        if (s === "." || s === "..") return "";
+        return s.length > 200 ? s.substring(0, 200) : s;
+    }
+
+    function extOf(name) {
+        var i = name.lastIndexOf(".");
+        return i <= 0 ? "" : name.substring(i).toLowerCase();
+    }
+
+    /* The first usable candidate, forced to the app's own extension: a
+       .doca is opened by Docs only when its name says .doca, and a name
+       carrying another extension would be the wrong format on Save. */
+    function fileName(app, candidates) {
+        var ext = EXT[app] || "";
+        var list = candidates || [];
+        for (var i = 0; i < list.length; i++) {
+            var n = cleanName(list[i]);
+            if (!n) continue;
+            if (!ext || extOf(n) === ext) return n;
+            var base = extOf(n) ? n.substring(0, n.lastIndexOf(".")) : n;
+            // ".doca" alone is an extension, not a name
+            if (base && base.toLowerCase() !== ext) return base + ext;
+        }
+        return (DEFAULT_NAME[app] || "Shared document") + ext;
+    }
+
+    /* filename from a Content-Disposition header: RFC 6266 filename* wins
+       over the plain filename, as browsers do. */
+    function dispositionName(header) {
+        var h = String(header || "");
+        if (!h) return "";
+        var star = /filename\*\s*=\s*([^']*)'[^']*'([^;]+)/i.exec(h);
+        if (star) {
+            try { return cleanName(decodeURIComponent(star[2].trim().replace(/^"|"$/g, ""))); }
+            catch (e) { /* fall through to the plain form */ }
+        }
+        var plain = /filename\s*=\s*("((?:[^"\\]|\\.)*)"|[^;]+)/i.exec(h);
+        if (!plain) return "";
+        var v = plain[2] !== undefined ? plain[2].replace(/\\(.)/g, "$1") : plain[1].trim();
+        return cleanName(v);
+    }
+
+    /* ---------------- the document itself ---------------- */
+
+    function looksLikeDocument(bytes) {
+        if (!bytes || bytes.length < 2) return false;
+        if (bytes[0] === 0x50 && bytes[1] === 0x4B) return true;   // zip container
+        // a pre-container plain JSON document, possibly after a BOM/space
+        for (var i = 0; i < Math.min(bytes.length, 8); i++) {
+            var c = bytes[i];
+            if (c === 0x7B) return true;                            // {
+            if (c !== 0x20 && c !== 0x0A && c !== 0x0D && c !== 0x09 &&
+                c !== 0xEF && c !== 0xBB && c !== 0xBF) return false;
+        }
+        return false;
+    }
+
+    // which app a native document belongs to, from its envelope
+    function appOf(bytes) {
+        var C = (typeof OfficeContainer !== "undefined") ? OfficeContainer : null;
+        if (!C || !looksLikeDocument(bytes)) return null;
+        try {
+            var env = JSON.parse(C.unpack(bytes));
+            return (env && EXT[env.app]) ? env.app : null;
+        } catch (e) {
+            return null;
+        }
+    }
+
+    /* ---------------- fetching ---------------- */
+
+    function describeFailure(status, url) {
+        if (status === 401 || status === 403) {
+            return "This share is not public. Ask its owner to set it to " +
+                "\"anyone with the link\", or open it in ArozOS.";
+        }
+        if (status === 404 || status === 410) return "That share no longer exists.";
+        if (status === 400) {
+            return "The server could not serve that share as a file - it may be a folder share.";
+        }
+        if (status) return "The server answered HTTP " + status + ".";
+        // status 0: blocked before any answer - say why when we can tell
+        var page = (typeof location !== "undefined") ? location.protocol : "";
+        if (page === "https:" && /^http:/i.test(url)) {
+            return "This page is served over https, and the browser blocks it " +
+                "from reading a share on a plain http server. Use an https " +
+                "link to the ArozOS server.";
+        }
+        // (a missing or restricted share is answered without CORS headers,
+        // so to this page it looks exactly like an unreachable server)
+        return "Could not read that share. It may have been removed, it may be " +
+            "limited to signed-in users, or the server may be offline.";
+    }
+
+    function fetchShare(previewUrl, cb, errcb) {
+        if (typeof XMLHttpRequest === "undefined") { errcb("This browser cannot download files."); return; }
+        var xhr = new XMLHttpRequest();
+        xhr.open("GET", previewUrl, true);
+        xhr.responseType = "arraybuffer";
+        xhr.withCredentials = false;
+        xhr.onload = function () {
+            if (xhr.status < 200 || xhr.status >= 300) {
+                errcb(describeFailure(xhr.status, previewUrl));
+                return;
+            }
+            var bytes = new Uint8Array(xhr.response || new ArrayBuffer(0));
+            if (!looksLikeDocument(bytes)) {
+                errcb("That share is not an ArozOS Office document (.doca, .xlsa or .ppta).");
+                return;
+            }
+            var name = "";
+            // readable only when the server exposes it (older ArozOS does
+            // not); asking for an unexposed header logs a console error, so
+            // look in the list of exposed ones first
+            try {
+                if (/^content-disposition:/im.test(xhr.getAllResponseHeaders() || "")) {
+                    name = dispositionName(xhr.getResponseHeader("Content-Disposition"));
+                }
+            } catch (e) { name = ""; }
+            cb(bytes, name);
+        };
+        xhr.onerror = function () { errcb(describeFailure(0, previewUrl)); };
+        xhr.send();
+    }
+
+    return {
+        parse: parse,
+        fetch: fetchShare,
+        appOf: appOf,
+        fileName: fileName,
+        cleanName: cleanName,
+        dispositionName: dispositionName,
+        extension: function (app) { return EXT[app] || ""; }
+    };
+})();
+
+if (typeof module !== "undefined" && module.exports) {
+    module.exports = OfficeShare;
+}

+ 29 - 0
src/web/Office/common/test_container.js

@@ -144,6 +144,35 @@ throws("a truncated container is rejected", function () {
 var noDoc = C.writeZip([{ name: "assets/x.png", data: bytes("xx") }]);
 throws("a container with no document.json is rejected", function () { C.unpack(noDoc); });
 
+/* ---------- a Docs body with a picture Go embedded inside the HTML ----------
+   GO_HTML_EMBED is a real .doca from office.PackEnvelope: its body.html held
+   <img src="../../media?file=user:/Photo/cat.png">, which Go read and stored
+   as assets/<hash>.png, leaving <img src="asset://<hash>.png"> in the HTML.
+   Regenerate the same way as GO_PACKED, from an envelope with such a link. */
+var GO_HTML_EMBED =
+    "UEsDBBQACAAIAAAAAAAAAAAAAAAAAAAAAAANAAAAZG9jdW1lbnQuanNvbkyMzWqFMBQGX+XyrUNj" +
+    "rlDhgMu+RTb5OWrAmGBiWyu+e6H2gqthZjEHTM4g+OS2yEuFgE1+Bx2YapxB0FvTtG5Sf+SPaNl7" +
+    "9leVr3xpvkuI46OsrtcwpXAlKe3TWu7eW9exesvLqPEodZ+51/gKvk6knk3+1rhf5P8Tp0DkakDH" +
+    "KVD3zCCYNf2kItMwBMcQ+OS1hLSA1PkbAAD//1BLBwgrX789nAAAANgAAABQSwMEFAAIAAAAAAAA" +
+    "AAAAAAAAAAAAAAAAABcAAABhc3NldHMvYjJiYmU3NjNjN2UxLnBuZ4lQTkcNChoKAAAADUlIRFIA" +
+    "AAABAAAAAQgGAAAAHxXEiQAAAA1JREFUeNpj/M/AUA8ABIUBgISpjCEAAAAASUVORK5CYIJQSwcI" +
+    "JqSVB0YAAABGAAAAUEsBAhQAFAAIAAgAAAAAACtfvz2cAAAA2AAAAA0AAAAAAAAAAAAAAAAAAAAA" +
+    "AGRvY3VtZW50Lmpzb25QSwECFAAUAAgAAAAAAAAAJqSVB0YAAABGAAAAFwAAAAAAAAAAAAAAAADX" +
+    "AAAAYXNzZXRzL2IyYmJlNzYzYzdlMS5wbmdQSwUGAAAAAAIAAgCAAAAAYgEAAAAA";
+var htmlBytes = fromBase64(GO_HTML_EMBED);
+var htmlStored = JSON.parse(C.utf8Decode(C.readZip(htmlBytes)["document.json"]));
+ok("Go stored the picture as an asset ref inside the HTML",
+    /<img src="asset:\/\/[0-9a-f]+\.png"/.test(htmlStored.body.html));
+var htmlEnv = JSON.parse(C.unpack(htmlBytes));
+ok("the ref inside the HTML comes back as a data URL",
+    htmlEnv.body.html.indexOf('<img src="data:image/png;base64,') >= 0);
+eq("no asset ref is left in the HTML", htmlEnv.body.html.indexOf("asset://"), -1);
+ok("the rest of the tag survives", htmlEnv.body.html.indexOf('style="width:120px"') >= 0);
+var unknownRef = C.writeZip([{ name: "document.json",
+    data: C.utf8Encode('{"app":"document","body":{"html":"<img src=\\"asset://gone.png\\">"}}') }]);
+eq("an embedded ref with no asset is left alone",
+    JSON.parse(C.unpack(unknownRef)).body.html, '<img src="asset://gone.png">');
+
 /* ---------- zip writer basics ---------- */
 var zip = C.writeZip([
     { name: "document.json", data: C.utf8Encode('{"k":"v"}') },

+ 102 - 0
src/web/Office/common/test_share.js

@@ -0,0 +1,102 @@
+/*
+    ArozOS Office Suite - share link unit tests (common/share.js)
+    Run with: node test_share.js   (exits 1 on failure)
+
+    parse() is the security boundary of ?request=: whatever it returns is
+    fetched, so most of these pin what it refuses.
+*/
+global.OfficeContainer = require("./container.js");
+var S = require("./share.js");
+
+var failures = 0, passes = 0;
+function eq(name, got, want) {
+    if (got === want) { passes++; return; }
+    failures++;
+    console.log("FAIL " + name + "\n  got:  " + JSON.stringify(got) + "\n  want: " + JSON.stringify(want));
+}
+function throws(name, fn) {
+    try { fn(); } catch (e) { passes++; return; }
+    failures++;
+    console.log("FAIL " + name + ": expected an error");
+}
+
+var ID = "f7453c19-66c8-4e84-8288-76b84ec0da9f";
+var PREVIEW = "http://localhost:8080/share/preview/" + ID + "/";
+
+/* ---- parse: accepted link shapes ---- */
+[
+    ["share page, trailing slash", "http://localhost:8080/share/" + ID + "/", PREVIEW],
+    ["share page, no slash", "http://localhost:8080/share/" + ID, PREVIEW],
+    ["preview link", PREVIEW, PREVIEW],
+    ["download link", "http://localhost:8080/share/download/" + ID + "/HelloWorld.doca", PREVIEW],
+    ["legacy ?id=", "http://localhost:8080/share?id=" + ID, PREVIEW],
+    ["surrounding space", "  http://localhost:8080/share/" + ID + "/  ", PREVIEW],
+    ["query and hash ignored", "http://localhost:8080/share/" + ID + "/?x=1#top", PREVIEW],
+    ["https + reverse-proxy prefix", "https://example.com/aroz/share/" + ID + "/",
+        "https://example.com/aroz/share/preview/" + ID + "/"],
+    ["prefix folder called share", "https://example.com/share/share/" + ID + "/",
+        "https://example.com/share/share/preview/" + ID + "/"]
+].forEach(function (c) {
+    var info;
+    try { info = S.parse(c[1]); } catch (e) { eq("parse " + c[0], "threw: " + e.message, c[2]); return; }
+    eq("parse " + c[0], info.previewUrl, c[2]);
+});
+eq("download link name hint",
+    S.parse("http://localhost:8080/share/download/" + ID + "/My%20Doc.doca").nameHint, "My Doc.doca");
+eq("share page has no name hint", S.parse("http://localhost:8080/share/" + ID + "/").nameHint, "");
+
+/* ---- parse: refused ---- */
+[
+    ["empty", ""],
+    ["not a url", "share/" + ID],
+    ["relative", "/share/" + ID + "/"],
+    ["javascript scheme", "javascript:alert(1)//share/" + ID],
+    ["file scheme", "file:///share/" + ID],
+    ["data scheme", "data:text/plain,share/" + ID],
+    ["credentials", "http://user:pw@localhost:8080/share/" + ID + "/"],
+    ["not a share path", "http://localhost:8080/files/" + ID],
+    ["bad id chars", "http://localhost:8080/share/..%2F..%2Fsystem/"],
+    ["id too short", "http://localhost:8080/share/abc/"],
+    ["folder listing", "http://localhost:8080/share/"],
+    ["other share op", "http://localhost:8080/share/opg/123/" + ID]
+].forEach(function (c) { throws("parse refuses " + c[0], function () { S.parse(c[1]); }); });
+
+/* ---- file names ---- */
+eq("name kept", S.fileName("document", ["Report.doca"]), "Report.doca");
+eq("first usable wins", S.fileName("document", [null, "", "B.doca"]), "B.doca");
+eq("wrong ext replaced", S.fileName("spreadsheet", ["Budget.doca"]), "Budget.xlsa");
+eq("no ext added", S.fileName("presentation", ["Pitch"]), "Pitch.ppta");
+eq("ext case-insensitive", S.fileName("document", ["A.DOCA"]), "A.DOCA");
+eq("default name", S.fileName("spreadsheet", []), "Shared spreadsheet.xlsa");
+eq("path stripped", S.fileName("document", ["../../etc/x.doca"]), "x.doca");
+eq("backslash path stripped", S.fileName("document", ["C:\\a\\y.doca"]), "y.doca");
+eq("control chars stripped", S.fileName("document", ["a\u0000b\n.doca"]), "ab.doca");
+eq("bare extension gets default", S.fileName("document", [".doca"]), "Shared document.doca");
+
+/* ---- Content-Disposition ---- */
+eq("disposition quoted", S.dispositionName('inline; filename="HelloWorld.doca"'), "HelloWorld.doca");
+eq("disposition bare", S.dispositionName("inline; filename=Plain.xlsa"), "Plain.xlsa");
+eq("disposition escaped quote", S.dispositionName('inline; filename="say \\"hi\\".ppta"'), 'say "hi".ppta');
+eq("disposition rfc2231 wins", S.dispositionName(
+    "inline; filename*=utf-8''%E5%A0%B1%E5%91%8A.doca; filename=\"fallback.doca\""), "\u5831\u544a.doca");
+eq("disposition absent", S.dispositionName(null), "");
+eq("disposition without name", S.dispositionName("inline"), "");
+
+/* ---- appOf ---- */
+function containerFor(app) {
+    return OfficeContainer.pack(JSON.stringify({
+        type: "arozos-office", app: app, version: 1, body: {}
+    }));
+}
+eq("appOf document", S.appOf(containerFor("document")), "document");
+eq("appOf spreadsheet", S.appOf(containerFor("spreadsheet")), "spreadsheet");
+eq("appOf presentation", S.appOf(containerFor("presentation")), "presentation");
+eq("appOf unknown app", S.appOf(containerFor("paint")), null);
+eq("appOf plain JSON document",
+    S.appOf(OfficeContainer.utf8Encode('{"app":"spreadsheet","body":{}}')), "spreadsheet");
+eq("appOf html page", S.appOf(OfficeContainer.utf8Encode("<!DOCTYPE html><html>")), null);
+eq("appOf garbage zip", S.appOf(new Uint8Array([0x50, 0x4B, 1, 2, 3])), null);
+eq("appOf empty", S.appOf(new Uint8Array(0)), null);
+
+console.log(passes + " passed, " + failures + " failed");
+process.exit(failures ? 1 : 0);

+ 1 - 0
src/web/Office/docs/index.html

@@ -25,6 +25,7 @@
     <script src="../../script/ao_module.js"></script>
     <script src="../common/mode.js"></script>
     <script src="../common/container.js"></script>
+    <script src="../common/share.js"></script>
     <script src="../common/recents.js"></script>
     <script src="../common/wasm.js"></script>
     <script src="../common/platform.js"></script>

+ 75 - 27
src/web/Office/home/home.css

@@ -22,6 +22,11 @@
     --blue: #2563eb;
     --blue-soft: #eaf1fe;
     --blue-line: #cddffc;
+    --blue-text: #2563eb;   /* blue used as text on --blue-soft */
+    --text-soft: #344054;
+    --hover: #f4f6fa;
+    --privacy-bg: #f2f6fe;
+    --privacy-line: #e2ebfb;
     --doc: #3b82f6;
     --sheet: #16a34a;
     --slides: #f97316;
@@ -86,22 +91,6 @@ body {
     padding: 0 6px 0 4px;
     margin-bottom: 26px;
 }
-.hm-brand-mark {
-    width: 38px;
-    height: 38px;
-    border-radius: 11px;
-    background: var(--blue);
-    display: flex;
-    align-items: center;
-    justify-content: center;
-    flex: none;
-    box-shadow: 0 2px 6px rgba(37, 99, 235, .30);
-}
-.hm-brand-glyph {
-    width: 21px; height: 21px;
-    fill: none; stroke: #fff; stroke-width: 1.7;
-    stroke-linecap: round; stroke-linejoin: round;
-}
 .hm-brand-text { display: flex; flex-direction: column; line-height: 1.25; }
 .hm-brand-text b { font-size: 15.5px; font-weight: 600; letter-spacing: -.01em; }
 .hm-brand-text span { font-size: 12px; color: var(--dim); }
@@ -116,27 +105,27 @@ body {
     border: 0;
     border-radius: 9px;
     background: transparent;
-    color: #344054;
+    color: var(--text-soft);
     font: inherit;
     font-size: 14px;
     text-align: left;
     cursor: pointer;
 }
-.hm-navitem:hover { background: #f4f6fa; }
-.hm-navitem.is-active { background: var(--blue-soft); color: var(--blue); font-weight: 500; }
+.hm-navitem:hover { background: var(--hover); }
+.hm-navitem.is-active { background: var(--blue-soft); color: var(--blue-text); font-weight: 500; }
 .hm-navitem .ic { stroke-width: 1.75; }
 .hm-navsep { height: 1px; background: var(--line); margin: 12px 4px; }
 
 .hm-side-foot { margin-top: auto; padding-top: 20px; }
 .hm-privacy {
-    background: #f2f6fe;
-    border: 1px solid #e2ebfb;
+    background: var(--privacy-bg);
+    border: 1px solid var(--privacy-line);
     border-radius: var(--radius);
     padding: 13px 14px;
 }
 .hm-privacy-head {
     display: flex; align-items: center; gap: 8px;
-    color: var(--blue); font-size: 13.5px; margin-bottom: 4px;
+    color: var(--blue-text); font-size: 13.5px; margin-bottom: 4px;
 }
 .hm-privacy-head .ic { width: 16px; height: 16px; }
 .hm-privacy p { margin: 0; font-size: 12px; color: var(--dim); line-height: 1.45; }
@@ -181,7 +170,7 @@ body {
     border: 1px solid var(--line);
     border-radius: 10px;
     background: var(--panel);
-    color: #344054;
+    color: var(--text-soft);
     font: inherit;
     font-weight: 500;
     cursor: pointer;
@@ -435,6 +424,42 @@ body {
     .hm-tplgrid { grid-template-columns: repeat(2, 1fr); }
 }
 
+/* ---- ?request= share fetch ---- */
+.hm-fetch {
+    position: fixed; inset: 0; z-index: 60;
+    display: flex; align-items: center; justify-content: center;
+    padding: 16px;
+    background: var(--bg);
+}
+.hm-fetch[hidden] { display: none; }
+.hm-fetch-card {
+    width: 100%; max-width: 440px;
+    padding: 28px 28px 24px;
+    background: var(--panel);
+    border: 1px solid var(--line);
+    border-radius: var(--radius);
+    box-shadow: var(--shadow-lift);
+    text-align: center;
+}
+.hm-fetch-spin {
+    width: 34px; height: 34px; margin: 0 auto 16px;
+    border-radius: 50%;
+    border: 3px solid var(--blue-soft);
+    border-top-color: var(--blue);
+    animation: hm-spin .8s linear infinite;
+}
+.hm-fetch-spin[hidden] { display: none; }
+@keyframes hm-spin { to { transform: rotate(360deg); } }
+.hm-fetch-title { margin: 0 0 6px; font-size: 17px; font-weight: 600; }
+.hm-fetch-msg { margin: 0; color: var(--dim); font-size: 13.5px; }
+.hm-fetch.is-error .hm-fetch-msg { color: var(--text); }
+.hm-fetch-src {
+    margin: 12px 0 0; font-size: 12px; color: var(--dimmer);
+    word-break: break-all;
+}
+.hm-fetch-actions { display: flex; gap: 10px; justify-content: center; margin-top: 20px; }
+.hm-fetch-actions[hidden] { display: none; }
+
 /* ================= dark ================= */
 /* The suite stores one theme choice for every app under office_theme; the
    home page follows it, and follows the system when nothing is stored. */
@@ -449,6 +474,11 @@ body {
         --dimmer: #7c848d;
         --blue-soft: #1c2a44;
         --blue-line: #2c4a7a;
+        --blue-text: #8ab4f8;
+        --text-soft: #c4c9d0;
+        --hover: #262a31;
+        --privacy-bg: #1b2436;
+        --privacy-line: #26344e;
         --shadow: 0 1px 2px rgba(0, 0, 0, .4);
         --shadow-lift: 0 8px 24px rgba(0, 0, 0, .5);
     }
@@ -462,13 +492,31 @@ body {
     --dim: #9aa0a6;
     --dimmer: #7c848d;
     --blue-soft: #1c2a44;
+    --blue-line: #2c4a7a;
+    --blue-text: #8ab4f8;
+    --text-soft: #c4c9d0;
+    --hover: #262a31;
+    --privacy-bg: #1b2436;
+    --privacy-line: #26344e;
     --shadow: 0 1px 2px rgba(0, 0, 0, .4);
     --shadow-lift: 0 8px 24px rgba(0, 0, 0, .5);
 }
-:root[data-theme="dark"] .hm-navitem:hover,
+/* Component overrides that are not worth a token. Listed twice, like the
+   tokens above, so the system dark mode gets them too - not only an
+   explicit office_theme = dark. */
 :root[data-theme="dark"] .hm-menu button:hover,
 :root[data-theme="dark"] .hm-row:hover,
-:root[data-theme="dark"] .hm-btn:hover { background: #262a31; }
-:root[data-theme="dark"] .hm-privacy { background: #1b2436; border-color: #26344e; }
-:root[data-theme="dark"] .hm-tip kbd { background: #262a31; color: var(--text); }
+:root[data-theme="dark"] .hm-row-more:hover,
+:root[data-theme="dark"] .hm-pgbtn:hover:not(:disabled),
+:root[data-theme="dark"] .hm-btn:hover { background: var(--hover); }
+:root[data-theme="dark"] .hm-tip kbd { background: var(--hover); color: var(--text); }
 :root[data-theme="dark"] .hm-tpl-thumb { background: #f4f6fa; }
+@media (prefers-color-scheme: dark) {
+    :root:not([data-theme="light"]) .hm-menu button:hover,
+    :root:not([data-theme="light"]) .hm-row:hover,
+    :root:not([data-theme="light"]) .hm-row-more:hover,
+    :root:not([data-theme="light"]) .hm-pgbtn:hover:not(:disabled),
+    :root:not([data-theme="light"]) .hm-btn:hover { background: var(--hover); }
+    :root:not([data-theme="light"]) .hm-tip kbd { background: var(--hover); color: var(--text); }
+    :root:not([data-theme="light"]) .hm-tpl-thumb { background: #f4f6fa; }
+}

+ 88 - 0
src/web/Office/home/home.js

@@ -8,6 +8,7 @@
       - mode.js      which build this is, so formats the build cannot convert
                      are not offered
       - recents.js   OfficeRecents: the documents this browser is keeping
+      - container.js + share.js   OfficeShare: ?request=<ArozOS share link>
 
     How opening a file from here works. A File the visitor picks cannot be
     handed across a page navigation, so "Open from device" writes the bytes
@@ -15,6 +16,13 @@
     OfficePlatform resolves back to those bytes. That is the same path the
     "Recently opened" list uses, so there is one mechanism rather than two.
 
+    ?request=<share link> (standalone build) uses the same hand-off: the page
+    downloads the document from the ArozOS share's preview endpoint, reads
+    which app it belongs to from its envelope, keeps it in OfficeRecents and
+    sends that app to ?recent=<id>. When the browser will not keep it (no
+    IndexedDB, or a document over the per-entry cap) the app is sent the
+    link itself instead - ?request=&name= - and fetches it again there.
+
     Paths: BASE comes from <body data-office-base>, which the web-viewer
     generator rewrites when it moves this page to the site root. Never
     hardcode ../ here - a relative URL in this file resolves against the
@@ -588,6 +596,84 @@
         reader.readAsArrayBuffer(file);
     }
 
+    /* ================= ?request=<ArozOS share link> ================= */
+    function queryParam(name) {
+        var m = new RegExp("[?&]" + name + "=([^&]*)").exec(window.location.search || "");
+        if (!m) return null;
+        try { return decodeURIComponent(m[1].replace(/\+/g, " ")) || null; } catch (e) { return null; }
+    }
+
+    function fetchView(state, title, msg) {
+        var box = $("#hmFetch");
+        box.hidden = false;
+        box.classList.toggle("is-error", state === "error");
+        $("#hmFetchSpin").hidden = state === "error";
+        $("#hmFetchActions").hidden = state !== "error";
+        $("#hmFetchTitle").textContent = title;
+        $("#hmFetchMsg").textContent = msg || "";
+    }
+
+    // leave the request behind: close the overlay and drop the parameters,
+    // so a reload shows the plain home page rather than fetching again
+    function closeFetch() {
+        $("#hmFetch").hidden = true;
+        try { window.history.replaceState(null, "", window.location.pathname); } catch (e) { }
+    }
+
+    function openSharedLink(link, nameParam) {
+        var fail = function (msg) { fetchView("error", "Could not open the shared document", msg); };
+        var info;
+        try { info = OfficeShare.parse(link); } catch (e) {
+            fail(e.message);
+            $("#hmFetchRetry").hidden = true;   // a bad link will not get better
+            return;
+        }
+
+        $("#hmFetchSrc").textContent = link;
+        var host = info.origin.replace(/^https?:\/\//, "");
+        fetchView("busy", "Opening shared document", "Downloading from " + host + "...");
+
+        OfficeShare.fetch(info.previewUrl, function (bytes, serverName) {
+            var app = OfficeShare.appOf(bytes);
+            if (!app) {
+                fail("That share is not an ArozOS Office document (.doca, .xlsa or .ppta).");
+                return;
+            }
+            var name = OfficeShare.fileName(app, [nameParam, serverName, info.nameHint]);
+            fetchView("busy", "Opening shared document",
+                "Opening " + name + " in " + APPS[app].label + "...");
+
+            // replace(), not assign: Back from the editor should not land on
+            // this page and download the document all over again
+            var handTo = function (query) { window.location.replace(appUrl(app, query)); };
+            var sendLink = function () {
+                handTo("?request=" + encodeURIComponent(link) + "&name=" + encodeURIComponent(name));
+            };
+            if (!window.OfficeRecents || !OfficeRecents.supported()) { sendLink(); return; }
+            OfficeRecents.remember({ name: name, app: app, ext: OfficeShare.extension(app), bytes: bytes },
+                function (id) { handTo("?recent=" + encodeURIComponent(id)); },
+                sendLink);
+        }, fail);
+    }
+
+    // true when the page was opened to fetch a share, and is now doing so
+    function handleRequest() {
+        var link = queryParam("request");
+        if (!link) return false;
+        if (!isStandalone() || !window.OfficeShare) {
+            // inside ArozOS the user opens the file from their own storage;
+            // recents and the hand-off are a standalone-build mechanism
+            fetchView("error", "Could not open the shared document",
+                "Opening share links is a feature of the standalone web edition. " +
+                "In ArozOS, open the file from the File Manager instead.");
+            return true;
+        }
+        var name = queryParam("name");
+        $("#hmFetchRetry").addEventListener("click", function () { openSharedLink(link, name); });
+        openSharedLink(link, name);
+        return true;
+    }
+
     /* ================= filters ================= */
     function setFilter(f) {
         state.filter = f;
@@ -603,6 +689,8 @@
     /* ================= wiring ================= */
     function init() {
         applyTheme();
+        $("#hmFetchClose").addEventListener("click", closeFetch);
+        handleRequest();
         renderNewGrid();
         buildCreateMenu();
         renderRecents();

+ 16 - 3
src/web/Office/home/index.html

@@ -52,9 +52,6 @@
     <!-- ============ sidebar ============ -->
     <aside class="hm-side">
         <a class="hm-brand" href="../home/index.html">
-            <span class="hm-brand-mark">
-                <svg viewBox="0 0 24 24" class="hm-brand-glyph"><use href="#i-doc"></use></svg>
-            </span>
             <span class="hm-brand-text">
                 <b>ArozOS Office</b>
                 <span>Web Edition</span>
@@ -223,9 +220,25 @@
 </div>
 
 <div class="hm-drop" id="hmDrop" hidden><div>Drop the file to open it</div></div>
+
+<!-- ?request=<share link>: fetching a document from an ArozOS share -->
+<div class="hm-fetch" id="hmFetch" hidden role="dialog" aria-modal="true" aria-labelledby="hmFetchTitle">
+    <div class="hm-fetch-card">
+        <div class="hm-fetch-spin" id="hmFetchSpin" aria-hidden="true"></div>
+        <h2 class="hm-fetch-title" id="hmFetchTitle">Opening shared document</h2>
+        <p class="hm-fetch-msg" id="hmFetchMsg"></p>
+        <p class="hm-fetch-src" id="hmFetchSrc"></p>
+        <div class="hm-fetch-actions" id="hmFetchActions" hidden>
+            <button type="button" class="hm-btn" id="hmFetchRetry">Try again</button>
+            <button type="button" class="hm-btn hm-btn-primary" id="hmFetchClose">Go to home</button>
+        </div>
+    </div>
+</div>
 <input type="file" id="hmFile" hidden>
 
 <script src="../common/mode.js"></script>
+<script src="../common/container.js"></script>
+<script src="../common/share.js"></script>
 <script src="../common/recents.js"></script>
 <script src="../home/home.js"></script>
 </body>

+ 1 - 0
src/web/Office/sheets/index.html

@@ -21,6 +21,7 @@
     <script src="../../script/ao_module.js"></script>
     <script src="../common/mode.js"></script>
     <script src="../common/container.js"></script>
+    <script src="../common/share.js"></script>
     <script src="../common/recents.js"></script>
     <script src="../common/wasm.js"></script>
     <script src="../common/platform.js"></script>

+ 1 - 0
src/web/Office/slides/index.html

@@ -21,6 +21,7 @@
     <script src="../../script/ao_module.js"></script>
     <script src="../common/mode.js"></script>
     <script src="../common/container.js"></script>
+    <script src="../common/share.js"></script>
     <script src="../common/recents.js"></script>
     <script src="../common/wasm.js"></script>
     <script src="../common/platform.js"></script>