commit - 8403ab16d82c2829a3ee4c45b8147aae113b4d66
commit + 1081cf75278f13ca411f20635423bcd737707d4f
blob - /dev/null
blob + b09f05ab3b40634852b2e295645424d911e3d782 (mode 644)
--- /dev/null
+++ cmd/Lemmy/Lemmy.go
+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
+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
+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
+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
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
+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
+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
+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
+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
+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
+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
+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
+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
+/*
+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
+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
+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
+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
+// 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
+{
+ "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": []
+}