Initial commit
This commit is contained in:
157
server/server.go
Normal file
157
server/server.go
Normal file
@@ -0,0 +1,157 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/pion/sdp/v3"
|
||||
"github.com/pion/webrtc/v4"
|
||||
)
|
||||
|
||||
//go:embed static
|
||||
var static embed.FS
|
||||
|
||||
type Server struct {
|
||||
users *UserStore
|
||||
streams *StreamStore
|
||||
http.Server
|
||||
}
|
||||
|
||||
func NewServer(store *UserStore) *Server {
|
||||
srv := &Server{
|
||||
users: store,
|
||||
streams: NewStreamStore(),
|
||||
}
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Get("/", srv.StaticHandler)
|
||||
r.Get("/{name}", srv.StaticHandler)
|
||||
r.Post("/whip", http.HandlerFunc(srv.WhipHandler))
|
||||
r.Get("/whip", srv.ListHandler)
|
||||
r.Delete("/whip/{streamKey}", srv.DeleteHandler)
|
||||
r.Post("/whip/{streamKey}", srv.PostOfferHandler)
|
||||
|
||||
srv.Handler = r
|
||||
|
||||
return srv
|
||||
}
|
||||
|
||||
func (s *Server) StaticHandler(w http.ResponseWriter, r *http.Request) {
|
||||
name := chi.URLParam(r, "name")
|
||||
if name == "" {
|
||||
name = "index.html"
|
||||
}
|
||||
|
||||
path := fmt.Sprintf("static/%s", name)
|
||||
|
||||
if strings.Contains(name, ".js") {
|
||||
w.Header().Add("Content-Type", "text/javascript")
|
||||
}
|
||||
if strings.Contains(name, ".css") {
|
||||
w.Header().Add("Content-Type", "text/css")
|
||||
}
|
||||
|
||||
f, err := static.Open(path)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
io.Copy(w, f)
|
||||
}
|
||||
|
||||
func (s *Server) PostOfferHandler(w http.ResponseWriter, r *http.Request) {
|
||||
defer r.Body.Close()
|
||||
streamKey := chi.URLParam(r, "streamKey")
|
||||
|
||||
stream, err := s.streams.Get(streamKey)
|
||||
if err != nil {
|
||||
slog.Warn("Unable to fetch stream.", "error", err, "stream_key", streamKey)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
io.Copy(&buf, r.Body)
|
||||
|
||||
offer := &webrtc.SessionDescription{Type: webrtc.SDPTypeOffer, SDP: buf.String()}
|
||||
|
||||
answer, err := stream.AddListener(offer)
|
||||
if err != nil {
|
||||
slog.Warn("Unable to add peer.", "error", err, "stream_key", streamKey, "buf", buf.String())
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
io.WriteString(w, answer.SDP)
|
||||
|
||||
slog.Info("Got offer.", "stream", stream)
|
||||
}
|
||||
|
||||
func (s *Server) DeleteHandler(w http.ResponseWriter, r *http.Request) {
|
||||
streamKey := chi.URLParam(r, "streamKey")
|
||||
s.streams.Delete(streamKey)
|
||||
slog.Info("Deleted stream.", "streamKey", streamKey)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (s *Server) ListHandler(w http.ResponseWriter, r *http.Request) {
|
||||
streams := s.streams.List()
|
||||
enc := json.NewEncoder(w)
|
||||
enc.Encode(&streams)
|
||||
}
|
||||
|
||||
func (s *Server) WhipHandler(w http.ResponseWriter, r *http.Request) {
|
||||
defer r.Body.Close()
|
||||
|
||||
streamKey, err := streamKeyFromHeader(r.Header.Get("Authorization"))
|
||||
if err != nil {
|
||||
slog.Warn("Incoming request with invalid auth header.")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
io.Copy(&buf, r.Body)
|
||||
|
||||
offer := &webrtc.SessionDescription{Type: webrtc.SDPTypeOffer, SDP: buf.String()}
|
||||
|
||||
offerSdp := sdp.SessionDescription{}
|
||||
if err := offerSdp.Unmarshal(buf.Bytes()); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
slog.Info("Got SDP.", "id", offerSdp.Origin.SessionID, "ip", offerSdp.Origin.UnicastAddress)
|
||||
|
||||
answer, err := s.streams.Add(streamKey, offer)
|
||||
if err != nil {
|
||||
slog.Error("Error adding stream.", "error", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Add("Location", fmt.Sprintf("/whip/%s", streamKey))
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
if _, err := io.WriteString(w, answer.SDP); err != nil {
|
||||
slog.Error("Error writing response.", "error", err)
|
||||
}
|
||||
slog.Info("Wrote answer.")
|
||||
}
|
||||
|
||||
func streamKeyFromHeader(header string) (string, error) {
|
||||
split := strings.Split(header, " ")
|
||||
if len(split) != 2 {
|
||||
return "", fmt.Errorf("invalid header")
|
||||
}
|
||||
|
||||
return split[1], nil
|
||||
}
|
15
server/static/index.html
Normal file
15
server/static/index.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>title</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<script src="script.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<h1>HELLO!</h1>
|
||||
<ol id="streamList">
|
||||
</ol>
|
||||
<video id="video1" width="1920" height="1080" autoplay muted></video>
|
||||
</body>
|
||||
</html>
|
66
server/static/script.js
Normal file
66
server/static/script.js
Normal file
@@ -0,0 +1,66 @@
|
||||
const startStream = function (streamKey) {
|
||||
const pc = new RTCPeerConnection({
|
||||
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
|
||||
})
|
||||
|
||||
pc.oniceconnectionstatechange = e => console.log(e)
|
||||
pc.addTransceiver('video')
|
||||
pc.addTransceiver('audio')
|
||||
pc.createOffer().then(offer => {
|
||||
fetch("/whip/tjuice", {
|
||||
method: "POST",
|
||||
body: offer.sdp
|
||||
}).then(resp => {
|
||||
resp.text().then(text => {
|
||||
var answer = {
|
||||
type: "answer",
|
||||
sdp: text
|
||||
}
|
||||
try {
|
||||
pc.setLocalDescription(offer).then(
|
||||
pc.setRemoteDescription(answer)
|
||||
)
|
||||
} catch (e) {
|
||||
console.log("Error setting remote description: " + e)
|
||||
}
|
||||
})
|
||||
});
|
||||
})
|
||||
|
||||
pc.ontrack = function (event) {
|
||||
console.log(event)
|
||||
const el = document.getElementById('video1')
|
||||
var ms = new MediaStream()
|
||||
event.streams.forEach(s => {
|
||||
const tracks = s.getTracks()
|
||||
tracks.forEach( t => {
|
||||
ms.addTrack(t)
|
||||
})
|
||||
})
|
||||
|
||||
el.srcObject = ms
|
||||
el.autoplay = true
|
||||
el.controls = true
|
||||
}
|
||||
}
|
||||
|
||||
displayStreams = function () {
|
||||
fetch("/whip").then(resp => {
|
||||
resp.json().then(streamList => {
|
||||
for (const element of streamList) {
|
||||
const ol = document.getElementById("streamList")
|
||||
var entry = document.createElement("li")
|
||||
var a = document.createElement("a")
|
||||
a.onclick = _ => startStream(element)
|
||||
a.append(document.createTextNode(element))
|
||||
entry.appendChild(a)
|
||||
ol.appendChild(entry)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
window.onload = function ( ev ) {
|
||||
console.log(ev)
|
||||
displayStreams()
|
||||
}
|
3
server/static/style.css
Normal file
3
server/static/style.css
Normal file
@@ -0,0 +1,3 @@
|
||||
body {
|
||||
color: #ff5722;
|
||||
}
|
249
server/stream.go
Normal file
249
server/stream.go
Normal file
@@ -0,0 +1,249 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/pion/interceptor"
|
||||
"github.com/pion/interceptor/pkg/intervalpli"
|
||||
"github.com/pion/webrtc/v4"
|
||||
)
|
||||
|
||||
var ErrNoSuchStream error = fmt.Errorf("no such stream")
|
||||
|
||||
type StreamStore struct {
|
||||
Streams map[string]*Stream
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewStreamStore() *StreamStore {
|
||||
s := &StreamStore{
|
||||
Streams: make(map[string]*Stream),
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
type Stream struct {
|
||||
peerConnection *webrtc.PeerConnection
|
||||
lastUpdate time.Time
|
||||
localTracks []*webrtc.TrackLocalStaticRTP
|
||||
peers []*webrtc.PeerConnection
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (s *Stream) AddListener(sd *webrtc.SessionDescription) (*webrtc.SessionDescription, error) {
|
||||
peerConnectionConfig := webrtc.Configuration{
|
||||
ICEServers: []webrtc.ICEServer{
|
||||
{
|
||||
URLs: []string{"stun:stun.l.google.com:19302"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
peerConnection, err := webrtc.NewPeerConnection(peerConnectionConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, ltrack := range s.localTracks {
|
||||
rtpSender, err := peerConnection.AddTrack(ltrack)
|
||||
if err != nil {
|
||||
// TODO, stop peerconn
|
||||
return nil, err
|
||||
}
|
||||
go func() {
|
||||
rtcpBuf := make([]byte, 1500)
|
||||
for {
|
||||
if _, _, err := rtpSender.Read(rtcpBuf); err != nil {
|
||||
peerConnection.Close()
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
err = peerConnection.SetRemoteDescription(*sd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
answer, err := peerConnection.CreateAnswer(nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gatherComplete := webrtc.GatheringCompletePromise(peerConnection)
|
||||
|
||||
// Sets the LocalDescription, and starts our UDP listeners
|
||||
err = peerConnection.SetLocalDescription(answer)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Block until ICE Gathering is complete, disabling trickle ICE
|
||||
// we do this because we only can exchange one signaling message
|
||||
// in a production application you should exchange ICE Candidates via OnICECandidate
|
||||
<-gatherComplete
|
||||
s.mu.Lock()
|
||||
s.peers = append(s.peers, peerConnection)
|
||||
|
||||
defer s.mu.Unlock()
|
||||
|
||||
// Get the LocalDescription and take it to base64 so we can paste in browser
|
||||
return peerConnection.LocalDescription(), nil
|
||||
}
|
||||
|
||||
func (s *StreamStore) Add(streamKey string, sd *webrtc.SessionDescription) (*webrtc.SessionDescription, error) {
|
||||
answerChan := make(chan *webrtc.SessionDescription)
|
||||
|
||||
go func() {
|
||||
stream := &Stream{
|
||||
lastUpdate: time.Now(),
|
||||
}
|
||||
m := &webrtc.MediaEngine{}
|
||||
if err := m.RegisterDefaultCodecs(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
i := &interceptor.Registry{}
|
||||
if err := webrtc.RegisterDefaultInterceptors(m, i); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
intervalPliFactory, err := intervalpli.NewReceiverInterceptor()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
i.Add(intervalPliFactory)
|
||||
|
||||
peerConnectionConfig := webrtc.Configuration{
|
||||
ICEServers: []webrtc.ICEServer{
|
||||
{
|
||||
URLs: []string{"stun:stun.l.google.com:19302"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Create a new RTCPeerConnection
|
||||
peerConnection, err := webrtc.NewAPI(webrtc.WithMediaEngine(m), webrtc.WithInterceptorRegistry(i)).NewPeerConnection(peerConnectionConfig)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
stream.peerConnection = peerConnection
|
||||
|
||||
// Allow us to receive 1 video track
|
||||
if _, err := peerConnection.AddTransceiverFromKind(webrtc.RTPCodecTypeVideo, webrtc.RTPTransceiverInit{Direction: webrtc.RTPTransceiverDirectionRecvonly}); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if _, err := peerConnection.AddTransceiverFromKind(webrtc.RTPCodecTypeAudio, webrtc.RTPTransceiverInit{Direction: webrtc.RTPTransceiverDirectionRecvonly}); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Set a handler for when a new remote track starts, this just distributes all our packets
|
||||
// to connected peers
|
||||
peerConnection.OnTrack(func(remoteTrack *webrtc.TrackRemote, receiver *webrtc.RTPReceiver) {
|
||||
// Create a local track, all our SFU clients will be fed via this track
|
||||
slog.Info("Got track!", "type", remoteTrack.Codec().MimeType, "id", remoteTrack.ID())
|
||||
localTrack, newTrackErr := webrtc.NewTrackLocalStaticRTP(remoteTrack.Codec().RTPCodecCapability, "video", "pion")
|
||||
if newTrackErr != nil {
|
||||
panic(newTrackErr)
|
||||
}
|
||||
|
||||
stream.mu.Lock()
|
||||
stream.localTracks = append(stream.localTracks, localTrack)
|
||||
stream.mu.Unlock()
|
||||
|
||||
rtpBuf := make([]byte, 1400)
|
||||
for {
|
||||
i, _, readErr := remoteTrack.Read(rtpBuf)
|
||||
if readErr != nil {
|
||||
if errors.Is(readErr, io.EOF) {
|
||||
slog.Warn("EOF from track.", "id", remoteTrack.ID())
|
||||
return
|
||||
}
|
||||
panic(readErr)
|
||||
}
|
||||
|
||||
// ErrClosedPipe means we don't have any subscribers, this is ok if no peers have connected yet
|
||||
if _, err = localTrack.Write(rtpBuf[:i]); err != nil && !errors.Is(err, io.ErrClosedPipe) {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Set the remote SessionDescription
|
||||
offer := webrtc.SessionDescription{Type: webrtc.SDPTypeOffer, SDP: sd.SDP}
|
||||
|
||||
err = peerConnection.SetRemoteDescription(offer)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Create answer
|
||||
answer, err := peerConnection.CreateAnswer(nil)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Create channel that is blocked until ICE Gathering is complete
|
||||
gatherComplete := webrtc.GatheringCompletePromise(peerConnection)
|
||||
|
||||
// Sets the LocalDescription, and starts our UDP listeners
|
||||
err = peerConnection.SetLocalDescription(answer)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Block until ICE Gathering is complete, disabling trickle ICE
|
||||
// we do this because we only can exchange one signaling message
|
||||
// in a production application you should exchange ICE Candidates via OnICECandidate
|
||||
<-gatherComplete
|
||||
slog.Info("ICE Gathering complete.", "answer", answer)
|
||||
answerChan <- &answer
|
||||
|
||||
s.Streams[streamKey] = stream
|
||||
slog.Info("Added stream.", "stream_key", streamKey)
|
||||
}()
|
||||
answer := <-answerChan
|
||||
return answer, nil
|
||||
}
|
||||
|
||||
func (s *StreamStore) Get(streamKey string) (*Stream, error) {
|
||||
stream, ok := s.Streams[streamKey]
|
||||
if !ok {
|
||||
return nil, ErrNoSuchStream
|
||||
}
|
||||
|
||||
return stream, nil
|
||||
}
|
||||
|
||||
func (s *StreamStore) Delete(streamKey string) error {
|
||||
stream, ok := s.Streams[streamKey]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
delete(s.Streams, streamKey)
|
||||
|
||||
for _, peer := range stream.peers {
|
||||
if err := peer.Close(); err != nil {
|
||||
slog.Warn("Error closing peer.", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
return stream.peerConnection.Close()
|
||||
}
|
||||
|
||||
func (s *StreamStore) List() []string {
|
||||
streams := []string{}
|
||||
for key := range s.Streams {
|
||||
streams = append(streams, key)
|
||||
}
|
||||
|
||||
return streams
|
||||
}
|
100
server/users.go
Normal file
100
server/users.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
Username string `json:"username"`
|
||||
HashedPassword []byte `json:"hashed_password"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
}
|
||||
|
||||
func (u *User) SetPassword(password string) error {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
u.HashedPassword = hash
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *User) VerifyPassword(password string) error {
|
||||
return bcrypt.CompareHashAndPassword([]byte(u.HashedPassword), []byte(password))
|
||||
}
|
||||
|
||||
type UserStore struct {
|
||||
users map[string]*User
|
||||
}
|
||||
|
||||
func NewUserStore() *UserStore {
|
||||
return &UserStore{
|
||||
users: make(map[string]*User),
|
||||
}
|
||||
}
|
||||
|
||||
var ErrNoSuchUser = fmt.Errorf("no such user")
|
||||
|
||||
func (s *UserStore) Put(u *User) error {
|
||||
s.users[u.Username] = u
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *UserStore) Get(username string) (*User, error) {
|
||||
u, ok := s.users[username]
|
||||
if !ok {
|
||||
return nil, ErrNoSuchUser
|
||||
}
|
||||
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (s *UserStore) ToWriter(w io.Writer) error {
|
||||
enc := json.NewEncoder(w)
|
||||
|
||||
if err := enc.Encode(&s.users); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *UserStore) ToFile(path string) error {
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
return s.ToWriter(f)
|
||||
}
|
||||
|
||||
func StoreFromReader(r io.Reader) (*UserStore, error) {
|
||||
dec := json.NewDecoder(r)
|
||||
|
||||
s := &UserStore{}
|
||||
|
||||
if err := dec.Decode(&s.users); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func StoreFromFile(path string) (*UserStore, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
return StoreFromReader(f)
|
||||
}
|
89
server/users_test.go
Normal file
89
server/users_test.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package server_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"git.t-juice.club/torjus/ministream/server"
|
||||
)
|
||||
|
||||
func TestPassword(t *testing.T) {
|
||||
t.Run("SetAndVerify", func(t *testing.T) {
|
||||
user := &server.User{}
|
||||
password := "i L0ve K1tt3ns"
|
||||
if err := user.SetPassword(password); err != nil {
|
||||
t.Fatalf("Error setting password: %s", err)
|
||||
}
|
||||
|
||||
if err := user.VerifyPassword(password); err != nil {
|
||||
t.Fatalf("Error verifying with correct password: %s", err)
|
||||
}
|
||||
|
||||
if err := user.VerifyPassword("wrong"); err == nil {
|
||||
t.Errorf("Verifying with wrong password did not return error")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestUserStore(t *testing.T) {
|
||||
t.Run("SaveLoad", func(t *testing.T) {
|
||||
data := []struct {
|
||||
Username string
|
||||
Passord string
|
||||
Admin bool
|
||||
}{
|
||||
{
|
||||
Username: "admin",
|
||||
Passord: "adminpw",
|
||||
Admin: true,
|
||||
},
|
||||
{
|
||||
Username: "dave",
|
||||
Passord: "dave",
|
||||
},
|
||||
}
|
||||
|
||||
users := make(map[string]*server.User)
|
||||
for _, item := range data {
|
||||
u := &server.User{Username: item.Username, IsAdmin: item.Admin}
|
||||
err := u.SetPassword(item.Passord)
|
||||
if err != nil {
|
||||
t.Fatalf("Error setting password: %s", err)
|
||||
}
|
||||
users[item.Username] = u
|
||||
}
|
||||
|
||||
s := server.NewUserStore()
|
||||
for _, u := range users {
|
||||
if err := s.Put(u); err != nil {
|
||||
t.Fatalf("Error storing user: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Write to buffer
|
||||
var buf bytes.Buffer
|
||||
if err := s.ToWriter(&buf); err != nil {
|
||||
t.Fatalf("Error writing store to buffer: %s", err)
|
||||
}
|
||||
|
||||
loaded, err := server.StoreFromReader(&buf)
|
||||
if err != nil {
|
||||
t.Fatalf("Error loading store: %s", err)
|
||||
}
|
||||
|
||||
for _, item := range data {
|
||||
u, err := loaded.Get(item.Username)
|
||||
if err != nil {
|
||||
t.Fatalf("Error getting user after load: %s", err)
|
||||
}
|
||||
|
||||
if u.IsAdmin != item.Admin {
|
||||
t.Fatalf("IsAdmin value changed.")
|
||||
}
|
||||
|
||||
if err := u.VerifyPassword(item.Passord); err != nil {
|
||||
t.Fatalf("Verifying password after load failed: %s", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
Reference in New Issue
Block a user