diff --git a/README.md b/README.md index b339240..2fcedd2 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,37 @@ links from `/admin/users`. The env admin token still exists as emergency fallback access. Set `WARPBOX_ADMIN_TOKEN` and use it at `/admin/login` if you need to recover access without a user session. +## Emoji reaction packs + +File reactions use emoji packs from the runtime data directory, not from the application code. By +default that means `./data/emoji`; if you change `WARPBOX_DATA_DIR`, use +`$WARPBOX_DATA_DIR/emoji` instead. + +Each folder under `./data/emoji` becomes one emoji tab in the reaction picker. Put image files +directly inside the pack folder: + +```text +data/ +├── db/ +├── files/ +├── logs/ +└── emoji/ + ├── openmoji/ + │ ├── 1F600.svg + │ ├── 1F44D.svg + │ └── 2764.svg + ├── pixel-pack/ + │ ├── happy.webp + │ ├── fire.webp + │ └── star.webp + └── custom-work/ + ├── approved.png + └── shipped.png +``` + +In this example, the picker shows tabs named `Openmoji`, `Pixel pack`, and `Custom work`. +Supported emoji image extensions are `.svg`, `.webp`, `.png`, `.jpg`, `.jpeg`, and `.gif`. + For one-off Go commands, run them from the backend module: ```bash diff --git a/backend/libs/handlers/app.go b/backend/libs/handlers/app.go index f0257cd..9bd26d8 100644 --- a/backend/libs/handlers/app.go +++ b/backend/libs/handlers/app.go @@ -16,12 +16,13 @@ type App struct { uploadService *services.UploadService authService *services.AuthService settingsService *services.SettingsService + reactionService *services.ReactionService banService *services.BanService rateLimiter *rateLimiter uploadGroups *uploadGrouper } -func NewApp(cfg config.Config, logger *slog.Logger, renderer *web.Renderer, uploadService *services.UploadService, authService *services.AuthService, settingsService *services.SettingsService, banService *services.BanService) *App { +func NewApp(cfg config.Config, logger *slog.Logger, renderer *web.Renderer, uploadService *services.UploadService, authService *services.AuthService, settingsService *services.SettingsService, reactionService *services.ReactionService, banService *services.BanService) *App { return &App{ cfg: cfg, logger: logger, @@ -29,6 +30,7 @@ func NewApp(cfg config.Config, logger *slog.Logger, renderer *web.Renderer, uplo uploadService: uploadService, authService: authService, settingsService: settingsService, + reactionService: reactionService, banService: banService, rateLimiter: newRateLimiter(), uploadGroups: newUploadGrouper(), @@ -121,6 +123,7 @@ func (a *App) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /d/{boxID}/manage/{token}/delete", a.ManageDeleteBox) mux.HandleFunc("POST /d/{boxID}/unlock", a.UnlockBox) mux.HandleFunc("GET /d/{boxID}/zip", a.DownloadZip) + mux.HandleFunc("POST /d/{boxID}/f/{fileID}/react", a.ReactToFile) mux.HandleFunc("GET /d/{boxID}/f/{fileID}", a.DownloadFile) mux.HandleFunc("GET /d/{boxID}/f/{fileID}/download", a.DownloadFileContent) mux.HandleFunc("GET /d/{boxID}/thumb/{fileID}", a.Thumbnail) @@ -132,5 +135,6 @@ func (a *App) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /api/v1/schemas/upload-request.json", a.UploadRequestSchema) mux.HandleFunc("GET /api/v1/schemas/upload-response.json", a.UploadResponseSchema) mux.HandleFunc("POST /api/v1/upload", a.Upload) + mux.HandleFunc("GET /emoji/{pack}/{file}", a.EmojiAsset) mux.Handle("GET /static/", a.Static()) } diff --git a/backend/libs/handlers/download.go b/backend/libs/handlers/download.go index da1a41e..780e616 100644 --- a/backend/libs/handlers/download.go +++ b/backend/libs/handlers/download.go @@ -2,12 +2,15 @@ package handlers import ( "bytes" + "encoding/json" "errors" "fmt" "io" "net/http" + "net/url" "os" "path/filepath" + "sort" "strings" "time" @@ -26,6 +29,7 @@ type downloadPageData struct { DownloadCount int MaxDownloads int ExpiresLabel string + EmojiTabs []emojiTabView } type boxView struct { @@ -41,6 +45,28 @@ type fileView struct { URL string DownloadURL string ThumbnailURL string + ReactURL string + Reactions []reactionView + Reacted bool +} + +type reactionView struct { + EmojiID string `json:"emojiId"` + URL string `json:"url"` + Label string `json:"label"` + Count int `json:"count"` +} + +type emojiTabView struct { + ID string + Label string + Emojis []emojiOptionView +} + +type emojiOptionView struct { + ID string `json:"id"` + URL string `json:"url"` + Label string `json:"label"` } type previewPageData struct { @@ -70,13 +96,22 @@ func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) { return } locked := a.uploadService.IsProtected(box) && !a.isBoxUnlocked(r, box) + visitorID := a.reactionVisitorID(w, r) + reactionsByFile, reactedByFile, err := a.reactionService.SummaryForBox(box.ID, visitorID) + if err != nil { + a.logger.Warn("failed to load file reactions", withRequestLogAttrs(r, "source", "reactions", "severity", "warn", "code", 4300, "box_id", box.ID, "error", err.Error())...) + } files := make([]fileView, 0, len(box.Files)) if !(locked && box.Obfuscate) { for _, file := range box.Files { - files = append(files, a.fileView(box, file)) + files = append(files, a.fileViewWithReactions(box, file, reactionsByFile[file.ID], reactedByFile[file.ID])) } } + emojiTabs, err := a.emojiTabs() + if err != nil { + a.logger.Warn("failed to load emoji tabs", withRequestLogAttrs(r, "source", "reactions", "severity", "warn", "code", 4301, "box_id", box.ID, "error", err.Error())...) + } expiresLabel := boxExpiryLabel(box.ExpiresAt, "Jan 2, 2006 15:04 MST") title := "Shared files on Warpbox" @@ -99,6 +134,7 @@ func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) { DownloadCount: box.DownloadCount, MaxDownloads: box.MaxDownloads, ExpiresLabel: expiresLabel, + EmojiTabs: emojiTabs, }, }) a.logger.Info("download page viewed", withRequestLogAttrs(r, "source", "download", "severity", "user_activity", "code", 2003, "box_id", box.ID, "locked", locked)...) @@ -310,6 +346,10 @@ func (a *App) DownloadZip(w http.ResponseWriter, r *http.Request) { } func (a *App) fileView(box services.Box, file services.File) fileView { + return a.fileViewWithReactions(box, file, nil, false) +} + +func (a *App) fileViewWithReactions(box services.Box, file services.File, reactions []services.ReactionSummary, reacted bool) fileView { return fileView{ ID: file.ID, Name: file.Name, @@ -319,9 +359,171 @@ func (a *App) fileView(box services.Box, file services.File) fileView { URL: fmt.Sprintf("/d/%s/f/%s", box.ID, file.ID), DownloadURL: fmt.Sprintf("/d/%s/f/%s/download", box.ID, file.ID), ThumbnailURL: fmt.Sprintf("/d/%s/thumb/%s", box.ID, file.ID), + ReactURL: fmt.Sprintf("/d/%s/f/%s/react", box.ID, file.ID), + Reactions: a.reactionViews(reactions), + Reacted: reacted, } } +func (a *App) ReactToFile(w http.ResponseWriter, r *http.Request) { + box, file, ok := a.loadFileForRequest(w, r) + if !ok { + return + } + if a.uploadService.IsProtected(box) && !a.isBoxUnlocked(r, box) { + http.Error(w, "password required", http.StatusUnauthorized) + return + } + + if err := r.ParseForm(); err != nil { + http.Error(w, "invalid reaction", http.StatusBadRequest) + return + } + emojiID := strings.TrimSpace(r.FormValue("emoji_id")) + if !a.validEmojiID(emojiID) { + http.Error(w, "unknown emoji", http.StatusBadRequest) + return + } + + visitorID := a.reactionVisitorID(w, r) + reactions, err := a.reactionService.Add(box.ID, file.ID, visitorID, emojiID) + if errors.Is(err, os.ErrExist) { + writeJSON(w, http.StatusConflict, map[string]any{"error": "already reacted"}) + return + } + if err != nil { + a.logger.Warn("file reaction failed", withRequestLogAttrs(r, "source", "reactions", "severity", "warn", "code", 4302, "box_id", box.ID, "file_id", file.ID, "error", err.Error())...) + http.Error(w, "could not save reaction", http.StatusInternalServerError) + return + } + + a.logger.Info("file reaction added", withRequestLogAttrs(r, "source", "reactions", "severity", "user_activity", "code", 2301, "box_id", box.ID, "file_id", file.ID, "emoji_id", emojiID)...) + writeJSON(w, http.StatusCreated, map[string]any{ + "reactions": a.reactionViews(reactions), + "reacted": true, + }) +} + +func (a *App) reactionViews(reactions []services.ReactionSummary) []reactionView { + views := make([]reactionView, 0, len(reactions)) + for _, reaction := range reactions { + views = append(views, reactionView{ + EmojiID: reaction.EmojiID, + URL: emojiURL(reaction.EmojiID), + Label: emojiLabel(reaction.EmojiID), + Count: reaction.Count, + }) + } + return views +} + +func (a *App) emojiTabs() ([]emojiTabView, error) { + root := a.emojiRoot() + entries, err := os.ReadDir(root) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + return nil, err + } + tabs := make([]emojiTabView, 0, len(entries)) + for _, entry := range entries { + if !entry.IsDir() { + continue + } + tabID := entry.Name() + files, err := os.ReadDir(filepath.Join(root, tabID)) + if err != nil { + return nil, err + } + tab := emojiTabView{ID: tabID, Label: emojiTabLabel(tabID)} + for _, file := range files { + if file.IsDir() || !isEmojiFile(file.Name()) { + continue + } + emojiID := tabID + "/" + file.Name() + tab.Emojis = append(tab.Emojis, emojiOptionView{ + ID: emojiID, + URL: emojiURL(emojiID), + Label: emojiLabel(emojiID), + }) + } + sort.Slice(tab.Emojis, func(i, j int) bool { return tab.Emojis[i].ID < tab.Emojis[j].ID }) + if len(tab.Emojis) > 0 { + tabs = append(tabs, tab) + } + } + sort.Slice(tabs, func(i, j int) bool { return tabs[i].ID < tabs[j].ID }) + return tabs, nil +} + +func (a *App) validEmojiID(id string) bool { + id = strings.TrimSpace(id) + if id == "" || strings.Contains(id, "\\") || strings.Contains(id, "..") || strings.HasPrefix(id, "/") { + return false + } + parts := strings.Split(id, "/") + if len(parts) != 2 || parts[0] == "" || parts[1] == "" || !isEmojiFile(parts[1]) { + return false + } + info, err := os.Stat(filepath.Join(a.emojiRoot(), parts[0], parts[1])) + return err == nil && !info.IsDir() +} + +func (a *App) emojiRoot() string { + return filepath.Join(a.cfg.DataDir, "emoji") +} + +func (a *App) reactionVisitorID(w http.ResponseWriter, r *http.Request) string { + const cookieName = "warpbox_reactor" + if cookie, err := r.Cookie(cookieName); err == nil && strings.TrimSpace(cookie.Value) != "" { + return cookie.Value + } + visitorID := services.RandomPublicToken(32) + http.SetCookie(w, &http.Cookie{ + Name: cookieName, + Value: visitorID, + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Secure: r.TLS != nil, + Expires: time.Now().AddDate(1, 0, 0), + }) + return visitorID +} + +func isEmojiFile(name string) bool { + ext := strings.ToLower(filepath.Ext(name)) + return ext == ".svg" || ext == ".webp" || ext == ".png" || ext == ".jpg" || ext == ".jpeg" || ext == ".gif" +} + +func emojiTabLabel(id string) string { + label := strings.NewReplacer("-", " ", "_", " ").Replace(id) + if label == "" { + return "Emoji" + } + return strings.ToUpper(label[:1]) + label[1:] +} + +func emojiLabel(id string) string { + base := strings.TrimSuffix(filepath.Base(id), filepath.Ext(id)) + return strings.ReplaceAll(base, "-", " ") +} + +func emojiURL(id string) string { + parts := strings.Split(id, "/") + if len(parts) != 2 { + return "" + } + return "/emoji/" + url.PathEscape(parts[0]) + "/" + url.PathEscape(parts[1]) +} + +func writeJSON(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(value) +} + func (a *App) isBoxUnlocked(r *http.Request, box services.Box) bool { if !a.uploadService.IsProtected(box) { return true diff --git a/backend/libs/handlers/static.go b/backend/libs/handlers/static.go index cf51890..ab882b1 100644 --- a/backend/libs/handlers/static.go +++ b/backend/libs/handlers/static.go @@ -2,6 +2,7 @@ package handlers import ( "net/http" + "os" "path/filepath" "strings" ) @@ -15,6 +16,24 @@ func (a *App) Static() http.Handler { }) } +func (a *App) EmojiAsset(w http.ResponseWriter, r *http.Request) { + pack := strings.TrimSpace(r.PathValue("pack")) + file := strings.TrimSpace(r.PathValue("file")) + if pack == "" || file == "" || strings.Contains(pack, "/") || strings.Contains(pack, "\\") || strings.Contains(pack, "..") || strings.Contains(file, "/") || strings.Contains(file, "\\") || strings.Contains(file, "..") || !isEmojiFile(file) { + http.NotFound(w, r) + return + } + + path := filepath.Join(a.emojiRoot(), pack, file) + info, err := os.Stat(path) + if err != nil || info.IsDir() { + http.NotFound(w, r) + return + } + setStaticCacheHeaders(w, r.URL.Path) + http.ServeFile(w, r, path) +} + func setStaticCacheHeaders(w http.ResponseWriter, path string) { ext := strings.ToLower(filepath.Ext(path)) diff --git a/backend/libs/handlers/upload_stage3_test.go b/backend/libs/handlers/upload_stage3_test.go index f812c18..09d7128 100644 --- a/backend/libs/handlers/upload_stage3_test.go +++ b/backend/libs/handlers/upload_stage3_test.go @@ -46,6 +46,42 @@ func TestUploadJSONIncludesManageURLsAndAcceptsShareXField(t *testing.T) { } } +func TestFileReactionCanBeAddedOncePerVisitor(t *testing.T) { + app, cleanup := newTestApp(t) + defer cleanup() + + payload := uploadThroughApp(t, app) + if len(payload.Files) != 1 { + t.Fatalf("uploaded files = %d", len(payload.Files)) + } + + request := httptest.NewRequest(http.MethodPost, "/d/"+payload.BoxID+"/f/"+payload.Files[0].ID+"/react", strings.NewReader("emoji_id=openmoji/1F600.svg")) + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + request.SetPathValue("boxID", payload.BoxID) + request.SetPathValue("fileID", payload.Files[0].ID) + response := httptest.NewRecorder() + app.ReactToFile(response, request) + if response.Code != http.StatusCreated { + t.Fatalf("first reaction status = %d, body = %s", response.Code, response.Body.String()) + } + if !strings.Contains(response.Body.String(), `"count":1`) { + t.Fatalf("reaction response missing count: %s", response.Body.String()) + } + + retry := httptest.NewRequest(http.MethodPost, "/d/"+payload.BoxID+"/f/"+payload.Files[0].ID+"/react", strings.NewReader("emoji_id=openmoji/1F600.svg")) + retry.Header.Set("Content-Type", "application/x-www-form-urlencoded") + retry.SetPathValue("boxID", payload.BoxID) + retry.SetPathValue("fileID", payload.Files[0].ID) + for _, cookie := range response.Result().Cookies() { + retry.AddCookie(cookie) + } + retryResponse := httptest.NewRecorder() + app.ReactToFile(retryResponse, retry) + if retryResponse.Code != http.StatusConflict { + t.Fatalf("second reaction status = %d, body = %s", retryResponse.Code, retryResponse.Body.String()) + } +} + func TestUploadTextResponseReturnsOnlyBoxURL(t *testing.T) { app, cleanup := newTestApp(t) defer cleanup() @@ -198,6 +234,14 @@ func newTestApp(t *testing.T) (*App, func()) { if err != nil { t.Fatalf("NewUploadService returned error: %v", err) } + if err := os.MkdirAll(filepath.Join(cfg.DataDir, "emoji", "openmoji"), 0o755); err != nil { + service.Close() + t.Fatalf("create emoji test dir: %v", err) + } + if err := os.WriteFile(filepath.Join(cfg.DataDir, "emoji", "openmoji", "1F600.svg"), []byte(``), 0o644); err != nil { + service.Close() + t.Fatalf("write emoji test file: %v", err) + } renderer, err := web.NewRenderer(cfg.TemplateDir, cfg.AppName, cfg.AppVersion, cfg.BaseURL) if err != nil { service.Close() @@ -213,12 +257,17 @@ func newTestApp(t *testing.T) (*App, func()) { service.Close() t.Fatalf("NewSettingsService returned error: %v", err) } + reactionService, err := services.NewReactionService(service.DB()) + if err != nil { + service.Close() + t.Fatalf("NewReactionService returned error: %v", err) + } banService, err := services.NewBanService(service.DB()) if err != nil { service.Close() t.Fatalf("NewBanService returned error: %v", err) } - return NewApp(cfg, logger, renderer, service, authService, settingsService, banService), func() { + return NewApp(cfg, logger, renderer, service, authService, settingsService, reactionService, banService), func() { if err := service.Close(); err != nil { t.Fatalf("Close returned error: %v", err) } diff --git a/backend/libs/httpserver/server.go b/backend/libs/httpserver/server.go index 717e161..f1a014b 100644 --- a/backend/libs/httpserver/server.go +++ b/backend/libs/httpserver/server.go @@ -32,13 +32,18 @@ func New(cfg config.Config, logger *slog.Logger) (*http.Server, error) { uploadService.Close() return nil, err } + reactionService, err := services.NewReactionService(uploadService.DB()) + if err != nil { + uploadService.Close() + return nil, err + } banService, err := services.NewBanService(uploadService.DB()) if err != nil { uploadService.Close() return nil, err } stopJobs := jobs.StartAll(cfg, logger, uploadService, banService) - app := handlers.NewApp(cfg, logger, renderer, uploadService, authService, settingsService, banService) + app := handlers.NewApp(cfg, logger, renderer, uploadService, authService, settingsService, reactionService, banService) router := http.NewServeMux() app.RegisterRoutes(router) diff --git a/backend/libs/services/reactions.go b/backend/libs/services/reactions.go new file mode 100644 index 0000000..e546754 --- /dev/null +++ b/backend/libs/services/reactions.go @@ -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[:]) +} diff --git a/backend/libs/services/upload.go b/backend/libs/services/upload.go index 6f7219c..afbea2c 100644 --- a/backend/libs/services/upload.go +++ b/backend/libs/services/upload.go @@ -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) diff --git a/backend/static/css/30-download.css b/backend/static/css/30-download.css index 47f6854..31ca5dd 100644 --- a/backend/static/css/30-download.css +++ b/backend/static/css/30-download.css @@ -65,6 +65,242 @@ .file-card { position: relative; + padding-bottom: 2.6rem; +} + +.file-reaction-dock { + position: absolute; + right: 0.65rem; + bottom: 0.55rem; + z-index: 2; + display: inline-flex; + align-items: center; + justify-content: flex-end; + max-width: calc(100% - 1.3rem); + gap: 0.35rem; + pointer-events: none; +} + +.file-reactions { + display: inline-flex; + align-items: center; + justify-content: flex-end; + min-width: 0; + gap: 0.25rem; + flex-wrap: wrap; +} + +.reaction-pill { + display: inline-flex; + align-items: center; + gap: 0.2rem; + min-height: 1.6rem; + padding: 0.16rem 0.38rem; + border: 1px solid color-mix(in srgb, var(--border) 84%, var(--primary)); + border-radius: 999px; + background: color-mix(in srgb, var(--card) 88%, #000); + color: var(--foreground); + font-size: 0.75rem; + font-weight: 700; + box-shadow: 0 8px 22px rgba(0, 0, 0, 0.24); + pointer-events: auto; +} + +.reaction-pill img { + width: 1rem; + height: 1rem; + display: block; +} + +.reaction-button { + width: 2.1rem; + height: 2.1rem; + display: inline-grid; + place-items: center; + border: 1px solid var(--border); + border-radius: 999px; + background: color-mix(in srgb, var(--card) 92%, #000); + color: var(--foreground); + opacity: 0; + transform: translateY(0.3rem) scale(0.94); + box-shadow: 0 12px 30px rgba(0, 0, 0, 0.32); + transition: opacity 150ms ease, transform 150ms ease, border-color 150ms ease, background 150ms ease; + pointer-events: auto; +} + +.reaction-button svg { + width: 1.15rem; + height: 1.15rem; + fill: none; + stroke: currentColor; + stroke-width: 1.9; + stroke-linecap: round; + stroke-linejoin: round; +} + +.file-card:hover .reaction-button, +.file-card:focus-within .reaction-button, +.reaction-button:focus-visible { + opacity: 1; + transform: translateY(0) scale(1); +} + +.reaction-button:hover, +.reaction-button:focus-visible { + border-color: var(--primary); + background: var(--primary); + color: var(--primary-foreground); +} + +.reaction-picker { + position: fixed; + top: 0; + left: 0; + z-index: 70; + width: min(21rem, calc(100vw - 1rem)); +} + +html.reaction-picker-open, +html.reaction-picker-open body { + overflow: hidden; + touch-action: none; +} + +.reaction-picker[hidden] { + display: none; +} + +.reaction-picker.is-mobile { + inset: 0; + width: auto; + height: 100dvh; + display: grid; + place-items: end center; + overflow: hidden; + padding: 0.75rem 0.75rem max(1.5rem, env(safe-area-inset-bottom)); + background: rgba(0, 0, 0, 0.54); +} + +.reaction-picker-panel { + overflow: hidden; + border: 1px solid var(--border); + border-radius: var(--radius); + background: color-mix(in srgb, var(--card) 97%, #000); + box-shadow: 0 26px 70px rgba(0, 0, 0, 0.52); +} + +.reaction-picker.is-mobile .reaction-picker-panel { + width: min(100%, 34rem); + height: 75dvh; + max-height: 75dvh; + display: flex; + flex-direction: column; +} + +.reaction-picker-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.7rem; + border-bottom: 1px solid var(--border); +} + +.reaction-picker-close { + min-height: 2rem; + padding: 0.3rem 0.55rem; + font-size: 0.75rem; +} + +.reaction-picker-tabs { + display: flex; + gap: 0.35rem; + overflow-x: auto; + padding: 0.55rem 0.7rem 0; +} + +.reaction-tab { + flex: 0 0 auto; + min-height: 1.8rem; + padding: 0.25rem 0.55rem; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--muted); + color: var(--muted-foreground); + font-size: 0.75rem; + font-weight: 700; +} + +.reaction-tab.is-active { + border-color: var(--primary); + background: var(--primary); + color: var(--primary-foreground); +} + +.reaction-search { + display: block; + padding: 0.55rem 0.7rem; +} + +.reaction-search input { + width: 100%; + min-height: 2.15rem; + padding: 0.35rem 0.55rem; +} + +.reaction-grid-wrap { + max-height: 18rem; + overflow: auto; + padding: 0 0.7rem 0.7rem; +} + +.reaction-picker.is-mobile .reaction-grid-wrap { + max-height: none; + flex: 1; + overscroll-behavior: contain; + -webkit-overflow-scrolling: touch; +} + +.reaction-grid { + display: none; + grid-template-columns: repeat(8, minmax(0, 1fr)); + gap: 0.25rem; +} + +.reaction-grid.is-active { + display: grid; +} + +.reaction-picker.is-mobile .reaction-grid { + grid-template-columns: repeat(6, minmax(0, 1fr)); +} + +.reaction-emoji { + aspect-ratio: 1; + display: grid; + place-items: center; + min-width: 0; + padding: 0.18rem; + border: 1px solid transparent; + border-radius: calc(var(--radius) - 0.25rem); + background: transparent; +} + +.reaction-emoji:hover, +.reaction-emoji:focus-visible { + border-color: var(--border); + background: var(--accent); +} + +.reaction-emoji[hidden] { + display: none; +} + +.reaction-emoji img { + width: 100%; + height: 100%; + display: block; + object-fit: contain; } .thumb-link { diff --git a/backend/static/css/90-responsive.css b/backend/static/css/90-responsive.css index 43b3b18..cb9507e 100644 --- a/backend/static/css/90-responsive.css +++ b/backend/static/css/90-responsive.css @@ -220,6 +220,16 @@ grid-template-columns: 1fr; } + .file-reaction-dock { + right: 0.5rem; + bottom: 0.45rem; + } + + .reaction-button { + opacity: 1; + transform: none; + } + .file-progress-side { width: 100%; } diff --git a/backend/static/js/12-reactions.js b/backend/static/js/12-reactions.js new file mode 100644 index 0000000..c66e8ba --- /dev/null +++ b/backend/static/js/12-reactions.js @@ -0,0 +1,198 @@ +(function () { + const picker = document.querySelector("[data-reaction-picker]"); + const panel = picker ? picker.querySelector(".reaction-picker-panel") : null; + const search = picker ? picker.querySelector("[data-reaction-search]") : null; + const closeButton = picker ? picker.querySelector("[data-reaction-close]") : null; + const tabs = picker ? Array.from(picker.querySelectorAll("[data-reaction-tab]")) : []; + const panels = picker ? Array.from(picker.querySelectorAll("[data-reaction-panel]")) : []; + + let activeButton = null; + let activeCard = null; + + document.querySelectorAll("[data-reaction-button]").forEach((button) => { + button.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + openPicker(button); + }); + }); + + if (!picker || !panel) { + return; + } + + // Aurora's glass card uses backdrop-filter, and the main content animates + // with transform. Both can create a containing block for fixed descendants, + // so keep the floating picker at body level where viewport coordinates mean + // what they say. + document.body.appendChild(picker); + + picker.addEventListener("click", (event) => { + if (event.target === picker) { + closePicker(); + } + }); + + panel.addEventListener("click", async (event) => { + const emoji = event.target.closest("[data-emoji-id]"); + if (!emoji || !activeButton || !activeCard) { + return; + } + await submitReaction(emoji); + }); + + tabs.forEach((tab) => { + tab.addEventListener("click", () => { + setActiveTab(tab.dataset.reactionTab); + }); + }); + + if (search) { + search.addEventListener("input", () => filterEmoji(search.value)); + } + + if (closeButton) { + closeButton.addEventListener("click", closePicker); + } + + document.addEventListener("click", (event) => { + if (picker.hidden) { + return; + } + if (panel.contains(event.target) || event.target.closest("[data-reaction-button]")) { + return; + } + closePicker(); + }); + + document.addEventListener("keydown", (event) => { + if (event.key === "Escape") { + closePicker(); + } + }); + + window.addEventListener("resize", () => { + if (activeButton && !picker.hidden) { + positionPicker(activeButton); + } + }); + + function openPicker(button) { + activeButton = button; + activeCard = button.closest("[data-reaction-card]"); + picker.hidden = false; + picker.classList.add("is-open"); + if (search) { + search.value = ""; + filterEmoji(""); + } + positionPicker(button); + } + + function closePicker() { + picker.hidden = true; + picker.classList.remove("is-open", "is-mobile"); + document.documentElement.classList.remove("reaction-picker-open"); + picker.style.left = ""; + picker.style.top = ""; + activeButton = null; + activeCard = null; + } + + function positionPicker(button) { + if (isMobilePicker()) { + picker.classList.add("is-mobile"); + document.documentElement.classList.add("reaction-picker-open"); + picker.style.left = "0px"; + picker.style.top = "0px"; + return; + } + + picker.classList.remove("is-mobile"); + document.documentElement.classList.remove("reaction-picker-open"); + picker.style.left = "0px"; + picker.style.top = "0px"; + const buttonRect = button.getBoundingClientRect(); + const pickerRect = panel.getBoundingClientRect(); + const margin = 10; + const preferredLeft = buttonRect.left + (buttonRect.width / 2) - (pickerRect.width / 2); + const preferredTop = buttonRect.bottom + 8; + const left = Math.min(Math.max(margin, preferredLeft), window.innerWidth - pickerRect.width - margin); + const top = Math.min(Math.max(margin, preferredTop), window.innerHeight - pickerRect.height - margin); + picker.style.left = `${left}px`; + picker.style.top = `${top}px`; + } + + function isMobilePicker() { + return window.matchMedia("(max-width: 820px), (pointer: coarse)").matches; + } + + function setActiveTab(tabID) { + tabs.forEach((tab) => { + const active = tab.dataset.reactionTab === tabID; + tab.classList.toggle("is-active", active); + tab.setAttribute("aria-selected", active ? "true" : "false"); + }); + panels.forEach((item) => { + item.classList.toggle("is-active", item.dataset.reactionPanel === tabID); + }); + } + + function filterEmoji(value) { + const query = value.trim().toLowerCase(); + picker.querySelectorAll("[data-emoji-id]").forEach((button) => { + const haystack = `${button.dataset.emojiId} ${button.dataset.emojiLabel}`.toLowerCase(); + button.hidden = query !== "" && !haystack.includes(query); + }); + } + + async function submitReaction(emoji) { + const body = new URLSearchParams(); + body.set("emoji_id", emoji.dataset.emojiId); + + activeButton.disabled = true; + const response = await fetch(activeButton.dataset.reactUrl, { + method: "POST", + headers: { + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded", + }, + body, + }); + + if (!response.ok) { + activeButton.disabled = false; + closePicker(); + return; + } + + const payload = await response.json(); + renderReactions(activeCard, payload.reactions || []); + activeButton.remove(); + closePicker(); + } + + function renderReactions(card, reactions) { + const list = card.querySelector("[data-reaction-list]"); + if (!list) { + return; + } + list.replaceChildren(); + reactions.forEach((reaction) => { + const pill = document.createElement("span"); + pill.className = "reaction-pill"; + pill.title = reaction.label || reaction.emojiId; + + const image = document.createElement("img"); + image.src = reaction.url; + image.alt = reaction.label || reaction.emojiId; + image.loading = "lazy"; + + const count = document.createElement("span"); + count.textContent = reaction.count; + + pill.append(image, count); + list.append(pill); + }); + } +})(); diff --git a/backend/templates/layouts/base.html b/backend/templates/layouts/base.html index 8394a35..5043ebb 100644 --- a/backend/templates/layouts/base.html +++ b/backend/templates/layouts/base.html @@ -31,6 +31,7 @@ + diff --git a/backend/templates/pages/download.html b/backend/templates/pages/download.html index aa558e5..b54451e 100644 --- a/backend/templates/pages/download.html +++ b/backend/templates/pages/download.html @@ -44,7 +44,7 @@
{{range .Data.Files}} -
+
@@ -64,11 +64,54 @@ Download
+
+
+ {{range .Reactions}} + + {{.Label}} + {{.Count}} + + {{end}} +
+ {{if not .Reacted}} + + {{end}} +
{{end}} {{end}} {{if not .Data.Locked}} +