Create the initial set of state events for room creation (#32)
This commit is contained in:
parent
e667f17e14
commit
49ed708ca4
|
@ -5,6 +5,9 @@ import (
|
|||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"golang.org/x/crypto/ed25519"
|
||||
|
||||
"github.com/matrix-org/dendrite/clientapi/config"
|
||||
"github.com/matrix-org/dendrite/clientapi/routing"
|
||||
|
||||
log "github.com/Sirupsen/logrus"
|
||||
|
@ -36,6 +39,16 @@ func main() {
|
|||
setupLogging(logDir)
|
||||
}
|
||||
log.Info("Starting clientapi")
|
||||
routing.Setup(http.DefaultServeMux, http.DefaultClient)
|
||||
// TODO: Rather than generating a new key on every startup, we should be
|
||||
// reading a PEM formatted file instead.
|
||||
_, privKey, err := ed25519.GenerateKey(nil)
|
||||
if err != nil {
|
||||
log.Panicf("Failed to generate private key: %s", err)
|
||||
}
|
||||
routing.Setup(http.DefaultServeMux, http.DefaultClient, config.ClientAPI{
|
||||
ServerName: "localhost",
|
||||
KeyID: "ed25519:something",
|
||||
PrivateKey: privKey,
|
||||
})
|
||||
log.Fatal(http.ListenAndServe(bindAddr, nil))
|
||||
}
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
package config
|
||||
|
||||
import "golang.org/x/crypto/ed25519"
|
||||
|
||||
// ClientAPI contains the config information necessary to spin up a clientapi process.
|
||||
type ClientAPI struct {
|
||||
ServerName string
|
||||
PrivateKey ed25519.PrivateKey
|
||||
KeyID string
|
||||
}
|
|
@ -0,0 +1,62 @@
|
|||
package events
|
||||
|
||||
// CreateContent is the event content for http://matrix.org/docs/spec/client_server/r0.2.0.html#m-room-create
|
||||
type CreateContent struct {
|
||||
Creator string `json:"creator"`
|
||||
Federate *bool `json:"m.federate,omitempty"`
|
||||
}
|
||||
|
||||
// MemberContent is the event content for http://matrix.org/docs/spec/client_server/r0.2.0.html#m-room-member
|
||||
type MemberContent struct {
|
||||
Membership string `json:"membership"`
|
||||
DisplayName string `json:"displayname,omitempty"`
|
||||
AvatarURL string `json:"avatar_url,omitempty"`
|
||||
// TODO: ThirdPartyInvite string `json:"third_party_invite,omitempty"`
|
||||
}
|
||||
|
||||
// JoinRulesContent is the event content for http://matrix.org/docs/spec/client_server/r0.2.0.html#m-room-join-rules
|
||||
type JoinRulesContent struct {
|
||||
JoinRule string `json:"join_rule"`
|
||||
}
|
||||
|
||||
// HistoryVisibilityContent is the event content for http://matrix.org/docs/spec/client_server/r0.2.0.html#m-room-history-visibility
|
||||
type HistoryVisibilityContent struct {
|
||||
HistoryVisibility string `json:"history_visibility"`
|
||||
}
|
||||
|
||||
// PowerLevelContent is the event content for http://matrix.org/docs/spec/client_server/r0.2.0.html#m-room-power-levels
|
||||
type PowerLevelContent struct {
|
||||
EventsDefault int `json:"events_default"`
|
||||
Invite int `json:"invite"`
|
||||
StateDefault int `json:"state_default"`
|
||||
Redact int `json:"redact"`
|
||||
Ban int `json:"ban"`
|
||||
UsersDefault int `json:"users_default"`
|
||||
Events map[string]int `json:"events"`
|
||||
Kick int `json:"kick"`
|
||||
Users map[string]int `json:"users"`
|
||||
}
|
||||
|
||||
// InitialPowerLevelsContent returns the initial values for m.room.power_levels on room creation
|
||||
// if they have not been specified.
|
||||
// http://matrix.org/docs/spec/client_server/r0.2.0.html#m-room-power-levels
|
||||
// https://github.com/matrix-org/synapse/blob/v0.19.2/synapse/handlers/room.py#L294
|
||||
func InitialPowerLevelsContent(roomCreator string) PowerLevelContent {
|
||||
return PowerLevelContent{
|
||||
EventsDefault: 0,
|
||||
Invite: 0,
|
||||
StateDefault: 50,
|
||||
Redact: 50,
|
||||
Ban: 50,
|
||||
UsersDefault: 0,
|
||||
Events: map[string]int{
|
||||
"m.room.name": 50,
|
||||
"m.room.power_levels": 100,
|
||||
"m.room.history_visibility": 100,
|
||||
"m.room.canonical_alias": 50,
|
||||
"m.room.avatar": 50,
|
||||
},
|
||||
Kick: 50,
|
||||
Users: map[string]int{roomCreator: 100},
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
package common
|
||||
package httputil
|
||||
|
||||
import (
|
||||
"encoding/json"
|
|
@ -4,6 +4,7 @@ import (
|
|||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/matrix-org/dendrite/clientapi/config"
|
||||
"github.com/matrix-org/dendrite/clientapi/readers"
|
||||
"github.com/matrix-org/dendrite/clientapi/writers"
|
||||
"github.com/matrix-org/util"
|
||||
|
@ -14,17 +15,17 @@ const pathPrefixR0 = "/_matrix/client/r0"
|
|||
|
||||
// Setup registers HTTP handlers with the given ServeMux. It also supplies the given http.Client
|
||||
// to clients which need to make outbound HTTP requests.
|
||||
func Setup(servMux *http.ServeMux, httpClient *http.Client) {
|
||||
func Setup(servMux *http.ServeMux, httpClient *http.Client, cfg config.ClientAPI) {
|
||||
apiMux := mux.NewRouter()
|
||||
r0mux := apiMux.PathPrefix(pathPrefixR0).Subrouter()
|
||||
r0mux.Handle("/createRoom", make("createRoom", wrap(func(req *http.Request) util.JSONResponse {
|
||||
return writers.CreateRoom(req)
|
||||
r0mux.Handle("/createRoom", make("createRoom", util.NewJSONRequestHandler(func(req *http.Request) util.JSONResponse {
|
||||
return writers.CreateRoom(req, cfg)
|
||||
})))
|
||||
r0mux.Handle("/sync", make("sync", wrap(func(req *http.Request) util.JSONResponse {
|
||||
r0mux.Handle("/sync", make("sync", util.NewJSONRequestHandler(func(req *http.Request) util.JSONResponse {
|
||||
return readers.Sync(req)
|
||||
})))
|
||||
r0mux.Handle("/rooms/{roomID}/send/{eventType}",
|
||||
make("send_message", wrap(func(req *http.Request) util.JSONResponse {
|
||||
make("send_message", util.NewJSONRequestHandler(func(req *http.Request) util.JSONResponse {
|
||||
vars := mux.Vars(req)
|
||||
return writers.SendMessage(req, vars["roomID"], vars["eventType"])
|
||||
})),
|
||||
|
@ -38,15 +39,3 @@ func Setup(servMux *http.ServeMux, httpClient *http.Client) {
|
|||
func make(metricsName string, h util.JSONRequestHandler) http.Handler {
|
||||
return prometheus.InstrumentHandler(metricsName, util.MakeJSONAPI(h))
|
||||
}
|
||||
|
||||
// jsonRequestHandlerWrapper is a wrapper to allow in-line functions to conform to util.JSONRequestHandler
|
||||
type jsonRequestHandlerWrapper struct {
|
||||
function func(req *http.Request) util.JSONResponse
|
||||
}
|
||||
|
||||
func (r *jsonRequestHandlerWrapper) OnIncomingRequest(req *http.Request) util.JSONResponse {
|
||||
return r.function(req)
|
||||
}
|
||||
func wrap(f func(req *http.Request) util.JSONResponse) *jsonRequestHandlerWrapper {
|
||||
return &jsonRequestHandlerWrapper{f}
|
||||
}
|
||||
|
|
|
@ -5,11 +5,16 @@ import (
|
|||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
log "github.com/Sirupsen/logrus"
|
||||
"github.com/matrix-org/dendrite/clientapi/auth"
|
||||
"github.com/matrix-org/dendrite/clientapi/common"
|
||||
"github.com/matrix-org/dendrite/clientapi/config"
|
||||
"github.com/matrix-org/dendrite/clientapi/events"
|
||||
"github.com/matrix-org/dendrite/clientapi/httputil"
|
||||
"github.com/matrix-org/dendrite/clientapi/jsonerror"
|
||||
"github.com/matrix-org/dendrite/common"
|
||||
"github.com/matrix-org/gomatrixserverlib"
|
||||
"github.com/matrix-org/util"
|
||||
)
|
||||
|
||||
|
@ -64,24 +69,30 @@ type createRoomResponse struct {
|
|||
RoomAlias string `json:"room_alias,omitempty"` // in synapse not spec
|
||||
}
|
||||
|
||||
// fledglingEvent is a helper representation of an event used when creating many events in succession.
|
||||
type fledglingEvent struct {
|
||||
Type string
|
||||
StateKey string
|
||||
Content interface{}
|
||||
}
|
||||
|
||||
// CreateRoom implements /createRoom
|
||||
func CreateRoom(req *http.Request) util.JSONResponse {
|
||||
serverName := "localhost"
|
||||
func CreateRoom(req *http.Request, cfg config.ClientAPI) util.JSONResponse {
|
||||
// TODO: Check room ID doesn't clash with an existing one, and we
|
||||
// probably shouldn't be using pseudo-random strings, maybe GUIDs?
|
||||
roomID := fmt.Sprintf("!%s:%s", util.RandomString(16), serverName)
|
||||
return createRoom(req, roomID)
|
||||
roomID := fmt.Sprintf("!%s:%s", util.RandomString(16), cfg.ServerName)
|
||||
return createRoom(req, cfg, roomID)
|
||||
}
|
||||
|
||||
// createRoom implements /createRoom
|
||||
func createRoom(req *http.Request, roomID string) util.JSONResponse {
|
||||
func createRoom(req *http.Request, cfg config.ClientAPI, roomID string) util.JSONResponse {
|
||||
logger := util.GetLogger(req.Context())
|
||||
userID, resErr := auth.VerifyAccessToken(req)
|
||||
if resErr != nil {
|
||||
return *resErr
|
||||
}
|
||||
var r createRoomRequest
|
||||
resErr = common.UnmarshalJSONRequest(req, &r)
|
||||
resErr = httputil.UnmarshalJSONRequest(req, &r)
|
||||
if resErr != nil {
|
||||
return *resErr
|
||||
}
|
||||
|
@ -98,7 +109,11 @@ func createRoom(req *http.Request, roomID string) util.JSONResponse {
|
|||
logger.WithFields(log.Fields{
|
||||
"userID": userID,
|
||||
"roomID": roomID,
|
||||
}).Info("Creating room")
|
||||
}).Info("Creating new room")
|
||||
|
||||
// Remember events we've built and key off the state tuple so we can look them up easily when filling in auth_events
|
||||
builtEventMap := make(map[common.StateKeyTuple]*gomatrixserverlib.Event)
|
||||
var builtEvents []*gomatrixserverlib.Event
|
||||
|
||||
// send events into the room in order of:
|
||||
// 1- m.room.create
|
||||
|
@ -117,12 +132,136 @@ func createRoom(req *http.Request, roomID string) util.JSONResponse {
|
|||
// This differs from Synapse slightly. Synapse would vary the ordering of 3-7
|
||||
// depending on if those events were in "initial_state" or not. This made it
|
||||
// harder to reason about, hence sticking to a strict static ordering.
|
||||
// TODO: Synapse has txn/token ID on each event. Do we need to do this here?
|
||||
eventsToMake := []fledglingEvent{
|
||||
{"m.room.create", "", events.CreateContent{Creator: userID}},
|
||||
{"m.room.member", userID, events.MemberContent{Membership: "join"}}, // TODO: Set avatar_url / displayname
|
||||
{"m.room.power_levels", "", events.InitialPowerLevelsContent(userID)},
|
||||
// TODO: m.room.canonical_alias
|
||||
{"m.room.join_rules", "", events.JoinRulesContent{"public"}}, // FIXME: Allow this to be changed
|
||||
{"m.room.history_visibility", "", events.HistoryVisibilityContent{"joined"}}, // FIXME: Allow this to be changed
|
||||
// TODO: m.room.guest_access
|
||||
// TODO: Other initial state items
|
||||
// TODO: m.room.name
|
||||
// TODO: m.room.topic
|
||||
// TODO: invite events
|
||||
// TODO: 3pid invite events
|
||||
// TODO m.room.aliases
|
||||
}
|
||||
|
||||
// f.e event:
|
||||
// - validate required keys/types (EventValidator in synapse)
|
||||
// - set additional keys (displayname/avatar_url for m.room.member)
|
||||
// - set token(?) and txn id
|
||||
// - then https://github.com/matrix-org/synapse/blob/v0.19.2/synapse/handlers/message.py#L419
|
||||
authEvents := authEventProvider{builtEventMap}
|
||||
for i, e := range eventsToMake {
|
||||
depth := i + 1 // depth starts at 1
|
||||
|
||||
return util.MessageResponse(404, "Not implemented yet")
|
||||
builder := gomatrixserverlib.EventBuilder{
|
||||
Sender: userID,
|
||||
RoomID: roomID,
|
||||
Type: e.Type,
|
||||
StateKey: &e.StateKey,
|
||||
Depth: int64(depth),
|
||||
}
|
||||
builder.SetContent(e.Content)
|
||||
if i > 0 {
|
||||
builder.PrevEvents = []gomatrixserverlib.EventReference{builtEvents[i-1].EventReference()}
|
||||
}
|
||||
ev, err := buildEvent(&builder, builtEventMap, cfg)
|
||||
if err != nil {
|
||||
return util.ErrorResponse(err)
|
||||
}
|
||||
|
||||
if err := gomatrixserverlib.Allowed(*ev, &authEvents); err != nil {
|
||||
return util.ErrorResponse(err)
|
||||
}
|
||||
|
||||
// Add the event to the list of auth events
|
||||
builtEventMap[common.StateKeyTuple{e.Type, e.StateKey}] = ev
|
||||
builtEvents = append(builtEvents, ev)
|
||||
|
||||
}
|
||||
|
||||
return util.JSONResponse{
|
||||
Code: 200,
|
||||
JSON: builtEvents,
|
||||
}
|
||||
}
|
||||
|
||||
// buildEvent fills out auth_events for the builder then builds the event
|
||||
func buildEvent(builder *gomatrixserverlib.EventBuilder,
|
||||
events map[common.StateKeyTuple]*gomatrixserverlib.Event,
|
||||
cfg config.ClientAPI) (*gomatrixserverlib.Event, error) {
|
||||
|
||||
eventsNeeded, err := gomatrixserverlib.StateNeededForEventBuilder(builder)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
builder.AuthEvents = authEventsFromStateNeeded(eventsNeeded, events)
|
||||
eventID := fmt.Sprintf("$%s:%s", util.RandomString(16), cfg.ServerName)
|
||||
now := time.Now()
|
||||
event, err := builder.Build(eventID, now, cfg.ServerName, cfg.KeyID, cfg.PrivateKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot build event %s : Builder failed to build. %s", builder.Type, err)
|
||||
}
|
||||
return &event, nil
|
||||
}
|
||||
|
||||
func authEventsFromStateNeeded(eventsNeeded gomatrixserverlib.StateNeeded,
|
||||
events map[common.StateKeyTuple]*gomatrixserverlib.Event) (authEvents []gomatrixserverlib.EventReference) {
|
||||
|
||||
// These events are only "needed" if they exist, so if they don't exist we can safely ignore them.
|
||||
if eventsNeeded.Create {
|
||||
ev := events[common.StateKeyTuple{"m.room.create", ""}]
|
||||
if ev != nil {
|
||||
authEvents = append(authEvents, ev.EventReference())
|
||||
}
|
||||
}
|
||||
if eventsNeeded.JoinRules {
|
||||
ev := events[common.StateKeyTuple{"m.room.join_rules", ""}]
|
||||
if ev != nil {
|
||||
authEvents = append(authEvents, ev.EventReference())
|
||||
}
|
||||
}
|
||||
if eventsNeeded.PowerLevels {
|
||||
ev := events[common.StateKeyTuple{"m.room.power_levels", ""}]
|
||||
if ev != nil {
|
||||
authEvents = append(authEvents, ev.EventReference())
|
||||
}
|
||||
}
|
||||
|
||||
for _, userID := range eventsNeeded.Member {
|
||||
ev := events[common.StateKeyTuple{"m.room.member", userID}]
|
||||
if ev != nil {
|
||||
authEvents = append(authEvents, ev.EventReference())
|
||||
}
|
||||
}
|
||||
for _, token := range eventsNeeded.ThirdPartyInvite {
|
||||
ev := events[common.StateKeyTuple{"m.room.member", token}]
|
||||
if ev != nil {
|
||||
authEvents = append(authEvents, ev.EventReference())
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
type authEventProvider struct {
|
||||
events map[common.StateKeyTuple]*gomatrixserverlib.Event
|
||||
}
|
||||
|
||||
func (a *authEventProvider) Create() (ev *gomatrixserverlib.Event, err error) {
|
||||
return a.events[common.StateKeyTuple{"m.room.create", ""}], nil
|
||||
}
|
||||
|
||||
func (a *authEventProvider) JoinRules() (ev *gomatrixserverlib.Event, err error) {
|
||||
return a.events[common.StateKeyTuple{"m.room.join_rules", ""}], nil
|
||||
}
|
||||
|
||||
func (a *authEventProvider) PowerLevels() (ev *gomatrixserverlib.Event, err error) {
|
||||
return a.events[common.StateKeyTuple{"m.room.power_levels", ""}], nil
|
||||
}
|
||||
|
||||
func (a *authEventProvider) Member(stateKey string) (ev *gomatrixserverlib.Event, err error) {
|
||||
return a.events[common.StateKeyTuple{"m.room.member", stateKey}], nil
|
||||
}
|
||||
|
||||
func (a *authEventProvider) ThirdPartyInvite(stateKey string) (ev *gomatrixserverlib.Event, err error) {
|
||||
return a.events[common.StateKeyTuple{"m.room.third_party_invite", stateKey}], nil
|
||||
}
|
||||
|
|
13
src/github.com/matrix-org/dendrite/common/types.go
Normal file
13
src/github.com/matrix-org/dendrite/common/types.go
Normal file
|
@ -0,0 +1,13 @@
|
|||
package common
|
||||
|
||||
// StateKeyTuple is a pair of an event type and state_key.
|
||||
// This is typically used as a key in a map.
|
||||
type StateKeyTuple struct {
|
||||
// The "type" key of a matrix event.
|
||||
EventType string
|
||||
// The "state_key" of a matrix event.
|
||||
// The empty string is a legitimate value for the "state_key" in matrix
|
||||
// so take care to initialise this field lest you accidentally request a
|
||||
// "state_key" with the go default of the empty string.
|
||||
EventStateKey string
|
||||
}
|
|
@ -4,29 +4,18 @@ import (
|
|||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/matrix-org/dendrite/common"
|
||||
"github.com/matrix-org/gomatrixserverlib"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// StateKeyTuple is a pair of an event type and state_key.
|
||||
// This is used when requesting parts of the state of a room.
|
||||
type StateKeyTuple struct {
|
||||
// The "type" key
|
||||
EventType string
|
||||
// The "state_key" of a matrix event.
|
||||
// The empty string is a legitimate value for the "state_key" in matrix
|
||||
// so take care to initialise this field lest you accidentally request a
|
||||
// "state_key" with the go default of the empty string.
|
||||
EventStateKey string
|
||||
}
|
||||
|
||||
// QueryLatestEventsAndStateRequest is a request to QueryLatestEventsAndState
|
||||
type QueryLatestEventsAndStateRequest struct {
|
||||
// The roomID to query the latest events for.
|
||||
RoomID string
|
||||
// The state key tuples to fetch from the room current state.
|
||||
// If this list is empty or nil then no state events are returned.
|
||||
StateToFetch []StateKeyTuple
|
||||
StateToFetch []common.StateKeyTuple
|
||||
}
|
||||
|
||||
// QueryLatestEventsAndStateResponse is a response to QueryLatestEventsAndState
|
||||
|
|
|
@ -2,6 +2,7 @@ package main
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/matrix-org/dendrite/common"
|
||||
"github.com/matrix-org/dendrite/roomserver/api"
|
||||
"github.com/matrix-org/gomatrixserverlib"
|
||||
"os"
|
||||
|
@ -367,7 +368,7 @@ func main() {
|
|||
if err := q.QueryLatestEventsAndState(
|
||||
&api.QueryLatestEventsAndStateRequest{
|
||||
RoomID: "!HCXfdvrfksxuYnIFiJ:matrix.org",
|
||||
StateToFetch: []api.StateKeyTuple{
|
||||
StateToFetch: []common.StateKeyTuple{
|
||||
{"m.room.member", "@richvdh:matrix.org"},
|
||||
},
|
||||
},
|
||||
|
|
|
@ -4,7 +4,7 @@ package state
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/matrix-org/dendrite/roomserver/api"
|
||||
"github.com/matrix-org/dendrite/common"
|
||||
"github.com/matrix-org/dendrite/roomserver/types"
|
||||
"github.com/matrix-org/util"
|
||||
"sort"
|
||||
|
@ -200,7 +200,7 @@ func DifferenceBetweeenStateSnapshots(db RoomStateDatabase, oldStateNID, newStat
|
|||
// stringTuplesToNumericTuples converts the string state key tuples into numeric IDs
|
||||
// If there isn't a numeric ID for either the event type or the event state key then the tuple is discarded.
|
||||
// Returns an error if there was a problem talking to the database.
|
||||
func stringTuplesToNumericTuples(db RoomStateDatabase, stringTuples []api.StateKeyTuple) ([]types.StateKeyTuple, error) {
|
||||
func stringTuplesToNumericTuples(db RoomStateDatabase, stringTuples []common.StateKeyTuple) ([]types.StateKeyTuple, error) {
|
||||
eventTypes := make([]string, len(stringTuples))
|
||||
stateKeys := make([]string, len(stringTuples))
|
||||
for i := range stringTuples {
|
||||
|
@ -239,7 +239,7 @@ func stringTuplesToNumericTuples(db RoomStateDatabase, stringTuples []api.StateK
|
|||
// This is typically the state before an event or the current state of a room.
|
||||
// Returns a sorted list of state entries or an error if there was a problem talking to the database.
|
||||
func LoadStateAtSnapshotForStringTuples(
|
||||
db RoomStateDatabase, stateNID types.StateSnapshotNID, stateKeyTuples []api.StateKeyTuple,
|
||||
db RoomStateDatabase, stateNID types.StateSnapshotNID, stateKeyTuples []common.StateKeyTuple,
|
||||
) ([]types.StateEntry, error) {
|
||||
numericTuples, err := stringTuplesToNumericTuples(db, stateKeyTuples)
|
||||
if err != nil {
|
||||
|
|
2
vendor/manifest
vendored
2
vendor/manifest
vendored
|
@ -92,7 +92,7 @@
|
|||
{
|
||||
"importpath": "github.com/matrix-org/gomatrixserverlib",
|
||||
"repository": "https://github.com/matrix-org/gomatrixserverlib",
|
||||
"revision": "ce2ae9c5812346444b0ca75d57834794cde03fb7",
|
||||
"revision": "4218890fdd60e73cc5539ec40b86fd51568f4a19",
|
||||
"branch": "master"
|
||||
},
|
||||
{
|
||||
|
|
|
@ -100,7 +100,7 @@ func (eb *EventBuilder) Build(eventID string, now time.Time, origin, keyID strin
|
|||
EventBuilder
|
||||
EventID string `json:"event_id"`
|
||||
RawContent rawJSON `json:"content"`
|
||||
RawUnsigned rawJSON `json:"unsigned"`
|
||||
RawUnsigned rawJSON `json:"unsigned,omitempty"`
|
||||
OriginServerTS int64 `json:"origin_server_ts"`
|
||||
Origin string `json:"origin"`
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue