commit 4a5e46556d98c2f01ca29f5f7d42dcc46f549154 from: Oliver Lowe date: Thu Jul 06 12:44:38 2023 UTC initial commit commit - /dev/null commit + 4a5e46556d98c2f01ca29f5f7d42dcc46f549154 blob - /dev/null blob + c1c9cb50f164d80716c4c7488f1d479ccd6e2b33 (mode 644) --- /dev/null +++ LICENSE @@ -0,0 +1,13 @@ +Copyright (c) 2023 Oliver Lowe + +Permission to use, copy, modify, and distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. blob - /dev/null blob + ab829bf31417d6c995617b0ad6a0b2fa01e421a9 (mode 644) --- /dev/null +++ auth.go @@ -0,0 +1,37 @@ +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 +} \ No newline at end of file blob - /dev/null blob + e6f7d5ef0401971f6b65467886800c93d3f78c2e (mode 644) --- /dev/null +++ client.go @@ -0,0 +1,315 @@ +package lemmy + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path" + "strconv" +) + +type Client struct { + *http.Client + Address string + authToken string + instance *url.URL + 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.Client{} + } + 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)} + 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, error) { + if !c.ready { + if err := c.init(); err != nil { + return Community{}, err + } + } + + params := map[string]string{"name": name} + resp, err := c.get("community", params) + if err != nil { + return Community{}, err + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusNotFound { + return Community{}, ErrNotFound + } else if resp.StatusCode != http.StatusOK { + return Community{}, fmt.Errorf("remote status %s: %w", resp.Status, decodeError(resp.Body)) + } + + type response struct { + View struct { + Community Community + } `json:"community_view"` + } + var cres response + if err := json.NewDecoder(resp.Body).Decode(&cres); err != nil { + return Community{}, fmt.Errorf("decode community response: %w", err) + } + return cres.View.Community, 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), + } + 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)) + } + + var jresponse struct { + Posts []struct { + Post Post + } + } + 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 { + posts = append(posts, post.Post) + } + return posts, nil +} + +func (c *Client) LookupPost(id int) (Post, error) { + if !c.ready { + if err := c.init(); err != nil { + return Post{}, err + } + } + + 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)) + } + + type jresponse struct { + PostView struct { + Post Post + } `json:"post_view"` + } + var jresp jresponse + if err := json.NewDecoder(resp.Body).Decode(&jresp); err != nil { + return Post{}, fmt.Errorf("decode post: %w", err) + } + return jresp.PostView.Post, nil +} + +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), + } + 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 + } + } + 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 { + 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 { + 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") + fmt.Fprintf(os.Stderr, "%s %s\n", req.Method, req.URL) + return c.Do(req) +} + +type jError struct { + Err string `json:"error"` +} + +func (err jError) Error() string { return err.Err } + +func decodeError(r io.Reader) error { + var jerr jError + buf := &bytes.Buffer{} + io.Copy(buf, r) + fmt.Fprintln(os.Stderr, "error", buf.String()) + if err := json.NewDecoder(buf).Decode(&jerr); err != nil { + return fmt.Errorf("decode error message: %v", err) + } + return jerr +} blob - /dev/null blob + 81d98fe53db384beb08bbc7dcb97c7e87c69a523 (mode 644) --- /dev/null +++ fs.go @@ -0,0 +1,450 @@ +package lemmy + +import ( + "bytes" + "errors" + "fmt" + "io" + "io/fs" + "path" + "strconv" + "strings" + "time" +) + +type FS struct { + Client *Client + Communities map[string]Community + Posts map[int]Post + Comments map[int]Comment + root *node + baseURL string + started bool +} + +type node struct { + name string + Community *Community + Post *Post + Comment *Comment + dummy *dummy + children []node +} + +func (n *node) Info() fs.FileInfo { + switch { + case n.Community != nil: + return n.Community + case n.Post != nil: + return n.Post + case n.Comment != nil: + return n.Comment + case n.dummy != nil: + return n.dummy + } + return nil +} + +type file struct { + name string + buf io.ReadCloser + dirinfo *dirInfo + client *Client + Community *Community + Post *Post + Comment *Comment + dummy *dummy +} + +type dirInfo struct { + entries []fs.DirEntry + entryp int +} + +func (f *file) Stat() (fs.FileInfo, error) { + switch { + case f.dummy != nil: + return f.dummy, nil + case f.Community != nil: + return f.Community, nil + case f.Post != nil: + return f.Post, nil + case f.Comment != nil: + return f.Comment, nil + } + return &dummy{}, nil +} + +func (f *file) Read(p []byte) (int, error) { + if f.buf == nil { + return 0, &fs.PathError{"read", f.name, fs.ErrClosed} + } + n, err := f.buf.Read(p) + if errors.Is(err, io.EOF) { + return n, io.EOF + } else if err != nil { + return n, &fs.PathError{"read", f.name, err} + } + return n, nil +} + +func (f *file) Close() error { + if f.buf == nil { + return fs.ErrClosed + } + err := f.buf.Close() + f.buf = nil + if err != nil { + return &fs.PathError{"close", f.name, err} + } + return nil +} + +func (f *file) ReadDir(n int) ([]fs.DirEntry, error) { + if f.dirinfo == nil { + return nil, &fs.PathError{"readdir", f.name, errors.New("not a directory")} + } + + d := f.dirinfo + // generate dir entries if we haven't read any yet + if d.entryp == 0 { + switch { + case f.Community != nil: + posts, err := f.client.Posts(f.name, ListAll) + if err != nil { + return nil, &fs.PathError{"readdir", f.name, err} + } + for _, post := range posts { + post := post + d.entries = append(d.entries, fs.FileInfoToDirEntry(&post)) + } + case f.name == "comments": + // special case of the comments directory. + // Lke a directory in Plan 9, we store directory data as its file contents. + // In this case we've stored Post.ID. + buf := make([]byte, 8) // big enough for int64 + n, err := f.Read(buf) + if err != nil && !errors.Is(err, io.EOF) { + return nil, &fs.PathError{"readdir", f.name, err} + } + id, err := strconv.Atoi(string(buf[:n])) + if err != nil { + return nil, &fs.PathError{"readdir", f.name, err} + } + comments, err := f.client.Comments(id, ListAll) + if err != nil { + return nil, &fs.PathError{"readdir", f.name, err} + } + for _, c := range comments { + c := c + d.entries = append(d.entries, fs.FileInfoToDirEntry(&c)) + } + } + } + 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 n < len(entries) { + entries = entries[:n-1] + } + d.entryp += n + return entries, err +} + +type dummy struct { + name string + isDir bool + contents []byte +} + +func (f *dummy) Name() string { return f.name } +func (f *dummy) Size() int64 { return int64(len(f.contents)) } + +func (f *dummy) Mode() fs.FileMode { + if f.isDir { + return fs.ModeDir | 0o0444 + } + return 0o0444 +} + +func (f *dummy) ModTime() time.Time { return time.Unix(0, 0) } +func (f *dummy) IsDir() bool { return f.isDir } +func (f *dummy) Sys() interface{} { return nil } + +func (fsys *FS) init() error { + if fsys.Client == nil { + fsys.Client = &Client{} + } + if fsys.Communities == nil { + fsys.Communities = make(map[string]Community) + } + if fsys.Posts == nil { + fsys.Posts = make(map[int]Post) + } + if fsys.Comments == nil { + fsys.Comments = make(map[int]Comment) + } + 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} + } + + if !fsys.started { + if err := fsys.init(); err != nil { + return nil, fmt.Errorf("start fs: %w", err) + } + } + + node, err := fsys.lookupNode(name) + if err != nil { + return nil, &fs.PathError{"open", name, err} + } + return fsys.nodeOpen(node) +} + +func (fsys *FS) nodeOpen(node *node) (*file, error) { + f := &file{ + name: node.name, + client: fsys.Client, + } + switch { + case node.Community != nil: + f.Community = node.Community + f.buf = io.NopCloser(strings.NewReader(node.name)) + case node.Post != nil: + f.Post = node.Post + f.buf = io.NopCloser(strings.NewReader(node.name)) + case node.Comment != nil: + f.Comment = node.Comment + f.buf = io.NopCloser(strings.NewReader(node.Comment.Content)) + case node.dummy != nil: + f.dummy = node.dummy + f.buf = io.NopCloser(bytes.NewReader(node.dummy.contents)) + default: + f.buf = io.NopCloser(bytes.NewReader(nil)) + } + if len(node.children) >= 0 && f.dirinfo == nil { + f.dirinfo = new(dirInfo) + for _, child := range node.children { + f.dirinfo.entries = append(f.dirinfo.entries, fs.FileInfoToDirEntry(child.Info())) + } + } + return f, nil +} + +func (fsys *FS) lookupNode(pathname string) (*node, error) { + root := &node{name: ".", children: []node{}} + if pathname == "." { + return root, nil + } + + for _, comm := range fsys.Communities { + root.children = append(root.children, comm.fsNode()) + } + if i, n := findNode(root, pathname); i >= 0 { + return n, nil + } + + // First element is a community. + comname, leftover, _ := strings.Cut(pathname, "/") + var community Community + var ok bool + community, ok = fsys.Communities[comname] + if !ok { + var err error + community, err = fsys.Client.LookupCommunity(comname) + if errors.Is(err, ErrNotFound) { + return nil, fs.ErrNotExist + } else if err != nil { + return nil, err + } + fsys.Communities[community.String()] = community + root.children = append(root.children, community.fsNode()) + } + // punt; maybe we only want the community. + if i, n := findNode(root, pathname); i >= 0 { + return n, nil + } + + // next element is a post. + postname, leftover, _ := strings.Cut(leftover, "/") + id, err := strconv.Atoi(postname) + if err != nil { + return nil, fmt.Errorf("get posts: %w", err) + } + var post Post + post, ok = fsys.Posts[id] + if !ok { + post, err = fsys.Client.LookupPost(id) + if errors.Is(err, ErrNotFound) { + return nil, fs.ErrNotExist + } else if err != nil { + return nil, err + } + fsys.Posts[id] = post + } + i, cnode := findNode(root, community.String()) + if i < 0 { + return nil, fs.ErrNotExist + } + cnode.children = append(cnode.children, post.fsNode()) + root.children[i] = *cnode + if i, n := findNode(root, pathname); i >= 0 { + return n, nil + } + + // community/1234/comments/5678/content + // only comments left. + // first, trim the "comments" element to get the comment ID. + // a comment is a directory file. + commentDir := strings.TrimPrefix(leftover, "comments/") + commentID, leftover, _ := strings.Cut(commentDir, "/") + id, err = strconv.Atoi(path.Clean(commentID)) + if err != nil { + return nil, fmt.Errorf("%s: %w", fs.ErrInvalid, err) + } + var comment Comment + comment, ok = fsys.Comments[id] + if !ok { + // Reading comments can result in too many requests + // so try loading comments in bulk first. + comments, err := fsys.Client.Comments(post.ID, ListAll) + if err != nil { + return nil, err + } + for _, c := range comments { + fsys.Comments[c.ID] = c + } + // Feeling lucky? We may have a match from the recent fetch. + // Otherwise, fall back to a slow lookup. + if c, ok := fsys.Comments[id]; ok { + comment = c + } else { + comment, err = fsys.Client.LookupComment(id) + if err != nil { + return nil, err + } + fsys.Comments[id] = comment + } + } + + j, pnode := findNode(root, path.Join(community.String(), postname)) + if j < 0 { + return nil, fs.ErrNotExist + } + for i, child := range pnode.children { + if child.name == "comments" { + child.children = []node{comment.fsNode()} + pnode.children[i] = child + } + } + root.children[i].children[j] = *pnode + + if i, n := findNode(root, pathname); i >= 0 { + return n, nil + } + return nil, fs.ErrNotExist +} + +func findNode(parent *node, pathname string) (int, *node) { + dirname, leftover, _ := strings.Cut(pathname, "/") + for i, child := range parent.children { + if child.name == dirname && leftover == "" { + return i, &child + } else if child.name == dirname { + return findNode(&child, leftover) + } + } + return -1, nil +} + +func (c *Community) fsNode() node { + return node{ + name: c.String(), + Community: c, + children: []node{}, + } +} + +func (p *Post) fsNode() node { + return node{ + name: strconv.Itoa(p.ID), + Post: p, + children: []node{ + node{ + name: "title", + dummy: &dummy{ + name: "title", + contents: []byte(p.Title), + }, + }, + node{ + name: "creator", + dummy: &dummy{ + name: "creator", + contents: []byte(strconv.Itoa(p.CreatorID)), + }, + }, + node{ + name: "body", + dummy: &dummy{ + name: "body", + contents: []byte(p.Body), + }, + }, + node{ + name: "comments", + dummy: &dummy{ + name: "comments", + isDir: true, + contents: []byte(strconv.Itoa(p.ID)), + }, + }, + }, + } +} + +func (c *Comment) fsNode() node { + return node{ + name: strconv.Itoa(c.ID), + Comment: c, + children: []node{ + node{ + name: "content", + dummy: &dummy{ + name: "content", + contents: []byte(c.Content), + }, + }, + node{ + name: "creator", + dummy: &dummy{ + name: "creator", + contents: []byte(strconv.Itoa(c.CreatorID)), + }, + }, + node{ + name: "references", + dummy: &dummy{ + name: "references", + contents: []byte(fmt.Sprintf("%v", c.References)), + }, + }, + }, + } +} blob - /dev/null blob + e7c7e0b9fd37c98d54a0584fbe1146fdd940d2c6 (mode 644) --- /dev/null +++ go.mod @@ -0,0 +1,5 @@ +module olowe.co/lemmy + +go 1.18 + +require 9fans.net/go v0.0.4 // indirect blob - /dev/null blob + 4a6085f1fdc7322b4d59ae74b031a8c10123c6d3 (mode 644) --- /dev/null +++ go.sum @@ -0,0 +1,2 @@ +9fans.net/go v0.0.4 h1:g7K+b5I1PlSBFLnjuco3LAx5boK39UUl0Gsrmw6Gl2U= +9fans.net/go v0.0.4/go.mod h1:lfPdxjq9v8pVQXUMBCx5EO5oLXWQFlKRQgs1kEkjoIM= blob - /dev/null blob + 7d45b292a6c8fa92978a1ee5156234729321afbd (mode 644) --- /dev/null +++ lemmy.go @@ -0,0 +1,99 @@ +package lemmy + +import ( + "errors" + "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"` + // Updated time.Time `json:"updated"` +} + +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 time.Unix(0, 0) } +func (c *Community) IsDir() bool { return true } +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"` + // Published time.Time + // Updated time.Time +} + +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 | 0o0444 } +func (p *Post) ModTime() time.Time { return time.Unix(0, 0) } +func (p *Post) IsDir() bool { return true } +func (p *Post) Sys() interface{} { return nil } + +type Comment struct { + ID int + PostID int `json:"post_id"` + // Holds ordered comment IDs referenced by this comment + // for threading. + References []int + Content string + CreatorID int `json:"creator_id"` + // Published time.Time +} + +func (c *Comment) Name() string { return strconv.Itoa(c.ID) } + +func (c *Comment) Size() int64 { return 0 } +func (c *Comment) Mode() fs.FileMode { return fs.ModeDir | 0o0444 } +func (c *Comment) ModTime() time.Time { return time.Unix(0, 0) } // TODO c.Updated +func (c *Comment) IsDir() bool { return true } +func (c *Comment) Sys() interface{} { return nil } + +// parseCommentPath returns the comment IDs from the path field of a Comment. +func parseCommentPath(s string) ([]int, error) { + elems := strings.Split(s, ".") + if len(elems) == 1 { + return nil, errors.New("only one comment in path") + } + if elems[0] != "0" { + return nil, fmt.Errorf("expected comment id 0, got %s", elems[0]) + } + refs := make([]int, len(elems)) + for _, ele := range elems { + id, err := strconv.Atoi(ele) + if err != nil { + return nil, fmt.Errorf("parse comment id: %w", err) + } + refs = append(refs, id) + } + return refs, nil +} + +type Creator struct { + ID int + Username string `json:"name"` +}