All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m46s
- Implement `ReactionService` to manage file reactions in the database.
- Add `POST /d/{boxID}/f/{fileID}/react` endpoint to handle user reactions.
- Add `GET /emoji/{pack}/{file}` endpoint to serve custom emoji assets.
- Support loading custom emoji packs dynamically from the data directory.
- Update README with instructions on configuring emoji reaction packs.
167 lines
4.2 KiB
Go
167 lines
4.2 KiB
Go
package services
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"go.etcd.io/bbolt"
|
|
)
|
|
|
|
var reactionsBucket = []byte("file_reactions")
|
|
|
|
type ReactionService struct {
|
|
db *bbolt.DB
|
|
}
|
|
|
|
type FileReaction struct {
|
|
BoxID string `json:"boxId"`
|
|
FileID string `json:"fileId"`
|
|
EmojiID string `json:"emojiId"`
|
|
VisitorHash string `json:"visitorHash"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
}
|
|
|
|
type ReactionSummary struct {
|
|
EmojiID string `json:"emojiId"`
|
|
Count int `json:"count"`
|
|
}
|
|
|
|
func NewReactionService(db *bbolt.DB) (*ReactionService, error) {
|
|
if err := db.Update(func(tx *bbolt.Tx) error {
|
|
_, err := tx.CreateBucketIfNotExists(reactionsBucket)
|
|
return err
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
return &ReactionService{db: db}, nil
|
|
}
|
|
|
|
func (s *ReactionService) Add(boxID, fileID, visitorID, emojiID string) ([]ReactionSummary, error) {
|
|
boxID = strings.TrimSpace(boxID)
|
|
fileID = strings.TrimSpace(fileID)
|
|
visitorHash := reactionVisitorHash(visitorID)
|
|
emojiID = strings.TrimSpace(emojiID)
|
|
if boxID == "" || fileID == "" || visitorHash == "" || emojiID == "" {
|
|
return nil, errors.New("missing reaction data")
|
|
}
|
|
|
|
reaction := FileReaction{
|
|
BoxID: boxID,
|
|
FileID: fileID,
|
|
EmojiID: emojiID,
|
|
VisitorHash: visitorHash,
|
|
CreatedAt: time.Now().UTC(),
|
|
}
|
|
data, err := json.Marshal(reaction)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
key := reactionKey(boxID, fileID, visitorHash)
|
|
if err := s.db.Update(func(tx *bbolt.Tx) error {
|
|
bucket := tx.Bucket(reactionsBucket)
|
|
if bucket.Get([]byte(key)) != nil {
|
|
return os.ErrExist
|
|
}
|
|
return bucket.Put([]byte(key), data)
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
return s.SummaryForFile(boxID, fileID)
|
|
}
|
|
|
|
func (s *ReactionService) SummaryForBox(boxID, visitorID string) (map[string][]ReactionSummary, map[string]bool, error) {
|
|
visitorHash := reactionVisitorHash(visitorID)
|
|
summaries := make(map[string]map[string]int)
|
|
viewerReacted := make(map[string]bool)
|
|
err := s.db.View(func(tx *bbolt.Tx) error {
|
|
bucket := tx.Bucket(reactionsBucket)
|
|
if bucket == nil {
|
|
return nil
|
|
}
|
|
return bucket.ForEach(func(_, data []byte) error {
|
|
var reaction FileReaction
|
|
if err := json.Unmarshal(data, &reaction); err != nil {
|
|
return err
|
|
}
|
|
if reaction.BoxID != boxID {
|
|
return nil
|
|
}
|
|
if summaries[reaction.FileID] == nil {
|
|
summaries[reaction.FileID] = make(map[string]int)
|
|
}
|
|
summaries[reaction.FileID][reaction.EmojiID]++
|
|
if visitorHash != "" && reaction.VisitorHash == visitorHash {
|
|
viewerReacted[reaction.FileID] = true
|
|
}
|
|
return nil
|
|
})
|
|
})
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
result := make(map[string][]ReactionSummary, len(summaries))
|
|
for fileID, counts := range summaries {
|
|
result[fileID] = reactionCountsToSummaries(counts)
|
|
}
|
|
return result, viewerReacted, nil
|
|
}
|
|
|
|
func (s *ReactionService) SummaryForFile(boxID, fileID string) ([]ReactionSummary, error) {
|
|
counts := make(map[string]int)
|
|
err := s.db.View(func(tx *bbolt.Tx) error {
|
|
bucket := tx.Bucket(reactionsBucket)
|
|
if bucket == nil {
|
|
return nil
|
|
}
|
|
return bucket.ForEach(func(_, data []byte) error {
|
|
var reaction FileReaction
|
|
if err := json.Unmarshal(data, &reaction); err != nil {
|
|
return err
|
|
}
|
|
if reaction.BoxID == boxID && reaction.FileID == fileID {
|
|
counts[reaction.EmojiID]++
|
|
}
|
|
return nil
|
|
})
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return reactionCountsToSummaries(counts), nil
|
|
}
|
|
|
|
func reactionCountsToSummaries(counts map[string]int) []ReactionSummary {
|
|
summaries := make([]ReactionSummary, 0, len(counts))
|
|
for emojiID, count := range counts {
|
|
summaries = append(summaries, ReactionSummary{EmojiID: emojiID, Count: count})
|
|
}
|
|
sort.Slice(summaries, func(i, j int) bool {
|
|
if summaries[i].Count == summaries[j].Count {
|
|
return summaries[i].EmojiID < summaries[j].EmojiID
|
|
}
|
|
return summaries[i].Count > summaries[j].Count
|
|
})
|
|
return summaries
|
|
}
|
|
|
|
func reactionKey(boxID, fileID, visitorHash string) string {
|
|
return boxID + "\x00" + fileID + "\x00" + visitorHash
|
|
}
|
|
|
|
func reactionVisitorHash(visitorID string) string {
|
|
visitorID = strings.TrimSpace(visitorID)
|
|
if visitorID == "" {
|
|
return ""
|
|
}
|
|
sum := sha256.Sum256([]byte(visitorID))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|