Compare commits

..

26 commits

Author SHA1 Message Date
dd27ffabaa
implement authorized fetch and cors 2025-01-04 20:45:22 -07:00
6bb5eff11b
add httpsig and fix webfinger lookups 2024-12-16 20:50:05 -07:00
1806140f4b
merge author-messages and timeline components, start implementing navigator for viewing individual messages and authors 2024-12-11 20:19:11 -07:00
2ff69b9d38
add CreateMessageElement and enable interactivity on messages (replies) 2024-12-09 21:18:08 -07:00
264be94427
fix bonks on timeline, flesh out HonkAdapter.Do and add an API wrapper 2024-12-09 10:35:44 -07:00
ab8249e0bf
update README a bit 2024-12-07 18:15:45 -07:00
2c39f7fd3c
change webpack config and fine tune to use the frontend as a library 2024-12-07 18:04:04 -07:00
508722feb5
update module path 2024-12-06 19:30:41 -07:00
bbf26561f6
changing timeline filters works 2024-12-06 19:25:58 -07:00
03ee058fe7
remove extra console spam 2024-12-05 21:08:11 -07:00
d61c786ffd
remove old components, get subscribe/timeline working with honk adapter 2024-12-05 21:05:08 -07:00
b00f26b17d
HonkAdapter: subscribe! 2024-12-04 20:22:38 -07:00
a053406fc5
fix boosts for anon AP/honk 2024-12-03 23:44:11 -07:00
5b20ff3135
message list element implemented; needs styling and to fix bonks 2024-12-02 13:10:15 -07:00
51bd3a6505
get pull a user's public posts, we just need to fill the dom with the data 2024-12-01 17:35:49 -07:00
cae8c8b597
correct type names for AP 2024-12-01 16:26:12 -07:00
64d00083b7
redoing frontend stuff, we have a basic profile fetch working 2024-12-01 14:08:02 -07:00
305366dc9e
honk fetch just calls anonymous fetch cause the honk API is so tiny 2024-12-01 10:52:36 -07:00
a2017e3de8
basically finish anonAp adapter, it can fetch a user's profile, their outbox, or an individual note 2024-12-01 09:02:12 -07:00
194a5aed48
generic AP and honk progress 2024-11-29 12:00:54 -07:00
3d99b53935
update quartzgun, continue with honk adapter 2024-11-28 16:51:12 -07:00
e3c1f9d54b
change adapter API, implement honk::do::post 2024-11-01 19:13:21 -06:00
9195bba7ed
get ready for more implementation work 2024-10-02 18:54:14 -06:00
a7e5c99cec
add skeleton for CLI processor 2024-10-01 19:49:13 -06:00
c6cfdf9e9f
refactor things to enable CLI mode 2024-09-28 11:39:03 -06:00
09c7eb8318
start implementing boost carousel 2024-08-31 11:01:31 -06:00
40 changed files with 2075 additions and 944 deletions

1
.gitignore vendored
View file

@ -2,4 +2,5 @@ node_modules/
frontend/dist/*.js frontend/dist/*.js
frontend/.js frontend/.js
underbbs underbbs
underbbs-cli
__debug_* __debug_*

View file

@ -1,27 +1,43 @@
# underBBS # underBBS
underBBS is a platform-agnostic messaging and social media client (feat consultation and motivational support from underBBS is a protocol-agnostic decentralized social media client and toolkit
miggymofongo!!!)
## design ## 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. `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 ## building and running
requirements are requirements are
- go 1.22 - go 1.22 (for the backend)
- any recent nodejs that can do `typescript` and `webpack` 5 - any recent nodejs that can do `typescript` and `webpack` 5 (for the frontend)
from the project root: from the project root:
1. `./build.sh front` 1. `./build.sh front` (if you will use the web components)
2. `./build.sh server` 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

View file

@ -1,14 +1,15 @@
package adapter package adapter
import ( import (
. "forge.lightcrystal.systems/lightcrystal/underbbs/models" . "forge.lightcrystal.systems/nilix/underbbs/models"
) )
type Adapter interface { type Adapter interface {
Init(Settings, chan SocketData) error Init(Settings, *chan SocketData) error
Stop()
Name() string Name() string
Subscribe(string) []error Subscribe(string, *string) []error
Fetch(string, []string) error Fetch(string, []string) error
Do(string) error Do(string, map[string]string) error
DefaultSubscriptionFilter() string DefaultSubscriptionFilter() string
} }

393
adapter/anonAp.go Normal file
View 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
View 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"
}

View file

@ -2,12 +2,14 @@ package adapter
import ( import (
"fmt" "fmt"
. "forge.lightcrystal.systems/lightcrystal/underbbs/models" . "forge.lightcrystal.systems/nilix/underbbs/models"
madon "github.com/McKael/madon" madon "github.com/McKael/madon"
"log"
"os"
) )
type MastoAdapter struct { type MastoAdapter struct {
data chan SocketData data *chan SocketData
nickname string nickname string
server string server string
apiKey string apiKey string
@ -21,11 +23,19 @@ type MastoAdapter struct {
var scopes = []string{"read", "write", "follow"} 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 { func (self *MastoAdapter) Name() string {
return self.nickname 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.nickname = settings.Nickname
self.server = *settings.Server self.server = *settings.Server
self.apiKey = *settings.ApiKey self.apiKey = *settings.ApiKey
@ -40,7 +50,11 @@ func (self *MastoAdapter) Init(settings Settings, data chan SocketData) error {
return err 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 // TODO: decode separate timelines and hashtags
// for now, the filter is just the timeline // for now, the filter is just the timeline
@ -61,8 +75,9 @@ func (self *MastoAdapter) Subscribe(filter string) []error {
return []error{err} return []error{err}
} }
go func() { go func() {
for e := range self.events { ee := self.events
fmt.Println("event: %s !!!", e.Event) for e := range ee {
log.Printf("event: %s !!!", e.Event)
switch e.Event { switch e.Event {
case "error": case "error":
case "update": case "update":
@ -77,7 +92,7 @@ func (self *MastoAdapter) Subscribe(filter string) []error {
msg = self.mastoUpdateToMessage(v) msg = self.mastoUpdateToMessage(v)
} }
if msg != nil { if msg != nil {
self.data <- msg self.send(msg)
} }
case "notification": case "notification":
case "delete": case "delete":
@ -94,7 +109,7 @@ func (self *MastoAdapter) Fetch(etype string, ids []string) error {
return nil return nil
} }
func (self *MastoAdapter) Do(action string) error { func (self *MastoAdapter) Do(action string, data map[string]string) error {
return nil return nil
} }

View file

@ -2,20 +2,22 @@ package adapter
import ( import (
"fmt" "fmt"
. "forge.lightcrystal.systems/lightcrystal/underbbs/models" . "forge.lightcrystal.systems/nilix/underbbs/models"
"github.com/yitsushi/go-misskey" "github.com/yitsushi/go-misskey"
mkcore "github.com/yitsushi/go-misskey/core" mkcore "github.com/yitsushi/go-misskey/core"
mkm "github.com/yitsushi/go-misskey/models" mkm "github.com/yitsushi/go-misskey/models"
n "github.com/yitsushi/go-misskey/services/notes" n "github.com/yitsushi/go-misskey/services/notes"
tl "github.com/yitsushi/go-misskey/services/notes/timeline" tl "github.com/yitsushi/go-misskey/services/notes/timeline"
users "github.com/yitsushi/go-misskey/services/users" users "github.com/yitsushi/go-misskey/services/users"
"log"
"os"
"strings" "strings"
"sync" "sync"
"time" "time"
) )
type MisskeyAdapter struct { type MisskeyAdapter struct {
data chan SocketData data *chan SocketData
nickname string nickname string
server string server string
apiKey string apiKey string
@ -31,19 +33,31 @@ type MisskeyAdapter struct {
stop chan bool 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 { func (self *MisskeyAdapter) Name() string {
return self.nickname return self.nickname
} }
func (self *MisskeyAdapter) Init(settings Settings, data chan SocketData) error { func (self *MisskeyAdapter) Stop() {
fmt.Println("initializing misskey adapter") close(self.stop)
}
func (self *MisskeyAdapter) Init(settings Settings, data *chan SocketData) error {
log.Print("initializing misskey adapter")
self.nickname = settings.Nickname self.nickname = settings.Nickname
self.server = *settings.Server self.server = *settings.Server
self.apiKey = *settings.ApiKey self.apiKey = *settings.ApiKey
self.data = data self.data = data
fmt.Println("getting ready to initialize internal client") log.Print("getting ready to initialize internal client")
client, err := misskey.NewClientWithOptions( client, err := misskey.NewClientWithOptions(
misskey.WithAPIToken(self.apiKey), misskey.WithAPIToken(self.apiKey),
@ -51,10 +65,10 @@ func (self *MisskeyAdapter) Init(settings Settings, data chan SocketData) error
) )
if err != nil { if err != nil {
fmt.Println(err.Error()) log.Print(err.Error())
return err return err
} }
fmt.Println("misskey client initialized") log.Print("misskey client initialized")
self.mk = client self.mk = client
self.cache = make(map[string]time.Time) self.cache = make(map[string]time.Time)
@ -62,7 +76,7 @@ func (self *MisskeyAdapter) Init(settings Settings, data chan SocketData) error
return nil return nil
} }
func (self *MisskeyAdapter) Subscribe(filter string) []error { func (self *MisskeyAdapter) Subscribe(filter string, target *string) []error {
// misskey streaming API is undocumented.... // misskey streaming API is undocumented....
// we could try to reverse engineer it by directly connecting to the websocket??? // we could try to reverse engineer it by directly connecting to the websocket???
// alternatively, we can poll timelines, mentions, etc with a cancellation channel, // alternatively, we can poll timelines, mentions, etc with a cancellation channel,
@ -92,9 +106,11 @@ func (self *MisskeyAdapter) poll() {
var notesService *n.Service var notesService *n.Service
var timelineService *tl.Service var timelineService *tl.Service
x := self.stop
for { for {
select { select {
case _, ok := <-self.stop: case _, ok := <-x:
if !ok { if !ok {
return return
} }
@ -119,10 +135,10 @@ func (self *MisskeyAdapter) poll() {
Limit: 100, Limit: 100,
}) })
if err != nil { if err != nil {
fmt.Println(err.Error()) log.Print(err.Error())
} }
if merr != nil { if merr != nil {
fmt.Println(merr.Error()) log.Print(merr.Error())
} }
// check the cache for everything we just collected // check the cache for everything we just collected
@ -131,13 +147,13 @@ func (self *MisskeyAdapter) poll() {
for _, n := range notes { for _, n := range notes {
msg := self.toMessageIfNew(n) msg := self.toMessageIfNew(n)
if msg != nil { if msg != nil {
self.data <- msg self.send(msg)
} }
} }
for _, n := range mentions { for _, n := range mentions {
msg := self.toMessageIfNew(n) msg := self.toMessageIfNew(n)
if msg != nil { if msg != nil {
self.data <- msg self.send(msg)
} }
} }
@ -150,7 +166,7 @@ func (self *MisskeyAdapter) poll() {
Limit: 100, Limit: 100,
}) })
if err != nil { if err != nil {
fmt.Println(err.Error()) log.Print(err.Error())
} }
for _, n := range notes { for _, n := range notes {
msg := self.toMessageIfNew(n) msg := self.toMessageIfNew(n)
@ -158,7 +174,7 @@ func (self *MisskeyAdapter) poll() {
latest = &probenote[0].CreatedAt latest = &probenote[0].CreatedAt
break break
} }
self.data <- msg self.send(msg)
} }
if *latest == probenote[0].CreatedAt { if *latest == probenote[0].CreatedAt {
break break
@ -216,6 +232,7 @@ func (self *MisskeyAdapter) toMessage(n mkm.Note, bustCache bool) *Message {
ReplyTo: n.ReplyID, ReplyTo: n.ReplyID,
ReplyCount: int(n.RepliesCount), ReplyCount: int(n.RepliesCount),
Replies: []string{}, Replies: []string{},
RenoteId: (*string)(n.RenoteID),
} }
for _, f := range n.Files { 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) { 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 { if usr.UpdatedAt != nil {
self.cache[authorId] = *usr.UpdatedAt self.cache[authorId] = *usr.UpdatedAt
} else { } else {
@ -283,6 +300,8 @@ func (self *MisskeyAdapter) toAuthor(usr mkm.User, bustCache bool) *Author {
func (self *MisskeyAdapter) Fetch(etype string, ids []string) error { func (self *MisskeyAdapter) Fetch(etype string, ids []string) error {
for _, id := range ids { for _, id := range ids {
switch etype { switch etype {
case "byAuthor":
// fetch notes by this author
case "message": case "message":
data, err := self.mk.Notes().Show(id) data, err := self.mk.Notes().Show(id)
if err != nil { if err != nil {
@ -290,7 +309,7 @@ func (self *MisskeyAdapter) Fetch(etype string, ids []string) error {
} else { } else {
msg := self.toMessage(data, true) msg := self.toMessage(data, true)
if msg != nil { if msg != nil {
self.data <- msg self.send(msg)
} }
} }
case "children": case "children":
@ -304,7 +323,7 @@ func (self *MisskeyAdapter) Fetch(etype string, ids []string) error {
for _, n := range data { for _, n := range data {
msg := self.toMessage(n, true) msg := self.toMessage(n, true)
if msg != nil { 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 { for _, n := range data {
msg := self.toMessage(n, true) msg := self.toMessage(n, true)
if msg != nil { if msg != nil {
self.data <- msg self.send(msg)
} }
} }
} }
@ -337,7 +356,6 @@ func (self *MisskeyAdapter) Fetch(etype string, ids []string) error {
hostPtr = &host hostPtr = &host
} }
// fmt.Printf("attempting user resolution: @%s@%s\n", user, host)
data, err := self.mk.Users().Show(users.ShowRequest{ data, err := self.mk.Users().Show(users.ShowRequest{
Username: &user, Username: &user,
Host: hostPtr, Host: hostPtr,
@ -347,16 +365,16 @@ func (self *MisskeyAdapter) Fetch(etype string, ids []string) error {
} else { } else {
a := self.toAuthor(data, false) a := self.toAuthor(data, false)
if a != nil { if a != nil {
self.data <- a self.send(a)
} }
} }
} }
} }
return nil return nil
} }
func (self *MisskeyAdapter) Do(action string) error { func (self *MisskeyAdapter) Do(action string, data map[string]string) error {
return nil return nil
} }

View file

@ -5,23 +5,33 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
. "forge.lightcrystal.systems/lightcrystal/underbbs/models" . "forge.lightcrystal.systems/nilix/underbbs/models"
nostr "github.com/nbd-wtf/go-nostr" nostr "github.com/nbd-wtf/go-nostr"
"log"
"os"
"strings" "strings"
) )
type NostrAdapter struct { type NostrAdapter struct {
data chan SocketData data *chan SocketData
nickname string nickname string
privkey string privkey string
relays []*nostr.Relay 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 { func (self *NostrAdapter) Name() string {
return self.nickname 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.nickname = settings.Nickname
self.privkey = *settings.PrivKey self.privkey = *settings.PrivKey
self.data = data self.data = data
@ -38,7 +48,12 @@ func (self *NostrAdapter) Init(settings Settings, data chan SocketData) error {
return nil 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 var filters nostr.Filters
err := json.Unmarshal([]byte(filter), &filters) err := json.Unmarshal([]byte(filter), &filters)
if err != nil { if err != nil {
@ -47,35 +62,32 @@ func (self *NostrAdapter) Subscribe(filter string) []error {
errs := make([]error, 0) 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 { for _, r := range self.relays {
fmt.Print(".")
sub, err := r.Subscribe(context.Background(), filters) sub, err := r.Subscribe(context.Background(), filters)
if err != nil { if err != nil {
errs = append(errs, err) errs = append(errs, err)
} else { } else {
go func() { go func() {
for ev := range sub.Events { for ev := range sub.Events {
fmt.Print("!")
// try sequentially to encode into an underbbs object // try sequentially to encode into an underbbs object
// and send it to the appropriate channel // and send it to the appropriate channel
m, err := self.nostrEventToMsg(ev) m, err := self.nostrEventToMsg(ev)
if err == nil { if err == nil {
self.data <- m self.send(m)
} }
} }
}() }()
} }
fmt.Println()
} }
if len(errs) > 0 { if len(errs) > 0 {
fmt.Println("subscription operation completed with errors") log.Print("subscription operation completed with errors")
return errs return errs
} }
fmt.Println("subscription operation completed without errors") log.Print("subscription operation completed without errors")
return nil return nil
} }
@ -83,7 +95,7 @@ func (self *NostrAdapter) Fetch(etype string, ids []string) error {
return nil return nil
} }
func (self *NostrAdapter) Do(action string) error { func (self *NostrAdapter) Do(action string, data map[string]string) error {
return nil return nil
} }

View file

@ -21,6 +21,7 @@ case "$1" in
server) server)
go mod tidy go mod tidy
go build go build
cp underbbs underbbs-cli
;; ;;
*) *)
echo "usage: ${0} <front|server>" echo "usage: ${0} <front|server>"

76
cli/cli.go Normal file
View 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
View 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
}

View file

@ -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>

View file

@ -1,11 +1,3 @@
:root {
--bg_color: #000000;
--fg_color: #ccc;
--main_color: #1f9b92;
--sub_color: #002b36;
--err_color: #DC143C;
}
* { * {
box-sizing: border-box; box-sizing: border-box;
padding: 0; padding: 0;
@ -17,17 +9,6 @@
background: var(--bg_color); 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 { a {
color: var(--main_color); color: var(--main_color);
} }
@ -44,24 +25,23 @@ input {
color: var(--err_color); color: var(--err_color);
} }
nav ul li { underbbs-message, underbbs-profile {
display: inline; max-width: 70ch;
padding: 0.5em; display: block;
}
underbbs-message img {
max-width: 100%;
}
underbbs-profile img {
max-width: 200px;
} }
nav { nav {
padding: 1em; padding: 1em;
} }
nav ul li a { underbbs-message .message_metadata span {
text-decoration: none; display: block;
border-bottom: solid 1px var(--bg_color);
}
.tabbar_current {
border-bottom: solid 1px var(--main_color);
}
main {
padding: 2em;
} }

View file

@ -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}">&larr; 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;
}
}

View 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
View 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);
}
}

View file

@ -1,12 +1,20 @@
export class BatchTimer { import util from './util'
export class Fetcher {
private _batch: string[]; private _batch: string[];
private _timer: number; private _timer: number;
private _reqFn: (id: string[])=>void; private _reqFn: (id: string[])=>void;
constructor(reqFn: (id: string[])=>void) { constructor(gateway: string, adapter: string, etype: string) {
this._batch = []; this._batch = [];
this._timer = new Date().getTime(); 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){ public queue(id: string, timeout: number){

View file

@ -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();

View file

@ -1,20 +1,174 @@
import util from "./util" import util from "./util"
var _ = util._ import { Message } from "./message"
import { Fetcher } from "./fetcher"
import { AdapterState } from "./adapter"
export class MessageElement extends HTMLElement { 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 _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() { constructor() {
super(); super();
this.innerHTML = `<div class="message_metadata"></div><div class="message_content"></div><div class="message_attachments"></div><div class="message_interactions"></div>`
} }
connectedCallback() { connectedCallback() {
this._id = this.getAttribute("data-id"); this._id = this.getAttribute("data-target");
this._adapter = _("settings").adapters.filter((a: any)=>a.nickname == this.getAttribute("data-adapter"))[0]; 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");
}
}
} }
} }

View file

@ -1,6 +1,7 @@
export class Message { export class Message {
public id: string = ""; public id: string = "";
public uri: string = ""; public uri: string = "";
public target: string | null = null;
public protocol: string = ""; public protocol: string = "";
public adapter: string = ""; public adapter: string = "";
public author: string = "" public author: string = ""
@ -12,11 +13,15 @@ export class Message {
public created: number = 0; public created: number = 0;
public edited: number | null = null; public edited: number | null = null;
public visibility: string = "public"; public visibility: string = "public";
public renoteId: string | null = null;
public renoter: string | null = null;
public renoteTime: Date | null = null;
} }
export class Author { export class Author {
public id: string = ""; public id: string = "";
public uri: string = ""; public uri: string = "";
public target: string | null = null;
public protocol: string = ""; public protocol: string = "";
public adapter: string = ""; public adapter: string = "";
public name: string = ""; public name: string = "";
@ -26,10 +31,10 @@ export class Author {
} }
export class Attachment { export class Attachment {
public Src: string = ""; public src: string = "";
public ThumbSrc: string = ""; public thumbSrc: string = "";
public Desc: string = ""; public desc: string = "";
public CreatedAt: Date = new Date(); public createdAt: Date = new Date();
} }
export default { Message, Attachment, Author } export default { Message, Attachment, Author }

108
frontend/ts/navigator.ts Normal file
View 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">&larr;</button><button class="nav_next">&rarr;</button><button class="nav_clear">&times;</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() {
}
}

View file

@ -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() { constructor() {
super(); 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() { 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;
}
} }
} }

View file

@ -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">&times;</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)

View file

@ -4,15 +4,17 @@ import {Settings} from "./settings"
export class SettingsElement extends HTMLElement { export class SettingsElement extends HTMLElement {
static observedAttributes = [ "data-adapters" ] static observedAttributes = [ "data-adapters", "data-gateway" ]
private _adapters: string[] = []; private _adapters: string[] = [];
private _gateway: string = "";
constructor() { constructor() {
super(); super();
} }
connectedCallback() { connectedCallback() {
this._gateway = this.getAttribute("data-gateway") ?? "";
} }
attributeChangedCallback(attr: string, prev: string, next: string) { attributeChangedCallback(attr: string, prev: string, next: string) {
@ -49,7 +51,7 @@ export class SettingsElement extends HTMLElement {
} }
let connect = util.$("settings_connect_btn"); let connect = util.$("settings_connect_btn");
if (connect) { 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 ()=>{ return ()=>{
// dropdown for protocol // dropdown for protocol
let html = "<select id='settings_newadapter_protocolselect'>"; 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>`; self += `<option value='${p}'>${p}</option>`;
return self; return self;
}, ""); }, "");
@ -114,6 +116,11 @@ export class SettingsElement extends HTMLElement {
const apiKey = (util.$("settings_newadapter_masto_apikey") as HTMLInputElement)?.value ?? ""; const apiKey = (util.$("settings_newadapter_masto_apikey") as HTMLInputElement)?.value ?? "";
adapterdata = { nickname: nickname, protocol: proto.options[proto.selectedIndex].value, server: server, apiKey: apiKey }; adapterdata = { nickname: nickname, protocol: proto.options[proto.selectedIndex].value, server: server, apiKey: apiKey };
break; 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; const settings = Settings._instance;
if (settings) { if (settings) {
@ -122,7 +129,7 @@ export class SettingsElement extends HTMLElement {
} }
settings.adapters.push(adapterdata); settings.adapters.push(adapterdata);
self._adapters.push(adapterdata.nickname); self._adapters.push(adapterdata.nickname);
localStorage.setItem("settings", JSON.stringify(settings)); localStorage.setItem("underbbs_settings", JSON.stringify(settings));
self.showSettings(self)(); self.showSettings(self)();
} }
@ -146,6 +153,10 @@ export class SettingsElement extends HTMLElement {
html += " <label>server<input id='settings_newadapter_masto_server'/></label>"; html += " <label>server<input id='settings_newadapter_masto_server'/></label>";
html += " <label>API key<input id='settings_newadapter_masto_apikey'/></label>"; html += " <label>API key<input id='settings_newadapter_masto_apikey'/></label>";
break; 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"); const div = util.$("settings_newadapter_protocoloptions");

View file

@ -10,6 +10,10 @@ export class AdapterConfig {
// nostr // nostr
public privkey: string | null = null; public privkey: string | null = null;
public relays: string[] | null = null; public relays: string[] | null = null;
// honk
public handle: string | null = null;
public password: string | null = null;
} }
export class Settings { export class Settings {

16
frontend/ts/subscriber.ts Normal file
View 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);
}
}

View file

@ -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);
}
}
}
}

View file

@ -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);
}
}
}
}

View 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);
}
}
}
}
}

View 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;
}
}
}

View file

@ -1,5 +1,4 @@
import { DatagramSocket } from './websocket' import { DatagramSocket } from './websocket'
import { BatchTimer } from './batch-timer'
function _(key: string, value: any | null | undefined = undefined): any | null { function _(key: string, value: any | null | undefined = undefined): any | null {
const x = <any>window; const x = <any>window;
@ -31,7 +30,7 @@ function closeErr(): void {
async function authorizedFetch(method: string, uri: string, body: any): Promise<Response> { async function authorizedFetch(method: string, uri: string, body: any): Promise<Response> {
const headers = new Headers() const headers = new Headers()
headers.set('Authorization', 'Bearer ' + DatagramSocket.skey) headers.set('X-Underbbs-Subscriber', DatagramSocket.skey ?? "")
return await fetch(uri, { return await fetch(uri, {
method: method, method: method,
headers: headers, headers: headers,

View file

@ -2,11 +2,18 @@ import util from "./util"
import {AdapterState, AdapterData} from "./adapter"; import {AdapterState, AdapterData} from "./adapter";
import {Message, Attachment, Author} from "./message" import {Message, Attachment, Author} from "./message"
import {Settings} from "./settings" 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 { export class DatagramSocket {
public static skey: string | null = null; public static skey: string | null = null;
public static conn: WebSocket | null; public static conn: WebSocket | null;
private static _gateway: string = ""
private static onOpen(e: Event) { private static onOpen(e: Event) {
@ -14,18 +21,29 @@ export class DatagramSocket {
console.log(JSON.stringify(e)); console.log(JSON.stringify(e));
} }
private static gatewayWithScheme(): string {
return location.protocol + "//" + DatagramSocket._gateway
}
private static onMsg(e: MessageEvent) { private static onMsg(e: MessageEvent) {
const data = JSON.parse(e.data); const data = JSON.parse(e.data);
console.log(data); console.log(data);
if (data.key) { if (data.key) {
DatagramSocket.skey = 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=> { .then(r=> {
if (r.ok) { if (r.ok) {
const tabbar = document.querySelector("underbbs-tabbar"); // iterate through any components which might want to fetch data
if (tabbar) { const profiles = document.querySelectorAll("underbbs-profile");
tabbar.setAttribute("data-adapters", Settings._instance.adapters.map(a=>a.nickname).join(",")); 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 => { .catch(e => {
@ -36,11 +54,12 @@ export class DatagramSocket {
if (!store) { if (!store) {
AdapterState._instance.data.set(data.adapter, new AdapterData(data.protocol)); AdapterState._instance.data.set(data.adapter, new AdapterData(data.protocol));
store = AdapterState._instance.data.get(data.adapter); store = AdapterState._instance.data.get(data.adapter);
} else { }
if (store) {
// typeswitch on the incoming data type and fill the memory // typeswitch on the incoming data type and fill the memory
switch (data.type) { switch (data.type) {
case "message": case "message":
store.messages.set(data.id, <Message>data); store.messages.set(data.renoteId ?? data.id, <Message>data);
break; break;
case "author": case "author":
store.profileCache.set(data.id, <Author>data); store.profileCache.set(data.id, <Author>data);
@ -49,22 +68,52 @@ export class DatagramSocket {
break; break;
} }
} }
// if the adapter is active signal it that there's new data // go through each type of component and give it the latest if it's relevant to them
let adapter = util.$(`adapter_${data.adapter}`); let profileTargets = document.querySelectorAll(`underbbs-profile[data-adapter="${data.adapter}"][data-target="${data.id}"]`);
if (adapter) { profileTargets.forEach(t=>{
adapter.setAttribute("data-latest", data.id); 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("open", DatagramSocket.onOpen);
_conn.addEventListener("message", DatagramSocket.onMsg); _conn.addEventListener("message", DatagramSocket.onMsg);
_conn.addEventListener("error", (e: any) => { _conn.addEventListener("error", (e: any) => {
console.log("websocket connection error"); console.log("websocket connection error");
console.log(JSON.stringify(e)); 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
View file

@ -1,13 +1,15 @@
module forge.lightcrystal.systems/lightcrystal/underbbs module forge.lightcrystal.systems/nilix/underbbs
go 1.22.0 go 1.22.0
require ( require (
forge.lightcrystal.systems/nilix/quartzgun v0.4.2
github.com/McKael/madon v2.3.0+incompatible 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/nbd-wtf/go-nostr v0.31.2
github.com/rs/cors v1.11.1
github.com/yitsushi/go-misskey v1.1.6 github.com/yitsushi/go-misskey v1.1.6
golang.org/x/time v0.5.0 golang.org/x/time v0.5.0
hacklab.nilfm.cc/quartzgun v0.3.2
nhooyr.io/websocket v1.8.11 nhooyr.io/websocket v1.8.11
) )
@ -29,6 +31,7 @@ require (
github.com/tidwall/gjson v1.14.4 // indirect github.com/tidwall/gjson v1.14.4 // indirect
github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // 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/exp v0.0.0-20230425010034-47ecfdc1ba53 // indirect
golang.org/x/net v0.25.0 // indirect golang.org/x/net v0.25.0 // indirect
golang.org/x/oauth2 v0.21.0 // indirect golang.org/x/oauth2 v0.21.0 // indirect

18
go.sum
View file

@ -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 h1:xMUA+Fy4saDV+8tN3MMnwJUoYWC//5Fy8LeOqJsRNIM=
github.com/McKael/madon v2.3.0+incompatible/go.mod h1:+issnvJjN1rpjAHZwXRB/x30uHh/NoQR7QaojJK/lSI= 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= 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/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 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= 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 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= 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/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 h1:3yESHrRFYr6xzkz61LLkvNiPFXxJEAABanTQpKbAaew=
github.com/puzpuzpuz/xsync/v3 v3.0.2/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA= 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 h1:1EyIcsNdn9KIisLW50MKwmSRSK+ekueiEMJ7NEoxJo0=
github.com/sendgrid/rest v2.6.9+incompatible/go.mod h1:kXX7q3jZtJXK5c5qK83bSGMdV6tsOE70KbHoqJls4lE= github.com/sendgrid/rest v2.6.9+incompatible/go.mod h1:kXX7q3jZtJXK5c5qK83bSGMdV6tsOE70KbHoqJls4lE=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 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/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 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= 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 h1:5llv2sWeaMSnA3w2kS57ouQQ4pudlXrR0dCgw51QK9o=
golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w= 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 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= 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 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs=
golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= 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.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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= 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/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 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 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/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.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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 h1:f/qXNc2/3DpoSZkHt1DQu6rj4zGC8JmkkLkWss0MgN0=
nhooyr.io/websocket v1.8.11/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c= nhooyr.io/websocket v1.8.11/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c=

View file

@ -12,7 +12,7 @@ type Datagram struct {
Type string `json:"type"` Type string `json:"type"`
Target *string `json:"target,omitempty"` Target *string `json:"target,omitempty"`
Created int64 `json:"created"` Created int64 `json:"created"`
Updated *int64 `json:"updated,omitempty"` Updated *int64 `json:"edited,omitempty"`
} }
type Message struct { type Message struct {
@ -25,6 +25,9 @@ type Message struct {
ReplyCount int `json:"replyCount"` ReplyCount int `json:"replyCount"`
Mentions []string `json:"mentions"` Mentions []string `json:"mentions"`
Visibility string `json:"visibility"` Visibility string `json:"visibility"`
RenoteId *string `json:"renoteId,omitempty"`
Renoter *string `json:"renoter,omitempty"`
RenoteTime *int64 `json:"renoteTime,omitempty"`
} }
type Author struct { type Author struct {

View file

@ -1,5 +1,16 @@
package models 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 { type Settings struct {
Nickname string Nickname string
Protocol string Protocol string
@ -7,4 +18,7 @@ type Settings struct {
Relays []string `json:"relays",omitempty` Relays []string `json:"relays",omitempty`
Server *string `json:"server",omitempty` Server *string `json:"server",omitempty`
ApiKey *string `json:"apiKey",omitempty` ApiKey *string `json:"apiKey",omitempty`
Handle *string `json:"handle",omitempty`
Password *string `json:"password",omitempty`
ApSigner *crypto.PrivateKey `json:"_",omitempty`
} }

View file

@ -1,25 +1,38 @@
package server package server
import ( import (
"bytes"
"crypto"
"crypto/x509"
"encoding/base64"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"forge.lightcrystal.systems/lightcrystal/underbbs/adapter" "forge.lightcrystal.systems/nilix/quartzgun/renderer"
"forge.lightcrystal.systems/lightcrystal/underbbs/models" "forge.lightcrystal.systems/nilix/quartzgun/router"
"hacklab.nilfm.cc/quartzgun/renderer" "forge.lightcrystal.systems/nilix/quartzgun/util"
"hacklab.nilfm.cc/quartzgun/router" "forge.lightcrystal.systems/nilix/underbbs/adapter"
"hacklab.nilfm.cc/quartzgun/util" "forge.lightcrystal.systems/nilix/underbbs/models"
"html/template" "html/template"
"log"
"net/http" "net/http"
"strings" "strings"
) )
func getSubscriberKey(req *http.Request) (string, error) { type PrivKeyAux interface {
authHeader := req.Header.Get("Authorization") Public() crypto.PublicKey
if strings.HasPrefix(authHeader, "Bearer ") { Equal(x crypto.PrivateKey) bool
return strings.Split(authHeader, "Bearer ")[1], nil }
}
return "", errors.New("No subscriber key") 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 { 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 { func setAdaptersForSubscriber(key string, adapters []adapter.Adapter, subscribers map[*Subscriber][]adapter.Adapter) error {
var ptr *Subscriber = nil var ptr *Subscriber = nil
fmt.Print("looking for subscriber in map..")
for s, _ := range subscribers { for s, _ := range subscribers {
fmt.Print(".")
if s.key == key { if s.key == key {
ptr = s ptr = s
} }
} }
fmt.Println()
if ptr != nil { if ptr != nil {
fmt.Println("setting adaters for the found subscriber: " + ptr.key)
subscribers[ptr] = adapters subscribers[ptr] = adapters
return nil return nil
} }
return errors.New("subscriber not present in map") 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) { return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
// get subscriber key skey := getSubscriberKey(req)
skey, err := getSubscriberKey(req)
if err != nil {
w.WriteHeader(500)
return
}
subscriber := getSubscriberByKey(skey, subscribers) subscriber := getSubscriberByKey(skey, subscribers)
if subscriber == nil { if subscriber == nil {
w.WriteHeader(404) w.WriteHeader(404)
return return
} }
// decode adapter config from request body // decode adapter config from request body
settings := make([]models.Settings, 0) settings := make([]models.Settings, 0)
err = json.NewDecoder(req.Body).Decode(&settings) err := json.NewDecoder(req.Body).Decode(&settings)
if err != nil { if err != nil {
w.WriteHeader(400) w.WriteHeader(400)
next.ServeHTTP(w, req) next.ServeHTTP(w, req)
@ -79,13 +84,18 @@ func apiConfigureAdapters(next http.Handler, subscribers map[*Subscriber][]adapt
a = &adapter.NostrAdapter{} a = &adapter.NostrAdapter{}
case "mastodon": case "mastodon":
a = &adapter.MastoAdapter{} a = &adapter.MastoAdapter{}
s.ApSigner = apKey
case "misskey": case "misskey":
a = &adapter.MisskeyAdapter{} a = &adapter.MisskeyAdapter{}
s.ApSigner = apKey
case "honk":
a = &adapter.HonkAdapter{}
s.ApSigner = apKey
default: default:
break break
} }
err := a.Init(s, subscriber.data) err := a.Init(s, &subscriber.data)
if err != nil { if err != nil {
util.AddContextValue(req, "data", err.Error()) util.AddContextValue(req, "data", err.Error())
w.WriteHeader(500) w.WriteHeader(500)
@ -93,25 +103,10 @@ func apiConfigureAdapters(next http.Handler, subscribers map[*Subscriber][]adapt
return return
} }
fmt.Println("adapter initialized - subscribing with default filter") log.Print("adapter initialized; adding to array")
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")
adapters = append(adapters, a) adapters = append(adapters, a)
fmt.Println("adapter added to array") log.Print("adapter added to array")
} }
// TODO: cancel subscriptions on any existing adapters // 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 { type subscribeParams struct {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { Filter string `json:"filter"`
w.WriteHeader(201) Target *string `json:"target,omitempty"`
next.ServeHTTP(w, req)
})
} }
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) { 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) w.WriteHeader(201)
next.ServeHTTP(w, req) next.ServeHTTP(w, req)
}
}
w.WriteHeader(404)
}) })
} }
func ProtectWithSubscriberKey(next http.Handler, subscribers map[*Subscriber][]adapter.Adapter) http.Handler { func ProtectWithSubscriberKey(next http.Handler, subscribers map[*Subscriber][]adapter.Adapter) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
authHeader := req.Header.Get("Authorization") skey := getSubscriberKey(req)
if strings.HasPrefix(authHeader, "Bearer ") { if getSubscriberByKey(skey, subscribers) != nil {
subscriberKey := strings.Split(authHeader, "Bearer ")[1]
if getSubscriberByKey(subscriberKey, subscribers) != nil {
next.ServeHTTP(w, req) next.ServeHTTP(w, req)
return return
} }
}
w.WriteHeader(http.StatusUnauthorized) w.WriteHeader(http.StatusUnauthorized)
}) })
} }
func apiAdapterFetch(next http.Handler, subscribers map[*Subscriber][]adapter.Adapter) http.Handler { func apiAdapterFetch(next http.Handler, subscribers map[*Subscriber][]adapter.Adapter) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
authHeader := req.Header.Get("Authorization") skey := getSubscriberKey(req)
if strings.HasPrefix(authHeader, "Bearer ") { s := getSubscriberByKey(skey, subscribers)
subscriberKey := strings.Split(authHeader, "Bearer ")[1]
s := getSubscriberByKey(subscriberKey, subscribers)
if s != nil { if s != nil {
apiParams := req.Context().Value("params").(map[string]string) apiParams := req.Context().Value("params").(map[string]string)
queryParams := req.URL.Query() queryParams := req.URL.Query()
@ -169,7 +183,7 @@ func apiAdapterFetch(next http.Handler, subscribers map[*Subscriber][]adapter.Ad
if a.Name() == apiParams["adapter_id"] { if a.Name() == apiParams["adapter_id"] {
err := a.Fetch(queryParams["entity_type"][0], queryParams["entity_id"]) err := a.Fetch(queryParams["entity_type"][0], queryParams["entity_id"])
if err != nil { if err != nil {
fmt.Println(err.Error()) log.Print(err.Error())
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
} else { } else {
w.WriteHeader(http.StatusAccepted) 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, Fallback: *errTemplate,
} }
// adapters (POST & GET)
rtr.Post("/adapters", ProtectWithSubscriberKey( rtr.Post("/adapters", ProtectWithSubscriberKey(
apiConfigureAdapters(renderer.JSON("data"), self.subscribers), apiConfigureAdapters(renderer.JSON("data"), self.subscribers, self.apKey),
self.subscribers,
))
rtr.Get("/adapters", ProtectWithSubscriberKey(
apiGetAdapters(renderer.JSON("data")),
self.subscribers, self.subscribers,
)) ))
// adapters/:name/subscribe rtr.Post(`/adapters/(?P<adapter_id>\S+)/subscribe`, ProtectWithSubscriberKey(
rtr.Post(`/adapters/(?P<id>\S+)/subscribe`, ProtectWithSubscriberKey( apiAdapterSubscribe(renderer.JSON("data"), self.subscribers),
apiAdapterSubscribe(renderer.JSON("data")),
self.subscribers, self.subscribers,
)) ))
@ -214,5 +296,14 @@ func (self *BBSServer) apiMux() http.Handler {
self.subscribers, 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) return http.HandlerFunc(rtr.ServeHTTP)
} }

View file

@ -2,13 +2,16 @@ package server
import ( import (
"context" "context"
"crypto"
"crypto/x509"
"encoding/pem"
"errors" "errors"
"fmt" "forge.lightcrystal.systems/nilix/quartzgun/cookie"
"forge.lightcrystal.systems/lightcrystal/underbbs/adapter" "forge.lightcrystal.systems/nilix/quartzgun/renderer"
"forge.lightcrystal.systems/lightcrystal/underbbs/models" "forge.lightcrystal.systems/nilix/underbbs/adapter"
"forge.lightcrystal.systems/nilix/underbbs/models"
_ "github.com/rs/cors"
"golang.org/x/time/rate" "golang.org/x/time/rate"
"hacklab.nilfm.cc/quartzgun/cookie"
"hacklab.nilfm.cc/quartzgun/renderer"
"io/ioutil" "io/ioutil"
"log" "log"
"net/http" "net/http"
@ -31,20 +34,70 @@ type BBSServer struct {
serveMux http.ServeMux serveMux http.ServeMux
subscribersLock sync.Mutex subscribersLock sync.Mutex
subscribers map[*Subscriber][]adapter.Adapter subscribers map[*Subscriber][]adapter.Adapter
apKey *crypto.PrivateKey
apDomain *string
} }
func New() *BBSServer { func CORS(next http.Handler) http.Handler {
srvr := &BBSServer{ 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, subscribeMessageBuffer: 16,
logf: log.Printf, logf: log.Printf,
subscribers: make(map[*Subscriber][]adapter.Adapter), 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 // frontend is here
srvr.serveMux.Handle("/app/", http.StripPrefix("/app/", renderer.Subtree("./frontend/dist"))) srvr.serveMux.Handle("/app/", http.StripPrefix("/app/", renderer.Subtree("./frontend/dist")))
// api // api
srvr.serveMux.Handle("/api/", http.StripPrefix("/api", srvr.apiMux())) srvr.serveMux.Handle("/api/", http.StripPrefix("/api", CORS(srvr.apiMux())))
// websocket // websocket
srvr.serveMux.HandleFunc("/subscribe", srvr.subscribeHandler) 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{ c, err := websocket.Accept(w, r, &websocket.AcceptOptions{
Subprotocols: []string{}, Subprotocols: []string{},
OriginPatterns: []string{"*"},
}) })
if err != nil { if err != nil {
self.logf("%v", err) self.logf("%v", err)
@ -90,14 +144,18 @@ func (self *BBSServer) subscribeHandler(w http.ResponseWriter, r *http.Request)
defer c.Close(websocket.StatusInternalError, "") defer c.Close(websocket.StatusInternalError, "")
go func() { go func() {
fmt.Println("waiting for data on the subscriber's channel") self.logf("waiting for data on the subscriber's channel")
for { for {
select { select {
case msg := <-s.msgs: case msg := <-s.msgs:
writeTimeout(ctx, time.Second*5, c, msg) writeTimeout(ctx, time.Second*5, c, msg)
case <-ctx.Done(): 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) close(s.data)
return //ctx.Err() 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 // block on the data channel, serializing and passing the data to the subscriber
listen([]chan models.SocketData{s.data}, s.msgs) 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) { if errors.Is(err, context.Canceled) {
return return

View file

@ -2,31 +2,59 @@ package main
import ( import (
"context" "context"
"fmt"
"log" "log"
"net" "net"
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
"path/filepath"
"strconv" "strconv"
"time" "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() { func main() {
err := run()
args := os.Args
var err error = nil
progname := filepath.Base(args[0])
cfg, err := config.LoadConfig()
if err != nil { 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 { func run_cli(settings models.GlobalSettings, args ...string) error {
l, err := net.Listen("tcp", ":"+strconv.FormatInt(int64(9090), 10)) 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 { if err != nil {
return err return err
} }
bbsServer := server.New() bbsServer := server.New(settings)
s := &http.Server{ s := &http.Server{
Handler: bbsServer, Handler: bbsServer,
ReadTimeout: time.Second * 10, ReadTimeout: time.Second * 10,

View file

@ -4,10 +4,10 @@ module.exports = {
mode: 'production', mode: 'production',
context: path.resolve(__dirname, 'frontend', '.js'), context: path.resolve(__dirname, 'frontend', '.js'),
entry: { entry: {
main: './index.js', underbbs: './websocket.js',
serviceWorker: './serviceWorker.js',
}, },
output: { output: {
iife: false,
filename: '[name].js', filename: '[name].js',
path: path.resolve(__dirname, 'frontend', 'dist'), path: path.resolve(__dirname, 'frontend', 'dist'),
}, },