ekster/cmd/ek/main.go

655 lines
15 KiB
Go
Raw Permalink Normal View History

/*
* Ekster is a microsub server
* Copyright (c) 2021 The Ekster authors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
// Ek is a microsub client.
2018-05-12 11:08:36 +00:00
package main
import (
"encoding/json"
2018-08-18 09:58:57 +00:00
"flag"
2018-05-12 11:08:36 +00:00
"fmt"
"log"
"net/url"
"os"
2018-09-10 18:19:32 +00:00
"time"
2018-05-12 11:08:36 +00:00
2018-11-25 11:50:12 +00:00
"github.com/gilliek/go-opml/opml"
"p83.nl/go/ekster/pkg/client"
"p83.nl/go/ekster/pkg/indieauth"
"p83.nl/go/ekster/pkg/microsub"
2018-05-12 11:08:36 +00:00
)
2018-09-10 19:25:02 +00:00
const (
// Version is the version of the command
2018-12-27 18:37:28 +00:00
Version = "0.8.4"
2018-09-10 19:25:02 +00:00
)
2018-08-18 09:58:57 +00:00
var (
verbose = flag.Bool("verbose", false, "show verbose logging")
)
2018-09-10 19:25:02 +00:00
// Export is the JSON export format
type Export struct {
Version string `json:"version"`
Generator string `json:"generator"`
Channels []ExportChannel `json:"channels,omitempty"`
Feeds map[string][]ExportFeed `json:"feeds,omitempty"`
}
// ExportFeed is a feed.
2018-09-10 19:25:02 +00:00
type ExportFeed string
// ExportChannel contains the channel information for exports.
2018-09-10 19:25:02 +00:00
type ExportChannel struct {
UID string `json:"uid,omitempty"`
Name string `json:"channel,omitempty"`
}
func init() {
log.SetFlags(log.Lshortfile | log.Ldate | log.Ltime)
}
2018-05-12 11:11:13 +00:00
func loadAuth(c *client.Client, filename string) error {
f, err := os.Open(filename)
if err != nil {
return err
}
defer f.Close()
var token indieauth.TokenResponse
dec := json.NewDecoder(f)
err = dec.Decode(&token)
if err != nil {
return err
}
c.Token = token.AccessToken
u, err := url.Parse(token.Me)
if err != nil {
return err
}
c.Me = u
2018-05-12 11:11:13 +00:00
return nil
}
func loadEndpoints(c *client.Client, me *url.URL, filename string) error {
var endpoints indieauth.Endpoints
f, err := os.Open(filename)
2018-05-12 11:16:15 +00:00
if err != nil {
f, err = os.Create(filename)
if err != nil {
return err
}
defer f.Close()
endpoints, err = indieauth.GetEndpoints(me)
if err != nil {
return err
}
enc := json.NewEncoder(f)
err = enc.Encode(&endpoints)
if err != nil {
return err
}
} else {
defer f.Close()
dec := json.NewDecoder(f)
err = dec.Decode(&endpoints)
if err != nil {
return err
}
}
if endpoints.MicrosubEndpoint == "" {
2018-12-08 14:36:50 +00:00
return fmt.Errorf("microsub endpoint is missing")
2018-05-12 11:16:15 +00:00
}
u, err := url.Parse(endpoints.MicrosubEndpoint)
if err != nil {
return err
}
2018-05-12 11:16:15 +00:00
c.MicrosubEndpoint = u
return nil
}
2018-05-12 11:08:36 +00:00
func main() {
2018-08-18 09:58:57 +00:00
flag.Parse()
2018-08-18 10:09:35 +00:00
flag.Usage = func() {
fmt.Print(`Ek is a tool for managing Microsub servers.
Usage:
ek command [arguments]
Commands:
2021-10-20 19:46:53 +00:00
connect URL login to a website that supports Indieauth and Microsub
2018-08-18 10:09:35 +00:00
channels list channels
channels NAME create channel with NAME
channels UID NAME update channel UID with NAME
channels -delete UID delete channel with UID
timeline UID show posts for channel UID
timeline UID -after AFTER show posts for channel UID starting from AFTER
timeline UID -before BEFORE show posts for channel UID ending at BEFORE
search QUERY search for feeds from QUERY
2021-05-31 20:24:20 +00:00
query QUERY CHANNEL search for items matching QUERY in CHANNEL
2018-08-18 10:09:35 +00:00
preview URL show items from the feed at URL
follow UID show follow list for channel UID
follow UID URL follow URL on channel UID
unfollow UID URL unfollow URL on channel UID
2018-09-10 18:19:32 +00:00
export opml export feeds as OPML
2018-09-10 18:41:30 +00:00
import opml FILENAME import OPML feeds
2018-09-10 18:19:32 +00:00
2018-09-10 19:25:02 +00:00
export json export feeds as json
import json FILENAME import json feeds
2018-08-18 10:09:35 +00:00
Global arguments:
`)
flag.PrintDefaults()
}
configDir := fmt.Sprintf("%s/.config/microsub", os.Getenv("HOME"))
2018-05-12 11:08:36 +00:00
if len(os.Args) == 3 && os.Args[1] == "connect" {
err := os.MkdirAll(configDir, os.FileMode(0770))
2018-05-12 11:08:36 +00:00
if err != nil {
log.Fatal(err)
}
f, err := os.Create(fmt.Sprintf("%s/client.json", configDir))
2018-05-12 11:08:36 +00:00
if err != nil {
log.Fatal(err)
}
defer f.Close()
me, err := url.Parse(os.Args[2])
if err != nil {
log.Fatal(err)
}
2018-05-12 11:08:36 +00:00
endpoints, err := indieauth.GetEndpoints(me)
if err != nil {
log.Fatal(err)
}
2018-05-12 11:08:36 +00:00
clientID := "https://p83.nl/microsub-client"
2018-06-27 19:04:19 +00:00
scope := "read follow mute block channels"
token, err := indieauth.Authorize(me, endpoints, clientID, scope)
2018-05-12 11:08:36 +00:00
if err != nil {
log.Fatal(err)
}
enc := json.NewEncoder(f)
err = enc.Encode(token)
if err != nil {
log.Fatal(err)
}
2018-05-12 11:08:36 +00:00
log.Println("Authorization successful")
return
2018-05-12 11:20:13 +00:00
}
2018-05-12 11:08:36 +00:00
2018-05-12 11:20:13 +00:00
var c client.Client
err := loadAuth(&c, fmt.Sprintf("%s/client.json", configDir))
2018-05-12 11:20:13 +00:00
if err != nil {
log.Fatal(err)
}
err = loadEndpoints(&c, c.Me, fmt.Sprintf("%s/endpoints.json", configDir))
2018-05-12 11:20:13 +00:00
if err != nil {
log.Fatal(err)
}
2018-05-12 11:08:36 +00:00
2018-08-18 09:58:57 +00:00
c.Logging = *verbose
performCommands(&c, flag.Args())
2018-05-12 11:58:01 +00:00
}
func channelID(sub microsub.Microsub, channelNameOrID string) (string, error) {
channels, err := sub.ChannelsGetList()
if err != nil {
// we encountered an error, so we are not sure if it worked
return channelNameOrID, err
}
for _, c := range channels {
if c.Name == channelNameOrID {
return c.UID, nil
}
if c.UID == channelNameOrID {
return c.UID, nil
}
}
// unknown?
return channelNameOrID, nil
}
2018-05-12 11:58:01 +00:00
func performCommands(sub microsub.Microsub, commands []string) {
2018-05-12 16:36:59 +00:00
if len(commands) == 0 {
2018-08-18 10:09:35 +00:00
flag.Usage()
2018-05-12 16:36:59 +00:00
return
}
2018-05-12 11:08:36 +00:00
if len(commands) == 1 && commands[0] == "channels" {
channels, err := sub.ChannelsGetList()
if err != nil {
log.Fatalf("An error occurred: %s\n", err)
}
2018-05-12 11:08:36 +00:00
for _, ch := range channels {
2019-01-03 20:34:36 +00:00
fmt.Printf("%-20s %-30s %s\n", ch.UID, ch.Name, ch.Unread)
2018-05-12 11:08:36 +00:00
}
}
2018-05-12 11:58:01 +00:00
if len(commands) == 2 && commands[0] == "channels" {
name := commands[1]
channel, err := sub.ChannelsCreate(name)
if err != nil {
log.Fatalf("An error occurred: %s\n", err)
}
fmt.Printf("%s\n", channel.UID)
2018-05-12 13:18:58 +00:00
}
if len(commands) == 3 && commands[0] == "channels" {
if commands[1] == "-delete" {
uid, _ := channelID(sub, commands[2])
err := sub.ChannelsDelete(uid)
if err != nil {
log.Fatalf("An error occurred: %s\n", err)
}
2018-05-13 17:38:27 +00:00
fmt.Printf("Channel %s deleted\n", uid)
2018-05-12 13:18:58 +00:00
} else {
uid, _ := channelID(sub, commands[1])
name := commands[2]
channel, err := sub.ChannelsUpdate(uid, name)
if err != nil {
log.Fatalf("An error occurred: %s\n", err)
}
2018-05-12 13:18:58 +00:00
fmt.Printf("Channel updated %s %s\n", channel.Name, channel.UID)
}
}
if len(commands) >= 2 && commands[0] == "timeline" {
channel, _ := channelID(sub, commands[1])
2018-05-12 12:37:29 +00:00
var timeline microsub.Timeline
var err error
2018-05-12 12:37:29 +00:00
if len(commands) == 4 && commands[2] == "-after" {
timeline, err = sub.TimelineGet("", commands[3], channel)
} else if len(commands) == 4 && commands[2] == "-before" {
timeline, err = sub.TimelineGet(commands[3], "", channel)
2018-05-12 12:37:29 +00:00
} else {
timeline, err = sub.TimelineGet("", "", channel)
}
if err != nil {
log.Fatalf("An error occurred: %s\n", err)
2018-05-12 12:37:29 +00:00
}
2018-05-12 11:58:01 +00:00
for _, item := range timeline.Items {
2018-05-12 12:08:40 +00:00
showItem(&item)
2018-05-12 11:58:01 +00:00
}
2018-05-12 12:37:29 +00:00
fmt.Printf("Before: %s, After: %s\n", timeline.Paging.Before, timeline.Paging.After)
2018-05-12 11:58:01 +00:00
}
if len(commands) == 2 && commands[0] == "search" {
query := commands[1]
feeds, err := sub.Search(query)
if err != nil {
log.Fatalf("An error occurred: %s\n", err)
}
2018-05-12 11:58:01 +00:00
for _, feed := range feeds {
fmt.Println(feed.Name, " ", feed.URL)
}
}
2021-05-31 20:24:20 +00:00
if len(commands) >= 2 && len(commands) <= 3 && commands[0] == "query" {
query := commands[1]
var channel string
if len(commands) == 3 {
channel, _ = channelID(sub, commands[2])
2021-05-31 20:24:20 +00:00
} else {
channel = "global"
}
items, err := sub.ItemSearch(channel, query)
if err != nil {
log.Fatalf("An error occurred: %s\n", err)
}
for _, item := range items {
showItem(&item)
}
}
if len(commands) == 2 && commands[0] == "preview" {
u := commands[1]
timeline, err := sub.PreviewURL(u)
2018-05-12 11:58:01 +00:00
if err != nil {
log.Fatalf("An error occurred: %s\n", err)
}
2018-05-12 11:58:01 +00:00
for _, item := range timeline.Items {
2018-05-12 12:08:40 +00:00
showItem(&item)
2018-05-12 11:58:01 +00:00
}
}
if len(commands) == 2 && commands[0] == "follow" {
uid, _ := channelID(sub, commands[1])
feeds, err := sub.FollowGetList(uid)
if err != nil {
log.Fatalf("An error occurred: %s\n", err)
}
2018-05-12 11:58:01 +00:00
for _, feed := range feeds {
2018-07-07 21:10:39 +00:00
fmt.Println(feed.URL)
2018-05-12 11:58:01 +00:00
}
}
if len(commands) == 3 && commands[0] == "follow" {
uid, _ := channelID(sub, commands[1])
u := commands[2]
_, err := sub.FollowURL(uid, u)
if err != nil {
2018-12-29 19:29:56 +00:00
log.Fatalf("ERROR: %s", err)
}
// NOTE(peter): should we show the returned feed here?
2018-05-12 11:58:01 +00:00
}
if len(commands) == 3 && commands[0] == "unfollow" {
uid, _ := channelID(sub, commands[1])
u := commands[2]
err := sub.UnfollowURL(uid, u)
if err != nil {
log.Fatalf("An error occurred: %s\n", err)
}
2018-05-12 11:58:01 +00:00
}
2018-09-10 18:19:32 +00:00
if len(commands) == 2 && commands[0] == "export" {
filetype := commands[1]
2018-09-10 19:25:02 +00:00
2018-09-10 18:19:32 +00:00
if filetype == "opml" {
exportOPMLFromMicrosub(sub)
2018-09-10 19:25:02 +00:00
} else if filetype == "json" {
exportJSONFromMicrosub(sub)
2018-09-10 18:19:32 +00:00
} else {
log.Fatalf("unsupported filetype %q", filetype)
}
}
2018-09-10 18:41:30 +00:00
if len(commands) == 3 && commands[0] == "import" {
filetype := commands[1]
filename := commands[2]
if filetype == "opml" {
importOPMLIntoMicrosub(sub, filename)
} else if filetype == "json" {
importJSONIntoMicrosub(sub, filename)
} else {
log.Fatalf("unsupported filetype %q", filetype)
}
}
2018-09-10 18:41:30 +00:00
if len(commands) == 1 && commands[0] == "version" {
fmt.Printf("ek %s\n", Version)
}
if len(commands) == 1 && commands[0] == "events" {
c, err := sub.Events()
if err != nil {
log.Fatalf("could not start event listener: %+v", err)
}
for msg := range c {
log.Printf("%s: %s", msg.Event, msg.Data)
}
}
}
2018-09-10 18:41:30 +00:00
func exportOPMLFromMicrosub(sub microsub.Microsub) {
output := opml.OPML{}
output.Head.Title = "Microsub channels and feeds"
output.Head.DateCreated = time.Now().Format(time.RFC3339)
output.Version = "1.0"
channels, err := sub.ChannelsGetList()
if err != nil {
log.Fatalf("An error occurred: %s\n", err)
}
for _, c := range channels {
var feeds []opml.Outline
list, err := sub.FollowGetList(c.UID)
if err != nil {
log.Fatalf("An error occurred: %s\n", err)
}
for _, f := range list {
feeds = append(feeds, opml.Outline{
Title: f.Name,
Text: f.Name,
Type: f.Type,
URL: f.URL,
HTMLURL: f.URL,
XMLURL: f.URL,
})
}
output.Body.Outlines = append(output.Body.Outlines, opml.Outline{
Text: c.Name,
Title: c.Name,
Outlines: feeds,
})
}
xml, err := output.XML()
if err != nil {
log.Fatalf("An error occurred: %s\n", err)
}
os.Stdout.WriteString(xml)
}
func exportJSONFromMicrosub(sub microsub.Microsub) {
contents := Export{Version: "1.0", Generator: "ek version " + Version}
channels, err := sub.ChannelsGetList()
if err != nil {
log.Fatalf("An error occurred: %s\n", err)
}
for _, c := range channels {
contents.Channels = append(contents.Channels, ExportChannel{UID: c.UID, Name: c.Name})
}
contents.Feeds = make(map[string][]ExportFeed)
for _, c := range channels {
list, err := sub.FollowGetList(c.UID)
if err != nil {
log.Fatalf("An error occurred: %s\n", err)
}
for _, f := range list {
contents.Feeds[c.UID] = append(contents.Feeds[c.UID], ExportFeed(f.URL))
}
}
err = json.NewEncoder(os.Stdout).Encode(&contents)
if err != nil {
log.Fatalf("An error occurred: %s\n", err)
}
}
2018-09-10 18:41:30 +00:00
func importJSONIntoMicrosub(sub microsub.Microsub, filename string) {
var export Export
f, err := os.Open(filename)
if err != nil {
log.Fatalf("can't open file %s: %s", filename, err)
}
defer f.Close()
err = json.NewDecoder(f).Decode(&export)
if err != nil {
log.Fatalf("error while reading %s: %s", filename, err)
}
channelMap := make(map[string]microsub.Channel)
channels, err := sub.ChannelsGetList()
if err != nil {
log.Fatalf("an error occurred: %s\n", err)
}
for _, c := range channels {
channelMap[c.Name] = c
}
for _, c := range export.Channels {
uid := ""
if ch, e := channelMap[c.Name]; !e {
channelCreated, err := sub.ChannelsCreate(c.Name)
2018-09-10 18:41:30 +00:00
if err != nil {
log.Printf("An error occurred: %q\n", err)
continue
2018-09-10 18:41:30 +00:00
}
uid = channelCreated.UID
log.Printf("Channel created: %s\n", c.Name)
} else {
uid = ch.UID
}
2018-09-10 18:41:30 +00:00
feedMap := make(map[string]bool)
2018-09-10 18:41:30 +00:00
feeds, err := sub.FollowGetList(uid)
if err != nil {
2018-12-29 19:29:56 +00:00
log.Fatalf("An error occurred: %s\n", err)
}
2018-09-10 18:41:30 +00:00
for _, f := range feeds {
feedMap[f.URL] = true
}
2018-09-10 18:41:30 +00:00
for _, feed := range export.Feeds[uid] {
2018-09-10 18:41:30 +00:00
if _, e := feedMap[string(feed)]; !e {
_, err := sub.FollowURL(uid, string(feed))
2018-09-10 18:41:30 +00:00
if err != nil {
2018-12-29 19:29:56 +00:00
log.Printf("An error occurred: %s\n", err)
continue
2018-09-10 18:41:30 +00:00
}
log.Printf("Feed followed: %s\n", string(feed))
2018-09-10 18:41:30 +00:00
}
}
}
}
2018-09-10 19:33:16 +00:00
func importOPMLIntoMicrosub(sub microsub.Microsub, filename string) {
channelMap := make(map[string]microsub.Channel)
channels, err := sub.ChannelsGetList()
if err != nil {
log.Fatalf("an error occurred: %s\n", err)
}
for _, c := range channels {
channelMap[c.Name] = c
}
xml, err := opml.NewOPMLFromFile(filename)
if err != nil {
log.Fatalf("An error occurred: %s\n", err)
}
for _, c := range xml.Body.Outlines {
if c.HTMLURL != "" {
log.Printf("First row item has url: %s\n", c.HTMLURL)
continue
}
if len(c.Outlines) == 0 {
continue
}
2018-09-10 19:33:16 +00:00
uid := ""
2018-09-10 19:33:16 +00:00
if ch, e := channelMap[c.Text]; !e {
channelCreated, err := sub.ChannelsCreate(c.Text)
2018-09-10 19:33:16 +00:00
if err != nil {
log.Printf("An error occurred: %q\n", err)
continue
2018-09-10 19:33:16 +00:00
}
uid = channelCreated.UID
log.Printf("Channel created: %s\n", c.Text)
} else {
uid = ch.UID
}
2018-09-10 19:33:16 +00:00
feedMap := make(map[string]bool)
2018-09-10 19:33:16 +00:00
feeds, err := sub.FollowGetList(uid)
if err != nil {
log.Fatalf("An error occurred: %q\n", err)
}
2018-09-10 19:33:16 +00:00
for _, f := range feeds {
feedMap[f.URL] = true
}
2018-09-10 19:33:16 +00:00
for _, f := range c.Outlines {
var url string
if f.HTMLURL != "" {
url = f.HTMLURL
} else if f.XMLURL != "" {
url = f.XMLURL
} else {
log.Println("Missing htmlUrl and xmlUrl attributes")
continue
}
2018-09-10 19:33:16 +00:00
if _, e := feedMap[url]; !e {
_, err := sub.FollowURL(uid, url)
2018-09-10 19:33:16 +00:00
if err != nil {
log.Printf("An error occurred while following feed %s: %q\n", url, err)
continue
2018-09-10 19:33:16 +00:00
}
log.Printf("Feed followed: %s\n", url)
} else {
log.Printf("Feed not followed: %s\n", url)
2018-09-10 19:33:16 +00:00
}
2018-09-10 18:41:30 +00:00
}
}
2018-05-12 11:08:36 +00:00
}
2018-05-12 12:08:40 +00:00
func showItem(item *microsub.Item) {
if item.Name != "" {
fmt.Printf("%s - ", item.Name)
}
fmt.Printf("%s\n", item.Published)
if item.Content != nil {
if item.Content.Text != "" {
fmt.Println(item.Content.Text)
} else {
fmt.Println(item.Content.HTML)
}
2018-05-12 12:08:40 +00:00
}
fmt.Println(item.URL)
2018-05-12 12:37:51 +00:00
fmt.Println()
2018-05-12 12:08:40 +00:00
}