Merge pull request #78 from ssb-ngi-pointer/aliases

Aliases: database and web/muxrpc handlers
This commit is contained in:
Henry 2021-03-16 11:35:50 +01:00 committed by GitHub
commit 206a776917
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
50 changed files with 3075 additions and 259 deletions

56
aliases/confirm.go Normal file
View File

@ -0,0 +1,56 @@
// SPDX-License-Identifier: MIT
// Package aliases implements the validation and signing features of https://ssb-ngi-pointer.github.io/rooms2/#alias
package aliases
import (
"bytes"
"golang.org/x/crypto/ed25519"
refs "go.mindeco.de/ssb-refs"
)
// Registration ties an alias to the ID of the user and the RoomID it should be registered on
type Registration struct {
Alias string
UserID refs.FeedRef
RoomID refs.FeedRef
}
// Sign takes the public key (belonging to UserID) and returns the signed confirmation
func (r Registration) Sign(privKey ed25519.PrivateKey) Confirmation {
var conf Confirmation
conf.Registration = r
msg := r.createRegistrationMessage()
conf.Signature = ed25519.Sign(privKey, msg)
return conf
}
// createRegistrationMessage returns the string of bytes that should be signed
func (r Registration) createRegistrationMessage() []byte {
var message bytes.Buffer
message.WriteString("=room-alias-registration:")
message.WriteString(r.RoomID.Ref())
message.WriteString(":")
message.WriteString(r.UserID.Ref())
message.WriteString(":")
message.WriteString(r.Alias)
return message.Bytes()
}
// Confirmation combines a registration with the corresponding signature
type Confirmation struct {
Registration
Signature []byte
}
// Verify checks that the confirmation is for the expected room and from the expected feed
func (c Confirmation) Verify() bool {
// re-construct the registration
message := c.createRegistrationMessage()
// check the signature matches
return ed25519.Verify(c.UserID.PubKey(), message, c.Signature)
}

67
aliases/confirm_test.go Normal file
View File

@ -0,0 +1,67 @@
// SPDX-License-Identifier: MIT
package aliases
import (
"bytes"
"testing"
"github.com/ssb-ngi-pointer/go-ssb-room/internal/maybemod/keys"
"github.com/stretchr/testify/require"
refs "go.mindeco.de/ssb-refs"
)
func TestConfirmation(t *testing.T) {
r := require.New(t)
// this is our room, it's not a valid feed but thats fine for this test
roomID := refs.FeedRef{
ID: bytes.Repeat([]byte("test"), 8),
Algo: "test",
}
// to make the test deterministic, decided by fair dice roll.
seed := bytes.Repeat([]byte("yeah"), 8)
// our user, who will sign the registration
userKeyPair, err := keys.NewKeyPair(bytes.NewReader(seed))
r.NoError(err)
// create and fill out the registration for an alias (in this case the name of the test)
var valid Registration
valid.RoomID = roomID
valid.UserID = userKeyPair.Feed
valid.Alias = t.Name()
// internal function to create the registration string
msg := valid.createRegistrationMessage()
want := "=room-alias-registration:@dGVzdHRlc3R0ZXN0dGVzdHRlc3R0ZXN0dGVzdHRlc3Q=.test:@Rt2aJrtOqWXhBZ5/vlfzeWQ9Bj/z6iT8CMhlr2WWlG4=.ed25519:TestConfirmation"
r.Equal(want, string(msg))
// create the signed confirmation
confirmation := valid.Sign(userKeyPair.Pair.Secret)
yes := confirmation.Verify()
r.True(yes, "should be valid for this room and feed")
// make up another id for the invalid test(s)
otherID := refs.FeedRef{
ID: bytes.Repeat([]byte("nope"), 8),
Algo: "test",
}
confirmation.RoomID = otherID
yes = confirmation.Verify()
r.False(yes, "should not be valid for another room")
confirmation.RoomID = roomID // restore
confirmation.UserID = otherID
yes = confirmation.Verify()
r.False(yes, "should not be valid for this room but another feed")
// puncture the signature to emulate an invalid one
confirmation.Signature[0] = confirmation.Signature[0] ^ 1
yes = confirmation.Verify()
r.False(yes, "should not be valid anymore")
}

42
aliases/names.go Normal file
View File

@ -0,0 +1,42 @@
// SPDX-License-Identifier: MIT
package aliases
import (
"fmt"
)
// IsValid decides whether an alias is okay for use or not.
// The room spec defines it as _labels valid under RFC 1035_ ( https://ssb-ngi-pointer.github.io/rooms2/#alias-string )
// but that can be mostly any string since DNS is a 8bit binary protocol,
// as long as it's shorter then 63 charachters.
//
// Right now it's pretty basic set of characters (a-z, A-Z, 0-9).
// In theory we could be more liberal but there is a bunch of stuff to figure out,
// like homograph attacks (https://en.wikipedia.org/wiki/IDN_homograph_attack),
// if we would decide to allow full utf8 unicode.
func IsValid(alias string) bool {
if len(alias) > 63 {
return false
}
var valid = true
for _, char := range alias {
if char >= '0' && char <= '9' { // is an ASCII number
continue
}
if char >= 'a' && char <= 'z' { // is an ASCII char between a and z
continue
}
if char >= 'A' && char <= 'Z' { // is an ASCII upper-case char between a and z
continue
}
fmt.Println("found", char)
valid = false
break
}
return valid
}

31
aliases/names_test.go Normal file
View File

@ -0,0 +1,31 @@
// SPDX-License-Identifier: MIT
package aliases
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestIsValid(t *testing.T) {
a := assert.New(t)
cases := []struct {
alias string
valid bool
}{
{"basic", true},
{"no spaces", false},
{"no.dots", false},
{"#*!(! nope", false},
// too long
{"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", false},
}
for i, tc := range cases {
yes := IsValid(tc.alias)
a.Equal(tc.valid, yes, "wrong for %d: %s", i, tc.alias)
}
}

View File

@ -29,8 +29,8 @@ import (
"github.com/unrolled/secure"
"go.cryptoscope.co/muxrpc/v2/debug"
"github.com/ssb-ngi-pointer/go-ssb-room/roomdb/sqlite"
"github.com/ssb-ngi-pointer/go-ssb-room/internal/repo"
"github.com/ssb-ngi-pointer/go-ssb-room/roomdb/sqlite"
"github.com/ssb-ngi-pointer/go-ssb-room/roomsrv"
mksrv "github.com/ssb-ngi-pointer/go-ssb-room/roomsrv"
"github.com/ssb-ngi-pointer/go-ssb-room/web/handlers"
@ -201,7 +201,10 @@ func runroomsrv() error {
}
// create the shs+muxrpc server
roomsrv, err := mksrv.New(db.AllowList, opts...)
roomsrv, err := mksrv.New(
db.AllowList,
db.Aliases,
opts...)
if err != nil {
return fmt.Errorf("failed to instantiate ssb server: %w", err)
}
@ -238,10 +241,11 @@ func runroomsrv() error {
Domain: httpsDomain,
PortHTTPS: uint(portHTTP),
PortMUXRPC: uint(portMUXRPC),
PubKey: roomsrv.Whoami().PubKey(),
RoomID: roomsrv.Whoami(),
},
roomsrv.StateManager,
handlers.Databases{
Aliases: db.Aliases,
AuthWithSSB: db.AuthWithSSB,
AuthFallback: db.AuthFallback,
AllowList: db.AllowList,

View File

@ -0,0 +1,133 @@
// SPDX-License-Identifier: MIT
// Package alias implements the muxrpc handlers for alias needs.
package alias
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"strings"
kitlog "github.com/go-kit/kit/log"
"go.cryptoscope.co/muxrpc/v2"
"github.com/ssb-ngi-pointer/go-ssb-room/aliases"
"github.com/ssb-ngi-pointer/go-ssb-room/internal/network"
"github.com/ssb-ngi-pointer/go-ssb-room/roomdb"
refs "go.mindeco.de/ssb-refs"
)
// Handler implements the muxrpc methods for alias registration and recvocation
type Handler struct {
logger kitlog.Logger
self refs.FeedRef
db roomdb.AliasService
}
// New returns a fresh alias muxrpc handler
func New(log kitlog.Logger, self refs.FeedRef, aliasDB roomdb.AliasService) Handler {
var h Handler
h.self = self
h.logger = log
h.db = aliasDB
return h
}
const sigSuffix = ".sig.ed25519"
// Register is an async muxrpc method handler for registering aliases.
// It receives two string arguments over muxrpc (alias and signature),
// checks the signature confirmation is correct (for this room and signed by the key of theconnection)
// If it is valid, it registers the alias on the roomdb and returns true. If not it returns an error.
func (h Handler) Register(ctx context.Context, req *muxrpc.Request) (interface{}, error) {
var args []string
err := json.Unmarshal(req.RawArgs, &args)
if err != nil {
return nil, fmt.Errorf("registerAlias: bad request: %w", err)
}
if n := len(args); n != 2 {
return nil, fmt.Errorf("registerAlias: expected two arguments got %d", n)
}
if !strings.HasSuffix(args[1], sigSuffix) {
return nil, fmt.Errorf("registerAlias: signature does not have the expected suffix")
}
// remove the suffix of the base64 string
sig := strings.TrimSuffix(args[1], sigSuffix)
var confirmation aliases.Confirmation
confirmation.RoomID = h.self
confirmation.Alias = args[0]
confirmation.Signature, err = base64.StdEncoding.DecodeString(sig)
if err != nil {
return nil, fmt.Errorf("registerAlias: bad signature encoding: %w", err)
}
// check alias is valid
if !aliases.IsValid(confirmation.Alias) {
return nil, fmt.Errorf("registerAlias: invalid alias")
}
// get the user from the muxrpc connection
userID, err := network.GetFeedRefFromAddr(req.RemoteAddr())
if err != nil {
return nil, err
}
confirmation.UserID = *userID
// check the signature
if !confirmation.Verify() {
return nil, fmt.Errorf("registerAlias: invalid signature")
}
err = h.db.Register(ctx, confirmation.Alias, confirmation.UserID, confirmation.Signature)
if err != nil {
return nil, fmt.Errorf("registerAlias: could not register alias: %w", err)
}
return true, nil
}
// Revoke checks that the alias is from that user before revoking the alias from the database.
func (h Handler) Revoke(ctx context.Context, req *muxrpc.Request) (interface{}, error) {
var args []string
err := json.Unmarshal(req.RawArgs, &args)
if err != nil {
return nil, fmt.Errorf("registerAlias: bad request: %w", err)
}
if n := len(args); n != 1 {
return nil, fmt.Errorf("registerAlias: expected two arguments got %d", n)
}
// get the user from the muxrpc connection
userID, err := network.GetFeedRefFromAddr(req.RemoteAddr())
if err != nil {
return nil, err
}
alias, err := h.db.Resolve(ctx, args[0])
if err != nil {
return nil, err
}
if !alias.Feed.Equal(userID) {
return nil, fmt.Errorf("revokeAlias: not your alias (moderators need to use the web dashboard of the room")
}
err = h.db.Revoke(ctx, alias.Name)
if err != nil {
return nil, err
}
return true, nil
}

View File

@ -23,9 +23,8 @@ type connectWithOriginArg struct {
Origin refs.FeedRef `json:"origin"` // this should be clear from the shs session already
}
func (h *handler) connect(ctx context.Context, req *muxrpc.Request, peerSrc *muxrpc.ByteSource, peerSnk *muxrpc.ByteSink) error {
func (h *Handler) connect(ctx context.Context, req *muxrpc.Request, peerSrc *muxrpc.ByteSource, peerSnk *muxrpc.ByteSink) error {
// unpack arguments
var args []connectArg
err := json.Unmarshal(req.RawArgs, &args)
if err != nil {

View File

@ -3,31 +3,14 @@
package server
import (
"net"
kitlog "github.com/go-kit/kit/log"
"go.cryptoscope.co/muxrpc/v2"
"go.cryptoscope.co/muxrpc/v2/typemux"
"github.com/ssb-ngi-pointer/go-ssb-room/internal/maybemuxrpc"
"github.com/ssb-ngi-pointer/go-ssb-room/roomstate"
refs "go.mindeco.de/ssb-refs"
)
const name = "tunnel"
var method muxrpc.Method = muxrpc.Method{name}
type plugin struct {
h muxrpc.Handler
log kitlog.Logger
}
func (plugin) Name() string { return name }
func (plugin) Method() muxrpc.Method { return method }
func (p plugin) Handler() muxrpc.Handler { return p.h }
func (plugin) Authorize(net.Conn) bool { return true }
/* manifest:
{
"announce": "sync",
@ -39,25 +22,23 @@ func (plugin) Authorize(net.Conn) bool { return true }
}
*/
func New(log kitlog.Logger, self refs.FeedRef, m *roomstate.Manager) maybemuxrpc.Plugin {
mux := typemux.New(log)
var h = new(handler)
func New(log kitlog.Logger, self refs.FeedRef, m *roomstate.Manager) *Handler {
var h = new(Handler)
h.self = self
h.logger = log
h.state = m
mux.RegisterAsync(append(method, "isRoom"), typemux.AsyncFunc(h.isRoom))
mux.RegisterAsync(append(method, "ping"), typemux.AsyncFunc(h.ping))
mux.RegisterAsync(append(method, "announce"), typemux.AsyncFunc(h.announce))
mux.RegisterAsync(append(method, "leave"), typemux.AsyncFunc(h.leave))
mux.RegisterSource(append(method, "endpoints"), typemux.SourceFunc(h.endpoints))
mux.RegisterDuplex(append(method, "connect"), typemux.DuplexFunc(h.connect))
return plugin{
h: &mux,
}
return h
}
func (h *Handler) Register(mux typemux.HandlerMux, namespace muxrpc.Method) {
mux.RegisterAsync(append(namespace, "isRoom"), typemux.AsyncFunc(h.isRoom))
mux.RegisterAsync(append(namespace, "ping"), typemux.AsyncFunc(h.ping))
mux.RegisterAsync(append(namespace, "announce"), typemux.AsyncFunc(h.announce))
mux.RegisterAsync(append(namespace, "leave"), typemux.AsyncFunc(h.leave))
mux.RegisterSource(append(namespace, "endpoints"), typemux.SourceFunc(h.endpoints))
mux.RegisterDuplex(append(namespace, "connect"), typemux.DuplexFunc(h.connect))
}

View File

@ -16,25 +16,25 @@ import (
"go.cryptoscope.co/muxrpc/v2"
)
type handler struct {
type Handler struct {
logger kitlog.Logger
self refs.FeedRef
state *roomstate.Manager
}
func (h *handler) isRoom(context.Context, *muxrpc.Request) (interface{}, error) {
func (h *Handler) isRoom(context.Context, *muxrpc.Request) (interface{}, error) {
level.Debug(h.logger).Log("called", "isRoom")
return true, nil
}
func (h *handler) ping(context.Context, *muxrpc.Request) (interface{}, error) {
func (h *Handler) ping(context.Context, *muxrpc.Request) (interface{}, error) {
now := time.Now().UnixNano() / 1000
level.Debug(h.logger).Log("called", "ping")
return now, nil
}
func (h *handler) announce(_ context.Context, req *muxrpc.Request) (interface{}, error) {
func (h *Handler) announce(_ context.Context, req *muxrpc.Request) (interface{}, error) {
level.Debug(h.logger).Log("called", "announce")
ref, err := network.GetFeedRefFromAddr(req.RemoteAddr())
if err != nil {
@ -46,7 +46,7 @@ func (h *handler) announce(_ context.Context, req *muxrpc.Request) (interface{},
return false, nil
}
func (h *handler) leave(_ context.Context, req *muxrpc.Request) (interface{}, error) {
func (h *Handler) leave(_ context.Context, req *muxrpc.Request) (interface{}, error) {
ref, err := network.GetFeedRefFromAddr(req.RemoteAddr())
if err != nil {
return nil, err
@ -57,7 +57,7 @@ func (h *handler) leave(_ context.Context, req *muxrpc.Request) (interface{}, er
return false, nil
}
func (h *handler) endpoints(_ context.Context, req *muxrpc.Request, snk *muxrpc.ByteSink) error {
func (h *Handler) endpoints(_ context.Context, req *muxrpc.Request, snk *muxrpc.ByteSink) error {
level.Debug(h.logger).Log("called", "endpoints")
toPeer := newForwarder(snk)

View File

@ -4,15 +4,14 @@ package whoami
import (
"context"
"fmt"
"net"
"go.cryptoscope.co/muxrpc/v2/typemux"
kitlog "github.com/go-kit/kit/log"
"github.com/go-kit/kit/log/level"
"go.cryptoscope.co/muxrpc/v2"
refs "go.mindeco.de/ssb-refs"
"github.com/ssb-ngi-pointer/go-ssb-room/internal/maybemuxrpc"
)
var (
@ -25,64 +24,18 @@ func checkAndLog(log kitlog.Logger, err error) {
}
}
func New(log kitlog.Logger, id refs.FeedRef) maybemuxrpc.Plugin {
return plugin{handler{
log: log,
id: id,
}}
func New(id refs.FeedRef) typemux.AsyncHandler {
return handler{id: id}
}
type plugin struct {
h handler
}
func (plugin) Name() string { return "whoami" }
func (plugin) Method() muxrpc.Method { return method }
func (wami plugin) Handler() muxrpc.Handler { return wami.h }
func (plugin) Authorize(net.Conn) bool { return true }
type handler struct {
log kitlog.Logger
id refs.FeedRef
id refs.FeedRef
}
func (handler) Handled(m muxrpc.Method) bool { return m.String() == "whoami" }
func (handler) HandleConnect(ctx context.Context, edp muxrpc.Endpoint) {}
func (h handler) HandleCall(ctx context.Context, req *muxrpc.Request) {
// TODO: push manifest check into muxrpc
if req.Type == "" {
req.Type = "async"
}
if req.Method.String() != "whoami" {
req.CloseWithError(fmt.Errorf("wrong method"))
return
}
func (h handler) HandleAsync(ctx context.Context, req *muxrpc.Request) (interface{}, error) {
type ret struct {
ID string `json:"id"`
}
err := req.Return(ctx, ret{h.id.Ref()})
checkAndLog(h.log, err)
}
type endpoint struct {
edp muxrpc.Endpoint
}
func (edp endpoint) WhoAmI(ctx context.Context) (refs.FeedRef, error) {
var resp struct {
ID refs.FeedRef `json:"id"`
}
err := edp.edp.Async(ctx, &resp, muxrpc.TypeJSON, method)
if err != nil {
return refs.FeedRef{}, fmt.Errorf("error making async call: %w", err)
}
return resp.ID, nil
return ret{h.id.Ref()}, nil
}

View File

@ -0,0 +1,113 @@
// SPDX-License-Identifier: MIT
package go_test
import (
"context"
"crypto/rand"
"encoding/base64"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.cryptoscope.co/muxrpc/v2"
"golang.org/x/sync/errgroup"
"github.com/ssb-ngi-pointer/go-ssb-room/aliases"
"github.com/ssb-ngi-pointer/go-ssb-room/internal/maybemod/keys"
"github.com/ssb-ngi-pointer/go-ssb-room/roomsrv"
)
// technically we are usign two servers here
// but we just treat one of them as a muxrpc client
func TestAliasRegister(t *testing.T) {
testInit(t)
ctx, cancel := context.WithCancel(context.Background())
botgroup, ctx := errgroup.WithContext(ctx)
bs := newBotServer(ctx, mainLog)
r := require.New(t)
a := assert.New(t)
// make a random test key
appKey := make([]byte, 32)
rand.Read(appKey)
netOpts := []roomsrv.Option{
roomsrv.WithAppKey(appKey),
roomsrv.WithContext(ctx),
}
theBots := []*roomsrv.Server{}
serv := makeNamedTestBot(t, "srv", netOpts)
botgroup.Go(bs.Serve(serv))
theBots = append(theBots, serv)
// we need bobs key to create the signature
bobsKey, err := keys.NewKeyPair(nil)
r.NoError(err)
bob := makeNamedTestBot(t, "bob", append(netOpts,
roomsrv.WithKeyPair(bobsKey),
))
botgroup.Go(bs.Serve(bob))
theBots = append(theBots, bob)
t.Cleanup(func() {
for _, bot := range theBots {
bot.Shutdown()
r.NoError(bot.Close())
}
r.NoError(botgroup.Wait())
})
// adds
serv.Allow(bob.Whoami(), true)
// serv.Allow(botB.Whoami(), true)
// allow bots to dial the remote
bob.Allow(serv.Whoami(), true)
// botB.Allow(serv.Whoami(), true)
// should work (we allowed A)
err = bob.Network.Connect(ctx, serv.Network.GetListenAddr())
r.NoError(err, "connect A to the Server")
t.Log("letting handshaking settle..")
time.Sleep(1 * time.Second)
clientForServer, ok := bob.Network.GetEndpointFor(serv.Whoami())
r.True(ok)
t.Log("got endpoint")
var testReg aliases.Registration
testReg.Alias = "bob"
testReg.RoomID = serv.Whoami()
testReg.UserID = bob.Whoami()
confirmation := testReg.Sign(bobsKey.Pair.Secret)
t.Logf("signature created: %x...", confirmation.Signature[:16])
// encode the signature as base64
sig := base64.StdEncoding.EncodeToString(confirmation.Signature) + ".sig.ed25519"
var worked bool
err = clientForServer.Async(ctx, &worked, muxrpc.TypeJSON, muxrpc.Method{"room", "registerAlias"}, "bob", sig)
r.NoError(err)
a.True(worked)
// server should have the alias now
alias, err := serv.Aliases.Resolve(ctx, "bob")
r.NoError(err)
a.Equal(confirmation.Alias, alias.Name)
a.Equal(confirmation.Signature, alias.Signature)
a.True(confirmation.UserID.Equal(&bobsKey.Feed))
t.Log("alias stored")
cancel()
}

View File

@ -18,10 +18,10 @@ import (
"github.com/stretchr/testify/require"
"go.cryptoscope.co/muxrpc/v2/debug"
"github.com/ssb-ngi-pointer/go-ssb-room/roomdb/sqlite"
"github.com/ssb-ngi-pointer/go-ssb-room/internal/maybemod/testutils"
"github.com/ssb-ngi-pointer/go-ssb-room/internal/network"
"github.com/ssb-ngi-pointer/go-ssb-room/internal/repo"
"github.com/ssb-ngi-pointer/go-ssb-room/roomdb/sqlite"
"github.com/ssb-ngi-pointer/go-ssb-room/roomsrv"
)
@ -82,9 +82,11 @@ func makeNamedTestBot(t testing.TB, name string, opts []roomsrv.Option) *roomsrv
db, err := sqlite.Open(repo.New(testPath))
r.NoError(err)
t.Cleanup(func() {
db.Close()
if err := db.Close(); err != nil {
t.Log("db close failed: ", err)
}
})
theBot, err := roomsrv.New(db.AllowList, botOptions...)
theBot, err := roomsrv.New(db.AllowList, db.Aliases, botOptions...)
r.NoError(err)
return theBot
}

View File

@ -80,8 +80,9 @@ func TestJSClient(t *testing.T) {
ts := newRandomSession(t)
// ts := newSession(t, nil)
var al = &mockdb.FakeAllowListService{}
srv := ts.startGoServer(al)
var allowDB = &mockdb.FakeAllowListService{}
var aliasDB = &mockdb.FakeAliasService{}
srv := ts.startGoServer(allowDB, aliasDB)
alice := ts.startJSClient("alice", "./testscripts/simple_client.js",
srv.Network.GetListenAddr(),
@ -110,7 +111,7 @@ func TestJSClient(t *testing.T) {
)
srv.Allow(bob, true)
al.HasFeedReturns(true)
allowDB.HasFeedReturns(true)
time.Sleep(5 * time.Second)
@ -137,10 +138,11 @@ func TestJSServer(t *testing.T) {
}
// now connect our go client
var al = &mockdb.FakeAllowListService{}
client := ts.startGoServer(al)
var allowDB = &mockdb.FakeAllowListService{}
var aliasDB = &mockdb.FakeAliasService{}
client := ts.startGoServer(allowDB, aliasDB)
client.Allow(*alice, true)
al.HasFeedReturns(true)
allowDB.HasFeedReturns(true)
var roomHandle bytes.Buffer
roomHandle.WriteString("tunnel:")

View File

@ -86,7 +86,10 @@ func newSession(t *testing.T, appKey []byte) *testSession {
return ts
}
func (ts *testSession) startGoServer(al roomdb.AllowListService, opts ...roomsrv.Option) *roomsrv.Server {
func (ts *testSession) startGoServer(
allowDB roomdb.AllowListService,
aliasDB roomdb.AliasService,
opts ...roomsrv.Option) *roomsrv.Server {
r := require.New(ts.t)
// prepend defaults
@ -107,7 +110,7 @@ func (ts *testSession) startGoServer(al roomdb.AllowListService, opts ...roomsrv
}),
)
srv, err := roomsrv.New(al, opts...)
srv, err := roomsrv.New(allowDB, aliasDB, opts...)
r.NoError(err, "failed to init tees a server")
ts.t.Logf("go server: %s", srv.Whoami().Ref())
ts.t.Cleanup(func() {

View File

@ -53,7 +53,22 @@ type AllowListService interface {
}
// AliasService manages alias handle registration and lookup
type AliasService interface{}
type AliasService interface {
// Resolve returns all the relevant information for that alias or an error if it doesnt exist
Resolve(context.Context, string) (Alias, error)
// GetByID returns the alias for that ID or an error
GetByID(context.Context, int64) (Alias, error)
// List returns a list of all registerd aliases
List(ctx context.Context) ([]Alias, error)
// Register receives an alias and signature for it. Validation needs to happen before this.
Register(ctx context.Context, alias string, userFeed refs.FeedRef, signature []byte) error
// Revoke removes an alias from the system
Revoke(ctx context.Context, alias string) error
}
// InviteService manages creation and consumption of invite tokens for joining the room.
type InviteService interface {

View File

@ -2,19 +2,423 @@
package mockdb
import (
"context"
"sync"
"github.com/ssb-ngi-pointer/go-ssb-room/roomdb"
refs "go.mindeco.de/ssb-refs"
)
type FakeAliasService struct {
GetByIDStub func(context.Context, int64) (roomdb.Alias, error)
getByIDMutex sync.RWMutex
getByIDArgsForCall []struct {
arg1 context.Context
arg2 int64
}
getByIDReturns struct {
result1 roomdb.Alias
result2 error
}
getByIDReturnsOnCall map[int]struct {
result1 roomdb.Alias
result2 error
}
ListStub func(context.Context) ([]roomdb.Alias, error)
listMutex sync.RWMutex
listArgsForCall []struct {
arg1 context.Context
}
listReturns struct {
result1 []roomdb.Alias
result2 error
}
listReturnsOnCall map[int]struct {
result1 []roomdb.Alias
result2 error
}
RegisterStub func(context.Context, string, refs.FeedRef, []byte) error
registerMutex sync.RWMutex
registerArgsForCall []struct {
arg1 context.Context
arg2 string
arg3 refs.FeedRef
arg4 []byte
}
registerReturns struct {
result1 error
}
registerReturnsOnCall map[int]struct {
result1 error
}
ResolveStub func(context.Context, string) (roomdb.Alias, error)
resolveMutex sync.RWMutex
resolveArgsForCall []struct {
arg1 context.Context
arg2 string
}
resolveReturns struct {
result1 roomdb.Alias
result2 error
}
resolveReturnsOnCall map[int]struct {
result1 roomdb.Alias
result2 error
}
RevokeStub func(context.Context, string) error
revokeMutex sync.RWMutex
revokeArgsForCall []struct {
arg1 context.Context
arg2 string
}
revokeReturns struct {
result1 error
}
revokeReturnsOnCall map[int]struct {
result1 error
}
invocations map[string][][]interface{}
invocationsMutex sync.RWMutex
}
func (fake *FakeAliasService) GetByID(arg1 context.Context, arg2 int64) (roomdb.Alias, error) {
fake.getByIDMutex.Lock()
ret, specificReturn := fake.getByIDReturnsOnCall[len(fake.getByIDArgsForCall)]
fake.getByIDArgsForCall = append(fake.getByIDArgsForCall, struct {
arg1 context.Context
arg2 int64
}{arg1, arg2})
stub := fake.GetByIDStub
fakeReturns := fake.getByIDReturns
fake.recordInvocation("GetByID", []interface{}{arg1, arg2})
fake.getByIDMutex.Unlock()
if stub != nil {
return stub(arg1, arg2)
}
if specificReturn {
return ret.result1, ret.result2
}
return fakeReturns.result1, fakeReturns.result2
}
func (fake *FakeAliasService) GetByIDCallCount() int {
fake.getByIDMutex.RLock()
defer fake.getByIDMutex.RUnlock()
return len(fake.getByIDArgsForCall)
}
func (fake *FakeAliasService) GetByIDCalls(stub func(context.Context, int64) (roomdb.Alias, error)) {
fake.getByIDMutex.Lock()
defer fake.getByIDMutex.Unlock()
fake.GetByIDStub = stub
}
func (fake *FakeAliasService) GetByIDArgsForCall(i int) (context.Context, int64) {
fake.getByIDMutex.RLock()
defer fake.getByIDMutex.RUnlock()
argsForCall := fake.getByIDArgsForCall[i]
return argsForCall.arg1, argsForCall.arg2
}
func (fake *FakeAliasService) GetByIDReturns(result1 roomdb.Alias, result2 error) {
fake.getByIDMutex.Lock()
defer fake.getByIDMutex.Unlock()
fake.GetByIDStub = nil
fake.getByIDReturns = struct {
result1 roomdb.Alias
result2 error
}{result1, result2}
}
func (fake *FakeAliasService) GetByIDReturnsOnCall(i int, result1 roomdb.Alias, result2 error) {
fake.getByIDMutex.Lock()
defer fake.getByIDMutex.Unlock()
fake.GetByIDStub = nil
if fake.getByIDReturnsOnCall == nil {
fake.getByIDReturnsOnCall = make(map[int]struct {
result1 roomdb.Alias
result2 error
})
}
fake.getByIDReturnsOnCall[i] = struct {
result1 roomdb.Alias
result2 error
}{result1, result2}
}
func (fake *FakeAliasService) List(arg1 context.Context) ([]roomdb.Alias, error) {
fake.listMutex.Lock()
ret, specificReturn := fake.listReturnsOnCall[len(fake.listArgsForCall)]
fake.listArgsForCall = append(fake.listArgsForCall, struct {
arg1 context.Context
}{arg1})
stub := fake.ListStub
fakeReturns := fake.listReturns
fake.recordInvocation("List", []interface{}{arg1})
fake.listMutex.Unlock()
if stub != nil {
return stub(arg1)
}
if specificReturn {
return ret.result1, ret.result2
}
return fakeReturns.result1, fakeReturns.result2
}
func (fake *FakeAliasService) ListCallCount() int {
fake.listMutex.RLock()
defer fake.listMutex.RUnlock()
return len(fake.listArgsForCall)
}
func (fake *FakeAliasService) ListCalls(stub func(context.Context) ([]roomdb.Alias, error)) {
fake.listMutex.Lock()
defer fake.listMutex.Unlock()
fake.ListStub = stub
}
func (fake *FakeAliasService) ListArgsForCall(i int) context.Context {
fake.listMutex.RLock()
defer fake.listMutex.RUnlock()
argsForCall := fake.listArgsForCall[i]
return argsForCall.arg1
}
func (fake *FakeAliasService) ListReturns(result1 []roomdb.Alias, result2 error) {
fake.listMutex.Lock()
defer fake.listMutex.Unlock()
fake.ListStub = nil
fake.listReturns = struct {
result1 []roomdb.Alias
result2 error
}{result1, result2}
}
func (fake *FakeAliasService) ListReturnsOnCall(i int, result1 []roomdb.Alias, result2 error) {
fake.listMutex.Lock()
defer fake.listMutex.Unlock()
fake.ListStub = nil
if fake.listReturnsOnCall == nil {
fake.listReturnsOnCall = make(map[int]struct {
result1 []roomdb.Alias
result2 error
})
}
fake.listReturnsOnCall[i] = struct {
result1 []roomdb.Alias
result2 error
}{result1, result2}
}
func (fake *FakeAliasService) Register(arg1 context.Context, arg2 string, arg3 refs.FeedRef, arg4 []byte) error {
var arg4Copy []byte
if arg4 != nil {
arg4Copy = make([]byte, len(arg4))
copy(arg4Copy, arg4)
}
fake.registerMutex.Lock()
ret, specificReturn := fake.registerReturnsOnCall[len(fake.registerArgsForCall)]
fake.registerArgsForCall = append(fake.registerArgsForCall, struct {
arg1 context.Context
arg2 string
arg3 refs.FeedRef
arg4 []byte
}{arg1, arg2, arg3, arg4Copy})
stub := fake.RegisterStub
fakeReturns := fake.registerReturns
fake.recordInvocation("Register", []interface{}{arg1, arg2, arg3, arg4Copy})
fake.registerMutex.Unlock()
if stub != nil {
return stub(arg1, arg2, arg3, arg4)
}
if specificReturn {
return ret.result1
}
return fakeReturns.result1
}
func (fake *FakeAliasService) RegisterCallCount() int {
fake.registerMutex.RLock()
defer fake.registerMutex.RUnlock()
return len(fake.registerArgsForCall)
}
func (fake *FakeAliasService) RegisterCalls(stub func(context.Context, string, refs.FeedRef, []byte) error) {
fake.registerMutex.Lock()
defer fake.registerMutex.Unlock()
fake.RegisterStub = stub
}
func (fake *FakeAliasService) RegisterArgsForCall(i int) (context.Context, string, refs.FeedRef, []byte) {
fake.registerMutex.RLock()
defer fake.registerMutex.RUnlock()
argsForCall := fake.registerArgsForCall[i]
return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3, argsForCall.arg4
}
func (fake *FakeAliasService) RegisterReturns(result1 error) {
fake.registerMutex.Lock()
defer fake.registerMutex.Unlock()
fake.RegisterStub = nil
fake.registerReturns = struct {
result1 error
}{result1}
}
func (fake *FakeAliasService) RegisterReturnsOnCall(i int, result1 error) {
fake.registerMutex.Lock()
defer fake.registerMutex.Unlock()
fake.RegisterStub = nil
if fake.registerReturnsOnCall == nil {
fake.registerReturnsOnCall = make(map[int]struct {
result1 error
})
}
fake.registerReturnsOnCall[i] = struct {
result1 error
}{result1}
}
func (fake *FakeAliasService) Resolve(arg1 context.Context, arg2 string) (roomdb.Alias, error) {
fake.resolveMutex.Lock()
ret, specificReturn := fake.resolveReturnsOnCall[len(fake.resolveArgsForCall)]
fake.resolveArgsForCall = append(fake.resolveArgsForCall, struct {
arg1 context.Context
arg2 string
}{arg1, arg2})
stub := fake.ResolveStub
fakeReturns := fake.resolveReturns
fake.recordInvocation("Resolve", []interface{}{arg1, arg2})
fake.resolveMutex.Unlock()
if stub != nil {
return stub(arg1, arg2)
}
if specificReturn {
return ret.result1, ret.result2
}
return fakeReturns.result1, fakeReturns.result2
}
func (fake *FakeAliasService) ResolveCallCount() int {
fake.resolveMutex.RLock()
defer fake.resolveMutex.RUnlock()
return len(fake.resolveArgsForCall)
}
func (fake *FakeAliasService) ResolveCalls(stub func(context.Context, string) (roomdb.Alias, error)) {
fake.resolveMutex.Lock()
defer fake.resolveMutex.Unlock()
fake.ResolveStub = stub
}
func (fake *FakeAliasService) ResolveArgsForCall(i int) (context.Context, string) {
fake.resolveMutex.RLock()
defer fake.resolveMutex.RUnlock()
argsForCall := fake.resolveArgsForCall[i]
return argsForCall.arg1, argsForCall.arg2
}
func (fake *FakeAliasService) ResolveReturns(result1 roomdb.Alias, result2 error) {
fake.resolveMutex.Lock()
defer fake.resolveMutex.Unlock()
fake.ResolveStub = nil
fake.resolveReturns = struct {
result1 roomdb.Alias
result2 error
}{result1, result2}
}
func (fake *FakeAliasService) ResolveReturnsOnCall(i int, result1 roomdb.Alias, result2 error) {
fake.resolveMutex.Lock()
defer fake.resolveMutex.Unlock()
fake.ResolveStub = nil
if fake.resolveReturnsOnCall == nil {
fake.resolveReturnsOnCall = make(map[int]struct {
result1 roomdb.Alias
result2 error
})
}
fake.resolveReturnsOnCall[i] = struct {
result1 roomdb.Alias
result2 error
}{result1, result2}
}
func (fake *FakeAliasService) Revoke(arg1 context.Context, arg2 string) error {
fake.revokeMutex.Lock()
ret, specificReturn := fake.revokeReturnsOnCall[len(fake.revokeArgsForCall)]
fake.revokeArgsForCall = append(fake.revokeArgsForCall, struct {
arg1 context.Context
arg2 string
}{arg1, arg2})
stub := fake.RevokeStub
fakeReturns := fake.revokeReturns
fake.recordInvocation("Revoke", []interface{}{arg1, arg2})
fake.revokeMutex.Unlock()
if stub != nil {
return stub(arg1, arg2)
}
if specificReturn {
return ret.result1
}
return fakeReturns.result1
}
func (fake *FakeAliasService) RevokeCallCount() int {
fake.revokeMutex.RLock()
defer fake.revokeMutex.RUnlock()
return len(fake.revokeArgsForCall)
}
func (fake *FakeAliasService) RevokeCalls(stub func(context.Context, string) error) {
fake.revokeMutex.Lock()
defer fake.revokeMutex.Unlock()
fake.RevokeStub = stub
}
func (fake *FakeAliasService) RevokeArgsForCall(i int) (context.Context, string) {
fake.revokeMutex.RLock()
defer fake.revokeMutex.RUnlock()
argsForCall := fake.revokeArgsForCall[i]
return argsForCall.arg1, argsForCall.arg2
}
func (fake *FakeAliasService) RevokeReturns(result1 error) {
fake.revokeMutex.Lock()
defer fake.revokeMutex.Unlock()
fake.RevokeStub = nil
fake.revokeReturns = struct {
result1 error
}{result1}
}
func (fake *FakeAliasService) RevokeReturnsOnCall(i int, result1 error) {
fake.revokeMutex.Lock()
defer fake.revokeMutex.Unlock()
fake.RevokeStub = nil
if fake.revokeReturnsOnCall == nil {
fake.revokeReturnsOnCall = make(map[int]struct {
result1 error
})
}
fake.revokeReturnsOnCall[i] = struct {
result1 error
}{result1}
}
func (fake *FakeAliasService) Invocations() map[string][][]interface{} {
fake.invocationsMutex.RLock()
defer fake.invocationsMutex.RUnlock()
fake.getByIDMutex.RLock()
defer fake.getByIDMutex.RUnlock()
fake.listMutex.RLock()
defer fake.listMutex.RUnlock()
fake.registerMutex.RLock()
defer fake.registerMutex.RUnlock()
fake.resolveMutex.RLock()
defer fake.resolveMutex.RUnlock()
fake.revokeMutex.RLock()
defer fake.revokeMutex.RUnlock()
copiedInvocations := map[string][][]interface{}{}
for key, value := range fake.invocations {
copiedInvocations[key] = value

View File

@ -3,9 +3,16 @@
package sqlite
import (
"context"
"database/sql"
"github.com/friendsofgo/errors"
"github.com/volatiletech/sqlboiler/v4/boil"
"github.com/volatiletech/sqlboiler/v4/queries/qm"
"github.com/ssb-ngi-pointer/go-ssb-room/roomdb"
"github.com/ssb-ngi-pointer/go-ssb-room/roomdb/sqlite/models"
refs "go.mindeco.de/ssb-refs"
)
// compiler assertion to ensure the struct fullfills the interface
@ -14,3 +21,98 @@ var _ roomdb.AliasService = (*Aliases)(nil)
type Aliases struct {
db *sql.DB
}
// Resolve returns all the relevant information for that alias or an error if it doesnt exist
func (a Aliases) Resolve(ctx context.Context, name string) (roomdb.Alias, error) {
return a.findOne(ctx, qm.Where("name = ?", name))
}
// GetByID returns the alias for that ID or an error
func (a Aliases) GetByID(ctx context.Context, id int64) (roomdb.Alias, error) {
return a.findOne(ctx, qm.Where("id = ?", id))
}
func (a Aliases) findOne(ctx context.Context, by qm.QueryMod) (roomdb.Alias, error) {
var found roomdb.Alias
// construct query which resolves the User relation and by which we shoudl look for it
qry := append([]qm.QueryMod{qm.Load("User")}, by)
entry, err := models.Aliases(qry...).One(ctx, a.db)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return found, roomdb.ErrNotFound
}
return found, err
}
// unpack models into roomdb type
found.ID = entry.ID
found.Name = entry.Name
found.Signature = entry.Signature
found.Feed = entry.R.User.PubKey.FeedRef
return found, nil
}
// List returns a list of all registerd aliases
func (a Aliases) List(ctx context.Context) ([]roomdb.Alias, error) {
all, err := models.Aliases(qm.Load("User")).All(ctx, a.db)
if err != nil {
return nil, err
}
var aliases = make([]roomdb.Alias, len(all))
for i, entry := range all {
aliases[i] = roomdb.Alias{
ID: entry.ID,
Name: entry.Name,
Feed: entry.R.User.PubKey.FeedRef,
Signature: entry.Signature,
}
}
return aliases, nil
}
// Register receives an alias and signature for it. Validation needs to happen before this.
func (a Aliases) Register(ctx context.Context, alias string, userFeed refs.FeedRef, signature []byte) error {
return transact(a.db, func(tx *sql.Tx) error {
// check we have a members entry for the feed and load it to get its ID
allowListEntry, err := models.AllowLists(qm.Where("pub_key = ?", userFeed.Ref())).One(ctx, tx)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return roomdb.ErrNotFound
}
return err
}
var newEntry models.Alias
newEntry.Name = alias
newEntry.UserID = allowListEntry.ID
newEntry.Signature = signature
err = newEntry.Insert(ctx, tx, boil.Infer())
return err
})
}
// Revoke removes an alias from the system
func (a Aliases) Revoke(ctx context.Context, alias string) error {
return transact(a.db, func(tx *sql.Tx) error {
qry := append([]qm.QueryMod{qm.Load("User")}, qm.Where("name = ?", alias))
entry, err := models.Aliases(qry...).One(ctx, a.db)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return roomdb.ErrNotFound
}
return err
}
_, err = entry.Delete(ctx, tx)
return err
})
}

View File

@ -0,0 +1,99 @@
// SPDX-License-Identifier: MIT
package sqlite
import (
"bytes"
"context"
"crypto/rand"
"errors"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
"github.com/ssb-ngi-pointer/go-ssb-room/internal/repo"
"github.com/ssb-ngi-pointer/go-ssb-room/roomdb"
refs "go.mindeco.de/ssb-refs"
)
func TestAliases(t *testing.T) {
ctx := context.Background()
testRepo := filepath.Join("testrun", t.Name())
os.RemoveAll(testRepo)
tr := repo.New(testRepo)
// fake feed for testing, looks ok at least
newMember := refs.FeedRef{ID: bytes.Repeat([]byte("acab"), 8), Algo: refs.RefAlgoFeedSSB1}
// 64 bytes of random for testing (validation is handled by the handlers)
testSig := make([]byte, 64)
rand.Read(testSig)
db, err := Open(tr)
require.NoError(t, err)
t.Run("not found", func(t *testing.T) {
r := require.New(t)
lst, err := db.Aliases.List(ctx)
r.NoError(err)
r.Len(lst, 0)
_, err = db.Aliases.GetByID(ctx, 9999)
r.Error(err)
r.EqualError(err, roomdb.ErrNotFound.Error())
_, err = db.Aliases.Resolve(ctx, "unknown")
r.Error(err)
r.EqualError(err, roomdb.ErrNotFound.Error())
err = db.Aliases.Revoke(ctx, "unknown")
r.Error(err)
r.EqualError(errors.Unwrap(err), roomdb.ErrNotFound.Error())
})
t.Run("register and revoke again", func(t *testing.T) {
r := require.New(t)
testName := "flaky"
// shouldnt work while not a member
err = db.Aliases.Register(ctx, testName, newMember, testSig)
r.Error(err)
// allow the member
err = db.AllowList.Add(ctx, newMember)
r.NoError(err)
err = db.Aliases.Register(ctx, testName, newMember, testSig)
r.NoError(err)
// should have one member now
lst, err := db.Aliases.List(ctx)
r.NoError(err)
r.Len(lst, 1)
aliasByID, err := db.Aliases.GetByID(ctx, lst[0].ID)
r.NoError(err)
r.Equal(testName, aliasByID.Name)
r.Equal(testSig, aliasByID.Signature)
resolvedAlias, err := db.Aliases.Resolve(ctx, testName)
r.NoError(err)
r.Equal(aliasByID, resolvedAlias)
err = db.Aliases.Revoke(ctx, testName)
r.NoError(err)
_, err = db.Aliases.GetByID(ctx, lst[0].ID)
r.Error(err)
r.EqualError(err, roomdb.ErrNotFound.Error())
_, err = db.Aliases.Resolve(ctx, testName)
r.Error(err)
r.EqualError(err, roomdb.ErrNotFound.Error())
})
}

View File

@ -0,0 +1,18 @@
-- +migrate Up
CREATE TABLE aliases (
id integer PRIMARY KEY AUTOINCREMENT NOT NULL,
name text UNIQUE NOT NULL,
user_id integer NOT NULL,
signature blob not null,
FOREIGN KEY ( user_id ) REFERENCES allow_list( "id" )
);
CREATE UNIQUE INDEX aliases_ids ON aliases(id);
CREATE UNIQUE INDEX aliases_names ON aliases(name);
-- +migrate Down
DROP TABLE aliases;
DROP INDEX aliases_ids;
DROP INDEX aliases_names;

File diff suppressed because it is too large Load Diff

View File

@ -23,7 +23,7 @@ import (
// AllowList is an object representing the database table.
type AllowList struct {
ID int64 `boil:"id" json:"id" toml:"id" yaml:"id"`
ID int64 `boil:"id" json:"id" toml:"id" yaml:"id"`
PubKey roomdb.DBFeedRef `boil:"pub_key" json:"pub_key" toml:"pub_key" yaml:"pub_key"`
R *allowListR `boil:"-" json:"-" toml:"-" yaml:"-"`
@ -40,64 +40,45 @@ var AllowListColumns = struct {
// Generated where
type whereHelperint64 struct{ field string }
type whereHelperroomdb_DBFeedRef struct{ field string }
func (w whereHelperint64) EQ(x int64) qm.QueryMod { return qmhelper.Where(w.field, qmhelper.EQ, x) }
func (w whereHelperint64) NEQ(x int64) qm.QueryMod { return qmhelper.Where(w.field, qmhelper.NEQ, x) }
func (w whereHelperint64) LT(x int64) qm.QueryMod { return qmhelper.Where(w.field, qmhelper.LT, x) }
func (w whereHelperint64) LTE(x int64) qm.QueryMod { return qmhelper.Where(w.field, qmhelper.LTE, x) }
func (w whereHelperint64) GT(x int64) qm.QueryMod { return qmhelper.Where(w.field, qmhelper.GT, x) }
func (w whereHelperint64) GTE(x int64) qm.QueryMod { return qmhelper.Where(w.field, qmhelper.GTE, x) }
func (w whereHelperint64) IN(slice []int64) qm.QueryMod {
values := make([]interface{}, 0, len(slice))
for _, value := range slice {
values = append(values, value)
}
return qm.WhereIn(fmt.Sprintf("%s IN ?", w.field), values...)
}
func (w whereHelperint64) NIN(slice []int64) qm.QueryMod {
values := make([]interface{}, 0, len(slice))
for _, value := range slice {
values = append(values, value)
}
return qm.WhereNotIn(fmt.Sprintf("%s NOT IN ?", w.field), values...)
}
type whereHelperadmindb_DBFeedRef struct{ field string }
func (w whereHelperadmindb_DBFeedRef) EQ(x roomdb.DBFeedRef) qm.QueryMod {
func (w whereHelperroomdb_DBFeedRef) EQ(x roomdb.DBFeedRef) qm.QueryMod {
return qmhelper.Where(w.field, qmhelper.EQ, x)
}
func (w whereHelperadmindb_DBFeedRef) NEQ(x roomdb.DBFeedRef) qm.QueryMod {
func (w whereHelperroomdb_DBFeedRef) NEQ(x roomdb.DBFeedRef) qm.QueryMod {
return qmhelper.Where(w.field, qmhelper.NEQ, x)
}
func (w whereHelperadmindb_DBFeedRef) LT(x roomdb.DBFeedRef) qm.QueryMod {
func (w whereHelperroomdb_DBFeedRef) LT(x roomdb.DBFeedRef) qm.QueryMod {
return qmhelper.Where(w.field, qmhelper.LT, x)
}
func (w whereHelperadmindb_DBFeedRef) LTE(x roomdb.DBFeedRef) qm.QueryMod {
func (w whereHelperroomdb_DBFeedRef) LTE(x roomdb.DBFeedRef) qm.QueryMod {
return qmhelper.Where(w.field, qmhelper.LTE, x)
}
func (w whereHelperadmindb_DBFeedRef) GT(x roomdb.DBFeedRef) qm.QueryMod {
func (w whereHelperroomdb_DBFeedRef) GT(x roomdb.DBFeedRef) qm.QueryMod {
return qmhelper.Where(w.field, qmhelper.GT, x)
}
func (w whereHelperadmindb_DBFeedRef) GTE(x roomdb.DBFeedRef) qm.QueryMod {
func (w whereHelperroomdb_DBFeedRef) GTE(x roomdb.DBFeedRef) qm.QueryMod {
return qmhelper.Where(w.field, qmhelper.GTE, x)
}
var AllowListWhere = struct {
ID whereHelperint64
PubKey whereHelperadmindb_DBFeedRef
PubKey whereHelperroomdb_DBFeedRef
}{
ID: whereHelperint64{field: "\"allow_list\".\"id\""},
PubKey: whereHelperadmindb_DBFeedRef{field: "\"allow_list\".\"pub_key\""},
PubKey: whereHelperroomdb_DBFeedRef{field: "\"allow_list\".\"pub_key\""},
}
// AllowListRels is where relationship names are stored.
var AllowListRels = struct {
}{}
UserAliases string
}{
UserAliases: "UserAliases",
}
// allowListR is where relationships are stored.
type allowListR struct {
UserAliases AliasSlice `boil:"UserAliases" json:"UserAliases" toml:"UserAliases" yaml:"UserAliases"`
}
// NewStruct creates a new relationship struct
@ -390,6 +371,178 @@ func (q allowListQuery) Exists(ctx context.Context, exec boil.ContextExecutor) (
return count > 0, nil
}
// UserAliases retrieves all the alias's Aliases with an executor via user_id column.
func (o *AllowList) UserAliases(mods ...qm.QueryMod) aliasQuery {
var queryMods []qm.QueryMod
if len(mods) != 0 {
queryMods = append(queryMods, mods...)
}
queryMods = append(queryMods,
qm.Where("\"aliases\".\"user_id\"=?", o.ID),
)
query := Aliases(queryMods...)
queries.SetFrom(query.Query, "\"aliases\"")
if len(queries.GetSelect(query.Query)) == 0 {
queries.SetSelect(query.Query, []string{"\"aliases\".*"})
}
return query
}
// LoadUserAliases allows an eager lookup of values, cached into the
// loaded structs of the objects. This is for a 1-M or N-M relationship.
func (allowListL) LoadUserAliases(ctx context.Context, e boil.ContextExecutor, singular bool, maybeAllowList interface{}, mods queries.Applicator) error {
var slice []*AllowList
var object *AllowList
if singular {
object = maybeAllowList.(*AllowList)
} else {
slice = *maybeAllowList.(*[]*AllowList)
}
args := make([]interface{}, 0, 1)
if singular {
if object.R == nil {
object.R = &allowListR{}
}
args = append(args, object.ID)
} else {
Outer:
for _, obj := range slice {
if obj.R == nil {
obj.R = &allowListR{}
}
for _, a := range args {
if a == obj.ID {
continue Outer
}
}
args = append(args, obj.ID)
}
}
if len(args) == 0 {
return nil
}
query := NewQuery(
qm.From(`aliases`),
qm.WhereIn(`aliases.user_id in ?`, args...),
)
if mods != nil {
mods.Apply(query)
}
results, err := query.QueryContext(ctx, e)
if err != nil {
return errors.Wrap(err, "failed to eager load aliases")
}
var resultSlice []*Alias
if err = queries.Bind(results, &resultSlice); err != nil {
return errors.Wrap(err, "failed to bind eager loaded slice aliases")
}
if err = results.Close(); err != nil {
return errors.Wrap(err, "failed to close results in eager load on aliases")
}
if err = results.Err(); err != nil {
return errors.Wrap(err, "error occurred during iteration of eager loaded relations for aliases")
}
if len(aliasAfterSelectHooks) != 0 {
for _, obj := range resultSlice {
if err := obj.doAfterSelectHooks(ctx, e); err != nil {
return err
}
}
}
if singular {
object.R.UserAliases = resultSlice
for _, foreign := range resultSlice {
if foreign.R == nil {
foreign.R = &aliasR{}
}
foreign.R.User = object
}
return nil
}
for _, foreign := range resultSlice {
for _, local := range slice {
if local.ID == foreign.UserID {
local.R.UserAliases = append(local.R.UserAliases, foreign)
if foreign.R == nil {
foreign.R = &aliasR{}
}
foreign.R.User = local
break
}
}
}
return nil
}
// AddUserAliases adds the given related objects to the existing relationships
// of the allow_list, optionally inserting them as new records.
// Appends related to o.R.UserAliases.
// Sets related.R.User appropriately.
func (o *AllowList) AddUserAliases(ctx context.Context, exec boil.ContextExecutor, insert bool, related ...*Alias) error {
var err error
for _, rel := range related {
if insert {
rel.UserID = o.ID
if err = rel.Insert(ctx, exec, boil.Infer()); err != nil {
return errors.Wrap(err, "failed to insert into foreign table")
}
} else {
updateQuery := fmt.Sprintf(
"UPDATE \"aliases\" SET %s WHERE %s",
strmangle.SetParamNames("\"", "\"", 0, []string{"user_id"}),
strmangle.WhereClause("\"", "\"", 0, aliasPrimaryKeyColumns),
)
values := []interface{}{o.ID, rel.ID}
if boil.IsDebug(ctx) {
writer := boil.DebugWriterFrom(ctx)
fmt.Fprintln(writer, updateQuery)
fmt.Fprintln(writer, values)
}
if _, err = exec.ExecContext(ctx, updateQuery, values...); err != nil {
return errors.Wrap(err, "failed to update foreign table")
}
rel.UserID = o.ID
}
}
if o.R == nil {
o.R = &allowListR{
UserAliases: related,
}
} else {
o.R.UserAliases = append(o.R.UserAliases, related...)
}
for _, rel := range related {
if rel.R == nil {
rel.R = &aliasR{
User: o,
}
} else {
rel.R.User = o
}
}
return nil
}
// AllowLists retrieves all the records using an executor.
func AllowLists(mods ...qm.QueryMod) allowListQuery {
mods = append(mods, qm.From("\"allow_list\""))

View File

@ -42,38 +42,6 @@ var AuthFallbackColumns = struct {
// Generated where
type whereHelperstring struct{ field string }
func (w whereHelperstring) EQ(x string) qm.QueryMod { return qmhelper.Where(w.field, qmhelper.EQ, x) }
func (w whereHelperstring) NEQ(x string) qm.QueryMod { return qmhelper.Where(w.field, qmhelper.NEQ, x) }
func (w whereHelperstring) LT(x string) qm.QueryMod { return qmhelper.Where(w.field, qmhelper.LT, x) }
func (w whereHelperstring) LTE(x string) qm.QueryMod { return qmhelper.Where(w.field, qmhelper.LTE, x) }
func (w whereHelperstring) GT(x string) qm.QueryMod { return qmhelper.Where(w.field, qmhelper.GT, x) }
func (w whereHelperstring) GTE(x string) qm.QueryMod { return qmhelper.Where(w.field, qmhelper.GTE, x) }
func (w whereHelperstring) IN(slice []string) qm.QueryMod {
values := make([]interface{}, 0, len(slice))
for _, value := range slice {
values = append(values, value)
}
return qm.WhereIn(fmt.Sprintf("%s IN ?", w.field), values...)
}
func (w whereHelperstring) NIN(slice []string) qm.QueryMod {
values := make([]interface{}, 0, len(slice))
for _, value := range slice {
values = append(values, value)
}
return qm.WhereNotIn(fmt.Sprintf("%s NOT IN ?", w.field), values...)
}
type whereHelper__byte struct{ field string }
func (w whereHelper__byte) EQ(x []byte) qm.QueryMod { return qmhelper.Where(w.field, qmhelper.EQ, x) }
func (w whereHelper__byte) NEQ(x []byte) qm.QueryMod { return qmhelper.Where(w.field, qmhelper.NEQ, x) }
func (w whereHelper__byte) LT(x []byte) qm.QueryMod { return qmhelper.Where(w.field, qmhelper.LT, x) }
func (w whereHelper__byte) LTE(x []byte) qm.QueryMod { return qmhelper.Where(w.field, qmhelper.LTE, x) }
func (w whereHelper__byte) GT(x []byte) qm.QueryMod { return qmhelper.Where(w.field, qmhelper.GT, x) }
func (w whereHelper__byte) GTE(x []byte) qm.QueryMod { return qmhelper.Where(w.field, qmhelper.GTE, x) }
var AuthFallbackWhere = struct {
ID whereHelperint64
Name whereHelperstring

View File

@ -4,6 +4,7 @@
package models
var TableNames = struct {
Aliases string
AllowList string
AuthFallback string
Invites string
@ -11,6 +12,7 @@ var TableNames = struct {
PinNotices string
Pins string
}{
Aliases: "aliases",
AllowList: "allow_list",
AuthFallback: "auth_fallback",
Invites: "invites",

View File

@ -26,8 +26,8 @@ import (
migrate "github.com/rubenv/sql-migrate"
"github.com/ssb-ngi-pointer/go-ssb-room/roomdb"
"github.com/ssb-ngi-pointer/go-ssb-room/internal/repo"
"github.com/ssb-ngi-pointer/go-ssb-room/roomdb"
)
type Database struct {

View File

@ -40,8 +40,8 @@ func (al AllowList) add(ctx context.Context, tx *sql.Tx, a refs.FeedRef) error {
}
var entry models.AllowList
entry.PubKey.FeedRef = a
err := entry.Insert(ctx, tx, boil.Whitelist("pub_key"))
if err != nil {
var sqlErr sqlite3.Error
@ -99,7 +99,6 @@ func (al AllowList) List(ctx context.Context) (roomdb.ListEntries, error) {
var asRefs = make(roomdb.ListEntries, len(all))
for i, allowed := range all {
asRefs[i] = roomdb.ListEntry{
ID: allowed.ID,
PubKey: allowed.PubKey.FeedRef,

View File

@ -14,6 +14,17 @@ import (
// ErrNotFound is returned by the admin db if an object couldn't be found.
var ErrNotFound = errors.New("roomdb: object not found")
// Alias is how the roomdb stores an alias.
type Alias struct {
ID int64
Name string // or "alias string" as the docs call it
Feed refs.FeedRef // the ssb identity that belongs to the user
Signature []byte
}
// User holds all the information an authenticated user of the site has.
type User struct {
ID int64

50
roomsrv/init_handlers.go Normal file
View File

@ -0,0 +1,50 @@
// SPDX-License-Identifier: MIT
package roomsrv
import (
kitlog "github.com/go-kit/kit/log"
"github.com/ssb-ngi-pointer/go-ssb-room/roomdb"
muxrpc "go.cryptoscope.co/muxrpc/v2"
"go.cryptoscope.co/muxrpc/v2/typemux"
"github.com/ssb-ngi-pointer/go-ssb-room/muxrpc/handlers/alias"
"github.com/ssb-ngi-pointer/go-ssb-room/muxrpc/handlers/tunnel/server"
"github.com/ssb-ngi-pointer/go-ssb-room/muxrpc/handlers/whoami"
)
// instantiate and register the muxrpc handlers
func (s *Server) initHandlers(aliasDB roomdb.AliasService) {
// inistaniate handler packages
whoami := whoami.New(s.Whoami())
tunnelHandler := server.New(
kitlog.With(s.logger, "unit", "tunnel"),
s.Whoami(),
s.StateManager,
)
aliasHandler := alias.New(
kitlog.With(s.logger, "unit", "aliases"),
s.Whoami(),
aliasDB,
)
// register muxrpc commands
registries := []typemux.HandlerMux{s.public, s.master}
for _, mux := range registries {
mux.RegisterAsync(muxrpc.Method{"manifest"}, manifest)
mux.RegisterAsync(muxrpc.Method{"whoami"}, whoami)
// register tunnel.connect etc twice (as tunnel.* and room.*)
var method = muxrpc.Method{"tunnel"}
tunnelHandler.Register(mux, method)
method = muxrpc.Method{"room"}
tunnelHandler.Register(mux, method)
mux.RegisterAsync(append(method, "registerAlias"), typemux.AsyncFunc(aliasHandler.Register))
mux.RegisterAsync(append(method, "revokeAlias"), typemux.AsyncFunc(aliasHandler.Revoke))
}
}

View File

@ -6,15 +6,13 @@ import (
"fmt"
"net"
kitlog "github.com/go-kit/kit/log"
"go.cryptoscope.co/muxrpc/v2"
refs "go.mindeco.de/ssb-refs"
"github.com/ssb-ngi-pointer/go-ssb-room/internal/network"
"github.com/ssb-ngi-pointer/go-ssb-room/muxrpc/handlers/tunnel/server"
"github.com/ssb-ngi-pointer/go-ssb-room/muxrpc/handlers/whoami"
)
// opens the shs listener for TCP connections
func (s *Server) initNetwork() error {
// muxrpc handler creation and authoratization decider
mkHandler := func(conn net.Conn) (muxrpc.Handler, error) {
@ -27,32 +25,16 @@ func (s *Server) initNetwork() error {
}
if s.keyPair.Feed.Equal(remote) {
return s.master.MakeHandler(conn)
return &s.master, nil
}
if s.authorizer.HasFeed(s.rootCtx, *remote) {
return s.public.MakeHandler(conn)
return &s.public, nil
}
return nil, fmt.Errorf("not authorized")
}
// whoami
whoami := whoami.New(kitlog.With(s.logger, "unit", "whoami"), s.Whoami())
s.public.Register(whoami)
s.master.Register(whoami)
s.master.Register(manifestPlug)
// s.master.Register(replicate.NewPlug(s.Users))
tunnelPlug := server.New(
kitlog.With(s.logger, "unit", "tunnel"),
s.Whoami(),
s.StateManager,
)
s.public.Register(tunnelPlug)
// tcp+shs
opts := network.Options{
Logger: s.logger,

View File

@ -25,6 +25,7 @@ func WithUNIXSocket(yes bool) Option {
}
}
// creates the UNIX socket file listener for local usage
func (s *Server) initUnixSock() error {
// this races because roomsrv might not be done with init yet
// TODO: refactor network peer code and make unixsock implement that (those will be inited late anyway)
@ -53,6 +54,7 @@ func (s *Server) initUnixSock() error {
go func() {
acceptLoop:
for {
c, err := uxLis.Accept()
if err != nil {
@ -77,7 +79,7 @@ func (s *Server) initUnixSock() error {
if err != nil {
level.Warn(s.logger).Log("err", err)
c.Close()
continue
continue acceptLoop
}
}
@ -86,13 +88,7 @@ func (s *Server) initUnixSock() error {
pkr := muxrpc.NewPacker(conn)
h, err := s.master.MakeHandler(conn)
if err != nil {
level.Warn(s.logger).Log("event", "unix sock make handler", "err", err)
return
}
edp := muxrpc.Handle(pkr, h,
edp := muxrpc.Handle(pkr, &s.master,
muxrpc.WithContext(s.rootCtx),
muxrpc.WithLogger(kitlog.NewNopLogger()),
)

View File

@ -6,32 +6,14 @@ import (
"context"
"encoding/json"
"fmt"
"net"
"go.cryptoscope.co/muxrpc/v2"
)
type namedPlugin struct {
h muxrpc.Handler
name string
}
func (np namedPlugin) Name() string { return np.name }
func (np namedPlugin) Method() muxrpc.Method { return muxrpc.Method{np.name} }
func (np namedPlugin) Handler() muxrpc.Handler { return np.h }
func (np namedPlugin) Authorize(net.Conn) bool { return true }
type manifestHandler string
func (manifestHandler) Handled(m muxrpc.Method) bool { return m.String() == "manifest" }
func (manifestHandler) HandleConnect(context.Context, muxrpc.Endpoint) {}
func (h manifestHandler) HandleCall(ctx context.Context, req *muxrpc.Request) {
err := req.Return(ctx, json.RawMessage(h))
if err != nil {
fmt.Println("manifest err", err)
}
func (h manifestHandler) HandleAsync(ctx context.Context, req *muxrpc.Request) (interface{}, error) {
return json.RawMessage(h), nil
}
func init() {
@ -50,6 +32,18 @@ const manifest manifestHandler = `
"whoami":"async",
"room": {
"registerAlias": "async",
"revokeAlias": "async",
"announce": "sync",
"leave": "sync",
"connect": "duplex",
"endpoints": "source",
"isRoom": "async",
"ping": "sync"
},
"tunnel": {
"announce": "sync",
"leave": "sync",
@ -59,8 +53,3 @@ const manifest manifestHandler = `
"ping": "sync"
}
}`
var manifestPlug = namedPlugin{
h: manifest,
name: "manifest",
}

View File

@ -1,5 +1,7 @@
// SPDX-License-Identifier: MIT
// Package roomsrv implements the muxrpc server for all the room related code.
// It ties the muxrpc/handlers packages and network listeners together.
package roomsrv
import (
@ -14,11 +16,11 @@ import (
kitlog "github.com/go-kit/kit/log"
"github.com/go-kit/kit/log/level"
"go.cryptoscope.co/muxrpc/v2/typemux"
"go.cryptoscope.co/netwrap"
"github.com/ssb-ngi-pointer/go-ssb-room/internal/maybemod/keys"
"github.com/ssb-ngi-pointer/go-ssb-room/internal/maybemod/multicloser"
"github.com/ssb-ngi-pointer/go-ssb-room/internal/maybemuxrpc"
"github.com/ssb-ngi-pointer/go-ssb-room/internal/network"
"github.com/ssb-ngi-pointer/go-ssb-room/internal/repo"
"github.com/ssb-ngi-pointer/go-ssb-room/roomdb"
@ -53,24 +55,31 @@ type Server struct {
preSecureWrappers []netwrap.ConnWrapper
postSecureWrappers []netwrap.ConnWrapper
public maybemuxrpc.PluginManager
master maybemuxrpc.PluginManager
public typemux.HandlerMux
master typemux.HandlerMux
authorizer roomdb.AllowListService
StateManager *roomstate.Manager
AllowList roomdb.AllowListService
Aliases roomdb.AliasService
}
func (s Server) Whoami() refs.FeedRef {
return s.keyPair.Feed
}
func New(allow roomdb.AllowListService, opts ...Option) (*Server, error) {
func New(
allowdb roomdb.AllowListService,
aliasdb roomdb.AliasService,
opts ...Option,
) (*Server, error) {
var s Server
s.authorizer = allow
s.authorizer = allowdb
s.public = maybemuxrpc.NewPluginManager()
s.master = maybemuxrpc.NewPluginManager()
s.AllowList = allowdb
s.Aliases = aliasdb
for i, opt := range opts {
err := opt(&s)
@ -110,6 +119,9 @@ func New(allow roomdb.AllowListService, opts ...Option) (*Server, error) {
s.logger = logger
}
s.public = typemux.New(kitlog.With(s.logger, "mux", "public"))
s.master = typemux.New(kitlog.With(s.logger, "mux", "master"))
if s.rootCtx == nil {
s.rootCtx, s.Shutdown = context.WithCancel(context.Background())
}
@ -126,6 +138,8 @@ func New(allow roomdb.AllowListService, opts ...Option) (*Server, error) {
s.StateManager = roomstate.NewManager(s.rootCtx, s.logger)
s.initHandlers(aliasdb)
if err := s.initNetwork(); err != nil {
return nil, err
}

View File

@ -0,0 +1,99 @@
// SPDX-License-Identifier: MIT
package admin
import (
"errors"
"fmt"
"net/http"
"strconv"
"github.com/gorilla/csrf"
"go.mindeco.de/http/render"
"github.com/ssb-ngi-pointer/go-ssb-room/roomdb"
weberrors "github.com/ssb-ngi-pointer/go-ssb-room/web/errors"
)
// aliasesHandler implements the managment endpoints for aliases (list and revoke),
// does light validation of the web arguments and passes them through to the roomdb.
type aliasesHandler struct {
r *render.Renderer
db roomdb.AliasService
}
const redirectToAliases = "/admin/aliases"
func (h aliasesHandler) overview(rw http.ResponseWriter, req *http.Request) (interface{}, error) {
lst, err := h.db.List(req.Context())
if err != nil {
return nil, err
}
// Reverse the slice to provide recent-to-oldest results
for i, j := 0, len(lst)-1; i < j; i, j = i+1, j-1 {
lst[i], lst[j] = lst[j], lst[i]
}
pageData, err := paginate(lst, len(lst), req.URL.Query())
if err != nil {
return nil, err
}
return pageData, nil
}
func (h aliasesHandler) revokeConfirm(rw http.ResponseWriter, req *http.Request) (interface{}, error) {
if req.Method != "GET" {
return nil, weberrors.ErrBadRequest{Where: "HTTP Method", Details: fmt.Errorf("expected GET request")}
}
id, err := strconv.ParseInt(req.URL.Query().Get("id"), 10, 64)
if err != nil {
err = weberrors.ErrBadRequest{Where: "ID", Details: err}
return nil, err
}
entry, err := h.db.GetByID(req.Context(), id)
if err != nil {
if errors.Is(err, roomdb.ErrNotFound) {
http.Redirect(rw, req, redirectToAliases, http.StatusFound)
return nil, ErrRedirected
}
return nil, err
}
return map[string]interface{}{
"Entry": entry,
csrf.TemplateTag: csrf.TemplateField(req),
}, nil
}
func (h aliasesHandler) revoke(rw http.ResponseWriter, req *http.Request) {
if req.Method != "POST" {
err := weberrors.ErrBadRequest{Where: "HTTP Method", Details: fmt.Errorf("expected POST request")}
h.r.Error(rw, req, http.StatusMethodNotAllowed, err)
return
}
err := req.ParseForm()
if err != nil {
err = weberrors.ErrBadRequest{Where: "Form data", Details: err}
http.Redirect(rw, req, redirectToAliases, http.StatusFound)
return
}
status := http.StatusFound
err = h.db.Revoke(req.Context(), req.FormValue("name"))
if err != nil {
if !errors.Is(err, roomdb.ErrNotFound) {
h.r.Error(rw, req, http.StatusInternalServerError, err)
return
}
status = http.StatusNotFound
}
http.Redirect(rw, req, redirectToAliases, status)
}

View File

@ -0,0 +1,122 @@
package admin
import (
"bytes"
"net/http"
"net/url"
"testing"
"github.com/stretchr/testify/assert"
"github.com/ssb-ngi-pointer/go-ssb-room/roomdb"
"github.com/ssb-ngi-pointer/go-ssb-room/web"
"github.com/ssb-ngi-pointer/go-ssb-room/web/router"
"github.com/ssb-ngi-pointer/go-ssb-room/web/webassert"
refs "go.mindeco.de/ssb-refs"
)
func TestAliasesOverview(t *testing.T) {
ts := newSession(t)
a := assert.New(t)
lst := []roomdb.Alias{
{ID: 1, Name: "alice", Feed: refs.FeedRef{ID: bytes.Repeat([]byte{0}, 32), Algo: "fake"}},
{ID: 2, Name: "bob", Feed: refs.FeedRef{ID: bytes.Repeat([]byte("1312"), 8), Algo: "test"}},
{ID: 3, Name: "cleo", Feed: refs.FeedRef{ID: bytes.Repeat([]byte("acab"), 8), Algo: "true"}},
}
ts.Aliases.ListReturns(lst, nil)
html, resp := ts.Client.GetHTML("/aliases")
a.Equal(http.StatusOK, resp.Code, "wrong HTTP status code")
webassert.Localized(t, html, []webassert.LocalizedElement{
{"#welcome", "AdminAliasesWelcome"},
{"title", "AdminAliasesTitle"},
{"#aliasCount", "ListCountPlural"},
})
a.EqualValues(html.Find("#theList li").Length(), 3)
lst = []roomdb.Alias{
{ID: 666, Name: "dave", Feed: refs.FeedRef{ID: bytes.Repeat([]byte{1}, 32), Algo: "one"}},
}
ts.Aliases.ListReturns(lst, nil)
html, resp = ts.Client.GetHTML("/aliases")
a.Equal(http.StatusOK, resp.Code, "wrong HTTP status code")
webassert.Localized(t, html, []webassert.LocalizedElement{
{"#welcome", "AdminAliasesWelcome"},
{"title", "AdminAliasesTitle"},
{"#aliasCount", "ListCountSingular"},
})
elems := html.Find("#theList li")
a.EqualValues(elems.Length(), 1)
// check for link to Revoke confirm link
link, yes := elems.ContentsFiltered("a").Attr("href")
a.True(yes, "a-tag has href attribute")
a.Equal("/admin/aliases/revoke/confirm?id=666", link)
}
func TestAliasesRevokeConfirmation(t *testing.T) {
ts := newSession(t)
a := assert.New(t)
testKey, err := refs.ParseFeedRef("@x7iOLUcq3o+sjGeAnipvWeGzfuYgrXl8L4LYlxIhwDc=.ed25519")
a.NoError(err)
testEntry := roomdb.Alias{ID: 666, Name: "the-test-name", Feed: *testKey}
ts.Aliases.GetByIDReturns(testEntry, nil)
urlTo := web.NewURLTo(ts.Router)
urlRevokeConfirm := urlTo(router.AdminAliasesRevokeConfirm, "id", 3)
html, resp := ts.Client.GetHTML(urlRevokeConfirm.String())
a.Equal(http.StatusOK, resp.Code, "wrong HTTP status code")
a.Equal(testKey.Ref(), html.Find("pre#verify").Text(), "has the key for verification")
form := html.Find("form#confirm")
method, ok := form.Attr("method")
a.True(ok, "form has method set")
a.Equal("POST", method)
action, ok := form.Attr("action")
a.True(ok, "form has action set")
addURL, err := ts.Router.Get(router.AdminAliasesRevoke).URL()
a.NoError(err)
a.Equal(addURL.String(), action)
webassert.InputsInForm(t, form, []webassert.InputElement{
{Name: "name", Type: "hidden", Value: testEntry.Name},
})
}
func TestAliasesRevoke(t *testing.T) {
ts := newSession(t)
a := assert.New(t)
urlTo := web.NewURLTo(ts.Router)
urlRevoke := urlTo(router.AdminAliasesRevoke)
ts.Aliases.RevokeReturns(nil)
addVals := url.Values{"name": []string{"the-name"}}
rec := ts.Client.PostForm(urlRevoke.String(), addVals)
a.Equal(http.StatusFound, rec.Code)
a.Equal(1, ts.Aliases.RevokeCallCount())
_, theName := ts.Aliases.RevokeArgsForCall(0)
a.EqualValues("the-name", theName)
// now for unknown ID
ts.Aliases.RevokeReturns(roomdb.ErrNotFound)
addVals = url.Values{"name": []string{"nope"}}
rec = ts.Client.PostForm(urlRevoke.String(), addVals)
a.Equal(http.StatusNotFound, rec.Code)
//TODO: update redirect code with flash errors
}

View File

@ -1,3 +1,5 @@
// SPDX-License-Identifier: MIT
package admin
import (

View File

@ -27,6 +27,7 @@ type testSession struct {
Client *tester.Tester
Router *mux.Router
Aliases *mockdb.FakeAliasService
AllowListDB *mockdb.FakeAllowListService
PinnedDB *mockdb.FakePinnedNoticesService
NoticeDB *mockdb.FakeNoticesService
@ -43,6 +44,7 @@ func newSession(t *testing.T) *testSession {
var ts testSession
// fake dbs
ts.Aliases = new(mockdb.FakeAliasService)
ts.AllowListDB = new(mockdb.FakeAllowListService)
ts.PinnedDB = new(mockdb.FakePinnedNoticesService)
ts.NoticeDB = new(mockdb.FakeNoticesService)
@ -97,6 +99,7 @@ func newSession(t *testing.T) *testSession {
r,
ts.RoomState,
Databases{
Aliases: ts.Aliases,
AllowList: ts.AllowListDB,
Invites: ts.InvitesDB,
Notices: ts.NoticeDB,

View File

@ -1,5 +1,7 @@
// SPDX-License-Identifier: MIT
// Package admin implements the dashboard for admins and moderators to change and control aspects of the room.
// Including aliases, allow/deny list managment, invites and settings of the room.
package admin
import (
@ -18,10 +20,14 @@ import (
"github.com/ssb-ngi-pointer/go-ssb-room/roomstate"
)
// HTMLTemplates define the list of files the template system should load.
var HTMLTemplates = []string{
"admin/dashboard.tmpl",
"admin/menu.tmpl",
"admin/aliases.tmpl",
"admin/aliases-revoke-confirm.tmpl",
"admin/allow-list.tmpl",
"admin/allow-list-remove-confirm.tmpl",
@ -34,6 +40,7 @@ var HTMLTemplates = []string{
// Databases is an option struct that encapsualtes the required database services
type Databases struct {
Aliases roomdb.AliasService
AllowList roomdb.AllowListService
Invites roomdb.InviteService
Notices roomdb.NoticesService
@ -62,14 +69,22 @@ func Handler(
return map[string]interface{}{}, nil
}))
var ah = allowListHandler{
var ah = aliasesHandler{
r: r,
db: dbs.Aliases,
}
mux.HandleFunc("/aliases", r.HTML("admin/aliases.tmpl", ah.overview))
mux.HandleFunc("/aliases/revoke/confirm", r.HTML("admin/aliases-revoke-confirm.tmpl", ah.revokeConfirm))
mux.HandleFunc("/aliases/revoke", ah.revoke)
var mh = allowListHandler{
r: r,
al: dbs.AllowList,
}
mux.HandleFunc("/members", r.HTML("admin/allow-list.tmpl", ah.overview))
mux.HandleFunc("/members/add", ah.add)
mux.HandleFunc("/members/remove/confirm", r.HTML("admin/allow-list-remove-confirm.tmpl", ah.removeConfirm))
mux.HandleFunc("/members/remove", ah.remove)
mux.HandleFunc("/members", r.HTML("admin/allow-list.tmpl", mh.overview))
mux.HandleFunc("/members/add", mh.add)
mux.HandleFunc("/members/remove/confirm", r.HTML("admin/allow-list-remove-confirm.tmpl", mh.removeConfirm))
mux.HandleFunc("/members/remove", mh.remove)
var ih = invitesHandler{
r: r,

153
web/handlers/aliases.go Normal file
View File

@ -0,0 +1,153 @@
package handlers
import (
"encoding/base64"
"encoding/json"
"fmt"
"log"
"net/http"
"github.com/gorilla/mux"
"go.mindeco.de/http/render"
refs "go.mindeco.de/ssb-refs"
"github.com/ssb-ngi-pointer/go-ssb-room/aliases"
"github.com/ssb-ngi-pointer/go-ssb-room/roomdb"
)
// aliasHandler implements the public resolve endpoint for HTML and JSON requests.
type aliasHandler struct {
r *render.Renderer
db roomdb.AliasService
muxrpcHostAndPort string
roomID refs.FeedRef
}
func (a aliasHandler) resolve(rw http.ResponseWriter, req *http.Request) {
respEncoding := req.URL.Query().Get("encoding")
var ar aliasResponder
switch respEncoding {
case "json":
ar = newAliasJSONResponder(rw)
default:
ar = newAliasHTMLResponder(a.r, rw, req)
}
ar.UpdateRoomInfo(a.muxrpcHostAndPort, a.roomID)
name := mux.Vars(req)["alias"]
if name == "" && !aliases.IsValid(name) {
ar.SendError(fmt.Errorf("invalid alias"))
return
}
alias, err := a.db.Resolve(req.Context(), name)
if err != nil {
ar.SendError(err)
return
}
ar.SendConfirmation(alias)
}
// aliasResponder is supposed to handle different encoding types transparently.
// It either sends the signed alias confirmation or an error.
type aliasResponder interface {
SendConfirmation(roomdb.Alias)
SendError(error)
UpdateRoomInfo(hostAndPort string, roomID refs.FeedRef)
}
// aliasJSONResponse dictates the field names and format of the JSON response for the alias web endpoint
type aliasJSONResponse struct {
Status string `json:"status"`
Address string `json:"address"`
RoomID string `json:"roomId"`
UserID string `json:"userId"`
Alias string `json:"alias"`
Signature string `json:"signature"`
}
// handles JSON responses
type aliasJSONResponder struct {
enc *json.Encoder
roomID refs.FeedRef
multiservAddr string
}
func newAliasJSONResponder(rw http.ResponseWriter) aliasResponder {
return &aliasJSONResponder{
enc: json.NewEncoder(rw),
}
}
func (json *aliasJSONResponder) UpdateRoomInfo(hostAndPort string, roomID refs.FeedRef) {
json.roomID = roomID
roomPubKey := base64.StdEncoding.EncodeToString(roomID.PubKey())
json.multiservAddr = fmt.Sprintf("net:%s~shs:%s", hostAndPort, roomPubKey)
}
func (json aliasJSONResponder) SendConfirmation(alias roomdb.Alias) {
var resp = aliasJSONResponse{
Status: "successful",
RoomID: json.roomID.Ref(),
Address: json.multiservAddr,
Alias: alias.Name,
UserID: alias.Feed.Ref(),
Signature: base64.StdEncoding.EncodeToString(alias.Signature),
}
json.enc.Encode(resp)
}
func (json aliasJSONResponder) SendError(err error) {
json.enc.Encode(struct {
Status string `json:"status"`
Error string `json:"error"`
}{"error", err.Error()})
}
// handles HTML responses
type aliasHTMLResponder struct {
renderer *render.Renderer
rw http.ResponseWriter
req *http.Request
roomID refs.FeedRef
multiservAddr string
}
func newAliasHTMLResponder(r *render.Renderer, rw http.ResponseWriter, req *http.Request) aliasResponder {
return &aliasHTMLResponder{
renderer: r,
rw: rw,
req: req,
}
}
func (html *aliasHTMLResponder) UpdateRoomInfo(hostAndPort string, roomID refs.FeedRef) {
html.roomID = roomID
roomPubKey := base64.StdEncoding.EncodeToString(roomID.PubKey())
html.multiservAddr = fmt.Sprintf("net:%s~shs:%s", hostAndPort, roomPubKey)
}
func (html aliasHTMLResponder) SendConfirmation(alias roomdb.Alias) {
err := html.renderer.Render(html.rw, html.req, "aliases-resolved.html", http.StatusOK, struct {
Alias roomdb.Alias
RoomAddr string
}{alias, html.multiservAddr})
if err != nil {
log.Println("alias-resolve render errr:", err)
}
}
func (html aliasHTMLResponder) SendError(err error) {
html.renderer.Error(html.rw, html.req, http.StatusInternalServerError, err)
}

View File

@ -0,0 +1,63 @@
package handlers
import (
"bytes"
"encoding/base64"
"encoding/json"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ssb-ngi-pointer/go-ssb-room/roomdb"
"github.com/ssb-ngi-pointer/go-ssb-room/web/router"
refs "go.mindeco.de/ssb-refs"
)
func TestAliasResolve(t *testing.T) {
ts := setup(t)
a := assert.New(t)
r := require.New(t)
var testAlias = roomdb.Alias{
ID: 54321,
Name: "test-name",
Feed: refs.FeedRef{
ID: bytes.Repeat([]byte{'F'}, 32),
Algo: "test",
},
Signature: bytes.Repeat([]byte{'S'}, 32),
}
ts.AliasesDB.ResolveReturns(testAlias, nil)
// default is HTML
htmlURL, err := ts.Router.Get(router.CompleteAliasResolve).URL("alias", testAlias.Name)
r.Nil(err)
t.Log("resolving", htmlURL.String())
html, resp := ts.Client.GetHTML(htmlURL.String())
a.Equal(http.StatusOK, resp.Code)
a.Equal(testAlias.Name, html.Find("title").Text())
// default is HTML
jsonURL, err := ts.Router.Get(router.CompleteAliasResolve).URL("alias", testAlias.Name)
r.Nil(err)
q := jsonURL.Query()
q.Set("encoding", "json")
jsonURL.RawQuery = q.Encode()
t.Log("resolving", jsonURL.String())
resp = ts.Client.GetBody(jsonURL.String())
a.Equal(http.StatusOK, resp.Code)
var ar aliasJSONResponse
err = json.NewDecoder(resp.Body).Decode(&ar)
r.NoError(err)
a.Equal(testAlias.Name, ar.Alias)
sigData, err := base64.StdEncoding.DecodeString(ar.Signature)
r.NoError(err)
a.Equal(testAlias.Signature, sigData)
a.Equal(testAlias.Feed.Ref(), ar.UserID, "wrong user feed on response")
a.Equal(ts.NetworkInfo.RoomID.Ref(), ar.RoomID, "wrong room feed on response")
}

View File

@ -26,7 +26,6 @@ func TestIndex(t *testing.T) {
webassert.Localized(t, html, []webassert.LocalizedElement{
{"h1", "Default Notice Title"},
{"title", "Default Notice Title"},
// {"#nav", "FooBar"},
})
content := html.Find("p").Text()

View File

@ -11,16 +11,17 @@ import (
"strconv"
"time"
refs "go.mindeco.de/ssb-refs"
"github.com/gorilla/csrf"
"github.com/gorilla/sessions"
"github.com/russross/blackfriday/v2"
"go.mindeco.de/http/auth"
"go.mindeco.de/http/render"
"go.mindeco.de/logging"
"golang.org/x/crypto/ed25519"
"github.com/ssb-ngi-pointer/go-ssb-room/roomdb"
"github.com/ssb-ngi-pointer/go-ssb-room/internal/repo"
"github.com/ssb-ngi-pointer/go-ssb-room/roomdb"
"github.com/ssb-ngi-pointer/go-ssb-room/roomstate"
"github.com/ssb-ngi-pointer/go-ssb-room/web"
"github.com/ssb-ngi-pointer/go-ssb-room/web/handlers/admin"
@ -33,6 +34,7 @@ import (
var HTMLTemplates = []string{
"landing/index.tmpl",
"landing/about.tmpl",
"aliases-resolved.html",
"invite/accept.tmpl",
"invite/consumed.tmpl",
"notice/list.tmpl",
@ -42,6 +44,7 @@ var HTMLTemplates = []string{
// Databases is an options stuct for the required databases of the web handlers
type Databases struct {
Aliases roomdb.AliasService
AuthWithSSB roomdb.AuthWithSSBService
AuthFallback roomdb.AuthFallbackService
AllowList roomdb.AllowListService
@ -55,7 +58,7 @@ type NetworkInfo struct {
PortMUXRPC uint
PortHTTPS uint // 0 assumes default (443)
PubKey ed25519.PublicKey
RoomID refs.FeedRef
Domain string
}
@ -97,7 +100,11 @@ func New(
}),
render.InjectTemplateFunc("current_page_is", func(r *http.Request) interface{} {
return func(routeName string) bool {
url, err := router.CompleteApp().Get(routeName).URLPath()
route := router.CompleteApp().Get(routeName)
if route == nil {
return false
}
url, err := route.URLPath()
if err != nil {
return false
}
@ -226,6 +233,7 @@ func New(
r,
roomState,
admin.Databases{
Aliases: dbs.Aliases,
AllowList: dbs.AllowList,
Invites: dbs.Invites,
Notices: dbs.Notices,
@ -256,10 +264,20 @@ func New(
m.Get(router.CompleteNoticeList).Handler(r.HTML("notice/list.tmpl", nh.list))
m.Get(router.CompleteNoticeShow).Handler(r.HTML("notice/show.tmpl", nh.show))
var ah = aliasHandler{
r: r,
db: dbs.Aliases,
roomID: netInfo.RoomID,
muxrpcHostAndPort: fmt.Sprintf("%s:%d", netInfo.Domain, netInfo.PortMUXRPC),
}
m.Get(router.CompleteAliasResolve).HandlerFunc(ah.resolve)
var ih = inviteHandler{
invites: dbs.Invites,
roomPubKey: netInfo.PubKey,
roomPubKey: netInfo.RoomID.PubKey(),
muxrpcHostAndPort: fmt.Sprintf("%s:%d", netInfo.Domain, netInfo.PortMUXRPC),
}
m.Get(router.CompleteInviteAccept).Handler(r.HTML("invite/accept.tmpl", ih.acceptForm))

View File

@ -6,7 +6,6 @@ import (
"fmt"
"net/http"
"go.mindeco.de/http/render"
"go.mindeco.de/logging"
"golang.org/x/crypto/ed25519"
@ -18,8 +17,6 @@ import (
)
type inviteHandler struct {
r *render.Renderer
invites roomdb.InviteService
aliases roomdb.AliasService

View File

@ -226,7 +226,7 @@ func TestInviteConsumeInvite(t *testing.T) {
// TODO: this is just a cheap stub for actual ssb-uri parsing
a.True(strings.HasPrefix(gotRA, "net:localhost:8008~shs:"), "not for the test host: %s", gotRA)
a.True(strings.Contains(gotRA, base64.StdEncoding.EncodeToString(ts.NetworkInfo.PubKey)), "public key missing? %s", gotRA)
a.True(strings.Contains(gotRA, base64.StdEncoding.EncodeToString(ts.NetworkInfo.RoomID.PubKey())), "public key missing? %s", gotRA)
a.True(strings.HasSuffix(gotRA, ":SSB+Room+PSK3TLYC2T86EHQCUHBUHASCASE18JBV24="), "magic suffix missing: %s", gotRA)
}

View File

@ -16,10 +16,11 @@ import (
"github.com/gorilla/mux"
"go.mindeco.de/http/tester"
"go.mindeco.de/logging/logtest"
refs "go.mindeco.de/ssb-refs"
"github.com/ssb-ngi-pointer/go-ssb-room/internal/repo"
"github.com/ssb-ngi-pointer/go-ssb-room/roomdb"
"github.com/ssb-ngi-pointer/go-ssb-room/roomdb/mockdb"
"github.com/ssb-ngi-pointer/go-ssb-room/internal/repo"
"github.com/ssb-ngi-pointer/go-ssb-room/roomstate"
"github.com/ssb-ngi-pointer/go-ssb-room/web/i18n"
"github.com/ssb-ngi-pointer/go-ssb-room/web/router"
@ -33,6 +34,7 @@ type testSession struct {
// mocked dbs
AuthDB *mockdb.FakeAuthWithSSBService
AuthFallbackDB *mockdb.FakeAuthFallbackService
AliasesDB *mockdb.FakeAliasService
AllowListDB *mockdb.FakeAllowListService
InvitesDB *mockdb.FakeInviteService
PinnedDB *mockdb.FakePinnedNoticesService
@ -61,6 +63,7 @@ func setup(t *testing.T) *testSession {
ts.AuthDB = new(mockdb.FakeAuthWithSSBService)
ts.AuthFallbackDB = new(mockdb.FakeAuthFallbackService)
ts.AliasesDB = new(mockdb.FakeAliasService)
ts.AllowListDB = new(mockdb.FakeAllowListService)
ts.InvitesDB = new(mockdb.FakeInviteService)
ts.PinnedDB = new(mockdb.FakePinnedNoticesService)
@ -76,7 +79,10 @@ func setup(t *testing.T) *testSession {
PortMUXRPC: 8008,
PortHTTPS: 443,
PubKey: bytes.Repeat([]byte("test"), 8),
RoomID: refs.FeedRef{
ID: bytes.Repeat([]byte("test"), 8),
Algo: refs.RefAlgoFeedSSB1,
},
}
log, _ := logtest.KitLogger("complete", t)
@ -91,6 +97,7 @@ func setup(t *testing.T) *testSession {
ts.NetworkInfo,
ts.RoomState,
Databases{
Aliases: ts.AliasesDB,
AuthWithSSB: ts.AuthDB,
AuthFallback: ts.AuthFallbackDB,
AllowList: ts.AllowListDB,

View File

@ -18,6 +18,10 @@ AuthSignOut = "Sign out"
AdminDashboardWelcome = "Welcome to your dashboard"
AdminDashboardTitle = "Room Admin Dashboard"
AdminAliasesTitle = "Aliases"
AdminAliasesWelcome = "Here you can see and revoke the registered aliases of this room."
AdminAliasesRevoke = "Revoke"
AdminAllowListTitle = "Members"
AdminAllowListWelcome = "Here you can see all the members of the room and ways to add new ones (by their SSB ID) or remove exising ones."
AdminAllowListAdd = "Add"
@ -84,4 +88,4 @@ other = "There are {{.Count}} items on the List"
[AdminRoomCount]
description = "The number of people in a room"
one = "There is one person in the Room"
other = "There are {{.Count}} people in the Room"
other = "There are {{.Count}} people in the Room"

View File

@ -9,6 +9,10 @@ const (
AdminDashboard = "admin:dashboard"
AdminMenu = "admin:menu"
AdminAliasesOverview = "admin:aliases:overview"
AdminAliasesRevokeConfirm = "admin:aliases:revoke:confirm"
AdminAliasesRevoke = "admin:aliases:revoke"
AdminAllowListOverview = "admin:allow-list:overview"
AdminAllowListAdd = "admin:allow-list:add"
AdminAllowListRemoveConfirm = "admin:allow-list:remove:confirm"
@ -34,6 +38,10 @@ func Admin(m *mux.Router) *mux.Router {
m.Path("/dashboard").Methods("GET").Name(AdminDashboard)
m.Path("/menu").Methods("GET").Name(AdminMenu)
m.Path("/aliases").Methods("GET").Name(AdminAliasesOverview)
m.Path("/aliases/revoke/confirm").Methods("GET").Name(AdminAliasesRevokeConfirm)
m.Path("/aliases/revoke").Methods("POST").Name(AdminAliasesRevoke)
m.Path("/members").Methods("GET").Name(AdminAllowListOverview)
m.Path("/members/add").Methods("POST").Name(AdminAllowListAdd)
m.Path("/members/remove/confirm").Methods("GET").Name(AdminAllowListRemoveConfirm)

View File

@ -14,6 +14,8 @@ const (
CompleteNoticeShow = "complete:notice:show"
CompleteNoticeList = "complete:notice:list"
CompleteAliasResolve = "complete:alias:resolve"
CompleteInviteAccept = "complete:invite:accept"
CompleteInviteConsume = "complete:invite:consume"
)
@ -28,6 +30,8 @@ func CompleteApp() *mux.Router {
m.Path("/").Methods("GET").Name(CompleteIndex)
m.Path("/about").Methods("GET").Name(CompleteAbout)
m.Path("/{alias}").Methods("GET").Name(CompleteAliasResolve)
m.Path("/invite/accept").Methods("GET").Name(CompleteInviteAccept)
m.Path("/invite/consume").Methods("POST").Name(CompleteInviteConsume)

View File

@ -0,0 +1,31 @@
{{ define "title" }}{{i18n "AdminAliasesRevokeConfirmTitle"}}{{ end }}
{{ define "content" }}
<div class="flex flex-col justify-center items-center h-64">
<span
id="welcome"
class="text-center"
>{{i18n "AdminAliasesRevokeConfirmWelcome"}}</span>
<pre
id="verify"
class="my-4 font-mono truncate max-w-full text-lg text-gray-700"
>{{.Entry.Feed.Ref}}</pre>
<form id="confirm" action="{{urlTo "admin:aliases:revoke"}}" method="POST">
{{ .csrfField }}
<input type="hidden" name="name" value={{.Entry.Name}}>
<div class="grid grid-cols-2 gap-4">
<a
href="javascript:history.back()"
class="px-4 h-8 shadow rounded flex flex-row justify-center items-center bg-white align-middle text-gray-600 focus:outline-none focus:ring-2 focus:ring-gray-300 focus:ring-opacity-50"
>{{i18n "GenericGoBack"}}</a>
<button
type="submit"
class="shadow rounded px-4 h-8 text-gray-100 bg-pink-600 hover:bg-pink-700 focus:outline-none focus:ring-2 focus:ring-pink-600 focus:ring-opacity-50"
>{{i18n "GenericConfirm"}}</button>
</div>
</form>
</div>
{{end}}

View File

@ -0,0 +1,72 @@
{{ define "title" }}{{i18n "AdminAliasesTitle"}}{{ end }}
{{ define "content" }}
<h1
class="text-3xl tracking-tight font-black text-black mt-2 mb-4"
>{{i18n "AdminAliasesTitle"}}</h1>
<p id="welcome" class="my-2">{{i18n "AdminAliasesWelcome"}}</p>
<p
id="aliasCount"
class="text-lg font-bold my-2"
>{{i18npl "ListCount" .Count}}</p>
<ul id="theList" class="divide-y pb-4">
{{range .Entries}}
<li class="flex flex-row items-center h-12">
<span
class="font-mono truncate flex-auto text-gray-600 tracking-wider"
>{{.Name}}</span>
<span
class="font-mono truncate flex-auto text-gray-600 tracking-wider"
>{{.Feed.Ref}}</span>
<a
href="{{urlTo "admin:aliases:revoke:confirm" "id" .ID}}"
class="pl-4 w-20 py-2 text-center text-gray-400 hover:text-red-600 font-bold cursor-pointer"
>{{i18n "AdminAliasesRevoke"}}</a>
</li>
{{end}}
</ul>
{{$pageNums := .Paginator.PageNums}}
{{$view := .View}}
{{if gt $pageNums 1}}
<div class="flex flex-row justify-center">
{{if not .FirstInView}}
<a
href="{{urlTo "admin:allow-list:overview"}}?page=1"
class="rounded px-3 py-2 text-pink-600 border-transparent hover:border-pink-400 border-2"
>1</a>
<span
class="px-3 py-2 text-gray-400 border-2 border-transparent"
>..</span>
{{end}}
{{range $view.Pages}}
{{if le . $pageNums}}
{{if eq . $view.Current}}
<span
class="px-3 py-2 cursor-default text-gray-500 border-2 border-transparent"
>{{.}}</span>
{{else}}
<a
href="{{urlTo "admin:allow-list:overview"}}?page={{.}}"
class="rounded px-3 py-2 mx-1 text-pink-600 border-transparent hover:border-pink-400 border-2"
>{{.}}</a>
{{end}}
{{end}}
{{end}}
{{if not .LastInView}}
<span
class="px-3 py-2 text-gray-400 border-2 border-transparent"
>..</span>
<a
href="{{urlTo "admin:allow-list:overview"}}?page={{$view.Last}}"
class="rounded px-3 py-2 text-pink-600 border-transparent hover:border-pink-400 border-2"
>{{$view.Last}}</a>
{{end}}
</div>
{{end}}
{{end}}

View File

@ -0,0 +1,9 @@
{{ define "title" }}{{.Alias.Name}}{{ end }}
{{ define "content" }}
<div>
<h1>{{.Alias.Name}}</h1>
<pre>{{.RoomAddr}}</pre>
<p class="color-red-600">TODO: ssb-uri</p>
</div>
{{end}}

View File

@ -18,6 +18,15 @@
</svg>{{i18n "NavAdminDashboard"}}
</a>
<a
href="{{urlTo "admin:aliases:overview"}}"
class="{{if current_page_is "admin:aliases:overview"}}bg-gray-300 {{else}}hover:bg-gray-200 {{end}}pr-1 pl-2 py-3 sm:py-1 rounded-md flex flex-row items-center font-semibold text-sm text-gray-700 hover:text-gray-800 truncate"
>
<svg class="text-green-600 w-4 h-4 mr-1" viewBox="0 0 24 24">
<path fill="currentColor" d="M23,12L20.56,9.22L20.9,5.54L17.29,4.72L15.4,1.54L12,3L8.6,1.54L6.71,4.72L3.1,5.53L3.44,9.21L1,12L3.44,14.78L3.1,18.47L6.71,19.29L8.6,22.47L12,21L15.4,22.46L17.29,19.28L20.9,18.46L20.56,14.78L23,12M10,17L6,13L7.41,11.59L10,14.17L16.59,7.58L18,9L10,17Z" />
</svg>{{i18n "AdminAliasesTitle"}}
</a>
<a
href="{{urlTo "admin:allow-list:overview"}}"
class="{{if current_page_is "admin:allow-list:overview"}}bg-gray-300 {{else}}hover:bg-gray-200 {{end}}pr-1 pl-2 py-3 sm:py-1 rounded-md flex flex-row items-center font-semibold text-sm text-gray-700 hover:text-gray-800 truncate"