acme-misc/feed/p9.go

724 lines
17 KiB
Go

package main
import (
"errors"
"fmt"
"io"
"log"
"net"
"strings"
"sync"
"9fans.net/go/plan9"
)
// ---------------------------------------------------------------------------
// Qid constants
// ---------------------------------------------------------------------------
const (
qidRoot uint64 = 0
qidCtl uint64 = 1
qidUnread uint64 = 2
qidAll uint64 = 3
qidFeedsDir uint64 = 4
qidPinned uint64 = 5
// Dynamic qids are computed via pathQid() from the path string.
// Static ones above are reserved in the low range.
)
// pathQid computes a stable Qid path from an arbitrary string.
// Uses FNV-1a to avoid import of crypto packages.
func pathQid(s string) uint64 {
var h uint64 = 14695981039346656037
for i := 0; i < len(s); i++ {
h ^= uint64(s[i])
h *= 1099511628211
}
// Keep top bit clear (reserved for QTDIR in some plan9 implementations).
return (h & 0x7FFFFFFFFFFFFFFF) | 0x1000
}
// ---------------------------------------------------------------------------
// FID state
// ---------------------------------------------------------------------------
type fidState struct {
qid plan9.Qid
path string
open bool
// write buffer — accumulated across Twrite calls, dispatched on Tclunk
writeBuf []byte
}
// ---------------------------------------------------------------------------
// P9Server
// ---------------------------------------------------------------------------
type P9Server struct {
listener net.Listener
store *FeedStore
sockPath string
}
func newP9Server(sockPath string, store *FeedStore) (*P9Server, error) {
ln, err := net.Listen("unix", sockPath)
if err != nil {
return nil, err
}
return &P9Server{listener: ln, store: store, sockPath: sockPath}, nil
}
func (srv *P9Server) serve() {
for {
conn, err := srv.listener.Accept()
if err != nil {
if !errors.Is(err, net.ErrClosed) {
log.Printf("9P accept: %v", err)
}
return
}
go srv.handleConn(conn)
}
}
func (srv *P9Server) close() {
srv.listener.Close()
}
// ---------------------------------------------------------------------------
// Connection handler
// ---------------------------------------------------------------------------
func (srv *P9Server) handleConn(conn net.Conn) {
defer conn.Close()
fids := make(map[uint32]*fidState)
var mu sync.Mutex
get := func(fid uint32) *fidState {
mu.Lock()
defer mu.Unlock()
return fids[fid]
}
set := func(fid uint32, f *fidState) {
mu.Lock()
defer mu.Unlock()
fids[fid] = f
}
del := func(fid uint32) {
mu.Lock()
defer mu.Unlock()
delete(fids, fid)
}
for {
fcall, err := plan9.ReadFcall(conn)
if err != nil {
if err != io.EOF {
log.Printf("9P read: %v", err)
}
return
}
resp := plan9.Fcall{Tag: fcall.Tag}
switch fcall.Type {
case plan9.Tversion:
resp.Type = plan9.Rversion
resp.Msize = fcall.Msize
resp.Version = "9P2000"
case plan9.Tattach:
resp.Type = plan9.Rattach
resp.Qid = plan9.Qid{Type: plan9.QTDIR, Path: qidRoot}
set(fcall.Fid, &fidState{qid: resp.Qid, path: "/"})
case plan9.Twalk:
f := get(fcall.Fid)
if f == nil {
resp.Type = plan9.Rerror
resp.Ename = "bad fid"
break
}
if len(fcall.Wname) == 0 {
// Clone.
nf := &fidState{qid: f.qid, path: f.path}
set(fcall.Newfid, nf)
resp.Type = plan9.Rwalk
break
}
cur := f.path
var wqids []plan9.Qid
for _, name := range fcall.Wname {
qid, next, err := srv.walk(cur, name)
if err != nil {
break
}
wqids = append(wqids, qid)
cur = next
}
if len(wqids) == 0 {
resp.Type = plan9.Rerror
resp.Ename = "file not found"
break
}
resp.Type = plan9.Rwalk
resp.Wqid = wqids
if len(wqids) == len(fcall.Wname) {
set(fcall.Newfid, &fidState{
qid: wqids[len(wqids)-1],
path: cur,
})
}
case plan9.Topen:
f := get(fcall.Fid)
if f == nil {
resp.Type = plan9.Rerror
resp.Ename = "bad fid"
break
}
f.open = true
resp.Type = plan9.Ropen
resp.Qid = f.qid
resp.Iounit = 0
case plan9.Tread:
f := get(fcall.Fid)
if f == nil {
resp.Type = plan9.Rerror
resp.Ename = "bad fid"
break
}
data, err := srv.read(f, int64(fcall.Offset), fcall.Count)
if err != nil {
resp.Type = plan9.Rerror
resp.Ename = err.Error()
break
}
resp.Type = plan9.Rread
resp.Data = data
case plan9.Twrite:
f := get(fcall.Fid)
if f == nil {
resp.Type = plan9.Rerror
resp.Ename = "bad fid"
break
}
// Buffer writes; dispatch on Tclunk.
f.writeBuf = append(f.writeBuf, fcall.Data...)
resp.Type = plan9.Rwrite
resp.Count = uint32(len(fcall.Data))
case plan9.Tclunk:
f := get(fcall.Fid)
if f != nil && len(f.writeBuf) > 0 {
if err := srv.dispatch(f.path, f.writeBuf); err != nil {
// Log but don't fail the clunk.
log.Printf("dispatch %s: %v", f.path, err)
}
}
del(fcall.Fid)
resp.Type = plan9.Rclunk
case plan9.Tstat:
f := get(fcall.Fid)
if f == nil {
resp.Type = plan9.Rerror
resp.Ename = "bad fid"
break
}
dir, err := srv.stat(f.path)
if err != nil {
resp.Type = plan9.Rerror
resp.Ename = err.Error()
break
}
resp.Type = plan9.Rstat
resp.Stat, _ = dir.Bytes()
default:
resp.Type = plan9.Rerror
resp.Ename = "not implemented"
}
if err := plan9.WriteFcall(conn, &resp); err != nil {
log.Printf("9P write: %v", err)
return
}
}
}
// ---------------------------------------------------------------------------
// walk
// ---------------------------------------------------------------------------
func (srv *P9Server) walk(cur, name string) (plan9.Qid, string, error) {
parts := splitPath(cur)
depth := len(parts)
switch {
// Root → top-level names.
case depth == 0:
switch name {
case "ctl":
return plan9.Qid{Type: plan9.QTFILE, Path: qidCtl}, "/ctl", nil
case "unread":
return plan9.Qid{Type: plan9.QTFILE, Path: qidUnread}, "/unread", nil
case "all":
return plan9.Qid{Type: plan9.QTFILE, Path: qidAll}, "/all", nil
case "pinned":
return plan9.Qid{Type: plan9.QTFILE, Path: qidPinned}, "/pinned", nil
case "feeds":
return plan9.Qid{Type: plan9.QTDIR, Path: qidFeedsDir}, "/feeds", nil
}
// /feeds → slug directory.
case depth == 1 && parts[0] == "feeds":
srv.store.mu.RLock()
exists := srv.store.slugExists(name)
srv.store.mu.RUnlock()
if exists {
p := "/feeds/" + name
return plan9.Qid{Type: plan9.QTDIR, Path: pathQid(p)}, p, nil
}
// /feeds/{slug} → per-feed files and subdirs.
case depth == 2 && parts[0] == "feeds":
slug := parts[1]
p := cur + "/" + name
switch name {
case "meta", "ctl", "unread", "all":
return plan9.Qid{Type: plan9.QTFILE, Path: pathQid(p)}, p, nil
case "new", "read":
return plan9.Qid{Type: plan9.QTDIR, Path: pathQid(p)}, p, nil
}
_ = slug
// /feeds/{slug}/new or /feeds/{slug}/read → entry files.
case depth == 3 && parts[0] == "feeds":
slug := parts[1]
bucket := parts[2] // "new" or "read"
if bucket != "new" && bucket != "read" {
break
}
srv.store.mu.RLock()
files := srv.store.files[slug]
srv.store.mu.RUnlock()
for _, ef := range files {
if ef.filename == name {
wantRead := bucket == "read"
if ef.read == wantRead {
p := cur + "/" + name
return plan9.Qid{Type: plan9.QTFILE, Path: pathQid(p)}, p, nil
}
}
}
}
return plan9.Qid{}, "", errors.New("not found")
}
// ---------------------------------------------------------------------------
// read
// ---------------------------------------------------------------------------
func (srv *P9Server) read(f *fidState, offset int64, count uint32) ([]byte, error) {
if f.qid.Type&plan9.QTDIR != 0 {
return srv.readDir(f.path, offset, count)
}
data, err := srv.readFile(f.path)
if err != nil {
return nil, err
}
return sliceAt(data, offset, count), nil
}
func (srv *P9Server) readDir(path string, offset int64, count uint32) ([]byte, error) {
parts := splitPath(path)
depth := len(parts)
var entries []plan9.Dir
switch {
case depth == 0: // root
entries = []plan9.Dir{
fileDir("ctl", 0644, qidCtl),
fileDir("unread", 0444, qidUnread),
fileDir("all", 0444, qidAll),
fileDir("pinned", 0444, qidPinned),
dirEntry("feeds", qidFeedsDir),
}
case depth == 1 && parts[0] == "feeds": // /feeds
srv.store.mu.RLock()
for _, sub := range srv.store.subs {
p := "/feeds/" + sub.Slug
entries = append(entries, dirEntry(sub.Slug, pathQid(p)))
}
srv.store.mu.RUnlock()
case depth == 2 && parts[0] == "feeds": // /feeds/{slug}
slug := parts[1]
base := path
entries = []plan9.Dir{
fileDir("meta", 0444, pathQid(base+"/meta")),
fileDir("ctl", 0644, pathQid(base+"/ctl")),
fileDir("unread", 0444, pathQid(base+"/unread")),
fileDir("all", 0444, pathQid(base+"/all")),
dirEntry("new", pathQid(base+"/new")),
dirEntry("read", pathQid(base+"/read")),
}
_ = slug
case depth == 3 && parts[0] == "feeds": // /feeds/{slug}/new or /feeds/{slug}/read
slug := parts[1]
bucket := parts[2]
wantRead := bucket == "read"
srv.store.mu.RLock()
files := srv.store.files[slug]
srv.store.mu.RUnlock()
for _, ef := range files {
if ef.read == wantRead {
p := path + "/" + ef.filename
entries = append(entries, fileDir(ef.filename, 0444, pathQid(p)))
}
}
}
return serializeDirs(entries, offset, count), nil
}
func (srv *P9Server) readFile(path string) ([]byte, error) {
parts := splitPath(path)
switch {
case path == "/ctl":
return []byte("sub <url> [slug]\nunsub <slug>\nrefresh [slug]\n"), nil
case path == "/unread":
return []byte(srv.globalIndexText(true)), nil
case path == "/all":
return []byte(srv.globalIndexText(false)), nil
case path == "/pinned":
return []byte(srv.pinnedIndexText()), nil
case len(parts) == 3 && parts[0] == "feeds" && parts[2] == "meta":
slug := parts[1]
srv.store.mu.RLock()
sub := srv.store.subscriptionBySlug(slug)
srv.store.mu.RUnlock()
if sub == nil {
return nil, fmt.Errorf("no feed %q", slug)
}
var sb strings.Builder
fmt.Fprintf(&sb, "url: %s\n", sub.URL)
fmt.Fprintf(&sb, "slug: %s\n", sub.Slug)
fmt.Fprintf(&sb, "title: %s\n", sub.Title)
fmt.Fprintf(&sb, "subscribed: %s\n", sub.SubscribedAt)
fmt.Fprintf(&sb, "last_fetched: %s\n", sub.LastFetched)
return []byte(sb.String()), nil
case len(parts) == 3 && parts[0] == "feeds" && parts[2] == "ctl":
return []byte("refresh\nunsub\nmark-all-read\nread <guid>\n"), nil
case len(parts) == 3 && parts[0] == "feeds" && parts[2] == "unread":
return []byte(srv.feedIndexText(parts[1], true)), nil
case len(parts) == 3 && parts[0] == "feeds" && parts[2] == "all":
return []byte(srv.feedIndexText(parts[1], false)), nil
case len(parts) == 4 && parts[0] == "feeds":
return srv.readEntryFile(parts[1], parts[2], parts[3])
}
return nil, fmt.Errorf("not found: %s", path)
}
func (srv *P9Server) readEntryFile(slug, bucket, filename string) ([]byte, error) {
wantRead := bucket == "read"
srv.store.mu.RLock()
files := srv.store.files[slug]
srv.store.mu.RUnlock()
for _, ef := range files {
if ef.filename == filename && ef.read == wantRead {
e := ef.entry
var sb strings.Builder
fmt.Fprintf(&sb, "%s\n", e.Title)
fmt.Fprintf(&sb, "%s\n", e.URL)
srv.store.mu.RLock()
sub := srv.store.subscriptionBySlug(slug)
srv.store.mu.RUnlock()
feedTitle := slug
if sub != nil {
feedTitle = sub.Title
}
fmt.Fprintf(&sb, "%s | %s\n", feedTitle, e.Timestamp)
if e.Summary != "" {
fmt.Fprintf(&sb, "\n%s\n", e.Summary)
}
return []byte(sb.String()), nil
}
}
return nil, fmt.Errorf("not found")
}
// ---------------------------------------------------------------------------
// dispatch — handle writes to ctl files (called on Tclunk)
// ---------------------------------------------------------------------------
func (srv *P9Server) dispatch(path string, data []byte) error {
cmd := strings.TrimSpace(string(data))
parts := splitPath(path)
switch {
case path == "/ctl":
return srv.handleGlobalCtl(cmd)
case len(parts) == 3 && parts[0] == "feeds" && parts[2] == "ctl":
return srv.handleFeedCtl(parts[1], cmd)
}
return nil
}
func (srv *P9Server) handleGlobalCtl(cmd string) error {
fields := strings.Fields(cmd)
if len(fields) == 0 {
return nil
}
switch fields[0] {
case "sub":
if len(fields) < 2 {
return fmt.Errorf("sub: missing URL")
}
url := fields[1]
alias := ""
if len(fields) >= 3 {
alias = fields[2]
}
return doSubscribe(srv.store, url, alias)
case "unsub":
if len(fields) < 2 {
return fmt.Errorf("unsub: missing slug")
}
return srv.store.unsubscribe(fields[1])
case "refresh":
if len(fields) >= 2 {
return doRefresh(srv.store, fields[1])
}
return doRefreshAll(srv.store)
}
return fmt.Errorf("unknown command: %s", fields[0])
}
func (srv *P9Server) handleFeedCtl(slug, cmd string) error {
fields := strings.Fields(cmd)
if len(fields) == 0 {
return nil
}
switch fields[0] {
case "refresh":
return doRefresh(srv.store, slug)
case "unsub":
return srv.store.unsubscribe(slug)
case "mark-all-read":
return srv.store.markAllRead(slug)
case "read":
if len(fields) < 2 {
return fmt.Errorf("read: missing guid")
}
return srv.store.markRead(slug, fields[1])
}
return fmt.Errorf("unknown command: %s", fields[0])
}
// ---------------------------------------------------------------------------
// stat
// ---------------------------------------------------------------------------
func (srv *P9Server) stat(path string) (plan9.Dir, error) {
parts := splitPath(path)
name := "/"
if len(parts) > 0 {
name = parts[len(parts)-1]
}
d := plan9.Dir{Name: name, Uid: "feeds", Gid: "feeds", Muid: "feeds"}
switch {
case path == "/":
d.Mode = plan9.DMDIR | 0755
d.Qid = plan9.Qid{Type: plan9.QTDIR, Path: qidRoot}
case path == "/ctl":
d.Mode = 0644
d.Qid = plan9.Qid{Type: plan9.QTFILE, Path: qidCtl}
case path == "/unread":
d.Mode = 0444
d.Qid = plan9.Qid{Type: plan9.QTFILE, Path: qidUnread}
case path == "/all":
d.Mode = 0444
d.Qid = plan9.Qid{Type: plan9.QTFILE, Path: qidAll}
case path == "/pinned":
d.Mode = 0444
d.Qid = plan9.Qid{Type: plan9.QTFILE, Path: qidPinned}
case path == "/feeds":
d.Mode = plan9.DMDIR | 0755
d.Qid = plan9.Qid{Type: plan9.QTDIR, Path: qidFeedsDir}
case len(parts) == 2 && parts[0] == "feeds":
slug := parts[1]
srv.store.mu.RLock()
exists := srv.store.slugExists(slug)
srv.store.mu.RUnlock()
if !exists {
return plan9.Dir{}, fmt.Errorf("not found")
}
d.Mode = plan9.DMDIR | 0755
d.Qid = plan9.Qid{Type: plan9.QTDIR, Path: pathQid(path)}
case len(parts) == 3 && parts[0] == "feeds":
switch parts[2] {
case "new", "read":
d.Mode = plan9.DMDIR | 0755
d.Qid = plan9.Qid{Type: plan9.QTDIR, Path: pathQid(path)}
case "meta", "unread", "all":
d.Mode = 0444
d.Qid = plan9.Qid{Type: plan9.QTFILE, Path: pathQid(path)}
case "ctl":
d.Mode = 0644
d.Qid = plan9.Qid{Type: plan9.QTFILE, Path: pathQid(path)}
default:
return plan9.Dir{}, fmt.Errorf("not found")
}
case len(parts) == 4 && parts[0] == "feeds":
d.Mode = 0444
d.Qid = plan9.Qid{Type: plan9.QTFILE, Path: pathQid(path)}
default:
return plan9.Dir{}, fmt.Errorf("not found: %s", path)
}
return d, nil
}
// ---------------------------------------------------------------------------
// Index text helpers
// ---------------------------------------------------------------------------
func (srv *P9Server) globalIndexText(unreadOnly bool) string {
rows := srv.store.globalIndex(unreadOnly)
var sb strings.Builder
for i, row := range rows {
e := row.ef.entry
fmt.Fprintf(&sb, "%4d %-20s %-20s %s\n",
i+1,
formatTS(e.Timestamp),
row.slug,
e.Title,
)
}
return sb.String()
}
func (srv *P9Server) pinnedIndexText() string {
rows := srv.store.globalPinned()
var sb strings.Builder
for i, row := range rows {
e := row.ef.entry
fmt.Fprintf(&sb, "%4d %-20s %-20s %s\n",
i+1,
formatTS(e.Timestamp),
row.slug,
e.Title,
)
}
return sb.String()
}
func (srv *P9Server) feedIndexText(slug string, unreadOnly bool) string {
srv.store.mu.RLock()
files := srv.store.files[slug]
srv.store.mu.RUnlock()
var sb strings.Builder
n := 0
for _, ef := range files {
if unreadOnly && ef.read {
continue
}
n++
fmt.Fprintf(&sb, "%4d %-20s %s\n", n, formatTS(ef.entry.Timestamp), ef.entry.Title)
}
return sb.String()
}
func formatTS(ts string) string {
if len(ts) >= 10 {
return ts[:10]
}
return ts
}
// ---------------------------------------------------------------------------
// Utility
// ---------------------------------------------------------------------------
func splitPath(path string) []string {
trimmed := strings.TrimPrefix(path, "/")
if trimmed == "" {
return nil
}
return strings.Split(trimmed, "/")
}
func sliceAt(data []byte, offset int64, count uint32) []byte {
if offset >= int64(len(data)) {
return []byte{}
}
end := offset + int64(count)
if end > int64(len(data)) {
end = int64(len(data))
}
return data[offset:end]
}
func serializeDirs(entries []plan9.Dir, offset int64, count uint32) []byte {
var buf []byte
for _, d := range entries {
b, _ := d.Bytes()
buf = append(buf, b...)
}
return sliceAt(buf, offset, count)
}
func fileDir(name string, mode plan9.Perm, qidPath uint64) plan9.Dir {
return plan9.Dir{
Name: name,
Mode: mode,
Qid: plan9.Qid{Type: plan9.QTFILE, Path: qidPath},
}
}
func dirEntry(name string, qidPath uint64) plan9.Dir {
return plan9.Dir{
Name: name,
Mode: plan9.DMDIR | 0755,
Qid: plan9.Qid{Type: plan9.QTDIR, Path: qidPath},
}
}