feat: add emoji reaction support for files
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m46s
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.
This commit is contained in:
166
backend/libs/services/reactions.go
Normal file
166
backend/libs/services/reactions.go
Normal file
@@ -0,0 +1,166 @@
|
||||
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[:])
|
||||
}
|
||||
@@ -137,6 +137,9 @@ func NewUploadService(maxUploadSize int64, dataDir, baseURL string, logger *slog
|
||||
if err := os.MkdirAll(dbDir, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(dataDir, "emoji"), 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
db, err := bbolt.Open(filepath.Join(dbDir, "warpbox.bbolt"), 0o600, &bbolt.Options{Timeout: time.Second})
|
||||
if err != nil {
|
||||
@@ -957,6 +960,10 @@ func randomID(byteCount int) string {
|
||||
return base64.RawURLEncoding.EncodeToString(data)
|
||||
}
|
||||
|
||||
func RandomPublicToken(byteCount int) string {
|
||||
return randomID(byteCount)
|
||||
}
|
||||
|
||||
func hashPassword(password string) (string, string) {
|
||||
salt := randomID(18)
|
||||
return salt, passwordHash(salt, password)
|
||||
|
||||
Reference in New Issue
Block a user