Compare commits
26 commits
Author | SHA1 | Date | |
---|---|---|---|
dd27ffabaa | |||
6bb5eff11b | |||
1806140f4b | |||
2ff69b9d38 | |||
264be94427 | |||
ab8249e0bf | |||
2c39f7fd3c | |||
508722feb5 | |||
bbf26561f6 | |||
03ee058fe7 | |||
d61c786ffd | |||
b00f26b17d | |||
a053406fc5 | |||
5b20ff3135 | |||
51bd3a6505 | |||
cae8c8b597 | |||
64d00083b7 | |||
305366dc9e | |||
a2017e3de8 | |||
194a5aed48 | |||
3d99b53935 | |||
e3c1f9d54b | |||
9195bba7ed | |||
a7e5c99cec | |||
c6cfdf9e9f | |||
09c7eb8318 |
40 changed files with 2075 additions and 944 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -2,4 +2,5 @@ node_modules/
|
|||
frontend/dist/*.js
|
||||
frontend/.js
|
||||
underbbs
|
||||
underbbs-cli
|
||||
__debug_*
|
34
README.md
34
README.md
|
@ -1,27 +1,43 @@
|
|||
# underBBS
|
||||
|
||||
underBBS is a platform-agnostic messaging and social media client (feat consultation and motivational support from
|
||||
miggymofongo!!!)
|
||||
underBBS is a protocol-agnostic decentralized social media client and toolkit
|
||||
|
||||
## design
|
||||
|
||||
`underbbs` can run in two modes depending on its executable name:
|
||||
|
||||
### web client
|
||||
|
||||
`underbbs` supports multiple simultaneous account logins, mediating them for each user through a gateway server that handles all protocol-specific logic via `adapter`s and streaming content to the user through a single websocket connection with a singular data interface.
|
||||
|
||||
each distinct `adapter` connection/configuration is represented in the frontend as a tab, and using the websocket's event-driven javascript interface with web components we can simply either store the data or tell the currently visible adapter that it might need to respond to the new data
|
||||
adapters receive commands via a quartzgun web API and send data back on their shared websocket connection; when data comes in on the websocket, `underbbs` will save it and then notify any relevant web components that the data has changed.
|
||||
|
||||
adapters receive commands via a quartzgun web API and send data back on their shared websocket connection
|
||||
### CLI
|
||||
|
||||
`underbbs-cli` pulls adapter credentials from `~/.config/underbbs/cli.conf` and accepts commands on individual adapters, printing data to standard output.
|
||||
|
||||
## building and running
|
||||
|
||||
requirements are
|
||||
|
||||
- go 1.22
|
||||
- any recent nodejs that can do `typescript` and `webpack` 5
|
||||
- go 1.22 (for the backend)
|
||||
- any recent nodejs that can do `typescript` and `webpack` 5 (for the frontend)
|
||||
|
||||
from the project root:
|
||||
|
||||
1. `./build.sh front`
|
||||
1. `./build.sh front` (if you will use the web components)
|
||||
2. `./build.sh server`
|
||||
3. `./underbbs`
|
||||
3. `./underbbs` or `./underbbs-cli ADAPTER ACTION ARGS...`
|
||||
|
||||
visit `http://localhost:9090/app`
|
||||
## integrating
|
||||
|
||||
### with the API and web components
|
||||
|
||||
1. fill `Settings._instance` with adapter settings; these will mostly be authentication data (`SettingsElement` illustrates this)
|
||||
2. instantiate whatever components you want on your page with their `data-adapter`, `data-gateway` and `data-target` appropriately set; further docs to come on these
|
||||
3. call `DatagramSocket.connect(GATEWAY)` where `GATEWAY` is the domain of the `underbbs` API. `SettingsElement`'s connect button does this for you.
|
||||
|
||||
### with the CLI
|
||||
|
||||
1. Call the CLI directly from the serverside or locally
|
||||
2. Process any output to your preference
|
||||
|
|
|
@ -1,14 +1,15 @@
|
|||
package adapter
|
||||
|
||||
import (
|
||||
. "forge.lightcrystal.systems/lightcrystal/underbbs/models"
|
||||
. "forge.lightcrystal.systems/nilix/underbbs/models"
|
||||
)
|
||||
|
||||
type Adapter interface {
|
||||
Init(Settings, chan SocketData) error
|
||||
Init(Settings, *chan SocketData) error
|
||||
Stop()
|
||||
Name() string
|
||||
Subscribe(string) []error
|
||||
Subscribe(string, *string) []error
|
||||
Fetch(string, []string) error
|
||||
Do(string) error
|
||||
Do(string, map[string]string) error
|
||||
DefaultSubscriptionFilter() string
|
||||
}
|
||||
|
|
393
adapter/anonAp.go
Normal file
393
adapter/anonAp.go
Normal file
|
@ -0,0 +1,393 @@
|
|||
package adapter
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"forge.lightcrystal.systems/nilix/underbbs/models"
|
||||
"github.com/go-fed/httpsig"
|
||||
)
|
||||
|
||||
type anonAPAdapter struct {
|
||||
data *chan models.SocketData
|
||||
server string
|
||||
client http.Client
|
||||
protocol string
|
||||
nickname string
|
||||
actorKey *crypto.PrivateKey
|
||||
actorURL *string
|
||||
}
|
||||
|
||||
type apLink struct {
|
||||
Href string
|
||||
Rel string
|
||||
Type string
|
||||
}
|
||||
|
||||
type apAttachment struct {
|
||||
MediaType string
|
||||
Type string
|
||||
Name string
|
||||
Summary string
|
||||
Url string
|
||||
}
|
||||
|
||||
type apTag struct {
|
||||
Href string
|
||||
Name string
|
||||
Type string
|
||||
}
|
||||
|
||||
type apIcon struct {
|
||||
MediaType string
|
||||
Type string
|
||||
Url string
|
||||
}
|
||||
|
||||
type apKey struct {
|
||||
Id string
|
||||
Owner string
|
||||
PublicKeyPem string
|
||||
}
|
||||
|
||||
type apActor struct {
|
||||
Icon *apIcon `json:"icon",omitempty`
|
||||
Id string
|
||||
Name string
|
||||
PreferredUsername string
|
||||
Summary string
|
||||
Url string
|
||||
PublicKey ApKey
|
||||
}
|
||||
|
||||
type ApActor apActor
|
||||
type ApKey apKey
|
||||
|
||||
type apOutbox struct {
|
||||
OrderedItems []interface{} `json:"-"`
|
||||
RawOrderedItems []json.RawMessage `json:"OrderedItems"`
|
||||
}
|
||||
|
||||
func (self *apOutbox) UnmarshalJSON(b []byte) error {
|
||||
type obInternal apOutbox
|
||||
err := json.Unmarshal(b, (*obInternal)(self))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, raw := range self.RawOrderedItems {
|
||||
var i apActivity
|
||||
err := json.Unmarshal(raw, &i)
|
||||
|
||||
var x interface{}
|
||||
switch i.Type {
|
||||
case "Create":
|
||||
fallthrough
|
||||
case "Update":
|
||||
x = &apCreateUpdateActivity{}
|
||||
case "Announce":
|
||||
x = &apAnnounceActivity{}
|
||||
}
|
||||
err = json.Unmarshal(raw, &x)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
self.OrderedItems = append(self.OrderedItems, x)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type apActivity struct {
|
||||
Actor string
|
||||
Id string
|
||||
Type string
|
||||
To string
|
||||
Published string
|
||||
}
|
||||
|
||||
type apCreateUpdateActivity struct {
|
||||
Object apObject
|
||||
apActivity
|
||||
}
|
||||
|
||||
type apAnnounceActivity struct {
|
||||
Object string
|
||||
apActivity
|
||||
}
|
||||
|
||||
type apObject struct {
|
||||
Id string
|
||||
Content string
|
||||
AttributedTo string
|
||||
Context string
|
||||
Conversation string
|
||||
Published string
|
||||
Tag []apTag
|
||||
Attachment []apAttachment
|
||||
To string
|
||||
Url string
|
||||
InReplyTo *string
|
||||
}
|
||||
|
||||
type webFinger struct {
|
||||
Links []apLink
|
||||
}
|
||||
|
||||
func (self *anonAPAdapter) Init(data *chan models.SocketData, server, protocol, nickname string, apSigner *crypto.PrivateKey, apActorURL *string) error {
|
||||
self.data = data
|
||||
self.server = server
|
||||
self.nickname = nickname
|
||||
self.protocol = protocol
|
||||
self.actorKey = apSigner
|
||||
self.actorURL = apActorURL
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *anonAPAdapter) signRequest(req *http.Request) error {
|
||||
prefs := []httpsig.Algorithm{httpsig.RSA_SHA512, httpsig.ED25519}
|
||||
// The "Date" and "Digest" headers must already be set on r, as well as r.URL.
|
||||
headersToSign := []string{httpsig.RequestTarget}
|
||||
signer, _, err := httpsig.NewSigner(prefs, httpsig.DigestSha512, headersToSign, httpsig.Signature, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return signer.SignRequest(self.actorKey, *self.actorURL+"/api/actor", req, nil)
|
||||
}
|
||||
|
||||
func getBodyJson(res *http.Response) []byte {
|
||||
l := res.ContentLength
|
||||
// if the length is unknown, we'll allocate 4k
|
||||
// really we should be using Decoders all around
|
||||
if l < 0 {
|
||||
l = 4096
|
||||
}
|
||||
jsonData := make([]byte, l)
|
||||
res.Body.Read(jsonData)
|
||||
|
||||
return jsonData
|
||||
}
|
||||
|
||||
func (self *anonAPAdapter) makeApRequest(method, url string, data io.Reader) (*http.Response, error) {
|
||||
req, err := http.NewRequest(method, url, data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Accept", "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"")
|
||||
|
||||
if self.actorKey != nil && self.actorURL != nil {
|
||||
self.signRequest(req)
|
||||
}
|
||||
return self.client.Do(req)
|
||||
}
|
||||
|
||||
func (self *anonAPAdapter) toMsg(object apObject) *models.Message {
|
||||
t, err := time.Parse(time.RFC3339, object.Published)
|
||||
if err != nil {
|
||||
t = time.Now()
|
||||
}
|
||||
vis := strings.Split(object.To, "#")
|
||||
if len(vis) > 1 {
|
||||
object.To = vis[1]
|
||||
}
|
||||
m := &models.Message{
|
||||
Datagram: models.Datagram{
|
||||
Id: object.Id,
|
||||
Uri: object.Url,
|
||||
Type: "message",
|
||||
Created: t.UnixMilli(),
|
||||
Updated: nil,
|
||||
Protocol: self.protocol,
|
||||
Adapter: self.nickname,
|
||||
},
|
||||
Author: object.AttributedTo,
|
||||
Content: object.Content,
|
||||
ReplyTo: object.InReplyTo,
|
||||
Visibility: object.To,
|
||||
}
|
||||
|
||||
for _, a := range object.Attachment {
|
||||
m.Attachments = append(m.Attachments, models.Attachment{
|
||||
Src: a.Url,
|
||||
Desc: a.Summary,
|
||||
})
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
func (self *anonAPAdapter) send(data models.SocketData) {
|
||||
if self.data != nil {
|
||||
*self.data <- data
|
||||
} else {
|
||||
fmt.Fprintln(os.Stderr, string(data.ToDatagram()))
|
||||
}
|
||||
}
|
||||
|
||||
func (self *anonAPAdapter) toAuthor(actor apActor) *models.Author {
|
||||
curtime := time.Now().UnixMilli()
|
||||
a := &models.Author{
|
||||
Datagram: models.Datagram{
|
||||
Id: actor.Id,
|
||||
Uri: actor.Url,
|
||||
Type: "author",
|
||||
Created: curtime,
|
||||
Updated: &curtime,
|
||||
Protocol: self.protocol,
|
||||
Adapter: self.nickname,
|
||||
},
|
||||
Name: actor.PreferredUsername,
|
||||
ProfileData: actor.Summary,
|
||||
ProfilePic: actor.Icon.Url,
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func (self *anonAPAdapter) getHostForId(id string) string {
|
||||
if strings.HasPrefix(id, "https://") {
|
||||
idNoScheme := strings.Split(id, "https://")[1]
|
||||
serverNoScheme := strings.Split(idNoScheme, "/")[0]
|
||||
return "https://" + serverNoScheme
|
||||
} else {
|
||||
return "https://" + strings.Split(id, "@")[1]
|
||||
}
|
||||
}
|
||||
|
||||
func (self *anonAPAdapter) normalizeActorId(id string) string {
|
||||
if string([]byte{id[0]}) == "@" {
|
||||
id = id[1:]
|
||||
}
|
||||
if !strings.Contains(id, "@") {
|
||||
// if the id is not a URI, add local server to it
|
||||
if !strings.HasPrefix(id, "https://") {
|
||||
serverNoScheme := strings.Split(self.server, "https://")[1]
|
||||
id = fmt.Sprintf("%s@%s", id, serverNoScheme)
|
||||
}
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func (self *anonAPAdapter) Fetch(etype string, ids []string) error {
|
||||
for _, id := range ids {
|
||||
switch etype {
|
||||
case "author": // webfinger lookup on id
|
||||
normalizedId := self.normalizeActorId(id)
|
||||
fmt.Println(normalizedId)
|
||||
reqHost := self.getHostForId(normalizedId)
|
||||
profile := normalizedId
|
||||
fmt.Println(reqHost)
|
||||
if !strings.HasPrefix(normalizedId, "https://") {
|
||||
res, err := http.Get(reqHost + "/.well-known/webfinger?resource=acct:" + normalizedId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data := getBodyJson(res)
|
||||
wf := webFinger{}
|
||||
json.Unmarshal(data, &wf)
|
||||
|
||||
for _, l := range wf.Links {
|
||||
if l.Rel == "self" {
|
||||
profile = l.Href
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
res, err := self.makeApRequest("GET", profile, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
authorData := getBodyJson(res)
|
||||
actor := apActor{}
|
||||
json.Unmarshal(authorData, &actor)
|
||||
author := self.toAuthor(actor)
|
||||
if author != nil {
|
||||
self.send(author)
|
||||
}
|
||||
case "byAuthor":
|
||||
normalizedId := self.normalizeActorId(id)
|
||||
reqHost := self.getHostForId(normalizedId)
|
||||
profile := normalizedId
|
||||
if !strings.HasPrefix(normalizedId, "https://") {
|
||||
res, err := http.Get(reqHost + "/.well-known/webfinger?resource=acct:" + normalizedId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data := getBodyJson(res)
|
||||
wf := webFinger{}
|
||||
json.Unmarshal(data, &wf)
|
||||
for _, l := range wf.Links {
|
||||
if l.Rel == "self" {
|
||||
profile = l.Href
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
res, err := self.makeApRequest("GET", profile+"/outbox", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
obData := getBodyJson(res)
|
||||
ob := apOutbox{}
|
||||
ob.UnmarshalJSON(obData)
|
||||
for _, i := range ob.OrderedItems {
|
||||
switch a := i.(type) {
|
||||
case *apCreateUpdateActivity:
|
||||
msg := self.toMsg(a.Object)
|
||||
if msg != nil {
|
||||
self.send(msg)
|
||||
}
|
||||
case *apAnnounceActivity:
|
||||
res, err = self.makeApRequest("GET", a.Object, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
objectData := getBodyJson(res)
|
||||
object := apObject{}
|
||||
json.Unmarshal(objectData, &object)
|
||||
ogMsg := self.toMsg(object)
|
||||
// if we couldn't fetch the original, skip it
|
||||
if ogMsg != nil && ogMsg.Author != "" {
|
||||
|
||||
t, err := time.Parse(time.RFC3339, a.Published)
|
||||
if err != nil {
|
||||
t = time.Now()
|
||||
}
|
||||
rt := t.UnixMilli()
|
||||
ogMsg.RenoteId = &a.Id
|
||||
ogMsg.Renoter = &a.Actor
|
||||
ogMsg.RenoteTime = &rt
|
||||
|
||||
self.send(ogMsg)
|
||||
}
|
||||
}
|
||||
}
|
||||
// for each item in outbox, check if it's a Create/Update or an Announce
|
||||
// Create/Update you can directly deserialize the object
|
||||
// if it's an Announce, try to get the object and deserialize it, build a boost out of it
|
||||
case "message":
|
||||
res, err := self.makeApRequest("GET", id, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
objectData := getBodyJson(res)
|
||||
object := apObject{}
|
||||
json.Unmarshal(objectData, &object)
|
||||
message := self.toMsg(object)
|
||||
if message != nil {
|
||||
self.send(message)
|
||||
}
|
||||
case "children":
|
||||
case "convoy":
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
325
adapter/honk.go
Normal file
325
adapter/honk.go
Normal file
|
@ -0,0 +1,325 @@
|
|||
package adapter
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
. "forge.lightcrystal.systems/nilix/underbbs/models"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type donk struct {
|
||||
Desc string
|
||||
URL string
|
||||
}
|
||||
|
||||
type honk struct {
|
||||
ID int
|
||||
Honker string
|
||||
Handles string
|
||||
Oonker string
|
||||
XID string
|
||||
RID *string
|
||||
Noise string
|
||||
Donks []donk
|
||||
Convoy string
|
||||
Public bool
|
||||
Date string
|
||||
}
|
||||
|
||||
type honkResponse struct {
|
||||
Honks []honk `json:"honks"`
|
||||
ChatCount int `json:"chatcount"`
|
||||
MeCount int `json:"mecount"`
|
||||
}
|
||||
|
||||
type HonkAdapter struct {
|
||||
data *chan SocketData
|
||||
nickname string
|
||||
server string
|
||||
username string
|
||||
password string
|
||||
token string
|
||||
apSigner *crypto.PrivateKey
|
||||
apDomain *string
|
||||
|
||||
cache map[string]time.Time
|
||||
maxId int
|
||||
mtx sync.RWMutex
|
||||
|
||||
stop chan bool
|
||||
}
|
||||
|
||||
func (self *HonkAdapter) isAnonymous() bool {
|
||||
return self.token == ""
|
||||
}
|
||||
|
||||
func (self *HonkAdapter) send(data SocketData) {
|
||||
if self.data != nil {
|
||||
*self.data <- data
|
||||
} else {
|
||||
fmt.Fprintln(os.Stdout, string(data.ToDatagram()))
|
||||
}
|
||||
}
|
||||
|
||||
func (self *HonkAdapter) Name() string {
|
||||
return self.nickname
|
||||
}
|
||||
|
||||
func (self *HonkAdapter) Init(settings Settings, data *chan SocketData) error {
|
||||
self.data = data
|
||||
// separate name and server in handle
|
||||
parts := strings.Split(*settings.Handle, "@")
|
||||
self.username = parts[1]
|
||||
self.server = "https://" + parts[2]
|
||||
self.nickname = settings.Nickname
|
||||
self.apSigner = settings.ApSigner
|
||||
|
||||
if settings.Password == nil || *settings.Password == "" {
|
||||
// we're anonymous!
|
||||
return nil
|
||||
}
|
||||
self.password = *settings.Password
|
||||
|
||||
r, err := http.PostForm(self.server+"/dologin", url.Values{
|
||||
"username": []string{self.username},
|
||||
"password": []string{self.password},
|
||||
"gettoken": []string{"1"},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var buf [32]byte
|
||||
_, err = r.Body.Read(buf[:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
self.token = string(buf[:])
|
||||
fmt.Println(self.token)
|
||||
|
||||
self.stop = make(chan bool)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *HonkAdapter) Stop() {
|
||||
close(self.stop)
|
||||
}
|
||||
|
||||
func (self *HonkAdapter) Subscribe(filter string, target *string) []error {
|
||||
if self.isAnonymous() {
|
||||
return nil
|
||||
}
|
||||
if self.stop != nil {
|
||||
close(self.stop)
|
||||
}
|
||||
self.stop = make(chan bool)
|
||||
|
||||
go self.gethonks(filter, target)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *HonkAdapter) gethonks(filter string, target *string) {
|
||||
x := self.stop
|
||||
self.maxId = 0
|
||||
for {
|
||||
select {
|
||||
case _, ok := <-x:
|
||||
if !ok {
|
||||
fmt.Println("stopping! filter was " + filter)
|
||||
|
||||
return
|
||||
}
|
||||
default:
|
||||
fmt.Println("getting honks, filter is " + filter)
|
||||
honkForm := url.Values{
|
||||
"action": []string{"gethonks"},
|
||||
"token": []string{self.token},
|
||||
"page": []string{filter},
|
||||
}
|
||||
if self.maxId != 0 {
|
||||
honkForm["after"] = []string{strconv.FormatInt(int64(self.maxId), 10)}
|
||||
}
|
||||
res, err := http.PostForm(self.server+"/api", honkForm)
|
||||
if err != nil {
|
||||
fmt.Println("fucked up: " + err.Error())
|
||||
self.stop <- true
|
||||
return
|
||||
}
|
||||
hr := honkResponse{}
|
||||
err = json.NewDecoder(res.Body).Decode(&hr)
|
||||
if err != nil {
|
||||
fmt.Println("malformed honks: " + err.Error())
|
||||
self.stop <- true
|
||||
return
|
||||
}
|
||||
for _, h := range hr.Honks {
|
||||
if h.ID > self.maxId {
|
||||
self.maxId = h.ID
|
||||
}
|
||||
msg := self.toMsg(h, target)
|
||||
self.send(msg)
|
||||
}
|
||||
time.Sleep(5 * time.Second)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (self *HonkAdapter) toMsg(h honk, target *string) Message {
|
||||
t, err := time.Parse(time.RFC3339, h.Date)
|
||||
if err != nil {
|
||||
t = time.Now()
|
||||
}
|
||||
tt := t.UnixMilli()
|
||||
|
||||
a := h.Honker
|
||||
if h.Oonker != "" {
|
||||
a = h.Oonker
|
||||
}
|
||||
|
||||
msg := Message{
|
||||
Datagram: Datagram{
|
||||
Id: h.XID,
|
||||
Uri: h.XID,
|
||||
Protocol: "honk",
|
||||
Adapter: self.nickname,
|
||||
Type: "message",
|
||||
Created: tt,
|
||||
},
|
||||
Author: a,
|
||||
Content: h.Noise,
|
||||
ReplyTo: h.RID,
|
||||
Visibility: "Private",
|
||||
}
|
||||
if h.Public {
|
||||
msg.Visibility = "Public"
|
||||
}
|
||||
if h.Oonker != "" {
|
||||
r := fmt.Sprintf("%s/bonk/%d", h.Honker, h.ID)
|
||||
fmt.Println(r)
|
||||
msg.Renoter = &h.Honker
|
||||
msg.RenoteId = &r
|
||||
msg.RenoteTime = &tt
|
||||
}
|
||||
for _, d := range h.Donks {
|
||||
a := Attachment{
|
||||
Src: d.URL,
|
||||
Desc: d.Desc,
|
||||
}
|
||||
msg.Attachments = append(msg.Attachments, a)
|
||||
}
|
||||
if target != nil {
|
||||
msg.Target = target
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
func (self *HonkAdapter) Fetch(etype string, ids []string) error {
|
||||
// honk API is limited, we fall back to the anonymous adapter for fetch ops
|
||||
aaa := anonAPAdapter{}
|
||||
aaa.Init(self.data, self.server, "honk", self.nickname, self.apSigner, self.apDomain)
|
||||
return aaa.Fetch(etype, ids)
|
||||
}
|
||||
|
||||
func (self *HonkAdapter) donk(data map[string]string) error {
|
||||
var b bytes.Buffer
|
||||
w := multipart.NewWriter(&b)
|
||||
fw, err := w.CreateFormField("action")
|
||||
io.Copy(fw, strings.NewReader("honk"))
|
||||
fw, err = w.CreateFormField("token")
|
||||
io.Copy(fw, strings.NewReader(self.token))
|
||||
for k, v := range data {
|
||||
if k == "file" {
|
||||
if fw, err = w.CreateFormFile("donk", "donk"); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
fieldName := "noise"
|
||||
switch k {
|
||||
case "desc":
|
||||
fieldName = "donkdesc"
|
||||
case "content":
|
||||
fieldName = "noise"
|
||||
case "replyto":
|
||||
fieldName = "rid"
|
||||
}
|
||||
if fw, err = w.CreateFormField(fieldName); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := io.Copy(fw, strings.NewReader(v)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
w.Close()
|
||||
|
||||
req, err := http.NewRequest("POST", self.server+"/api", &b)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", w.FormDataContentType())
|
||||
c := http.Client{}
|
||||
res, err := c.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if res.StatusCode < 400 {
|
||||
return nil
|
||||
} else {
|
||||
return errors.New(fmt.Sprintf("status: %d", res.StatusCode))
|
||||
}
|
||||
}
|
||||
|
||||
func (self *HonkAdapter) Do(action string, data map[string]string) error {
|
||||
if self.isAnonymous() {
|
||||
return nil
|
||||
}
|
||||
switch action {
|
||||
case "post":
|
||||
honkForm := url.Values{
|
||||
"action": []string{"honk"},
|
||||
"token": []string{self.token},
|
||||
"noise": []string{data["content"]},
|
||||
}
|
||||
_, exists := data["file"]
|
||||
if exists {
|
||||
return self.donk(data)
|
||||
}
|
||||
|
||||
replyto, exists := data["replyto"]
|
||||
if exists {
|
||||
honkForm["rid"] = []string{replyto}
|
||||
}
|
||||
|
||||
res, err := http.PostForm(self.server+"/api", honkForm)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var buf [256]byte
|
||||
_, err = res.Body.Read(buf[:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(string(buf[:]))
|
||||
default:
|
||||
return errors.New("Do: unknown action")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *HonkAdapter) DefaultSubscriptionFilter() string {
|
||||
return "home"
|
||||
}
|
|
@ -2,12 +2,14 @@ package adapter
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
. "forge.lightcrystal.systems/lightcrystal/underbbs/models"
|
||||
. "forge.lightcrystal.systems/nilix/underbbs/models"
|
||||
madon "github.com/McKael/madon"
|
||||
"log"
|
||||
"os"
|
||||
)
|
||||
|
||||
type MastoAdapter struct {
|
||||
data chan SocketData
|
||||
data *chan SocketData
|
||||
nickname string
|
||||
server string
|
||||
apiKey string
|
||||
|
@ -21,11 +23,19 @@ type MastoAdapter struct {
|
|||
|
||||
var scopes = []string{"read", "write", "follow"}
|
||||
|
||||
func (self *MastoAdapter) send(data SocketData) {
|
||||
if self.data != nil {
|
||||
*self.data <- data
|
||||
} else {
|
||||
fmt.Println(os.Stdout, string(data.ToDatagram()))
|
||||
}
|
||||
}
|
||||
|
||||
func (self *MastoAdapter) Name() string {
|
||||
return self.nickname
|
||||
}
|
||||
|
||||
func (self *MastoAdapter) Init(settings Settings, data chan SocketData) error {
|
||||
func (self *MastoAdapter) Init(settings Settings, data *chan SocketData) error {
|
||||
self.nickname = settings.Nickname
|
||||
self.server = *settings.Server
|
||||
self.apiKey = *settings.ApiKey
|
||||
|
@ -40,7 +50,11 @@ func (self *MastoAdapter) Init(settings Settings, data chan SocketData) error {
|
|||
return err
|
||||
}
|
||||
|
||||
func (self *MastoAdapter) Subscribe(filter string) []error {
|
||||
func (self *MastoAdapter) Stop() {
|
||||
close(self.stop)
|
||||
}
|
||||
|
||||
func (self *MastoAdapter) Subscribe(filter string, target *string) []error {
|
||||
// TODO: decode separate timelines and hashtags
|
||||
// for now, the filter is just the timeline
|
||||
|
||||
|
@ -61,8 +75,9 @@ func (self *MastoAdapter) Subscribe(filter string) []error {
|
|||
return []error{err}
|
||||
}
|
||||
go func() {
|
||||
for e := range self.events {
|
||||
fmt.Println("event: %s !!!", e.Event)
|
||||
ee := self.events
|
||||
for e := range ee {
|
||||
log.Printf("event: %s !!!", e.Event)
|
||||
switch e.Event {
|
||||
case "error":
|
||||
case "update":
|
||||
|
@ -77,7 +92,7 @@ func (self *MastoAdapter) Subscribe(filter string) []error {
|
|||
msg = self.mastoUpdateToMessage(v)
|
||||
}
|
||||
if msg != nil {
|
||||
self.data <- msg
|
||||
self.send(msg)
|
||||
}
|
||||
case "notification":
|
||||
case "delete":
|
||||
|
@ -94,7 +109,7 @@ func (self *MastoAdapter) Fetch(etype string, ids []string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (self *MastoAdapter) Do(action string) error {
|
||||
func (self *MastoAdapter) Do(action string, data map[string]string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
|
@ -2,20 +2,22 @@ package adapter
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
. "forge.lightcrystal.systems/lightcrystal/underbbs/models"
|
||||
. "forge.lightcrystal.systems/nilix/underbbs/models"
|
||||
"github.com/yitsushi/go-misskey"
|
||||
mkcore "github.com/yitsushi/go-misskey/core"
|
||||
mkm "github.com/yitsushi/go-misskey/models"
|
||||
n "github.com/yitsushi/go-misskey/services/notes"
|
||||
tl "github.com/yitsushi/go-misskey/services/notes/timeline"
|
||||
users "github.com/yitsushi/go-misskey/services/users"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type MisskeyAdapter struct {
|
||||
data chan SocketData
|
||||
data *chan SocketData
|
||||
nickname string
|
||||
server string
|
||||
apiKey string
|
||||
|
@ -31,19 +33,31 @@ type MisskeyAdapter struct {
|
|||
stop chan bool
|
||||
}
|
||||
|
||||
func (self *MisskeyAdapter) send(data SocketData) {
|
||||
if self.data != nil {
|
||||
*self.data <- data
|
||||
} else {
|
||||
fmt.Fprintln(os.Stderr, string(data.ToDatagram()))
|
||||
}
|
||||
}
|
||||
|
||||
func (self *MisskeyAdapter) Name() string {
|
||||
return self.nickname
|
||||
}
|
||||
|
||||
func (self *MisskeyAdapter) Init(settings Settings, data chan SocketData) error {
|
||||
fmt.Println("initializing misskey adapter")
|
||||
func (self *MisskeyAdapter) Stop() {
|
||||
close(self.stop)
|
||||
}
|
||||
|
||||
func (self *MisskeyAdapter) Init(settings Settings, data *chan SocketData) error {
|
||||
log.Print("initializing misskey adapter")
|
||||
|
||||
self.nickname = settings.Nickname
|
||||
self.server = *settings.Server
|
||||
self.apiKey = *settings.ApiKey
|
||||
self.data = data
|
||||
|
||||
fmt.Println("getting ready to initialize internal client")
|
||||
log.Print("getting ready to initialize internal client")
|
||||
|
||||
client, err := misskey.NewClientWithOptions(
|
||||
misskey.WithAPIToken(self.apiKey),
|
||||
|
@ -51,10 +65,10 @@ func (self *MisskeyAdapter) Init(settings Settings, data chan SocketData) error
|
|||
)
|
||||
|
||||
if err != nil {
|
||||
fmt.Println(err.Error())
|
||||
log.Print(err.Error())
|
||||
return err
|
||||
}
|
||||
fmt.Println("misskey client initialized")
|
||||
log.Print("misskey client initialized")
|
||||
self.mk = client
|
||||
|
||||
self.cache = make(map[string]time.Time)
|
||||
|
@ -62,7 +76,7 @@ func (self *MisskeyAdapter) Init(settings Settings, data chan SocketData) error
|
|||
return nil
|
||||
}
|
||||
|
||||
func (self *MisskeyAdapter) Subscribe(filter string) []error {
|
||||
func (self *MisskeyAdapter) Subscribe(filter string, target *string) []error {
|
||||
// misskey streaming API is undocumented....
|
||||
// we could try to reverse engineer it by directly connecting to the websocket???
|
||||
// alternatively, we can poll timelines, mentions, etc with a cancellation channel,
|
||||
|
@ -92,9 +106,11 @@ func (self *MisskeyAdapter) poll() {
|
|||
var notesService *n.Service
|
||||
var timelineService *tl.Service
|
||||
|
||||
x := self.stop
|
||||
|
||||
for {
|
||||
select {
|
||||
case _, ok := <-self.stop:
|
||||
case _, ok := <-x:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
@ -119,10 +135,10 @@ func (self *MisskeyAdapter) poll() {
|
|||
Limit: 100,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Println(err.Error())
|
||||
log.Print(err.Error())
|
||||
}
|
||||
if merr != nil {
|
||||
fmt.Println(merr.Error())
|
||||
log.Print(merr.Error())
|
||||
}
|
||||
|
||||
// check the cache for everything we just collected
|
||||
|
@ -131,13 +147,13 @@ func (self *MisskeyAdapter) poll() {
|
|||
for _, n := range notes {
|
||||
msg := self.toMessageIfNew(n)
|
||||
if msg != nil {
|
||||
self.data <- msg
|
||||
self.send(msg)
|
||||
}
|
||||
}
|
||||
for _, n := range mentions {
|
||||
msg := self.toMessageIfNew(n)
|
||||
if msg != nil {
|
||||
self.data <- msg
|
||||
self.send(msg)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -150,7 +166,7 @@ func (self *MisskeyAdapter) poll() {
|
|||
Limit: 100,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Println(err.Error())
|
||||
log.Print(err.Error())
|
||||
}
|
||||
for _, n := range notes {
|
||||
msg := self.toMessageIfNew(n)
|
||||
|
@ -158,7 +174,7 @@ func (self *MisskeyAdapter) poll() {
|
|||
latest = &probenote[0].CreatedAt
|
||||
break
|
||||
}
|
||||
self.data <- msg
|
||||
self.send(msg)
|
||||
}
|
||||
if *latest == probenote[0].CreatedAt {
|
||||
break
|
||||
|
@ -216,6 +232,7 @@ func (self *MisskeyAdapter) toMessage(n mkm.Note, bustCache bool) *Message {
|
|||
ReplyTo: n.ReplyID,
|
||||
ReplyCount: int(n.RepliesCount),
|
||||
Replies: []string{},
|
||||
RenoteId: (*string)(n.RenoteID),
|
||||
}
|
||||
|
||||
for _, f := range n.Files {
|
||||
|
@ -253,7 +270,7 @@ func (self *MisskeyAdapter) toAuthor(usr mkm.User, bustCache bool) *Author {
|
|||
}
|
||||
|
||||
if bustCache || !exists || (updated != nil && timestamp.Before(time.UnixMilli(*updated))) || timestamp.Before(*usr.CreatedAt) {
|
||||
fmt.Println("converting author: " + usr.ID)
|
||||
log.Print("converting author: " + usr.ID)
|
||||
if usr.UpdatedAt != nil {
|
||||
self.cache[authorId] = *usr.UpdatedAt
|
||||
} else {
|
||||
|
@ -283,6 +300,8 @@ func (self *MisskeyAdapter) toAuthor(usr mkm.User, bustCache bool) *Author {
|
|||
func (self *MisskeyAdapter) Fetch(etype string, ids []string) error {
|
||||
for _, id := range ids {
|
||||
switch etype {
|
||||
case "byAuthor":
|
||||
// fetch notes by this author
|
||||
case "message":
|
||||
data, err := self.mk.Notes().Show(id)
|
||||
if err != nil {
|
||||
|
@ -290,7 +309,7 @@ func (self *MisskeyAdapter) Fetch(etype string, ids []string) error {
|
|||
} else {
|
||||
msg := self.toMessage(data, true)
|
||||
if msg != nil {
|
||||
self.data <- msg
|
||||
self.send(msg)
|
||||
}
|
||||
}
|
||||
case "children":
|
||||
|
@ -304,7 +323,7 @@ func (self *MisskeyAdapter) Fetch(etype string, ids []string) error {
|
|||
for _, n := range data {
|
||||
msg := self.toMessage(n, true)
|
||||
if msg != nil {
|
||||
self.data <- msg
|
||||
self.send(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -319,7 +338,7 @@ func (self *MisskeyAdapter) Fetch(etype string, ids []string) error {
|
|||
for _, n := range data {
|
||||
msg := self.toMessage(n, true)
|
||||
if msg != nil {
|
||||
self.data <- msg
|
||||
self.send(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -337,7 +356,6 @@ func (self *MisskeyAdapter) Fetch(etype string, ids []string) error {
|
|||
hostPtr = &host
|
||||
}
|
||||
|
||||
// fmt.Printf("attempting user resolution: @%s@%s\n", user, host)
|
||||
data, err := self.mk.Users().Show(users.ShowRequest{
|
||||
Username: &user,
|
||||
Host: hostPtr,
|
||||
|
@ -347,16 +365,16 @@ func (self *MisskeyAdapter) Fetch(etype string, ids []string) error {
|
|||
} else {
|
||||
a := self.toAuthor(data, false)
|
||||
if a != nil {
|
||||
self.data <- a
|
||||
self.send(a)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *MisskeyAdapter) Do(action string) error {
|
||||
func (self *MisskeyAdapter) Do(action string, data map[string]string) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
|
@ -5,23 +5,33 @@ import (
|
|||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
. "forge.lightcrystal.systems/lightcrystal/underbbs/models"
|
||||
. "forge.lightcrystal.systems/nilix/underbbs/models"
|
||||
nostr "github.com/nbd-wtf/go-nostr"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type NostrAdapter struct {
|
||||
data chan SocketData
|
||||
data *chan SocketData
|
||||
nickname string
|
||||
privkey string
|
||||
relays []*nostr.Relay
|
||||
}
|
||||
|
||||
func (self *NostrAdapter) send(data SocketData) {
|
||||
if self.data != nil {
|
||||
*self.data <- data
|
||||
} else {
|
||||
fmt.Fprintln(os.Stdout, string(data.ToDatagram()))
|
||||
}
|
||||
}
|
||||
|
||||
func (self *NostrAdapter) Name() string {
|
||||
return self.nickname
|
||||
}
|
||||
|
||||
func (self *NostrAdapter) Init(settings Settings, data chan SocketData) error {
|
||||
func (self *NostrAdapter) Init(settings Settings, data *chan SocketData) error {
|
||||
self.nickname = settings.Nickname
|
||||
self.privkey = *settings.PrivKey
|
||||
self.data = data
|
||||
|
@ -38,7 +48,12 @@ func (self *NostrAdapter) Init(settings Settings, data chan SocketData) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (self *NostrAdapter) Subscribe(filter string) []error {
|
||||
func (self *NostrAdapter) Stop() {
|
||||
// nostr has native streaming so we don't need to do anything in this method
|
||||
return
|
||||
}
|
||||
|
||||
func (self *NostrAdapter) Subscribe(filter string, target *string) []error {
|
||||
var filters nostr.Filters
|
||||
err := json.Unmarshal([]byte(filter), &filters)
|
||||
if err != nil {
|
||||
|
@ -47,35 +62,32 @@ func (self *NostrAdapter) Subscribe(filter string) []error {
|
|||
|
||||
errs := make([]error, 0)
|
||||
|
||||
fmt.Print("unmarshalled filter from json; iterating through relays to subscribe..")
|
||||
log.Print("unmarshalled filter from json; iterating through relays to subscribe...")
|
||||
|
||||
for _, r := range self.relays {
|
||||
fmt.Print(".")
|
||||
sub, err := r.Subscribe(context.Background(), filters)
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
} else {
|
||||
go func() {
|
||||
for ev := range sub.Events {
|
||||
fmt.Print("!")
|
||||
// try sequentially to encode into an underbbs object
|
||||
// and send it to the appropriate channel
|
||||
m, err := self.nostrEventToMsg(ev)
|
||||
if err == nil {
|
||||
self.data <- m
|
||||
self.send(m)
|
||||
}
|
||||
|
||||
}
|
||||
}()
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
fmt.Println("subscription operation completed with errors")
|
||||
log.Print("subscription operation completed with errors")
|
||||
return errs
|
||||
}
|
||||
fmt.Println("subscription operation completed without errors")
|
||||
log.Print("subscription operation completed without errors")
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@ -83,7 +95,7 @@ func (self *NostrAdapter) Fetch(etype string, ids []string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (self *NostrAdapter) Do(action string) error {
|
||||
func (self *NostrAdapter) Do(action string, data map[string]string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
1
build.sh
1
build.sh
|
@ -21,6 +21,7 @@ case "$1" in
|
|||
server)
|
||||
go mod tidy
|
||||
go build
|
||||
cp underbbs underbbs-cli
|
||||
;;
|
||||
*)
|
||||
echo "usage: ${0} <front|server>"
|
||||
|
|
76
cli/cli.go
Normal file
76
cli/cli.go
Normal file
|
@ -0,0 +1,76 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"forge.lightcrystal.systems/nilix/underbbs/adapter"
|
||||
"forge.lightcrystal.systems/nilix/underbbs/models"
|
||||
)
|
||||
|
||||
func Process(gsettings models.GlobalSettings, args ...string) error {
|
||||
adapterName := args[0]
|
||||
args = args[1:]
|
||||
var s *models.Settings
|
||||
|
||||
for _, x := range gsettings.Adapters {
|
||||
if x.Nickname == adapterName {
|
||||
s = &x
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if s == nil {
|
||||
return errors.New("given adapter " + adapterName + " is not in the config file")
|
||||
}
|
||||
|
||||
// instantiate adapter with config
|
||||
var a adapter.Adapter
|
||||
switch s.Protocol {
|
||||
case "nostr":
|
||||
a = &adapter.NostrAdapter{}
|
||||
case "mastodon":
|
||||
a = &adapter.MastoAdapter{}
|
||||
case "misskey":
|
||||
a = &adapter.MisskeyAdapter{}
|
||||
case "honk":
|
||||
a = &adapter.HonkAdapter{}
|
||||
default:
|
||||
break
|
||||
}
|
||||
a.Init(*s, nil)
|
||||
// process remaining args and execute
|
||||
|
||||
switch args[0] {
|
||||
case "fetch":
|
||||
a.Fetch(args[1], args[2:])
|
||||
case "do":
|
||||
data := map[string]string{}
|
||||
for _, a := range args[2:] {
|
||||
if !strings.Contains(a, "=") {
|
||||
return errors.New("args are in the form KEY=VALUE")
|
||||
} else {
|
||||
aa := strings.Split(a, "=")
|
||||
k := aa[0]
|
||||
v := strings.Join(aa[1:], "=")
|
||||
if k == "file" {
|
||||
b, err := ioutil.ReadFile(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
v = string(b)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
data[k] = v
|
||||
}
|
||||
}
|
||||
a.Do(args[1], data)
|
||||
default:
|
||||
log.Print(args)
|
||||
}
|
||||
return nil
|
||||
}
|
55
config/config.go
Normal file
55
config/config.go
Normal file
|
@ -0,0 +1,55 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
|
||||
"forge.lightcrystal.systems/nilix/underbbs/models"
|
||||
)
|
||||
|
||||
func getConfigLocation() string {
|
||||
home := os.Getenv("HOME")
|
||||
appdata := os.Getenv("APPDATA")
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
return filepath.Join(appdata, "underbbs")
|
||||
case "darwin":
|
||||
return filepath.Join(home, "Library", "Application Support", "underbbs")
|
||||
case "plan9":
|
||||
return filepath.Join(home, "lib", "underbbs")
|
||||
default:
|
||||
return filepath.Join(home, ".config", "underbbs")
|
||||
}
|
||||
}
|
||||
|
||||
func ensureConfigLocationExists() {
|
||||
fileInfo, err := os.Stat(getConfigLocation())
|
||||
|
||||
if os.IsNotExist(err) {
|
||||
os.MkdirAll(getConfigLocation(), os.ModePerm)
|
||||
} else if !fileInfo.IsDir() {
|
||||
panic("Config location is not a directory!")
|
||||
}
|
||||
}
|
||||
|
||||
func LoadConfig() (models.GlobalSettings, error) {
|
||||
var settings models.GlobalSettings
|
||||
|
||||
ensureConfigLocationExists()
|
||||
cfgdir := getConfigLocation()
|
||||
|
||||
// get config from config fle based on adapter
|
||||
content, err := ioutil.ReadFile(filepath.Join(cfgdir, "config.json"))
|
||||
if err != nil {
|
||||
return settings, err
|
||||
}
|
||||
|
||||
err = json.Unmarshal(content, &settings)
|
||||
if err != nil {
|
||||
return settings, err
|
||||
}
|
||||
return settings, nil
|
||||
}
|
22
frontend/dist/index.html
vendored
22
frontend/dist/index.html
vendored
|
@ -1,22 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en-US">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>underBBS</title>
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<link rel="shortcut icon" href="./favicon.png"/>
|
||||
<link href="./style.css" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<noscript><div id="noscript_container">
|
||||
JS app lol
|
||||
</div>
|
||||
</noscript>
|
||||
<div id="err_wrapper" style='display:none'><button id="err_close" onclick="closeErr()">x</button><div id="err_div"></div></div>
|
||||
<nav id="tabbar_injectparent">
|
||||
</nav>
|
||||
<main id="mainarea_injectparent">
|
||||
</main>
|
||||
</body>
|
||||
<script src="./main.js" type="application/javascript"></script>
|
||||
</html>
|
46
frontend/dist/style.css
vendored
46
frontend/dist/style.css
vendored
|
@ -1,11 +1,3 @@
|
|||
:root {
|
||||
--bg_color: #000000;
|
||||
--fg_color: #ccc;
|
||||
--main_color: #1f9b92;
|
||||
--sub_color: #002b36;
|
||||
--err_color: #DC143C;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
padding: 0;
|
||||
|
@ -17,17 +9,6 @@
|
|||
background: var(--bg_color);
|
||||
}
|
||||
|
||||
* { scrollbar-color:var(--main_color) var(--sub_color); }
|
||||
*::-webkit-scrollbar { width:6px;height:6px; }
|
||||
*::-webkit-scrollbar-track { background: var(--sub_color);}
|
||||
*::-webkit-scrollbar-thumb { background:var(--main_color);border-radius:0;border:none; }
|
||||
*::-webkit-scrollbar-corner { background:var(--sub_color); }
|
||||
*::selection { background-color:var(--main_color);color:var(--bg_color);text-decoration:none;text-shadow:none; }
|
||||
|
||||
body {
|
||||
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--main_color);
|
||||
}
|
||||
|
@ -44,24 +25,23 @@ input {
|
|||
color: var(--err_color);
|
||||
}
|
||||
|
||||
nav ul li {
|
||||
display: inline;
|
||||
padding: 0.5em;
|
||||
underbbs-message, underbbs-profile {
|
||||
max-width: 70ch;
|
||||
display: block;
|
||||
}
|
||||
|
||||
underbbs-message img {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
underbbs-profile img {
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
nav {
|
||||
padding: 1em;
|
||||
}
|
||||
|
||||
nav ul li a {
|
||||
text-decoration: none;
|
||||
border-bottom: solid 1px var(--bg_color);
|
||||
}
|
||||
|
||||
.tabbar_current {
|
||||
border-bottom: solid 1px var(--main_color);
|
||||
}
|
||||
|
||||
main {
|
||||
padding: 2em;
|
||||
underbbs-message .message_metadata span {
|
||||
display: block;
|
||||
}
|
|
@ -1,271 +0,0 @@
|
|||
import util from "./util"
|
||||
|
||||
import { Message, Author } from "./message"
|
||||
import { MessageThread } from "./thread"
|
||||
import { AdapterState } from "./adapter"
|
||||
import { BatchTimer } from "./batch-timer"
|
||||
|
||||
export class AdapterElement extends HTMLElement {
|
||||
static observedAttributes = [ "data-latest", "data-view", "data-viewing" ]
|
||||
|
||||
private _latest: string = "" ;
|
||||
private _view: string = "";
|
||||
private _name: string = ""
|
||||
private _viewing: string = "";
|
||||
|
||||
private _convoyBatchTimer = new BatchTimer((ids: string[])=>{
|
||||
let url = `/api/adapters/${this._name}/fetch?entity_type=convoy`;
|
||||
for (let id of ids) {
|
||||
url += `&entity_id=${id}`;
|
||||
}
|
||||
util.authorizedFetch("GET", url, null)
|
||||
});
|
||||
|
||||
// TODO: use visibility of the thread to organize into DMs and public threads
|
||||
private _threads: MessageThread[] = [];
|
||||
private _orphans: Message[] = [];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
const name = this.getAttribute("data-name");
|
||||
this._name = name ?? "";
|
||||
this.buildThreads();
|
||||
}
|
||||
|
||||
attributeChangedCallback(attr: string, prev: string, next: string) {
|
||||
switch (attr) {
|
||||
case "data-viewing":
|
||||
if (next != prev) {
|
||||
this._viewing = next;
|
||||
}
|
||||
// probably only needed for profile view; we might be able to move this to child components
|
||||
// keep it simple for now, but eventually we will need a way to force _view to update
|
||||
break;
|
||||
case "data-view":
|
||||
if (!next) {
|
||||
this._view = "index";
|
||||
} else {
|
||||
this._view = next;
|
||||
}
|
||||
switch (this._view) {
|
||||
case "index":
|
||||
this.setIdxView();
|
||||
this.populateIdxView();
|
||||
break;
|
||||
case "thread":
|
||||
this.setThreadView();
|
||||
this.populateThreadView();
|
||||
break;
|
||||
case "profile":
|
||||
this.setProfileView();
|
||||
this.populateProfileView();
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case "data-latest":
|
||||
let datastore = AdapterState._instance.data.get(this._name);
|
||||
if (!datastore) {
|
||||
// this shouldn't be possible
|
||||
return;
|
||||
}
|
||||
if (prev != next && next) {
|
||||
this._latest = next;
|
||||
}
|
||||
const latestMsg = datastore.messages.get(this._latest);
|
||||
if (latestMsg) {
|
||||
const rootId = this.placeMsg(this._latest);
|
||||
// if rootId is null, this is an orphan and we don't need to actually do any UI updates yet
|
||||
if (rootId) {
|
||||
switch (this._view) {
|
||||
case "index":
|
||||
this.updateIdxView(this._latest, rootId);
|
||||
break;
|
||||
case "thread":
|
||||
// if the the message is part of this thread, update it
|
||||
case "profile":
|
||||
// if the message is from this user, show it in their profile
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const latestAuthor = datastore.profileCache.get(this._latest);
|
||||
if (latestAuthor) {
|
||||
switch (this._view) {
|
||||
case "index":
|
||||
const threadsByThisAuthor = this._threads.filter(t=>t.root.data.author == this._latest);
|
||||
for (let t of threadsByThisAuthor) {
|
||||
let tse = this.querySelector(`underbbs-thread-summary[data-msg='${t.root.data.id}']`)
|
||||
if (tse) {
|
||||
tse.setAttribute("data-author", this._latest);
|
||||
}
|
||||
}
|
||||
case "thread":
|
||||
case "profile":
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
setIdxView() {
|
||||
this.innerHTML = "<ul id='dm_list'></ul><ul id='public_list'></ul>"
|
||||
}
|
||||
|
||||
setThreadView() {
|
||||
let html = `<a href="#${this._name}">← return to index</a>`;
|
||||
html += "<ul id='msg_list'></ul>";
|
||||
this.innerHTML = html;
|
||||
}
|
||||
|
||||
setProfileView() {
|
||||
let profile_bar = util.$("profile_bar");
|
||||
if (profile_bar) {
|
||||
// clear any previous data
|
||||
} else {
|
||||
// insert the profileSidebar into the dom
|
||||
}
|
||||
}
|
||||
|
||||
populateIdxView() {
|
||||
// skip dm list for now
|
||||
// public/unified list
|
||||
const pl = util.$("public_list");
|
||||
if (pl) {
|
||||
let html = "";
|
||||
for (const t of this._threads.sort((a: MessageThread, b: MessageThread) => b.latest - a.latest)) {
|
||||
html +=`<li><underbbs-thread-summary data-len="${t.messageCount}" data-adapter="${t.root.data.adapter}" data-msg="${t.root.data.id}" data-created="${t.created}" data-latest="${t.latest}"></underbbs-thread-summary></li>`;
|
||||
}
|
||||
pl.innerHTML = html;
|
||||
}
|
||||
}
|
||||
|
||||
updateIdxView(latest: string, rootId: string) {
|
||||
const threadSelector = `underbbs-thread-summary[data-msg='${rootId}']`
|
||||
const existingThread = document.querySelector(threadSelector);
|
||||
const thread = this._threads.find(t=>t.root.data.id == rootId);
|
||||
if (existingThread && thread) {
|
||||
debugger;
|
||||
existingThread.setAttribute("data-latest", `${thread.latest}`);
|
||||
existingThread.setAttribute("data-len", `${thread.messageCount}`);
|
||||
existingThread.setAttribute("data-new", "true");
|
||||
} else {
|
||||
// unified/public list for now
|
||||
const pl = util.$("public_list");
|
||||
if (pl && thread) {
|
||||
const li = document.createElement("li");
|
||||
li.innerHTML = `<underbbs-thread-summary data-len="1" data-adapter="${thread.root.data.adapter}" data-msg="${thread.root.data.id}" data-latest="${thread.latest}" data-created="${thread.created}"></underbbs-thread-summary>`;
|
||||
let nextThread: Element | null = null;
|
||||
for (let i = 0; i < pl.children.length; i++) {
|
||||
const c = pl.children.item(i);
|
||||
const latest = c?.children.item(0)?.getAttribute("data-latest")
|
||||
if (latest && parseInt(latest) < thread.latest) {
|
||||
nextThread = c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (nextThread) {
|
||||
nextThread.insertAdjacentElement('beforebegin', li);
|
||||
} else {
|
||||
pl.append(li);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
populateThreadView() {
|
||||
}
|
||||
|
||||
populateProfileView() {
|
||||
}
|
||||
|
||||
buildThreads() {
|
||||
const datastore = AdapterState._instance.data.get(this._name);
|
||||
if (!datastore) {
|
||||
util.errMsg(this._name + " has no datastore!");
|
||||
return;
|
||||
}
|
||||
// make multiple passes over the store until every message is either
|
||||
// placed in a thread, or orphaned and waiting for its parent to be returned
|
||||
do{
|
||||
for (let k of datastore.messages.keys()) {
|
||||
this.placeMsg(k);
|
||||
}
|
||||
} while (this._threads.reduce((sum: number, thread: MessageThread)=>{
|
||||
return sum + thread.messageCount;
|
||||
}, 0) + this._orphans.length < datastore.messages.size);
|
||||
}
|
||||
|
||||
placeMsg(k: string): string | null {
|
||||
const datastore = AdapterState._instance.data.get(this._name);
|
||||
if (!datastore) {
|
||||
util.errMsg(this._name + " has no datastore!");
|
||||
return null;
|
||||
}
|
||||
const msg = datastore.messages.get(k);
|
||||
if (!msg) {
|
||||
util.errMsg(`message [${this._name}:${k}] doesn't exist`);
|
||||
return null;
|
||||
}
|
||||
for (let t of this._threads) {
|
||||
// avoid processing nodes again on subsequent passes
|
||||
if (!msg || t.findNode(t.root, msg.id)) {
|
||||
return null;
|
||||
}
|
||||
if (msg.replyTo) {
|
||||
let x = t.addReply(msg.replyTo, msg);
|
||||
if (x) {
|
||||
// after adding, we try to adopt some orphans
|
||||
const orphanChildren = this._orphans.filter(m=>m.replyTo == k);
|
||||
for (let o of orphanChildren) {
|
||||
let adopted = this.placeMsg(o.id);
|
||||
if (adopted) {
|
||||
this._orphans.splice(this._orphans.indexOf(o), 1);
|
||||
}
|
||||
}
|
||||
return t.root.data.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
// if we made it this far, this message doesn't go in any existing thread
|
||||
|
||||
// if it doesn't have a parent, we can make a new thread with it
|
||||
if (!msg.replyTo) {
|
||||
this._threads.push(new MessageThread(msg));
|
||||
// after adding, we try to adopt some orphans
|
||||
const orphanChildren = this._orphans.filter(m=>m.replyTo == k);
|
||||
for (let o of orphanChildren) {
|
||||
let adopted = this.placeMsg(o.id);
|
||||
if (adopted) {
|
||||
this._orphans.splice(this._orphans.indexOf(o), 1);
|
||||
}
|
||||
}
|
||||
return msg.id;
|
||||
}
|
||||
|
||||
// then, we should check if its parent is an orphan
|
||||
const orphanedParent = this._orphans.find(o=>o.id == msg.replyTo);
|
||||
if (orphanedParent) {
|
||||
// then, try to place them both
|
||||
|
||||
if (this.placeMsg(orphanedParent.id)) {
|
||||
this._orphans.splice(this._orphans.indexOf(orphanedParent), 1);
|
||||
return this.placeMsg(k);
|
||||
}
|
||||
}
|
||||
|
||||
// otherwise we can orphan it and try to fill it in later
|
||||
if (this._orphans.filter(o=>o.id == msg.id).length == 0) {
|
||||
this._orphans.push(msg);
|
||||
if (msg.replyTo) {
|
||||
this._convoyBatchTimer.queue(k, 2000);
|
||||
}
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
101
frontend/ts/create-message-element.ts
Normal file
101
frontend/ts/create-message-element.ts
Normal file
|
@ -0,0 +1,101 @@
|
|||
import { Doer } from './doer'
|
||||
|
||||
export class CreateMessageElement extends HTMLElement {
|
||||
private _gateway: string = "";
|
||||
private _adapter: string = ""
|
||||
private _doer: Doer | null = null;
|
||||
static observedAttributes = [ "data-replyto" ];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.innerHTML = `<form onsubmit="return false"><input readonly class="createmessage_replyto"></input><textarea class="createmessage_content"></textarea><details><summary>attachment?</summary><input type="file" class="createmessage_file"></input><input class="createmessage_desc"></input></details><input type="submit" value="post"></input></form>`
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this._gateway = this.getAttribute("data-gateway") ?? "";
|
||||
this._adapter = this.getAttribute("data-adapter") ?? "";
|
||||
this._doer = new Doer(this._gateway, this._adapter);
|
||||
|
||||
const postBtn = this.querySelector(`input[type="submit"]`);
|
||||
if (postBtn) {
|
||||
postBtn.addEventListener("click", this.doPost.bind(this));
|
||||
}
|
||||
}
|
||||
|
||||
attributeChangedCallback(attr: string, prev: string, next: string) {
|
||||
switch (attr) {
|
||||
// when we implement replies, we'll come back here
|
||||
case "data-replyto":
|
||||
const e = this.querySelector(".createmessage_replyto") as HTMLInputElement;
|
||||
if (e) {
|
||||
e.value = next;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
resetInput(r: Response) {
|
||||
if (r.ok) {
|
||||
const f = this.querySelector("form") as HTMLFormElement;
|
||||
if (f) {
|
||||
f.reset();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
doPost() {
|
||||
const msgContent = this.querySelector(".createmessage_content") as HTMLTextAreaElement;
|
||||
const msgAttachment = this.querySelector(".createmessage_file") as HTMLInputElement;
|
||||
const msgAttachmentDesc = this.querySelector(".createmessage_desc") as HTMLInputElement;
|
||||
const msgReplyTo = this.querySelector(".createmessage_replyto") as HTMLInputElement;
|
||||
|
||||
const doReq: any = {};
|
||||
doReq.action = "post";
|
||||
doReq.content = msgContent.value ?? "";
|
||||
|
||||
if (msgReplyTo && msgReplyTo.value) {
|
||||
doReq.replyto = msgReplyTo.value;
|
||||
}
|
||||
|
||||
if (msgAttachment.files && msgAttachment.files[0]) {
|
||||
const r = new FileReader();
|
||||
const self = this;
|
||||
|
||||
doReq.desc = msgAttachmentDesc.value ?? "";
|
||||
r.onload = ()=>{
|
||||
if (self && self._doer) {
|
||||
doReq.file = r.result;
|
||||
self._doer.do(doReq)
|
||||
.then(r=>{
|
||||
if (r.ok) {
|
||||
msgContent.value = '';
|
||||
msgAttachment.value = '';
|
||||
msgAttachmentDesc.value = '';
|
||||
msgReplyTo.value = '';
|
||||
}
|
||||
})
|
||||
.catch(err=>{
|
||||
// try to display an error locally on the component
|
||||
});
|
||||
}
|
||||
}
|
||||
r.readAsDataURL(msgAttachment.files[0]);
|
||||
return;
|
||||
}
|
||||
if (this._doer) {
|
||||
this._doer.do(doReq)
|
||||
.then(r=>{
|
||||
if (r.ok) {
|
||||
msgContent.value = '';
|
||||
msgAttachment.value = '';
|
||||
msgAttachmentDesc.value = '';
|
||||
msgReplyTo.value = '';
|
||||
}
|
||||
})
|
||||
.catch(err=>{
|
||||
// try to display an error locally on the component
|
||||
});;
|
||||
}
|
||||
}
|
||||
}
|
16
frontend/ts/doer.ts
Normal file
16
frontend/ts/doer.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
import util from './util';
|
||||
|
||||
export class Doer {
|
||||
private _reqFn: (doParams: any) => Promise<Response>;
|
||||
|
||||
constructor(gateway: string, adapter: string) {
|
||||
this._reqFn = (doParams: any) => {
|
||||
let url = `${gateway}/api/adapters/${adapter}/do`;
|
||||
return util.authorizedFetch("POST", url, JSON.stringify(doParams));
|
||||
}
|
||||
}
|
||||
|
||||
do(doParams: any): Promise<Response> {
|
||||
return this._reqFn(doParams);
|
||||
}
|
||||
}
|
|
@ -1,12 +1,20 @@
|
|||
export class BatchTimer {
|
||||
import util from './util'
|
||||
|
||||
export class Fetcher {
|
||||
private _batch: string[];
|
||||
private _timer: number;
|
||||
private _reqFn: (id: string[])=>void;
|
||||
|
||||
constructor(reqFn: (id: string[])=>void) {
|
||||
constructor(gateway: string, adapter: string, etype: string) {
|
||||
this._batch = [];
|
||||
this._timer = new Date().getTime();
|
||||
this._reqFn = reqFn;
|
||||
this._reqFn = (ids: string[])=>{
|
||||
let url = `${gateway}/api/adapters/${adapter}/fetch?entity_type=${etype}`;
|
||||
for (let id of ids) {
|
||||
url += `&entity_id=${id}`;
|
||||
}
|
||||
util.authorizedFetch("GET", url, null);
|
||||
};
|
||||
}
|
||||
|
||||
public queue(id: string, timeout: number){
|
|
@ -1,56 +0,0 @@
|
|||
import util from "./util"
|
||||
import {AdapterState, AdapterData} from "./adapter";
|
||||
import {Message, Attachment, Author} from "./message"
|
||||
import {Settings} from "./settings"
|
||||
import { TabBarElement } from "./tabbar-element"
|
||||
import { MessageElement } from "./message-element"
|
||||
import { SettingsElement } from "./settings-element"
|
||||
import { AdapterElement } from "./adapter-element"
|
||||
import { ThreadSummaryElement } from "./thread-summary-element"
|
||||
|
||||
function main() {
|
||||
const saveData = localStorage.getItem("settings");
|
||||
Settings._instance = saveData ? <Settings>JSON.parse(saveData) : new Settings();
|
||||
|
||||
customElements.define("underbbs-tabbar", TabBarElement);
|
||||
customElements.define("underbbs-message", MessageElement);
|
||||
customElements.define("underbbs-settings", SettingsElement);
|
||||
customElements.define("underbbs-adapter", AdapterElement);
|
||||
customElements.define("underbbs-thread-summary", ThreadSummaryElement);
|
||||
|
||||
util._("closeErr", util.closeErr);
|
||||
|
||||
tabbarInit(Settings._instance.adapters?.map(a=>a.nickname) ?? []);
|
||||
|
||||
registerServiceWorker();
|
||||
}
|
||||
|
||||
function tabbarInit(adapters: string[]) {
|
||||
const nav = util.$("tabbar_injectparent");
|
||||
if (nav) {
|
||||
nav.innerHTML = `<underbbs-tabbar data-adapters="" data-currentadapter=""></underbbs-tabbar>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function registerServiceWorker() {
|
||||
if ("serviceWorker" in navigator) {
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.register("/serviceWorker.js", {
|
||||
scope: "/",
|
||||
});
|
||||
if (registration.installing) {
|
||||
console.log("Service worker installing");
|
||||
} else if (registration.waiting) {
|
||||
console.log("Service worker installed");
|
||||
} else if (registration.active) {
|
||||
console.log("Service worker active");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Registration failed with ${error}`);
|
||||
}
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
(registration as any).sync.register("testdata").then((r:any)=>{console.log("but i will see this!")});
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
|
@ -1,20 +1,174 @@
|
|||
import util from "./util"
|
||||
var _ = util._
|
||||
import { Message } from "./message"
|
||||
import { Fetcher } from "./fetcher"
|
||||
import { AdapterState } from "./adapter"
|
||||
|
||||
export class MessageElement extends HTMLElement {
|
||||
static observedAttributes = [ "data-id", "data-adapter", "data-replyCt", "data-reactionCt", "data-boostCt" ]
|
||||
static observedAttributes = [ "data-target", "data-latest", "data-adapter", "data-replyCt", "data-reactionCt", "data-boostCt" ]
|
||||
|
||||
private _id: string | null = null;
|
||||
private _adapter: any;
|
||||
private _adapter: string | null = null;
|
||||
|
||||
private _message: Message | null = null;
|
||||
private _interactable: boolean = false;
|
||||
private _replyWith: string | null = null;
|
||||
private _inspectWith: string | null = null;
|
||||
|
||||
private _msgFetcher: Fetcher | null = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.innerHTML = `<div class="message_metadata"></div><div class="message_content"></div><div class="message_attachments"></div><div class="message_interactions"></div>`
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this._id = this.getAttribute("data-id");
|
||||
this._adapter = _("settings").adapters.filter((a: any)=>a.nickname == this.getAttribute("data-adapter"))[0];
|
||||
this._id = this.getAttribute("data-target");
|
||||
this._adapter = this.getAttribute("data-adapter");
|
||||
const gateway = this.getAttribute("data-gateway") ?? "";
|
||||
this._msgFetcher = new Fetcher(gateway, this._adapter ?? "", "message");
|
||||
this._interactable = this.getAttribute("data-interactable") != null;
|
||||
this._replyWith = this.getAttribute("data-replywith");
|
||||
this._inspectWith = this.getAttribute("data-inspectwith");
|
||||
}
|
||||
|
||||
// grab message content from the store and format our innerHTML
|
||||
attributeChangedCallback(attr: string, prev: string, next: string) {
|
||||
switch (attr) {
|
||||
case "data-target":
|
||||
if (!next) {
|
||||
return
|
||||
}
|
||||
this._id = next;
|
||||
this._message = null;
|
||||
this.innerHTML = `<div class="message_metadata"></div><div class="message_content"></div><div class="message_attachments"></div><div class="message_interactions"></div>`;
|
||||
if (this._msgFetcher) {
|
||||
this._msgFetcher.queue(next, 100);
|
||||
}
|
||||
break;
|
||||
case "data-latest":
|
||||
let datastore = AdapterState._instance.data.get(this._adapter ?? "");
|
||||
if (!datastore) {
|
||||
console.log("no data yet, wait for some to come in maybe...");
|
||||
return;
|
||||
}
|
||||
let msg = datastore.messages.get(next);
|
||||
if (msg) {
|
||||
this._message = msg;
|
||||
const metadata = this.querySelector(".message_metadata");
|
||||
const content = this.querySelector(".message_content");
|
||||
const attachments = this.querySelector(".message_attachments");
|
||||
const interactions = this.querySelector(".message_interactions");
|
||||
if (metadata) {
|
||||
if (this._message.renoteId) {
|
||||
metadata.innerHTML = `<span class="message_renoter">${this._message.renoter} boosted</span><span class="message_renotetime">${new Date(this._message.renoteTime ?? 0)}</span>`
|
||||
} else {
|
||||
metadata.innerHTML = "";
|
||||
}
|
||||
metadata.innerHTML += `<span class="message_author">${this._message.author}</span><span class="message_timestamp">${new Date(this._message.created)}</span><span class="message_url">${this._message.uri}</span>${this._message.replyTo ? "<span class='message_inreplyto'>reply to " + this._message.replyTo + "</span>" : ""}<span class="message_visibility">${this._message.visibility}</span><span class="message_protocol">${this._message.protocol}</span>`
|
||||
|
||||
const renoter = this.querySelector(".message_renoter");
|
||||
const author = this.querySelector(".message_author");
|
||||
const url = this.querySelector(".message_url");
|
||||
const replyToUrl = this.querySelector(".message_inreplyto");
|
||||
|
||||
if (renoter) {
|
||||
renoter.addEventListener("click", this.inspect(this._message.renoter ?? "", "author"));
|
||||
}
|
||||
if (author) {
|
||||
author.addEventListener("click", this.inspect(this._message.author, "author"));
|
||||
}
|
||||
if (url) {
|
||||
url.addEventListener("click", this.inspect(this._message.uri, "message"));
|
||||
}
|
||||
if (replyToUrl) {
|
||||
replyToUrl.addEventListener("click", this.inspect(this._message.replyTo ?? "", "message"));
|
||||
}
|
||||
}
|
||||
if (content) {
|
||||
content.innerHTML = this._message.content;
|
||||
}
|
||||
if (attachments && this._message.attachments && this._message.attachments.length > 0) {
|
||||
let html = "<ul>";
|
||||
for (const a of this._message.attachments) {
|
||||
// we can do it based on actual mimetype later but now let's just do an extension check
|
||||
const srcUrl = new URL(a.src);
|
||||
const pathParts = srcUrl.pathname.split(".");
|
||||
if (pathParts.length < 2) {
|
||||
html += `<li><a href="${a.src}">${a.desc}</a></li>`
|
||||
continue;
|
||||
}
|
||||
const ext = pathParts[pathParts.length - 1];
|
||||
switch (ext.toLowerCase()) {
|
||||
case "jpg":
|
||||
case "jpeg":
|
||||
case "png":
|
||||
case "gif":
|
||||
case "avif":
|
||||
case "svg":
|
||||
case "bmp":
|
||||
html += `<li><a href="${a.src}"><img src="${a.src}" alt="${a.desc}"/></a>`
|
||||
break;
|
||||
case "mp3":
|
||||
case "wav":
|
||||
case "ogg":
|
||||
case "opus":
|
||||
case "aac":
|
||||
case "flac":
|
||||
html += `<li><audio src="${a.src}" controls preload="metadata"><a href="${a.src}">${a.desc}</a></audio></li>`
|
||||
break;
|
||||
case "mp4":
|
||||
case "mkv":
|
||||
case "avi":
|
||||
case "mov":
|
||||
html += `<li><video src="${a.src}" controls preload="metadata"><a href="${a.src}">${a.desc}</a></video></li>`
|
||||
break;
|
||||
default:
|
||||
html += `<li><a href="${a.src}">${a.desc}</a></li>`
|
||||
}
|
||||
}
|
||||
html += "</ul>";
|
||||
attachments.innerHTML = html;
|
||||
}
|
||||
if (this._interactable && interactions) {
|
||||
interactions.innerHTML = `<button class="message_reply">reply</button><button class="message_boost">boost</button>`
|
||||
const replyBtn = this.querySelector(".message_reply");
|
||||
const boostBtn = this.querySelector(".message_boost");
|
||||
if (replyBtn) {
|
||||
replyBtn.addEventListener("click", this.reply.bind(this));
|
||||
}
|
||||
if (boostBtn) {
|
||||
boostBtn.addEventListener("click", this.boost.bind(this));
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private reply() {
|
||||
const e = document.querySelector(`#${this._replyWith}`);
|
||||
if (e) {
|
||||
e.setAttribute("data-replyto", this._id || "");
|
||||
const txtArea = e.querySelector("textarea") as HTMLTextAreaElement;
|
||||
if (txtArea) {
|
||||
txtArea.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boost() {
|
||||
// use a Doer to boost
|
||||
}
|
||||
|
||||
private inspect(target: string, type: string): ()=>void {
|
||||
const self = this;
|
||||
|
||||
return ()=> {
|
||||
const e = document.querySelector(`#${self._inspectWith}`);
|
||||
if (e) {
|
||||
e.setAttribute("data-" + type, target);
|
||||
} else {
|
||||
window.open(target, "_blank");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
export class Message {
|
||||
public id: string = "";
|
||||
public uri: string = "";
|
||||
public target: string | null = null;
|
||||
public protocol: string = "";
|
||||
public adapter: string = "";
|
||||
public author: string = ""
|
||||
|
@ -12,11 +13,15 @@ export class Message {
|
|||
public created: number = 0;
|
||||
public edited: number | null = null;
|
||||
public visibility: string = "public";
|
||||
public renoteId: string | null = null;
|
||||
public renoter: string | null = null;
|
||||
public renoteTime: Date | null = null;
|
||||
}
|
||||
|
||||
export class Author {
|
||||
public id: string = "";
|
||||
public uri: string = "";
|
||||
public target: string | null = null;
|
||||
public protocol: string = "";
|
||||
public adapter: string = "";
|
||||
public name: string = "";
|
||||
|
@ -26,10 +31,10 @@ export class Author {
|
|||
}
|
||||
|
||||
export class Attachment {
|
||||
public Src: string = "";
|
||||
public ThumbSrc: string = "";
|
||||
public Desc: string = "";
|
||||
public CreatedAt: Date = new Date();
|
||||
public src: string = "";
|
||||
public thumbSrc: string = "";
|
||||
public desc: string = "";
|
||||
public createdAt: Date = new Date();
|
||||
}
|
||||
|
||||
export default { Message, Attachment, Author }
|
108
frontend/ts/navigator.ts
Normal file
108
frontend/ts/navigator.ts
Normal file
|
@ -0,0 +1,108 @@
|
|||
import { AdapterState } from "./adapter"
|
||||
|
||||
class HistoryNode {
|
||||
id: string;
|
||||
type: string;
|
||||
prev: HistoryNode | null = null;
|
||||
next: HistoryNode | null = null;
|
||||
|
||||
constructor(id: string, type: string) {
|
||||
this.id = id;
|
||||
this.type = type;
|
||||
}
|
||||
}
|
||||
|
||||
export class NavigatorElement extends HTMLElement {
|
||||
static observedAttributes = [ "data-author", "data-message" ];
|
||||
|
||||
private _adapter: string = "";
|
||||
private _history: HistoryNode | null = null;
|
||||
private _replyWith: string | null = null;
|
||||
private _gateway: string = "";
|
||||
constructor() {
|
||||
super();
|
||||
this.innerHTML = `<nav><button class="nav_prev">←</button><button class="nav_next">→</button><button class="nav_clear">×</button></nav><div class="nav_container"></div>`
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this._adapter = this.getAttribute("data-adapter") ?? "";
|
||||
this._replyWith = this.getAttribute("data-replywith");
|
||||
this._gateway = this.getAttribute("data-gateway") ?? "";
|
||||
|
||||
const prevBtn = this.querySelector(".nav_prev");
|
||||
const nextBtn = this.querySelector(".nav_next");
|
||||
const clearBtn = this.querySelector(".nav_clear");
|
||||
|
||||
if (prevBtn) {
|
||||
prevBtn.addEventListener("click", this.goPrev);
|
||||
}
|
||||
if (nextBtn) {
|
||||
nextBtn.addEventListener("click", this.goNext);
|
||||
}
|
||||
if (clearBtn) {
|
||||
clearBtn.addEventListener("click", this.clear);
|
||||
}
|
||||
}
|
||||
|
||||
attributeChangedCallback(attr: string, prev: string, next: string) {
|
||||
switch (attr) {
|
||||
case "data-author":
|
||||
case "data-message":
|
||||
if (next == prev) {
|
||||
return;
|
||||
}
|
||||
if (this._history && this._history.prev && this._history.prev.id == next) {
|
||||
this._history = this._history.prev;
|
||||
} else {
|
||||
const h = this._history;
|
||||
this._history = new HistoryNode(next, attr.slice(attr.indexOf("-") + 1));
|
||||
this._history.prev = h;
|
||||
}
|
||||
const panel = this.querySelector(".nav_container");
|
||||
const datastore = AdapterState._instance.data.get(this._adapter);
|
||||
if (datastore && panel) {
|
||||
switch (attr) {
|
||||
case "data-author":
|
||||
const author = datastore.profileCache.get(next);
|
||||
panel.innerHTML = `<underbbs-profile data-gateway="${this._gateway}" data-adapter="${this._adapter}" data-target="${next}"></underbbs-profile><underbbs-timeline data-gateway="${this._gateway}" data-target="${next}" data-adapter="${this._adapter}" data-interactable data-mode="fetch" data-inspectwith="${this.getAttribute("id")??""}" data-replywith="${this._replyWith}"></underbbs-timeline>`
|
||||
|
||||
const profile = this.querySelector("underbbs-profile");
|
||||
const tl = this.querySelector("underbbs-timeline");
|
||||
if (profile && tl) {
|
||||
if (!author) {
|
||||
profile.setAttribute("data-target", next);
|
||||
} else {
|
||||
profile.setAttribute("data-latest", next);
|
||||
}
|
||||
tl.setAttribute("data-target", next);
|
||||
}
|
||||
break;
|
||||
case "data-message":
|
||||
const msg = datastore.messages.get(next);
|
||||
panel.innerHTML = `<underbbs-message data-gateway="${this._gateway}" data-adapter="${this._adapter}" data-target="${next}" data-interactable data-inspectwith="${this.getAttribute("id")??""}" data-replywith="${this._replyWith}"></underbbs-message>`
|
||||
const e = this.querySelector("underbbs-message");
|
||||
if (e) {
|
||||
if (!msg) {
|
||||
e.setAttribute("data-target", next);
|
||||
} else {
|
||||
e.setAttribute("data-latest", next);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
private goNext() {
|
||||
}
|
||||
|
||||
private goPrev() {
|
||||
}
|
||||
|
||||
private clear() {
|
||||
}
|
||||
}
|
|
@ -1,14 +1,75 @@
|
|||
export class ProfileView extends HTMLElement {
|
||||
import { Author } from "./message"
|
||||
import util from "./util"
|
||||
import { Fetcher } from "./fetcher"
|
||||
import { AdapterState } from "./adapter"
|
||||
|
||||
export class ProfileElement extends HTMLElement {
|
||||
static observedAttributes = [ "data-latest", "data-adapter", "data-target" ];
|
||||
|
||||
private _id: string | null = null;
|
||||
private _adapter: string = "";
|
||||
|
||||
private _author: Author | null = null;
|
||||
|
||||
private _authorFetcher: Fetcher | null = null;
|
||||
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.innerHTML = "<div class='author_pfp'></div><div class='author_id'></div><div class='author_name'></div><div class='author_description'></div>"
|
||||
|
||||
}
|
||||
|
||||
const prof_view = document.createElement("span");
|
||||
|
||||
prof_view.setAttribute("class", "wrapper")
|
||||
|
||||
connectedCallback() {
|
||||
this.
|
||||
this._id = this.getAttribute("data-target");
|
||||
this._adapter = this.getAttribute("data-adapter") ?? "";
|
||||
const gateway = this.getAttribute("data-gateway") ?? "";
|
||||
this._authorFetcher = new Fetcher(gateway, this._adapter, "author");
|
||||
}
|
||||
|
||||
attributeChangedCallback(attr: string, prev: string, next: string) {
|
||||
|
||||
switch (attr) {
|
||||
case "data-target":
|
||||
if (!next) {
|
||||
return
|
||||
}
|
||||
this._id = next;
|
||||
if (this._authorFetcher) {
|
||||
this._authorFetcher.queue(next, 100);
|
||||
}
|
||||
break;
|
||||
case "data-latest":
|
||||
let datastore = AdapterState._instance.data.get(this._adapter);
|
||||
if (!datastore) {
|
||||
console.log("no data yet, wait for some to come in maybe...");
|
||||
return;
|
||||
}
|
||||
this._author = datastore.profileCache.get(next) ?? null;
|
||||
console.log(this._author);
|
||||
if (this._author == null) {
|
||||
return;
|
||||
}
|
||||
if (this._author.id == next) {
|
||||
let pfp = this.querySelector(".author_pfp");
|
||||
let handle = this.querySelector(".author_id");
|
||||
let prefName = this.querySelector(".author_name");
|
||||
let bio = this.querySelector(".author_description");
|
||||
|
||||
if (pfp) {
|
||||
pfp.innerHTML = `<img src="${this._author.profilePic}">`
|
||||
}
|
||||
if (handle) {
|
||||
handle.innerHTML = `<a href="${this._author.uri}">${this._author.id}</a>`;
|
||||
}
|
||||
if (prefName) {
|
||||
prefName.innerHTML = this._author.name;
|
||||
}
|
||||
if (bio) {
|
||||
bio.innerHTML = this._author.profileData;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,144 +0,0 @@
|
|||
/* WEPA this is miggymofongo's contribution to underBBS! I'm practicing web components
|
||||
in this bishhh
|
||||
|
||||
this template contains the html code of the side bar
|
||||
*/
|
||||
const template = document.createElement("template")
|
||||
|
||||
template.innerHTML = `
|
||||
<style>
|
||||
|
||||
/* side profile view menu */
|
||||
.sidenav {
|
||||
height: 50%;
|
||||
width: 0;
|
||||
position: fixed; /* Stay in place */
|
||||
z-index: 1;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background-color: #888;
|
||||
overflow-x: hidden; /* Disable horizontal scroll */
|
||||
padding-top: 60px; /* Place content 60px from the top */
|
||||
transition: width 0.5s ease; /* smooth transition */
|
||||
}
|
||||
|
||||
/*nav menu links */
|
||||
.sidenav a {
|
||||
padding: 8px 8px 8px 32px;
|
||||
text-decoration: none;
|
||||
font-size: 25px;
|
||||
color: #818181;
|
||||
display: block;
|
||||
transition: 0.3s;
|
||||
}
|
||||
|
||||
/* change their color when you hover the mouse */
|
||||
.sidenav a:hover {
|
||||
color: #f1f1f1;
|
||||
}
|
||||
|
||||
/* style the close button (puts in the top right corner) */
|
||||
.sidenav .closeBtn {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 25px;
|
||||
font-size: 36px;
|
||||
margin-left: 50px;
|
||||
}
|
||||
|
||||
/* Style page content - use this if you want to push the page content to the right when you open the side navigation */
|
||||
#main {
|
||||
transition: margin-left .5s ease;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* change the style of the sidenav on smaller screens where height is less than 450px,
|
||||
(less padding and a smaller font size) */
|
||||
|
||||
@media screen and (max-height: 450px) {
|
||||
.sidenav {padding-top: 15px;}
|
||||
.sidenav a {font-size: 18px;}
|
||||
}
|
||||
/* the div block has an id and class so that its targeted by the styles
|
||||
and the component underneath */
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
<div id="mySidenav" class="sidenav">
|
||||
<div id="profile">
|
||||
|
||||
<img src="Kaido.png" alt="profile pic" style="width:100px;height100px"/>
|
||||
|
||||
|
||||
|
||||
<button href="" class="closeBtn">×</button>
|
||||
<a href=""><button>Message</button></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button id="openBtn">open profile view</button>
|
||||
|
||||
`
|
||||
|
||||
/* setting up a web component will extend the set of available HTML elements for you to use in your
|
||||
project and keep your codebase organized and modular. Set up a web component in a separate file
|
||||
and link it directly in your html document head.
|
||||
|
||||
creating a custom web component is just like creating a class. after setting up
|
||||
the constructor function, you need to create some methods that tell the browser more about it.
|
||||
you can add whatever methods you want, but they at least need some of the following:
|
||||
|
||||
1) connectedCallback() => call this method when the element is added to the document,
|
||||
2) disconnectedCallback() => call this method when the element is removed from the document
|
||||
3) static get observedAttributes() => this returns an array of attribute names to track for changes
|
||||
4) attributeChangedCallback(name, oldValue, newValue) => this one is called when one of the attributes
|
||||
5) adoptedCallback() => called when the element is moved to a new document
|
||||
*/
|
||||
|
||||
|
||||
|
||||
class ProfileSideBar extends HTMLElement {
|
||||
//
|
||||
constructor() { // the constructor function initiates the new side bar
|
||||
super()
|
||||
this.appendChild(template.content.cloneNode(true))
|
||||
this.sidenav = this.querySelector("#mySidenav");
|
||||
this.openBtn = this.querySelector("#openBtn");
|
||||
this.closeBtn = this.querySelector(".closeBtn");
|
||||
this.mainContent = document.getElementById("main");
|
||||
|
||||
}
|
||||
/* this method adds event listeners to the openBtn and closeBtn classes that
|
||||
call the openNav closeNav function to open and close the sidebar*/
|
||||
connectedCallback() {
|
||||
|
||||
this.openBtn.addEventListener("click", this.openNav.bind(this));
|
||||
this.closeBtn.addEventListener("click", this.closeNav.bind(this));
|
||||
|
||||
}
|
||||
|
||||
|
||||
disconnectedCallback() {
|
||||
this.openBtn.removeEventListener("click", this.openNav.bind(this));
|
||||
this.closeBtn.removeEventListener("click", this.closeNav.bind(this));
|
||||
|
||||
}
|
||||
|
||||
/* these two methods will open and close the profile side menu*/
|
||||
openNav() {
|
||||
this.sidenav.style.width = "450px"; // changes sidenav width from 0px to 450px
|
||||
if (this.mainContent) {
|
||||
this.mainContent.style.marginLeft = "250px";
|
||||
}
|
||||
}
|
||||
|
||||
closeNav() {
|
||||
this.sidenav.style.width = "0"; //changes sidenav's width back to 0px
|
||||
if (this.mainContent) {
|
||||
this.mainContent.style.marginLeft = "0px"
|
||||
}
|
||||
}
|
||||
}
|
||||
/*lastly, we need to define the element tag itself and attach it to the webcomponent */
|
||||
customElements.define("profile-side-bar", ProfileSideBar)
|
|
@ -4,15 +4,17 @@ import {Settings} from "./settings"
|
|||
|
||||
|
||||
export class SettingsElement extends HTMLElement {
|
||||
static observedAttributes = [ "data-adapters" ]
|
||||
static observedAttributes = [ "data-adapters", "data-gateway" ]
|
||||
|
||||
private _adapters: string[] = [];
|
||||
private _gateway: string = "";
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this._gateway = this.getAttribute("data-gateway") ?? "";
|
||||
}
|
||||
|
||||
attributeChangedCallback(attr: string, prev: string, next: string) {
|
||||
|
@ -49,7 +51,7 @@ export class SettingsElement extends HTMLElement {
|
|||
}
|
||||
let connect = util.$("settings_connect_btn");
|
||||
if (connect) {
|
||||
connect.addEventListener("click", DatagramSocket.connect, false);
|
||||
connect.addEventListener("click", ()=>{DatagramSocket.connect(this._gateway)}, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -58,7 +60,7 @@ export class SettingsElement extends HTMLElement {
|
|||
return ()=>{
|
||||
// dropdown for protocol
|
||||
let html = "<select id='settings_newadapter_protocolselect'>";
|
||||
html += [ "nostr", "mastodon", "misskey" ].reduce((self, p)=>{
|
||||
html += [ "nostr", "mastodon", "misskey", "honk" ].reduce((self, p)=>{
|
||||
self += `<option value='${p}'>${p}</option>`;
|
||||
return self;
|
||||
}, "");
|
||||
|
@ -114,6 +116,11 @@ export class SettingsElement extends HTMLElement {
|
|||
const apiKey = (util.$("settings_newadapter_masto_apikey") as HTMLInputElement)?.value ?? "";
|
||||
adapterdata = { nickname: nickname, protocol: proto.options[proto.selectedIndex].value, server: server, apiKey: apiKey };
|
||||
break;
|
||||
case "honk":
|
||||
const handle = (util.$("settings_newadapter_honk_handle") as HTMLInputElement)?.value ?? "";
|
||||
const password = (util.$("settings_newadapter_honk_password") as HTMLInputElement)?.value || null;
|
||||
adapterdata = { nickname: nickname, protocol: proto.options[proto.selectedIndex].value, handle: handle, password: password };
|
||||
break;
|
||||
}
|
||||
const settings = Settings._instance;
|
||||
if (settings) {
|
||||
|
@ -122,7 +129,7 @@ export class SettingsElement extends HTMLElement {
|
|||
}
|
||||
settings.adapters.push(adapterdata);
|
||||
self._adapters.push(adapterdata.nickname);
|
||||
localStorage.setItem("settings", JSON.stringify(settings));
|
||||
localStorage.setItem("underbbs_settings", JSON.stringify(settings));
|
||||
|
||||
self.showSettings(self)();
|
||||
}
|
||||
|
@ -146,6 +153,10 @@ export class SettingsElement extends HTMLElement {
|
|||
html += " <label>server<input id='settings_newadapter_masto_server'/></label>";
|
||||
html += " <label>API key<input id='settings_newadapter_masto_apikey'/></label>";
|
||||
break;
|
||||
case "honk":
|
||||
html += " <label>nickname<input id='settings_newadapter_nickname'/></label>";
|
||||
html += " <label>handle<input id='settings_newadapter_honk_handle'/></label>";
|
||||
html += " <label>password<input id='settings_newadapter_honk_password'/></label>";
|
||||
}
|
||||
|
||||
const div = util.$("settings_newadapter_protocoloptions");
|
||||
|
|
|
@ -10,6 +10,10 @@ export class AdapterConfig {
|
|||
// nostr
|
||||
public privkey: string | null = null;
|
||||
public relays: string[] | null = null;
|
||||
|
||||
// honk
|
||||
public handle: string | null = null;
|
||||
public password: string | null = null;
|
||||
}
|
||||
|
||||
export class Settings {
|
||||
|
|
16
frontend/ts/subscriber.ts
Normal file
16
frontend/ts/subscriber.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
import util from './util';
|
||||
|
||||
export class Subscriber {
|
||||
private _reqFn: (filter: string)=>void;
|
||||
|
||||
constructor(gateway: string, adapter: string, target: string | null) {
|
||||
this._reqFn = (filter: string) => {
|
||||
let url = `${gateway}/api/adapters/${adapter}/subscribe`
|
||||
util.authorizedFetch("POST", url, JSON.stringify({filter, target}));
|
||||
}
|
||||
}
|
||||
|
||||
subscribe(filter: string) {
|
||||
this._reqFn(filter);
|
||||
}
|
||||
}
|
|
@ -1,84 +0,0 @@
|
|||
import util from "./util"
|
||||
import {Settings} from "./settings"
|
||||
|
||||
export class TabBarElement extends HTMLElement {
|
||||
static observedAttributes = [ "data-adapters", "data-currentadapter" ]
|
||||
|
||||
private _adapters: string[] = [];
|
||||
private _currentAdapter: string | null = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
if (this._currentAdapter) {
|
||||
this.showAdapterFunc(this, this._currentAdapter)();
|
||||
} else {
|
||||
this.showSettings(this)();
|
||||
}
|
||||
}
|
||||
|
||||
attributeChangedCallback(attr: string, prev: string, next: string) {
|
||||
switch (attr) {
|
||||
case "data-adapters":
|
||||
if (next) {
|
||||
this._adapters = next.split(",")
|
||||
} else {
|
||||
this._adapters = [];
|
||||
}
|
||||
break;
|
||||
case "data-currentadapter":
|
||||
this._currentAdapter = next || null;
|
||||
break;
|
||||
}
|
||||
|
||||
let html = "<ul><li><a id='tabbar_settings' href='#settings'>settings</a></li>";
|
||||
|
||||
html = this._adapters.reduce((self: string, a: string)=>{
|
||||
self += `<li><a id="tabbar_${a}" href="#${a}">${a}</a></li>`;
|
||||
return self;
|
||||
}, html);
|
||||
html += "</ul>";
|
||||
|
||||
this.innerHTML = html;
|
||||
// now we can query the child elements and add click handlers to them
|
||||
var s = util.$("tabbar_settings");
|
||||
if (s) {
|
||||
s.addEventListener("click", this.showSettings(this), false);
|
||||
if (!this._currentAdapter) {
|
||||
s.classList.add("tabbar_current");
|
||||
}
|
||||
}
|
||||
for (let i of this._adapters) {
|
||||
var a = util.$(`tabbar_${i}`);
|
||||
if (a) {
|
||||
a.addEventListener("click", this.showAdapterFunc(this, i), false);
|
||||
if (this._currentAdapter == i) {
|
||||
a.classList.add("tabbar_current");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
showSettings(self: TabBarElement): ()=>void {
|
||||
return () => {
|
||||
let x = util.$("mainarea_injectparent");
|
||||
if (x) {
|
||||
x.innerHTML = `<underbbs-settings data-adapters=${Settings._instance.adapters.map(a=>a.nickname).join(",") ?? []}></underbbs-settings>`;
|
||||
self.setAttribute("data-currentadapter", "");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
showAdapterFunc(self: TabBarElement, adapter: string): ()=>void {
|
||||
return ()=>{
|
||||
let x = util.$("mainarea_injectparent");
|
||||
if (x) {
|
||||
x.innerHTML = `<underbbs-adapter id="adapter_${adapter}" data-name="${adapter}" data-view=""></underbbs-adapter>`;
|
||||
self.setAttribute("data-currentadapter", adapter);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,113 +0,0 @@
|
|||
import util from "./util"
|
||||
import { Message, Author } from "./message"
|
||||
import { AdapterState } from "./adapter"
|
||||
import { BatchTimer } from "./batch-timer"
|
||||
|
||||
export class ThreadSummaryElement extends HTMLElement {
|
||||
static observedAttributes = [ "data-msg", "data-len", "data-author", "data-created", "data-latest", "data-new" ];
|
||||
|
||||
private _len: number = 0;;
|
||||
private _msg: Message | null = null;;
|
||||
private _author: Author | null = null;
|
||||
private _adapter: string = "";
|
||||
private _created: number = 0;
|
||||
private _latest: number = 0;
|
||||
private _new: boolean = false;
|
||||
|
||||
private _authorTimer: BatchTimer;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.innerHTML = "<div class='thread_summary'><div class='thread_author'></div><div class='thread_text'></div><div class='thread_metadata'></div></div>"
|
||||
|
||||
// adapter shouldn't change, just set it here
|
||||
this._adapter = this.getAttribute("data-adapter") ?? "";
|
||||
this.addEventListener("click", this.viewThread(this), false);
|
||||
this._authorTimer = new BatchTimer((ids: string[])=>{
|
||||
let url = `/api/adapters/${this._adapter}/fetch?entity_type=author`;
|
||||
for (let id of ids) {
|
||||
url += `&entity_id=${id}`;
|
||||
}
|
||||
util.authorizedFetch("GET", url, null)
|
||||
});
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
|
||||
}
|
||||
|
||||
attributeChangedCallback(attr: string, prev: string, next: string) {
|
||||
|
||||
const datastore = AdapterState._instance.data.get(this._adapter);
|
||||
if (!datastore) {
|
||||
return;
|
||||
}
|
||||
let metadataChanged = false;
|
||||
|
||||
switch (attr) {
|
||||
case "data-msg":
|
||||
if (next && next != prev) {
|
||||
this._msg = datastore.messages.get(next) || null;
|
||||
if (this._msg) {
|
||||
// TODO: use attachment alttext or fallback text if no msg content
|
||||
// TODO: handle boosts, quotes properly
|
||||
|
||||
const threadText = this.querySelector(".thread_text");
|
||||
if (threadText) {
|
||||
threadText.innerHTML = this._msg.content;
|
||||
}
|
||||
this.setAttribute("data-author", this._msg.author);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "data-author":
|
||||
if (next) {
|
||||
let authorData= datastore.profileCache.get(next);
|
||||
if (!authorData) {
|
||||
this._authorTimer.queue(next, 2000);
|
||||
this._author = <Author>{id: next};
|
||||
const threadAuthor = this.querySelector(".thread_author");
|
||||
if (threadAuthor) {
|
||||
threadAuthor.innerHTML = `<a id="thread_${this._adapter}_${(this._msg ? this._msg.id : 0)}_${this._author.id}" href="#author?id=${this._author.id}">${this._author.id}</a>`;
|
||||
}
|
||||
} else {
|
||||
this._author = authorData;
|
||||
const threadAuthor = this.querySelector(".thread_author");
|
||||
if (threadAuthor) {
|
||||
threadAuthor.innerHTML = `<img src="${this._author.profilePic}" alt="${this._author.id}"/> <a id="thread_${this._adapter}_${(this._msg ? this._msg.id : 0)}_${this._author.id}" href="#author?id=${this._author.id}">${this._author.id}</a>`
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "data-len":
|
||||
this._len = parseInt(next);
|
||||
metadataChanged = true;
|
||||
break;
|
||||
case "data-latest":
|
||||
this._latest = parseInt(next);
|
||||
metadataChanged = true;
|
||||
break;
|
||||
case "data-new":
|
||||
this._new = next ? true : false;
|
||||
metadataChanged = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (metadataChanged) {
|
||||
const threadMeta = this.querySelector(".thread_metadata");
|
||||
if (threadMeta) {
|
||||
threadMeta.innerHTML = `<span>${this._new ? "!" : ""}[${this._len}] created: ${new Date(this._created)}, updated: ${new Date(this._latest)}</span>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
viewThread(self: ThreadSummaryElement) {
|
||||
return () => {
|
||||
const a = util.$(`adapter_${self._adapter}`);
|
||||
if (a && self._msg) {
|
||||
a.setAttribute("data-view", "thread");
|
||||
a.setAttribute("data-viewing", self._msg.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
113
frontend/ts/timeline-element.ts
Normal file
113
frontend/ts/timeline-element.ts
Normal file
|
@ -0,0 +1,113 @@
|
|||
import { Author, Message } from "./message"
|
||||
import util from "./util"
|
||||
import { Subscriber } from "./subscriber"
|
||||
import { Fetcher } from "./fetcher"
|
||||
import { AdapterState } from "./adapter"
|
||||
|
||||
export class TimelineElement extends HTMLElement {
|
||||
static observedAttributes = [ "data-latest", "data-adapter", "data-target" ];
|
||||
|
||||
private _timeline: string | null = null;
|
||||
private _adapter: string = "";
|
||||
|
||||
private _interactable: boolean = false;
|
||||
private _replyWith: string | null = null;
|
||||
private _inspectWith: string | null = null;
|
||||
private _mode: string = "subscribe";
|
||||
|
||||
private _messages: Message[] = [];
|
||||
private _subscriber: Subscriber | null = null;
|
||||
private _byAuthorFetcher: Fetcher | null = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.innerHTML = `<ul class="messages_list"></ul>`;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this._timeline = this.getAttribute("data-target");
|
||||
this._adapter = this.getAttribute("data-adapter") ?? "";
|
||||
const gateway = this.getAttribute("data-gateway") ?? "";
|
||||
this._subscriber = new Subscriber(gateway, this._adapter ?? "", this.getAttribute("id") ?? null);
|
||||
this._byAuthorFetcher = new Fetcher(gateway, this._adapter, "byAuthor");
|
||||
this._interactable = this.getAttribute("data-interactable") != null;
|
||||
this._replyWith = this.getAttribute("data-replywith");
|
||||
this._inspectWith = this.getAttribute("data-inspectwith");
|
||||
this._mode = this.getAttribute("data-mode") ?? "subscribe";
|
||||
}
|
||||
|
||||
attributeChangedCallback(attr: string, prev: string, next: string) {
|
||||
switch (attr) {
|
||||
case "data-target":
|
||||
if (!next) {
|
||||
return
|
||||
}
|
||||
this._timeline = next;
|
||||
this.innerHTML = `<ul class="messages_list"></ul>`;
|
||||
this._messages = [];
|
||||
switch (this._mode) {
|
||||
case "byAuthor":
|
||||
if (this._byAuthorFetcher) {
|
||||
this._byAuthorFetcher.queue(next, 100);
|
||||
}
|
||||
break;
|
||||
case "subscribe":
|
||||
if (this._subscriber) {
|
||||
this._subscriber.subscribe(next);
|
||||
}
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case "data-latest":
|
||||
let datastore = AdapterState._instance.data.get(this._adapter);
|
||||
if (!datastore) {
|
||||
console.log("no data yet, wait for some to come in maybe...");
|
||||
return;
|
||||
}
|
||||
let msg = datastore.messages.get(next);
|
||||
if (msg) {
|
||||
console.log(msg);
|
||||
const existingIdx = this._messages.findIndex(m=>(m.renoteId ?? m.id) == (msg.renoteId ?? msg.id) && ((m.edited ?? m.created) <= (msg.edited ?? msg.created)));
|
||||
|
||||
// first we update the backing data store
|
||||
if (existingIdx >= 0) {
|
||||
this._messages[existingIdx] = msg;
|
||||
} else if (!this._messages.some(m=>(m.renoteId ?? m.id) == (msg.renoteId ?? msg.id))) {
|
||||
this._messages.push(msg);
|
||||
}
|
||||
|
||||
|
||||
const ul = this.children[0];
|
||||
if (ul) {
|
||||
// first pass through the dom, try to update a message if it's there
|
||||
for (let i = 0; i < ul.childElementCount; i++){
|
||||
const id = ul.children[i]?.children[0]?.getAttribute("data-target");
|
||||
const ogMsg = this._messages.find(m=>(m.renoteId ?? m.id) == id);
|
||||
if (ogMsg && existingIdx >= 0) {
|
||||
ul.children[i]?.children[0]?.setAttribute("data-latest", id ?? "");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// if we made it this far, let's create a node
|
||||
const e = document.createElement("li");
|
||||
e.innerHTML = `<underbbs-message data-adapter="${this._adapter}" data-target="${next}" ${this._interactable ? "data-interactable" : ""} ${this._replyWith ? "data-replywith='" + this._replyWith + "'" : ""} ${this._inspectWith ? "data-inspectwith='" + this._inspectWith + "'": ""}></underbbs-message>`
|
||||
// second pass, try to place it in reverse-chronological order
|
||||
for (let i = 0; i < ul.childElementCount; i++){
|
||||
const id = ul.children[i]?.children[0]?.getAttribute("data-target");
|
||||
const ogMsg = this._messages.find(m=>(m.renoteId ?? m.id) == id);
|
||||
if (ogMsg && (ogMsg.renoteTime ?? ogMsg.created) <= (msg.renoteTime ?? msg.created)) {
|
||||
ul.insertBefore(e, ul.children[i])
|
||||
e.children[0].setAttribute("data-latest", next);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// final pass, we must be the earliest child (or maybe the first one to be rendered)
|
||||
ul.append(e);
|
||||
e.children[0].setAttribute("data-latest", next);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
55
frontend/ts/timeline-filter-element.ts
Normal file
55
frontend/ts/timeline-filter-element.ts
Normal file
|
@ -0,0 +1,55 @@
|
|||
export class TimelineFilter {
|
||||
public name: string = "";
|
||||
public filter: string = "";
|
||||
}
|
||||
|
||||
export class TimelineFilterElement extends HTMLElement {
|
||||
static observedAttributes = [ "data-filters", "data-target", "data-latest" ];
|
||||
|
||||
private _filters: TimelineFilter[] = [];
|
||||
private _target: string = "";
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.innerHTML = "<select class='filter_select'></select>"
|
||||
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this._target = this.getAttribute("data-target") ?? "";
|
||||
this._filters = (this.getAttribute("data-filters") ?? "").split("/").map(f=>{
|
||||
const ff = f.split("::");
|
||||
return <TimelineFilter>{ name: ff[0], filter: ff[1] };
|
||||
});
|
||||
let html = "<select class='filter_select'>";
|
||||
html += this._filters.reduce((self: string, f: TimelineFilter)=>{
|
||||
self += `<option value='${f.filter}'>${f.name}</option>`;
|
||||
return self;
|
||||
}, "");
|
||||
html += "</select>";
|
||||
this.innerHTML = html;
|
||||
const select = this.querySelector(".filter_select");
|
||||
if (select) {
|
||||
select.addEventListener("change", this.onChanges.bind(this));
|
||||
}
|
||||
}
|
||||
|
||||
onChanges() {
|
||||
const select = this.querySelector(".filter_select") as HTMLSelectElement;
|
||||
|
||||
// change target's target!
|
||||
const target = document.getElementById(this._target);
|
||||
if (target) {
|
||||
target.setAttribute("data-target", select?.options[select.selectedIndex].value)
|
||||
}
|
||||
}
|
||||
|
||||
attributeChangedCallback(attr: string, prev: string, next: string) {
|
||||
switch (attr) {
|
||||
case "data-latest":
|
||||
const select = this.querySelector(".filter_select") as HTMLSelectElement;
|
||||
select.selectedIndex = this._filters.findIndex(f=>f.name == next);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,5 +1,4 @@
|
|||
import { DatagramSocket } from './websocket'
|
||||
import { BatchTimer } from './batch-timer'
|
||||
|
||||
function _(key: string, value: any | null | undefined = undefined): any | null {
|
||||
const x = <any>window;
|
||||
|
@ -31,7 +30,7 @@ function closeErr(): void {
|
|||
|
||||
async function authorizedFetch(method: string, uri: string, body: any): Promise<Response> {
|
||||
const headers = new Headers()
|
||||
headers.set('Authorization', 'Bearer ' + DatagramSocket.skey)
|
||||
headers.set('X-Underbbs-Subscriber', DatagramSocket.skey ?? "")
|
||||
return await fetch(uri, {
|
||||
method: method,
|
||||
headers: headers,
|
||||
|
|
|
@ -2,11 +2,18 @@ import util from "./util"
|
|||
import {AdapterState, AdapterData} from "./adapter";
|
||||
import {Message, Attachment, Author} from "./message"
|
||||
import {Settings} from "./settings"
|
||||
|
||||
import {SettingsElement} from "./settings-element"
|
||||
import {ProfileElement} from "./profile-element"
|
||||
import {MessageElement} from "./message-element"
|
||||
import {TimelineElement} from "./timeline-element"
|
||||
import {TimelineFilterElement} from "./timeline-filter-element"
|
||||
import {CreateMessageElement} from "./create-message-element"
|
||||
import {NavigatorElement} from "./navigator"
|
||||
|
||||
export class DatagramSocket {
|
||||
public static skey: string | null = null;
|
||||
public static conn: WebSocket | null;
|
||||
private static _gateway: string = ""
|
||||
|
||||
|
||||
private static onOpen(e: Event) {
|
||||
|
@ -14,18 +21,29 @@ export class DatagramSocket {
|
|||
console.log(JSON.stringify(e));
|
||||
}
|
||||
|
||||
private static gatewayWithScheme(): string {
|
||||
return location.protocol + "//" + DatagramSocket._gateway
|
||||
}
|
||||
|
||||
private static onMsg(e: MessageEvent) {
|
||||
const data = JSON.parse(e.data);
|
||||
console.log(data);
|
||||
if (data.key) {
|
||||
DatagramSocket.skey = data.key;
|
||||
util.authorizedFetch("POST", "/api/adapters", JSON.stringify(Settings._instance.adapters))
|
||||
util.authorizedFetch("POST", DatagramSocket._gateway + "/api/adapters", JSON.stringify(Settings._instance.adapters))
|
||||
.then(r=> {
|
||||
if (r.ok) {
|
||||
const tabbar = document.querySelector("underbbs-tabbar");
|
||||
if (tabbar) {
|
||||
tabbar.setAttribute("data-adapters", Settings._instance.adapters.map(a=>a.nickname).join(","));
|
||||
}
|
||||
// iterate through any components which might want to fetch data
|
||||
const profiles = document.querySelectorAll("underbbs-profile");
|
||||
profiles.forEach(p=>{
|
||||
const target = p.getAttribute("data-target");
|
||||
p.setAttribute("data-target", target ?? "");
|
||||
});
|
||||
const timelines = document.querySelectorAll("underbbs-timeline");
|
||||
timelines.forEach(t=>{
|
||||
const target = t.getAttribute("data-target");
|
||||
t.setAttribute("data-target", target ?? "");
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(e => {
|
||||
|
@ -36,11 +54,12 @@ export class DatagramSocket {
|
|||
if (!store) {
|
||||
AdapterState._instance.data.set(data.adapter, new AdapterData(data.protocol));
|
||||
store = AdapterState._instance.data.get(data.adapter);
|
||||
} else {
|
||||
}
|
||||
if (store) {
|
||||
// typeswitch on the incoming data type and fill the memory
|
||||
switch (data.type) {
|
||||
case "message":
|
||||
store.messages.set(data.id, <Message>data);
|
||||
store.messages.set(data.renoteId ?? data.id, <Message>data);
|
||||
break;
|
||||
case "author":
|
||||
store.profileCache.set(data.id, <Author>data);
|
||||
|
@ -49,22 +68,52 @@ export class DatagramSocket {
|
|||
break;
|
||||
}
|
||||
}
|
||||
// if the adapter is active signal it that there's new data
|
||||
let adapter = util.$(`adapter_${data.adapter}`);
|
||||
if (adapter) {
|
||||
adapter.setAttribute("data-latest", data.id);
|
||||
// go through each type of component and give it the latest if it's relevant to them
|
||||
let profileTargets = document.querySelectorAll(`underbbs-profile[data-adapter="${data.adapter}"][data-target="${data.id}"]`);
|
||||
profileTargets.forEach(t=>{
|
||||
t.setAttribute("data-latest", data.id);
|
||||
});
|
||||
let byAuthorTargets = document.querySelectorAll(`underbbs-author-messages[data-adapter="${data.adapter}"][data-target="${data.author}"]`);
|
||||
byAuthorTargets.forEach(t=>{
|
||||
t.setAttribute("data-latest", data.id);
|
||||
});
|
||||
if (data.renoter) {
|
||||
let byAuthorTargetsForBoosts = document.querySelectorAll(`underbbs-author-messages[data-adapter="${data.adapter}"][data-target="${data.renoter}"]`);
|
||||
byAuthorTargetsForBoosts.forEach(t=>{
|
||||
console.log("setting renote id on data-latest")
|
||||
t.setAttribute("data-latest", data.renoteId);
|
||||
});
|
||||
}
|
||||
if (data.target) {
|
||||
|
||||
console.log("data has target: " + data.target);
|
||||
let e = document.querySelector(`underbbs-timeline#${data.target}[data-adapter="${data.adapter}"]`);
|
||||
if (e) {
|
||||
e.setAttribute("data-latest", data.renoteId ?? data.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static connect(): void {
|
||||
static connect(gateway: string): void {
|
||||
|
||||
DatagramSocket._gateway = gateway;
|
||||
|
||||
const wsProto = gateway.startsWith("https://") ? "wss" : "ws";
|
||||
let wsUrl = ""
|
||||
if (!gateway) {
|
||||
wsUrl = `${wsProto}://${location.host}/subscribe`;
|
||||
} else {
|
||||
if (gateway.startsWith("https:")) {
|
||||
gateway = gateway.split("https://")[1];
|
||||
}
|
||||
wsUrl = wsProto + "://" + gateway + "/subscribe"
|
||||
}
|
||||
const _conn = new WebSocket(wsUrl, "underbbs");
|
||||
|
||||
const wsProto = location.protocol == "https:" ? "wss" : "ws";
|
||||
const _conn = new WebSocket(`${wsProto}://${location.host}/subscribe`, "underbbs");
|
||||
_conn.addEventListener("open", DatagramSocket.onOpen);
|
||||
_conn.addEventListener("message", DatagramSocket.onMsg);
|
||||
|
||||
_conn.addEventListener("error", (e: any) => {
|
||||
console.log("websocket connection error");
|
||||
console.log(JSON.stringify(e));
|
||||
|
@ -73,3 +122,20 @@ export class DatagramSocket {
|
|||
}
|
||||
}
|
||||
|
||||
function init() {
|
||||
const saveData = localStorage.getItem("underbbs_settings");
|
||||
Settings._instance = saveData ? JSON.parse(saveData) : new Settings();
|
||||
|
||||
customElements.define("underbbs-message", MessageElement);
|
||||
customElements.define("underbbs-settings", SettingsElement);
|
||||
customElements.define("underbbs-profile", ProfileElement);
|
||||
customElements.define("underbbs-timeline", TimelineElement);
|
||||
customElements.define("underbbs-timeline-filter", TimelineFilterElement);
|
||||
customElements.define("underbbs-create-message", CreateMessageElement);
|
||||
customElements.define("underbbs-navigator", NavigatorElement);
|
||||
|
||||
console.log("underbbs initialized!")
|
||||
}
|
||||
|
||||
init();
|
||||
|
||||
|
|
7
go.mod
7
go.mod
|
@ -1,13 +1,15 @@
|
|||
module forge.lightcrystal.systems/lightcrystal/underbbs
|
||||
module forge.lightcrystal.systems/nilix/underbbs
|
||||
|
||||
go 1.22.0
|
||||
|
||||
require (
|
||||
forge.lightcrystal.systems/nilix/quartzgun v0.4.2
|
||||
github.com/McKael/madon v2.3.0+incompatible
|
||||
github.com/go-fed/httpsig v1.1.0
|
||||
github.com/nbd-wtf/go-nostr v0.31.2
|
||||
github.com/rs/cors v1.11.1
|
||||
github.com/yitsushi/go-misskey v1.1.6
|
||||
golang.org/x/time v0.5.0
|
||||
hacklab.nilfm.cc/quartzgun v0.3.2
|
||||
nhooyr.io/websocket v1.8.11
|
||||
)
|
||||
|
||||
|
@ -29,6 +31,7 @@ require (
|
|||
github.com/tidwall/gjson v1.14.4 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.0 // indirect
|
||||
golang.org/x/crypto v0.23.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53 // indirect
|
||||
golang.org/x/net v0.25.0 // indirect
|
||||
golang.org/x/oauth2 v0.21.0 // indirect
|
||||
|
|
18
go.sum
18
go.sum
|
@ -1,3 +1,5 @@
|
|||
forge.lightcrystal.systems/nilix/quartzgun v0.4.2 h1:S4ae33noQ+ilMvAKNh50KfwLb+SQqeXKZSoWMRsjEoM=
|
||||
forge.lightcrystal.systems/nilix/quartzgun v0.4.2/go.mod h1:hIXDh7AKtAVekjR6RIFW94d/c7cCQbyh8mzaTmP/pM8=
|
||||
github.com/McKael/madon v2.3.0+incompatible h1:xMUA+Fy4saDV+8tN3MMnwJUoYWC//5Fy8LeOqJsRNIM=
|
||||
github.com/McKael/madon v2.3.0+incompatible/go.mod h1:+issnvJjN1rpjAHZwXRB/x30uHh/NoQR7QaojJK/lSI=
|
||||
github.com/btcsuite/btcd/btcec/v2 v2.3.2 h1:5n0X6hX0Zk+6omWcihdYvdAlGf2DfasC0GMf7DClJ3U=
|
||||
|
@ -11,6 +13,8 @@ github.com/decred/dcrd/crypto/blake256 v1.0.1 h1:7PltbUIQB7u/FfZ39+DGa/ShuMyJ5il
|
|||
github.com/decred/dcrd/crypto/blake256 v1.0.1/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0=
|
||||
github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=
|
||||
github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM=
|
||||
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
|
||||
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
|
||||
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
|
||||
|
@ -33,6 +37,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
|||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/puzpuzpuz/xsync/v3 v3.0.2 h1:3yESHrRFYr6xzkz61LLkvNiPFXxJEAABanTQpKbAaew=
|
||||
github.com/puzpuzpuz/xsync/v3 v3.0.2/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
|
||||
github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA=
|
||||
github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
|
||||
github.com/sendgrid/rest v2.6.9+incompatible h1:1EyIcsNdn9KIisLW50MKwmSRSK+ekueiEMJ7NEoxJo0=
|
||||
github.com/sendgrid/rest v2.6.9+incompatible/go.mod h1:kXX7q3jZtJXK5c5qK83bSGMdV6tsOE70KbHoqJls4lE=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
|
@ -47,23 +53,31 @@ github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
|||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53 h1:5llv2sWeaMSnA3w2kS57ouQQ4pudlXrR0dCgw51QK9o=
|
||||
golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs=
|
||||
golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.20.0 h1:VnkxpohqXaOBYJtBmEppKUG6mXpi+4O6purfc2+sMhw=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
|
||||
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
hacklab.nilfm.cc/quartzgun v0.3.2 h1:PmRFZ/IgsXVWyNn1iOsQ/ZeMnOQIQy0PzFakhXBdZoU=
|
||||
hacklab.nilfm.cc/quartzgun v0.3.2/go.mod h1:P6qK4HB0CD/xfyRq8wdEGevAPFDDmv0KCaESSvv93LU=
|
||||
nhooyr.io/websocket v1.8.11 h1:f/qXNc2/3DpoSZkHt1DQu6rj4zGC8JmkkLkWss0MgN0=
|
||||
nhooyr.io/websocket v1.8.11/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c=
|
||||
|
|
|
@ -12,7 +12,7 @@ type Datagram struct {
|
|||
Type string `json:"type"`
|
||||
Target *string `json:"target,omitempty"`
|
||||
Created int64 `json:"created"`
|
||||
Updated *int64 `json:"updated,omitempty"`
|
||||
Updated *int64 `json:"edited,omitempty"`
|
||||
}
|
||||
|
||||
type Message struct {
|
||||
|
@ -25,6 +25,9 @@ type Message struct {
|
|||
ReplyCount int `json:"replyCount"`
|
||||
Mentions []string `json:"mentions"`
|
||||
Visibility string `json:"visibility"`
|
||||
RenoteId *string `json:"renoteId,omitempty"`
|
||||
Renoter *string `json:"renoter,omitempty"`
|
||||
RenoteTime *int64 `json:"renoteTime,omitempty"`
|
||||
}
|
||||
|
||||
type Author struct {
|
||||
|
|
|
@ -1,5 +1,16 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
)
|
||||
|
||||
type GlobalSettings struct {
|
||||
ApKey *string `json:"apkey",omitempty`
|
||||
ApDomain *string `json:"apDomain",omitempty`
|
||||
Port int `json:"port"`
|
||||
Adapters []Settings `json:"adapters"`
|
||||
}
|
||||
|
||||
type Settings struct {
|
||||
Nickname string
|
||||
Protocol string
|
||||
|
@ -7,4 +18,7 @@ type Settings struct {
|
|||
Relays []string `json:"relays",omitempty`
|
||||
Server *string `json:"server",omitempty`
|
||||
ApiKey *string `json:"apiKey",omitempty`
|
||||
Handle *string `json:"handle",omitempty`
|
||||
Password *string `json:"password",omitempty`
|
||||
ApSigner *crypto.PrivateKey `json:"_",omitempty`
|
||||
}
|
||||
|
|
225
server/api.go
225
server/api.go
|
@ -1,25 +1,38 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"forge.lightcrystal.systems/lightcrystal/underbbs/adapter"
|
||||
"forge.lightcrystal.systems/lightcrystal/underbbs/models"
|
||||
"hacklab.nilfm.cc/quartzgun/renderer"
|
||||
"hacklab.nilfm.cc/quartzgun/router"
|
||||
"hacklab.nilfm.cc/quartzgun/util"
|
||||
"forge.lightcrystal.systems/nilix/quartzgun/renderer"
|
||||
"forge.lightcrystal.systems/nilix/quartzgun/router"
|
||||
"forge.lightcrystal.systems/nilix/quartzgun/util"
|
||||
"forge.lightcrystal.systems/nilix/underbbs/adapter"
|
||||
"forge.lightcrystal.systems/nilix/underbbs/models"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func getSubscriberKey(req *http.Request) (string, error) {
|
||||
authHeader := req.Header.Get("Authorization")
|
||||
if strings.HasPrefix(authHeader, "Bearer ") {
|
||||
return strings.Split(authHeader, "Bearer ")[1], nil
|
||||
}
|
||||
return "", errors.New("No subscriber key")
|
||||
type PrivKeyAux interface {
|
||||
Public() crypto.PublicKey
|
||||
Equal(x crypto.PrivateKey) bool
|
||||
}
|
||||
|
||||
type doRequest struct {
|
||||
Action string `json:"action"`
|
||||
Content *string `json:"content,omitempty"`
|
||||
File *string `json:"file,omitempty"`
|
||||
Desc *string `json:"desc,omitempty"`
|
||||
}
|
||||
|
||||
func getSubscriberKey(req *http.Request) string {
|
||||
return req.Header.Get("X-Underbbs-Subscriber")
|
||||
}
|
||||
|
||||
func getSubscriberByKey(key string, subscribers map[*Subscriber][]adapter.Adapter) *Subscriber {
|
||||
|
@ -33,38 +46,30 @@ func getSubscriberByKey(key string, subscribers map[*Subscriber][]adapter.Adapte
|
|||
|
||||
func setAdaptersForSubscriber(key string, adapters []adapter.Adapter, subscribers map[*Subscriber][]adapter.Adapter) error {
|
||||
var ptr *Subscriber = nil
|
||||
fmt.Print("looking for subscriber in map..")
|
||||
for s, _ := range subscribers {
|
||||
fmt.Print(".")
|
||||
if s.key == key {
|
||||
ptr = s
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
if ptr != nil {
|
||||
fmt.Println("setting adaters for the found subscriber: " + ptr.key)
|
||||
subscribers[ptr] = adapters
|
||||
return nil
|
||||
}
|
||||
return errors.New("subscriber not present in map")
|
||||
}
|
||||
|
||||
func apiConfigureAdapters(next http.Handler, subscribers map[*Subscriber][]adapter.Adapter) http.Handler {
|
||||
func apiConfigureAdapters(next http.Handler, subscribers map[*Subscriber][]adapter.Adapter, apKey *crypto.PrivateKey) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
// get subscriber key
|
||||
skey, err := getSubscriberKey(req)
|
||||
if err != nil {
|
||||
w.WriteHeader(500)
|
||||
return
|
||||
}
|
||||
skey := getSubscriberKey(req)
|
||||
subscriber := getSubscriberByKey(skey, subscribers)
|
||||
if subscriber == nil {
|
||||
w.WriteHeader(404)
|
||||
return
|
||||
}
|
||||
|
||||
// decode adapter config from request body
|
||||
settings := make([]models.Settings, 0)
|
||||
err = json.NewDecoder(req.Body).Decode(&settings)
|
||||
err := json.NewDecoder(req.Body).Decode(&settings)
|
||||
if err != nil {
|
||||
w.WriteHeader(400)
|
||||
next.ServeHTTP(w, req)
|
||||
|
@ -79,13 +84,18 @@ func apiConfigureAdapters(next http.Handler, subscribers map[*Subscriber][]adapt
|
|||
a = &adapter.NostrAdapter{}
|
||||
case "mastodon":
|
||||
a = &adapter.MastoAdapter{}
|
||||
s.ApSigner = apKey
|
||||
case "misskey":
|
||||
a = &adapter.MisskeyAdapter{}
|
||||
s.ApSigner = apKey
|
||||
case "honk":
|
||||
a = &adapter.HonkAdapter{}
|
||||
s.ApSigner = apKey
|
||||
default:
|
||||
break
|
||||
|
||||
}
|
||||
err := a.Init(s, subscriber.data)
|
||||
err := a.Init(s, &subscriber.data)
|
||||
if err != nil {
|
||||
util.AddContextValue(req, "data", err.Error())
|
||||
w.WriteHeader(500)
|
||||
|
@ -93,25 +103,10 @@ func apiConfigureAdapters(next http.Handler, subscribers map[*Subscriber][]adapt
|
|||
return
|
||||
}
|
||||
|
||||
fmt.Println("adapter initialized - subscribing with default filter")
|
||||
|
||||
errs := a.Subscribe(a.DefaultSubscriptionFilter())
|
||||
if errs != nil {
|
||||
errMsg := ""
|
||||
for _, e := range errs {
|
||||
fmt.Println("processing an error")
|
||||
errMsg += fmt.Sprintf("- %s\n", e.Error())
|
||||
}
|
||||
util.AddContextValue(req, "data", errMsg)
|
||||
w.WriteHeader(500)
|
||||
next.ServeHTTP(w, req)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("adapter ready for use; adding to array")
|
||||
log.Print("adapter initialized; adding to array")
|
||||
|
||||
adapters = append(adapters, a)
|
||||
fmt.Println("adapter added to array")
|
||||
log.Print("adapter added to array")
|
||||
}
|
||||
|
||||
// TODO: cancel subscriptions on any existing adapters
|
||||
|
@ -128,40 +123,59 @@ func apiConfigureAdapters(next http.Handler, subscribers map[*Subscriber][]adapt
|
|||
})
|
||||
}
|
||||
|
||||
func apiGetAdapters(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
w.WriteHeader(201)
|
||||
next.ServeHTTP(w, req)
|
||||
})
|
||||
type subscribeParams struct {
|
||||
Filter string `json:"filter"`
|
||||
Target *string `json:"target,omitempty"`
|
||||
}
|
||||
|
||||
func apiAdapterSubscribe(next http.Handler) http.Handler {
|
||||
func apiAdapterSubscribe(next http.Handler, subscribers map[*Subscriber][]adapter.Adapter) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
skey := getSubscriberKey(req)
|
||||
subscriber := getSubscriberByKey(skey, subscribers)
|
||||
if subscriber == nil {
|
||||
w.WriteHeader(404)
|
||||
return
|
||||
}
|
||||
|
||||
adapters := subscribers[subscriber]
|
||||
|
||||
urlParams := req.Context().Value("params").(map[string]string)
|
||||
adapter := urlParams["adapter_id"]
|
||||
sp := subscribeParams{}
|
||||
err := json.NewDecoder(req.Body).Decode(&sp)
|
||||
if err != nil {
|
||||
w.WriteHeader(500)
|
||||
fmt.Println(err.Error())
|
||||
}
|
||||
for _, a := range adapters {
|
||||
if a.Name() == adapter {
|
||||
fmt.Printf("adapter.subscribe call: %s {%s, %s}\n", adapter, sp.Filter, *sp.Target)
|
||||
a.Subscribe(sp.Filter, sp.Target)
|
||||
|
||||
w.WriteHeader(201)
|
||||
|
||||
next.ServeHTTP(w, req)
|
||||
}
|
||||
}
|
||||
w.WriteHeader(404)
|
||||
})
|
||||
}
|
||||
|
||||
func ProtectWithSubscriberKey(next http.Handler, subscribers map[*Subscriber][]adapter.Adapter) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
authHeader := req.Header.Get("Authorization")
|
||||
if strings.HasPrefix(authHeader, "Bearer ") {
|
||||
subscriberKey := strings.Split(authHeader, "Bearer ")[1]
|
||||
if getSubscriberByKey(subscriberKey, subscribers) != nil {
|
||||
skey := getSubscriberKey(req)
|
||||
if getSubscriberByKey(skey, subscribers) != nil {
|
||||
next.ServeHTTP(w, req)
|
||||
return
|
||||
}
|
||||
}
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
})
|
||||
}
|
||||
|
||||
func apiAdapterFetch(next http.Handler, subscribers map[*Subscriber][]adapter.Adapter) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
authHeader := req.Header.Get("Authorization")
|
||||
if strings.HasPrefix(authHeader, "Bearer ") {
|
||||
subscriberKey := strings.Split(authHeader, "Bearer ")[1]
|
||||
s := getSubscriberByKey(subscriberKey, subscribers)
|
||||
skey := getSubscriberKey(req)
|
||||
s := getSubscriberByKey(skey, subscribers)
|
||||
if s != nil {
|
||||
apiParams := req.Context().Value("params").(map[string]string)
|
||||
queryParams := req.URL.Query()
|
||||
|
@ -169,7 +183,7 @@ func apiAdapterFetch(next http.Handler, subscribers map[*Subscriber][]adapter.Ad
|
|||
if a.Name() == apiParams["adapter_id"] {
|
||||
err := a.Fetch(queryParams["entity_type"][0], queryParams["entity_id"])
|
||||
if err != nil {
|
||||
fmt.Println(err.Error())
|
||||
log.Print(err.Error())
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
} else {
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
|
@ -179,8 +193,82 @@ func apiAdapterFetch(next http.Handler, subscribers map[*Subscriber][]adapter.Ad
|
|||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func apiAdapterDo(next http.Handler, subscribers map[*Subscriber][]adapter.Adapter) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
skey := getSubscriberKey(req)
|
||||
s := getSubscriberByKey(skey, subscribers)
|
||||
if s != nil {
|
||||
apiParams := req.Context().Value("params").(map[string]string)
|
||||
for _, a := range subscribers[s] {
|
||||
if a.Name() == apiParams["adapter_id"] {
|
||||
// request body is json
|
||||
// if we have a `file`, it needs to be transformed from base64 to standard bytes/string
|
||||
doReq := map[string]string{}
|
||||
err := json.NewDecoder(req.Body).Decode(&doReq)
|
||||
if err != nil {
|
||||
w.WriteHeader(422)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
if f, exists := doReq["file"]; exists {
|
||||
firstCommaIdx := strings.Index(f, ",")
|
||||
rawFile, err := base64.StdEncoding.DecodeString(f[firstCommaIdx+1:])
|
||||
if err != nil {
|
||||
w.WriteHeader(500)
|
||||
return
|
||||
}
|
||||
doReq["file"] = string(rawFile)
|
||||
}
|
||||
action, ok := doReq["action"]
|
||||
if ok {
|
||||
delete(doReq, "action")
|
||||
} else {
|
||||
w.WriteHeader(422)
|
||||
return
|
||||
}
|
||||
a.Do(action, doReq)
|
||||
next.ServeHTTP(w, req)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func apiGetActor(next http.Handler, apKey crypto.PrivateKey, apDomain string) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
pubkey := apKey.(PrivKeyAux).Public()
|
||||
pubkeypem, err := x509.MarshalPKIXPublicKey(pubkey)
|
||||
|
||||
if err != nil {
|
||||
w.WriteHeader(500)
|
||||
return
|
||||
}
|
||||
|
||||
encodedkey := make([]byte, len(pubkeypem)*2)
|
||||
base64.StdEncoding.Encode(encodedkey, pubkeypem)
|
||||
lastpos := bytes.Index(encodedkey, []byte{byte(0)})
|
||||
|
||||
keystring := "-----BEGIN PUBLIC KEY-----\n"
|
||||
keystring += string(encodedkey[:lastpos])
|
||||
keystring += "\n-----END PUBLIC KEY-----\n"
|
||||
|
||||
actor := adapter.ApActor{
|
||||
Id: apDomain + "/api/actor",
|
||||
Name: "underbbs_actor",
|
||||
PreferredUsername: "underbbs_actor",
|
||||
Summary: "gateway for building modular social integrations",
|
||||
Url: apDomain + "/api/actor",
|
||||
PublicKey: adapter.ApKey{
|
||||
Id: apDomain + "/api/actor#key",
|
||||
Owner: apDomain + "/api/actor",
|
||||
PublicKeyPem: keystring,
|
||||
},
|
||||
}
|
||||
util.AddContextValue(req, "actor", actor)
|
||||
next.ServeHTTP(w, req)
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -193,19 +281,13 @@ func (self *BBSServer) apiMux() http.Handler {
|
|||
Fallback: *errTemplate,
|
||||
}
|
||||
|
||||
// adapters (POST & GET)
|
||||
rtr.Post("/adapters", ProtectWithSubscriberKey(
|
||||
apiConfigureAdapters(renderer.JSON("data"), self.subscribers),
|
||||
self.subscribers,
|
||||
))
|
||||
rtr.Get("/adapters", ProtectWithSubscriberKey(
|
||||
apiGetAdapters(renderer.JSON("data")),
|
||||
apiConfigureAdapters(renderer.JSON("data"), self.subscribers, self.apKey),
|
||||
self.subscribers,
|
||||
))
|
||||
|
||||
// adapters/:name/subscribe
|
||||
rtr.Post(`/adapters/(?P<id>\S+)/subscribe`, ProtectWithSubscriberKey(
|
||||
apiAdapterSubscribe(renderer.JSON("data")),
|
||||
rtr.Post(`/adapters/(?P<adapter_id>\S+)/subscribe`, ProtectWithSubscriberKey(
|
||||
apiAdapterSubscribe(renderer.JSON("data"), self.subscribers),
|
||||
self.subscribers,
|
||||
))
|
||||
|
||||
|
@ -214,5 +296,14 @@ func (self *BBSServer) apiMux() http.Handler {
|
|||
self.subscribers,
|
||||
))
|
||||
|
||||
rtr.Post(`/adapters/(?P<adapter_id>\S+)/do`, ProtectWithSubscriberKey(
|
||||
apiAdapterDo(renderer.JSON("data"), self.subscribers),
|
||||
self.subscribers,
|
||||
))
|
||||
|
||||
if self.apKey != nil && self.apDomain != nil {
|
||||
rtr.Get("/actor", apiGetActor(renderer.JSON("actor"), *self.apKey, *self.apDomain))
|
||||
}
|
||||
|
||||
return http.HandlerFunc(rtr.ServeHTTP)
|
||||
}
|
||||
|
|
|
@ -2,13 +2,16 @@ package server
|
|||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"forge.lightcrystal.systems/lightcrystal/underbbs/adapter"
|
||||
"forge.lightcrystal.systems/lightcrystal/underbbs/models"
|
||||
"forge.lightcrystal.systems/nilix/quartzgun/cookie"
|
||||
"forge.lightcrystal.systems/nilix/quartzgun/renderer"
|
||||
"forge.lightcrystal.systems/nilix/underbbs/adapter"
|
||||
"forge.lightcrystal.systems/nilix/underbbs/models"
|
||||
_ "github.com/rs/cors"
|
||||
"golang.org/x/time/rate"
|
||||
"hacklab.nilfm.cc/quartzgun/cookie"
|
||||
"hacklab.nilfm.cc/quartzgun/renderer"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net/http"
|
||||
|
@ -31,20 +34,70 @@ type BBSServer struct {
|
|||
serveMux http.ServeMux
|
||||
subscribersLock sync.Mutex
|
||||
subscribers map[*Subscriber][]adapter.Adapter
|
||||
apKey *crypto.PrivateKey
|
||||
apDomain *string
|
||||
}
|
||||
|
||||
func New() *BBSServer {
|
||||
srvr := &BBSServer{
|
||||
func CORS(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Add("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Add("Access-Control-Allow-Credentials", "true")
|
||||
w.Header().Add("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With, X-Underbbs-Subscriber")
|
||||
w.Header().Add("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, DELETE")
|
||||
|
||||
if r.Method == "OPTIONS" {
|
||||
http.Error(w, "No Content", http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func New(config models.GlobalSettings) *BBSServer {
|
||||
var apKey crypto.PrivateKey
|
||||
|
||||
useApKey := false
|
||||
|
||||
if config.ApKey != nil && config.ApDomain != nil {
|
||||
keybytes, err := ioutil.ReadFile(*config.ApKey)
|
||||
if err != nil {
|
||||
panic(err.Error())
|
||||
}
|
||||
keypem, _ := pem.Decode(keybytes)
|
||||
if keypem == nil {
|
||||
panic("couldn't decode pem block from AP key")
|
||||
}
|
||||
apKey, err = x509.ParsePKCS8PrivateKey(keypem.Bytes)
|
||||
if err != nil {
|
||||
panic(err.Error())
|
||||
}
|
||||
useApKey = true
|
||||
}
|
||||
|
||||
var srvr *BBSServer
|
||||
|
||||
if useApKey {
|
||||
srvr = &BBSServer{
|
||||
subscribeMessageBuffer: 16,
|
||||
logf: log.Printf,
|
||||
subscribers: make(map[*Subscriber][]adapter.Adapter),
|
||||
apKey: &apKey,
|
||||
apDomain: config.ApDomain,
|
||||
}
|
||||
} else {
|
||||
srvr = &BBSServer{
|
||||
subscribeMessageBuffer: 16,
|
||||
logf: log.Printf,
|
||||
subscribers: make(map[*Subscriber][]adapter.Adapter),
|
||||
}
|
||||
}
|
||||
|
||||
// frontend is here
|
||||
srvr.serveMux.Handle("/app/", http.StripPrefix("/app/", renderer.Subtree("./frontend/dist")))
|
||||
|
||||
// api
|
||||
srvr.serveMux.Handle("/api/", http.StripPrefix("/api", srvr.apiMux()))
|
||||
srvr.serveMux.Handle("/api/", http.StripPrefix("/api", CORS(srvr.apiMux())))
|
||||
|
||||
// websocket
|
||||
srvr.serveMux.HandleFunc("/subscribe", srvr.subscribeHandler)
|
||||
|
@ -62,6 +115,7 @@ func (self *BBSServer) subscribeHandler(w http.ResponseWriter, r *http.Request)
|
|||
|
||||
c, err := websocket.Accept(w, r, &websocket.AcceptOptions{
|
||||
Subprotocols: []string{},
|
||||
OriginPatterns: []string{"*"},
|
||||
})
|
||||
if err != nil {
|
||||
self.logf("%v", err)
|
||||
|
@ -90,14 +144,18 @@ func (self *BBSServer) subscribeHandler(w http.ResponseWriter, r *http.Request)
|
|||
defer c.Close(websocket.StatusInternalError, "")
|
||||
|
||||
go func() {
|
||||
fmt.Println("waiting for data on the subscriber's channel")
|
||||
self.logf("waiting for data on the subscriber's channel")
|
||||
for {
|
||||
select {
|
||||
case msg := <-s.msgs:
|
||||
writeTimeout(ctx, time.Second*5, c, msg)
|
||||
|
||||
case <-ctx.Done():
|
||||
fmt.Println("subscriber has disconnected")
|
||||
self.logf("subscriber has disconnected")
|
||||
for _, a := range self.subscribers[s] {
|
||||
// keeps any adapter's subscription from trying to send on the data channel after we close it
|
||||
a.Stop()
|
||||
}
|
||||
close(s.data)
|
||||
return //ctx.Err()
|
||||
}
|
||||
|
@ -110,7 +168,7 @@ func (self *BBSServer) subscribeHandler(w http.ResponseWriter, r *http.Request)
|
|||
// block on the data channel, serializing and passing the data to the subscriber
|
||||
listen([]chan models.SocketData{s.data}, s.msgs)
|
||||
|
||||
fmt.Println("data listener is done!")
|
||||
self.logf("data listener is done!")
|
||||
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return
|
||||
|
|
40
underbbs.go
40
underbbs.go
|
@ -2,31 +2,59 @@ package main
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"forge.lightcrystal.systems/lightcrystal/underbbs/server"
|
||||
"forge.lightcrystal.systems/nilix/underbbs/cli"
|
||||
"forge.lightcrystal.systems/nilix/underbbs/config"
|
||||
"forge.lightcrystal.systems/nilix/underbbs/models"
|
||||
"forge.lightcrystal.systems/nilix/underbbs/server"
|
||||
)
|
||||
|
||||
func main() {
|
||||
err := run()
|
||||
|
||||
args := os.Args
|
||||
|
||||
var err error = nil
|
||||
|
||||
progname := filepath.Base(args[0])
|
||||
|
||||
cfg, err := config.LoadConfig()
|
||||
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
panic(err.Error())
|
||||
}
|
||||
|
||||
switch progname {
|
||||
case "underbbs-cli":
|
||||
err = run_cli(cfg, args[1:]...)
|
||||
default:
|
||||
err = run_srvr(cfg)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
fmt.Println(err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
l, err := net.Listen("tcp", ":"+strconv.FormatInt(int64(9090), 10))
|
||||
func run_cli(settings models.GlobalSettings, args ...string) error {
|
||||
return cli.Process(settings, args...)
|
||||
}
|
||||
|
||||
func run_srvr(settings models.GlobalSettings) error {
|
||||
l, err := net.Listen("tcp", ":"+strconv.FormatInt(int64(settings.Port), 10))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
bbsServer := server.New()
|
||||
bbsServer := server.New(settings)
|
||||
s := &http.Server{
|
||||
Handler: bbsServer,
|
||||
ReadTimeout: time.Second * 10,
|
||||
|
|
|
@ -4,10 +4,10 @@ module.exports = {
|
|||
mode: 'production',
|
||||
context: path.resolve(__dirname, 'frontend', '.js'),
|
||||
entry: {
|
||||
main: './index.js',
|
||||
serviceWorker: './serviceWorker.js',
|
||||
underbbs: './websocket.js',
|
||||
},
|
||||
output: {
|
||||
iife: false,
|
||||
filename: '[name].js',
|
||||
path: path.resolve(__dirname, 'frontend', 'dist'),
|
||||
},
|
||||
|
|
Loading…
Reference in a new issue