mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* chore(claude): deny hand-edits to generated files via permissions.deny CLAUDE.md already says the sqlc, protocol and tauri-typegen outputs are never hand-edited; this turns the sentence into a permission rule so the Edit/Write tools refuse those paths outright. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jwaz4CHGAz85Rpypjvto5a * chore(lint): switch-exhaustiveness-check on the client, default branch counts as exhaustive A switch over a string union that misses a member is a silent drop, not a type error. Every existing default-less switch already covers its union, so this adds no exceptions; the four switches with a default keep it as the deliberate catch-all. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jwaz4CHGAz85Rpypjvto5a * chore(lint): enable errorlint and exhaustive in golangci and fix the 110 hits errorlint: 68 fmt.Errorf sites wrapped the inner error with %v, which hid it from errors.Is/As upstream — now %w; 6 == / != comparisons on sentinel errors become errors.Is (the recover() branch in the router asserts the recovered value is an error first); 36 ClientError type assertions become errors.As, so a wrapped ClientError still reaches the client with its code. Three test assertions the autofixer inverted (!ok || code mismatch) are restored by hand. exhaustive (default-signifies-exhaustive): one hit, the hub simulation's FaultStatus switch — FaultOK moves from an if-guard into the switch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jwaz4CHGAz85Rpypjvto5a * chore(claude): path-scoped rules for the three generated-code workflows .claude/rules/{db-change,protocol-change,gendocs}.md load only when Claude reads a matching source-of-truth file, so the db-change / protocol-change skills and the gendocs regeneration step surface at the moment they apply instead of relying on the CLAUDE.md table being remembered. .gitignore whitelists .claude/rules/ next to skills/, workflows/ and settings.json. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jwaz4CHGAz85Rpypjvto5a --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
199 lines
7.5 KiB
Go
199 lines
7.5 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/J3vb/OwnCord/Server/db"
|
|
"github.com/J3vb/OwnCord/Server/permissions"
|
|
)
|
|
|
|
// EmojiService owns the server-wide custom emoji set: who may change it, what
|
|
// a shortcode is allowed to look like, and the uniqueness rule.
|
|
//
|
|
// Emoji are deliberately gated on MANAGE_SERVER rather than on a new bit.
|
|
// Adding a permission bit is a schema-visible, forever decision, and "who may
|
|
// change server-wide branding" is exactly what MANAGE_SERVER already answers
|
|
// for the server name, icon and settings. Reading is not gated at all: an
|
|
// emoji nobody can render is not an emoji, and the set is server-wide with no
|
|
// per-channel scope to leak.
|
|
type EmojiService struct {
|
|
st Store
|
|
perms *PermissionService
|
|
}
|
|
|
|
// NewEmojiService creates an EmojiService.
|
|
func NewEmojiService(st Store, perms *PermissionService) *EmojiService {
|
|
return &EmojiService{st: st, perms: perms}
|
|
}
|
|
|
|
const (
|
|
// MinShortcodeLen / MaxShortcodeLen bound `:name:`. Two characters is the
|
|
// shortest thing worth typing; 32 keeps a shortcode from dominating the
|
|
// composer, the picker row and the reaction pill it ends up in.
|
|
MinShortcodeLen = 2
|
|
// MaxShortcodeLen is the upper bound on a shortcode's length.
|
|
MaxShortcodeLen = 32
|
|
// MaxEmojiCount bounds the set. Every connected client holds the whole list
|
|
// in memory and re-receives it on every emoji_update, so this is the cap on
|
|
// what one MANAGE_SERVER holder can push to every session at once.
|
|
MaxEmojiCount = 200
|
|
)
|
|
|
|
// EmojiImageURL is the server-relative path the image bytes of an emoji are
|
|
// served from. Defined once here because three surfaces have to agree on it:
|
|
// the REST list response, the emoji_update broadcast, and the docs.
|
|
func EmojiImageURL(id int64) string {
|
|
return fmt.Sprintf("/api/v1/emoji/%d/image", id)
|
|
}
|
|
|
|
// shortcodeRe is the exact spelling a shortcode may take. Lowercase only, so
|
|
// the table's plain UNIQUE index enforces case-insensitive uniqueness without
|
|
// a COLLATE change; no leading/trailing colon, because the colons are the
|
|
// delimiter and not part of the name.
|
|
var shortcodeRe = regexp.MustCompile(`^[a-z0-9_]{2,32}$`)
|
|
|
|
// NormalizeShortcode strips the optional surrounding colons and lowercases the
|
|
// result, so `:WAVE:`, `WAVE` and `wave` are all the same shortcode. It does
|
|
// not validate -- ValidateShortcode does that on the normalized form.
|
|
func NormalizeShortcode(raw string) string {
|
|
s := strings.ToLower(strings.TrimSpace(raw))
|
|
s = strings.TrimPrefix(s, ":")
|
|
s = strings.TrimSuffix(s, ":")
|
|
return s
|
|
}
|
|
|
|
// ValidateShortcode normalizes and checks a shortcode, returning the canonical
|
|
// (lowercase, colon-free) form.
|
|
func ValidateShortcode(raw string) (string, error) {
|
|
s := NormalizeShortcode(raw)
|
|
if s == "" {
|
|
return "", fmt.Errorf("%w: shortcode is required", ErrBadRequest)
|
|
}
|
|
if !shortcodeRe.MatchString(s) {
|
|
return "", fmt.Errorf(
|
|
"%w: shortcode must be %d-%d characters of a-z, 0-9 or underscore",
|
|
ErrBadRequest, MinShortcodeLen, MaxShortcodeLen)
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// RequireManage reports whether actorID may add or remove emoji. Handlers call
|
|
// it BEFORE reading an upload body, so a user without the bit is refused
|
|
// without first being allowed to spend the server's disk on a multipart parse.
|
|
func (s *EmojiService) RequireManage(ctx context.Context, actorID int64) error {
|
|
if s.perms == nil {
|
|
return fmt.Errorf("%w: permission service unavailable", ErrForbidden)
|
|
}
|
|
role, err := s.perms.GetRoleForUser(ctx, actorID)
|
|
if err != nil || role == nil {
|
|
return fmt.Errorf("%w: failed to load actor role", ErrForbidden)
|
|
}
|
|
if !permissions.HasServerPerm(role.Permissions, permissions.ManageServer) {
|
|
return fmt.Errorf("%w: missing %s permission", ErrForbidden, permissions.Name(permissions.ManageServer))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// List returns every custom emoji, ordered by shortcode. Ungated.
|
|
func (s *EmojiService) List(ctx context.Context) ([]*db.Emoji, error) {
|
|
list, err := s.st.ListEmoji(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: failed to list emoji: %w", ErrInternal, err)
|
|
}
|
|
return list, nil
|
|
}
|
|
|
|
// Create records an already-stored image as a custom emoji. The caller is
|
|
// responsible for having validated and stored the bytes; this owns the
|
|
// permission gate, the shortcode rules, the count cap and the audit entry.
|
|
//
|
|
// A shortcode collision is ErrConflict, not ErrBadRequest: the request was
|
|
// well-formed, the name is simply taken. Callers delete the orphaned file when
|
|
// this returns an error.
|
|
func (s *EmojiService) Create(ctx context.Context, actorID int64, rawShortcode, storedAs, mimeType string) (*db.Emoji, error) {
|
|
if err := s.RequireManage(ctx, actorID); err != nil {
|
|
return nil, err
|
|
}
|
|
shortcode, err := ValidateShortcode(rawShortcode)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
existing, err := s.st.GetEmojiByShortcode(ctx, shortcode)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: failed to check shortcode: %w", ErrInternal, err)
|
|
}
|
|
if existing != nil {
|
|
return nil, fmt.Errorf("%w: an emoji named :%s: already exists", ErrConflict, shortcode)
|
|
}
|
|
|
|
current, err := s.st.ListEmoji(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: failed to count emoji: %w", ErrInternal, err)
|
|
}
|
|
if len(current) >= MaxEmojiCount {
|
|
return nil, fmt.Errorf("%w: this server already has the maximum of %d emoji", ErrBadRequest, MaxEmojiCount)
|
|
}
|
|
|
|
created, err := s.st.CreateEmoji(ctx, shortcode, storedAs, mimeType, actorID)
|
|
if err != nil {
|
|
if db.IsUniqueConstraintError(err) {
|
|
// Lost a race with another Create between the check above and this
|
|
// INSERT -- report the conflict the check would have caught, not a
|
|
// server fault.
|
|
return nil, fmt.Errorf("%w: an emoji named :%s: already exists", ErrConflict, shortcode)
|
|
}
|
|
return nil, fmt.Errorf("%w: failed to create emoji: %w", ErrInternal, err)
|
|
}
|
|
|
|
db.WriteAudit(context.WithoutCancel(ctx), s.st, actorID, "emoji_create", "emoji", created.ID,
|
|
fmt.Sprintf("uploaded emoji :%s: (%s)", shortcode, mimeType))
|
|
slog.Info("custom emoji created", "actor_id", actorID, "emoji_id", created.ID, "shortcode", shortcode)
|
|
return created, nil
|
|
}
|
|
|
|
// Delete removes an emoji and returns the row that was removed, so the caller
|
|
// can unlink the stored file. The row is returned even though it no longer
|
|
// exists: the storage id is only knowable from it.
|
|
func (s *EmojiService) Delete(ctx context.Context, actorID, emojiID int64) (*db.Emoji, error) {
|
|
if err := s.RequireManage(ctx, actorID); err != nil {
|
|
return nil, err
|
|
}
|
|
existing, err := s.st.GetEmoji(ctx, emojiID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: failed to load emoji: %w", ErrInternal, err)
|
|
}
|
|
if existing == nil {
|
|
return nil, fmt.Errorf("%w: emoji not found", ErrNotFound)
|
|
}
|
|
deleted, err := s.st.DeleteEmoji(ctx, emojiID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: failed to delete emoji: %w", ErrInternal, err)
|
|
}
|
|
if !deleted {
|
|
// Lost a race with another delete -- report it as the 404 it now is.
|
|
return nil, fmt.Errorf("%w: emoji not found", ErrNotFound)
|
|
}
|
|
|
|
db.WriteAudit(context.WithoutCancel(ctx), s.st, actorID, "emoji_delete", "emoji", emojiID,
|
|
fmt.Sprintf("deleted emoji :%s:", existing.Shortcode))
|
|
slog.Info("custom emoji deleted", "actor_id", actorID, "emoji_id", emojiID, "shortcode", existing.Shortcode)
|
|
return existing, nil
|
|
}
|
|
|
|
// Get returns one emoji by id, or ErrNotFound. Used by the image route.
|
|
func (s *EmojiService) Get(ctx context.Context, emojiID int64) (*db.Emoji, error) {
|
|
e, err := s.st.GetEmoji(ctx, emojiID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: failed to load emoji: %w", ErrInternal, err)
|
|
}
|
|
if e == nil {
|
|
return nil, fmt.Errorf("%w: emoji not found", ErrNotFound)
|
|
}
|
|
return e, nil
|
|
}
|