Compare commits
26 commits
Author | SHA1 | Date | |
---|---|---|---|
dd27ffabaa | |||
6bb5eff11b | |||
1806140f4b | |||
2ff69b9d38 | |||
264be94427 | |||
ab8249e0bf | |||
2c39f7fd3c | |||
508722feb5 | |||
bbf26561f6 | |||
03ee058fe7 | |||
d61c786ffd | |||
b00f26b17d | |||
a053406fc5 | |||
5b20ff3135 | |||
51bd3a6505 | |||
cae8c8b597 | |||
64d00083b7 | |||
305366dc9e | |||
a2017e3de8 | |||
194a5aed48 | |||
3d99b53935 | |||
e3c1f9d54b | |||
9195bba7ed | |||
a7e5c99cec | |||
c6cfdf9e9f | |||
09c7eb8318 |
39 changed files with 2077 additions and 787 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -2,4 +2,5 @@ node_modules/
|
||||||
frontend/dist/*.js
|
frontend/dist/*.js
|
||||||
frontend/.js
|
frontend/.js
|
||||||
underbbs
|
underbbs
|
||||||
|
underbbs-cli
|
||||||
__debug_*
|
__debug_*
|
33
README.md
33
README.md
|
@ -1,26 +1,43 @@
|
||||||
# underBBS
|
# underBBS
|
||||||
|
|
||||||
underBBS is a platform-agnostic messaging and social media client
|
underBBS is a protocol-agnostic decentralized social media client and toolkit
|
||||||
|
|
||||||
## 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
|
||||||
|
|
|
@ -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
393
adapter/anonAp.go
Normal file
|
@ -0,0 +1,393 @@
|
||||||
|
package adapter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"forge.lightcrystal.systems/nilix/underbbs/models"
|
||||||
|
"github.com/go-fed/httpsig"
|
||||||
|
)
|
||||||
|
|
||||||
|
type anonAPAdapter struct {
|
||||||
|
data *chan models.SocketData
|
||||||
|
server string
|
||||||
|
client http.Client
|
||||||
|
protocol string
|
||||||
|
nickname string
|
||||||
|
actorKey *crypto.PrivateKey
|
||||||
|
actorURL *string
|
||||||
|
}
|
||||||
|
|
||||||
|
type apLink struct {
|
||||||
|
Href string
|
||||||
|
Rel string
|
||||||
|
Type string
|
||||||
|
}
|
||||||
|
|
||||||
|
type apAttachment struct {
|
||||||
|
MediaType string
|
||||||
|
Type string
|
||||||
|
Name string
|
||||||
|
Summary string
|
||||||
|
Url string
|
||||||
|
}
|
||||||
|
|
||||||
|
type apTag struct {
|
||||||
|
Href string
|
||||||
|
Name string
|
||||||
|
Type string
|
||||||
|
}
|
||||||
|
|
||||||
|
type apIcon struct {
|
||||||
|
MediaType string
|
||||||
|
Type string
|
||||||
|
Url string
|
||||||
|
}
|
||||||
|
|
||||||
|
type apKey struct {
|
||||||
|
Id string
|
||||||
|
Owner string
|
||||||
|
PublicKeyPem string
|
||||||
|
}
|
||||||
|
|
||||||
|
type apActor struct {
|
||||||
|
Icon *apIcon `json:"icon",omitempty`
|
||||||
|
Id string
|
||||||
|
Name string
|
||||||
|
PreferredUsername string
|
||||||
|
Summary string
|
||||||
|
Url string
|
||||||
|
PublicKey ApKey
|
||||||
|
}
|
||||||
|
|
||||||
|
type ApActor apActor
|
||||||
|
type ApKey apKey
|
||||||
|
|
||||||
|
type apOutbox struct {
|
||||||
|
OrderedItems []interface{} `json:"-"`
|
||||||
|
RawOrderedItems []json.RawMessage `json:"OrderedItems"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (self *apOutbox) UnmarshalJSON(b []byte) error {
|
||||||
|
type obInternal apOutbox
|
||||||
|
err := json.Unmarshal(b, (*obInternal)(self))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, raw := range self.RawOrderedItems {
|
||||||
|
var i apActivity
|
||||||
|
err := json.Unmarshal(raw, &i)
|
||||||
|
|
||||||
|
var x interface{}
|
||||||
|
switch i.Type {
|
||||||
|
case "Create":
|
||||||
|
fallthrough
|
||||||
|
case "Update":
|
||||||
|
x = &apCreateUpdateActivity{}
|
||||||
|
case "Announce":
|
||||||
|
x = &apAnnounceActivity{}
|
||||||
|
}
|
||||||
|
err = json.Unmarshal(raw, &x)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
self.OrderedItems = append(self.OrderedItems, x)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type apActivity struct {
|
||||||
|
Actor string
|
||||||
|
Id string
|
||||||
|
Type string
|
||||||
|
To string
|
||||||
|
Published string
|
||||||
|
}
|
||||||
|
|
||||||
|
type apCreateUpdateActivity struct {
|
||||||
|
Object apObject
|
||||||
|
apActivity
|
||||||
|
}
|
||||||
|
|
||||||
|
type apAnnounceActivity struct {
|
||||||
|
Object string
|
||||||
|
apActivity
|
||||||
|
}
|
||||||
|
|
||||||
|
type apObject struct {
|
||||||
|
Id string
|
||||||
|
Content string
|
||||||
|
AttributedTo string
|
||||||
|
Context string
|
||||||
|
Conversation string
|
||||||
|
Published string
|
||||||
|
Tag []apTag
|
||||||
|
Attachment []apAttachment
|
||||||
|
To string
|
||||||
|
Url string
|
||||||
|
InReplyTo *string
|
||||||
|
}
|
||||||
|
|
||||||
|
type webFinger struct {
|
||||||
|
Links []apLink
|
||||||
|
}
|
||||||
|
|
||||||
|
func (self *anonAPAdapter) Init(data *chan models.SocketData, server, protocol, nickname string, apSigner *crypto.PrivateKey, apActorURL *string) error {
|
||||||
|
self.data = data
|
||||||
|
self.server = server
|
||||||
|
self.nickname = nickname
|
||||||
|
self.protocol = protocol
|
||||||
|
self.actorKey = apSigner
|
||||||
|
self.actorURL = apActorURL
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (self *anonAPAdapter) signRequest(req *http.Request) error {
|
||||||
|
prefs := []httpsig.Algorithm{httpsig.RSA_SHA512, httpsig.ED25519}
|
||||||
|
// The "Date" and "Digest" headers must already be set on r, as well as r.URL.
|
||||||
|
headersToSign := []string{httpsig.RequestTarget}
|
||||||
|
signer, _, err := httpsig.NewSigner(prefs, httpsig.DigestSha512, headersToSign, httpsig.Signature, 0)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return signer.SignRequest(self.actorKey, *self.actorURL+"/api/actor", req, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getBodyJson(res *http.Response) []byte {
|
||||||
|
l := res.ContentLength
|
||||||
|
// if the length is unknown, we'll allocate 4k
|
||||||
|
// really we should be using Decoders all around
|
||||||
|
if l < 0 {
|
||||||
|
l = 4096
|
||||||
|
}
|
||||||
|
jsonData := make([]byte, l)
|
||||||
|
res.Body.Read(jsonData)
|
||||||
|
|
||||||
|
return jsonData
|
||||||
|
}
|
||||||
|
|
||||||
|
func (self *anonAPAdapter) makeApRequest(method, url string, data io.Reader) (*http.Response, error) {
|
||||||
|
req, err := http.NewRequest(method, url, data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Accept", "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"")
|
||||||
|
|
||||||
|
if self.actorKey != nil && self.actorURL != nil {
|
||||||
|
self.signRequest(req)
|
||||||
|
}
|
||||||
|
return self.client.Do(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (self *anonAPAdapter) toMsg(object apObject) *models.Message {
|
||||||
|
t, err := time.Parse(time.RFC3339, object.Published)
|
||||||
|
if err != nil {
|
||||||
|
t = time.Now()
|
||||||
|
}
|
||||||
|
vis := strings.Split(object.To, "#")
|
||||||
|
if len(vis) > 1 {
|
||||||
|
object.To = vis[1]
|
||||||
|
}
|
||||||
|
m := &models.Message{
|
||||||
|
Datagram: models.Datagram{
|
||||||
|
Id: object.Id,
|
||||||
|
Uri: object.Url,
|
||||||
|
Type: "message",
|
||||||
|
Created: t.UnixMilli(),
|
||||||
|
Updated: nil,
|
||||||
|
Protocol: self.protocol,
|
||||||
|
Adapter: self.nickname,
|
||||||
|
},
|
||||||
|
Author: object.AttributedTo,
|
||||||
|
Content: object.Content,
|
||||||
|
ReplyTo: object.InReplyTo,
|
||||||
|
Visibility: object.To,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, a := range object.Attachment {
|
||||||
|
m.Attachments = append(m.Attachments, models.Attachment{
|
||||||
|
Src: a.Url,
|
||||||
|
Desc: a.Summary,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func (self *anonAPAdapter) send(data models.SocketData) {
|
||||||
|
if self.data != nil {
|
||||||
|
*self.data <- data
|
||||||
|
} else {
|
||||||
|
fmt.Fprintln(os.Stderr, string(data.ToDatagram()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (self *anonAPAdapter) toAuthor(actor apActor) *models.Author {
|
||||||
|
curtime := time.Now().UnixMilli()
|
||||||
|
a := &models.Author{
|
||||||
|
Datagram: models.Datagram{
|
||||||
|
Id: actor.Id,
|
||||||
|
Uri: actor.Url,
|
||||||
|
Type: "author",
|
||||||
|
Created: curtime,
|
||||||
|
Updated: &curtime,
|
||||||
|
Protocol: self.protocol,
|
||||||
|
Adapter: self.nickname,
|
||||||
|
},
|
||||||
|
Name: actor.PreferredUsername,
|
||||||
|
ProfileData: actor.Summary,
|
||||||
|
ProfilePic: actor.Icon.Url,
|
||||||
|
}
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
func (self *anonAPAdapter) getHostForId(id string) string {
|
||||||
|
if strings.HasPrefix(id, "https://") {
|
||||||
|
idNoScheme := strings.Split(id, "https://")[1]
|
||||||
|
serverNoScheme := strings.Split(idNoScheme, "/")[0]
|
||||||
|
return "https://" + serverNoScheme
|
||||||
|
} else {
|
||||||
|
return "https://" + strings.Split(id, "@")[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (self *anonAPAdapter) normalizeActorId(id string) string {
|
||||||
|
if string([]byte{id[0]}) == "@" {
|
||||||
|
id = id[1:]
|
||||||
|
}
|
||||||
|
if !strings.Contains(id, "@") {
|
||||||
|
// if the id is not a URI, add local server to it
|
||||||
|
if !strings.HasPrefix(id, "https://") {
|
||||||
|
serverNoScheme := strings.Split(self.server, "https://")[1]
|
||||||
|
id = fmt.Sprintf("%s@%s", id, serverNoScheme)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func (self *anonAPAdapter) Fetch(etype string, ids []string) error {
|
||||||
|
for _, id := range ids {
|
||||||
|
switch etype {
|
||||||
|
case "author": // webfinger lookup on id
|
||||||
|
normalizedId := self.normalizeActorId(id)
|
||||||
|
fmt.Println(normalizedId)
|
||||||
|
reqHost := self.getHostForId(normalizedId)
|
||||||
|
profile := normalizedId
|
||||||
|
fmt.Println(reqHost)
|
||||||
|
if !strings.HasPrefix(normalizedId, "https://") {
|
||||||
|
res, err := http.Get(reqHost + "/.well-known/webfinger?resource=acct:" + normalizedId)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data := getBodyJson(res)
|
||||||
|
wf := webFinger{}
|
||||||
|
json.Unmarshal(data, &wf)
|
||||||
|
|
||||||
|
for _, l := range wf.Links {
|
||||||
|
if l.Rel == "self" {
|
||||||
|
profile = l.Href
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res, err := self.makeApRequest("GET", profile, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
authorData := getBodyJson(res)
|
||||||
|
actor := apActor{}
|
||||||
|
json.Unmarshal(authorData, &actor)
|
||||||
|
author := self.toAuthor(actor)
|
||||||
|
if author != nil {
|
||||||
|
self.send(author)
|
||||||
|
}
|
||||||
|
case "byAuthor":
|
||||||
|
normalizedId := self.normalizeActorId(id)
|
||||||
|
reqHost := self.getHostForId(normalizedId)
|
||||||
|
profile := normalizedId
|
||||||
|
if !strings.HasPrefix(normalizedId, "https://") {
|
||||||
|
res, err := http.Get(reqHost + "/.well-known/webfinger?resource=acct:" + normalizedId)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data := getBodyJson(res)
|
||||||
|
wf := webFinger{}
|
||||||
|
json.Unmarshal(data, &wf)
|
||||||
|
for _, l := range wf.Links {
|
||||||
|
if l.Rel == "self" {
|
||||||
|
profile = l.Href
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res, err := self.makeApRequest("GET", profile+"/outbox", nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
obData := getBodyJson(res)
|
||||||
|
ob := apOutbox{}
|
||||||
|
ob.UnmarshalJSON(obData)
|
||||||
|
for _, i := range ob.OrderedItems {
|
||||||
|
switch a := i.(type) {
|
||||||
|
case *apCreateUpdateActivity:
|
||||||
|
msg := self.toMsg(a.Object)
|
||||||
|
if msg != nil {
|
||||||
|
self.send(msg)
|
||||||
|
}
|
||||||
|
case *apAnnounceActivity:
|
||||||
|
res, err = self.makeApRequest("GET", a.Object, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
objectData := getBodyJson(res)
|
||||||
|
object := apObject{}
|
||||||
|
json.Unmarshal(objectData, &object)
|
||||||
|
ogMsg := self.toMsg(object)
|
||||||
|
// if we couldn't fetch the original, skip it
|
||||||
|
if ogMsg != nil && ogMsg.Author != "" {
|
||||||
|
|
||||||
|
t, err := time.Parse(time.RFC3339, a.Published)
|
||||||
|
if err != nil {
|
||||||
|
t = time.Now()
|
||||||
|
}
|
||||||
|
rt := t.UnixMilli()
|
||||||
|
ogMsg.RenoteId = &a.Id
|
||||||
|
ogMsg.Renoter = &a.Actor
|
||||||
|
ogMsg.RenoteTime = &rt
|
||||||
|
|
||||||
|
self.send(ogMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// for each item in outbox, check if it's a Create/Update or an Announce
|
||||||
|
// Create/Update you can directly deserialize the object
|
||||||
|
// if it's an Announce, try to get the object and deserialize it, build a boost out of it
|
||||||
|
case "message":
|
||||||
|
res, err := self.makeApRequest("GET", id, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
objectData := getBodyJson(res)
|
||||||
|
object := apObject{}
|
||||||
|
json.Unmarshal(objectData, &object)
|
||||||
|
message := self.toMsg(object)
|
||||||
|
if message != nil {
|
||||||
|
self.send(message)
|
||||||
|
}
|
||||||
|
case "children":
|
||||||
|
case "convoy":
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
325
adapter/honk.go
Normal file
325
adapter/honk.go
Normal file
|
@ -0,0 +1,325 @@
|
||||||
|
package adapter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
. "forge.lightcrystal.systems/nilix/underbbs/models"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type donk struct {
|
||||||
|
Desc string
|
||||||
|
URL string
|
||||||
|
}
|
||||||
|
|
||||||
|
type honk struct {
|
||||||
|
ID int
|
||||||
|
Honker string
|
||||||
|
Handles string
|
||||||
|
Oonker string
|
||||||
|
XID string
|
||||||
|
RID *string
|
||||||
|
Noise string
|
||||||
|
Donks []donk
|
||||||
|
Convoy string
|
||||||
|
Public bool
|
||||||
|
Date string
|
||||||
|
}
|
||||||
|
|
||||||
|
type honkResponse struct {
|
||||||
|
Honks []honk `json:"honks"`
|
||||||
|
ChatCount int `json:"chatcount"`
|
||||||
|
MeCount int `json:"mecount"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type HonkAdapter struct {
|
||||||
|
data *chan SocketData
|
||||||
|
nickname string
|
||||||
|
server string
|
||||||
|
username string
|
||||||
|
password string
|
||||||
|
token string
|
||||||
|
apSigner *crypto.PrivateKey
|
||||||
|
apDomain *string
|
||||||
|
|
||||||
|
cache map[string]time.Time
|
||||||
|
maxId int
|
||||||
|
mtx sync.RWMutex
|
||||||
|
|
||||||
|
stop chan bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (self *HonkAdapter) isAnonymous() bool {
|
||||||
|
return self.token == ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (self *HonkAdapter) send(data SocketData) {
|
||||||
|
if self.data != nil {
|
||||||
|
*self.data <- data
|
||||||
|
} else {
|
||||||
|
fmt.Fprintln(os.Stdout, string(data.ToDatagram()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (self *HonkAdapter) Name() string {
|
||||||
|
return self.nickname
|
||||||
|
}
|
||||||
|
|
||||||
|
func (self *HonkAdapter) Init(settings Settings, data *chan SocketData) error {
|
||||||
|
self.data = data
|
||||||
|
// separate name and server in handle
|
||||||
|
parts := strings.Split(*settings.Handle, "@")
|
||||||
|
self.username = parts[1]
|
||||||
|
self.server = "https://" + parts[2]
|
||||||
|
self.nickname = settings.Nickname
|
||||||
|
self.apSigner = settings.ApSigner
|
||||||
|
|
||||||
|
if settings.Password == nil || *settings.Password == "" {
|
||||||
|
// we're anonymous!
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
self.password = *settings.Password
|
||||||
|
|
||||||
|
r, err := http.PostForm(self.server+"/dologin", url.Values{
|
||||||
|
"username": []string{self.username},
|
||||||
|
"password": []string{self.password},
|
||||||
|
"gettoken": []string{"1"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf [32]byte
|
||||||
|
_, err = r.Body.Read(buf[:])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
self.token = string(buf[:])
|
||||||
|
fmt.Println(self.token)
|
||||||
|
|
||||||
|
self.stop = make(chan bool)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (self *HonkAdapter) Stop() {
|
||||||
|
close(self.stop)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (self *HonkAdapter) Subscribe(filter string, target *string) []error {
|
||||||
|
if self.isAnonymous() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if self.stop != nil {
|
||||||
|
close(self.stop)
|
||||||
|
}
|
||||||
|
self.stop = make(chan bool)
|
||||||
|
|
||||||
|
go self.gethonks(filter, target)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (self *HonkAdapter) gethonks(filter string, target *string) {
|
||||||
|
x := self.stop
|
||||||
|
self.maxId = 0
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case _, ok := <-x:
|
||||||
|
if !ok {
|
||||||
|
fmt.Println("stopping! filter was " + filter)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
fmt.Println("getting honks, filter is " + filter)
|
||||||
|
honkForm := url.Values{
|
||||||
|
"action": []string{"gethonks"},
|
||||||
|
"token": []string{self.token},
|
||||||
|
"page": []string{filter},
|
||||||
|
}
|
||||||
|
if self.maxId != 0 {
|
||||||
|
honkForm["after"] = []string{strconv.FormatInt(int64(self.maxId), 10)}
|
||||||
|
}
|
||||||
|
res, err := http.PostForm(self.server+"/api", honkForm)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("fucked up: " + err.Error())
|
||||||
|
self.stop <- true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hr := honkResponse{}
|
||||||
|
err = json.NewDecoder(res.Body).Decode(&hr)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("malformed honks: " + err.Error())
|
||||||
|
self.stop <- true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, h := range hr.Honks {
|
||||||
|
if h.ID > self.maxId {
|
||||||
|
self.maxId = h.ID
|
||||||
|
}
|
||||||
|
msg := self.toMsg(h, target)
|
||||||
|
self.send(msg)
|
||||||
|
}
|
||||||
|
time.Sleep(5 * time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (self *HonkAdapter) toMsg(h honk, target *string) Message {
|
||||||
|
t, err := time.Parse(time.RFC3339, h.Date)
|
||||||
|
if err != nil {
|
||||||
|
t = time.Now()
|
||||||
|
}
|
||||||
|
tt := t.UnixMilli()
|
||||||
|
|
||||||
|
a := h.Honker
|
||||||
|
if h.Oonker != "" {
|
||||||
|
a = h.Oonker
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := Message{
|
||||||
|
Datagram: Datagram{
|
||||||
|
Id: h.XID,
|
||||||
|
Uri: h.XID,
|
||||||
|
Protocol: "honk",
|
||||||
|
Adapter: self.nickname,
|
||||||
|
Type: "message",
|
||||||
|
Created: tt,
|
||||||
|
},
|
||||||
|
Author: a,
|
||||||
|
Content: h.Noise,
|
||||||
|
ReplyTo: h.RID,
|
||||||
|
Visibility: "Private",
|
||||||
|
}
|
||||||
|
if h.Public {
|
||||||
|
msg.Visibility = "Public"
|
||||||
|
}
|
||||||
|
if h.Oonker != "" {
|
||||||
|
r := fmt.Sprintf("%s/bonk/%d", h.Honker, h.ID)
|
||||||
|
fmt.Println(r)
|
||||||
|
msg.Renoter = &h.Honker
|
||||||
|
msg.RenoteId = &r
|
||||||
|
msg.RenoteTime = &tt
|
||||||
|
}
|
||||||
|
for _, d := range h.Donks {
|
||||||
|
a := Attachment{
|
||||||
|
Src: d.URL,
|
||||||
|
Desc: d.Desc,
|
||||||
|
}
|
||||||
|
msg.Attachments = append(msg.Attachments, a)
|
||||||
|
}
|
||||||
|
if target != nil {
|
||||||
|
msg.Target = target
|
||||||
|
}
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
|
||||||
|
func (self *HonkAdapter) Fetch(etype string, ids []string) error {
|
||||||
|
// honk API is limited, we fall back to the anonymous adapter for fetch ops
|
||||||
|
aaa := anonAPAdapter{}
|
||||||
|
aaa.Init(self.data, self.server, "honk", self.nickname, self.apSigner, self.apDomain)
|
||||||
|
return aaa.Fetch(etype, ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (self *HonkAdapter) donk(data map[string]string) error {
|
||||||
|
var b bytes.Buffer
|
||||||
|
w := multipart.NewWriter(&b)
|
||||||
|
fw, err := w.CreateFormField("action")
|
||||||
|
io.Copy(fw, strings.NewReader("honk"))
|
||||||
|
fw, err = w.CreateFormField("token")
|
||||||
|
io.Copy(fw, strings.NewReader(self.token))
|
||||||
|
for k, v := range data {
|
||||||
|
if k == "file" {
|
||||||
|
if fw, err = w.CreateFormFile("donk", "donk"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fieldName := "noise"
|
||||||
|
switch k {
|
||||||
|
case "desc":
|
||||||
|
fieldName = "donkdesc"
|
||||||
|
case "content":
|
||||||
|
fieldName = "noise"
|
||||||
|
case "replyto":
|
||||||
|
fieldName = "rid"
|
||||||
|
}
|
||||||
|
if fw, err = w.CreateFormField(fieldName); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := io.Copy(fw, strings.NewReader(v)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.Close()
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", self.server+"/api", &b)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", w.FormDataContentType())
|
||||||
|
c := http.Client{}
|
||||||
|
res, err := c.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if res.StatusCode < 400 {
|
||||||
|
return nil
|
||||||
|
} else {
|
||||||
|
return errors.New(fmt.Sprintf("status: %d", res.StatusCode))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (self *HonkAdapter) Do(action string, data map[string]string) error {
|
||||||
|
if self.isAnonymous() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch action {
|
||||||
|
case "post":
|
||||||
|
honkForm := url.Values{
|
||||||
|
"action": []string{"honk"},
|
||||||
|
"token": []string{self.token},
|
||||||
|
"noise": []string{data["content"]},
|
||||||
|
}
|
||||||
|
_, exists := data["file"]
|
||||||
|
if exists {
|
||||||
|
return self.donk(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
replyto, exists := data["replyto"]
|
||||||
|
if exists {
|
||||||
|
honkForm["rid"] = []string{replyto}
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := http.PostForm(self.server+"/api", honkForm)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var buf [256]byte
|
||||||
|
_, err = res.Body.Read(buf[:])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Println(string(buf[:]))
|
||||||
|
default:
|
||||||
|
return errors.New("Do: unknown action")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (self *HonkAdapter) DefaultSubscriptionFilter() string {
|
||||||
|
return "home"
|
||||||
|
}
|
|
@ -2,12 +2,14 @@ package adapter
|
||||||
|
|
||||||
import (
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
1
build.sh
1
build.sh
|
@ -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
76
cli/cli.go
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io/ioutil"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"forge.lightcrystal.systems/nilix/underbbs/adapter"
|
||||||
|
"forge.lightcrystal.systems/nilix/underbbs/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Process(gsettings models.GlobalSettings, args ...string) error {
|
||||||
|
adapterName := args[0]
|
||||||
|
args = args[1:]
|
||||||
|
var s *models.Settings
|
||||||
|
|
||||||
|
for _, x := range gsettings.Adapters {
|
||||||
|
if x.Nickname == adapterName {
|
||||||
|
s = &x
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if s == nil {
|
||||||
|
return errors.New("given adapter " + adapterName + " is not in the config file")
|
||||||
|
}
|
||||||
|
|
||||||
|
// instantiate adapter with config
|
||||||
|
var a adapter.Adapter
|
||||||
|
switch s.Protocol {
|
||||||
|
case "nostr":
|
||||||
|
a = &adapter.NostrAdapter{}
|
||||||
|
case "mastodon":
|
||||||
|
a = &adapter.MastoAdapter{}
|
||||||
|
case "misskey":
|
||||||
|
a = &adapter.MisskeyAdapter{}
|
||||||
|
case "honk":
|
||||||
|
a = &adapter.HonkAdapter{}
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
a.Init(*s, nil)
|
||||||
|
// process remaining args and execute
|
||||||
|
|
||||||
|
switch args[0] {
|
||||||
|
case "fetch":
|
||||||
|
a.Fetch(args[1], args[2:])
|
||||||
|
case "do":
|
||||||
|
data := map[string]string{}
|
||||||
|
for _, a := range args[2:] {
|
||||||
|
if !strings.Contains(a, "=") {
|
||||||
|
return errors.New("args are in the form KEY=VALUE")
|
||||||
|
} else {
|
||||||
|
aa := strings.Split(a, "=")
|
||||||
|
k := aa[0]
|
||||||
|
v := strings.Join(aa[1:], "=")
|
||||||
|
if k == "file" {
|
||||||
|
b, err := ioutil.ReadFile(v)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
v = string(b)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.Do(args[1], data)
|
||||||
|
default:
|
||||||
|
log.Print(args)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
55
config/config.go
Normal file
55
config/config.go
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io/ioutil"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
|
||||||
|
"forge.lightcrystal.systems/nilix/underbbs/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func getConfigLocation() string {
|
||||||
|
home := os.Getenv("HOME")
|
||||||
|
appdata := os.Getenv("APPDATA")
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "windows":
|
||||||
|
return filepath.Join(appdata, "underbbs")
|
||||||
|
case "darwin":
|
||||||
|
return filepath.Join(home, "Library", "Application Support", "underbbs")
|
||||||
|
case "plan9":
|
||||||
|
return filepath.Join(home, "lib", "underbbs")
|
||||||
|
default:
|
||||||
|
return filepath.Join(home, ".config", "underbbs")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureConfigLocationExists() {
|
||||||
|
fileInfo, err := os.Stat(getConfigLocation())
|
||||||
|
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
os.MkdirAll(getConfigLocation(), os.ModePerm)
|
||||||
|
} else if !fileInfo.IsDir() {
|
||||||
|
panic("Config location is not a directory!")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadConfig() (models.GlobalSettings, error) {
|
||||||
|
var settings models.GlobalSettings
|
||||||
|
|
||||||
|
ensureConfigLocationExists()
|
||||||
|
cfgdir := getConfigLocation()
|
||||||
|
|
||||||
|
// get config from config fle based on adapter
|
||||||
|
content, err := ioutil.ReadFile(filepath.Join(cfgdir, "config.json"))
|
||||||
|
if err != nil {
|
||||||
|
return settings, err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = json.Unmarshal(content, &settings)
|
||||||
|
if err != nil {
|
||||||
|
return settings, err
|
||||||
|
}
|
||||||
|
return settings, nil
|
||||||
|
}
|
22
frontend/dist/index.html
vendored
22
frontend/dist/index.html
vendored
|
@ -1,22 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en-US">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<title>underBBS</title>
|
|
||||||
<meta name="viewport" content="width=device-width" />
|
|
||||||
<link rel="shortcut icon" href="./favicon.png"/>
|
|
||||||
<link href="./style.css" rel="stylesheet" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<noscript><div id="noscript_container">
|
|
||||||
JS app lol
|
|
||||||
</div>
|
|
||||||
</noscript>
|
|
||||||
<div id="err_wrapper" style='display:none'><button id="err_close" onclick="closeErr()">x</button><div id="err_div"></div></div>
|
|
||||||
<nav id="tabbar_injectparent">
|
|
||||||
</nav>
|
|
||||||
<main id="mainarea_injectparent">
|
|
||||||
</main>
|
|
||||||
</body>
|
|
||||||
<script src="./main.js" type="application/javascript"></script>
|
|
||||||
</html>
|
|
46
frontend/dist/style.css
vendored
46
frontend/dist/style.css
vendored
|
@ -1,11 +1,3 @@
|
||||||
:root {
|
|
||||||
--bg_color: #000000;
|
|
||||||
--fg_color: #ccc;
|
|
||||||
--main_color: #1f9b92;
|
|
||||||
--sub_color: #002b36;
|
|
||||||
--err_color: #DC143C;
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
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;
|
|
||||||
}
|
}
|
|
@ -1,271 +0,0 @@
|
||||||
import util from "./util"
|
|
||||||
|
|
||||||
import { Message, Author } from "./message"
|
|
||||||
import { MessageThread } from "./thread"
|
|
||||||
import { AdapterState } from "./adapter"
|
|
||||||
import { BatchTimer } from "./batch-timer"
|
|
||||||
|
|
||||||
export class AdapterElement extends HTMLElement {
|
|
||||||
static observedAttributes = [ "data-latest", "data-view", "data-viewing" ]
|
|
||||||
|
|
||||||
private _latest: string = "" ;
|
|
||||||
private _view: string = "";
|
|
||||||
private _name: string = ""
|
|
||||||
private _viewing: string = "";
|
|
||||||
|
|
||||||
private _convoyBatchTimer = new BatchTimer((ids: string[])=>{
|
|
||||||
let url = `/api/adapters/${this._name}/fetch?entity_type=convoy`;
|
|
||||||
for (let id of ids) {
|
|
||||||
url += `&entity_id=${id}`;
|
|
||||||
}
|
|
||||||
util.authorizedFetch("GET", url, null)
|
|
||||||
});
|
|
||||||
|
|
||||||
// TODO: use visibility of the thread to organize into DMs and public threads
|
|
||||||
private _threads: MessageThread[] = [];
|
|
||||||
private _orphans: Message[] = [];
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
super();
|
|
||||||
}
|
|
||||||
|
|
||||||
connectedCallback() {
|
|
||||||
const name = this.getAttribute("data-name");
|
|
||||||
this._name = name ?? "";
|
|
||||||
this.buildThreads();
|
|
||||||
}
|
|
||||||
|
|
||||||
attributeChangedCallback(attr: string, prev: string, next: string) {
|
|
||||||
switch (attr) {
|
|
||||||
case "data-viewing":
|
|
||||||
if (next != prev) {
|
|
||||||
this._viewing = next;
|
|
||||||
}
|
|
||||||
// probably only needed for profile view; we might be able to move this to child components
|
|
||||||
// keep it simple for now, but eventually we will need a way to force _view to update
|
|
||||||
break;
|
|
||||||
case "data-view":
|
|
||||||
if (!next) {
|
|
||||||
this._view = "index";
|
|
||||||
} else {
|
|
||||||
this._view = next;
|
|
||||||
}
|
|
||||||
switch (this._view) {
|
|
||||||
case "index":
|
|
||||||
this.setIdxView();
|
|
||||||
this.populateIdxView();
|
|
||||||
break;
|
|
||||||
case "thread":
|
|
||||||
this.setThreadView();
|
|
||||||
this.populateThreadView();
|
|
||||||
break;
|
|
||||||
case "profile":
|
|
||||||
this.setProfileView();
|
|
||||||
this.populateProfileView();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case "data-latest":
|
|
||||||
let datastore = AdapterState._instance.data.get(this._name);
|
|
||||||
if (!datastore) {
|
|
||||||
// this shouldn't be possible
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (prev != next && next) {
|
|
||||||
this._latest = next;
|
|
||||||
}
|
|
||||||
const latestMsg = datastore.messages.get(this._latest);
|
|
||||||
if (latestMsg) {
|
|
||||||
const rootId = this.placeMsg(this._latest);
|
|
||||||
// if rootId is null, this is an orphan and we don't need to actually do any UI updates yet
|
|
||||||
if (rootId) {
|
|
||||||
switch (this._view) {
|
|
||||||
case "index":
|
|
||||||
this.updateIdxView(this._latest, rootId);
|
|
||||||
break;
|
|
||||||
case "thread":
|
|
||||||
// if the the message is part of this thread, update it
|
|
||||||
case "profile":
|
|
||||||
// if the message is from this user, show it in their profile
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const latestAuthor = datastore.profileCache.get(this._latest);
|
|
||||||
if (latestAuthor) {
|
|
||||||
switch (this._view) {
|
|
||||||
case "index":
|
|
||||||
const threadsByThisAuthor = this._threads.filter(t=>t.root.data.author == this._latest);
|
|
||||||
for (let t of threadsByThisAuthor) {
|
|
||||||
let tse = this.querySelector(`underbbs-thread-summary[data-msg='${t.root.data.id}']`)
|
|
||||||
if (tse) {
|
|
||||||
tse.setAttribute("data-author", this._latest);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "thread":
|
|
||||||
case "profile":
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setIdxView() {
|
|
||||||
this.innerHTML = "<ul id='dm_list'></ul><ul id='public_list'></ul>"
|
|
||||||
}
|
|
||||||
|
|
||||||
setThreadView() {
|
|
||||||
let html = `<a href="#${this._name}">← return to index</a>`;
|
|
||||||
html += "<ul id='msg_list'></ul>";
|
|
||||||
this.innerHTML = html;
|
|
||||||
}
|
|
||||||
|
|
||||||
setProfileView() {
|
|
||||||
let profile_bar = util.$("profile_bar");
|
|
||||||
if (profile_bar) {
|
|
||||||
// clear any previous data
|
|
||||||
} else {
|
|
||||||
// insert the profileSidebar into the dom
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
populateIdxView() {
|
|
||||||
// skip dm list for now
|
|
||||||
// public/unified list
|
|
||||||
const pl = util.$("public_list");
|
|
||||||
if (pl) {
|
|
||||||
let html = "";
|
|
||||||
for (const t of this._threads.sort((a: MessageThread, b: MessageThread) => b.latest - a.latest)) {
|
|
||||||
html +=`<li><underbbs-thread-summary data-len="${t.messageCount}" data-adapter="${t.root.data.adapter}" data-msg="${t.root.data.id}" data-created="${t.created}" data-latest="${t.latest}"></underbbs-thread-summary></li>`;
|
|
||||||
}
|
|
||||||
pl.innerHTML = html;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateIdxView(latest: string, rootId: string) {
|
|
||||||
const threadSelector = `underbbs-thread-summary[data-msg='${rootId}']`
|
|
||||||
const existingThread = document.querySelector(threadSelector);
|
|
||||||
const thread = this._threads.find(t=>t.root.data.id == rootId);
|
|
||||||
if (existingThread && thread) {
|
|
||||||
debugger;
|
|
||||||
existingThread.setAttribute("data-latest", `${thread.latest}`);
|
|
||||||
existingThread.setAttribute("data-len", `${thread.messageCount}`);
|
|
||||||
existingThread.setAttribute("data-new", "true");
|
|
||||||
} else {
|
|
||||||
// unified/public list for now
|
|
||||||
const pl = util.$("public_list");
|
|
||||||
if (pl && thread) {
|
|
||||||
const li = document.createElement("li");
|
|
||||||
li.innerHTML = `<underbbs-thread-summary data-len="1" data-adapter="${thread.root.data.adapter}" data-msg="${thread.root.data.id}" data-latest="${thread.latest}" data-created="${thread.created}"></underbbs-thread-summary>`;
|
|
||||||
let nextThread: Element | null = null;
|
|
||||||
for (let i = 0; i < pl.children.length; i++) {
|
|
||||||
const c = pl.children.item(i);
|
|
||||||
const latest = c?.children.item(0)?.getAttribute("data-latest")
|
|
||||||
if (latest && parseInt(latest) < thread.latest) {
|
|
||||||
nextThread = c;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (nextThread) {
|
|
||||||
nextThread.insertAdjacentElement('beforebegin', li);
|
|
||||||
} else {
|
|
||||||
pl.append(li);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
populateThreadView() {
|
|
||||||
}
|
|
||||||
|
|
||||||
populateProfileView() {
|
|
||||||
}
|
|
||||||
|
|
||||||
buildThreads() {
|
|
||||||
const datastore = AdapterState._instance.data.get(this._name);
|
|
||||||
if (!datastore) {
|
|
||||||
util.errMsg(this._name + " has no datastore!");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// make multiple passes over the store until every message is either
|
|
||||||
// placed in a thread, or orphaned and waiting for its parent to be returned
|
|
||||||
do{
|
|
||||||
for (let k of datastore.messages.keys()) {
|
|
||||||
this.placeMsg(k);
|
|
||||||
}
|
|
||||||
} while (this._threads.reduce((sum: number, thread: MessageThread)=>{
|
|
||||||
return sum + thread.messageCount;
|
|
||||||
}, 0) + this._orphans.length < datastore.messages.size);
|
|
||||||
}
|
|
||||||
|
|
||||||
placeMsg(k: string): string | null {
|
|
||||||
const datastore = AdapterState._instance.data.get(this._name);
|
|
||||||
if (!datastore) {
|
|
||||||
util.errMsg(this._name + " has no datastore!");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const msg = datastore.messages.get(k);
|
|
||||||
if (!msg) {
|
|
||||||
util.errMsg(`message [${this._name}:${k}] doesn't exist`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
for (let t of this._threads) {
|
|
||||||
// avoid processing nodes again on subsequent passes
|
|
||||||
if (!msg || t.findNode(t.root, msg.id)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (msg.replyTo) {
|
|
||||||
let x = t.addReply(msg.replyTo, msg);
|
|
||||||
if (x) {
|
|
||||||
// after adding, we try to adopt some orphans
|
|
||||||
const orphanChildren = this._orphans.filter(m=>m.replyTo == k);
|
|
||||||
for (let o of orphanChildren) {
|
|
||||||
let adopted = this.placeMsg(o.id);
|
|
||||||
if (adopted) {
|
|
||||||
this._orphans.splice(this._orphans.indexOf(o), 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return t.root.data.id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// if we made it this far, this message doesn't go in any existing thread
|
|
||||||
|
|
||||||
// if it doesn't have a parent, we can make a new thread with it
|
|
||||||
if (!msg.replyTo) {
|
|
||||||
this._threads.push(new MessageThread(msg));
|
|
||||||
// after adding, we try to adopt some orphans
|
|
||||||
const orphanChildren = this._orphans.filter(m=>m.replyTo == k);
|
|
||||||
for (let o of orphanChildren) {
|
|
||||||
let adopted = this.placeMsg(o.id);
|
|
||||||
if (adopted) {
|
|
||||||
this._orphans.splice(this._orphans.indexOf(o), 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return msg.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
// then, we should check if its parent is an orphan
|
|
||||||
const orphanedParent = this._orphans.find(o=>o.id == msg.replyTo);
|
|
||||||
if (orphanedParent) {
|
|
||||||
// then, try to place them both
|
|
||||||
|
|
||||||
if (this.placeMsg(orphanedParent.id)) {
|
|
||||||
this._orphans.splice(this._orphans.indexOf(orphanedParent), 1);
|
|
||||||
return this.placeMsg(k);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// otherwise we can orphan it and try to fill it in later
|
|
||||||
if (this._orphans.filter(o=>o.id == msg.id).length == 0) {
|
|
||||||
this._orphans.push(msg);
|
|
||||||
if (msg.replyTo) {
|
|
||||||
this._convoyBatchTimer.queue(k, 2000);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
101
frontend/ts/create-message-element.ts
Normal file
101
frontend/ts/create-message-element.ts
Normal file
|
@ -0,0 +1,101 @@
|
||||||
|
import { Doer } from './doer'
|
||||||
|
|
||||||
|
export class CreateMessageElement extends HTMLElement {
|
||||||
|
private _gateway: string = "";
|
||||||
|
private _adapter: string = ""
|
||||||
|
private _doer: Doer | null = null;
|
||||||
|
static observedAttributes = [ "data-replyto" ];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.innerHTML = `<form onsubmit="return false"><input readonly class="createmessage_replyto"></input><textarea class="createmessage_content"></textarea><details><summary>attachment?</summary><input type="file" class="createmessage_file"></input><input class="createmessage_desc"></input></details><input type="submit" value="post"></input></form>`
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
this._gateway = this.getAttribute("data-gateway") ?? "";
|
||||||
|
this._adapter = this.getAttribute("data-adapter") ?? "";
|
||||||
|
this._doer = new Doer(this._gateway, this._adapter);
|
||||||
|
|
||||||
|
const postBtn = this.querySelector(`input[type="submit"]`);
|
||||||
|
if (postBtn) {
|
||||||
|
postBtn.addEventListener("click", this.doPost.bind(this));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
attributeChangedCallback(attr: string, prev: string, next: string) {
|
||||||
|
switch (attr) {
|
||||||
|
// when we implement replies, we'll come back here
|
||||||
|
case "data-replyto":
|
||||||
|
const e = this.querySelector(".createmessage_replyto") as HTMLInputElement;
|
||||||
|
if (e) {
|
||||||
|
e.value = next;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resetInput(r: Response) {
|
||||||
|
if (r.ok) {
|
||||||
|
const f = this.querySelector("form") as HTMLFormElement;
|
||||||
|
if (f) {
|
||||||
|
f.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
doPost() {
|
||||||
|
const msgContent = this.querySelector(".createmessage_content") as HTMLTextAreaElement;
|
||||||
|
const msgAttachment = this.querySelector(".createmessage_file") as HTMLInputElement;
|
||||||
|
const msgAttachmentDesc = this.querySelector(".createmessage_desc") as HTMLInputElement;
|
||||||
|
const msgReplyTo = this.querySelector(".createmessage_replyto") as HTMLInputElement;
|
||||||
|
|
||||||
|
const doReq: any = {};
|
||||||
|
doReq.action = "post";
|
||||||
|
doReq.content = msgContent.value ?? "";
|
||||||
|
|
||||||
|
if (msgReplyTo && msgReplyTo.value) {
|
||||||
|
doReq.replyto = msgReplyTo.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msgAttachment.files && msgAttachment.files[0]) {
|
||||||
|
const r = new FileReader();
|
||||||
|
const self = this;
|
||||||
|
|
||||||
|
doReq.desc = msgAttachmentDesc.value ?? "";
|
||||||
|
r.onload = ()=>{
|
||||||
|
if (self && self._doer) {
|
||||||
|
doReq.file = r.result;
|
||||||
|
self._doer.do(doReq)
|
||||||
|
.then(r=>{
|
||||||
|
if (r.ok) {
|
||||||
|
msgContent.value = '';
|
||||||
|
msgAttachment.value = '';
|
||||||
|
msgAttachmentDesc.value = '';
|
||||||
|
msgReplyTo.value = '';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err=>{
|
||||||
|
// try to display an error locally on the component
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.readAsDataURL(msgAttachment.files[0]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this._doer) {
|
||||||
|
this._doer.do(doReq)
|
||||||
|
.then(r=>{
|
||||||
|
if (r.ok) {
|
||||||
|
msgContent.value = '';
|
||||||
|
msgAttachment.value = '';
|
||||||
|
msgAttachmentDesc.value = '';
|
||||||
|
msgReplyTo.value = '';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err=>{
|
||||||
|
// try to display an error locally on the component
|
||||||
|
});;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
16
frontend/ts/doer.ts
Normal file
16
frontend/ts/doer.ts
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import util from './util';
|
||||||
|
|
||||||
|
export class Doer {
|
||||||
|
private _reqFn: (doParams: any) => Promise<Response>;
|
||||||
|
|
||||||
|
constructor(gateway: string, adapter: string) {
|
||||||
|
this._reqFn = (doParams: any) => {
|
||||||
|
let url = `${gateway}/api/adapters/${adapter}/do`;
|
||||||
|
return util.authorizedFetch("POST", url, JSON.stringify(doParams));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
do(doParams: any): Promise<Response> {
|
||||||
|
return this._reqFn(doParams);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,12 +1,20 @@
|
||||||
export class BatchTimer {
|
import util from './util'
|
||||||
|
|
||||||
|
export class Fetcher {
|
||||||
private _batch: string[];
|
private _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){
|
|
@ -1,56 +0,0 @@
|
||||||
import util from "./util"
|
|
||||||
import {AdapterState, AdapterData} from "./adapter";
|
|
||||||
import {Message, Attachment, Author} from "./message"
|
|
||||||
import {Settings} from "./settings"
|
|
||||||
import { TabBarElement } from "./tabbar-element"
|
|
||||||
import { MessageElement } from "./message-element"
|
|
||||||
import { SettingsElement } from "./settings-element"
|
|
||||||
import { AdapterElement } from "./adapter-element"
|
|
||||||
import { ThreadSummaryElement } from "./thread-summary-element"
|
|
||||||
|
|
||||||
function main() {
|
|
||||||
const saveData = localStorage.getItem("settings");
|
|
||||||
Settings._instance = saveData ? <Settings>JSON.parse(saveData) : new Settings();
|
|
||||||
|
|
||||||
customElements.define("underbbs-tabbar", TabBarElement);
|
|
||||||
customElements.define("underbbs-message", MessageElement);
|
|
||||||
customElements.define("underbbs-settings", SettingsElement);
|
|
||||||
customElements.define("underbbs-adapter", AdapterElement);
|
|
||||||
customElements.define("underbbs-thread-summary", ThreadSummaryElement);
|
|
||||||
|
|
||||||
util._("closeErr", util.closeErr);
|
|
||||||
|
|
||||||
tabbarInit(Settings._instance.adapters?.map(a=>a.nickname) ?? []);
|
|
||||||
|
|
||||||
registerServiceWorker();
|
|
||||||
}
|
|
||||||
|
|
||||||
function tabbarInit(adapters: string[]) {
|
|
||||||
const nav = util.$("tabbar_injectparent");
|
|
||||||
if (nav) {
|
|
||||||
nav.innerHTML = `<underbbs-tabbar data-adapters="" data-currentadapter=""></underbbs-tabbar>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function registerServiceWorker() {
|
|
||||||
if ("serviceWorker" in navigator) {
|
|
||||||
try {
|
|
||||||
const registration = await navigator.serviceWorker.register("/serviceWorker.js", {
|
|
||||||
scope: "/",
|
|
||||||
});
|
|
||||||
if (registration.installing) {
|
|
||||||
console.log("Service worker installing");
|
|
||||||
} else if (registration.waiting) {
|
|
||||||
console.log("Service worker installed");
|
|
||||||
} else if (registration.active) {
|
|
||||||
console.log("Service worker active");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Registration failed with ${error}`);
|
|
||||||
}
|
|
||||||
const registration = await navigator.serviceWorker.ready;
|
|
||||||
(registration as any).sync.register("testdata").then((r:any)=>{console.log("but i will see this!")});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main();
|
|
|
@ -1,20 +1,174 @@
|
||||||
import util from "./util"
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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
108
frontend/ts/navigator.ts
Normal file
|
@ -0,0 +1,108 @@
|
||||||
|
import { AdapterState } from "./adapter"
|
||||||
|
|
||||||
|
class HistoryNode {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
prev: HistoryNode | null = null;
|
||||||
|
next: HistoryNode | null = null;
|
||||||
|
|
||||||
|
constructor(id: string, type: string) {
|
||||||
|
this.id = id;
|
||||||
|
this.type = type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NavigatorElement extends HTMLElement {
|
||||||
|
static observedAttributes = [ "data-author", "data-message" ];
|
||||||
|
|
||||||
|
private _adapter: string = "";
|
||||||
|
private _history: HistoryNode | null = null;
|
||||||
|
private _replyWith: string | null = null;
|
||||||
|
private _gateway: string = "";
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.innerHTML = `<nav><button class="nav_prev">←</button><button class="nav_next">→</button><button class="nav_clear">×</button></nav><div class="nav_container"></div>`
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
this._adapter = this.getAttribute("data-adapter") ?? "";
|
||||||
|
this._replyWith = this.getAttribute("data-replywith");
|
||||||
|
this._gateway = this.getAttribute("data-gateway") ?? "";
|
||||||
|
|
||||||
|
const prevBtn = this.querySelector(".nav_prev");
|
||||||
|
const nextBtn = this.querySelector(".nav_next");
|
||||||
|
const clearBtn = this.querySelector(".nav_clear");
|
||||||
|
|
||||||
|
if (prevBtn) {
|
||||||
|
prevBtn.addEventListener("click", this.goPrev);
|
||||||
|
}
|
||||||
|
if (nextBtn) {
|
||||||
|
nextBtn.addEventListener("click", this.goNext);
|
||||||
|
}
|
||||||
|
if (clearBtn) {
|
||||||
|
clearBtn.addEventListener("click", this.clear);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
attributeChangedCallback(attr: string, prev: string, next: string) {
|
||||||
|
switch (attr) {
|
||||||
|
case "data-author":
|
||||||
|
case "data-message":
|
||||||
|
if (next == prev) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this._history && this._history.prev && this._history.prev.id == next) {
|
||||||
|
this._history = this._history.prev;
|
||||||
|
} else {
|
||||||
|
const h = this._history;
|
||||||
|
this._history = new HistoryNode(next, attr.slice(attr.indexOf("-") + 1));
|
||||||
|
this._history.prev = h;
|
||||||
|
}
|
||||||
|
const panel = this.querySelector(".nav_container");
|
||||||
|
const datastore = AdapterState._instance.data.get(this._adapter);
|
||||||
|
if (datastore && panel) {
|
||||||
|
switch (attr) {
|
||||||
|
case "data-author":
|
||||||
|
const author = datastore.profileCache.get(next);
|
||||||
|
panel.innerHTML = `<underbbs-profile data-gateway="${this._gateway}" data-adapter="${this._adapter}" data-target="${next}"></underbbs-profile><underbbs-timeline data-gateway="${this._gateway}" data-target="${next}" data-adapter="${this._adapter}" data-interactable data-mode="fetch" data-inspectwith="${this.getAttribute("id")??""}" data-replywith="${this._replyWith}"></underbbs-timeline>`
|
||||||
|
|
||||||
|
const profile = this.querySelector("underbbs-profile");
|
||||||
|
const tl = this.querySelector("underbbs-timeline");
|
||||||
|
if (profile && tl) {
|
||||||
|
if (!author) {
|
||||||
|
profile.setAttribute("data-target", next);
|
||||||
|
} else {
|
||||||
|
profile.setAttribute("data-latest", next);
|
||||||
|
}
|
||||||
|
tl.setAttribute("data-target", next);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "data-message":
|
||||||
|
const msg = datastore.messages.get(next);
|
||||||
|
panel.innerHTML = `<underbbs-message data-gateway="${this._gateway}" data-adapter="${this._adapter}" data-target="${next}" data-interactable data-inspectwith="${this.getAttribute("id")??""}" data-replywith="${this._replyWith}"></underbbs-message>`
|
||||||
|
const e = this.querySelector("underbbs-message");
|
||||||
|
if (e) {
|
||||||
|
if (!msg) {
|
||||||
|
e.setAttribute("data-target", next);
|
||||||
|
} else {
|
||||||
|
e.setAttribute("data-latest", next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private goNext() {
|
||||||
|
}
|
||||||
|
|
||||||
|
private goPrev() {
|
||||||
|
}
|
||||||
|
|
||||||
|
private clear() {
|
||||||
|
}
|
||||||
|
}
|
75
frontend/ts/profile-element.ts
Normal file
75
frontend/ts/profile-element.ts
Normal file
|
@ -0,0 +1,75 @@
|
||||||
|
import { Author } from "./message"
|
||||||
|
import util from "./util"
|
||||||
|
import { Fetcher } from "./fetcher"
|
||||||
|
import { AdapterState } from "./adapter"
|
||||||
|
|
||||||
|
export class ProfileElement extends HTMLElement {
|
||||||
|
static observedAttributes = [ "data-latest", "data-adapter", "data-target" ];
|
||||||
|
|
||||||
|
private _id: string | null = null;
|
||||||
|
private _adapter: string = "";
|
||||||
|
|
||||||
|
private _author: Author | null = null;
|
||||||
|
|
||||||
|
private _authorFetcher: Fetcher | null = null;
|
||||||
|
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.innerHTML = "<div class='author_pfp'></div><div class='author_id'></div><div class='author_name'></div><div class='author_description'></div>"
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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");
|
||||||
|
|
|
@ -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
16
frontend/ts/subscriber.ts
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import util from './util';
|
||||||
|
|
||||||
|
export class Subscriber {
|
||||||
|
private _reqFn: (filter: string)=>void;
|
||||||
|
|
||||||
|
constructor(gateway: string, adapter: string, target: string | null) {
|
||||||
|
this._reqFn = (filter: string) => {
|
||||||
|
let url = `${gateway}/api/adapters/${adapter}/subscribe`
|
||||||
|
util.authorizedFetch("POST", url, JSON.stringify({filter, target}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(filter: string) {
|
||||||
|
this._reqFn(filter);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,84 +0,0 @@
|
||||||
import util from "./util"
|
|
||||||
import {Settings} from "./settings"
|
|
||||||
|
|
||||||
export class TabBarElement extends HTMLElement {
|
|
||||||
static observedAttributes = [ "data-adapters", "data-currentadapter" ]
|
|
||||||
|
|
||||||
private _adapters: string[] = [];
|
|
||||||
private _currentAdapter: string | null = null;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
super();
|
|
||||||
}
|
|
||||||
|
|
||||||
connectedCallback() {
|
|
||||||
if (this._currentAdapter) {
|
|
||||||
this.showAdapterFunc(this, this._currentAdapter)();
|
|
||||||
} else {
|
|
||||||
this.showSettings(this)();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
attributeChangedCallback(attr: string, prev: string, next: string) {
|
|
||||||
switch (attr) {
|
|
||||||
case "data-adapters":
|
|
||||||
if (next) {
|
|
||||||
this._adapters = next.split(",")
|
|
||||||
} else {
|
|
||||||
this._adapters = [];
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case "data-currentadapter":
|
|
||||||
this._currentAdapter = next || null;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
let html = "<ul><li><a id='tabbar_settings' href='#settings'>settings</a></li>";
|
|
||||||
|
|
||||||
html = this._adapters.reduce((self: string, a: string)=>{
|
|
||||||
self += `<li><a id="tabbar_${a}" href="#${a}">${a}</a></li>`;
|
|
||||||
return self;
|
|
||||||
}, html);
|
|
||||||
html += "</ul>";
|
|
||||||
|
|
||||||
this.innerHTML = html;
|
|
||||||
// now we can query the child elements and add click handlers to them
|
|
||||||
var s = util.$("tabbar_settings");
|
|
||||||
if (s) {
|
|
||||||
s.addEventListener("click", this.showSettings(this), false);
|
|
||||||
if (!this._currentAdapter) {
|
|
||||||
s.classList.add("tabbar_current");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (let i of this._adapters) {
|
|
||||||
var a = util.$(`tabbar_${i}`);
|
|
||||||
if (a) {
|
|
||||||
a.addEventListener("click", this.showAdapterFunc(this, i), false);
|
|
||||||
if (this._currentAdapter == i) {
|
|
||||||
a.classList.add("tabbar_current");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
showSettings(self: TabBarElement): ()=>void {
|
|
||||||
return () => {
|
|
||||||
let x = util.$("mainarea_injectparent");
|
|
||||||
if (x) {
|
|
||||||
x.innerHTML = `<underbbs-settings data-adapters=${Settings._instance.adapters.map(a=>a.nickname).join(",") ?? []}></underbbs-settings>`;
|
|
||||||
self.setAttribute("data-currentadapter", "");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
showAdapterFunc(self: TabBarElement, adapter: string): ()=>void {
|
|
||||||
return ()=>{
|
|
||||||
let x = util.$("mainarea_injectparent");
|
|
||||||
if (x) {
|
|
||||||
x.innerHTML = `<underbbs-adapter id="adapter_${adapter}" data-name="${adapter}" data-view=""></underbbs-adapter>`;
|
|
||||||
self.setAttribute("data-currentadapter", adapter);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,113 +0,0 @@
|
||||||
import util from "./util"
|
|
||||||
import { Message, Author } from "./message"
|
|
||||||
import { AdapterState } from "./adapter"
|
|
||||||
import { BatchTimer } from "./batch-timer"
|
|
||||||
|
|
||||||
export class ThreadSummaryElement extends HTMLElement {
|
|
||||||
static observedAttributes = [ "data-msg", "data-len", "data-author", "data-created", "data-latest", "data-new" ];
|
|
||||||
|
|
||||||
private _len: number = 0;;
|
|
||||||
private _msg: Message | null = null;;
|
|
||||||
private _author: Author | null = null;
|
|
||||||
private _adapter: string = "";
|
|
||||||
private _created: number = 0;
|
|
||||||
private _latest: number = 0;
|
|
||||||
private _new: boolean = false;
|
|
||||||
|
|
||||||
private _authorTimer: BatchTimer;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
super();
|
|
||||||
this.innerHTML = "<div class='thread_summary'><div class='thread_author'></div><div class='thread_text'></div><div class='thread_metadata'></div></div>"
|
|
||||||
|
|
||||||
// adapter shouldn't change, just set it here
|
|
||||||
this._adapter = this.getAttribute("data-adapter") ?? "";
|
|
||||||
this.addEventListener("click", this.viewThread(this), false);
|
|
||||||
this._authorTimer = new BatchTimer((ids: string[])=>{
|
|
||||||
let url = `/api/adapters/${this._adapter}/fetch?entity_type=author`;
|
|
||||||
for (let id of ids) {
|
|
||||||
url += `&entity_id=${id}`;
|
|
||||||
}
|
|
||||||
util.authorizedFetch("GET", url, null)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
connectedCallback() {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
attributeChangedCallback(attr: string, prev: string, next: string) {
|
|
||||||
|
|
||||||
const datastore = AdapterState._instance.data.get(this._adapter);
|
|
||||||
if (!datastore) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let metadataChanged = false;
|
|
||||||
|
|
||||||
switch (attr) {
|
|
||||||
case "data-msg":
|
|
||||||
if (next && next != prev) {
|
|
||||||
this._msg = datastore.messages.get(next) || null;
|
|
||||||
if (this._msg) {
|
|
||||||
// TODO: use attachment alttext or fallback text if no msg content
|
|
||||||
// TODO: handle boosts, quotes properly
|
|
||||||
|
|
||||||
const threadText = this.querySelector(".thread_text");
|
|
||||||
if (threadText) {
|
|
||||||
threadText.innerHTML = this._msg.content;
|
|
||||||
}
|
|
||||||
this.setAttribute("data-author", this._msg.author);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case "data-author":
|
|
||||||
if (next) {
|
|
||||||
let authorData= datastore.profileCache.get(next);
|
|
||||||
if (!authorData) {
|
|
||||||
this._authorTimer.queue(next, 2000);
|
|
||||||
this._author = <Author>{id: next};
|
|
||||||
const threadAuthor = this.querySelector(".thread_author");
|
|
||||||
if (threadAuthor) {
|
|
||||||
threadAuthor.innerHTML = `<a id="thread_${this._adapter}_${(this._msg ? this._msg.id : 0)}_${this._author.id}" href="#author?id=${this._author.id}">${this._author.id}</a>`;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this._author = authorData;
|
|
||||||
const threadAuthor = this.querySelector(".thread_author");
|
|
||||||
if (threadAuthor) {
|
|
||||||
threadAuthor.innerHTML = `<img src="${this._author.profilePic}" alt="${this._author.id}"/> <a id="thread_${this._adapter}_${(this._msg ? this._msg.id : 0)}_${this._author.id}" href="#author?id=${this._author.id}">${this._author.id}</a>`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case "data-len":
|
|
||||||
this._len = parseInt(next);
|
|
||||||
metadataChanged = true;
|
|
||||||
break;
|
|
||||||
case "data-latest":
|
|
||||||
this._latest = parseInt(next);
|
|
||||||
metadataChanged = true;
|
|
||||||
break;
|
|
||||||
case "data-new":
|
|
||||||
this._new = next ? true : false;
|
|
||||||
metadataChanged = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (metadataChanged) {
|
|
||||||
const threadMeta = this.querySelector(".thread_metadata");
|
|
||||||
if (threadMeta) {
|
|
||||||
threadMeta.innerHTML = `<span>${this._new ? "!" : ""}[${this._len}] created: ${new Date(this._created)}, updated: ${new Date(this._latest)}</span>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
viewThread(self: ThreadSummaryElement) {
|
|
||||||
return () => {
|
|
||||||
const a = util.$(`adapter_${self._adapter}`);
|
|
||||||
if (a && self._msg) {
|
|
||||||
a.setAttribute("data-view", "thread");
|
|
||||||
a.setAttribute("data-viewing", self._msg.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
113
frontend/ts/timeline-element.ts
Normal file
113
frontend/ts/timeline-element.ts
Normal file
|
@ -0,0 +1,113 @@
|
||||||
|
import { Author, Message } from "./message"
|
||||||
|
import util from "./util"
|
||||||
|
import { Subscriber } from "./subscriber"
|
||||||
|
import { Fetcher } from "./fetcher"
|
||||||
|
import { AdapterState } from "./adapter"
|
||||||
|
|
||||||
|
export class TimelineElement extends HTMLElement {
|
||||||
|
static observedAttributes = [ "data-latest", "data-adapter", "data-target" ];
|
||||||
|
|
||||||
|
private _timeline: string | null = null;
|
||||||
|
private _adapter: string = "";
|
||||||
|
|
||||||
|
private _interactable: boolean = false;
|
||||||
|
private _replyWith: string | null = null;
|
||||||
|
private _inspectWith: string | null = null;
|
||||||
|
private _mode: string = "subscribe";
|
||||||
|
|
||||||
|
private _messages: Message[] = [];
|
||||||
|
private _subscriber: Subscriber | null = null;
|
||||||
|
private _byAuthorFetcher: Fetcher | null = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.innerHTML = `<ul class="messages_list"></ul>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
this._timeline = this.getAttribute("data-target");
|
||||||
|
this._adapter = this.getAttribute("data-adapter") ?? "";
|
||||||
|
const gateway = this.getAttribute("data-gateway") ?? "";
|
||||||
|
this._subscriber = new Subscriber(gateway, this._adapter ?? "", this.getAttribute("id") ?? null);
|
||||||
|
this._byAuthorFetcher = new Fetcher(gateway, this._adapter, "byAuthor");
|
||||||
|
this._interactable = this.getAttribute("data-interactable") != null;
|
||||||
|
this._replyWith = this.getAttribute("data-replywith");
|
||||||
|
this._inspectWith = this.getAttribute("data-inspectwith");
|
||||||
|
this._mode = this.getAttribute("data-mode") ?? "subscribe";
|
||||||
|
}
|
||||||
|
|
||||||
|
attributeChangedCallback(attr: string, prev: string, next: string) {
|
||||||
|
switch (attr) {
|
||||||
|
case "data-target":
|
||||||
|
if (!next) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this._timeline = next;
|
||||||
|
this.innerHTML = `<ul class="messages_list"></ul>`;
|
||||||
|
this._messages = [];
|
||||||
|
switch (this._mode) {
|
||||||
|
case "byAuthor":
|
||||||
|
if (this._byAuthorFetcher) {
|
||||||
|
this._byAuthorFetcher.queue(next, 100);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "subscribe":
|
||||||
|
if (this._subscriber) {
|
||||||
|
this._subscriber.subscribe(next);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "data-latest":
|
||||||
|
let datastore = AdapterState._instance.data.get(this._adapter);
|
||||||
|
if (!datastore) {
|
||||||
|
console.log("no data yet, wait for some to come in maybe...");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let msg = datastore.messages.get(next);
|
||||||
|
if (msg) {
|
||||||
|
console.log(msg);
|
||||||
|
const existingIdx = this._messages.findIndex(m=>(m.renoteId ?? m.id) == (msg.renoteId ?? msg.id) && ((m.edited ?? m.created) <= (msg.edited ?? msg.created)));
|
||||||
|
|
||||||
|
// first we update the backing data store
|
||||||
|
if (existingIdx >= 0) {
|
||||||
|
this._messages[existingIdx] = msg;
|
||||||
|
} else if (!this._messages.some(m=>(m.renoteId ?? m.id) == (msg.renoteId ?? msg.id))) {
|
||||||
|
this._messages.push(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const ul = this.children[0];
|
||||||
|
if (ul) {
|
||||||
|
// first pass through the dom, try to update a message if it's there
|
||||||
|
for (let i = 0; i < ul.childElementCount; i++){
|
||||||
|
const id = ul.children[i]?.children[0]?.getAttribute("data-target");
|
||||||
|
const ogMsg = this._messages.find(m=>(m.renoteId ?? m.id) == id);
|
||||||
|
if (ogMsg && existingIdx >= 0) {
|
||||||
|
ul.children[i]?.children[0]?.setAttribute("data-latest", id ?? "");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// if we made it this far, let's create a node
|
||||||
|
const e = document.createElement("li");
|
||||||
|
e.innerHTML = `<underbbs-message data-adapter="${this._adapter}" data-target="${next}" ${this._interactable ? "data-interactable" : ""} ${this._replyWith ? "data-replywith='" + this._replyWith + "'" : ""} ${this._inspectWith ? "data-inspectwith='" + this._inspectWith + "'": ""}></underbbs-message>`
|
||||||
|
// second pass, try to place it in reverse-chronological order
|
||||||
|
for (let i = 0; i < ul.childElementCount; i++){
|
||||||
|
const id = ul.children[i]?.children[0]?.getAttribute("data-target");
|
||||||
|
const ogMsg = this._messages.find(m=>(m.renoteId ?? m.id) == id);
|
||||||
|
if (ogMsg && (ogMsg.renoteTime ?? ogMsg.created) <= (msg.renoteTime ?? msg.created)) {
|
||||||
|
ul.insertBefore(e, ul.children[i])
|
||||||
|
e.children[0].setAttribute("data-latest", next);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// final pass, we must be the earliest child (or maybe the first one to be rendered)
|
||||||
|
ul.append(e);
|
||||||
|
e.children[0].setAttribute("data-latest", next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
55
frontend/ts/timeline-filter-element.ts
Normal file
55
frontend/ts/timeline-filter-element.ts
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
export class TimelineFilter {
|
||||||
|
public name: string = "";
|
||||||
|
public filter: string = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TimelineFilterElement extends HTMLElement {
|
||||||
|
static observedAttributes = [ "data-filters", "data-target", "data-latest" ];
|
||||||
|
|
||||||
|
private _filters: TimelineFilter[] = [];
|
||||||
|
private _target: string = "";
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.innerHTML = "<select class='filter_select'></select>"
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
this._target = this.getAttribute("data-target") ?? "";
|
||||||
|
this._filters = (this.getAttribute("data-filters") ?? "").split("/").map(f=>{
|
||||||
|
const ff = f.split("::");
|
||||||
|
return <TimelineFilter>{ name: ff[0], filter: ff[1] };
|
||||||
|
});
|
||||||
|
let html = "<select class='filter_select'>";
|
||||||
|
html += this._filters.reduce((self: string, f: TimelineFilter)=>{
|
||||||
|
self += `<option value='${f.filter}'>${f.name}</option>`;
|
||||||
|
return self;
|
||||||
|
}, "");
|
||||||
|
html += "</select>";
|
||||||
|
this.innerHTML = html;
|
||||||
|
const select = this.querySelector(".filter_select");
|
||||||
|
if (select) {
|
||||||
|
select.addEventListener("change", this.onChanges.bind(this));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onChanges() {
|
||||||
|
const select = this.querySelector(".filter_select") as HTMLSelectElement;
|
||||||
|
|
||||||
|
// change target's target!
|
||||||
|
const target = document.getElementById(this._target);
|
||||||
|
if (target) {
|
||||||
|
target.setAttribute("data-target", select?.options[select.selectedIndex].value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
attributeChangedCallback(attr: string, prev: string, next: string) {
|
||||||
|
switch (attr) {
|
||||||
|
case "data-latest":
|
||||||
|
const select = this.querySelector(".filter_select") as HTMLSelectElement;
|
||||||
|
select.selectedIndex = this._filters.findIndex(f=>f.name == next);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,5 +1,4 @@
|
||||||
import { DatagramSocket } from './websocket'
|
import { 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,
|
||||||
|
|
|
@ -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
7
go.mod
|
@ -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
18
go.sum
|
@ -1,3 +1,5 @@
|
||||||
|
forge.lightcrystal.systems/nilix/quartzgun v0.4.2 h1:S4ae33noQ+ilMvAKNh50KfwLb+SQqeXKZSoWMRsjEoM=
|
||||||
|
forge.lightcrystal.systems/nilix/quartzgun v0.4.2/go.mod h1:hIXDh7AKtAVekjR6RIFW94d/c7cCQbyh8mzaTmP/pM8=
|
||||||
github.com/McKael/madon v2.3.0+incompatible h1:xMUA+Fy4saDV+8tN3MMnwJUoYWC//5Fy8LeOqJsRNIM=
|
github.com/McKael/madon v2.3.0+incompatible 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=
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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`
|
||||||
}
|
}
|
||||||
|
|
223
server/api.go
223
server/api.go
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
40
underbbs.go
40
underbbs.go
|
@ -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,
|
||||||
|
|
|
@ -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'),
|
||||||
},
|
},
|
||||||
|
|
Loading…
Reference in a new issue