commit 1081cf75278f13ca411f20635423bcd737707d4f from: Oliver Lowe date: Mon Nov 04 08:43:50 2024 UTC lemmy: import from its own repo No need to manage this seperately, it's all related and only brings in a single dep. 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": [] +}