Commit Diff


commit - 8403ab16d82c2829a3ee4c45b8147aae113b4d66
commit + 1081cf75278f13ca411f20635423bcd737707d4f
blob - /dev/null
blob + b09f05ab3b40634852b2e295645424d911e3d782 (mode 644)
--- /dev/null
+++ cmd/Lemmy/Lemmy.go
@@ -0,0 +1,161 @@
+package main
+
+import (
+	"bytes"
+	"flag"
+	"fmt"
+	"log"
+	"os"
+	"path"
+	"strconv"
+	"strings"
+	"time"
+
+	"9fans.net/go/acme"
+	"olowe.co/apub/lemmy"
+)
+
+type awin struct {
+	*acme.Win
+}
+
+func (win *awin) Look(text string) bool {
+	if acme.Show(text) != nil {
+		return true
+	}
+
+	text = strings.TrimSpace(text)
+	text = strings.TrimSuffix(text, "/")
+	postID, err := strconv.Atoi(text)
+	if err != nil {
+		return openCommunity(text)
+	}
+
+	community := path.Base(win.name())
+	return openPost(postID, community)
+	return false
+}
+
+func (win *awin) Execute(cmd string) bool {
+	switch cmd {
+	case "Del":
+	default:
+		log.Println("unsupported execute", cmd)
+	}
+	return false
+}
+
+func (w *awin) name() string {
+	buf, err := w.ReadAll("tag")
+	if err != nil {
+		w.Err(err.Error())
+		return ""
+	}
+	name := strings.Fields(string(buf))[0]
+	return path.Clean(name)
+}
+
+var client *lemmy.Client
+
+func loadPostList(community string) ([]byte, error) {
+	buf := &bytes.Buffer{}
+	posts, err := client.Posts(community, lemmy.ListAll)
+	if err != nil {
+		return buf.Bytes(), err
+	}
+	for _, p := range posts {
+		// 1234/	User
+		// 	Hello world!
+		// 5678/	Pengguna
+		// 	Halo Dunia!
+		fmt.Fprintf(buf, "%d/\t%s\n\t%s\n", p.ID, p.Creator, p.Title)
+	}
+	return buf.Bytes(), err
+}
+
+func loadPost(post lemmy.Post) ([]byte, error) {
+	buf := &bytes.Buffer{}
+	fmt.Fprintf(buf, "From: %s\n", post.Creator)
+	fmt.Fprintf(buf, "Date: %s\n", post.Published.Format(time.RFC822))
+	fmt.Fprintf(buf, "Subject: %s\n", post.Title)
+
+	fmt.Fprintln(buf)
+	if post.URL != "" {
+		fmt.Fprintln(buf, post.URL)
+		fmt.Fprintln(buf)
+	}
+	if post.Body != "" {
+		fmt.Fprintln(buf, post.Body)
+		fmt.Fprintln(buf)
+	}
+	return buf.Bytes(), nil
+}
+
+func loadComments(id int) ([]byte, error) {
+	comments, err := client.Comments(id, lemmy.ListAll)
+	if err != nil {
+		return nil, err
+	}
+	buf := &bytes.Buffer{}
+	for _, c := range comments {
+		refs := lemmy.ParseCommentPath(c.Path)
+		// do we have a root comment?
+		// A root comment only referenences itself and "0"
+		if len(refs) == 2 {
+			fprintComment(buf, "", c)
+			printThread(buf, "\t", c.ID, comments)
+		}
+	}
+	return buf.Bytes(), nil
+}
+
+const Usage string = "usage: Lemmy [host]"
+
+func init() {
+	log.SetFlags(0)
+	log.SetPrefix("Lemmy: ")
+}
+
+func main() {
+	debug := flag.Bool("d", false, "enable debug output to stderr")
+	login := flag.Bool("l", false, "log in to Lemmy")
+	flag.Parse()
+
+	addr := "lemmy.sdf.org"
+	if len(flag.Args()) > 1 {
+		fmt.Fprintln(os.Stderr, Usage)
+		os.Exit(2)
+	} else if len(flag.Args()) == 1 {
+		addr = flag.Arg(0)
+	}
+	client = &lemmy.Client{
+		Address: addr,
+		Debug:   *debug,
+	}
+
+	if *login {
+		config, err := os.UserConfigDir()
+		if err != nil {
+			log.Fatalln(err)
+		}
+		username, password, err := readCreds(path.Join(config, "Lemmy"))
+		if err != nil {
+			log.Fatalln("read lemmy credentials:", err)
+		}
+		if err := client.Login(username, password); err != nil {
+			log.Fatalln("login:", err)
+		}
+	}
+
+	openCommunityList()
+	acme.AutoExit(true)
+	select {}
+}
+
+func mustPathMatch(pattern, name string) bool {
+	match, err := path.Match(pattern, name)
+	if err != nil {
+		panic(err)
+	}
+	return match
+}
blob - /dev/null
blob + ea667415e8e84767d2a8efa6c4d3aaf1e4e179ed (mode 644)
--- /dev/null
+++ cmd/Lemmy/comment.go
@@ -0,0 +1,62 @@
+package main
+
+import (
+	"fmt"
+	"io"
+	"net/mail"
+	"path"
+	"strconv"
+
+	"olowe.co/apub/lemmy"
+)
+
+func loadNewReply(pathname string) []byte {
+	if pathname == "" {
+		return []byte("To: ")
+	}
+	return []byte(fmt.Sprintf("To: %s\n\n", path.Base(pathname)))
+}
+
+func parseReply(r io.Reader) (*lemmy.Comment, error) {
+	msg, err := mail.ReadMessage(r)
+	if err != nil {
+		return nil, err
+	}
+	var comment lemmy.Comment
+	b, err := io.ReadAll(msg.Body)
+	if err != nil {
+		return nil, err
+	}
+	comment.Content = string(b)
+	if comment.PostID, err = strconv.Atoi(msg.Header.Get("To")); err != nil {
+		return nil, fmt.Errorf("parse post id: %w", err)
+	}
+	return &comment, nil
+}
+
+func printThread(w io.Writer, prefix string, parent int, comments []lemmy.Comment) {
+	for _, child := range children(parent, comments) {
+		fprintComment(w, prefix, child)
+		if len(children(child.ID, comments)) > 0 {
+			printThread(w, prefix+"\t", child.ID, comments)
+		}
+	}
+}
+
+func fprintComment(w io.Writer, prefix string, c lemmy.Comment) {
+	fmt.Fprintln(w, prefix, "From:", c.Creator)
+	fmt.Fprintln(w, prefix, "Archived-At:", c.ActivityURL)
+	fmt.Fprintln(w, prefix, c.Content)
+}
+
+func children(parent int, pool []lemmy.Comment) []lemmy.Comment {
+	var kids []lemmy.Comment
+	for _, c := range pool {
+		refs := lemmy.ParseCommentPath(c.Path)
+		pnt := refs[len(refs)-2]
+		if pnt == parent {
+			kids = append(kids, c)
+		}
+	}
+	return kids
+}
blob - /dev/null
blob + 39c4b9ec297eb71e9ad3740c62dec96eb7e160c6 (mode 644)
--- /dev/null
+++ cmd/Lemmy/config.go
@@ -0,0 +1,20 @@
+package main
+
+import (
+	"bufio"
+	"os"
+)
+
+func readCreds(name string) (username, password string, err error) {
+	f, err := os.Open(name)
+	if err != nil {
+		return "", "", err
+	}
+	defer f.Close()
+	sc := bufio.NewScanner(f)
+	sc.Scan()
+	username = sc.Text()
+	sc.Scan()
+	password = sc.Text()
+	return username, password, sc.Err()
+}
blob - /dev/null
blob + c03e3d328ffda55a60450b6d15b6701daf16c798 (mode 644)
--- /dev/null
+++ cmd/Lemmy/open.go
@@ -0,0 +1,102 @@
+package main
+
+import (
+	"errors"
+	"log"
+	"path"
+	"strconv"
+
+	"9fans.net/go/acme"
+	"olowe.co/apub/lemmy"
+)
+
+func openCommunityList() bool {
+	win, err := acme.New()
+	if err != nil {
+		log.Fatal(err)
+	}
+	win.Name("/lemmy/")
+	win.Ctl("dirty")
+	defer win.Ctl("clean")
+
+	communities, err := client.Communities(lemmy.ListAll)
+	if err != nil {
+		log.Print(err)
+		return false
+	}
+	for _, c := range communities {
+		win.Fprintf("body", "%s/\n", c.Name())
+	}
+	awin := &awin{win}
+	go awin.EventLoop(awin)
+	return true
+}
+
+func openCommunity(name string) bool {
+	_, _, err := client.LookupCommunity(name)
+	if errors.Is(err, lemmy.ErrNotFound) {
+		return false
+	} else if err != nil {
+		log.Print(err)
+		return false
+	}
+
+	win, err := acme.New()
+	if err != nil {
+		log.Fatal(err)
+	}
+	win.Ctl("dirty")
+	defer win.Ctl("clean")
+
+	awin := &awin{win}
+	awin.Name(path.Join("/lemmy", name) + "/")
+
+	body, err := loadPostList(name)
+	if err != nil {
+		win.Err(err.Error())
+		return false
+	}
+	awin.Write("body", body)
+	win.Addr("#0")
+	win.Ctl("dot=addr")
+	win.Ctl("show")
+	go awin.EventLoop(awin)
+	return true
+}
+
+func openPost(id int, community string) bool {
+	post, err := client.LookupPost(id)
+	if err != nil {
+		log.Print(err)
+		return false
+	}
+
+	win, err := acme.New()
+	if err != nil {
+		log.Fatal(err)
+	}
+	awin := &awin{win}
+	awin.Name(path.Join("/lemmy", community, strconv.Itoa(id)))
+	win.Ctl("dirty")
+	defer win.Ctl("clean")
+
+	body, err := loadPost(post)
+	if err != nil {
+		awin.Err(err.Error())
+		return false
+	}
+	awin.Write("body", body)
+
+	body, err = loadComments(post.ID)
+	if err != nil {
+		awin.Err(err.Error())
+		return false
+	}
+	awin.Write("body", body)
+
+	win.Addr("#0")
+	win.Ctl("dot=addr")
+	win.Ctl("show")
+	go awin.EventLoop(awin)
+	return true
+}
blob - 66e602e1e4f79e3d20d5d9834585ff5fbe991b32
blob + 81ae4644143560c543fd1d23d5d1b9691ef9c580
--- go.mod
+++ go.mod
@@ -3,6 +3,7 @@ module olowe.co/apub
 go 1.19
 
 require (
+	9fans.net/go v0.0.7
 	github.com/emersion/go-smtp v0.20.2
 	webfinger.net/go/webfinger v0.1.0
 )
blob - a139f20def695ecfe9371f5f60ac72f4b7b3dd19
blob + 555d2d50c9137c105432293bcca38c79f81a86d3
--- go.sum
+++ go.sum
@@ -1,6 +1,40 @@
+9fans.net/go v0.0.7 h1:H5CsYJTf99C8EYAQr+uSoEJnLP/iZU8RmDuhyk30iSM=
+9fans.net/go v0.0.7/go.mod h1:Rxvbbc1e+1TyGMjAvLthGTyO97t+6JMQ6ly+Lcs9Uf0=
+dmitri.shuralyov.com/gpu/mtl v0.0.0-20201218220906-28db891af037/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
+github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
 github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ=
 github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
 github.com/emersion/go-smtp v0.20.2 h1:peX42Qnh5Q0q3vrAnRy43R/JwTnnv75AebxbkTL7Ia4=
 github.com/emersion/go-smtp v0.20.2/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
+github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56/go.mod h1:JhuoJpWY28nO4Vef9tZUw9qufEGTyX1+7lmHxV5q5G4=
+golang.org/x/exp v0.0.0-20210405174845-4513512abef3/go.mod h1:I6l2HNBLBZEcrOoCpyKLdY2lHoRZ8lI4x60KMCQDft4=
+golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
+golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
+golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
+golang.org/x/mobile v0.0.0-20201217150744-e6ae53a27f4f/go.mod h1:skQtrUTUwhdJvXM/2KKJzY8pDgNr9I/FOMqDVRPBUS4=
+golang.org/x/mobile v0.0.0-20210220033013-bdb1ca9a1e08/go.mod h1:skQtrUTUwhdJvXM/2KKJzY8pDgNr9I/FOMqDVRPBUS4=
+golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
+golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
+golang.org/x/mod v0.1.1-0.20191209134235-331c550502dd/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/mod v0.3.1-0.20200828183125-ce943fd02449/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210415045647-66c3f260301c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20200117012304-6edc0a871e69/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 webfinger.net/go/webfinger v0.1.0 h1:e/J18UgjFE8+ZbKxzKm4+gv4ehidNnF6hcbHwS3K63U=
 webfinger.net/go/webfinger v0.1.0/go.mod h1:+najbdnIKfnKo68tU2TF+AXm8/MOqLYXqx22j8Xw7FM=
blob - /dev/null
blob + f17aae80d38fe908fefe427d4ce6513d2f0584a6 (mode 644)
--- /dev/null
+++ lemmy/auth.go
@@ -0,0 +1,41 @@
+package lemmy
+
+import (
+	"encoding/json"
+	"fmt"
+	"net/http"
+)
+
+func (c *Client) Login(name, password string) error {
+	if !c.ready {
+		if err := c.init(); err != nil {
+			return err
+		}
+	}
+
+	params := map[string]interface{}{
+		"username_or_email": name,
+		"password":          password,
+	}
+	resp, err := c.post("/user/login", params)
+	if err != nil {
+		return err
+	}
+	defer resp.Body.Close()
+	if resp.StatusCode != http.StatusOK {
+		return fmt.Errorf("remote status %s: %w", resp.Status, decodeError(resp.Body))
+	}
+
+	var response struct {
+		JWT string
+	}
+	if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
+		return fmt.Errorf("decode login response: %w", err)
+	}
+	c.authToken = response.JWT
+	return nil
+}
+
+func (c *Client) Authenticated() bool {
+	return c.authToken != ""
+}
blob - /dev/null
blob + c131343175181319f4127c2ac07bd312a89a7621 (mode 644)
--- /dev/null
+++ lemmy/cache.go
@@ -0,0 +1,74 @@
+package lemmy
+
+import (
+	"fmt"
+	"net/http"
+	"strconv"
+	"strings"
+	"sync"
+	"time"
+)
+
+type cache struct {
+	post      map[int]entry
+	community map[string]entry
+	mu        *sync.Mutex
+}
+
+type entry struct {
+	post      Post
+	community Community
+	expiry    time.Time
+}
+
+func (c *cache) store(p Post, com Community, dur time.Duration) {
+	c.mu.Lock()
+	defer c.mu.Unlock()
+	t := time.Now().Add(dur)
+	entry := entry{expiry: t}
+	if p.Name() != "" {
+		entry.post = p
+		c.post[p.ID] = entry
+	}
+	if com.Name() != "" {
+		entry.community = com
+		c.community[com.Name()] = entry
+	}
+}
+
+func (c *cache) delete(p Post, com Community) {
+	c.mu.Lock()
+	defer c.mu.Unlock()
+	delete(c.post, p.ID)
+	delete(c.community, com.Name())
+}
+
+// max-age=50
+func parseMaxAge(s string) (time.Duration, error) {
+	var want string
+	elems := strings.Split(s, ",")
+	for i := range elems {
+		elems[i] = strings.TrimSpace(elems[i])
+		if strings.HasPrefix(elems[i], "max-age") {
+			want = elems[i]
+		}
+	}
+	_, num, found := strings.Cut(want, "=")
+	if !found {
+		return 0, fmt.Errorf("missing = separator")
+	}
+	n, err := strconv.Atoi(num)
+	if err != nil {
+		return 0, fmt.Errorf("parse seconds: %w", err)
+	}
+	return time.Duration(n) * time.Second, nil
+}
+
+// Cache-Control: public, max-age=50
+func extractMaxAge(header http.Header) string {
+	cc := header.Get("Cache-Control")
+	if !strings.Contains(cc, "max-age=") {
+		return ""
+	}
+	return cc
+}
blob - /dev/null
blob + d4525a1aac064f938c573fa2a294cac44b96dfbb (mode 644)
--- /dev/null
+++ lemmy/client.go
@@ -0,0 +1,391 @@
+package lemmy
+
+import (
+	"bytes"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"os"
+	"path"
+	"strconv"
+	"sync"
+	"time"
+)
+
+type Client struct {
+	*http.Client
+	Address string
+	// If true, HTTP request summaries are printed to standard error.
+	Debug     bool
+	authToken string
+	instance  *url.URL
+	cache     *cache
+	ready     bool
+}
+
+type ListMode string
+
+const (
+	ListAll        ListMode = "All"
+	ListLocal               = "Local"
+	ListSubscribed          = "Subscribed"
+)
+
+var ErrNotFound error = errors.New("not found")
+
+func (c *Client) init() error {
+	if c.Address == "" {
+		c.Address = "127.0.0.1"
+	}
+	if c.instance == nil {
+		u, err := url.Parse("https://" + c.Address + "/api/v3/")
+		if err != nil {
+			return fmt.Errorf("initialise client: parse instance url: %w", err)
+		}
+		c.instance = u
+	}
+	if c.Client == nil {
+		c.Client = http.DefaultClient
+	}
+	if c.cache == nil {
+		c.cache = &cache{
+			post:      make(map[int]entry),
+			community: make(map[string]entry),
+			mu:        &sync.Mutex{},
+		}
+	}
+	c.ready = true
+	return nil
+}
+
+func (c *Client) Communities(mode ListMode) ([]Community, error) {
+	if !c.ready {
+		if err := c.init(); err != nil {
+			return nil, err
+		}
+	}
+
+	params := map[string]string{
+		"type_": string(mode),
+		"limit": "30", // TODO go through pages
+		"sort":  "New",
+	}
+	if mode == ListSubscribed {
+		if c.authToken == "" {
+			return nil, errors.New("not logged in, no subscriptions")
+		}
+		params["auth"] = c.authToken
+	}
+	resp, err := c.get("community/list", params)
+	if err != nil {
+		return nil, err
+	}
+	defer resp.Body.Close()
+	if resp.StatusCode != http.StatusOK {
+		return nil, fmt.Errorf("remote status %s: %w", resp.Status, decodeError(resp.Body))
+	}
+
+	var response struct {
+		Communities []struct {
+			Community Community
+		}
+	}
+	if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
+		return nil, fmt.Errorf("decode community response: %w", err)
+	}
+	var communities []Community
+	for _, c := range response.Communities {
+		communities = append(communities, c.Community)
+	}
+	return communities, nil
+}
+
+func (c *Client) LookupCommunity(name string) (Community, Counts, error) {
+	if !c.ready {
+		if err := c.init(); err != nil {
+			return Community{}, Counts{}, err
+		}
+	}
+	if ent, ok := c.cache.community[name]; ok {
+		if time.Now().Before(ent.expiry) {
+			return ent.community, Counts{}, nil
+		}
+		c.cache.delete(ent.post, ent.community)
+	}
+
+	params := map[string]string{"name": name}
+	resp, err := c.get("community", params)
+	if err != nil {
+		return Community{}, Counts{}, err
+	}
+	defer resp.Body.Close()
+	if resp.StatusCode == http.StatusNotFound {
+		return Community{}, Counts{}, ErrNotFound
+	} else if resp.StatusCode != http.StatusOK {
+		return Community{}, Counts{}, fmt.Errorf("remote status %s: %w", resp.Status, decodeError(resp.Body))
+	}
+
+	type response struct {
+		View struct {
+			Community Community
+			Counts    Counts
+		} `json:"community_view"`
+	}
+	var cres response
+	if err := json.NewDecoder(resp.Body).Decode(&cres); err != nil {
+		return Community{}, Counts{}, fmt.Errorf("decode community response: %w", err)
+	}
+	community := cres.View.Community
+	age := extractMaxAge(resp.Header)
+	if age != "" {
+		dur, err := parseMaxAge(age)
+		if err != nil {
+			return community, Counts{}, fmt.Errorf("parse cache max age from response header: %w", err)
+		}
+		c.cache.store(Post{}, community, dur)
+	}
+	return community, cres.View.Counts, nil
+}
+
+func (c *Client) Posts(community string, mode ListMode) ([]Post, error) {
+	if !c.ready {
+		if err := c.init(); err != nil {
+			return nil, err
+		}
+	}
+
+	params := map[string]string{
+		"community_name": community,
+		//		"limit":          "30",
+		"type_": string(mode),
+		"sort":  "New",
+	}
+	resp, err := c.get("post/list", params)
+	if err != nil {
+		return nil, err
+	}
+	defer resp.Body.Close()
+	if resp.StatusCode != http.StatusOK {
+		return nil, fmt.Errorf("remote status %s: %w", resp.Status, decodeError(resp.Body))
+	}
+	age := extractMaxAge(resp.Header)
+	ttl, err := parseMaxAge(age)
+	if c.Debug && err != nil {
+		fmt.Fprintln(os.Stderr, "parse cache max-age from header:", err)
+	}
+
+	var jresponse struct {
+		Posts []struct {
+			Post    Post
+			Creator Person
+		}
+	}
+	if err := json.NewDecoder(resp.Body).Decode(&jresponse); err != nil {
+		return nil, fmt.Errorf("decode posts response: %w", err)
+	}
+	var posts []Post
+	for _, post := range jresponse.Posts {
+		post.Post.Creator = post.Creator
+		posts = append(posts, post.Post)
+		if ttl > 0 {
+			c.cache.store(post.Post, Community{}, ttl)
+		}
+	}
+	return posts, nil
+}
+
+func (c *Client) LookupPost(id int) (Post, error) {
+	if !c.ready {
+		if err := c.init(); err != nil {
+			return Post{}, err
+		}
+	}
+	if ent, ok := c.cache.post[id]; ok {
+		if time.Now().Before(ent.expiry) {
+			return ent.post, nil
+		}
+		c.cache.delete(ent.post, Community{})
+	}
+
+	params := map[string]string{"id": strconv.Itoa(id)}
+	resp, err := c.get("post", params)
+	if err != nil {
+		return Post{}, err
+	}
+	defer resp.Body.Close()
+	if resp.StatusCode == http.StatusNotFound {
+		return Post{}, ErrNotFound
+	} else if resp.StatusCode != http.StatusOK {
+		return Post{}, fmt.Errorf("remote status %s: %w", resp.Status, decodeError(resp.Body))
+	}
+	post, _, _, err := decodePostResponse(resp.Body)
+	age := extractMaxAge(resp.Header)
+	if age != "" {
+		dur, err := parseMaxAge(age)
+		if err != nil {
+			return post, fmt.Errorf("parse cache max age from response header: %w", err)
+		}
+		c.cache.store(post, Community{}, dur)
+	}
+	return post, err
+}
+
+func (c *Client) Comments(post int, mode ListMode) ([]Comment, error) {
+	if !c.ready {
+		if err := c.init(); err != nil {
+			return nil, err
+		}
+	}
+
+	params := map[string]string{
+		"post_id": strconv.Itoa(post),
+		"type_":   string(mode),
+		"limit":   "30",
+		"sort":    "New",
+	}
+	resp, err := c.get("comment/list", params)
+	if err != nil {
+		return nil, err
+	}
+	defer resp.Body.Close()
+	if resp.StatusCode != http.StatusOK {
+		return nil, fmt.Errorf("remote status %s: %w", resp.Status, decodeError(resp.Body))
+	}
+
+	var jresponse struct {
+		Comments []struct {
+			Comment Comment
+			Creator Person
+		}
+	}
+	if err := json.NewDecoder(resp.Body).Decode(&jresponse); err != nil {
+		return nil, fmt.Errorf("decode comments: %w", err)
+	}
+	var comments []Comment
+	for _, comment := range jresponse.Comments {
+		comment.Comment.Creator = comment.Creator
+		comments = append(comments, comment.Comment)
+	}
+	return comments, nil
+}
+
+func (c *Client) LookupComment(id int) (Comment, error) {
+	if !c.ready {
+		if err := c.init(); err != nil {
+			return Comment{}, err
+		}
+	}
+
+	params := map[string]string{"id": strconv.Itoa(id)}
+	resp, err := c.get("comment", params)
+	if err != nil {
+		return Comment{}, err
+	}
+	defer resp.Body.Close()
+	if resp.StatusCode != http.StatusOK {
+		return Comment{}, fmt.Errorf("remote status %s: %w", resp.Status, decodeError(resp.Body))
+	}
+
+	type jresponse struct {
+		CommentView struct {
+			Comment Comment
+		} `json:"comment_view"`
+	}
+	var jresp jresponse
+	if err := json.NewDecoder(resp.Body).Decode(&jresp); err != nil {
+		return Comment{}, fmt.Errorf("decode comment: %w", err)
+	}
+	return jresp.CommentView.Comment, nil
+}
+
+func (c *Client) Reply(post int, parent int, msg string) error {
+	if c.authToken == "" {
+		return errors.New("not logged in")
+	}
+
+	params := map[string]interface{}{
+		"post_id": post,
+		"content": msg,
+		"auth":    c.authToken,
+	}
+	if parent > 0 {
+		params["parent_id"] = strconv.Itoa(parent)
+	}
+	resp, err := c.post("/comment", params)
+	if err != nil {
+		return err
+	}
+	defer resp.Body.Close()
+	if resp.StatusCode != http.StatusOK {
+		return fmt.Errorf("remote status %s: %w", resp.Status, decodeError(resp.Body))
+	}
+	return nil
+}
+
+func (c *Client) post(pathname string, params map[string]interface{}) (*http.Response, error) {
+	u := *c.instance
+	u.Path = path.Join(u.Path, pathname)
+
+	b, err := json.Marshal(params)
+	if err != nil {
+		return nil, fmt.Errorf("encode body: %w", err)
+	}
+	req, err := http.NewRequest(http.MethodPost, u.String(), bytes.NewReader(b))
+	if err != nil {
+		return nil, err
+	}
+	req.Header.Set("Content-Type", "application/json")
+	req.Header.Set("Accept", "application/json")
+	return c.Do(req)
+}
+
+func (c *Client) get(pathname string, params map[string]string) (*http.Response, error) {
+	u := *c.instance
+	u.Path = path.Join(u.Path, pathname)
+	vals := make(url.Values)
+	for k, v := range params {
+		vals.Set(k, v)
+	}
+	u.RawQuery = vals.Encode()
+	req, err := http.NewRequest(http.MethodGet, u.String(), nil)
+	if err != nil {
+		return nil, err
+	}
+	req.Header.Set("Accept", "application/json")
+	if c.Debug {
+		fmt.Fprintf(os.Stderr, "%s %s\n", req.Method, req.URL)
+	}
+	resp, err := c.Do(req)
+	if err != nil {
+		return resp, err
+	}
+	if resp.StatusCode == http.StatusServiceUnavailable {
+		time.Sleep(2 * time.Second)
+		resp, err = c.get(pathname, params)
+	}
+	return resp, err
+}
+
+type jError struct {
+	Err string `json:"error"`
+}
+
+func (err jError) Error() string { return err.Err }
+
+func decodeError(r io.Reader) error {
+	var jerr jError
+	if err := json.NewDecoder(r).Decode(&jerr); err != nil {
+		return fmt.Errorf("decode error message: %v", err)
+	}
+	return jerr
+}
+
+type Counts struct {
+	Posts       int
+	Comments    int
+	CommunityID int `json:"community_id"`
+	PostID      int `json:"post_id"`
+}
blob - /dev/null
blob + be02ada34641ad3460761810eff0a166c2f77360 (mode 644)
--- /dev/null
+++ lemmy/client_test.go
@@ -0,0 +1,10 @@
+package lemmy
+
+import "testing"
+
+func TestZeroClient(t *testing.T) {
+	client := &Client{}
+	if _, _, err := client.LookupCommunity("test"); err != nil {
+		t.Log(err)
+	}
+}
blob - /dev/null
blob + 59d0d76dadbcfaa8839516f898f9138b05834559 (mode 644)
--- /dev/null
+++ lemmy/decode.go
@@ -0,0 +1,39 @@
+package lemmy
+
+import (
+	"encoding/json"
+	"fmt"
+	"io"
+)
+
+func decodePosts(r io.Reader) ([]Post, error) {
+	var jresponse struct {
+		Posts []struct {
+			Post Post
+		}
+	}
+	if err := json.NewDecoder(r).Decode(&jresponse); err != nil {
+		return nil, fmt.Errorf("decode posts response: %w", err)
+	}
+	var posts []Post
+	for _, post := range jresponse.Posts {
+		posts = append(posts, post.Post)
+	}
+	return posts, nil
+}
+
+func decodePostResponse(r io.Reader) (Post, Person, Community, error) {
+	type jresponse struct {
+		PostView struct {
+			Post      Post
+			Creator   Person
+			Community Community
+		} `json:"post_view"`
+	}
+	var jresp jresponse
+	if err := json.NewDecoder(r).Decode(&jresp); err != nil {
+		return Post{}, Person{}, Community{}, fmt.Errorf("decode post: %w", err)
+	}
+	jresp.PostView.Post.Creator = jresp.PostView.Creator
+	return jresp.PostView.Post, jresp.PostView.Creator, jresp.PostView.Community, nil
+}
blob - /dev/null
blob + b7aabd94beb820d8430bd35e075d0fdeb38a7a90 (mode 644)
--- /dev/null
+++ lemmy/decode_test.go
@@ -0,0 +1,25 @@
+package lemmy
+
+import (
+	"os"
+	"testing"
+)
+
+func TestPost(t *testing.T) {
+	f, err := os.Open("testdata/post.json")
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer f.Close()
+	post, creator, _, err := decodePostResponse(f)
+	if err != nil {
+		t.Fatal(err)
+	}
+	t.Log(post.ID)
+	if creator.ID != 151025 {
+		t.Errorf("check creator ID: want %d, got %d", 2, creator.ID)
+	}
+	if creator.String() != "otl@lemmy.sdf.org" {
+		t.Errorf("creator username: want %s, got %s", "TheAnonymouseJoker@lemmy.ml", creator.String())
+	}
+}
blob - /dev/null
blob + e8e734b105bceb685fcf884777f81a0de929c2cf (mode 644)
--- /dev/null
+++ lemmy/fs/dir.go
@@ -0,0 +1,33 @@
+package fs
+
+import (
+	"io"
+	"io/fs"
+)
+
+type dirInfo struct {
+	entries []fs.DirEntry
+	entryp  int
+}
+
+func (d *dirInfo) ReadDir(n int) ([]fs.DirEntry, error) {
+	entries := d.entries[d.entryp:]
+	if n < 0 {
+		d.entryp = len(d.entries) // advance to the end
+		if len(entries) == 0 {
+			return nil, nil
+		}
+		return entries, nil
+	}
+
+	var err error
+	if n >= len(entries) {
+		err = io.EOF
+	} else if d.entryp >= len(d.entries) {
+		err = io.EOF
+	} else {
+		entries = entries[:n-1]
+	}
+	d.entryp += n
+	return entries, err
+}
blob - /dev/null
blob + 9227e6b58f1c63117d90ac3e10dae531fa84ec3f (mode 644)
--- /dev/null
+++ lemmy/fs/doc.go
@@ -0,0 +1,37 @@
+/*
+FS is a read-only filesystem interface to a Lemmy instance.
+The root of the filesystem holds directories for each community known to the filesystem.
+Local communities are named by their plain name verbatim.
+Remote communities have the instance address as a suffix. For example:
+
+	golang/
+	plan9@lemmy.sdf.org/
+	openbsd@lemmy.sdf.org/
+
+Each community directory holds posts.
+Each post has associated a directory numbered by its ID.
+Within each post are the following entries:
+
+	body     Text describing, or accompanying, the post.
+	creator  The numeric user ID of the post's author.
+	title    The post's title.
+	url      A URL pointing to a picture or website, usually as the
+	         subject of the post if present.
+	123...   Numbered files containing user discussion.
+	         Described in more detail below.
+
+A comment file is named by its unique comment ID.
+Its contents are a RFC 5322 message.
+The message body contains the text content of the comment.
+The header contains the following fields:
+
+	From       User ID of the comment's author.
+	References A list of comment IDs referenced by this comment, one
+	           per line. The first line is the immediately referenced
+	           comment (the parent); the second is the grandparent and
+	           so on. This can be used by readers to render discussion
+	           threads.
+
+FS satisfies io/fs.FS.
+*/
+package fs
blob - /dev/null
blob + 3e854ef3f59dd14aa4c8c3a8c0c590b9c45095e8 (mode 644)
--- /dev/null
+++ lemmy/fs/file.go
@@ -0,0 +1,170 @@
+package fs
+
+import (
+	"bytes"
+	"fmt"
+	"io"
+	"io/fs"
+	"strings"
+	"time"
+
+	"olowe.co/apub/lemmy"
+)
+
+type fakeStat struct {
+	name  string
+	size  int64
+	mode  fs.FileMode
+	mtime time.Time
+}
+
+func (s *fakeStat) Name() string       { return s.name }
+func (s *fakeStat) Size() int64        { return s.size }
+func (s *fakeStat) Mode() fs.FileMode  { return s.mode }
+func (s *fakeStat) ModTime() time.Time { return s.mtime }
+func (s *fakeStat) IsDir() bool        { return s.mode.IsDir() }
+func (s *fakeStat) Sys() any           { return nil }
+
+type dummy struct {
+	name     string
+	mode     fs.FileMode
+	mtime    time.Time
+	contents []byte
+	dirinfo  *dirInfo
+	buf      io.ReadCloser
+}
+
+func (f *dummy) Name() string               { return f.name }
+func (f *dummy) IsDir() bool                { return f.mode.IsDir() }
+func (f *dummy) Type() fs.FileMode          { return f.mode.Type() }
+func (f *dummy) Info() (fs.FileInfo, error) { return f.Stat() }
+
+func (f *dummy) Stat() (fs.FileInfo, error) {
+	return &fakeStat{
+		name:  f.name,
+		mode:  f.mode,
+		size:  int64(len(f.contents)),
+		mtime: f.mtime,
+	}, nil
+}
+
+func (f *dummy) Read(p []byte) (int, error) {
+	if f.buf == nil {
+		f.buf = io.NopCloser(bytes.NewReader(f.contents))
+	}
+	return f.buf.Read(p)
+}
+
+func (f *dummy) Close() error {
+	if f.buf == nil {
+		return nil
+	}
+	err := f.buf.Close()
+	f.buf = nil
+	return err
+}
+
+func (f *dummy) ReadDir(n int) ([]fs.DirEntry, error) {
+	if !f.mode.IsDir() {
+		return nil, &fs.PathError{"readdir", f.name, fmt.Errorf("not a directory")}
+	} else if f.dirinfo == nil {
+		// TODO(otl): is this accidental? maybe return an error here.
+		return nil, &fs.PathError{"readdir", f.name, fmt.Errorf("no dirinfo to track reads")}
+	}
+
+	return f.dirinfo.ReadDir(n)
+}
+
+type lFile struct {
+	info    fs.FileInfo
+	dirinfo *dirInfo
+	client  *lemmy.Client
+	buf     io.ReadCloser
+}
+
+func (f *lFile) Read(p []byte) (int, error) {
+	if f.buf == nil {
+		f.buf = io.NopCloser(strings.NewReader("directory"))
+	}
+	return f.buf.Read(p)
+}
+
+func (f *lFile) Close() error {
+	if f.buf == nil || f.dirinfo == nil {
+		return fs.ErrClosed
+	}
+	f.dirinfo = nil
+	err := f.buf.Close()
+	f.buf = nil
+	return err
+}
+
+func (f *lFile) Stat() (fs.FileInfo, error) {
+	return f.info, nil
+}
+
+func (f *lFile) ReadDir(n int) ([]fs.DirEntry, error) {
+	if f.dirinfo == nil {
+		f.dirinfo = new(dirInfo)
+		switch f.info.(type) {
+		case *lemmy.Community:
+			posts, err := f.client.Posts(f.info.Name(), lemmy.ListAll)
+			if err != nil {
+				return nil, &fs.PathError{"readdir", f.info.Name(), err}
+			}
+			for _, p := range posts {
+				p := p
+				f.dirinfo.entries = append(f.dirinfo.entries, fs.FileInfoToDirEntry(&p))
+			}
+		case *lemmy.Post:
+			p := f.info.(*lemmy.Post)
+			comments, err := f.client.Comments(p.ID, lemmy.ListAll)
+			if err != nil {
+				return nil, &fs.PathError{"readdir", f.info.Name(), err}
+			}
+			for _, c := range comments {
+				c := c
+				f.dirinfo.entries = append(f.dirinfo.entries, fs.FileInfoToDirEntry(&c))
+			}
+			f.dirinfo.entries = append(f.dirinfo.entries, postFile(p))
+		default:
+			return nil, &fs.PathError{"readdir", f.info.Name(), fmt.Errorf("not a directory")}
+		}
+	}
+	return f.dirinfo.ReadDir(n)
+}
+func postText(p *lemmy.Post) *bytes.Buffer {
+	buf := &bytes.Buffer{}
+	fmt.Fprintln(buf, "From:", p.CreatorID)
+	fmt.Fprintf(buf, "Message-Id: <%d>\n", p.ID)
+	fmt.Fprintf(buf, "List-Archive: <%s>\n", p.URL)
+	fmt.Fprintln(buf, "Date:", p.ModTime().Format(time.RFC822))
+	fmt.Fprintln(buf, "Subject:", p.Title)
+	fmt.Fprintln(buf)
+	if p.URL != "" {
+		fmt.Fprintln(buf, p.URL)
+	}
+	fmt.Fprintln(buf, p.Body)
+	return buf
+}
+
+func postFile(p *lemmy.Post) *dummy {
+	return &dummy{
+		name:     "post",
+		mode:     0o444,
+		mtime:    p.ModTime(),
+		contents: postText(p).Bytes(),
+	}
+}
+
+func commentText(c *lemmy.Comment) *bytes.Buffer {
+	buf := &bytes.Buffer{}
+	fmt.Fprintln(buf, "From:", c.CreatorID)
+	fmt.Fprintln(buf, "Date:", c.ModTime().Format(time.RFC822))
+	fmt.Fprintf(buf, "Message-ID: <%d>\n", c.ID)
+	fmt.Fprintf(buf, "List-Archive: <%s>\n", c.ActivityURL)
+	fmt.Fprintln(buf, "Subject: Re:", c.PostID)
+	fmt.Fprintln(buf)
+	fmt.Fprintln(buf, c.Content)
+	return buf
+}
blob - /dev/null
blob + 5b1bcfeb698ad3c353b53e03afa6b738f02cf2bc (mode 644)
--- /dev/null
+++ lemmy/fs/fs.go
@@ -0,0 +1,131 @@
+package fs
+
+import (
+	"errors"
+	"fmt"
+	"io"
+	"io/fs"
+	"path"
+	"strconv"
+	"strings"
+	"time"
+
+	"olowe.co/apub/lemmy"
+)
+
+type FS struct {
+	Client  *lemmy.Client
+	started bool
+}
+
+func (fsys *FS) start() error {
+	if fsys.Client == nil {
+		fsys.Client = &lemmy.Client{}
+	}
+	fsys.started = true
+	return nil
+}
+
+func (fsys *FS) Open(name string) (fs.File, error) {
+	if !fs.ValidPath(name) {
+		return nil, &fs.PathError{"open", name, fs.ErrInvalid}
+	} else if strings.Contains(name, `\`) {
+		return nil, &fs.PathError{"open", name, fs.ErrInvalid}
+	}
+	name = path.Clean(name)
+
+	if !fsys.started {
+		if err := fsys.start(); err != nil {
+			return nil, fmt.Errorf("start fs: %w", err)
+		}
+	}
+	if name == "." {
+		return fsys.openRoot()
+	}
+
+	elems := strings.Split(name, "/")
+	// We've only got communities, then posts/comments.
+	// Anything deeper cannot exist.
+	if len(elems) > 3 {
+		return nil, &fs.PathError{"open", name, fs.ErrNotExist}
+	}
+
+	community, _, err := fsys.Client.LookupCommunity(elems[0])
+	if errors.Is(err, lemmy.ErrNotFound) {
+		return nil, &fs.PathError{"open", name, fs.ErrNotExist}
+	} else if err != nil {
+		return nil, &fs.PathError{"open", name, err}
+	}
+	if len(elems) == 1 {
+		return &lFile{
+			info:   &community,
+			buf:    io.NopCloser(strings.NewReader(community.Name())),
+			client: fsys.Client,
+		}, nil
+	}
+
+	id, err := strconv.Atoi(elems[1])
+	if err != nil {
+		return nil, &fs.PathError{"open", name, fmt.Errorf("bad post id")}
+	}
+	post, err := fsys.Client.LookupPost(id)
+	if errors.Is(err, lemmy.ErrNotFound) {
+		return nil, &fs.PathError{"open", name, fs.ErrNotExist}
+	} else if err != nil {
+		return nil, &fs.PathError{"open", name, err}
+	}
+	if len(elems) == 2 {
+		return &lFile{
+			info:   &post,
+			buf:    io.NopCloser(strings.NewReader(post.Name())),
+			client: fsys.Client,
+		}, nil
+	}
+	if elems[2] == "post" {
+		info, err := postFile(&post).Stat()
+		if err != nil {
+			return nil, &fs.PathError{"open", name, fmt.Errorf("prepare post file info: %w", err)}
+		}
+		return &lFile{
+			info:   info,
+			buf:    io.NopCloser(postText(&post)),
+			client: fsys.Client,
+		}, nil
+	}
+
+	id, err = strconv.Atoi(elems[2])
+	if err != nil {
+		return nil, &fs.PathError{"open", name, fmt.Errorf("bad comment id")}
+	}
+	comment, err := fsys.Client.LookupComment(id)
+	if errors.Is(err, lemmy.ErrNotFound) {
+		return nil, &fs.PathError{"open", name, fs.ErrNotExist}
+	} else if err != nil {
+		return nil, &fs.PathError{"open", name, err}
+	}
+	return &lFile{
+		info:   &comment,
+		buf:    io.NopCloser(commentText(&comment)),
+		client: fsys.Client,
+	}, nil
+}
+
+func (fsys *FS) openRoot() (fs.File, error) {
+	dirinfo := new(dirInfo)
+	communities, err := fsys.Client.Communities(lemmy.ListAll)
+	if err != nil {
+		return nil, err
+	}
+	for _, c := range communities {
+		c := c
+		dent := fs.FileInfoToDirEntry(&c)
+		dirinfo.entries = append(dirinfo.entries, dent)
+	}
+	return &dummy{
+		name:     ".",
+		mode:     fs.ModeDir | 0444,
+		contents: []byte("hello, world!"),
+		dirinfo:  dirinfo,
+		mtime:    time.Now(),
+	}, nil
+}
blob - /dev/null
blob + 7e3c04ee1dee2e9ed0e163eff91a8451b49c3ac8 (mode 644)
--- /dev/null
+++ lemmy/fs/fs_test.go
@@ -0,0 +1,35 @@
+package fs
+
+import (
+	"io/fs"
+	"net/http"
+	"testing"
+	"testing/fstest"
+
+	"olowe.co/apub/lemmy"
+)
+
+// ds9.lemmy.ml is a test instance run by the Lemmy maintainers.
+func TestFS(t *testing.T) {
+	if _, err := http.Head("https://ds9.lemmy.ml"); err != nil {
+		t.Skip(err)
+	}
+	fsys := &FS{
+		Client: &lemmy.Client{
+			Address: "ds9.lemmy.ml",
+			Debug:   true,
+		},
+	}
+	_, err := fsys.Open("zzztestcommunity1")
+	if err != nil {
+		t.Fatal(err)
+	}
+	_, err = fs.ReadFile(fsys, "zzztestcommunity1/447/331")
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if err := fstest.TestFS(fsys, "zzztestcommunity1", "zzztestcommunity1/447/post", "zzztestcommunity1/447/331"); err != nil {
+		t.Fatal(err)
+	}
+}
blob - /dev/null
blob + 3baacdcfc27eb099557777dbe1a5c6af3cff4e1b (mode 644)
--- /dev/null
+++ lemmy/lemmy.go
@@ -0,0 +1,125 @@
+// Package lemmy provides a client interface to the Lemmy HTTP API version 3.
+package lemmy
+
+import (
+	"fmt"
+	"io/fs"
+	"strconv"
+	"strings"
+	"time"
+)
+
+type Community struct {
+	ID        int    `json:"id"`
+	FName     string `json:"name"`
+	Title     string `json:"title"`
+	Local     bool
+	ActorID   string `json:"actor_id"`
+	Published time.Time
+}
+
+func (c *Community) Name() string       { return c.String() }
+func (c *Community) Size() int64        { return 0 }
+func (c *Community) Mode() fs.FileMode  { return fs.ModeDir | 0o0555 }
+func (c *Community) ModTime() time.Time { return c.Published }
+func (c *Community) IsDir() bool        { return c.Mode().IsDir() }
+func (c *Community) Sys() interface{}   { return nil }
+
+func (c Community) String() string {
+	if c.Local {
+		return c.FName
+	}
+	noscheme := strings.TrimPrefix(c.ActorID, "https://")
+	instance, _, _ := strings.Cut(noscheme, "/")
+	return fmt.Sprintf("%s@%s", c.FName, instance)
+}
+
+type Post struct {
+	ID        int
+	Title     string `json:"name"`
+	Body      string
+	CreatorID int `json:"creator_id"`
+	URL       string
+	Published time.Time
+	Updated   time.Time
+	Creator   Person `json:"-"`
+}
+
+func (p *Post) Name() string { return strconv.Itoa(p.ID) }
+
+func (p *Post) Size() int64 {
+	return int64(len(p.Body))
+}
+
+func (p *Post) Mode() fs.FileMode { return fs.ModeDir | 0o0555 }
+func (p *Post) IsDir() bool       { return p.Mode().IsDir() }
+func (p *Post) Sys() interface{}  { return nil }
+func (p *Post) ModTime() time.Time {
+	if p.Updated.IsZero() {
+		return p.Published
+	}
+	return p.Updated
+}
+
+type Comment struct {
+	ID     int
+	PostID int `json:"post_id"`
+	// Holds ordered comment IDs referenced by this comment
+	// for threading.
+	Path        string
+	Content     string
+	CreatorID   int `json:"creator_id"`
+	Published   time.Time
+	Updated     time.Time
+	ActivityURL string `json:"ap_id"`
+	Creator     Person `json:"-"`
+}
+
+func (c *Comment) Name() string { return strconv.Itoa(c.ID) }
+
+func (c *Comment) Size() int64       { return 0 }
+func (c *Comment) Mode() fs.FileMode { return 0444 }
+func (c *Comment) ModTime() time.Time {
+	if c.Updated.IsZero() {
+		return c.Published
+	}
+	return c.Updated
+}
+func (c *Comment) IsDir() bool      { return c.Mode().IsDir() }
+func (c *Comment) Sys() interface{} { return nil }
+
+// ParseCommentPath returns the comment IDs referenced by a Comment.
+func ParseCommentPath(s string) []int {
+	elems := strings.Split(s, ".")
+	if len(elems) == 1 {
+		return []int{}
+	}
+	if elems[0] != "0" {
+		return []int{}
+	}
+	refs := make([]int, len(elems))
+	for i, ele := range elems {
+		id, err := strconv.Atoi(ele)
+		if err != nil {
+			return refs
+		}
+		refs[i] = id
+	}
+	return refs
+}
+
+type Person struct {
+	ID      int    `json:"id"`
+	Name    string `json:"name"`
+	ActorID string `json:"actor_id"`
+	Local   bool   `json:"local"`
+}
+
+func (p Person) String() string {
+	if p.Local {
+		return p.Name
+	}
+	noscheme := strings.TrimPrefix(p.ActorID, "https://")
+	instance, _, _ := strings.Cut(noscheme, "/")
+	return fmt.Sprintf("%s@%s", p.Name, instance)
+}
blob - /dev/null
blob + 65acbc29767e59191370017966bd57273b48dfcf (mode 644)
--- /dev/null
+++ lemmy/testdata/post.json
@@ -0,0 +1,170 @@
+{
+    "post_view": {
+        "post": {
+            "id": 1363000,
+            "name": "mpost",
+            "body": "Hello, world!",
+            "creator_id": 151025,
+            "community_id": 3,
+            "removed": false,
+            "locked": false,
+            "published": "2023-08-23T07:22:39.559420Z",
+            "deleted": false,
+            "nsfw": false,
+            "ap_id": "https://lemmy.sdf.org/post/2583193",
+            "local": false,
+            "language_id": 0,
+            "featured_community": false,
+            "featured_local": false
+        },
+        "creator": {
+            "id": 151025,
+            "name": "otl",
+            "display_name": "Oliver Lowe",
+            "avatar": "https://lemmy.sdf.org/pictrs/image/eea12c44-dc27-4e44-979e-0b206e90bcc4.png",
+            "banned": false,
+            "published": "2023-06-13T15:00:36.897955Z",
+            "actor_id": "https://lemmy.sdf.org/u/otl",
+            "bio": "[About me](http://www.olowe.co/about.html)",
+            "local": false,
+            "banner": "https://lemmy.sdf.org/pictrs/image/2bfb82b5-a078-4411-bbf2-87c0df899041.png",
+            "deleted": false,
+            "bot_account": false,
+            "instance_id": 57
+        },
+        "community": {
+            "id": 3,
+            "name": "localtesting",
+            "title": "Testing",
+            "description": "A local community for testing lemmy on the aussie.zone instance.",
+            "removed": false,
+            "published": "2023-06-08T07:05:23.856002Z",
+            "updated": "2023-06-18T15:28:59.347113Z",
+            "deleted": false,
+            "nsfw": false,
+            "actor_id": "https://aussie.zone/c/localtesting",
+            "local": true,
+            "icon": "https://aussie.zone/pictrs/image/9775c10c-b40c-4481-a935-223a71953634.jpeg",
+            "hidden": false,
+            "posting_restricted_to_mods": false,
+            "instance_id": 1
+        },
+        "creator_banned_from_community": false,
+        "creator_is_moderator": false,
+        "creator_is_admin": false,
+        "counts": {
+            "post_id": 1363000,
+            "comments": 0,
+            "score": 3,
+            "upvotes": 3,
+            "downvotes": 0,
+            "published": "2023-08-23T07:22:39.559420Z",
+            "newest_comment_time": "2023-08-23T07:22:39.559420Z"
+        },
+        "subscribed": "NotSubscribed",
+        "saved": false,
+        "read": false,
+        "creator_blocked": false,
+        "unread_comments": 0
+    },
+    "community_view": {
+        "community": {
+            "id": 3,
+            "name": "localtesting",
+            "title": "Testing",
+            "description": "A local community for testing lemmy on the aussie.zone instance.",
+            "removed": false,
+            "published": "2023-06-08T07:05:23.856002Z",
+            "updated": "2023-06-18T15:28:59.347113Z",
+            "deleted": false,
+            "nsfw": false,
+            "actor_id": "https://aussie.zone/c/localtesting",
+            "local": true,
+            "icon": "https://aussie.zone/pictrs/image/9775c10c-b40c-4481-a935-223a71953634.jpeg",
+            "hidden": false,
+            "posting_restricted_to_mods": false,
+            "instance_id": 1
+        },
+        "subscribed": "NotSubscribed",
+        "blocked": false,
+        "counts": {
+            "community_id": 3,
+            "subscribers": 94,
+            "posts": 69,
+            "comments": 129,
+            "published": "2023-06-08T07:05:23.856002Z",
+            "users_active_day": 4,
+            "users_active_week": 4,
+            "users_active_month": 13,
+            "users_active_half_year": 54
+        }
+    },
+    "moderators": [
+        {
+            "community": {
+                "id": 3,
+                "name": "localtesting",
+                "title": "Testing",
+                "description": "A local community for testing lemmy on the aussie.zone instance.",
+                "removed": false,
+                "published": "2023-06-08T07:05:23.856002Z",
+                "updated": "2023-06-18T15:28:59.347113Z",
+                "deleted": false,
+                "nsfw": false,
+                "actor_id": "https://aussie.zone/c/localtesting",
+                "local": true,
+                "icon": "https://aussie.zone/pictrs/image/9775c10c-b40c-4481-a935-223a71953634.jpeg",
+                "hidden": false,
+                "posting_restricted_to_mods": false,
+                "instance_id": 1
+            },
+            "moderator": {
+                "id": 2,
+                "name": "admin",
+                "avatar": "https://aussie.zone/pictrs/image/95fc3553-a929-4143-a370-605db92fe4ae.jpeg",
+                "banned": false,
+                "published": "2023-06-08T06:56:38.842362Z",
+                "actor_id": "https://aussie.zone/u/admin",
+                "bio": "Benevolent dictator",
+                "local": true,
+                "deleted": false,
+                "bot_account": false,
+                "instance_id": 1
+            }
+        },
+        {
+            "community": {
+                "id": 3,
+                "name": "localtesting",
+                "title": "Testing",
+                "description": "A local community for testing lemmy on the aussie.zone instance.",
+                "removed": false,
+                "published": "2023-06-08T07:05:23.856002Z",
+                "updated": "2023-06-18T15:28:59.347113Z",
+                "deleted": false,
+                "nsfw": false,
+                "actor_id": "https://aussie.zone/c/localtesting",
+                "local": true,
+                "icon": "https://aussie.zone/pictrs/image/9775c10c-b40c-4481-a935-223a71953634.jpeg",
+                "hidden": false,
+                "posting_restricted_to_mods": false,
+                "instance_id": 1
+            },
+            "moderator": {
+                "id": 3,
+                "name": "lodion",
+                "display_name": "Lodion \ud83c\udde6\ud83c\uddfa",
+                "avatar": "https://aussie.zone/pictrs/image/2644a4fe-cce3-4126-b80b-40e6369976d1.jpeg",
+                "banned": false,
+                "published": "2023-06-08T06:59:14.987834Z",
+                "actor_id": "https://aussie.zone/u/lodion",
+                "bio": "I also have backup accounts on these instances:  \nhttps://beehaw.org/u/lodion  \nhttps://sh.itjust.works/u/lodion  \nhttps://lemmy.world/u/lodion  \nhttps://lemm.ee/u/lodion  \nhttps://reddthat.com/u/lodion",
+                "local": true,
+                "deleted": false,
+                "bot_account": false,
+                "instance_id": 1
+            }
+        }
+    ],
+    "cross_posts": []
+}