Compare commits
No commits in common. "b3fe5c17ed6db5aba74b37eed29b1789ede7c265" and "a7f19759dfd5c90697a5000f8ae933a0ab22bd95" have entirely different histories.
b3fe5c17ed
...
a7f19759df
13
README.md
13
README.md
|
|
@ -1,6 +1,11 @@
|
||||||
# ekster
|
# ekster
|
||||||
|
|
||||||
a [Microsub](https://indieweb.org/Microsub) server
|
a microsub server
|
||||||
|
|
||||||
|
|
||||||
|
## Warning!
|
||||||
|
|
||||||
|
Very alpha: no warranty.
|
||||||
|
|
||||||
## Installing and running ekster
|
## Installing and running ekster
|
||||||
|
|
||||||
|
|
@ -14,7 +19,7 @@ you need a Go environment. Use these commands to install the programs.
|
||||||
go get -u p83.nl/go/ekster/cmd/eksterd
|
go get -u p83.nl/go/ekster/cmd/eksterd
|
||||||
go get -u p83.nl/go/ekster/cmd/ek
|
go get -u p83.nl/go/ekster/cmd/ek
|
||||||
|
|
||||||
`eksterd` uses [Redis](https://redis.io/) as the database to temporarily save
|
`eksterd` uses [Redis](https://redis.io/) as the database, to temporarily save
|
||||||
the items and feeds. The more permanent information is saved in `backend.json`.
|
the items and feeds. The more permanent information is saved in `backend.json`.
|
||||||
|
|
||||||
#### Running eksterd
|
#### Running eksterd
|
||||||
|
|
@ -157,7 +162,3 @@ Micropub client.
|
||||||
`ekster` will check every 10 minutes, if the token is still valid. This could
|
`ekster` will check every 10 minutes, if the token is still valid. This could
|
||||||
be retrieved automatically, but this doesn't happen at the moment.
|
be retrieved automatically, but this doesn't happen at the moment.
|
||||||
|
|
||||||
## Other Microsub projects
|
|
||||||
|
|
||||||
* <https://indieweb.org/Microsub>
|
|
||||||
* Aperture: [code](https://github.com/aaronparecki/Aperture), [hosted](https://aperture.p3k.io)
|
|
||||||
|
|
|
||||||
|
|
@ -26,12 +26,20 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gomodule/redigo/redis"
|
"github.com/gomodule/redigo/redis"
|
||||||
"p83.nl/go/ekster/pkg/auth"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// TokenResponse is the information that we get back from the token endpoint of the user...
|
||||||
|
type TokenResponse struct {
|
||||||
|
Me string `json:"me"`
|
||||||
|
ClientID string `json:"client_id"`
|
||||||
|
Scope string `json:"scope"`
|
||||||
|
IssuedAt int64 `json:"issued_at"`
|
||||||
|
Nonce int64 `json:"nonce"`
|
||||||
|
}
|
||||||
|
|
||||||
var authHeaderRegex = regexp.MustCompile("^Bearer (.+)$")
|
var authHeaderRegex = regexp.MustCompile("^Bearer (.+)$")
|
||||||
|
|
||||||
func (b *memoryBackend) cachedCheckAuthToken(conn redis.Conn, header string, r *auth.TokenResponse) bool {
|
func (h *microsubHandler) cachedCheckAuthToken(conn redis.Conn, header string, r *TokenResponse) bool {
|
||||||
log.Println("Cached checking Auth Token")
|
log.Println("Cached checking Auth Token")
|
||||||
|
|
||||||
tokens := authHeaderRegex.FindStringSubmatch(header)
|
tokens := authHeaderRegex.FindStringSubmatch(header)
|
||||||
|
|
@ -52,7 +60,7 @@ func (b *memoryBackend) cachedCheckAuthToken(conn redis.Conn, header string, r *
|
||||||
log.Printf("Error while HGETALL %v\n", err)
|
log.Printf("Error while HGETALL %v\n", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
authorized := b.checkAuthToken(header, r)
|
authorized := h.checkAuthToken(header, r)
|
||||||
authorized = true
|
authorized = true
|
||||||
|
|
||||||
if authorized {
|
if authorized {
|
||||||
|
|
@ -77,10 +85,10 @@ func (b *memoryBackend) cachedCheckAuthToken(conn redis.Conn, header string, r *
|
||||||
return authorized
|
return authorized
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *memoryBackend) checkAuthToken(header string, token *auth.TokenResponse) bool {
|
func (h *microsubHandler) checkAuthToken(header string, token *TokenResponse) bool {
|
||||||
log.Println("Checking auth token")
|
log.Println("Checking auth token")
|
||||||
|
|
||||||
tokenEndpoint := b.TokenEndpoint
|
tokenEndpoint := h.Backend.(*memoryBackend).TokenEndpoint
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", tokenEndpoint, nil)
|
req, err := http.NewRequest("GET", tokenEndpoint, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package server
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
|
@ -211,7 +211,7 @@ func isLoggedIn(backend *memoryBackend, sess *session) bool {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if !authEnabled {
|
if !auth {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -448,7 +448,7 @@ func (h *mainHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
scope = "create"
|
scope = "create"
|
||||||
}
|
}
|
||||||
|
|
||||||
authReq := authRequest{
|
auth := authRequest{
|
||||||
Me: me,
|
Me: me,
|
||||||
ClientID: clientID,
|
ClientID: clientID,
|
||||||
RedirectURI: redirectURI,
|
RedirectURI: redirectURI,
|
||||||
|
|
@ -456,7 +456,7 @@ func (h *mainHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
State: state,
|
State: state,
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = conn.Do("HMSET", redis.Args{}.Add("state:"+state).AddFlat(&authReq)...)
|
_, err = conn.Do("HMSET", redis.Args{}.Add("state:"+state).AddFlat(&auth)...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println(err)
|
log.Println(err)
|
||||||
fmt.Fprintf(w, "ERROR: %q\n", err)
|
fmt.Fprintf(w, "ERROR: %q\n", err)
|
||||||
|
|
|
||||||
|
|
@ -23,13 +23,12 @@ import (
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"regexp"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gomodule/redigo/redis"
|
"github.com/gomodule/redigo/redis"
|
||||||
"p83.nl/go/ekster/pkg/auth"
|
|
||||||
|
|
||||||
"p83.nl/go/ekster/pkg/microsub"
|
"p83.nl/go/ekster/pkg/microsub"
|
||||||
"p83.nl/go/ekster/pkg/server"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
@ -39,15 +38,16 @@ const (
|
||||||
var (
|
var (
|
||||||
pool *redis.Pool
|
pool *redis.Pool
|
||||||
port int
|
port int
|
||||||
authEnabled bool
|
auth bool
|
||||||
redisServer = flag.String("redis", "redis:6379", "")
|
redisServer = flag.String("redis", "redis:6379", "")
|
||||||
|
entryRegex = regexp.MustCompile("^entry\\[\\d+\\]$")
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
log.SetFlags(log.Lshortfile | log.Ldate | log.Ltime)
|
log.SetFlags(log.Lshortfile | log.Ldate | log.Ltime)
|
||||||
|
|
||||||
flag.IntVar(&port, "port", 80, "port for serving api")
|
flag.IntVar(&port, "port", 80, "port for serving api")
|
||||||
flag.BoolVar(&authEnabled, "auth", true, "use auth")
|
flag.BoolVar(&auth, "auth", true, "use auth")
|
||||||
}
|
}
|
||||||
|
|
||||||
func newPool(addr string) *redis.Pool {
|
func newPool(addr string) *redis.Pool {
|
||||||
|
|
@ -58,33 +58,11 @@ func newPool(addr string) *redis.Pool {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func WithAuth(handler http.Handler, b *memoryBackend) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
authorization := r.Header.Get("Authorization")
|
|
||||||
|
|
||||||
var token auth.TokenResponse
|
|
||||||
|
|
||||||
if !b.AuthTokenAccepted(authorization, &token) {
|
|
||||||
log.Printf("Token could not be validated")
|
|
||||||
http.Error(w, "Can't validate token", 403)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if token.Me != b.Me {
|
|
||||||
log.Printf("Missing \"me\" in token response: %#v\n", token)
|
|
||||||
http.Error(w, "Wrong me", 403)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
handler.ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
log.Println("eksterd - microsub server")
|
log.Println("eksterd - microsub server")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
if authEnabled {
|
if auth {
|
||||||
log.Println("Using auth")
|
log.Println("Using auth")
|
||||||
} else {
|
} else {
|
||||||
log.Println("Authentication disabled")
|
log.Println("Authentication disabled")
|
||||||
|
|
@ -120,13 +98,10 @@ func main() {
|
||||||
Backend: backend.(*memoryBackend),
|
Backend: backend.(*memoryBackend),
|
||||||
})
|
})
|
||||||
|
|
||||||
handler := server.NewMicrosubHandler(backend)
|
http.Handle("/microsub", µsubHandler{
|
||||||
if authEnabled {
|
Backend: backend,
|
||||||
handler = WithAuth(handler, backend.(*memoryBackend))
|
HubIncomingBackend: &hubBackend,
|
||||||
}
|
})
|
||||||
|
|
||||||
http.Handle("/microsub", handler)
|
|
||||||
|
|
||||||
http.Handle("/incoming/", &incomingHandler{
|
http.Handle("/incoming/", &incomingHandler{
|
||||||
Backend: &hubBackend,
|
Backend: &hubBackend,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,6 @@ import (
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"p83.nl/go/ekster/pkg/auth"
|
|
||||||
"p83.nl/go/ekster/pkg/fetch"
|
"p83.nl/go/ekster/pkg/fetch"
|
||||||
"p83.nl/go/ekster/pkg/microsub"
|
"p83.nl/go/ekster/pkg/microsub"
|
||||||
"p83.nl/go/ekster/pkg/util"
|
"p83.nl/go/ekster/pkg/util"
|
||||||
|
|
@ -42,8 +41,6 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type memoryBackend struct {
|
type memoryBackend struct {
|
||||||
hubIncomingBackend
|
|
||||||
|
|
||||||
lock sync.RWMutex
|
lock sync.RWMutex
|
||||||
Channels map[string]microsub.Channel
|
Channels map[string]microsub.Channel
|
||||||
Feeds map[string][]microsub.Feed
|
Feeds map[string][]microsub.Feed
|
||||||
|
|
@ -81,12 +78,6 @@ func (f *fetch2) Fetch(url string) (*http.Response, error) {
|
||||||
return Fetch2(url)
|
return Fetch2(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *memoryBackend) AuthTokenAccepted(header string, r *auth.TokenResponse) bool {
|
|
||||||
conn := pool.Get()
|
|
||||||
defer conn.Close()
|
|
||||||
return b.cachedCheckAuthToken(conn, header, r)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *memoryBackend) Debug() {
|
func (b *memoryBackend) Debug() {
|
||||||
b.lock.RLock()
|
b.lock.RLock()
|
||||||
defer b.lock.RUnlock()
|
defer b.lock.RUnlock()
|
||||||
|
|
@ -421,8 +412,6 @@ func (b *memoryBackend) FollowURL(uid string, url string) (microsub.Feed, error)
|
||||||
|
|
||||||
b.ProcessContent(uid, feed.URL, resp.Header.Get("Content-Type"), resp.Body)
|
b.ProcessContent(uid, feed.URL, resp.Header.Get("Content-Type"), resp.Body)
|
||||||
|
|
||||||
b.CreateFeed(url, uid)
|
|
||||||
|
|
||||||
return feed, nil
|
return feed, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -509,14 +498,12 @@ func (b *memoryBackend) Search(query string) ([]microsub.Feed, error) {
|
||||||
}
|
}
|
||||||
defer feedResp.Body.Close()
|
defer feedResp.Body.Close()
|
||||||
|
|
||||||
// TODO: Combine FeedHeader and FeedItems so we can use it here
|
|
||||||
parsedFeed, err := fetch.FeedHeader(&fetch2{}, fetchUrl.String(), feedResp.Header.Get("Content-Type"), feedResp.Body)
|
parsedFeed, err := fetch.FeedHeader(&fetch2{}, fetchUrl.String(), feedResp.Header.Get("Content-Type"), feedResp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error in parse of %s - %v\n", fetchUrl, err)
|
log.Printf("Error in parse of %s - %v\n", fetchUrl, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Only include the feed if it contains some items
|
|
||||||
feeds = append(feeds, parsedFeed)
|
feeds = append(feeds, parsedFeed)
|
||||||
|
|
||||||
if alts, e := md.Rels["alternate"]; e {
|
if alts, e := md.Rels["alternate"]; e {
|
||||||
|
|
|
||||||
|
|
@ -15,35 +15,30 @@
|
||||||
You should have received a copy of the GNU General Public License
|
You should have received a copy of the GNU General Public License
|
||||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
package server
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"os"
|
||||||
|
|
||||||
"p83.nl/go/ekster/pkg/microsub"
|
"p83.nl/go/ekster/pkg/microsub"
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
"github.com/gomodule/redigo/redis"
|
||||||
entryRegex = regexp.MustCompile("^entry\\[\\d+\\]$")
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
OutputContentType = "application/json; charset=utf-8"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type microsubHandler struct {
|
type microsubHandler struct {
|
||||||
backend microsub.Microsub
|
Backend microsub.Microsub
|
||||||
}
|
HubIncomingBackend HubBackend
|
||||||
|
|
||||||
func NewMicrosubHandler(backend microsub.Microsub) http.Handler {
|
|
||||||
return µsubHandler{backend}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *microsubHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
func (h *microsubHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var logger = log.New(os.Stdout, "logger: ", log.Lshortfile)
|
||||||
|
conn := redis.NewLoggingConn(pool.Get(), logger, "microsub")
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
r.ParseForm()
|
r.ParseForm()
|
||||||
log.Printf("%s %s\n", r.Method, r.URL)
|
log.Printf("%s %s\n", r.Method, r.URL)
|
||||||
log.Println(r.URL.Query())
|
log.Println(r.URL.Query())
|
||||||
|
|
@ -56,12 +51,31 @@ func (h *microsubHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if auth {
|
||||||
|
authorization := r.Header.Get("Authorization")
|
||||||
|
|
||||||
|
var token TokenResponse
|
||||||
|
|
||||||
|
if !h.cachedCheckAuthToken(conn, authorization, &token) {
|
||||||
|
log.Printf("Token could not be validated")
|
||||||
|
http.Error(w, "Can't validate token", 403)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if token.Me != h.Backend.(*memoryBackend).Me {
|
||||||
|
log.Printf("Missing \"me\" in token response: %#v\n", token)
|
||||||
|
http.Error(w, "Wrong me", 403)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const OutputContentType = "application/json; charset=utf-8"
|
||||||
if r.Method == http.MethodGet {
|
if r.Method == http.MethodGet {
|
||||||
w.Header().Add("Access-Control-Allow-Origin", "*")
|
w.Header().Add("Access-Control-Allow-Origin", "*")
|
||||||
values := r.URL.Query()
|
values := r.URL.Query()
|
||||||
action := values.Get("action")
|
action := values.Get("action")
|
||||||
if action == "channels" {
|
if action == "channels" {
|
||||||
channels, err := h.backend.ChannelsGetList()
|
channels, err := h.Backend.ChannelsGetList()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), 500)
|
http.Error(w, err.Error(), 500)
|
||||||
return
|
return
|
||||||
|
|
@ -76,7 +90,7 @@ func (h *microsubHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else if action == "timeline" {
|
} else if action == "timeline" {
|
||||||
timeline, err := h.backend.TimelineGet(values.Get("before"), values.Get("after"), values.Get("channel"))
|
timeline, err := h.Backend.TimelineGet(values.Get("before"), values.Get("after"), values.Get("channel"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), 500)
|
http.Error(w, err.Error(), 500)
|
||||||
return
|
return
|
||||||
|
|
@ -91,7 +105,7 @@ func (h *microsubHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else if action == "preview" {
|
} else if action == "preview" {
|
||||||
timeline, err := h.backend.PreviewURL(values.Get("url"))
|
timeline, err := h.Backend.PreviewURL(values.Get("url"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), 500)
|
http.Error(w, err.Error(), 500)
|
||||||
return
|
return
|
||||||
|
|
@ -106,7 +120,7 @@ func (h *microsubHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
} else if action == "follow" {
|
} else if action == "follow" {
|
||||||
channel := values.Get("channel")
|
channel := values.Get("channel")
|
||||||
following, err := h.backend.FollowGetList(channel)
|
following, err := h.Backend.FollowGetList(channel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), 500)
|
http.Error(w, err.Error(), 500)
|
||||||
return
|
return
|
||||||
|
|
@ -123,7 +137,7 @@ func (h *microsubHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
} else if action == "events" {
|
} else if action == "events" {
|
||||||
conn, _, _ := w.(http.Hijacker).Hijack()
|
conn, _, _ := w.(http.Hijacker).Hijack()
|
||||||
cons := newConsumer(conn)
|
cons := newConsumer(conn)
|
||||||
h.backend.AddEventListener(cons)
|
h.Backend.AddEventListener(cons)
|
||||||
} else {
|
} else {
|
||||||
http.Error(w, fmt.Sprintf("unknown action %s\n", action), 500)
|
http.Error(w, fmt.Sprintf("unknown action %s\n", action), 500)
|
||||||
return
|
return
|
||||||
|
|
@ -139,19 +153,20 @@ func (h *microsubHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
method := values.Get("method")
|
method := values.Get("method")
|
||||||
uid := values.Get("channel")
|
uid := values.Get("channel")
|
||||||
if method == "delete" {
|
if method == "delete" {
|
||||||
err := h.backend.ChannelsDelete(uid)
|
err := h.Backend.ChannelsDelete(uid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), 500)
|
http.Error(w, err.Error(), 500)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Add("Content-Type", OutputContentType)
|
w.Header().Add("Content-Type", OutputContentType)
|
||||||
fmt.Fprintln(w, "[]")
|
fmt.Fprintln(w, "[]")
|
||||||
|
h.Backend.(Debug).Debug()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
jw := json.NewEncoder(w)
|
jw := json.NewEncoder(w)
|
||||||
if uid == "" {
|
if uid == "" {
|
||||||
channel, err := h.backend.ChannelsCreate(name)
|
channel, err := h.Backend.ChannelsCreate(name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), 500)
|
http.Error(w, err.Error(), 500)
|
||||||
return
|
return
|
||||||
|
|
@ -163,7 +178,7 @@ func (h *microsubHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
channel, err := h.backend.ChannelsUpdate(uid, name)
|
channel, err := h.Backend.ChannelsUpdate(uid, name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), 500)
|
http.Error(w, err.Error(), 500)
|
||||||
return
|
return
|
||||||
|
|
@ -175,11 +190,12 @@ func (h *microsubHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
h.Backend.(Debug).Debug()
|
||||||
} else if action == "follow" {
|
} else if action == "follow" {
|
||||||
uid := values.Get("channel")
|
uid := values.Get("channel")
|
||||||
url := values.Get("url")
|
url := values.Get("url")
|
||||||
// h.HubIncomingBackend.CreateFeed(url, uid)
|
h.HubIncomingBackend.CreateFeed(url, uid)
|
||||||
feed, err := h.backend.FollowURL(uid, url)
|
feed, err := h.Backend.FollowURL(uid, url)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), 500)
|
http.Error(w, err.Error(), 500)
|
||||||
return
|
return
|
||||||
|
|
@ -194,7 +210,7 @@ func (h *microsubHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
} else if action == "unfollow" {
|
} else if action == "unfollow" {
|
||||||
uid := values.Get("channel")
|
uid := values.Get("channel")
|
||||||
url := values.Get("url")
|
url := values.Get("url")
|
||||||
err := h.backend.UnfollowURL(uid, url)
|
err := h.Backend.UnfollowURL(uid, url)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), 500)
|
http.Error(w, err.Error(), 500)
|
||||||
return
|
return
|
||||||
|
|
@ -203,7 +219,7 @@ func (h *microsubHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
fmt.Fprintln(w, "[]")
|
fmt.Fprintln(w, "[]")
|
||||||
} else if action == "search" {
|
} else if action == "search" {
|
||||||
query := values.Get("query")
|
query := values.Get("query")
|
||||||
feeds, err := h.backend.Search(query)
|
feeds, err := h.Backend.Search(query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), 500)
|
http.Error(w, err.Error(), 500)
|
||||||
return
|
return
|
||||||
|
|
@ -239,7 +255,7 @@ func (h *microsubHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(markAsRead) > 0 {
|
if len(markAsRead) > 0 {
|
||||||
err := h.backend.MarkRead(channel, markAsRead)
|
err := h.Backend.MarkRead(channel, markAsRead)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), 500)
|
http.Error(w, err.Error(), 500)
|
||||||
return
|
return
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
package auth
|
|
||||||
|
|
||||||
type Auther interface {
|
|
||||||
AuthTokenAccepted(header string, r *TokenResponse) bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// TokenResponse is the information that we get back from the token endpoint of the user...
|
|
||||||
type TokenResponse struct {
|
|
||||||
Me string `json:"me"`
|
|
||||||
ClientID string `json:"client_id"`
|
|
||||||
Scope string `json:"scope"`
|
|
||||||
IssuedAt int64 `json:"issued_at"`
|
|
||||||
Nonce int64 `json:"nonce"`
|
|
||||||
}
|
|
||||||
|
|
@ -20,7 +20,6 @@ package jf2
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"reflect"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -29,54 +28,6 @@ import (
|
||||||
"willnorris.com/go/microformats"
|
"willnorris.com/go/microformats"
|
||||||
)
|
)
|
||||||
|
|
||||||
func ConvertItemProps(item interface{}, props map[string][]interface{}) {
|
|
||||||
sv := reflect.ValueOf(item).Elem()
|
|
||||||
st := reflect.TypeOf(item).Elem()
|
|
||||||
|
|
||||||
for i := 0; i < st.NumField(); i++ {
|
|
||||||
ft := st.Field(i)
|
|
||||||
fv := sv.Field(i)
|
|
||||||
|
|
||||||
if value, ok := ft.Tag.Lookup("mf2"); ok {
|
|
||||||
if value == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if s, e := props[value]; e {
|
|
||||||
if len(s) > 0 {
|
|
||||||
if str, ok := s[0].(string); ft.Type.Kind() == reflect.String && ok {
|
|
||||||
fv.SetString(str)
|
|
||||||
} else if ft.Type.Kind() == reflect.Slice {
|
|
||||||
for _, v := range s {
|
|
||||||
fv.Set(reflect.Append(fv, reflect.ValueOf(v)))
|
|
||||||
}
|
|
||||||
} else if card, ok := s[0].(map[string]interface{}); ok {
|
|
||||||
var hcard microsub.Card
|
|
||||||
if t, ok := card["type"].([]interface{}); ok {
|
|
||||||
hcard.Type = t[0].(string)[2:]
|
|
||||||
}
|
|
||||||
if properties, ok := card["properties"].(map[string]interface{}); ok {
|
|
||||||
ps := make(map[string][]interface{})
|
|
||||||
for k, v := range properties {
|
|
||||||
ps[k] = v.([]interface{})
|
|
||||||
}
|
|
||||||
ConvertItemProps(&hcard, ps)
|
|
||||||
}
|
|
||||||
fv.Set(reflect.ValueOf(&hcard))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func ConvertItem(item interface{}, md *microformats.Microformat) {
|
|
||||||
sv := reflect.ValueOf(item).Elem()
|
|
||||||
|
|
||||||
sv.FieldByName("Type").SetString(md.Type[0][2:])
|
|
||||||
|
|
||||||
ConvertItemProps(item, md.Properties)
|
|
||||||
}
|
|
||||||
|
|
||||||
func simplify(itemType string, item map[string][]interface{}, author map[string]string) map[string]interface{} {
|
func simplify(itemType string, item map[string][]interface{}, author map[string]string) map[string]interface{} {
|
||||||
feedItem := make(map[string]interface{})
|
feedItem := make(map[string]interface{})
|
||||||
|
|
||||||
|
|
@ -249,7 +200,6 @@ func fetchValue(key string, values map[string]string) string {
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func MapToAuthor(result map[string]string) *microsub.Card {
|
func MapToAuthor(result map[string]string) *microsub.Card {
|
||||||
item := µsub.Card{}
|
item := µsub.Card{}
|
||||||
item.Type = "card"
|
item.Type = "card"
|
||||||
|
|
|
||||||
|
|
@ -18,197 +18,78 @@
|
||||||
package jf2
|
package jf2
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"log"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"p83.nl/go/ekster/pkg/microsub"
|
|
||||||
"willnorris.com/go/microformats"
|
"willnorris.com/go/microformats"
|
||||||
)
|
)
|
||||||
|
|
||||||
// func TestInReplyTo(t *testing.T) {
|
func TestInReplyTo(t *testing.T) {
|
||||||
//
|
|
||||||
// f, err := os.Open("./tests/tantek-in-reply-to.html")
|
|
||||||
// if err != nil {
|
|
||||||
// log.Fatal(err)
|
|
||||||
// }
|
|
||||||
// defer f.Close()
|
|
||||||
//
|
|
||||||
// u, err := url.Parse("http://tantek.com/2018/115/t1/")
|
|
||||||
// if err != nil {
|
|
||||||
// log.Fatal(err)
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// data := microformats.Parse(f, u)
|
|
||||||
// results := SimplifyMicroformatData(data)
|
|
||||||
//
|
|
||||||
// if results[0]["type"] != "entry" {
|
|
||||||
// t.Fatalf("not an h-entry, but %s", results[0]["type"])
|
|
||||||
// }
|
|
||||||
// if results[0]["in-reply-to"] != "https://github.com/w3c/csswg-drafts/issues/2589" {
|
|
||||||
// t.Fatalf("not in-reply-to, but %s", results[0]["in-reply-to"])
|
|
||||||
// }
|
|
||||||
// if results[0]["syndication"] != "https://github.com/w3c/csswg-drafts/issues/2589#thumbs_up-by-tantek" {
|
|
||||||
// t.Fatalf("not in-reply-to, but %s", results[0]["syndication"])
|
|
||||||
// }
|
|
||||||
// if results[0]["published"] != "2018-04-25 11:14-0700" {
|
|
||||||
// t.Fatalf("not published, but %s", results[0]["published"])
|
|
||||||
// }
|
|
||||||
// if results[0]["updated"] != "2018-04-25 11:14-0700" {
|
|
||||||
// t.Fatalf("not updated, but %s", results[0]["updated"])
|
|
||||||
// }
|
|
||||||
// if results[0]["url"] != "http://tantek.com/2018/115/t1/" {
|
|
||||||
// t.Fatalf("not url, but %s", results[0]["url"])
|
|
||||||
// }
|
|
||||||
// if results[0]["uid"] != "http://tantek.com/2018/115/t1/" {
|
|
||||||
// t.Fatalf("not uid, but %s", results[0]["url"])
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// if authorValue, e := results[0]["author"]; e {
|
|
||||||
// if author, ok := authorValue.(map[string]string); ok {
|
|
||||||
// if author["name"] != "Tantek Çelik" {
|
|
||||||
// t.Fatalf("name is not expected name, but %q", author["name"])
|
|
||||||
// }
|
|
||||||
// if author["photo"] != "http://tantek.com/logo.jpg" {
|
|
||||||
// t.Fatalf("photo is not expected photo, but %q", author["photo"])
|
|
||||||
// }
|
|
||||||
// if author["url"] != "http://tantek.com/" {
|
|
||||||
// t.Fatalf("url is not expected url, but %q", author["url"])
|
|
||||||
// }
|
|
||||||
// } else {
|
|
||||||
// t.Fatal("author not a map")
|
|
||||||
// }
|
|
||||||
// } else {
|
|
||||||
// t.Fatal("author missing")
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// if contentValue, e := results[0]["content"]; e {
|
|
||||||
// if content, ok := contentValue.(map[string]string); ok {
|
|
||||||
// if content["text"] != "👍" {
|
|
||||||
// t.Fatal("text content missing")
|
|
||||||
// }
|
|
||||||
// if content["html"] != "👍" {
|
|
||||||
// t.Fatal("html content missing")
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
func TestMapToAuthor(t *testing.T) {
|
f, err := os.Open("./tests/tantek-in-reply-to.html")
|
||||||
cardmap := make(map[string]string)
|
|
||||||
|
|
||||||
cardmap["name"] = "Peter"
|
|
||||||
cardmap["url"] = "https://p83.nl/"
|
|
||||||
cardmap["photo"] = "https://peterstuifzand.nl/img/profile.jpg"
|
|
||||||
|
|
||||||
card := MapToAuthor(cardmap)
|
|
||||||
|
|
||||||
if card.Type != "card" {
|
|
||||||
t.Error("mapped author type is not card")
|
|
||||||
}
|
|
||||||
if card.Name != cardmap["name"] {
|
|
||||||
t.Errorf("%q is not equal to %q", card.Name, "Peter")
|
|
||||||
}
|
|
||||||
if card.URL != cardmap["url"] {
|
|
||||||
t.Errorf("%q is not equal to %q", card.URL, cardmap["url"])
|
|
||||||
}
|
|
||||||
if card.Photo != cardmap["photo"] {
|
|
||||||
t.Errorf("%q is not equal to %q", card.Photo, cardmap["photo"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMapToItem(t *testing.T) {
|
|
||||||
itemmap := make(map[string]interface{})
|
|
||||||
itemmap["type"] = "entry"
|
|
||||||
itemmap["name"] = "Title"
|
|
||||||
c := make(map[string]interface{})
|
|
||||||
c["text"] = "Simple content"
|
|
||||||
c["html"] = "<p>Simple content</p>"
|
|
||||||
itemmap["content"] = c
|
|
||||||
itemmap["like-of"] = []string{
|
|
||||||
"https://p83.nl/",
|
|
||||||
"https://p83.nl/test.html",
|
|
||||||
}
|
|
||||||
item := MapToItem(itemmap)
|
|
||||||
if item.Type != "entry" {
|
|
||||||
t.Errorf("Expected Type entry, was %q", item.Type)
|
|
||||||
}
|
|
||||||
if item.Name != "Title" {
|
|
||||||
t.Errorf("Expected Name == %q, was actually %q", "Title", item.Name)
|
|
||||||
}
|
|
||||||
if item.Content.Text != "Simple content" {
|
|
||||||
t.Errorf("Expected Content.Text == %q, was actually %q", "Simple content", item.Content.Text)
|
|
||||||
}
|
|
||||||
if item.Content.HTML != "<p>Simple content</p>" {
|
|
||||||
t.Errorf("Expected Content.HTML == %q, was actually %q", "<p>Simple content</p>", item.Content.HTML)
|
|
||||||
}
|
|
||||||
// if val := item.LikeOf[0]; val != "https://p83.nl/" {
|
|
||||||
// t.Errorf("Expected LikeOf[0] == %q, was actually %q", "https://p83.nl/", val)
|
|
||||||
// }
|
|
||||||
// if val := item.LikeOf[1]; val != "https://p83.nl/test.html" {
|
|
||||||
// t.Errorf("Expected LikeOf[1] == %q, was actually %q", "https://p83.nl/test.html", val)
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConvertItem0(t *testing.T) {
|
|
||||||
var item microsub.Item
|
|
||||||
var mdItem microformats.Microformat
|
|
||||||
f, err := os.Open("tests/test0.json")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("error while opening test0.json: %s", err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
json.NewDecoder(f).Decode(&mdItem)
|
defer f.Close()
|
||||||
ConvertItem(&item, &mdItem)
|
|
||||||
|
|
||||||
if item.Type != "entry" {
|
u, err := url.Parse("http://tantek.com/2018/115/t1/")
|
||||||
t.Errorf("Expected Type entry, was %q", item.Type)
|
|
||||||
}
|
|
||||||
if item.Name != "name test" {
|
|
||||||
t.Errorf("Expected Name == %q, was %q", "name test", item.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConvertItem1(t *testing.T) {
|
|
||||||
var item microsub.Item
|
|
||||||
var mdItem microformats.Microformat
|
|
||||||
f, err := os.Open("tests/test1.json")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("error while opening test1.json: %s", err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
json.NewDecoder(f).Decode(&mdItem)
|
|
||||||
ConvertItem(&item, &mdItem)
|
|
||||||
|
|
||||||
if item.Type != "entry" {
|
data := microformats.Parse(f, u)
|
||||||
t.Errorf("Expected Type entry, was %q", item.Type)
|
results := SimplifyMicroformatData(data)
|
||||||
|
|
||||||
|
if results[0]["type"] != "entry" {
|
||||||
|
t.Fatalf("not an h-entry, but %s", results[0]["type"])
|
||||||
}
|
}
|
||||||
if item.Author.Type != "card" {
|
if results[0]["in-reply-to"] != "https://github.com/w3c/csswg-drafts/issues/2589" {
|
||||||
t.Errorf("Expected Author.Type card, was %q", item.Author.Type)
|
t.Fatalf("not in-reply-to, but %s", results[0]["in-reply-to"])
|
||||||
}
|
}
|
||||||
if item.Author.Name != "Peter" {
|
if results[0]["syndication"] != "https://github.com/w3c/csswg-drafts/issues/2589#thumbs_up-by-tantek" {
|
||||||
t.Errorf("Expected Author.Name == %q, was %q", "Peter", item.Author.Name)
|
t.Fatalf("not in-reply-to, but %s", results[0]["syndication"])
|
||||||
}
|
}
|
||||||
}
|
if results[0]["published"] != "2018-04-25 11:14-0700" {
|
||||||
|
t.Fatalf("not published, but %s", results[0]["published"])
|
||||||
func TestConvertItem2(t *testing.T) {
|
}
|
||||||
var item microsub.Item
|
if results[0]["updated"] != "2018-04-25 11:14-0700" {
|
||||||
var mdItem microformats.Microformat
|
t.Fatalf("not updated, but %s", results[0]["updated"])
|
||||||
f, err := os.Open("tests/test2.json")
|
}
|
||||||
if err != nil {
|
if results[0]["url"] != "http://tantek.com/2018/115/t1/" {
|
||||||
t.Fatalf("error while opening test2.json: %s", err)
|
t.Fatalf("not url, but %s", results[0]["url"])
|
||||||
}
|
}
|
||||||
json.NewDecoder(f).Decode(&mdItem)
|
if results[0]["uid"] != "http://tantek.com/2018/115/t1/" {
|
||||||
ConvertItem(&item, &mdItem)
|
t.Fatalf("not uid, but %s", results[0]["url"])
|
||||||
|
}
|
||||||
if item.Type != "entry" {
|
|
||||||
t.Errorf("Expected Type entry, was %q", item.Type)
|
if authorValue, e := results[0]["author"]; e {
|
||||||
}
|
if author, ok := authorValue.(map[string]string); ok {
|
||||||
if item.Photo[0] != "https://peterstuifzand.nl/img/profile.jpg" {
|
if author["name"] != "Tantek Çelik" {
|
||||||
t.Errorf("Expected Photo[0], was %q", item.Type)
|
t.Fatalf("name is not expected name, but %q", author["name"])
|
||||||
}
|
}
|
||||||
if item.Author.Type != "card" {
|
if author["photo"] != "http://tantek.com/logo.jpg" {
|
||||||
t.Errorf("Expected Author.Type card, was %q", item.Author.Type)
|
t.Fatalf("photo is not expected photo, but %q", author["photo"])
|
||||||
}
|
}
|
||||||
if item.Author.Name != "Peter" {
|
if author["url"] != "http://tantek.com/" {
|
||||||
t.Errorf("Expected Author.Name == %q, was %q", "Peter", item.Author.Name)
|
t.Fatalf("url is not expected url, but %q", author["url"])
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
t.Fatal("author not a map")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
t.Fatal("author missing")
|
||||||
|
}
|
||||||
|
|
||||||
|
if contentValue, e := results[0]["content"]; e {
|
||||||
|
if content, ok := contentValue.(map[string]string); ok {
|
||||||
|
if content["text"] != "👍" {
|
||||||
|
t.Fatal("text content missing")
|
||||||
|
}
|
||||||
|
if content["html"] != "👍" {
|
||||||
|
t.Fatal("html content missing")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
{"type":["h-entry"],"properties":{"name":["name test"]}}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
{"type":["h-entry"],"properties":{"name":["name test"],"author":[{"type":["h-card"],"properties":{"name":["Peter"]}}]}}
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
{
|
|
||||||
"type": [
|
|
||||||
"h-entry"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"name": [
|
|
||||||
"name test"
|
|
||||||
],
|
|
||||||
"photo": [
|
|
||||||
"https://peterstuifzand.nl/img/profile.jpg"
|
|
||||||
],
|
|
||||||
"author": [
|
|
||||||
{
|
|
||||||
"type": [
|
|
||||||
"h-card"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"name": [
|
|
||||||
"Peter"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -40,40 +40,40 @@ type Channel struct {
|
||||||
type Card struct {
|
type Card struct {
|
||||||
Filled bool `json:"-,omitempty"`
|
Filled bool `json:"-,omitempty"`
|
||||||
Type string `json:"type,omitempty"`
|
Type string `json:"type,omitempty"`
|
||||||
Name string `json:"name,omitempty" mf2:"name"`
|
Name string `json:"name,omitempty"`
|
||||||
URL string `json:"url,omitempty" mf2:"url"`
|
URL string `json:"url,omitempty"`
|
||||||
Photo string `json:"photo,omitempty" mf2:"photo"`
|
Photo string `json:"photo,omitempty"`
|
||||||
Locality string `json:"locality,omitempty" mf2:"locality"`
|
Locality string `json:"locality,omitempty"`
|
||||||
Region string `json:"region,omitempty" mf2:"region"`
|
Region string `json:"region,omitempty"`
|
||||||
CountryName string `json:"country-name,omitempty" mf2:"country-name"`
|
CountryName string `json:"country-name,omitempty"`
|
||||||
Longitude string `json:"longitude,omitempty" mf2:"longitude"`
|
Longitude string `json:"longitude,omitempty"`
|
||||||
Latitude string `json:"latitude,omitempty" mf2:"latitude"`
|
Latitude string `json:"latitude,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Content struct {
|
type Content struct {
|
||||||
Text string `json:"text,omitempty" mf2:"value"`
|
Text string `json:"text,omitempty"`
|
||||||
HTML string `json:"html,omitempty" mf2:"html"`
|
HTML string `json:"html,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Item is a post object
|
// Item is a post object
|
||||||
type Item struct {
|
type Item struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Name string `json:"name,omitempty" mf2:"name"`
|
Name string `json:"name,omitempty"`
|
||||||
Published string `json:"published,omitempty" mf2:"published"`
|
Published string `json:"published,omitempty"`
|
||||||
Updated string `json:"updated,omitempty" mf2:"updated"`
|
Updated string `json:"updated,omitempty"`
|
||||||
URL string `json:"url,omitempty" mf2:"url"`
|
URL string `json:"url,omitempty"`
|
||||||
UID string `json:"uid,omitempty" mf2:"uid"`
|
UID string `json:"uid,omitempty"`
|
||||||
Author *Card `json:"author,omitempty" mf2:"author"`
|
Author *Card `json:"author,omitempty"`
|
||||||
Category []string `json:"category,omitempty" mf2:"category"`
|
Category []string `json:"category,omitempty"`
|
||||||
Photo []string `json:"photo,omitempty" mf2:"photo"`
|
Photo []string `json:"photo,omitempty"`
|
||||||
LikeOf []string `json:"like-of,omitempty" mf2:"like-of"`
|
LikeOf []string `json:"like-of,omitempty"`
|
||||||
BookmarkOf []string `json:"bookmark-of,omitempty" mf2:"bookmark-of"`
|
BookmarkOf []string `json:"bookmark-of,omitempty"`
|
||||||
RepostOf []string `json:"repost-of,omitempty" mf2:"repost-of"`
|
RepostOf []string `json:"repost-of,omitempty"`
|
||||||
InReplyTo []string `json:"in-reply-to,omitempty" mf2:"in-reply-to"`
|
InReplyTo []string `json:"in-reply-to,omitempty"`
|
||||||
Content *Content `json:"content,omitempty" mf2:"content"`
|
Content *Content `json:"content,omitempty"`
|
||||||
Latitude string `json:"latitude,omitempty" mf2:"latitude"`
|
Latitude string `json:"latitude,omitempty"`
|
||||||
Longitude string `json:"longitude,omitempty" mf2:"longitude"`
|
Longitude string `json:"longitude,omitempty"`
|
||||||
Checkin *Card `json:"checkin,omitempty" mf2:"checkin"`
|
Checkin *Card `json:"checkin,omitempty"`
|
||||||
Refs map[string]Item `json:"refs,omitempty"`
|
Refs map[string]Item `json:"refs,omitempty"`
|
||||||
ID string `json:"_id,omitempty"`
|
ID string `json:"_id,omitempty"`
|
||||||
Read bool `json:"_is_read"`
|
Read bool `json:"_is_read"`
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user