Initial commit

This commit is contained in:
Kevin Kandlbinder 2022-02-20 23:42:32 +01:00
commit b81af24e50
21 changed files with 2283 additions and 0 deletions

View file

@ -0,0 +1,67 @@
/*
* Copyright © 2022 Kevin Kandlbinder.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package bot
import (
"github.com/Unkn0wnCat/matrix-veles/internal/config"
"log"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/id"
)
// isInRoom checks if the given mautrix.Client is joined in the given room
func isInRoom(client *mautrix.Client, id id.RoomID) (bool, error) {
res, err := client.JoinedRooms()
if err != nil {
return false, err
}
for _, joinedRoom := range res.JoinedRooms {
if joinedRoom == id {
return true, nil
} // If this is the room we're searching for, return from function
}
// If we arrived here there is no room we joined with the given ID
return false, nil
}
// doAcceptInvite accepts the invite to the given room with the given mautrix.Client
func doAcceptInvite(client *mautrix.Client, id id.RoomID) {
roomAlreadyJoined, err := isInRoom(client, id)
if err != nil {
log.Printf("Could not accept invite to %s due to internal error", id)
log.Println(err)
return
}
if roomAlreadyJoined {
return
} // If the room was already joined, ignore
_, err = client.JoinRoom(id.String(), "", nil)
if err != nil {
log.Printf("Could not accept invite to %s due to join error", id)
log.Println(err)
return
}
log.Printf("Successfully joined room %s", id)
config.AddRoomConfig(id.String())
}

233
internal/bot/bot.go Normal file
View file

@ -0,0 +1,233 @@
/*
* Copyright © 2022 Kevin Kandlbinder.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package bot
import (
"github.com/Unkn0wnCat/matrix-veles/internal/config"
"github.com/spf13/viper"
"log"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
"os"
"os/signal"
"strings"
"syscall"
"time"
)
// Run starts the bot, blocking until an interrupt or SIGTERM is received
func Run() {
// Set up signal channel for controlled shutdown
c := make(chan os.Signal)
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
// Save Timestamp for filtering
startTs := time.Now().Unix()
checkConfig()
log.Printf("matrix-veles has started.")
// Initialize client, this does not check access key!
matrixClient, err := mautrix.NewClient(
viper.GetString("bot.homeserver_url"),
id.NewUserID(viper.GetString("bot.username"), viper.GetString("bot.homeserver")),
viper.GetString("bot.accessKey"))
if err != nil {
log.Printf("matrix-veles couldn't initialize matrix client, please check credentials")
log.Fatal(err)
return
}
// If no accessKey is set, perform login
if viper.GetString("bot.accessKey") == "" {
performLogin(matrixClient)
}
// Set up sync handlers for invites and messages
syncer := matrixClient.Syncer.(*mautrix.DefaultSyncer)
syncer.OnEventType(event.EventMessage, handleMessageEvent(matrixClient, startTs))
syncer.OnEventType(event.StateMember, handleMemberEvent(matrixClient, startTs))
// Set up async tasks
go startSync(matrixClient)
go doInitialUpdate(matrixClient)
<-c
log.Printf("Shutting down...")
matrixClient.StopSync()
log.Printf("Goodbye!")
os.Exit(0)
}
// checkConfig applies constraints to the configuration and exits the program on violation
func checkConfig() {
// Both homeserver and username are required!
if viper.GetString("bot.homeserver") == "" || viper.GetString("bot.username") == "" {
log.Printf("matrix-veles is missing user identification (homeserver / username)")
os.Exit(1)
return
}
// Either accessKey or password are required
if viper.GetString("bot.accessKey") == "" && viper.GetString("bot.password") == "" {
log.Printf("matrix-veles is missing user credentials (access-key / password)")
log.Printf("Please provide either an access-key or password")
os.Exit(1)
return
}
}
// performLogin logs in the mautrix.Client using the username and password from config
func performLogin(matrixClient *mautrix.Client) {
res, err := matrixClient.Login(&mautrix.ReqLogin{
Type: "m.login.password",
Identifier: mautrix.UserIdentifier{Type: mautrix.IdentifierTypeUser, User: viper.GetString("bot.username")},
Password: viper.GetString("bot.password"),
StoreCredentials: true,
InitialDeviceDisplayName: "github.com/Unkn0wnCat/matrix-veles",
})
if err != nil {
log.Printf("matrix-veles couldn't sign in, please check credentials")
log.Fatal(err)
return
}
accessToken := res.AccessToken
// Save accessKey to configuration
viper.Set("bot.accessKey", accessToken)
err = viper.WriteConfig()
if err != nil {
log.Printf("matrix-veles could not save the accessKey to config")
log.Fatal(err)
return
}
}
// doInitialUpdate updates the config right after startup to catch up with joined/left rooms
func doInitialUpdate(matrixClient *mautrix.Client) {
resp, err := matrixClient.JoinedRooms()
if err != nil {
log.Printf("matrix-veles could not read joined rooms, something is horribly wrong")
log.Fatalln(err)
}
// Hand-off list to config helper
config.RoomConfigInitialUpdate(resp.JoinedRooms)
}
// handleMessageEvent wraps message handler taking the mautrix.Client and start timestamp as parameters
func handleMessageEvent(matrixClient *mautrix.Client, startTs int64) mautrix.EventHandler {
return func(source mautrix.EventSource, evt *event.Event) {
if evt.Timestamp < (startTs * 1000) {
// Ignore old events
return
}
// Cast event to correct event type
content, ok := evt.Content.Parsed.(*event.MessageEventContent)
if !ok {
log.Println("Uh oh, could not typecast m.room.member event content...")
return
}
username, _, err := matrixClient.UserID.Parse()
if err != nil {
log.Panicln("Invalid user id in client")
}
if content.URL != "" {
handleHashing(content, evt, matrixClient)
return
}
if !strings.HasPrefix(content.Body, "!"+username) &&
!strings.HasPrefix(content.Body, "@"+username) &&
!(strings.HasPrefix(content.Body, username) && strings.HasPrefix(content.FormattedBody, "<a href=\"https://matrix.to/#/"+matrixClient.UserID.String()+"\">")) {
return
}
handleCommand(content.Body, evt.Sender, evt.RoomID, matrixClient)
}
}
// handleMemberEvent wraps m.room.member (invite, join, leave, ban etc.) handler taking the mautrix.Client and start timestamp as parameters
func handleMemberEvent(matrixClient *mautrix.Client, startTs int64) func(source mautrix.EventSource, evt *event.Event) {
return func(source mautrix.EventSource, evt *event.Event) {
if *evt.StateKey != matrixClient.UserID.String() {
return
} // This does not concern us, as we are not the subject
if evt.Timestamp < (startTs * 1000) {
// Ignore old events, TODO: Handle missed invites.
return
}
// Cast event to correct event type
content, ok := evt.Content.Parsed.(*event.MemberEventContent)
if !ok {
log.Println("Uh oh, could not typecast m.room.member event content...")
return
}
// If it is an invite, accept it
if content.Membership == event.MembershipInvite {
doAcceptInvite(matrixClient, evt.RoomID)
return
}
// If it is our join event, set room to active
if content.Membership == event.MembershipJoin {
config.SetRoomConfigActive(evt.RoomID.String(), true)
return
}
// If we left or got banned, set room to inactive
if content.Membership.IsLeaveOrBan() {
config.SetRoomConfigActive(evt.RoomID.String(), false)
return
}
}
}
// startSync starts the mautrix.Client sync for receiving events
func startSync(matrixClient *mautrix.Client) {
err := matrixClient.Sync()
if err != nil {
log.Printf("matrix-veles has encountered a fatal error whilst syncing")
log.Println(err)
os.Exit(2)
}
log.Println("sync exited.")
}
// formattedMessage is a helper struct for sending HTML content
type formattedMessage struct {
Type string `json:"msgtype"`
Body string `json:"body"`
Format string `json:"format"`
FormattedBody string `json:"formatted_body"`
}

View file

@ -0,0 +1,59 @@
/*
* Copyright © 2022 Kevin Kandlbinder.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package bot
import (
"log"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/id"
"strings"
)
// handleCommand takes a command, parses it and executes any actions it implies
func handleCommand(command string, sender id.UserID, id id.RoomID, client *mautrix.Client) {
myUsername, _, err := client.UserID.Parse()
if err != nil {
log.Panicln("Invalid user id in client")
}
command = strings.TrimPrefix(command, "!") // Remove !
command = strings.TrimPrefix(command, "@") // Remove @
command = strings.TrimPrefix(command, myUsername) // Remove our own username
command = strings.TrimPrefix(command, ":") // Remove : (as in "@soccerbot:")
command = strings.TrimSpace(command)
// TODO: Remove this, it is debug!
log.Println(command)
// Is this a help command?
if strings.HasPrefix(command, "help") {
commandHelp(sender, id, client)
return
}
// No match :( - display help
commandHelp(sender, id, client)
return
}
func commandHelp(_ id.UserID, id id.RoomID, client *mautrix.Client) {
// TODO: Improve help message
// Ignore errors as we can't do anything about them, the user will probably retry
_, _ = client.SendNotice(id, "matrix-veles help\n\n!veles-bot help - shows this help")
}

View file

@ -0,0 +1,216 @@
package bot
import (
"bytes"
"crypto/sha512"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"github.com/Unkn0wnCat/matrix-veles/internal/config"
"github.com/Unkn0wnCat/matrix-veles/internal/db"
"github.com/Unkn0wnCat/matrix-veles/internal/db/model"
"go.mongodb.org/mongo-driver/mongo"
"io"
"log"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)
func handleHashing(content *event.MessageEventContent, evt *event.Event, matrixClient *mautrix.Client) {
url, err := content.URL.Parse()
if err != nil {
log.Printf("Error: Could not parse Content-URL: \"%s\" - %v", content.URL, err)
return
}
reader, err := matrixClient.Download(url)
if err != nil {
log.Printf("Error: Could not read file from Content-URL: \"%s\" - %v", content.URL, err)
return
}
defer func(reader io.ReadCloser) {
_ = reader.Close()
}(reader)
hashWriter := sha512.New()
if _, err = io.Copy(hashWriter, reader); err != nil {
log.Printf("Error: Could not hash file from Content-URL: \"%s\" - %v", content.URL, err)
return
}
sum := hex.EncodeToString(hashWriter.Sum(nil))
roomConfig := config.GetRoomConfig(evt.RoomID.String())
hashObj, err := db.GetEntryByHash(sum)
if err != nil {
if errors.Is(err, mongo.ErrNoDocuments) {
/*entry := model.DBEntry{
ID: primitive.NewObjectID(),
HashValue: sum,
FileURL: "placeholder",
Timestamp: time.Now(),
AddedBy: nil,
Comments: nil,
}
db.SaveEntry(&entry)*/
if roomConfig.Debug {
matrixClient.SendNotice(evt.RoomID, fmt.Sprintf("DEBUG - This file is not on the hashlist: %s", sum))
}
return
}
if roomConfig.Debug {
matrixClient.SendNotice(evt.RoomID, "DEBUG - Failed to check file. See log.")
}
fmt.Printf("Error trying to check database: %v", err)
return
}
if roomConfig.Debug {
matrixClient.SendNotice(evt.RoomID, fmt.Sprintf("DEBUG !!! This file is on the hashlist: %s", sum))
jsonVal, _ := json.Marshal(hashObj)
var buf bytes.Buffer
json.Indent(&buf, jsonVal, "", " ")
matrixClient.SendNotice(evt.RoomID, fmt.Sprintf("DEBUG:\n%s", buf.String()))
}
handleIllegalContent(evt, matrixClient, hashObj, roomConfig)
}
func redactMessage(evt *event.Event, matrixClient *mautrix.Client, hashObj *model.DBEntry) {
opts := mautrix.ReqRedact{Reason: fmt.Sprintf("Veles has detected an hash-map-match! Tags: %s, ID: %s", hashObj.Tags, hashObj.ID.Hex())}
_, err := matrixClient.RedactEvent(evt.RoomID, evt.ID, opts)
if err != nil {
log.Printf("ERROR: Could not redact event - %v", err)
}
}
type StateEventPL struct {
Type string `json:"type"`
Sender string `json:"sender"`
RoomID string `json:"room_id"`
EventID string `json:"event_id"`
OriginServerTS int64 `json:"origin_server_ts"`
Content StateEventPLContent `json:"content"`
Unsigned struct {
Age int `json:"age"`
} `json:"unsigned"`
}
type StateEventPLContent struct {
Ban int `json:"ban"`
Events map[string]int `json:"events"`
EventsDefault int `json:"events_default"`
Invite int `json:"invite"`
Kick int `json:"kick"`
Notifications map[string]int `json:"notifications"`
Redact int `json:"redact"`
StateDefault int `json:"state_default"`
Users map[string]int `json:"users"`
UsersDefault int `json:"users_default"`
}
func GetRoomState(matrixClient *mautrix.Client, roomId id.RoomID) (*StateEventPLContent, error) {
url := matrixClient.BuildURL("rooms", roomId.String(), "state")
log.Println(url)
res, err := matrixClient.MakeRequest("GET", url, nil, nil)
if err != nil {
return nil, fmt.Errorf("ERROR: Could request room state - %v", err)
}
var stateEvents []StateEventPL
err = json.Unmarshal(res, &stateEvents)
if err != nil {
return nil, fmt.Errorf("ERROR: Could parse room state - %v", err)
}
var plEventContent StateEventPLContent
found := false
for _, e2 := range stateEvents {
if e2.Type != event.StatePowerLevels.Type {
continue
}
found = true
plEventContent = e2.Content
}
if !found {
return nil, fmt.Errorf("ERROR: Could find room power level - %v", err)
}
if plEventContent.Events == nil {
plEventContent.Events = make(map[string]int)
}
if plEventContent.Notifications == nil {
plEventContent.Notifications = make(map[string]int)
}
if plEventContent.Users == nil {
plEventContent.Users = make(map[string]int)
}
return &plEventContent, nil
}
func muteUser(evt *event.Event, matrixClient *mautrix.Client, hashObj *model.DBEntry) {
plEventContent, err := GetRoomState(matrixClient, evt.RoomID)
if err != nil {
log.Printf("ERROR: Could mute user - %v", err)
return
}
plEventContent.Users[evt.Sender.String()] = -1
_, err = matrixClient.SendStateEvent(evt.RoomID, event.StatePowerLevels, "", plEventContent)
if err != nil {
log.Printf("ERROR: Could mute user - %v", err)
return
}
}
func postNotice(evt *event.Event, matrixClient *mautrix.Client, hashObj *model.DBEntry, roomConfig config.RoomConfig) {
local, server, err := evt.Sender.Parse()
if err != nil {
return
}
matrixClient.SendNotice(evt.RoomID, fmt.Sprintf(
`Veles Triggered: The message by %s (on %s) was flagged for containing material used by spammers or trolls!
If you believe this action was an accident, please contact an room administrator or moderator. (Reference: %s)`, local, server, hashObj.ID.Hex()))
}
func handleIllegalContent(evt *event.Event, matrixClient *mautrix.Client, hashObj *model.DBEntry, roomConfig config.RoomConfig) {
switch roomConfig.HashCheckMode {
case 0:
break
case 1:
break
case 2:
muteUser(evt, matrixClient, hashObj)
redactMessage(evt, matrixClient, hashObj)
postNotice(evt, matrixClient, hashObj, roomConfig)
break
case 3:
break
}
}

149
internal/config/helpers.go Normal file
View file

@ -0,0 +1,149 @@
/*
* Copyright © 2022 Kevin Kandlbinder.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package config
import (
"context"
"errors"
"github.com/Unkn0wnCat/matrix-veles/internal/db"
"github.com/spf13/viper"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
"log"
"maunium.net/go/mautrix/id"
"sync"
)
var (
roomConfigWg sync.WaitGroup
)
// SetRoomConfigActive updates the active state for a given room
func SetRoomConfigActive(id string, active bool) {
// Lock room config system to prevent race conditions
roomConfigWg.Wait()
roomConfigWg.Add(1)
roomConfig := GetRoomConfig(id)
roomConfig.Active = true
err := SaveRoomConfig(&roomConfig)
if err != nil {
log.Panicf("Error writing room config to database: %v", err)
}
// Unlock room config system
roomConfigWg.Done()
}
// GetRoomConfig returns the RoomConfig linked to the specified ID
func GetRoomConfig(id string) RoomConfig {
config, err := GetRoomConfigByRoomID(id)
if err != nil {
if errors.Is(err, mongo.ErrNoDocuments) {
return AddRoomConfig(id)
}
}
return *config
}
// RoomConfigInitialUpdate updates all RoomConfig entries to set activity and create blank configs
func RoomConfigInitialUpdate(ids []id.RoomID) {
db := db.DbClient.Database(viper.GetString("bot.mongo.database"))
cursor, err := db.Collection("rooms").Find(context.TODO(), bson.D{}, nil)
if err != nil {
log.Panicf("Error querying room configs: %v", err)
}
var roomConfigs []RoomConfig
err = cursor.All(context.TODO(), &roomConfigs)
if err != nil {
log.Panicf("Error querying room configs: %v", err)
}
activeRooms := make(map[string]bool)
// Set all active states to "false" for a blank start
for _, roomConfig := range roomConfigs {
activeRooms[roomConfig.RoomID] = false
}
// Go over all joined rooms
for _, roomID := range ids {
activeRooms[roomID.String()] = true
GetRoomConfig(roomID.String())
}
for roomID, isActive := range activeRooms {
SetRoomConfigActive(roomID, isActive)
}
}
func AddRoomConfig(id string) RoomConfig {
// Lock room config system to prevent race conditions
roomConfigWg.Wait()
roomConfigWg.Add(1)
config := RoomConfig{ID: primitive.NewObjectID(), Active: true, RoomID: id}
err := SaveRoomConfig(&config)
if err != nil {
log.Panicf("Error writing room config to database: %v", err)
}
// Unlock room config system
roomConfigWg.Done()
return config
}
func SaveRoomConfig(roomConfig *RoomConfig) error {
db := db.DbClient.Database(viper.GetString("bot.mongo.database"))
opts := options.Replace().SetUpsert(true)
filter := bson.D{{"room_id", roomConfig.RoomID}}
_, err := db.Collection("rooms").ReplaceOne(context.TODO(), filter, roomConfig, opts)
return err
}
func GetRoomConfigByRoomID(id string) (*RoomConfig, error) {
db := db.DbClient.Database(viper.GetString("bot.mongo.database"))
res := db.Collection("rooms").FindOne(context.TODO(), bson.D{{"room_id", id}})
if res.Err() != nil {
return nil, res.Err()
}
object := RoomConfig{}
err := res.Decode(&object)
if err != nil {
return nil, err
}
return &object, nil
}

View file

@ -0,0 +1,48 @@
/*
* Copyright © 2022 Kevin Kandlbinder.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package config
import "go.mongodb.org/mongo-driver/bson/primitive"
// RoomConfigTree is a map from string to RoomConfig
type RoomConfigTree map[string]RoomConfig
// RoomConfig is the configuration attached to every joined room
type RoomConfig struct {
ID primitive.ObjectID `bson:"_id"`
// Active tells if the bot is active in this room (Set to false on leave/kick/ban)
Active bool `yaml:"active" bson:"active"`
// RoomID is the rooms ID
RoomID string `yaml:"roomID" bson:"room_id"`
// Debug specifies if the bot shall run in dry run mode
Debug bool `yaml:"debug" bson:"debug"`
/*
HashCheckMode specifies the mode the bot should operate under in this room
HashCheck-Modes:
0. Silent Mode (Don't do anything)
1. Notify Mode (Message Room Admins & Mods)
2. Mute Mode (Remove message, notify admin & mute user)
3. Ban Mode (Remove message, notify admin & ban user)
*/
HashCheckMode uint8 `yaml:"mode" bson:"hash_check_mode"`
}

61
internal/db/db.go Normal file
View file

@ -0,0 +1,61 @@
package db
import (
"context"
"github.com/Unkn0wnCat/matrix-veles/internal/db/model"
"github.com/spf13/viper"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
"log"
"time"
)
var DbClient *mongo.Client
func Connect() {
if viper.GetString("bot.mongo.uri") == "" {
log.Println("Skipping database login...")
return
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
newClient, err := mongo.Connect(ctx, options.Client().ApplyURI(viper.GetString("bot.mongo.uri")))
if err != nil {
log.Println("Could not connect to DB")
log.Panicln(err)
}
DbClient = newClient
}
func SaveEntry(entry *model.DBEntry) error {
db := DbClient.Database(viper.GetString("bot.mongo.database"))
opts := options.Replace().SetUpsert(true)
filter := bson.D{{"_id", entry.ID}}
_, err := db.Collection("entries").ReplaceOne(context.TODO(), filter, entry, opts)
return err
}
func GetEntryByHash(hash string) (*model.DBEntry, error) {
db := DbClient.Database(viper.GetString("bot.mongo.database"))
res := db.Collection("entries").FindOne(context.TODO(), bson.D{{"hash_value", hash}})
if res.Err() != nil {
return nil, res.Err()
}
object := model.DBEntry{}
err := res.Decode(&object)
if err != nil {
return nil, err
}
return &object, nil
}

View file

@ -0,0 +1,21 @@
package model
import (
"go.mongodb.org/mongo-driver/bson/primitive"
"time"
)
type DBEntry struct {
ID primitive.ObjectID `bson:"_id" json:"id"`
Tags []string `bson:"tags" json:"tags"`
HashValue string `bson:"hash_value" json:"hash"`
FileURL string `bson:"file_url" json:"file_url"`
Timestamp time.Time `bson:"timestamp" json:"timestamp"`
AddedBy *primitive.ObjectID `bson:"added_by" json:"added_by"`
Comments []*DBEntryComment `bson:"comments" json:"comments"`
}
type DBEntryComment struct {
CommentedBy *primitive.ObjectID `bson:"commented_by" json:"commented_by"`
Content string `bson:"content" json:"content"`
}