diff --git a/.woodpecker/.backend.yml b/.woodpecker/.backend.yml new file mode 100644 index 0000000..07b7898 --- /dev/null +++ b/.woodpecker/.backend.yml @@ -0,0 +1,13 @@ +when: + branch: + exclude: stable + +steps: + check: + image: golang:alpine + commands: + - apk update && apk add curl vips-dev build-base + - make backend + # Install golangci-lint + - curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.55.2 + - golangci-lint run diff --git a/.woodpecker/.frontend.yml b/.woodpecker/.frontend.yml new file mode 100644 index 0000000..bedfa1f --- /dev/null +++ b/.woodpecker/.frontend.yml @@ -0,0 +1,20 @@ +when: + branch: + exclude: stable + +steps: + check: + image: node + directory: frontend + environment: # SvelteKit expects these in the environment during build time. + - PRIVATE_SENTRY_DSN= + - PUBLIC_BASE_URL=http://pronouns.localhost + - PUBLIC_MEDIA_URL=http://pronouns.localhost/media + - PUBLIC_SHORT_BASE=http://prns.localhost + - PUBLIC_HCAPTCHA_SITEKEY=non_existent_sitekey + commands: + - corepack enable + - pnpm install + - pnpm check + - pnpm lint + - pnpm build diff --git a/Makefile b/Makefile index 1b15eff..8a7c089 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ all: generate backend frontend .PHONY: backend backend: - go build -v -o pronouns -ldflags="-buildid= -X codeberg.org/pronounscc/pronouns.cc/backend/server.Revision=`git rev-parse --short HEAD` -X codeberg.org/pronounscc/pronouns.cc/backend/server.Tag=`git describe --tags --long`" . + go build -v -o pronouns -ldflags="-buildid= -X codeberg.org/pronounscc/pronouns.cc/backend/server.Revision=`git rev-parse --short HEAD` -X codeberg.org/pronounscc/pronouns.cc/backend/server.Tag=`git describe --tags --long --always`" . .PHONY: generate generate: diff --git a/backend/db/export.go b/backend/db/export.go index 6141aac..5095445 100644 --- a/backend/db/export.go +++ b/backend/db/export.go @@ -79,7 +79,7 @@ func (db *DB) CreateExport(ctx context.Context, userID xid.ID, filename string, return de, errors.Wrap(err, "building query") } - pgxscan.Get(ctx, db, &de, sql, args...) + err = pgxscan.Get(ctx, db, &de, sql, args...) if err != nil { return de, errors.Wrap(err, "executing sql") } diff --git a/backend/db/invites.go b/backend/db/invites.go index d56662e..d13ab0e 100644 --- a/backend/db/invites.go +++ b/backend/db/invites.go @@ -6,6 +6,7 @@ import ( "encoding/base64" "time" + "codeberg.org/pronounscc/pronouns.cc/backend/log" "emperror.dev/errors" "github.com/georgysavva/scany/v2/pgxscan" "github.com/jackc/pgx/v5" @@ -43,7 +44,12 @@ func (db *DB) CreateInvite(ctx context.Context, userID xid.ID) (i Invite, err er if err != nil { return i, errors.Wrap(err, "beginning transaction") } - defer tx.Rollback(ctx) + defer func() { + err := tx.Rollback(ctx) + if err != nil && !errors.Is(err, pgx.ErrTxClosed) { + log.Error("rolling back transaction:", err) + } + }() var maxInvites, inviteCount int err = tx.QueryRow(ctx, "SELECT max_invites FROM users WHERE id = $1", userID).Scan(&maxInvites) diff --git a/backend/db/member.go b/backend/db/member.go index f301c89..a50c431 100644 --- a/backend/db/member.go +++ b/backend/db/member.go @@ -7,6 +7,7 @@ import ( "time" "codeberg.org/pronounscc/pronouns.cc/backend/common" + "codeberg.org/pronounscc/pronouns.cc/backend/log" "emperror.dev/errors" "github.com/Masterminds/squirrel" "github.com/georgysavva/scany/v2/pgxscan" @@ -41,12 +42,14 @@ const ( ) // member names must match this regex -var memberNameRegex = regexp.MustCompile("^[^@\\?!#/\\\\[\\]\"\\{\\}'$%&()+<=>^|~`,]{1,100}$") +var memberNameRegex = regexp.MustCompile("^[^@\\?!#/\\\\[\\]\"\\{\\}'$%&()+<=>^|~`,\\*]{1,100}$") // List of member names that cannot be used because they would break routing or be inaccessible due to page conflicts. var invalidMemberNames = []string{ + // these break routing outright ".", "..", + // the user edit page lives at `/@{username}/edit`, so a member named "edit" would be inaccessible "edit", } @@ -285,7 +288,12 @@ func (db *DB) RerollMemberSID(ctx context.Context, userID, memberID xid.ID) (new if err != nil { return "", errors.Wrap(err, "beginning transaction") } - defer tx.Rollback(ctx) + defer func() { + err := tx.Rollback(ctx) + if err != nil && !errors.Is(err, pgx.ErrTxClosed) { + log.Error("rolling back transaction:", err) + } + }() sql, args, err := sq.Update("members"). Set("sid", squirrel.Expr("find_free_member_sid()")). diff --git a/backend/icons/icons.go b/backend/icons/icons.go index 3bc0e80..84355f3 100644 --- a/backend/icons/icons.go +++ b/backend/icons/icons.go @@ -1,1968 +1,1964 @@ // Generated code. DO NOT EDIT package icons -var icons = [...]string{ - "123", - "alarm-fill", - "alarm", - "align-bottom", - "align-center", - "align-end", - "align-middle", - "align-start", - "align-top", - "alt", - "app-indicator", - "app", - "archive-fill", - "archive", - "arrow-90deg-down", - "arrow-90deg-left", - "arrow-90deg-right", - "arrow-90deg-up", - "arrow-bar-down", - "arrow-bar-left", - "arrow-bar-right", - "arrow-bar-up", - "arrow-clockwise", - "arrow-counterclockwise", - "arrow-down-circle-fill", - "arrow-down-circle", - "arrow-down-left-circle-fill", - "arrow-down-left-circle", - "arrow-down-left-square-fill", - "arrow-down-left-square", - "arrow-down-left", - "arrow-down-right-circle-fill", - "arrow-down-right-circle", - "arrow-down-right-square-fill", - "arrow-down-right-square", - "arrow-down-right", - "arrow-down-short", - "arrow-down-square-fill", - "arrow-down-square", - "arrow-down-up", - "arrow-down", - "arrow-left-circle-fill", - "arrow-left-circle", - "arrow-left-right", - "arrow-left-short", - "arrow-left-square-fill", - "arrow-left-square", - "arrow-left", - "arrow-repeat", - "arrow-return-left", - "arrow-return-right", - "arrow-right-circle-fill", - "arrow-right-circle", - "arrow-right-short", - "arrow-right-square-fill", - "arrow-right-square", - "arrow-right", - "arrow-up-circle-fill", - "arrow-up-circle", - "arrow-up-left-circle-fill", - "arrow-up-left-circle", - "arrow-up-left-square-fill", - "arrow-up-left-square", - "arrow-up-left", - "arrow-up-right-circle-fill", - "arrow-up-right-circle", - "arrow-up-right-square-fill", - "arrow-up-right-square", - "arrow-up-right", - "arrow-up-short", - "arrow-up-square-fill", - "arrow-up-square", - "arrow-up", - "arrows-angle-contract", - "arrows-angle-expand", - "arrows-collapse", - "arrows-expand", - "arrows-fullscreen", - "arrows-move", - "aspect-ratio-fill", - "aspect-ratio", - "asterisk", - "at", - "award-fill", - "award", - "back", - "backspace-fill", - "backspace-reverse-fill", - "backspace-reverse", - "backspace", - "badge-3d-fill", - "badge-3d", - "badge-4k-fill", - "badge-4k", - "badge-8k-fill", - "badge-8k", - "badge-ad-fill", - "badge-ad", - "badge-ar-fill", - "badge-ar", - "badge-cc-fill", - "badge-cc", - "badge-hd-fill", - "badge-hd", - "badge-tm-fill", - "badge-tm", - "badge-vo-fill", - "badge-vo", - "badge-vr-fill", - "badge-vr", - "badge-wc-fill", - "badge-wc", - "bag-check-fill", - "bag-check", - "bag-dash-fill", - "bag-dash", - "bag-fill", - "bag-plus-fill", - "bag-plus", - "bag-x-fill", - "bag-x", - "bag", - "bar-chart-fill", - "bar-chart-line-fill", - "bar-chart-line", - "bar-chart-steps", - "bar-chart", - "basket-fill", - "basket", - "basket2-fill", - "basket2", - "basket3-fill", - "basket3", - "battery-charging", - "battery-full", - "battery-half", - "battery", - "bell-fill", - "bell", - "bezier", - "bezier2", - "bicycle", - "binoculars-fill", - "binoculars", - "blockquote-left", - "blockquote-right", - "book-fill", - "book-half", - "book", - "bookmark-check-fill", - "bookmark-check", - "bookmark-dash-fill", - "bookmark-dash", - "bookmark-fill", - "bookmark-heart-fill", - "bookmark-heart", - "bookmark-plus-fill", - "bookmark-plus", - "bookmark-star-fill", - "bookmark-star", - "bookmark-x-fill", - "bookmark-x", - "bookmark", - "bookmarks-fill", - "bookmarks", - "bookshelf", - "bootstrap-fill", - "bootstrap-reboot", - "bootstrap", - "border-all", - "border-bottom", - "border-center", - "border-inner", - "border-left", - "border-middle", - "border-outer", - "border-right", - "border-style", - "border-top", - "border-width", - "border", - "bounding-box-circles", - "bounding-box", - "box-arrow-down-left", - "box-arrow-down-right", - "box-arrow-down", - "box-arrow-in-down-left", - "box-arrow-in-down-right", - "box-arrow-in-down", - "box-arrow-in-left", - "box-arrow-in-right", - "box-arrow-in-up-left", - "box-arrow-in-up-right", - "box-arrow-in-up", - "box-arrow-left", - "box-arrow-right", - "box-arrow-up-left", - "box-arrow-up-right", - "box-arrow-up", - "box-seam", - "box", - "braces", - "bricks", - "briefcase-fill", - "briefcase", - "brightness-alt-high-fill", - "brightness-alt-high", - "brightness-alt-low-fill", - "brightness-alt-low", - "brightness-high-fill", - "brightness-high", - "brightness-low-fill", - "brightness-low", - "broadcast-pin", - "broadcast", - "brush-fill", - "brush", - "bucket-fill", - "bucket", - "bug-fill", - "bug", - "building", - "bullseye", - "calculator-fill", - "calculator", - "calendar-check-fill", - "calendar-check", - "calendar-date-fill", - "calendar-date", - "calendar-day-fill", - "calendar-day", - "calendar-event-fill", - "calendar-event", - "calendar-fill", - "calendar-minus-fill", - "calendar-minus", - "calendar-month-fill", - "calendar-month", - "calendar-plus-fill", - "calendar-plus", - "calendar-range-fill", - "calendar-range", - "calendar-week-fill", - "calendar-week", - "calendar-x-fill", - "calendar-x", - "calendar", - "calendar2-check-fill", - "calendar2-check", - "calendar2-date-fill", - "calendar2-date", - "calendar2-day-fill", - "calendar2-day", - "calendar2-event-fill", - "calendar2-event", - "calendar2-fill", - "calendar2-minus-fill", - "calendar2-minus", - "calendar2-month-fill", - "calendar2-month", - "calendar2-plus-fill", - "calendar2-plus", - "calendar2-range-fill", - "calendar2-range", - "calendar2-week-fill", - "calendar2-week", - "calendar2-x-fill", - "calendar2-x", - "calendar2", - "calendar3-event-fill", - "calendar3-event", - "calendar3-fill", - "calendar3-range-fill", - "calendar3-range", - "calendar3-week-fill", - "calendar3-week", - "calendar3", - "calendar4-event", - "calendar4-range", - "calendar4-week", - "calendar4", - "camera-fill", - "camera-reels-fill", - "camera-reels", - "camera-video-fill", - "camera-video-off-fill", - "camera-video-off", - "camera-video", - "camera", - "camera2", - "capslock-fill", - "capslock", - "card-checklist", - "card-heading", - "card-image", - "card-list", - "card-text", - "caret-down-fill", - "caret-down-square-fill", - "caret-down-square", - "caret-down", - "caret-left-fill", - "caret-left-square-fill", - "caret-left-square", - "caret-left", - "caret-right-fill", - "caret-right-square-fill", - "caret-right-square", - "caret-right", - "caret-up-fill", - "caret-up-square-fill", - "caret-up-square", - "caret-up", - "cart-check-fill", - "cart-check", - "cart-dash-fill", - "cart-dash", - "cart-fill", - "cart-plus-fill", - "cart-plus", - "cart-x-fill", - "cart-x", - "cart", - "cart2", - "cart3", - "cart4", - "cash-stack", - "cash", - "cast", - "chat-dots-fill", - "chat-dots", - "chat-fill", - "chat-left-dots-fill", - "chat-left-dots", - "chat-left-fill", - "chat-left-quote-fill", - "chat-left-quote", - "chat-left-text-fill", - "chat-left-text", - "chat-left", - "chat-quote-fill", - "chat-quote", - "chat-right-dots-fill", - "chat-right-dots", - "chat-right-fill", - "chat-right-quote-fill", - "chat-right-quote", - "chat-right-text-fill", - "chat-right-text", - "chat-right", - "chat-square-dots-fill", - "chat-square-dots", - "chat-square-fill", - "chat-square-quote-fill", - "chat-square-quote", - "chat-square-text-fill", - "chat-square-text", - "chat-square", - "chat-text-fill", - "chat-text", - "chat", - "check-all", - "check-circle-fill", - "check-circle", - "check-square-fill", - "check-square", - "check", - "check2-all", - "check2-circle", - "check2-square", - "check2", - "chevron-bar-contract", - "chevron-bar-down", - "chevron-bar-expand", - "chevron-bar-left", - "chevron-bar-right", - "chevron-bar-up", - "chevron-compact-down", - "chevron-compact-left", - "chevron-compact-right", - "chevron-compact-up", - "chevron-contract", - "chevron-double-down", - "chevron-double-left", - "chevron-double-right", - "chevron-double-up", - "chevron-down", - "chevron-expand", - "chevron-left", - "chevron-right", - "chevron-up", - "circle-fill", - "circle-half", - "circle-square", - "circle", - "clipboard-check", - "clipboard-data", - "clipboard-minus", - "clipboard-plus", - "clipboard-x", - "clipboard", - "clock-fill", - "clock-history", - "clock", - "cloud-arrow-down-fill", - "cloud-arrow-down", - "cloud-arrow-up-fill", - "cloud-arrow-up", - "cloud-check-fill", - "cloud-check", - "cloud-download-fill", - "cloud-download", - "cloud-drizzle-fill", - "cloud-drizzle", - "cloud-fill", - "cloud-fog-fill", - "cloud-fog", - "cloud-fog2-fill", - "cloud-fog2", - "cloud-hail-fill", - "cloud-hail", - "cloud-haze-fill", - "cloud-haze", - "cloud-haze2-fill", - "cloud-lightning-fill", - "cloud-lightning-rain-fill", - "cloud-lightning-rain", - "cloud-lightning", - "cloud-minus-fill", - "cloud-minus", - "cloud-moon-fill", - "cloud-moon", - "cloud-plus-fill", - "cloud-plus", - "cloud-rain-fill", - "cloud-rain-heavy-fill", - "cloud-rain-heavy", - "cloud-rain", - "cloud-slash-fill", - "cloud-slash", - "cloud-sleet-fill", - "cloud-sleet", - "cloud-snow-fill", - "cloud-snow", - "cloud-sun-fill", - "cloud-sun", - "cloud-upload-fill", - "cloud-upload", - "cloud", - "clouds-fill", - "clouds", - "cloudy-fill", - "cloudy", - "code-slash", - "code-square", - "code", - "collection-fill", - "collection-play-fill", - "collection-play", - "collection", - "columns-gap", - "columns", - "command", - "compass-fill", - "compass", - "cone-striped", - "cone", - "controller", - "cpu-fill", - "cpu", - "credit-card-2-back-fill", - "credit-card-2-back", - "credit-card-2-front-fill", - "credit-card-2-front", - "credit-card-fill", - "credit-card", - "crop", - "cup-fill", - "cup-straw", - "cup", - "cursor-fill", - "cursor-text", - "cursor", - "dash-circle-dotted", - "dash-circle-fill", - "dash-circle", - "dash-square-dotted", - "dash-square-fill", - "dash-square", - "dash", - "diagram-2-fill", - "diagram-2", - "diagram-3-fill", - "diagram-3", - "diamond-fill", - "diamond-half", - "diamond", - "dice-1-fill", - "dice-1", - "dice-2-fill", - "dice-2", - "dice-3-fill", - "dice-3", - "dice-4-fill", - "dice-4", - "dice-5-fill", - "dice-5", - "dice-6-fill", - "dice-6", - "disc-fill", - "disc", - "discord", - "display-fill", - "display", - "distribute-horizontal", - "distribute-vertical", - "door-closed-fill", - "door-closed", - "door-open-fill", - "door-open", - "dot", - "download", - "droplet-fill", - "droplet-half", - "droplet", - "earbuds", - "easel-fill", - "easel", - "egg-fill", - "egg-fried", - "egg", - "eject-fill", - "eject", - "emoji-angry-fill", - "emoji-angry", - "emoji-dizzy-fill", - "emoji-dizzy", - "emoji-expressionless-fill", - "emoji-expressionless", - "emoji-frown-fill", - "emoji-frown", - "emoji-heart-eyes-fill", - "emoji-heart-eyes", - "emoji-laughing-fill", - "emoji-laughing", - "emoji-neutral-fill", - "emoji-neutral", - "emoji-smile-fill", - "emoji-smile-upside-down-fill", - "emoji-smile-upside-down", - "emoji-smile", - "emoji-sunglasses-fill", - "emoji-sunglasses", - "emoji-wink-fill", - "emoji-wink", - "envelope-fill", - "envelope-open-fill", - "envelope-open", - "envelope", - "eraser-fill", - "eraser", - "exclamation-circle-fill", - "exclamation-circle", - "exclamation-diamond-fill", - "exclamation-diamond", - "exclamation-octagon-fill", - "exclamation-octagon", - "exclamation-square-fill", - "exclamation-square", - "exclamation-triangle-fill", - "exclamation-triangle", - "exclamation", - "exclude", - "eye-fill", - "eye-slash-fill", - "eye-slash", - "eye", - "eyedropper", - "eyeglasses", - "facebook", - "file-arrow-down-fill", - "file-arrow-down", - "file-arrow-up-fill", - "file-arrow-up", - "file-bar-graph-fill", - "file-bar-graph", - "file-binary-fill", - "file-binary", - "file-break-fill", - "file-break", - "file-check-fill", - "file-check", - "file-code-fill", - "file-code", - "file-diff-fill", - "file-diff", - "file-earmark-arrow-down-fill", - "file-earmark-arrow-down", - "file-earmark-arrow-up-fill", - "file-earmark-arrow-up", - "file-earmark-bar-graph-fill", - "file-earmark-bar-graph", - "file-earmark-binary-fill", - "file-earmark-binary", - "file-earmark-break-fill", - "file-earmark-break", - "file-earmark-check-fill", - "file-earmark-check", - "file-earmark-code-fill", - "file-earmark-code", - "file-earmark-diff-fill", - "file-earmark-diff", - "file-earmark-easel-fill", - "file-earmark-easel", - "file-earmark-excel-fill", - "file-earmark-excel", - "file-earmark-fill", - "file-earmark-font-fill", - "file-earmark-font", - "file-earmark-image-fill", - "file-earmark-image", - "file-earmark-lock-fill", - "file-earmark-lock", - "file-earmark-lock2-fill", - "file-earmark-lock2", - "file-earmark-medical-fill", - "file-earmark-medical", - "file-earmark-minus-fill", - "file-earmark-minus", - "file-earmark-music-fill", - "file-earmark-music", - "file-earmark-person-fill", - "file-earmark-person", - "file-earmark-play-fill", - "file-earmark-play", - "file-earmark-plus-fill", - "file-earmark-plus", - "file-earmark-post-fill", - "file-earmark-post", - "file-earmark-ppt-fill", - "file-earmark-ppt", - "file-earmark-richtext-fill", - "file-earmark-richtext", - "file-earmark-ruled-fill", - "file-earmark-ruled", - "file-earmark-slides-fill", - "file-earmark-slides", - "file-earmark-spreadsheet-fill", - "file-earmark-spreadsheet", - "file-earmark-text-fill", - "file-earmark-text", - "file-earmark-word-fill", - "file-earmark-word", - "file-earmark-x-fill", - "file-earmark-x", - "file-earmark-zip-fill", - "file-earmark-zip", - "file-earmark", - "file-easel-fill", - "file-easel", - "file-excel-fill", - "file-excel", - "file-fill", - "file-font-fill", - "file-font", - "file-image-fill", - "file-image", - "file-lock-fill", - "file-lock", - "file-lock2-fill", - "file-lock2", - "file-medical-fill", - "file-medical", - "file-minus-fill", - "file-minus", - "file-music-fill", - "file-music", - "file-person-fill", - "file-person", - "file-play-fill", - "file-play", - "file-plus-fill", - "file-plus", - "file-post-fill", - "file-post", - "file-ppt-fill", - "file-ppt", - "file-richtext-fill", - "file-richtext", - "file-ruled-fill", - "file-ruled", - "file-slides-fill", - "file-slides", - "file-spreadsheet-fill", - "file-spreadsheet", - "file-text-fill", - "file-text", - "file-word-fill", - "file-word", - "file-x-fill", - "file-x", - "file-zip-fill", - "file-zip", - "file", - "files-alt", - "files", - "film", - "filter-circle-fill", - "filter-circle", - "filter-left", - "filter-right", - "filter-square-fill", - "filter-square", - "filter", - "flag-fill", - "flag", - "flower1", - "flower2", - "flower3", - "folder-check", - "folder-fill", - "folder-minus", - "folder-plus", - "folder-symlink-fill", - "folder-symlink", - "folder-x", - "folder", - "folder2-open", - "folder2", - "fonts", - "forward-fill", - "forward", - "front", - "fullscreen-exit", - "fullscreen", - "funnel-fill", - "funnel", - "gear-fill", - "gear-wide-connected", - "gear-wide", - "gear", - "gem", - "geo-alt-fill", - "geo-alt", - "geo-fill", - "geo", - "gift-fill", - "gift", - "github", - "globe", - "globe2", - "google", - "graph-down", - "graph-up", - "grid-1x2-fill", - "grid-1x2", - "grid-3x2-gap-fill", - "grid-3x2-gap", - "grid-3x2", - "grid-3x3-gap-fill", - "grid-3x3-gap", - "grid-3x3", - "grid-fill", - "grid", - "grip-horizontal", - "grip-vertical", - "hammer", - "hand-index-fill", - "hand-index-thumb-fill", - "hand-index-thumb", - "hand-index", - "hand-thumbs-down-fill", - "hand-thumbs-down", - "hand-thumbs-up-fill", - "hand-thumbs-up", - "handbag-fill", - "handbag", - "hash", - "hdd-fill", - "hdd-network-fill", - "hdd-network", - "hdd-rack-fill", - "hdd-rack", - "hdd-stack-fill", - "hdd-stack", - "hdd", - "headphones", - "headset", - "heart-fill", - "heart-half", - "heart", - "heptagon-fill", - "heptagon-half", - "heptagon", - "hexagon-fill", - "hexagon-half", - "hexagon", - "hourglass-bottom", - "hourglass-split", - "hourglass-top", - "hourglass", - "house-door-fill", - "house-door", - "house-fill", - "house", - "hr", - "hurricane", - "image-alt", - "image-fill", - "image", - "images", - "inbox-fill", - "inbox", - "inboxes-fill", - "inboxes", - "info-circle-fill", - "info-circle", - "info-square-fill", - "info-square", - "info", - "input-cursor-text", - "input-cursor", - "instagram", - "intersect", - "journal-album", - "journal-arrow-down", - "journal-arrow-up", - "journal-bookmark-fill", - "journal-bookmark", - "journal-check", - "journal-code", - "journal-medical", - "journal-minus", - "journal-plus", - "journal-richtext", - "journal-text", - "journal-x", - "journal", - "journals", - "joystick", - "justify-left", - "justify-right", - "justify", - "kanban-fill", - "kanban", - "key-fill", - "key", - "keyboard-fill", - "keyboard", - "ladder", - "lamp-fill", - "lamp", - "laptop-fill", - "laptop", - "layer-backward", - "layer-forward", - "layers-fill", - "layers-half", - "layers", - "layout-sidebar-inset-reverse", - "layout-sidebar-inset", - "layout-sidebar-reverse", - "layout-sidebar", - "layout-split", - "layout-text-sidebar-reverse", - "layout-text-sidebar", - "layout-text-window-reverse", - "layout-text-window", - "layout-three-columns", - "layout-wtf", - "life-preserver", - "lightbulb-fill", - "lightbulb-off-fill", - "lightbulb-off", - "lightbulb", - "lightning-charge-fill", - "lightning-charge", - "lightning-fill", - "lightning", - "link-45deg", - "link", - "linkedin", - "list-check", - "list-nested", - "list-ol", - "list-stars", - "list-task", - "list-ul", - "list", - "lock-fill", - "lock", - "mailbox", - "mailbox2", - "map-fill", - "map", - "markdown-fill", - "markdown", - "mask", - "megaphone-fill", - "megaphone", - "menu-app-fill", - "menu-app", - "menu-button-fill", - "menu-button-wide-fill", - "menu-button-wide", - "menu-button", - "menu-down", - "menu-up", - "mic-fill", - "mic-mute-fill", - "mic-mute", - "mic", - "minecart-loaded", - "minecart", - "moisture", - "moon-fill", - "moon-stars-fill", - "moon-stars", - "moon", - "mouse-fill", - "mouse", - "mouse2-fill", - "mouse2", - "mouse3-fill", - "mouse3", - "music-note-beamed", - "music-note-list", - "music-note", - "music-player-fill", - "music-player", - "newspaper", - "node-minus-fill", - "node-minus", - "node-plus-fill", - "node-plus", - "nut-fill", - "nut", - "octagon-fill", - "octagon-half", - "octagon", - "option", - "outlet", - "paint-bucket", - "palette-fill", - "palette", - "palette2", - "paperclip", - "paragraph", - "patch-check-fill", - "patch-check", - "patch-exclamation-fill", - "patch-exclamation", - "patch-minus-fill", - "patch-minus", - "patch-plus-fill", - "patch-plus", - "patch-question-fill", - "patch-question", - "pause-btn-fill", - "pause-btn", - "pause-circle-fill", - "pause-circle", - "pause-fill", - "pause", - "peace-fill", - "peace", - "pen-fill", - "pen", - "pencil-fill", - "pencil-square", - "pencil", - "pentagon-fill", - "pentagon-half", - "pentagon", - "people-fill", - "people", - "percent", - "person-badge-fill", - "person-badge", - "person-bounding-box", - "person-check-fill", - "person-check", - "person-circle", - "person-dash-fill", - "person-dash", - "person-fill", - "person-lines-fill", - "person-plus-fill", - "person-plus", - "person-square", - "person-x-fill", - "person-x", - "person", - "phone-fill", - "phone-landscape-fill", - "phone-landscape", - "phone-vibrate-fill", - "phone-vibrate", - "phone", - "pie-chart-fill", - "pie-chart", - "pin-angle-fill", - "pin-angle", - "pin-fill", - "pin", - "pip-fill", - "pip", - "play-btn-fill", - "play-btn", - "play-circle-fill", - "play-circle", - "play-fill", - "play", - "plug-fill", - "plug", - "plus-circle-dotted", - "plus-circle-fill", - "plus-circle", - "plus-square-dotted", - "plus-square-fill", - "plus-square", - "plus", - "power", - "printer-fill", - "printer", - "puzzle-fill", - "puzzle", - "question-circle-fill", - "question-circle", - "question-diamond-fill", - "question-diamond", - "question-octagon-fill", - "question-octagon", - "question-square-fill", - "question-square", - "question", - "rainbow", - "receipt-cutoff", - "receipt", - "reception-0", - "reception-1", - "reception-2", - "reception-3", - "reception-4", - "record-btn-fill", - "record-btn", - "record-circle-fill", - "record-circle", - "record-fill", - "record", - "record2-fill", - "record2", - "reply-all-fill", - "reply-all", - "reply-fill", - "reply", - "rss-fill", - "rss", - "rulers", - "save-fill", - "save", - "save2-fill", - "save2", - "scissors", - "screwdriver", - "search", - "segmented-nav", - "server", - "share-fill", - "share", - "shield-check", - "shield-exclamation", - "shield-fill-check", - "shield-fill-exclamation", - "shield-fill-minus", - "shield-fill-plus", - "shield-fill-x", - "shield-fill", - "shield-lock-fill", - "shield-lock", - "shield-minus", - "shield-plus", - "shield-shaded", - "shield-slash-fill", - "shield-slash", - "shield-x", - "shield", - "shift-fill", - "shift", - "shop-window", - "shop", - "shuffle", - "signpost-2-fill", - "signpost-2", - "signpost-fill", - "signpost-split-fill", - "signpost-split", - "signpost", - "sim-fill", - "sim", - "skip-backward-btn-fill", - "skip-backward-btn", - "skip-backward-circle-fill", - "skip-backward-circle", - "skip-backward-fill", - "skip-backward", - "skip-end-btn-fill", - "skip-end-btn", - "skip-end-circle-fill", - "skip-end-circle", - "skip-end-fill", - "skip-end", - "skip-forward-btn-fill", - "skip-forward-btn", - "skip-forward-circle-fill", - "skip-forward-circle", - "skip-forward-fill", - "skip-forward", - "skip-start-btn-fill", - "skip-start-btn", - "skip-start-circle-fill", - "skip-start-circle", - "skip-start-fill", - "skip-start", - "slack", - "slash-circle-fill", - "slash-circle", - "slash-square-fill", - "slash-square", - "slash", - "sliders", - "smartwatch", - "snow", - "snow2", - "snow3", - "sort-alpha-down-alt", - "sort-alpha-down", - "sort-alpha-up-alt", - "sort-alpha-up", - "sort-down-alt", - "sort-down", - "sort-numeric-down-alt", - "sort-numeric-down", - "sort-numeric-up-alt", - "sort-numeric-up", - "sort-up-alt", - "sort-up", - "soundwave", - "speaker-fill", - "speaker", - "speedometer", - "speedometer2", - "spellcheck", - "square-fill", - "square-half", - "square", - "stack", - "star-fill", - "star-half", - "star", - "stars", - "stickies-fill", - "stickies", - "sticky-fill", - "sticky", - "stop-btn-fill", - "stop-btn", - "stop-circle-fill", - "stop-circle", - "stop-fill", - "stop", - "stoplights-fill", - "stoplights", - "stopwatch-fill", - "stopwatch", - "subtract", - "suit-club-fill", - "suit-club", - "suit-diamond-fill", - "suit-diamond", - "suit-heart-fill", - "suit-heart", - "suit-spade-fill", - "suit-spade", - "sun-fill", - "sun", - "sunglasses", - "sunrise-fill", - "sunrise", - "sunset-fill", - "sunset", - "symmetry-horizontal", - "symmetry-vertical", - "table", - "tablet-fill", - "tablet-landscape-fill", - "tablet-landscape", - "tablet", - "tag-fill", - "tag", - "tags-fill", - "tags", - "telegram", - "telephone-fill", - "telephone-forward-fill", - "telephone-forward", - "telephone-inbound-fill", - "telephone-inbound", - "telephone-minus-fill", - "telephone-minus", - "telephone-outbound-fill", - "telephone-outbound", - "telephone-plus-fill", - "telephone-plus", - "telephone-x-fill", - "telephone-x", - "telephone", - "terminal-fill", - "terminal", - "text-center", - "text-indent-left", - "text-indent-right", - "text-left", - "text-paragraph", - "text-right", - "textarea-resize", - "textarea-t", - "textarea", - "thermometer-half", - "thermometer-high", - "thermometer-low", - "thermometer-snow", - "thermometer-sun", - "thermometer", - "three-dots-vertical", - "three-dots", - "toggle-off", - "toggle-on", - "toggle2-off", - "toggle2-on", - "toggles", - "toggles2", - "tools", - "tornado", - "trash-fill", - "trash", - "trash2-fill", - "trash2", - "tree-fill", - "tree", - "triangle-fill", - "triangle-half", - "triangle", - "trophy-fill", - "trophy", - "tropical-storm", - "truck-flatbed", - "truck", - "tsunami", - "tv-fill", - "tv", - "twitch", - "twitter", - "type-bold", - "type-h1", - "type-h2", - "type-h3", - "type-italic", - "type-strikethrough", - "type-underline", - "type", - "ui-checks-grid", - "ui-checks", - "ui-radios-grid", - "ui-radios", - "umbrella-fill", - "umbrella", - "union", - "unlock-fill", - "unlock", - "upc-scan", - "upc", - "upload", - "vector-pen", - "view-list", - "view-stacked", - "vinyl-fill", - "vinyl", - "voicemail", - "volume-down-fill", - "volume-down", - "volume-mute-fill", - "volume-mute", - "volume-off-fill", - "volume-off", - "volume-up-fill", - "volume-up", - "vr", - "wallet-fill", - "wallet", - "wallet2", - "watch", - "water", - "whatsapp", - "wifi-1", - "wifi-2", - "wifi-off", - "wifi", - "wind", - "window-dock", - "window-sidebar", - "window", - "wrench", - "x-circle-fill", - "x-circle", - "x-diamond-fill", - "x-diamond", - "x-octagon-fill", - "x-octagon", - "x-square-fill", - "x-square", - "x", - "youtube", - "zoom-in", - "zoom-out", - "bank", - "bank2", - "bell-slash-fill", - "bell-slash", - "cash-coin", - "check-lg", - "coin", - "currency-bitcoin", - "currency-dollar", - "currency-euro", - "currency-exchange", - "currency-pound", - "currency-yen", - "dash-lg", - "exclamation-lg", - "file-earmark-pdf-fill", - "file-earmark-pdf", - "file-pdf-fill", - "file-pdf", - "gender-ambiguous", - "gender-female", - "gender-male", - "gender-trans", - "headset-vr", - "info-lg", - "mastodon", - "messenger", - "piggy-bank-fill", - "piggy-bank", - "pin-map-fill", - "pin-map", - "plus-lg", - "question-lg", - "recycle", - "reddit", - "safe-fill", - "safe2-fill", - "safe2", - "sd-card-fill", - "sd-card", - "skype", - "slash-lg", - "translate", - "x-lg", - "safe", - "apple", - "microsoft", - "windows", - "behance", - "dribbble", - "line", - "medium", - "paypal", - "pinterest", - "signal", - "snapchat", - "spotify", - "stack-overflow", - "strava", - "wordpress", - "vimeo", - "activity", - "easel2-fill", - "easel2", - "easel3-fill", - "easel3", - "fan", - "fingerprint", - "graph-down-arrow", - "graph-up-arrow", - "hypnotize", - "magic", - "person-rolodex", - "person-video", - "person-video2", - "person-video3", - "person-workspace", - "radioactive", - "webcam-fill", - "webcam", - "yin-yang", - "bandaid-fill", - "bandaid", - "bluetooth", - "body-text", - "boombox", - "boxes", - "dpad-fill", - "dpad", - "ear-fill", - "ear", - "envelope-check-fill", - "envelope-check", - "envelope-dash-fill", - "envelope-dash", - "envelope-exclamation-fill", - "envelope-exclamation", - "envelope-plus-fill", - "envelope-plus", - "envelope-slash-fill", - "envelope-slash", - "envelope-x-fill", - "envelope-x", - "explicit-fill", - "explicit", - "git", - "infinity", - "list-columns-reverse", - "list-columns", - "meta", - "nintendo-switch", - "pc-display-horizontal", - "pc-display", - "pc-horizontal", - "pc", - "playstation", - "plus-slash-minus", - "projector-fill", - "projector", - "qr-code-scan", - "qr-code", - "quora", - "quote", - "robot", - "send-check-fill", - "send-check", - "send-dash-fill", - "send-dash", - "send-exclamation-fill", - "send-exclamation", - "send-fill", - "send-plus-fill", - "send-plus", - "send-slash-fill", - "send-slash", - "send-x-fill", - "send-x", - "send", - "steam", - "terminal-dash", - "terminal-plus", - "terminal-split", - "ticket-detailed-fill", - "ticket-detailed", - "ticket-fill", - "ticket-perforated-fill", - "ticket-perforated", - "ticket", - "tiktok", - "window-dash", - "window-desktop", - "window-fullscreen", - "window-plus", - "window-split", - "window-stack", - "window-x", - "xbox", - "ethernet", - "hdmi-fill", - "hdmi", - "usb-c-fill", - "usb-c", - "usb-fill", - "usb-plug-fill", - "usb-plug", - "usb-symbol", - "usb", - "boombox-fill", - "displayport", - "gpu-card", - "memory", - "modem-fill", - "modem", - "motherboard-fill", - "motherboard", - "optical-audio-fill", - "optical-audio", - "pci-card", - "router-fill", - "router", - "thunderbolt-fill", - "thunderbolt", - "usb-drive-fill", - "usb-drive", - "usb-micro-fill", - "usb-micro", - "usb-mini-fill", - "usb-mini", - "cloud-haze2", - "device-hdd-fill", - "device-hdd", - "device-ssd-fill", - "device-ssd", - "displayport-fill", - "mortarboard-fill", - "mortarboard", - "terminal-x", - "arrow-through-heart-fill", - "arrow-through-heart", - "badge-sd-fill", - "badge-sd", - "bag-heart-fill", - "bag-heart", - "balloon-fill", - "balloon-heart-fill", - "balloon-heart", - "balloon", - "box2-fill", - "box2-heart-fill", - "box2-heart", - "box2", - "braces-asterisk", - "calendar-heart-fill", - "calendar-heart", - "calendar2-heart-fill", - "calendar2-heart", - "chat-heart-fill", - "chat-heart", - "chat-left-heart-fill", - "chat-left-heart", - "chat-right-heart-fill", - "chat-right-heart", - "chat-square-heart-fill", - "chat-square-heart", - "clipboard-check-fill", - "clipboard-data-fill", - "clipboard-fill", - "clipboard-heart-fill", - "clipboard-heart", - "clipboard-minus-fill", - "clipboard-plus-fill", - "clipboard-pulse", - "clipboard-x-fill", - "clipboard2-check-fill", - "clipboard2-check", - "clipboard2-data-fill", - "clipboard2-data", - "clipboard2-fill", - "clipboard2-heart-fill", - "clipboard2-heart", - "clipboard2-minus-fill", - "clipboard2-minus", - "clipboard2-plus-fill", - "clipboard2-plus", - "clipboard2-pulse-fill", - "clipboard2-pulse", - "clipboard2-x-fill", - "clipboard2-x", - "clipboard2", - "emoji-kiss-fill", - "emoji-kiss", - "envelope-heart-fill", - "envelope-heart", - "envelope-open-heart-fill", - "envelope-open-heart", - "envelope-paper-fill", - "envelope-paper-heart-fill", - "envelope-paper-heart", - "envelope-paper", - "filetype-aac", - "filetype-ai", - "filetype-bmp", - "filetype-cs", - "filetype-css", - "filetype-csv", - "filetype-doc", - "filetype-docx", - "filetype-exe", - "filetype-gif", - "filetype-heic", - "filetype-html", - "filetype-java", - "filetype-jpg", - "filetype-js", - "filetype-jsx", - "filetype-key", - "filetype-m4p", - "filetype-md", - "filetype-mdx", - "filetype-mov", - "filetype-mp3", - "filetype-mp4", - "filetype-otf", - "filetype-pdf", - "filetype-php", - "filetype-png", - "filetype-ppt", - "filetype-psd", - "filetype-py", - "filetype-raw", - "filetype-rb", - "filetype-sass", - "filetype-scss", - "filetype-sh", - "filetype-svg", - "filetype-tiff", - "filetype-tsx", - "filetype-ttf", - "filetype-txt", - "filetype-wav", - "filetype-woff", - "filetype-xls", - "filetype-xml", - "filetype-yml", - "heart-arrow", - "heart-pulse-fill", - "heart-pulse", - "heartbreak-fill", - "heartbreak", - "hearts", - "hospital-fill", - "hospital", - "house-heart-fill", - "house-heart", - "incognito", - "magnet-fill", - "magnet", - "person-heart", - "person-hearts", - "phone-flip", - "plugin", - "postage-fill", - "postage-heart-fill", - "postage-heart", - "postage", - "postcard-fill", - "postcard-heart-fill", - "postcard-heart", - "postcard", - "search-heart-fill", - "search-heart", - "sliders2-vertical", - "sliders2", - "trash3-fill", - "trash3", - "valentine", - "valentine2", - "wrench-adjustable-circle-fill", - "wrench-adjustable-circle", - "wrench-adjustable", - "filetype-json", - "filetype-pptx", - "filetype-xlsx", - "1-circle-fill", - "1-circle", - "1-square-fill", - "1-square", - "2-circle-fill", - "2-circle", - "2-square-fill", - "2-square", - "3-circle-fill", - "3-circle", - "3-square-fill", - "3-square", - "4-circle-fill", - "4-circle", - "4-square-fill", - "4-square", - "5-circle-fill", - "5-circle", - "5-square-fill", - "5-square", - "6-circle-fill", - "6-circle", - "6-square-fill", - "6-square", - "7-circle-fill", - "7-circle", - "7-square-fill", - "7-square", - "8-circle-fill", - "8-circle", - "8-square-fill", - "8-square", - "9-circle-fill", - "9-circle", - "9-square-fill", - "9-square", - "airplane-engines-fill", - "airplane-engines", - "airplane-fill", - "airplane", - "alexa", - "alipay", - "android", - "android2", - "box-fill", - "box-seam-fill", - "browser-chrome", - "browser-edge", - "browser-firefox", - "browser-safari", - "c-circle-fill", - "c-circle", - "c-square-fill", - "c-square", - "capsule-pill", - "capsule", - "car-front-fill", - "car-front", - "cassette-fill", - "cassette", - "cc-circle-fill", - "cc-circle", - "cc-square-fill", - "cc-square", - "cup-hot-fill", - "cup-hot", - "currency-rupee", - "dropbox", - "escape", - "fast-forward-btn-fill", - "fast-forward-btn", - "fast-forward-circle-fill", - "fast-forward-circle", - "fast-forward-fill", - "fast-forward", - "filetype-sql", - "fire", - "google-play", - "h-circle-fill", - "h-circle", - "h-square-fill", - "h-square", - "indent", - "lungs-fill", - "lungs", - "microsoft-teams", - "p-circle-fill", - "p-circle", - "p-square-fill", - "p-square", - "pass-fill", - "pass", - "prescription", - "prescription2", - "r-circle-fill", - "r-circle", - "r-square-fill", - "r-square", - "repeat-1", - "repeat", - "rewind-btn-fill", - "rewind-btn", - "rewind-circle-fill", - "rewind-circle", - "rewind-fill", - "rewind", - "train-freight-front-fill", - "train-freight-front", - "train-front-fill", - "train-front", - "train-lightrail-front-fill", - "train-lightrail-front", - "truck-front-fill", - "truck-front", - "ubuntu", - "unindent", - "unity", - "universal-access-circle", - "universal-access", - "virus", - "virus2", - "wechat", - "yelp", - "sign-stop-fill", - "sign-stop-lights-fill", - "sign-stop-lights", - "sign-stop", - "sign-turn-left-fill", - "sign-turn-left", - "sign-turn-right-fill", - "sign-turn-right", - "sign-turn-slight-left-fill", - "sign-turn-slight-left", - "sign-turn-slight-right-fill", - "sign-turn-slight-right", - "sign-yield-fill", - "sign-yield", - "ev-station-fill", - "ev-station", - "fuel-pump-diesel-fill", - "fuel-pump-diesel", - "fuel-pump-fill", - "fuel-pump", - "0-circle-fill", - "0-circle", - "0-square-fill", - "0-square", - "rocket-fill", - "rocket-takeoff-fill", - "rocket-takeoff", - "rocket", - "stripe", - "subscript", - "superscript", - "trello", - "envelope-at-fill", - "envelope-at", - "regex", - "text-wrap", - "sign-dead-end-fill", - "sign-dead-end", - "sign-do-not-enter-fill", - "sign-do-not-enter", - "sign-intersection-fill", - "sign-intersection-side-fill", - "sign-intersection-side", - "sign-intersection-t-fill", - "sign-intersection-t", - "sign-intersection-y-fill", - "sign-intersection-y", - "sign-intersection", - "sign-merge-left-fill", - "sign-merge-left", - "sign-merge-right-fill", - "sign-merge-right", - "sign-no-left-turn-fill", - "sign-no-left-turn", - "sign-no-parking-fill", - "sign-no-parking", - "sign-no-right-turn-fill", - "sign-no-right-turn", - "sign-railroad-fill", - "sign-railroad", - "building-add", - "building-check", - "building-dash", - "building-down", - "building-exclamation", - "building-fill-add", - "building-fill-check", - "building-fill-dash", - "building-fill-down", - "building-fill-exclamation", - "building-fill-gear", - "building-fill-lock", - "building-fill-slash", - "building-fill-up", - "building-fill-x", - "building-fill", - "building-gear", - "building-lock", - "building-slash", - "building-up", - "building-x", - "buildings-fill", - "buildings", - "bus-front-fill", - "bus-front", - "ev-front-fill", - "ev-front", - "globe-americas", - "globe-asia-australia", - "globe-central-south-asia", - "globe-europe-africa", - "house-add-fill", - "house-add", - "house-check-fill", - "house-check", - "house-dash-fill", - "house-dash", - "house-down-fill", - "house-down", - "house-exclamation-fill", - "house-exclamation", - "house-gear-fill", - "house-gear", - "house-lock-fill", - "house-lock", - "house-slash-fill", - "house-slash", - "house-up-fill", - "house-up", - "house-x-fill", - "house-x", - "person-add", - "person-down", - "person-exclamation", - "person-fill-add", - "person-fill-check", - "person-fill-dash", - "person-fill-down", - "person-fill-exclamation", - "person-fill-gear", - "person-fill-lock", - "person-fill-slash", - "person-fill-up", - "person-fill-x", - "person-gear", - "person-lock", - "person-slash", - "person-up", - "scooter", - "taxi-front-fill", - "taxi-front", - "amd", - "database-add", - "database-check", - "database-dash", - "database-down", - "database-exclamation", - "database-fill-add", - "database-fill-check", - "database-fill-dash", - "database-fill-down", - "database-fill-exclamation", - "database-fill-gear", - "database-fill-lock", - "database-fill-slash", - "database-fill-up", - "database-fill-x", - "database-fill", - "database-gear", - "database-lock", - "database-slash", - "database-up", - "database-x", - "database", - "houses-fill", - "houses", - "nvidia", - "person-vcard-fill", - "person-vcard", - "sina-weibo", - "tencent-qq", - "wikipedia", +var icons = map[string]struct{}{ + "123": {}, + "alarm-fill": {}, + "alarm": {}, + "align-bottom": {}, + "align-center": {}, + "align-end": {}, + "align-middle": {}, + "align-start": {}, + "align-top": {}, + "alt": {}, + "app-indicator": {}, + "app": {}, + "archive-fill": {}, + "archive": {}, + "arrow-90deg-down": {}, + "arrow-90deg-left": {}, + "arrow-90deg-right": {}, + "arrow-90deg-up": {}, + "arrow-bar-down": {}, + "arrow-bar-left": {}, + "arrow-bar-right": {}, + "arrow-bar-up": {}, + "arrow-clockwise": {}, + "arrow-counterclockwise": {}, + "arrow-down-circle-fill": {}, + "arrow-down-circle": {}, + "arrow-down-left-circle-fill": {}, + "arrow-down-left-circle": {}, + "arrow-down-left-square-fill": {}, + "arrow-down-left-square": {}, + "arrow-down-left": {}, + "arrow-down-right-circle-fill": {}, + "arrow-down-right-circle": {}, + "arrow-down-right-square-fill": {}, + "arrow-down-right-square": {}, + "arrow-down-right": {}, + "arrow-down-short": {}, + "arrow-down-square-fill": {}, + "arrow-down-square": {}, + "arrow-down-up": {}, + "arrow-down": {}, + "arrow-left-circle-fill": {}, + "arrow-left-circle": {}, + "arrow-left-right": {}, + "arrow-left-short": {}, + "arrow-left-square-fill": {}, + "arrow-left-square": {}, + "arrow-left": {}, + "arrow-repeat": {}, + "arrow-return-left": {}, + "arrow-return-right": {}, + "arrow-right-circle-fill": {}, + "arrow-right-circle": {}, + "arrow-right-short": {}, + "arrow-right-square-fill": {}, + "arrow-right-square": {}, + "arrow-right": {}, + "arrow-up-circle-fill": {}, + "arrow-up-circle": {}, + "arrow-up-left-circle-fill": {}, + "arrow-up-left-circle": {}, + "arrow-up-left-square-fill": {}, + "arrow-up-left-square": {}, + "arrow-up-left": {}, + "arrow-up-right-circle-fill": {}, + "arrow-up-right-circle": {}, + "arrow-up-right-square-fill": {}, + "arrow-up-right-square": {}, + "arrow-up-right": {}, + "arrow-up-short": {}, + "arrow-up-square-fill": {}, + "arrow-up-square": {}, + "arrow-up": {}, + "arrows-angle-contract": {}, + "arrows-angle-expand": {}, + "arrows-collapse": {}, + "arrows-expand": {}, + "arrows-fullscreen": {}, + "arrows-move": {}, + "aspect-ratio-fill": {}, + "aspect-ratio": {}, + "asterisk": {}, + "at": {}, + "award-fill": {}, + "award": {}, + "back": {}, + "backspace-fill": {}, + "backspace-reverse-fill": {}, + "backspace-reverse": {}, + "backspace": {}, + "badge-3d-fill": {}, + "badge-3d": {}, + "badge-4k-fill": {}, + "badge-4k": {}, + "badge-8k-fill": {}, + "badge-8k": {}, + "badge-ad-fill": {}, + "badge-ad": {}, + "badge-ar-fill": {}, + "badge-ar": {}, + "badge-cc-fill": {}, + "badge-cc": {}, + "badge-hd-fill": {}, + "badge-hd": {}, + "badge-tm-fill": {}, + "badge-tm": {}, + "badge-vo-fill": {}, + "badge-vo": {}, + "badge-vr-fill": {}, + "badge-vr": {}, + "badge-wc-fill": {}, + "badge-wc": {}, + "bag-check-fill": {}, + "bag-check": {}, + "bag-dash-fill": {}, + "bag-dash": {}, + "bag-fill": {}, + "bag-plus-fill": {}, + "bag-plus": {}, + "bag-x-fill": {}, + "bag-x": {}, + "bag": {}, + "bar-chart-fill": {}, + "bar-chart-line-fill": {}, + "bar-chart-line": {}, + "bar-chart-steps": {}, + "bar-chart": {}, + "basket-fill": {}, + "basket": {}, + "basket2-fill": {}, + "basket2": {}, + "basket3-fill": {}, + "basket3": {}, + "battery-charging": {}, + "battery-full": {}, + "battery-half": {}, + "battery": {}, + "bell-fill": {}, + "bell": {}, + "bezier": {}, + "bezier2": {}, + "bicycle": {}, + "binoculars-fill": {}, + "binoculars": {}, + "blockquote-left": {}, + "blockquote-right": {}, + "book-fill": {}, + "book-half": {}, + "book": {}, + "bookmark-check-fill": {}, + "bookmark-check": {}, + "bookmark-dash-fill": {}, + "bookmark-dash": {}, + "bookmark-fill": {}, + "bookmark-heart-fill": {}, + "bookmark-heart": {}, + "bookmark-plus-fill": {}, + "bookmark-plus": {}, + "bookmark-star-fill": {}, + "bookmark-star": {}, + "bookmark-x-fill": {}, + "bookmark-x": {}, + "bookmark": {}, + "bookmarks-fill": {}, + "bookmarks": {}, + "bookshelf": {}, + "bootstrap-fill": {}, + "bootstrap-reboot": {}, + "bootstrap": {}, + "border-all": {}, + "border-bottom": {}, + "border-center": {}, + "border-inner": {}, + "border-left": {}, + "border-middle": {}, + "border-outer": {}, + "border-right": {}, + "border-style": {}, + "border-top": {}, + "border-width": {}, + "border": {}, + "bounding-box-circles": {}, + "bounding-box": {}, + "box-arrow-down-left": {}, + "box-arrow-down-right": {}, + "box-arrow-down": {}, + "box-arrow-in-down-left": {}, + "box-arrow-in-down-right": {}, + "box-arrow-in-down": {}, + "box-arrow-in-left": {}, + "box-arrow-in-right": {}, + "box-arrow-in-up-left": {}, + "box-arrow-in-up-right": {}, + "box-arrow-in-up": {}, + "box-arrow-left": {}, + "box-arrow-right": {}, + "box-arrow-up-left": {}, + "box-arrow-up-right": {}, + "box-arrow-up": {}, + "box-seam": {}, + "box": {}, + "braces": {}, + "bricks": {}, + "briefcase-fill": {}, + "briefcase": {}, + "brightness-alt-high-fill": {}, + "brightness-alt-high": {}, + "brightness-alt-low-fill": {}, + "brightness-alt-low": {}, + "brightness-high-fill": {}, + "brightness-high": {}, + "brightness-low-fill": {}, + "brightness-low": {}, + "broadcast-pin": {}, + "broadcast": {}, + "brush-fill": {}, + "brush": {}, + "bucket-fill": {}, + "bucket": {}, + "bug-fill": {}, + "bug": {}, + "building": {}, + "bullseye": {}, + "calculator-fill": {}, + "calculator": {}, + "calendar-check-fill": {}, + "calendar-check": {}, + "calendar-date-fill": {}, + "calendar-date": {}, + "calendar-day-fill": {}, + "calendar-day": {}, + "calendar-event-fill": {}, + "calendar-event": {}, + "calendar-fill": {}, + "calendar-minus-fill": {}, + "calendar-minus": {}, + "calendar-month-fill": {}, + "calendar-month": {}, + "calendar-plus-fill": {}, + "calendar-plus": {}, + "calendar-range-fill": {}, + "calendar-range": {}, + "calendar-week-fill": {}, + "calendar-week": {}, + "calendar-x-fill": {}, + "calendar-x": {}, + "calendar": {}, + "calendar2-check-fill": {}, + "calendar2-check": {}, + "calendar2-date-fill": {}, + "calendar2-date": {}, + "calendar2-day-fill": {}, + "calendar2-day": {}, + "calendar2-event-fill": {}, + "calendar2-event": {}, + "calendar2-fill": {}, + "calendar2-minus-fill": {}, + "calendar2-minus": {}, + "calendar2-month-fill": {}, + "calendar2-month": {}, + "calendar2-plus-fill": {}, + "calendar2-plus": {}, + "calendar2-range-fill": {}, + "calendar2-range": {}, + "calendar2-week-fill": {}, + "calendar2-week": {}, + "calendar2-x-fill": {}, + "calendar2-x": {}, + "calendar2": {}, + "calendar3-event-fill": {}, + "calendar3-event": {}, + "calendar3-fill": {}, + "calendar3-range-fill": {}, + "calendar3-range": {}, + "calendar3-week-fill": {}, + "calendar3-week": {}, + "calendar3": {}, + "calendar4-event": {}, + "calendar4-range": {}, + "calendar4-week": {}, + "calendar4": {}, + "camera-fill": {}, + "camera-reels-fill": {}, + "camera-reels": {}, + "camera-video-fill": {}, + "camera-video-off-fill": {}, + "camera-video-off": {}, + "camera-video": {}, + "camera": {}, + "camera2": {}, + "capslock-fill": {}, + "capslock": {}, + "card-checklist": {}, + "card-heading": {}, + "card-image": {}, + "card-list": {}, + "card-text": {}, + "caret-down-fill": {}, + "caret-down-square-fill": {}, + "caret-down-square": {}, + "caret-down": {}, + "caret-left-fill": {}, + "caret-left-square-fill": {}, + "caret-left-square": {}, + "caret-left": {}, + "caret-right-fill": {}, + "caret-right-square-fill": {}, + "caret-right-square": {}, + "caret-right": {}, + "caret-up-fill": {}, + "caret-up-square-fill": {}, + "caret-up-square": {}, + "caret-up": {}, + "cart-check-fill": {}, + "cart-check": {}, + "cart-dash-fill": {}, + "cart-dash": {}, + "cart-fill": {}, + "cart-plus-fill": {}, + "cart-plus": {}, + "cart-x-fill": {}, + "cart-x": {}, + "cart": {}, + "cart2": {}, + "cart3": {}, + "cart4": {}, + "cash-stack": {}, + "cash": {}, + "cast": {}, + "chat-dots-fill": {}, + "chat-dots": {}, + "chat-fill": {}, + "chat-left-dots-fill": {}, + "chat-left-dots": {}, + "chat-left-fill": {}, + "chat-left-quote-fill": {}, + "chat-left-quote": {}, + "chat-left-text-fill": {}, + "chat-left-text": {}, + "chat-left": {}, + "chat-quote-fill": {}, + "chat-quote": {}, + "chat-right-dots-fill": {}, + "chat-right-dots": {}, + "chat-right-fill": {}, + "chat-right-quote-fill": {}, + "chat-right-quote": {}, + "chat-right-text-fill": {}, + "chat-right-text": {}, + "chat-right": {}, + "chat-square-dots-fill": {}, + "chat-square-dots": {}, + "chat-square-fill": {}, + "chat-square-quote-fill": {}, + "chat-square-quote": {}, + "chat-square-text-fill": {}, + "chat-square-text": {}, + "chat-square": {}, + "chat-text-fill": {}, + "chat-text": {}, + "chat": {}, + "check-all": {}, + "check-circle-fill": {}, + "check-circle": {}, + "check-square-fill": {}, + "check-square": {}, + "check": {}, + "check2-all": {}, + "check2-circle": {}, + "check2-square": {}, + "check2": {}, + "chevron-bar-contract": {}, + "chevron-bar-down": {}, + "chevron-bar-expand": {}, + "chevron-bar-left": {}, + "chevron-bar-right": {}, + "chevron-bar-up": {}, + "chevron-compact-down": {}, + "chevron-compact-left": {}, + "chevron-compact-right": {}, + "chevron-compact-up": {}, + "chevron-contract": {}, + "chevron-double-down": {}, + "chevron-double-left": {}, + "chevron-double-right": {}, + "chevron-double-up": {}, + "chevron-down": {}, + "chevron-expand": {}, + "chevron-left": {}, + "chevron-right": {}, + "chevron-up": {}, + "circle-fill": {}, + "circle-half": {}, + "circle-square": {}, + "circle": {}, + "clipboard-check": {}, + "clipboard-data": {}, + "clipboard-minus": {}, + "clipboard-plus": {}, + "clipboard-x": {}, + "clipboard": {}, + "clock-fill": {}, + "clock-history": {}, + "clock": {}, + "cloud-arrow-down-fill": {}, + "cloud-arrow-down": {}, + "cloud-arrow-up-fill": {}, + "cloud-arrow-up": {}, + "cloud-check-fill": {}, + "cloud-check": {}, + "cloud-download-fill": {}, + "cloud-download": {}, + "cloud-drizzle-fill": {}, + "cloud-drizzle": {}, + "cloud-fill": {}, + "cloud-fog-fill": {}, + "cloud-fog": {}, + "cloud-fog2-fill": {}, + "cloud-fog2": {}, + "cloud-hail-fill": {}, + "cloud-hail": {}, + "cloud-haze-fill": {}, + "cloud-haze": {}, + "cloud-haze2-fill": {}, + "cloud-lightning-fill": {}, + "cloud-lightning-rain-fill": {}, + "cloud-lightning-rain": {}, + "cloud-lightning": {}, + "cloud-minus-fill": {}, + "cloud-minus": {}, + "cloud-moon-fill": {}, + "cloud-moon": {}, + "cloud-plus-fill": {}, + "cloud-plus": {}, + "cloud-rain-fill": {}, + "cloud-rain-heavy-fill": {}, + "cloud-rain-heavy": {}, + "cloud-rain": {}, + "cloud-slash-fill": {}, + "cloud-slash": {}, + "cloud-sleet-fill": {}, + "cloud-sleet": {}, + "cloud-snow-fill": {}, + "cloud-snow": {}, + "cloud-sun-fill": {}, + "cloud-sun": {}, + "cloud-upload-fill": {}, + "cloud-upload": {}, + "cloud": {}, + "clouds-fill": {}, + "clouds": {}, + "cloudy-fill": {}, + "cloudy": {}, + "code-slash": {}, + "code-square": {}, + "code": {}, + "collection-fill": {}, + "collection-play-fill": {}, + "collection-play": {}, + "collection": {}, + "columns-gap": {}, + "columns": {}, + "command": {}, + "compass-fill": {}, + "compass": {}, + "cone-striped": {}, + "cone": {}, + "controller": {}, + "cpu-fill": {}, + "cpu": {}, + "credit-card-2-back-fill": {}, + "credit-card-2-back": {}, + "credit-card-2-front-fill": {}, + "credit-card-2-front": {}, + "credit-card-fill": {}, + "credit-card": {}, + "crop": {}, + "cup-fill": {}, + "cup-straw": {}, + "cup": {}, + "cursor-fill": {}, + "cursor-text": {}, + "cursor": {}, + "dash-circle-dotted": {}, + "dash-circle-fill": {}, + "dash-circle": {}, + "dash-square-dotted": {}, + "dash-square-fill": {}, + "dash-square": {}, + "dash": {}, + "diagram-2-fill": {}, + "diagram-2": {}, + "diagram-3-fill": {}, + "diagram-3": {}, + "diamond-fill": {}, + "diamond-half": {}, + "diamond": {}, + "dice-1-fill": {}, + "dice-1": {}, + "dice-2-fill": {}, + "dice-2": {}, + "dice-3-fill": {}, + "dice-3": {}, + "dice-4-fill": {}, + "dice-4": {}, + "dice-5-fill": {}, + "dice-5": {}, + "dice-6-fill": {}, + "dice-6": {}, + "disc-fill": {}, + "disc": {}, + "discord": {}, + "display-fill": {}, + "display": {}, + "distribute-horizontal": {}, + "distribute-vertical": {}, + "door-closed-fill": {}, + "door-closed": {}, + "door-open-fill": {}, + "door-open": {}, + "dot": {}, + "download": {}, + "droplet-fill": {}, + "droplet-half": {}, + "droplet": {}, + "earbuds": {}, + "easel-fill": {}, + "easel": {}, + "egg-fill": {}, + "egg-fried": {}, + "egg": {}, + "eject-fill": {}, + "eject": {}, + "emoji-angry-fill": {}, + "emoji-angry": {}, + "emoji-dizzy-fill": {}, + "emoji-dizzy": {}, + "emoji-expressionless-fill": {}, + "emoji-expressionless": {}, + "emoji-frown-fill": {}, + "emoji-frown": {}, + "emoji-heart-eyes-fill": {}, + "emoji-heart-eyes": {}, + "emoji-laughing-fill": {}, + "emoji-laughing": {}, + "emoji-neutral-fill": {}, + "emoji-neutral": {}, + "emoji-smile-fill": {}, + "emoji-smile-upside-down-fill": {}, + "emoji-smile-upside-down": {}, + "emoji-smile": {}, + "emoji-sunglasses-fill": {}, + "emoji-sunglasses": {}, + "emoji-wink-fill": {}, + "emoji-wink": {}, + "envelope-fill": {}, + "envelope-open-fill": {}, + "envelope-open": {}, + "envelope": {}, + "eraser-fill": {}, + "eraser": {}, + "exclamation-circle-fill": {}, + "exclamation-circle": {}, + "exclamation-diamond-fill": {}, + "exclamation-diamond": {}, + "exclamation-octagon-fill": {}, + "exclamation-octagon": {}, + "exclamation-square-fill": {}, + "exclamation-square": {}, + "exclamation-triangle-fill": {}, + "exclamation-triangle": {}, + "exclamation": {}, + "exclude": {}, + "eye-fill": {}, + "eye-slash-fill": {}, + "eye-slash": {}, + "eye": {}, + "eyedropper": {}, + "eyeglasses": {}, + "facebook": {}, + "file-arrow-down-fill": {}, + "file-arrow-down": {}, + "file-arrow-up-fill": {}, + "file-arrow-up": {}, + "file-bar-graph-fill": {}, + "file-bar-graph": {}, + "file-binary-fill": {}, + "file-binary": {}, + "file-break-fill": {}, + "file-break": {}, + "file-check-fill": {}, + "file-check": {}, + "file-code-fill": {}, + "file-code": {}, + "file-diff-fill": {}, + "file-diff": {}, + "file-earmark-arrow-down-fill": {}, + "file-earmark-arrow-down": {}, + "file-earmark-arrow-up-fill": {}, + "file-earmark-arrow-up": {}, + "file-earmark-bar-graph-fill": {}, + "file-earmark-bar-graph": {}, + "file-earmark-binary-fill": {}, + "file-earmark-binary": {}, + "file-earmark-break-fill": {}, + "file-earmark-break": {}, + "file-earmark-check-fill": {}, + "file-earmark-check": {}, + "file-earmark-code-fill": {}, + "file-earmark-code": {}, + "file-earmark-diff-fill": {}, + "file-earmark-diff": {}, + "file-earmark-easel-fill": {}, + "file-earmark-easel": {}, + "file-earmark-excel-fill": {}, + "file-earmark-excel": {}, + "file-earmark-fill": {}, + "file-earmark-font-fill": {}, + "file-earmark-font": {}, + "file-earmark-image-fill": {}, + "file-earmark-image": {}, + "file-earmark-lock-fill": {}, + "file-earmark-lock": {}, + "file-earmark-lock2-fill": {}, + "file-earmark-lock2": {}, + "file-earmark-medical-fill": {}, + "file-earmark-medical": {}, + "file-earmark-minus-fill": {}, + "file-earmark-minus": {}, + "file-earmark-music-fill": {}, + "file-earmark-music": {}, + "file-earmark-person-fill": {}, + "file-earmark-person": {}, + "file-earmark-play-fill": {}, + "file-earmark-play": {}, + "file-earmark-plus-fill": {}, + "file-earmark-plus": {}, + "file-earmark-post-fill": {}, + "file-earmark-post": {}, + "file-earmark-ppt-fill": {}, + "file-earmark-ppt": {}, + "file-earmark-richtext-fill": {}, + "file-earmark-richtext": {}, + "file-earmark-ruled-fill": {}, + "file-earmark-ruled": {}, + "file-earmark-slides-fill": {}, + "file-earmark-slides": {}, + "file-earmark-spreadsheet-fill": {}, + "file-earmark-spreadsheet": {}, + "file-earmark-text-fill": {}, + "file-earmark-text": {}, + "file-earmark-word-fill": {}, + "file-earmark-word": {}, + "file-earmark-x-fill": {}, + "file-earmark-x": {}, + "file-earmark-zip-fill": {}, + "file-earmark-zip": {}, + "file-earmark": {}, + "file-easel-fill": {}, + "file-easel": {}, + "file-excel-fill": {}, + "file-excel": {}, + "file-fill": {}, + "file-font-fill": {}, + "file-font": {}, + "file-image-fill": {}, + "file-image": {}, + "file-lock-fill": {}, + "file-lock": {}, + "file-lock2-fill": {}, + "file-lock2": {}, + "file-medical-fill": {}, + "file-medical": {}, + "file-minus-fill": {}, + "file-minus": {}, + "file-music-fill": {}, + "file-music": {}, + "file-person-fill": {}, + "file-person": {}, + "file-play-fill": {}, + "file-play": {}, + "file-plus-fill": {}, + "file-plus": {}, + "file-post-fill": {}, + "file-post": {}, + "file-ppt-fill": {}, + "file-ppt": {}, + "file-richtext-fill": {}, + "file-richtext": {}, + "file-ruled-fill": {}, + "file-ruled": {}, + "file-slides-fill": {}, + "file-slides": {}, + "file-spreadsheet-fill": {}, + "file-spreadsheet": {}, + "file-text-fill": {}, + "file-text": {}, + "file-word-fill": {}, + "file-word": {}, + "file-x-fill": {}, + "file-x": {}, + "file-zip-fill": {}, + "file-zip": {}, + "file": {}, + "files-alt": {}, + "files": {}, + "film": {}, + "filter-circle-fill": {}, + "filter-circle": {}, + "filter-left": {}, + "filter-right": {}, + "filter-square-fill": {}, + "filter-square": {}, + "filter": {}, + "flag-fill": {}, + "flag": {}, + "flower1": {}, + "flower2": {}, + "flower3": {}, + "folder-check": {}, + "folder-fill": {}, + "folder-minus": {}, + "folder-plus": {}, + "folder-symlink-fill": {}, + "folder-symlink": {}, + "folder-x": {}, + "folder": {}, + "folder2-open": {}, + "folder2": {}, + "fonts": {}, + "forward-fill": {}, + "forward": {}, + "front": {}, + "fullscreen-exit": {}, + "fullscreen": {}, + "funnel-fill": {}, + "funnel": {}, + "gear-fill": {}, + "gear-wide-connected": {}, + "gear-wide": {}, + "gear": {}, + "gem": {}, + "geo-alt-fill": {}, + "geo-alt": {}, + "geo-fill": {}, + "geo": {}, + "gift-fill": {}, + "gift": {}, + "github": {}, + "globe": {}, + "globe2": {}, + "google": {}, + "graph-down": {}, + "graph-up": {}, + "grid-1x2-fill": {}, + "grid-1x2": {}, + "grid-3x2-gap-fill": {}, + "grid-3x2-gap": {}, + "grid-3x2": {}, + "grid-3x3-gap-fill": {}, + "grid-3x3-gap": {}, + "grid-3x3": {}, + "grid-fill": {}, + "grid": {}, + "grip-horizontal": {}, + "grip-vertical": {}, + "hammer": {}, + "hand-index-fill": {}, + "hand-index-thumb-fill": {}, + "hand-index-thumb": {}, + "hand-index": {}, + "hand-thumbs-down-fill": {}, + "hand-thumbs-down": {}, + "hand-thumbs-up-fill": {}, + "hand-thumbs-up": {}, + "handbag-fill": {}, + "handbag": {}, + "hash": {}, + "hdd-fill": {}, + "hdd-network-fill": {}, + "hdd-network": {}, + "hdd-rack-fill": {}, + "hdd-rack": {}, + "hdd-stack-fill": {}, + "hdd-stack": {}, + "hdd": {}, + "headphones": {}, + "headset": {}, + "heart-fill": {}, + "heart-half": {}, + "heart": {}, + "heptagon-fill": {}, + "heptagon-half": {}, + "heptagon": {}, + "hexagon-fill": {}, + "hexagon-half": {}, + "hexagon": {}, + "hourglass-bottom": {}, + "hourglass-split": {}, + "hourglass-top": {}, + "hourglass": {}, + "house-door-fill": {}, + "house-door": {}, + "house-fill": {}, + "house": {}, + "hr": {}, + "hurricane": {}, + "image-alt": {}, + "image-fill": {}, + "image": {}, + "images": {}, + "inbox-fill": {}, + "inbox": {}, + "inboxes-fill": {}, + "inboxes": {}, + "info-circle-fill": {}, + "info-circle": {}, + "info-square-fill": {}, + "info-square": {}, + "info": {}, + "input-cursor-text": {}, + "input-cursor": {}, + "instagram": {}, + "intersect": {}, + "journal-album": {}, + "journal-arrow-down": {}, + "journal-arrow-up": {}, + "journal-bookmark-fill": {}, + "journal-bookmark": {}, + "journal-check": {}, + "journal-code": {}, + "journal-medical": {}, + "journal-minus": {}, + "journal-plus": {}, + "journal-richtext": {}, + "journal-text": {}, + "journal-x": {}, + "journal": {}, + "journals": {}, + "joystick": {}, + "justify-left": {}, + "justify-right": {}, + "justify": {}, + "kanban-fill": {}, + "kanban": {}, + "key-fill": {}, + "key": {}, + "keyboard-fill": {}, + "keyboard": {}, + "ladder": {}, + "lamp-fill": {}, + "lamp": {}, + "laptop-fill": {}, + "laptop": {}, + "layer-backward": {}, + "layer-forward": {}, + "layers-fill": {}, + "layers-half": {}, + "layers": {}, + "layout-sidebar-inset-reverse": {}, + "layout-sidebar-inset": {}, + "layout-sidebar-reverse": {}, + "layout-sidebar": {}, + "layout-split": {}, + "layout-text-sidebar-reverse": {}, + "layout-text-sidebar": {}, + "layout-text-window-reverse": {}, + "layout-text-window": {}, + "layout-three-columns": {}, + "layout-wtf": {}, + "life-preserver": {}, + "lightbulb-fill": {}, + "lightbulb-off-fill": {}, + "lightbulb-off": {}, + "lightbulb": {}, + "lightning-charge-fill": {}, + "lightning-charge": {}, + "lightning-fill": {}, + "lightning": {}, + "link-45deg": {}, + "link": {}, + "linkedin": {}, + "list-check": {}, + "list-nested": {}, + "list-ol": {}, + "list-stars": {}, + "list-task": {}, + "list-ul": {}, + "list": {}, + "lock-fill": {}, + "lock": {}, + "mailbox": {}, + "mailbox2": {}, + "map-fill": {}, + "map": {}, + "markdown-fill": {}, + "markdown": {}, + "mask": {}, + "megaphone-fill": {}, + "megaphone": {}, + "menu-app-fill": {}, + "menu-app": {}, + "menu-button-fill": {}, + "menu-button-wide-fill": {}, + "menu-button-wide": {}, + "menu-button": {}, + "menu-down": {}, + "menu-up": {}, + "mic-fill": {}, + "mic-mute-fill": {}, + "mic-mute": {}, + "mic": {}, + "minecart-loaded": {}, + "minecart": {}, + "moisture": {}, + "moon-fill": {}, + "moon-stars-fill": {}, + "moon-stars": {}, + "moon": {}, + "mouse-fill": {}, + "mouse": {}, + "mouse2-fill": {}, + "mouse2": {}, + "mouse3-fill": {}, + "mouse3": {}, + "music-note-beamed": {}, + "music-note-list": {}, + "music-note": {}, + "music-player-fill": {}, + "music-player": {}, + "newspaper": {}, + "node-minus-fill": {}, + "node-minus": {}, + "node-plus-fill": {}, + "node-plus": {}, + "nut-fill": {}, + "nut": {}, + "octagon-fill": {}, + "octagon-half": {}, + "octagon": {}, + "option": {}, + "outlet": {}, + "paint-bucket": {}, + "palette-fill": {}, + "palette": {}, + "palette2": {}, + "paperclip": {}, + "paragraph": {}, + "patch-check-fill": {}, + "patch-check": {}, + "patch-exclamation-fill": {}, + "patch-exclamation": {}, + "patch-minus-fill": {}, + "patch-minus": {}, + "patch-plus-fill": {}, + "patch-plus": {}, + "patch-question-fill": {}, + "patch-question": {}, + "pause-btn-fill": {}, + "pause-btn": {}, + "pause-circle-fill": {}, + "pause-circle": {}, + "pause-fill": {}, + "pause": {}, + "peace-fill": {}, + "peace": {}, + "pen-fill": {}, + "pen": {}, + "pencil-fill": {}, + "pencil-square": {}, + "pencil": {}, + "pentagon-fill": {}, + "pentagon-half": {}, + "pentagon": {}, + "people-fill": {}, + "people": {}, + "percent": {}, + "person-badge-fill": {}, + "person-badge": {}, + "person-bounding-box": {}, + "person-check-fill": {}, + "person-check": {}, + "person-circle": {}, + "person-dash-fill": {}, + "person-dash": {}, + "person-fill": {}, + "person-lines-fill": {}, + "person-plus-fill": {}, + "person-plus": {}, + "person-square": {}, + "person-x-fill": {}, + "person-x": {}, + "person": {}, + "phone-fill": {}, + "phone-landscape-fill": {}, + "phone-landscape": {}, + "phone-vibrate-fill": {}, + "phone-vibrate": {}, + "phone": {}, + "pie-chart-fill": {}, + "pie-chart": {}, + "pin-angle-fill": {}, + "pin-angle": {}, + "pin-fill": {}, + "pin": {}, + "pip-fill": {}, + "pip": {}, + "play-btn-fill": {}, + "play-btn": {}, + "play-circle-fill": {}, + "play-circle": {}, + "play-fill": {}, + "play": {}, + "plug-fill": {}, + "plug": {}, + "plus-circle-dotted": {}, + "plus-circle-fill": {}, + "plus-circle": {}, + "plus-square-dotted": {}, + "plus-square-fill": {}, + "plus-square": {}, + "plus": {}, + "power": {}, + "printer-fill": {}, + "printer": {}, + "puzzle-fill": {}, + "puzzle": {}, + "question-circle-fill": {}, + "question-circle": {}, + "question-diamond-fill": {}, + "question-diamond": {}, + "question-octagon-fill": {}, + "question-octagon": {}, + "question-square-fill": {}, + "question-square": {}, + "question": {}, + "rainbow": {}, + "receipt-cutoff": {}, + "receipt": {}, + "reception-0": {}, + "reception-1": {}, + "reception-2": {}, + "reception-3": {}, + "reception-4": {}, + "record-btn-fill": {}, + "record-btn": {}, + "record-circle-fill": {}, + "record-circle": {}, + "record-fill": {}, + "record": {}, + "record2-fill": {}, + "record2": {}, + "reply-all-fill": {}, + "reply-all": {}, + "reply-fill": {}, + "reply": {}, + "rss-fill": {}, + "rss": {}, + "rulers": {}, + "save-fill": {}, + "save": {}, + "save2-fill": {}, + "save2": {}, + "scissors": {}, + "screwdriver": {}, + "search": {}, + "segmented-nav": {}, + "server": {}, + "share-fill": {}, + "share": {}, + "shield-check": {}, + "shield-exclamation": {}, + "shield-fill-check": {}, + "shield-fill-exclamation": {}, + "shield-fill-minus": {}, + "shield-fill-plus": {}, + "shield-fill-x": {}, + "shield-fill": {}, + "shield-lock-fill": {}, + "shield-lock": {}, + "shield-minus": {}, + "shield-plus": {}, + "shield-shaded": {}, + "shield-slash-fill": {}, + "shield-slash": {}, + "shield-x": {}, + "shield": {}, + "shift-fill": {}, + "shift": {}, + "shop-window": {}, + "shop": {}, + "shuffle": {}, + "signpost-2-fill": {}, + "signpost-2": {}, + "signpost-fill": {}, + "signpost-split-fill": {}, + "signpost-split": {}, + "signpost": {}, + "sim-fill": {}, + "sim": {}, + "skip-backward-btn-fill": {}, + "skip-backward-btn": {}, + "skip-backward-circle-fill": {}, + "skip-backward-circle": {}, + "skip-backward-fill": {}, + "skip-backward": {}, + "skip-end-btn-fill": {}, + "skip-end-btn": {}, + "skip-end-circle-fill": {}, + "skip-end-circle": {}, + "skip-end-fill": {}, + "skip-end": {}, + "skip-forward-btn-fill": {}, + "skip-forward-btn": {}, + "skip-forward-circle-fill": {}, + "skip-forward-circle": {}, + "skip-forward-fill": {}, + "skip-forward": {}, + "skip-start-btn-fill": {}, + "skip-start-btn": {}, + "skip-start-circle-fill": {}, + "skip-start-circle": {}, + "skip-start-fill": {}, + "skip-start": {}, + "slack": {}, + "slash-circle-fill": {}, + "slash-circle": {}, + "slash-square-fill": {}, + "slash-square": {}, + "slash": {}, + "sliders": {}, + "smartwatch": {}, + "snow": {}, + "snow2": {}, + "snow3": {}, + "sort-alpha-down-alt": {}, + "sort-alpha-down": {}, + "sort-alpha-up-alt": {}, + "sort-alpha-up": {}, + "sort-down-alt": {}, + "sort-down": {}, + "sort-numeric-down-alt": {}, + "sort-numeric-down": {}, + "sort-numeric-up-alt": {}, + "sort-numeric-up": {}, + "sort-up-alt": {}, + "sort-up": {}, + "soundwave": {}, + "speaker-fill": {}, + "speaker": {}, + "speedometer": {}, + "speedometer2": {}, + "spellcheck": {}, + "square-fill": {}, + "square-half": {}, + "square": {}, + "stack": {}, + "star-fill": {}, + "star-half": {}, + "star": {}, + "stars": {}, + "stickies-fill": {}, + "stickies": {}, + "sticky-fill": {}, + "sticky": {}, + "stop-btn-fill": {}, + "stop-btn": {}, + "stop-circle-fill": {}, + "stop-circle": {}, + "stop-fill": {}, + "stop": {}, + "stoplights-fill": {}, + "stoplights": {}, + "stopwatch-fill": {}, + "stopwatch": {}, + "subtract": {}, + "suit-club-fill": {}, + "suit-club": {}, + "suit-diamond-fill": {}, + "suit-diamond": {}, + "suit-heart-fill": {}, + "suit-heart": {}, + "suit-spade-fill": {}, + "suit-spade": {}, + "sun-fill": {}, + "sun": {}, + "sunglasses": {}, + "sunrise-fill": {}, + "sunrise": {}, + "sunset-fill": {}, + "sunset": {}, + "symmetry-horizontal": {}, + "symmetry-vertical": {}, + "table": {}, + "tablet-fill": {}, + "tablet-landscape-fill": {}, + "tablet-landscape": {}, + "tablet": {}, + "tag-fill": {}, + "tag": {}, + "tags-fill": {}, + "tags": {}, + "telegram": {}, + "telephone-fill": {}, + "telephone-forward-fill": {}, + "telephone-forward": {}, + "telephone-inbound-fill": {}, + "telephone-inbound": {}, + "telephone-minus-fill": {}, + "telephone-minus": {}, + "telephone-outbound-fill": {}, + "telephone-outbound": {}, + "telephone-plus-fill": {}, + "telephone-plus": {}, + "telephone-x-fill": {}, + "telephone-x": {}, + "telephone": {}, + "terminal-fill": {}, + "terminal": {}, + "text-center": {}, + "text-indent-left": {}, + "text-indent-right": {}, + "text-left": {}, + "text-paragraph": {}, + "text-right": {}, + "textarea-resize": {}, + "textarea-t": {}, + "textarea": {}, + "thermometer-half": {}, + "thermometer-high": {}, + "thermometer-low": {}, + "thermometer-snow": {}, + "thermometer-sun": {}, + "thermometer": {}, + "three-dots-vertical": {}, + "three-dots": {}, + "toggle-off": {}, + "toggle-on": {}, + "toggle2-off": {}, + "toggle2-on": {}, + "toggles": {}, + "toggles2": {}, + "tools": {}, + "tornado": {}, + "trash-fill": {}, + "trash": {}, + "trash2-fill": {}, + "trash2": {}, + "tree-fill": {}, + "tree": {}, + "triangle-fill": {}, + "triangle-half": {}, + "triangle": {}, + "trophy-fill": {}, + "trophy": {}, + "tropical-storm": {}, + "truck-flatbed": {}, + "truck": {}, + "tsunami": {}, + "tv-fill": {}, + "tv": {}, + "twitch": {}, + "twitter": {}, + "type-bold": {}, + "type-h1": {}, + "type-h2": {}, + "type-h3": {}, + "type-italic": {}, + "type-strikethrough": {}, + "type-underline": {}, + "type": {}, + "ui-checks-grid": {}, + "ui-checks": {}, + "ui-radios-grid": {}, + "ui-radios": {}, + "umbrella-fill": {}, + "umbrella": {}, + "union": {}, + "unlock-fill": {}, + "unlock": {}, + "upc-scan": {}, + "upc": {}, + "upload": {}, + "vector-pen": {}, + "view-list": {}, + "view-stacked": {}, + "vinyl-fill": {}, + "vinyl": {}, + "voicemail": {}, + "volume-down-fill": {}, + "volume-down": {}, + "volume-mute-fill": {}, + "volume-mute": {}, + "volume-off-fill": {}, + "volume-off": {}, + "volume-up-fill": {}, + "volume-up": {}, + "vr": {}, + "wallet-fill": {}, + "wallet": {}, + "wallet2": {}, + "watch": {}, + "water": {}, + "whatsapp": {}, + "wifi-1": {}, + "wifi-2": {}, + "wifi-off": {}, + "wifi": {}, + "wind": {}, + "window-dock": {}, + "window-sidebar": {}, + "window": {}, + "wrench": {}, + "x-circle-fill": {}, + "x-circle": {}, + "x-diamond-fill": {}, + "x-diamond": {}, + "x-octagon-fill": {}, + "x-octagon": {}, + "x-square-fill": {}, + "x-square": {}, + "x": {}, + "youtube": {}, + "zoom-in": {}, + "zoom-out": {}, + "bank": {}, + "bank2": {}, + "bell-slash-fill": {}, + "bell-slash": {}, + "cash-coin": {}, + "check-lg": {}, + "coin": {}, + "currency-bitcoin": {}, + "currency-dollar": {}, + "currency-euro": {}, + "currency-exchange": {}, + "currency-pound": {}, + "currency-yen": {}, + "dash-lg": {}, + "exclamation-lg": {}, + "file-earmark-pdf-fill": {}, + "file-earmark-pdf": {}, + "file-pdf-fill": {}, + "file-pdf": {}, + "gender-ambiguous": {}, + "gender-female": {}, + "gender-male": {}, + "gender-trans": {}, + "headset-vr": {}, + "info-lg": {}, + "mastodon": {}, + "messenger": {}, + "piggy-bank-fill": {}, + "piggy-bank": {}, + "pin-map-fill": {}, + "pin-map": {}, + "plus-lg": {}, + "question-lg": {}, + "recycle": {}, + "reddit": {}, + "safe-fill": {}, + "safe2-fill": {}, + "safe2": {}, + "sd-card-fill": {}, + "sd-card": {}, + "skype": {}, + "slash-lg": {}, + "translate": {}, + "x-lg": {}, + "safe": {}, + "apple": {}, + "microsoft": {}, + "windows": {}, + "behance": {}, + "dribbble": {}, + "line": {}, + "medium": {}, + "paypal": {}, + "pinterest": {}, + "signal": {}, + "snapchat": {}, + "spotify": {}, + "stack-overflow": {}, + "strava": {}, + "wordpress": {}, + "vimeo": {}, + "activity": {}, + "easel2-fill": {}, + "easel2": {}, + "easel3-fill": {}, + "easel3": {}, + "fan": {}, + "fingerprint": {}, + "graph-down-arrow": {}, + "graph-up-arrow": {}, + "hypnotize": {}, + "magic": {}, + "person-rolodex": {}, + "person-video": {}, + "person-video2": {}, + "person-video3": {}, + "person-workspace": {}, + "radioactive": {}, + "webcam-fill": {}, + "webcam": {}, + "yin-yang": {}, + "bandaid-fill": {}, + "bandaid": {}, + "bluetooth": {}, + "body-text": {}, + "boombox": {}, + "boxes": {}, + "dpad-fill": {}, + "dpad": {}, + "ear-fill": {}, + "ear": {}, + "envelope-check-fill": {}, + "envelope-check": {}, + "envelope-dash-fill": {}, + "envelope-dash": {}, + "envelope-exclamation-fill": {}, + "envelope-exclamation": {}, + "envelope-plus-fill": {}, + "envelope-plus": {}, + "envelope-slash-fill": {}, + "envelope-slash": {}, + "envelope-x-fill": {}, + "envelope-x": {}, + "explicit-fill": {}, + "explicit": {}, + "git": {}, + "infinity": {}, + "list-columns-reverse": {}, + "list-columns": {}, + "meta": {}, + "nintendo-switch": {}, + "pc-display-horizontal": {}, + "pc-display": {}, + "pc-horizontal": {}, + "pc": {}, + "playstation": {}, + "plus-slash-minus": {}, + "projector-fill": {}, + "projector": {}, + "qr-code-scan": {}, + "qr-code": {}, + "quora": {}, + "quote": {}, + "robot": {}, + "send-check-fill": {}, + "send-check": {}, + "send-dash-fill": {}, + "send-dash": {}, + "send-exclamation-fill": {}, + "send-exclamation": {}, + "send-fill": {}, + "send-plus-fill": {}, + "send-plus": {}, + "send-slash-fill": {}, + "send-slash": {}, + "send-x-fill": {}, + "send-x": {}, + "send": {}, + "steam": {}, + "terminal-dash": {}, + "terminal-plus": {}, + "terminal-split": {}, + "ticket-detailed-fill": {}, + "ticket-detailed": {}, + "ticket-fill": {}, + "ticket-perforated-fill": {}, + "ticket-perforated": {}, + "ticket": {}, + "tiktok": {}, + "window-dash": {}, + "window-desktop": {}, + "window-fullscreen": {}, + "window-plus": {}, + "window-split": {}, + "window-stack": {}, + "window-x": {}, + "xbox": {}, + "ethernet": {}, + "hdmi-fill": {}, + "hdmi": {}, + "usb-c-fill": {}, + "usb-c": {}, + "usb-fill": {}, + "usb-plug-fill": {}, + "usb-plug": {}, + "usb-symbol": {}, + "usb": {}, + "boombox-fill": {}, + "displayport": {}, + "gpu-card": {}, + "memory": {}, + "modem-fill": {}, + "modem": {}, + "motherboard-fill": {}, + "motherboard": {}, + "optical-audio-fill": {}, + "optical-audio": {}, + "pci-card": {}, + "router-fill": {}, + "router": {}, + "thunderbolt-fill": {}, + "thunderbolt": {}, + "usb-drive-fill": {}, + "usb-drive": {}, + "usb-micro-fill": {}, + "usb-micro": {}, + "usb-mini-fill": {}, + "usb-mini": {}, + "cloud-haze2": {}, + "device-hdd-fill": {}, + "device-hdd": {}, + "device-ssd-fill": {}, + "device-ssd": {}, + "displayport-fill": {}, + "mortarboard-fill": {}, + "mortarboard": {}, + "terminal-x": {}, + "arrow-through-heart-fill": {}, + "arrow-through-heart": {}, + "badge-sd-fill": {}, + "badge-sd": {}, + "bag-heart-fill": {}, + "bag-heart": {}, + "balloon-fill": {}, + "balloon-heart-fill": {}, + "balloon-heart": {}, + "balloon": {}, + "box2-fill": {}, + "box2-heart-fill": {}, + "box2-heart": {}, + "box2": {}, + "braces-asterisk": {}, + "calendar-heart-fill": {}, + "calendar-heart": {}, + "calendar2-heart-fill": {}, + "calendar2-heart": {}, + "chat-heart-fill": {}, + "chat-heart": {}, + "chat-left-heart-fill": {}, + "chat-left-heart": {}, + "chat-right-heart-fill": {}, + "chat-right-heart": {}, + "chat-square-heart-fill": {}, + "chat-square-heart": {}, + "clipboard-check-fill": {}, + "clipboard-data-fill": {}, + "clipboard-fill": {}, + "clipboard-heart-fill": {}, + "clipboard-heart": {}, + "clipboard-minus-fill": {}, + "clipboard-plus-fill": {}, + "clipboard-pulse": {}, + "clipboard-x-fill": {}, + "clipboard2-check-fill": {}, + "clipboard2-check": {}, + "clipboard2-data-fill": {}, + "clipboard2-data": {}, + "clipboard2-fill": {}, + "clipboard2-heart-fill": {}, + "clipboard2-heart": {}, + "clipboard2-minus-fill": {}, + "clipboard2-minus": {}, + "clipboard2-plus-fill": {}, + "clipboard2-plus": {}, + "clipboard2-pulse-fill": {}, + "clipboard2-pulse": {}, + "clipboard2-x-fill": {}, + "clipboard2-x": {}, + "clipboard2": {}, + "emoji-kiss-fill": {}, + "emoji-kiss": {}, + "envelope-heart-fill": {}, + "envelope-heart": {}, + "envelope-open-heart-fill": {}, + "envelope-open-heart": {}, + "envelope-paper-fill": {}, + "envelope-paper-heart-fill": {}, + "envelope-paper-heart": {}, + "envelope-paper": {}, + "filetype-aac": {}, + "filetype-ai": {}, + "filetype-bmp": {}, + "filetype-cs": {}, + "filetype-css": {}, + "filetype-csv": {}, + "filetype-doc": {}, + "filetype-docx": {}, + "filetype-exe": {}, + "filetype-gif": {}, + "filetype-heic": {}, + "filetype-html": {}, + "filetype-java": {}, + "filetype-jpg": {}, + "filetype-js": {}, + "filetype-jsx": {}, + "filetype-key": {}, + "filetype-m4p": {}, + "filetype-md": {}, + "filetype-mdx": {}, + "filetype-mov": {}, + "filetype-mp3": {}, + "filetype-mp4": {}, + "filetype-otf": {}, + "filetype-pdf": {}, + "filetype-php": {}, + "filetype-png": {}, + "filetype-ppt": {}, + "filetype-psd": {}, + "filetype-py": {}, + "filetype-raw": {}, + "filetype-rb": {}, + "filetype-sass": {}, + "filetype-scss": {}, + "filetype-sh": {}, + "filetype-svg": {}, + "filetype-tiff": {}, + "filetype-tsx": {}, + "filetype-ttf": {}, + "filetype-txt": {}, + "filetype-wav": {}, + "filetype-woff": {}, + "filetype-xls": {}, + "filetype-xml": {}, + "filetype-yml": {}, + "heart-arrow": {}, + "heart-pulse-fill": {}, + "heart-pulse": {}, + "heartbreak-fill": {}, + "heartbreak": {}, + "hearts": {}, + "hospital-fill": {}, + "hospital": {}, + "house-heart-fill": {}, + "house-heart": {}, + "incognito": {}, + "magnet-fill": {}, + "magnet": {}, + "person-heart": {}, + "person-hearts": {}, + "phone-flip": {}, + "plugin": {}, + "postage-fill": {}, + "postage-heart-fill": {}, + "postage-heart": {}, + "postage": {}, + "postcard-fill": {}, + "postcard-heart-fill": {}, + "postcard-heart": {}, + "postcard": {}, + "search-heart-fill": {}, + "search-heart": {}, + "sliders2-vertical": {}, + "sliders2": {}, + "trash3-fill": {}, + "trash3": {}, + "valentine": {}, + "valentine2": {}, + "wrench-adjustable-circle-fill": {}, + "wrench-adjustable-circle": {}, + "wrench-adjustable": {}, + "filetype-json": {}, + "filetype-pptx": {}, + "filetype-xlsx": {}, + "1-circle-fill": {}, + "1-circle": {}, + "1-square-fill": {}, + "1-square": {}, + "2-circle-fill": {}, + "2-circle": {}, + "2-square-fill": {}, + "2-square": {}, + "3-circle-fill": {}, + "3-circle": {}, + "3-square-fill": {}, + "3-square": {}, + "4-circle-fill": {}, + "4-circle": {}, + "4-square-fill": {}, + "4-square": {}, + "5-circle-fill": {}, + "5-circle": {}, + "5-square-fill": {}, + "5-square": {}, + "6-circle-fill": {}, + "6-circle": {}, + "6-square-fill": {}, + "6-square": {}, + "7-circle-fill": {}, + "7-circle": {}, + "7-square-fill": {}, + "7-square": {}, + "8-circle-fill": {}, + "8-circle": {}, + "8-square-fill": {}, + "8-square": {}, + "9-circle-fill": {}, + "9-circle": {}, + "9-square-fill": {}, + "9-square": {}, + "airplane-engines-fill": {}, + "airplane-engines": {}, + "airplane-fill": {}, + "airplane": {}, + "alexa": {}, + "alipay": {}, + "android": {}, + "android2": {}, + "box-fill": {}, + "box-seam-fill": {}, + "browser-chrome": {}, + "browser-edge": {}, + "browser-firefox": {}, + "browser-safari": {}, + "c-circle-fill": {}, + "c-circle": {}, + "c-square-fill": {}, + "c-square": {}, + "capsule-pill": {}, + "capsule": {}, + "car-front-fill": {}, + "car-front": {}, + "cassette-fill": {}, + "cassette": {}, + "cc-circle-fill": {}, + "cc-circle": {}, + "cc-square-fill": {}, + "cc-square": {}, + "cup-hot-fill": {}, + "cup-hot": {}, + "currency-rupee": {}, + "dropbox": {}, + "escape": {}, + "fast-forward-btn-fill": {}, + "fast-forward-btn": {}, + "fast-forward-circle-fill": {}, + "fast-forward-circle": {}, + "fast-forward-fill": {}, + "fast-forward": {}, + "filetype-sql": {}, + "fire": {}, + "google-play": {}, + "h-circle-fill": {}, + "h-circle": {}, + "h-square-fill": {}, + "h-square": {}, + "indent": {}, + "lungs-fill": {}, + "lungs": {}, + "microsoft-teams": {}, + "p-circle-fill": {}, + "p-circle": {}, + "p-square-fill": {}, + "p-square": {}, + "pass-fill": {}, + "pass": {}, + "prescription": {}, + "prescription2": {}, + "r-circle-fill": {}, + "r-circle": {}, + "r-square-fill": {}, + "r-square": {}, + "repeat-1": {}, + "repeat": {}, + "rewind-btn-fill": {}, + "rewind-btn": {}, + "rewind-circle-fill": {}, + "rewind-circle": {}, + "rewind-fill": {}, + "rewind": {}, + "train-freight-front-fill": {}, + "train-freight-front": {}, + "train-front-fill": {}, + "train-front": {}, + "train-lightrail-front-fill": {}, + "train-lightrail-front": {}, + "truck-front-fill": {}, + "truck-front": {}, + "ubuntu": {}, + "unindent": {}, + "unity": {}, + "universal-access-circle": {}, + "universal-access": {}, + "virus": {}, + "virus2": {}, + "wechat": {}, + "yelp": {}, + "sign-stop-fill": {}, + "sign-stop-lights-fill": {}, + "sign-stop-lights": {}, + "sign-stop": {}, + "sign-turn-left-fill": {}, + "sign-turn-left": {}, + "sign-turn-right-fill": {}, + "sign-turn-right": {}, + "sign-turn-slight-left-fill": {}, + "sign-turn-slight-left": {}, + "sign-turn-slight-right-fill": {}, + "sign-turn-slight-right": {}, + "sign-yield-fill": {}, + "sign-yield": {}, + "ev-station-fill": {}, + "ev-station": {}, + "fuel-pump-diesel-fill": {}, + "fuel-pump-diesel": {}, + "fuel-pump-fill": {}, + "fuel-pump": {}, + "0-circle-fill": {}, + "0-circle": {}, + "0-square-fill": {}, + "0-square": {}, + "rocket-fill": {}, + "rocket-takeoff-fill": {}, + "rocket-takeoff": {}, + "rocket": {}, + "stripe": {}, + "subscript": {}, + "superscript": {}, + "trello": {}, + "envelope-at-fill": {}, + "envelope-at": {}, + "regex": {}, + "text-wrap": {}, + "sign-dead-end-fill": {}, + "sign-dead-end": {}, + "sign-do-not-enter-fill": {}, + "sign-do-not-enter": {}, + "sign-intersection-fill": {}, + "sign-intersection-side-fill": {}, + "sign-intersection-side": {}, + "sign-intersection-t-fill": {}, + "sign-intersection-t": {}, + "sign-intersection-y-fill": {}, + "sign-intersection-y": {}, + "sign-intersection": {}, + "sign-merge-left-fill": {}, + "sign-merge-left": {}, + "sign-merge-right-fill": {}, + "sign-merge-right": {}, + "sign-no-left-turn-fill": {}, + "sign-no-left-turn": {}, + "sign-no-parking-fill": {}, + "sign-no-parking": {}, + "sign-no-right-turn-fill": {}, + "sign-no-right-turn": {}, + "sign-railroad-fill": {}, + "sign-railroad": {}, + "building-add": {}, + "building-check": {}, + "building-dash": {}, + "building-down": {}, + "building-exclamation": {}, + "building-fill-add": {}, + "building-fill-check": {}, + "building-fill-dash": {}, + "building-fill-down": {}, + "building-fill-exclamation": {}, + "building-fill-gear": {}, + "building-fill-lock": {}, + "building-fill-slash": {}, + "building-fill-up": {}, + "building-fill-x": {}, + "building-fill": {}, + "building-gear": {}, + "building-lock": {}, + "building-slash": {}, + "building-up": {}, + "building-x": {}, + "buildings-fill": {}, + "buildings": {}, + "bus-front-fill": {}, + "bus-front": {}, + "ev-front-fill": {}, + "ev-front": {}, + "globe-americas": {}, + "globe-asia-australia": {}, + "globe-central-south-asia": {}, + "globe-europe-africa": {}, + "house-add-fill": {}, + "house-add": {}, + "house-check-fill": {}, + "house-check": {}, + "house-dash-fill": {}, + "house-dash": {}, + "house-down-fill": {}, + "house-down": {}, + "house-exclamation-fill": {}, + "house-exclamation": {}, + "house-gear-fill": {}, + "house-gear": {}, + "house-lock-fill": {}, + "house-lock": {}, + "house-slash-fill": {}, + "house-slash": {}, + "house-up-fill": {}, + "house-up": {}, + "house-x-fill": {}, + "house-x": {}, + "person-add": {}, + "person-down": {}, + "person-exclamation": {}, + "person-fill-add": {}, + "person-fill-check": {}, + "person-fill-dash": {}, + "person-fill-down": {}, + "person-fill-exclamation": {}, + "person-fill-gear": {}, + "person-fill-lock": {}, + "person-fill-slash": {}, + "person-fill-up": {}, + "person-fill-x": {}, + "person-gear": {}, + "person-lock": {}, + "person-slash": {}, + "person-up": {}, + "scooter": {}, + "taxi-front-fill": {}, + "taxi-front": {}, + "amd": {}, + "database-add": {}, + "database-check": {}, + "database-dash": {}, + "database-down": {}, + "database-exclamation": {}, + "database-fill-add": {}, + "database-fill-check": {}, + "database-fill-dash": {}, + "database-fill-down": {}, + "database-fill-exclamation": {}, + "database-fill-gear": {}, + "database-fill-lock": {}, + "database-fill-slash": {}, + "database-fill-up": {}, + "database-fill-x": {}, + "database-fill": {}, + "database-gear": {}, + "database-lock": {}, + "database-slash": {}, + "database-up": {}, + "database-x": {}, + "database": {}, + "houses-fill": {}, + "houses": {}, + "nvidia": {}, + "person-vcard-fill": {}, + "person-vcard": {}, + "sina-weibo": {}, + "tencent-qq": {}, + "wikipedia": {}, } // IsValid returns true if the input is the name of a Bootstrap icon. func IsValid(name string) bool { - for i := range icons { - if icons[i] == name { - return true - } - } - return false + _, ok := icons[name] + return ok } diff --git a/backend/main.go b/backend/main.go index 008e80f..c17679e 100644 --- a/backend/main.go +++ b/backend/main.go @@ -11,6 +11,7 @@ import ( "codeberg.org/pronounscc/pronouns.cc/backend/server" "github.com/davidbyttow/govips/v2/vips" + "github.com/getsentry/sentry-go" "github.com/go-chi/render" _ "github.com/joho/godotenv/autoload" "github.com/urfave/cli/v2" @@ -23,6 +24,19 @@ var Command = &cli.Command{ } func run(c *cli.Context) error { + // initialize sentry + if dsn := os.Getenv("SENTRY_DSN"); dsn != "" { + // We don't need to check the error here--it's fine if no DSN is set. + _ = sentry.Init(sentry.ClientOptions{ + Dsn: dsn, + Debug: os.Getenv("DEBUG") == "true", + Release: server.Tag, + EnableTracing: os.Getenv("SENTRY_TRACING") == "true", + TracesSampleRate: 0.05, + ProfilesSampleRate: 0.05, + }) + } + // set vips log level to WARN, else it will spam logs on info level vips.LoggingSettings(nil, vips.LogLevelWarning) diff --git a/backend/routes.go b/backend/routes.go index f755929..406e05f 100644 --- a/backend/routes.go +++ b/backend/routes.go @@ -2,7 +2,6 @@ package backend import ( "codeberg.org/pronounscc/pronouns.cc/backend/routes/v1/auth" - "codeberg.org/pronounscc/pronouns.cc/backend/routes/v1/bot" "codeberg.org/pronounscc/pronouns.cc/backend/routes/v1/member" "codeberg.org/pronounscc/pronouns.cc/backend/routes/v1/meta" "codeberg.org/pronounscc/pronouns.cc/backend/routes/v1/mod" @@ -21,7 +20,6 @@ func mountRoutes(s *server.Server) { auth.Mount(s, r) user.Mount(s, r) member.Mount(s, r) - bot.Mount(s, r) meta.Mount(s, r) mod.Mount(s, r) }) diff --git a/backend/routes/v1/auth/discord.go b/backend/routes/v1/auth/discord.go index f22518c..b8a72f3 100644 --- a/backend/routes/v1/auth/discord.go +++ b/backend/routes/v1/auth/discord.go @@ -11,6 +11,7 @@ import ( "emperror.dev/errors" "github.com/bwmarrin/discordgo" "github.com/go-chi/render" + "github.com/jackc/pgx/v5" "github.com/mediocregopher/radix/v4" "github.com/rs/xid" "golang.org/x/oauth2" @@ -61,7 +62,7 @@ func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error { // if the state can't be validated, return if valid, err := s.validateCSRFState(ctx, decoded.State); !valid { if err != nil { - return err + return errors.Wrap(err, "validating state") } return server.APIError{Code: server.ErrInvalidState} @@ -79,7 +80,7 @@ func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error { dg, _ := discordgo.New(token.Type() + " " + token.AccessToken) du, err := dg.User("@me") if err != nil { - return err + return errors.Wrap(err, "getting discord user") } u, err := s.DB.DiscordUser(ctx, du.ID) @@ -90,7 +91,7 @@ func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error { err = s.saveUndeleteToken(ctx, u.ID, token) if err != nil { log.Errorf("saving undelete token: %v", err) - return err + return errors.Wrap(err, "saving undelete token") } render.JSON(w, r, discordCallbackResponse{ @@ -114,7 +115,7 @@ func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error { tokenID := xid.New() token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true) if err != nil { - return err + return errors.Wrap(err, "creating token") } // save token to database @@ -137,7 +138,7 @@ func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error { return nil } else if err != db.ErrUserNotFound { // internal error - return err + return errors.Wrap(err, "getting user") } // no user found, so save a ticket + save their Discord info in Redis @@ -145,7 +146,7 @@ func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error { err = s.DB.SetJSON(ctx, "discord:"+ticket, du, "EX", "600") if err != nil { log.Errorf("setting Discord user for ticket %q: %v", ticket, err) - return err + return errors.Wrap(err, "caching discord user for ticket") } render.JSON(w, r, discordCallbackResponse{ @@ -278,7 +279,7 @@ func (s *Server) discordSignup(w http.ResponseWriter, r *http.Request) error { valid, taken, err := s.DB.UsernameTaken(ctx, req.Username) if err != nil { - return err + return errors.Wrap(err, "checking if username is taken") } if !valid { return server.APIError{Code: server.ErrInvalidUsername} @@ -291,7 +292,12 @@ func (s *Server) discordSignup(w http.ResponseWriter, r *http.Request) error { if err != nil { return errors.Wrap(err, "beginning transaction") } - defer tx.Rollback(ctx) + defer func() { + err := tx.Rollback(ctx) + if err != nil && !errors.Is(err, pgx.ErrTxClosed) { + log.Error("rolling back transaction:", err) + } + }() du := new(discordgo.User) err = s.DB.GetJSON(ctx, "discord:"+req.Ticket, &du) diff --git a/backend/routes/v1/auth/fedi_mastodon.go b/backend/routes/v1/auth/fedi_mastodon.go index 1cae10b..f69a2b3 100644 --- a/backend/routes/v1/auth/fedi_mastodon.go +++ b/backend/routes/v1/auth/fedi_mastodon.go @@ -11,6 +11,7 @@ import ( "codeberg.org/pronounscc/pronouns.cc/backend/server" "emperror.dev/errors" "github.com/go-chi/render" + "github.com/jackc/pgx/v5" "github.com/mediocregopher/radix/v4" "github.com/rs/xid" ) @@ -54,7 +55,7 @@ func (s *Server) mastodonCallback(w http.ResponseWriter, r *http.Request) error // if the state can't be validated, return if valid, err := s.validateCSRFState(ctx, decoded.State); !valid { if err != nil { - return err + return errors.Wrap(err, "validating state") } return server.APIError{Code: server.ErrInvalidState} @@ -111,7 +112,7 @@ func (s *Server) mastodonCallback(w http.ResponseWriter, r *http.Request) error err = s.saveUndeleteToken(ctx, u.ID, token) if err != nil { log.Errorf("saving undelete token: %v", err) - return err + return errors.Wrap(err, "saving undelete token") } render.JSON(w, r, fediCallbackResponse{ @@ -135,7 +136,7 @@ func (s *Server) mastodonCallback(w http.ResponseWriter, r *http.Request) error tokenID := xid.New() token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true) if err != nil { - return err + return errors.Wrap(err, "creating token") } // save token to database @@ -158,7 +159,7 @@ func (s *Server) mastodonCallback(w http.ResponseWriter, r *http.Request) error return nil } else if err != db.ErrUserNotFound { // internal error - return err + return errors.Wrap(err, "getting user") } // no user found, so save a ticket + save their Mastodon info in Redis @@ -166,7 +167,7 @@ func (s *Server) mastodonCallback(w http.ResponseWriter, r *http.Request) error err = s.DB.SetJSON(ctx, "mastodon:"+ticket, mu, "EX", "600") if err != nil { log.Errorf("setting mastoAPI user for ticket %q: %v", ticket, err) - return err + return errors.Wrap(err, "setting user for ticket") } render.JSON(w, r, fediCallbackResponse{ @@ -306,7 +307,7 @@ func (s *Server) mastodonSignup(w http.ResponseWriter, r *http.Request) error { valid, taken, err := s.DB.UsernameTaken(ctx, req.Username) if err != nil { - return err + return errors.Wrap(err, "checking if username is taken") } if !valid { return server.APIError{Code: server.ErrInvalidUsername} @@ -319,7 +320,12 @@ func (s *Server) mastodonSignup(w http.ResponseWriter, r *http.Request) error { if err != nil { return errors.Wrap(err, "beginning transaction") } - defer tx.Rollback(ctx) + defer func() { + err := tx.Rollback(ctx) + if err != nil && !errors.Is(err, pgx.ErrTxClosed) { + log.Error("rolling back transaction:", err) + } + }() mu := new(partialMastodonAccount) err = s.DB.GetJSON(ctx, "mastodon:"+req.Ticket, &mu) diff --git a/backend/routes/v1/auth/fedi_misskey.go b/backend/routes/v1/auth/fedi_misskey.go index 864b852..d162f77 100644 --- a/backend/routes/v1/auth/fedi_misskey.go +++ b/backend/routes/v1/auth/fedi_misskey.go @@ -12,6 +12,7 @@ import ( "codeberg.org/pronounscc/pronouns.cc/backend/server" "emperror.dev/errors" "github.com/go-chi/render" + "github.com/jackc/pgx/v5" "github.com/mediocregopher/radix/v4" "github.com/rs/xid" ) @@ -90,7 +91,7 @@ func (s *Server) misskeyCallback(w http.ResponseWriter, r *http.Request) error { err = s.saveUndeleteToken(ctx, u.ID, token) if err != nil { log.Errorf("saving undelete token: %v", err) - return err + return errors.Wrap(err, "saving undelete token") } render.JSON(w, r, fediCallbackResponse{ @@ -114,7 +115,7 @@ func (s *Server) misskeyCallback(w http.ResponseWriter, r *http.Request) error { tokenID := xid.New() token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true) if err != nil { - return err + return errors.Wrap(err, "creating token") } // save token to database @@ -137,7 +138,7 @@ func (s *Server) misskeyCallback(w http.ResponseWriter, r *http.Request) error { return nil } else if err != db.ErrUserNotFound { // internal error - return err + return errors.Wrap(err, "getting user") } // no user found, so save a ticket + save their Misskey info in Redis @@ -145,7 +146,7 @@ func (s *Server) misskeyCallback(w http.ResponseWriter, r *http.Request) error { err = s.DB.SetJSON(ctx, "misskey:"+ticket, mu.User, "EX", "600") if err != nil { log.Errorf("setting misskey user for ticket %q: %v", ticket, err) - return err + return errors.Wrap(err, "setting user for ticket") } render.JSON(w, r, fediCallbackResponse{ @@ -234,7 +235,7 @@ func (s *Server) misskeySignup(w http.ResponseWriter, r *http.Request) error { valid, taken, err := s.DB.UsernameTaken(ctx, req.Username) if err != nil { - return err + return errors.Wrap(err, "checking if username is taken") } if !valid { return server.APIError{Code: server.ErrInvalidUsername} @@ -247,7 +248,12 @@ func (s *Server) misskeySignup(w http.ResponseWriter, r *http.Request) error { if err != nil { return errors.Wrap(err, "beginning transaction") } - defer tx.Rollback(ctx) + defer func() { + err := tx.Rollback(ctx) + if err != nil && !errors.Is(err, pgx.ErrTxClosed) { + log.Error("rolling back transaction:", err) + } + }() mu := new(partialMisskeyAccount) err = s.DB.GetJSON(ctx, "misskey:"+req.Ticket, &mu) diff --git a/backend/routes/v1/auth/google.go b/backend/routes/v1/auth/google.go index 182c8a6..7904889 100644 --- a/backend/routes/v1/auth/google.go +++ b/backend/routes/v1/auth/google.go @@ -10,6 +10,7 @@ import ( "codeberg.org/pronounscc/pronouns.cc/backend/server" "emperror.dev/errors" "github.com/go-chi/render" + "github.com/jackc/pgx/v5" "github.com/mediocregopher/radix/v4" "github.com/rs/xid" "golang.org/x/oauth2" @@ -60,7 +61,7 @@ func (s *Server) googleCallback(w http.ResponseWriter, r *http.Request) error { // if the state can't be validated, return if valid, err := s.validateCSRFState(ctx, decoded.State); !valid { if err != nil { - return err + return errors.Wrap(err, "validating state") } return server.APIError{Code: server.ErrInvalidState} @@ -109,7 +110,7 @@ func (s *Server) googleCallback(w http.ResponseWriter, r *http.Request) error { err = s.saveUndeleteToken(ctx, u.ID, token) if err != nil { log.Errorf("saving undelete token: %v", err) - return err + return errors.Wrap(err, "saving undelete token") } render.JSON(w, r, googleCallbackResponse{ @@ -133,7 +134,7 @@ func (s *Server) googleCallback(w http.ResponseWriter, r *http.Request) error { tokenID := xid.New() token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true) if err != nil { - return err + return errors.Wrap(err, "creating token") } // save token to database @@ -156,7 +157,7 @@ func (s *Server) googleCallback(w http.ResponseWriter, r *http.Request) error { return nil } else if err != db.ErrUserNotFound { // internal error - return err + return errors.Wrap(err, "getting user") } // no user found, so save a ticket + save their Google info in Redis @@ -164,7 +165,7 @@ func (s *Server) googleCallback(w http.ResponseWriter, r *http.Request) error { err = s.DB.SetJSON(ctx, "google:"+ticket, partialGoogleUser{ID: googleID, Email: googleUsername}, "EX", "600") if err != nil { log.Errorf("setting Google user for ticket %q: %v", ticket, err) - return err + return errors.Wrap(err, "setting user for ticket") } render.JSON(w, r, googleCallbackResponse{ @@ -281,7 +282,7 @@ func (s *Server) googleSignup(w http.ResponseWriter, r *http.Request) error { valid, taken, err := s.DB.UsernameTaken(ctx, req.Username) if err != nil { - return err + return errors.Wrap(err, "checking if username is taken") } if !valid { return server.APIError{Code: server.ErrInvalidUsername} @@ -294,7 +295,12 @@ func (s *Server) googleSignup(w http.ResponseWriter, r *http.Request) error { if err != nil { return errors.Wrap(err, "beginning transaction") } - defer tx.Rollback(ctx) + defer func() { + err := tx.Rollback(ctx) + if err != nil && !errors.Is(err, pgx.ErrTxClosed) { + log.Error("rolling back transaction:", err) + } + }() gu := new(partialGoogleUser) err = s.DB.GetJSON(ctx, "google:"+req.Ticket, &gu) diff --git a/backend/routes/v1/auth/routes.go b/backend/routes/v1/auth/routes.go index 103d43e..2e1a724 100644 --- a/backend/routes/v1/auth/routes.go +++ b/backend/routes/v1/auth/routes.go @@ -185,7 +185,7 @@ func (s *Server) oauthURLs(w http.ResponseWriter, r *http.Request) error { if googleOAuthConfig.ClientID != "" { googleCfg := googleOAuthConfig googleCfg.RedirectURL = req.CallbackDomain + "/auth/login/google" - resp.Google = googleCfg.AuthCodeURL(state) + resp.Google = googleCfg.AuthCodeURL(state) + "&prompt=select_account" } render.JSON(w, r, resp) diff --git a/backend/routes/v1/auth/tokens.go b/backend/routes/v1/auth/tokens.go index e48662d..0f8b8b8 100644 --- a/backend/routes/v1/auth/tokens.go +++ b/backend/routes/v1/auth/tokens.go @@ -5,9 +5,11 @@ import ( "time" "codeberg.org/pronounscc/pronouns.cc/backend/db" + "codeberg.org/pronounscc/pronouns.cc/backend/log" "codeberg.org/pronounscc/pronouns.cc/backend/server" "emperror.dev/errors" "github.com/go-chi/render" + "github.com/jackc/pgx/v5" "github.com/rs/xid" ) @@ -63,7 +65,12 @@ func (s *Server) deleteToken(w http.ResponseWriter, r *http.Request) error { if err != nil { return errors.Wrap(err, "beginning transaction") } - defer tx.Rollback(ctx) + defer func() { + err := tx.Rollback(ctx) + if err != nil && !errors.Is(err, pgx.ErrTxClosed) { + log.Error("rolling back transaction:", err) + } + }() err = s.DB.InvalidateAllTokens(ctx, tx, claims.UserID) if err != nil { diff --git a/backend/routes/v1/auth/tumblr.go b/backend/routes/v1/auth/tumblr.go index d35781a..3463c9d 100644 --- a/backend/routes/v1/auth/tumblr.go +++ b/backend/routes/v1/auth/tumblr.go @@ -12,6 +12,7 @@ import ( "codeberg.org/pronounscc/pronouns.cc/backend/server" "emperror.dev/errors" "github.com/go-chi/render" + "github.com/jackc/pgx/v5" "github.com/mediocregopher/radix/v4" "github.com/rs/xid" "golang.org/x/oauth2" @@ -77,7 +78,7 @@ func (s *Server) tumblrCallback(w http.ResponseWriter, r *http.Request) error { // if the state can't be validated, return if valid, err := s.validateCSRFState(ctx, decoded.State); !valid { if err != nil { - return err + return errors.Wrap(err, "validating state") } return server.APIError{Code: server.ErrInvalidState} @@ -142,7 +143,7 @@ func (s *Server) tumblrCallback(w http.ResponseWriter, r *http.Request) error { err = s.saveUndeleteToken(ctx, u.ID, token) if err != nil { log.Errorf("saving undelete token: %v", err) - return err + return errors.Wrap(err, "saving undelete token") } render.JSON(w, r, tumblrCallbackResponse{ @@ -166,7 +167,7 @@ func (s *Server) tumblrCallback(w http.ResponseWriter, r *http.Request) error { tokenID := xid.New() token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true) if err != nil { - return err + return errors.Wrap(err, "creating token") } // save token to database @@ -189,7 +190,7 @@ func (s *Server) tumblrCallback(w http.ResponseWriter, r *http.Request) error { return nil } else if err != db.ErrUserNotFound { // internal error - return err + return errors.Wrap(err, "getting user") } // no user found, so save a ticket + save their Tumblr info in Redis @@ -197,7 +198,7 @@ func (s *Server) tumblrCallback(w http.ResponseWriter, r *http.Request) error { err = s.DB.SetJSON(ctx, "tumblr:"+ticket, tumblrUserInfo{ID: tumblrID, Name: tumblrName}, "EX", "600") if err != nil { log.Errorf("setting Tumblr user for ticket %q: %v", ticket, err) - return err + return errors.Wrap(err, "setting user for ticket") } render.JSON(w, r, tumblrCallbackResponse{ @@ -314,7 +315,7 @@ func (s *Server) tumblrSignup(w http.ResponseWriter, r *http.Request) error { valid, taken, err := s.DB.UsernameTaken(ctx, req.Username) if err != nil { - return err + return errors.Wrap(err, "checking if username is taken") } if !valid { return server.APIError{Code: server.ErrInvalidUsername} @@ -327,7 +328,12 @@ func (s *Server) tumblrSignup(w http.ResponseWriter, r *http.Request) error { if err != nil { return errors.Wrap(err, "beginning transaction") } - defer tx.Rollback(ctx) + defer func() { + err := tx.Rollback(ctx) + if err != nil && !errors.Is(err, pgx.ErrTxClosed) { + log.Error("rolling back transaction:", err) + } + }() tui := new(tumblrUserInfo) err = s.DB.GetJSON(ctx, "tumblr:"+req.Ticket, &tui) diff --git a/backend/routes/v1/bot/bot.go b/backend/routes/v1/bot/bot.go deleted file mode 100644 index 1fa8f4c..0000000 --- a/backend/routes/v1/bot/bot.go +++ /dev/null @@ -1,183 +0,0 @@ -package bot - -import ( - "crypto/ed25519" - "encoding/hex" - "encoding/json" - "fmt" - "net/http" - "os" - - "codeberg.org/pronounscc/pronouns.cc/backend/db" - "codeberg.org/pronounscc/pronouns.cc/backend/log" - "codeberg.org/pronounscc/pronouns.cc/backend/server" - "github.com/bwmarrin/discordgo" - "github.com/go-chi/chi/v5" - "github.com/go-chi/render" -) - -type Bot struct { - *server.Server - - publicKey ed25519.PublicKey - baseURL string -} - -func (bot *Bot) UserAvatarURL(u db.User) string { - if u.Avatar == nil { - return "" - } - - return bot.baseURL + "/media/users/" + u.ID.String() + "/" + *u.Avatar + ".webp" -} - -func Mount(srv *server.Server, r chi.Router) { - publicKey, err := hex.DecodeString(os.Getenv("DISCORD_PUBLIC_KEY")) - if err != nil { - return - } - - b := &Bot{ - Server: srv, - publicKey: publicKey, - baseURL: os.Getenv("BASE_URL"), - } - - r.HandleFunc("/interactions", b.handle) -} - -func (bot *Bot) handle(w http.ResponseWriter, r *http.Request) { - if !discordgo.VerifyInteraction(r, bot.publicKey) { - http.Error(w, "Forbidden", http.StatusForbidden) - return - } - - var ev *discordgo.InteractionCreate - - if err := json.NewDecoder(r.Body).Decode(&ev); err != nil { - http.Error(w, "Bad Request", http.StatusBadRequest) - } - - // we can always respond to ping with pong - if ev.Type == discordgo.InteractionPing { - log.Debug("received ping interaction") - render.JSON(w, r, discordgo.InteractionResponse{ - Type: discordgo.InteractionResponsePong, - }) - return - } - - if ev.Type != discordgo.InteractionApplicationCommand { - return - } - data := ev.ApplicationCommandData() - - switch data.Name { - case "Show user's pronouns": - bot.userPronouns(w, r, ev) - case "Show author's pronouns": - } -} - -func (bot *Bot) userPronouns(w http.ResponseWriter, r *http.Request, ev *discordgo.InteractionCreate) { - ctx := r.Context() - - var du *discordgo.User - for _, user := range ev.ApplicationCommandData().Resolved.Users { - du = user - break - } - if du == nil { - return - } - - u, err := bot.DB.DiscordUser(ctx, du.ID) - if err != nil { - if err == db.ErrUserNotFound { - respond(w, r, &discordgo.MessageEmbed{ - Description: du.String() + " does not have any pronouns set.", - }) - return - } - - log.Errorf("getting discord user: %v", err) - return - } - - avatarURL := du.AvatarURL("") - if url := bot.UserAvatarURL(u); url != "" { - avatarURL = url - } - name := u.Username - if u.DisplayName != nil { - name = fmt.Sprintf("%s (%s)", *u.DisplayName, u.Username) - } - url := bot.baseURL - if url != "" { - url += "/@" + u.Username - } - - e := &discordgo.MessageEmbed{ - Author: &discordgo.MessageEmbedAuthor{ - Name: name, - IconURL: avatarURL, - URL: url, - }, - } - - if u.Bio != nil { - e.Fields = append(e.Fields, &discordgo.MessageEmbedField{ - Name: "Bio", - Value: *u.Bio, - }) - } - - fields, err := bot.DB.UserFields(ctx, u.ID) - if err != nil { - respond(w, r, e) - - log.Errorf("getting user fields: %v", err) - return - } - - for _, field := range fields { - var favs []db.FieldEntry - - for _, e := range field.Entries { - if e.Status == "favourite" { - favs = append(favs, e) - } - } - - if len(favs) == 0 { - continue - } - - var value string - for _, fav := range favs { - if len(fav.Value) > 500 { - break - } - - value += fav.Value + "\n" - } - - e.Fields = append(e.Fields, &discordgo.MessageEmbedField{ - Name: field.Name, - Value: value, - Inline: true, - }) - } - - respond(w, r, e) -} - -func respond(w http.ResponseWriter, r *http.Request, embeds ...*discordgo.MessageEmbed) { - render.JSON(w, r, discordgo.InteractionResponse{ - Type: discordgo.InteractionResponseChannelMessageWithSource, - Data: &discordgo.InteractionResponseData{ - Embeds: embeds, - Flags: discordgo.MessageFlagsEphemeral, - }, - }) -} diff --git a/backend/routes/v1/member/create_member.go b/backend/routes/v1/member/create_member.go index e26ab74..288aafc 100644 --- a/backend/routes/v1/member/create_member.go +++ b/backend/routes/v1/member/create_member.go @@ -11,6 +11,7 @@ import ( "codeberg.org/pronounscc/pronouns.cc/backend/server" "emperror.dev/errors" "github.com/go-chi/render" + "github.com/jackc/pgx/v5" ) type CreateMemberRequest struct { @@ -119,7 +120,12 @@ func (s *Server) createMember(w http.ResponseWriter, r *http.Request) (err error if err != nil { return errors.Wrap(err, "starting transaction") } - defer tx.Rollback(ctx) + defer func() { + err := tx.Rollback(ctx) + if err != nil && !errors.Is(err, pgx.ErrTxClosed) { + log.Error("rolling back transaction:", err) + } + }() m, err := s.DB.CreateMember(ctx, tx, claims.UserID, cmr.Name, cmr.DisplayName, cmr.Bio, cmr.Links) if err != nil { @@ -127,14 +133,14 @@ func (s *Server) createMember(w http.ResponseWriter, r *http.Request) (err error return server.APIError{Code: server.ErrMemberNameInUse} } - return err + return errors.Wrap(err, "creating member") } // set names, pronouns, fields err = s.DB.SetMemberNamesPronouns(ctx, tx, m.ID, db.NotNull(cmr.Names), db.NotNull(cmr.Pronouns)) if err != nil { log.Errorf("setting names and pronouns for member %v: %v", m.ID, err) - return err + return errors.Wrap(err, "setting names/pronouns") } m.Names = cmr.Names m.Pronouns = cmr.Pronouns @@ -142,7 +148,7 @@ func (s *Server) createMember(w http.ResponseWriter, r *http.Request) (err error err = s.DB.SetMemberFields(ctx, tx, m.ID, cmr.Fields) if err != nil { log.Errorf("setting fields for member %v: %v", m.ID, err) - return err + return errors.Wrap(err, "setting fields") } if cmr.Avatar != "" { @@ -161,13 +167,13 @@ func (s *Server) createMember(w http.ResponseWriter, r *http.Request) (err error } log.Errorf("converting member avatar: %v", err) - return err + return errors.Wrap(err, "converting avatar") } hash, err := s.DB.WriteMemberAvatar(ctx, m.ID, webp, jpg) if err != nil { log.Errorf("uploading member avatar: %v", err) - return err + return errors.Wrap(err, "uploading avatar") } err = tx.QueryRow(ctx, "UPDATE members SET avatar = $1 WHERE id = $2", hash, m.ID).Scan(&m.Avatar) @@ -180,7 +186,7 @@ func (s *Server) createMember(w http.ResponseWriter, r *http.Request) (err error err = s.DB.UpdateActiveTime(ctx, tx, claims.UserID) if err != nil { log.Errorf("updating last active time for user %v: %v", claims.UserID, err) - return err + return errors.Wrap(err, "updating last active time") } err = tx.Commit(ctx) diff --git a/backend/routes/v1/member/delete_member.go b/backend/routes/v1/member/delete_member.go index 4c2abde..5c8e68a 100644 --- a/backend/routes/v1/member/delete_member.go +++ b/backend/routes/v1/member/delete_member.go @@ -66,7 +66,7 @@ func (s *Server) deleteMember(w http.ResponseWriter, r *http.Request) (err error err = s.DB.UpdateActiveTime(ctx, s.DB, claims.UserID) if err != nil { log.Errorf("updating last active time for user %v: %v", claims.UserID, err) - return err + return errors.Wrap(err, "updating last active time") } render.NoContent(w, r) diff --git a/backend/routes/v1/member/get_member.go b/backend/routes/v1/member/get_member.go index 2a7d5e6..ffea5d5 100644 --- a/backend/routes/v1/member/get_member.go +++ b/backend/routes/v1/member/get_member.go @@ -105,7 +105,7 @@ func (s *Server) getMember(w http.ResponseWriter, r *http.Request) (err error) { u, err := s.DB.User(ctx, m.UserID) if err != nil { - return err + return errors.Wrap(err, "getting user") } if u.DeletedAt != nil { @@ -119,12 +119,12 @@ func (s *Server) getMember(w http.ResponseWriter, r *http.Request) (err error) { fields, err := s.DB.MemberFields(ctx, m.ID) if err != nil { - return err + return errors.Wrap(err, "getting member fields") } flags, err := s.DB.MemberFlags(ctx, m.ID) if err != nil { - return err + return errors.Wrap(err, "getting member flags") } render.JSON(w, r, dbMemberToMember(u, m, fields, flags, isOwnMember)) @@ -159,12 +159,12 @@ func (s *Server) getUserMember(w http.ResponseWriter, r *http.Request) error { fields, err := s.DB.MemberFields(ctx, m.ID) if err != nil { - return err + return errors.Wrap(err, "getting member fields") } flags, err := s.DB.MemberFlags(ctx, m.ID) if err != nil { - return err + return errors.Wrap(err, "getting member flags") } render.JSON(w, r, dbMemberToMember(u, m, fields, flags, isOwnMember)) @@ -189,12 +189,12 @@ func (s *Server) getMeMember(w http.ResponseWriter, r *http.Request) error { fields, err := s.DB.MemberFields(ctx, m.ID) if err != nil { - return err + return errors.Wrap(err, "getting member fields") } flags, err := s.DB.MemberFlags(ctx, m.ID) if err != nil { - return err + return errors.Wrap(err, "getting member flags") } render.JSON(w, r, dbMemberToMember(u, m, fields, flags, true)) diff --git a/backend/routes/v1/member/get_members.go b/backend/routes/v1/member/get_members.go index 6dba566..2442d39 100644 --- a/backend/routes/v1/member/get_members.go +++ b/backend/routes/v1/member/get_members.go @@ -6,6 +6,7 @@ import ( "codeberg.org/pronounscc/pronouns.cc/backend/common" "codeberg.org/pronounscc/pronouns.cc/backend/db" "codeberg.org/pronounscc/pronouns.cc/backend/server" + "emperror.dev/errors" "github.com/go-chi/chi/v5" "github.com/go-chi/render" "github.com/rs/xid" @@ -74,7 +75,7 @@ func (s *Server) getUserMembers(w http.ResponseWriter, r *http.Request) error { ms, err := s.DB.UserMembers(ctx, u.ID, isSelf) if err != nil { - return err + return errors.Wrap(err, "getting members") } render.JSON(w, r, membersToMemberList(ms, isSelf)) @@ -87,7 +88,7 @@ func (s *Server) getMeMembers(w http.ResponseWriter, r *http.Request) error { ms, err := s.DB.UserMembers(ctx, claims.UserID, true) if err != nil { - return err + return errors.Wrap(err, "getting members") } render.JSON(w, r, membersToMemberList(ms, true)) diff --git a/backend/routes/v1/member/patch_member.go b/backend/routes/v1/member/patch_member.go index 8d56a87..c26cf37 100644 --- a/backend/routes/v1/member/patch_member.go +++ b/backend/routes/v1/member/patch_member.go @@ -13,6 +13,7 @@ import ( "emperror.dev/errors" "github.com/go-chi/chi/v5" "github.com/go-chi/render" + "github.com/jackc/pgx/v5" "github.com/rs/xid" ) @@ -220,13 +221,13 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error { } log.Errorf("converting member avatar: %v", err) - return err + return errors.Wrap(err, "converting member avatar") } hash, err := s.DB.WriteMemberAvatar(ctx, m.ID, webp, jpg) if err != nil { log.Errorf("uploading member avatar: %v", err) - return err + return errors.Wrap(err, "writing member avatar") } avatarHash = &hash @@ -244,9 +245,14 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error { tx, err := s.DB.Begin(ctx) if err != nil { log.Errorf("creating transaction: %v", err) - return err + return errors.Wrap(err, "creating transaction") } - defer tx.Rollback(ctx) + defer func() { + err := tx.Rollback(ctx) + if err != nil && !errors.Is(err, pgx.ErrTxClosed) { + log.Error("rolling back transaction:", err) + } + }() m, err = s.DB.UpdateMember(ctx, tx, m.ID, req.Name, req.DisplayName, req.Bio, req.Unlisted, req.Links, avatarHash) if err != nil { @@ -275,7 +281,7 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error { err = s.DB.SetMemberNamesPronouns(ctx, tx, m.ID, names, pronouns) if err != nil { log.Errorf("setting names for member %v: %v", m.ID, err) - return err + return errors.Wrap(err, "setting names/pronouns") } m.Names = names m.Pronouns = pronouns @@ -286,14 +292,14 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error { err = s.DB.SetMemberFields(ctx, tx, m.ID, *req.Fields) if err != nil { log.Errorf("setting fields for member %v: %v", m.ID, err) - return err + return errors.Wrap(err, "setting fields") } fields = *req.Fields } else { fields, err = s.DB.MemberFields(ctx, m.ID) if err != nil { log.Errorf("getting fields for member %v: %v", m.ID, err) - return err + return errors.Wrap(err, "getting fields") } } @@ -306,7 +312,7 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error { } log.Errorf("updating flags for member %v: %v", m.ID, err) - return err + return errors.Wrap(err, "updating flags") } } @@ -314,20 +320,20 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error { err = s.DB.UpdateActiveTime(ctx, tx, claims.UserID) if err != nil { log.Errorf("updating last active time for user %v: %v", claims.UserID, err) - return err + return errors.Wrap(err, "updating last active time") } err = tx.Commit(ctx) if err != nil { log.Errorf("committing transaction: %v", err) - return err + return errors.Wrap(err, "committing transaction") } // get flags to return (we need to return full flag objects, not the array of IDs in the request body) flags, err := s.DB.MemberFlags(ctx, m.ID) if err != nil { log.Errorf("getting user flags: %v", err) - return err + return errors.Wrap(err, "getting flags") } // echo the updated member back on success diff --git a/backend/routes/v1/mod/resolve_report.go b/backend/routes/v1/mod/resolve_report.go index 77e4b8d..c36cb86 100644 --- a/backend/routes/v1/mod/resolve_report.go +++ b/backend/routes/v1/mod/resolve_report.go @@ -10,6 +10,7 @@ import ( "emperror.dev/errors" "github.com/go-chi/chi/v5" "github.com/go-chi/render" + "github.com/jackc/pgx/v5" ) type resolveReportRequest struct { @@ -43,7 +44,12 @@ func (s *Server) resolveReport(w http.ResponseWriter, r *http.Request) error { log.Errorf("creating transaction: %v", err) return errors.Wrap(err, "creating transaction") } - defer tx.Rollback(ctx) + defer func() { + err := tx.Rollback(ctx) + if err != nil && !errors.Is(err, pgx.ErrTxClosed) { + log.Error("rolling back transaction:", err) + } + }() report, err := s.DB.Report(ctx, tx, id) if err != nil { diff --git a/backend/routes/v1/user/delete_user.go b/backend/routes/v1/user/delete_user.go index 1523978..1132f05 100644 --- a/backend/routes/v1/user/delete_user.go +++ b/backend/routes/v1/user/delete_user.go @@ -3,9 +3,11 @@ package user import ( "net/http" + "codeberg.org/pronounscc/pronouns.cc/backend/log" "codeberg.org/pronounscc/pronouns.cc/backend/server" "emperror.dev/errors" "github.com/go-chi/render" + "github.com/jackc/pgx/v5" ) func (s *Server) deleteUser(w http.ResponseWriter, r *http.Request) error { @@ -20,7 +22,12 @@ func (s *Server) deleteUser(w http.ResponseWriter, r *http.Request) error { if err != nil { return errors.Wrap(err, "creating transaction") } - defer tx.Rollback(ctx) + defer func() { + err := tx.Rollback(ctx) + if err != nil && !errors.Is(err, pgx.ErrTxClosed) { + log.Error("rolling back transaction:", err) + } + }() err = s.DB.DeleteUser(ctx, tx, claims.UserID, true, "") if err != nil { diff --git a/backend/routes/v1/user/export.go b/backend/routes/v1/user/export.go index 2dde011..189b3bb 100644 --- a/backend/routes/v1/user/export.go +++ b/backend/routes/v1/user/export.go @@ -7,6 +7,7 @@ import ( "codeberg.org/pronounscc/pronouns.cc/backend/db" "codeberg.org/pronounscc/pronouns.cc/backend/log" "codeberg.org/pronounscc/pronouns.cc/backend/server" + "emperror.dev/errors" "github.com/go-chi/render" ) @@ -71,7 +72,7 @@ func (s *Server) getExport(w http.ResponseWriter, r *http.Request) error { } log.Errorf("getting export for user %v: %v", claims.UserID, err) - return err + return errors.Wrap(err, "getting export") } render.JSON(w, r, dataExportResponse{ diff --git a/backend/routes/v1/user/flags.go b/backend/routes/v1/user/flags.go index b9c3ced..c7b6ad7 100644 --- a/backend/routes/v1/user/flags.go +++ b/backend/routes/v1/user/flags.go @@ -13,6 +13,7 @@ import ( "emperror.dev/errors" "github.com/go-chi/chi/v5" "github.com/go-chi/render" + "github.com/jackc/pgx/v5" "github.com/rs/xid" ) @@ -80,7 +81,12 @@ func (s *Server) postUserFlag(w http.ResponseWriter, r *http.Request) error { if err != nil { return errors.Wrap(err, "starting transaction") } - defer tx.Rollback(ctx) + defer func() { + err := tx.Rollback(ctx) + if err != nil && !errors.Is(err, pgx.ErrTxClosed) { + log.Error("rolling back transaction:", err) + } + }() flag, err := s.DB.CreateFlag(ctx, tx, claims.UserID, req.Name, req.Description) if err != nil { @@ -192,7 +198,12 @@ func (s *Server) patchUserFlag(w http.ResponseWriter, r *http.Request) error { if err != nil { return errors.Wrap(err, "beginning transaction") } - defer tx.Rollback(ctx) + defer func() { + err := tx.Rollback(ctx) + if err != nil && !errors.Is(err, pgx.ErrTxClosed) { + log.Error("rolling back transaction:", err) + } + }() flag, err = s.DB.EditFlag(ctx, tx, flag.ID, req.Name, req.Description, nil) if err != nil { diff --git a/backend/routes/v1/user/get_user.go b/backend/routes/v1/user/get_user.go index af43a4b..297c469 100644 --- a/backend/routes/v1/user/get_user.go +++ b/backend/routes/v1/user/get_user.go @@ -8,6 +8,7 @@ import ( "codeberg.org/pronounscc/pronouns.cc/backend/db" "codeberg.org/pronounscc/pronouns.cc/backend/log" "codeberg.org/pronounscc/pronouns.cc/backend/server" + "emperror.dev/errors" "github.com/go-chi/chi/v5" "github.com/go-chi/render" "github.com/rs/xid" @@ -146,7 +147,7 @@ func (s *Server) getUser(w http.ResponseWriter, r *http.Request) (err error) { } } else if err != nil { log.Errorf("Error getting user by username: %v", err) - return err + return errors.Wrap(err, "getting user") } } @@ -162,13 +163,13 @@ func (s *Server) getUser(w http.ResponseWriter, r *http.Request) (err error) { fields, err := s.DB.UserFields(ctx, u.ID) if err != nil { log.Errorf("Error getting user fields: %v", err) - return err + return errors.Wrap(err, "getting fields") } flags, err := s.DB.UserFlags(ctx, u.ID) if err != nil { log.Errorf("getting user flags: %v", err) - return err + return errors.Wrap(err, "getting flags") } var members []db.Member @@ -176,7 +177,7 @@ func (s *Server) getUser(w http.ResponseWriter, r *http.Request) (err error) { members, err = s.DB.UserMembers(ctx, u.ID, isSelf) if err != nil { log.Errorf("Error getting user members: %v", err) - return err + return errors.Wrap(err, "getting user members") } } @@ -191,25 +192,25 @@ func (s *Server) getMeUser(w http.ResponseWriter, r *http.Request) error { u, err := s.DB.User(ctx, claims.UserID) if err != nil { log.Errorf("Error getting user: %v", err) - return err + return errors.Wrap(err, "getting users") } fields, err := s.DB.UserFields(ctx, u.ID) if err != nil { log.Errorf("Error getting user fields: %v", err) - return err + return errors.Wrap(err, "getting fields") } members, err := s.DB.UserMembers(ctx, u.ID, true) if err != nil { log.Errorf("Error getting user members: %v", err) - return err + return errors.Wrap(err, "getting members") } flags, err := s.DB.UserFlags(ctx, u.ID) if err != nil { log.Errorf("getting user flags: %v", err) - return err + return errors.Wrap(err, "getting flags") } render.JSON(w, r, GetMeResponse{ diff --git a/backend/routes/v1/user/patch_user.go b/backend/routes/v1/user/patch_user.go index fc465b1..6e4f6ab 100644 --- a/backend/routes/v1/user/patch_user.go +++ b/backend/routes/v1/user/patch_user.go @@ -12,6 +12,7 @@ import ( "emperror.dev/errors" "github.com/go-chi/render" "github.com/google/uuid" + "github.com/jackc/pgx/v5" "github.com/rs/xid" ) @@ -195,13 +196,13 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error { } log.Errorf("converting user avatar: %v", err) - return err + return errors.Wrap(err, "converting avatar") } hash, err := s.DB.WriteUserAvatar(ctx, claims.UserID, webp, jpg) if err != nil { log.Errorf("uploading user avatar: %v", err) - return err + return errors.Wrap(err, "uploading avatar") } avatarHash = &hash @@ -219,9 +220,14 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error { tx, err := s.DB.Begin(ctx) if err != nil { log.Errorf("creating transaction: %v", err) - return err + return errors.Wrap(err, "creating transaction") } - defer tx.Rollback(ctx) + defer func() { + err := tx.Rollback(ctx) + if err != nil && !errors.Is(err, pgx.ErrTxClosed) { + log.Error("rolling back transaction:", err) + } + }() // update username if req.Username != nil && *req.Username != u.Username { @@ -243,7 +249,7 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error { u, err = s.DB.UpdateUser(ctx, tx, claims.UserID, req.DisplayName, req.Bio, req.MemberTitle, req.ListPrivate, req.Links, avatarHash, req.Timezone, req.CustomPreferences) if err != nil && errors.Cause(err) != db.ErrNothingToUpdate { log.Errorf("updating user: %v", err) - return err + return errors.Wrap(err, "updating user") } if req.Names != nil || req.Pronouns != nil { @@ -260,7 +266,7 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error { err = s.DB.SetUserNamesPronouns(ctx, tx, claims.UserID, names, pronouns) if err != nil { log.Errorf("setting names for member %v: %v", claims.UserID, err) - return err + return errors.Wrap(err, "setting names/pronouns") } u.Names = names u.Pronouns = pronouns @@ -271,14 +277,14 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error { err = s.DB.SetUserFields(ctx, tx, claims.UserID, *req.Fields) if err != nil { log.Errorf("setting fields for user %v: %v", claims.UserID, err) - return err + return errors.Wrap(err, "setting fields") } fields = *req.Fields } else { fields, err = s.DB.UserFields(ctx, claims.UserID) if err != nil { log.Errorf("getting fields for user %v: %v", claims.UserID, err) - return err + return errors.Wrap(err, "getting fields") } } @@ -291,7 +297,7 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error { } log.Errorf("updating flags for user %v: %v", claims.UserID, err) - return err + return errors.Wrap(err, "updating flags") } } @@ -299,13 +305,13 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error { err = s.DB.UpdateActiveTime(ctx, tx, claims.UserID) if err != nil { log.Errorf("updating last active time for user %v: %v", claims.UserID, err) - return err + return errors.Wrap(err, "updating last active time") } err = tx.Commit(ctx) if err != nil { log.Errorf("committing transaction: %v", err) - return err + return errors.Wrap(err, "committing transaction") } // get fedi instance name if the user has a linked fedi account @@ -321,7 +327,7 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error { flags, err := s.DB.UserFlags(ctx, u.ID) if err != nil { log.Errorf("getting user flags: %v", err) - return err + return errors.Wrap(err, "getting flags") } // echo the updated user back on success diff --git a/backend/routes/v2/user/get_settings.go b/backend/routes/v2/user/get_settings.go index d618831..096c0e0 100644 --- a/backend/routes/v2/user/get_settings.go +++ b/backend/routes/v2/user/get_settings.go @@ -5,6 +5,7 @@ import ( "codeberg.org/pronounscc/pronouns.cc/backend/log" "codeberg.org/pronounscc/pronouns.cc/backend/server" + "emperror.dev/errors" "github.com/go-chi/render" ) @@ -13,7 +14,7 @@ func (s *Server) GetSettings(w http.ResponseWriter, r *http.Request) (err error) u, err := s.DB.User(r.Context(), claims.UserID) if err != nil { log.Errorf("getting user: %v", err) - return err + return errors.Wrap(err, "getting user") } render.JSON(w, r, u.Settings) diff --git a/backend/server/errors.go b/backend/server/errors.go index b4b8b07..063e773 100644 --- a/backend/server/errors.go +++ b/backend/server/errors.go @@ -1,10 +1,14 @@ package server import ( + "context" "fmt" "net/http" "codeberg.org/pronounscc/pronouns.cc/backend/log" + "emperror.dev/errors" + "github.com/getsentry/sentry-go" + "github.com/go-chi/chi/v5" "github.com/go-chi/render" ) @@ -12,6 +16,11 @@ import ( // The inner HandlerFunc additionally returns an error. func WrapHandler(hn func(w http.ResponseWriter, r *http.Request) error) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { + hub := sentry.GetHubFromContext(r.Context()) + if hub == nil { + hub = sentry.CurrentHub().Clone() + } + err := hn(w, r) if err != nil { // if the function returned an API error, just render that verbatim @@ -24,10 +33,20 @@ func WrapHandler(hn func(w http.ResponseWriter, r *http.Request) error) http.Han return } - // otherwise, we log the error and return an internal server error message - log.Errorf("error in http handler: %v", err) + rctx := chi.RouteContext(r.Context()) + hub.ConfigureScope(func(scope *sentry.Scope) { + scope.SetTag("method", rctx.RouteMethod) + scope.SetTag("path", rctx.RoutePattern()) + }) - apiErr := APIError{Code: ErrInternalServerError} + var eventID *sentry.EventID = nil + if isExpectedError(err) { + log.Infof("expected error in handler for %v %v, ignoring", rctx.RouteMethod, rctx.RoutePattern()) + } else { + log.Errorf("error in handler for %v %v: %v", rctx.RouteMethod, rctx.RoutePattern(), err) + eventID = hub.CaptureException(err) + } + apiErr := APIError{ID: eventID, Code: ErrInternalServerError} apiErr.prepare() render.Status(r, apiErr.Status) @@ -36,12 +55,17 @@ func WrapHandler(hn func(w http.ResponseWriter, r *http.Request) error) http.Han } } +func isExpectedError(err error) bool { + return errors.Is(err, context.Canceled) +} + // APIError is an object returned by the API when an error occurs. // It implements the error interface and can be returned by handlers. type APIError struct { - Code int `json:"code"` - Message string `json:"message,omitempty"` - Details string `json:"details,omitempty"` + Code int `json:"code"` + ID *sentry.EventID `json:"id,omitempty"` + Message string `json:"message,omitempty"` + Details string `json:"details,omitempty"` RatelimitReset *int `json:"ratelimit_reset,omitempty"` diff --git a/backend/server/sentry.go b/backend/server/sentry.go new file mode 100644 index 0000000..9641dec --- /dev/null +++ b/backend/server/sentry.go @@ -0,0 +1,89 @@ +package server + +import ( + "context" + "fmt" + "net/http" + + "github.com/getsentry/sentry-go" + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" +) + +func (s *Server) sentry(handler http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor) + + ctx := r.Context() + hub := sentry.GetHubFromContext(ctx) + if hub == nil { + hub = sentry.CurrentHub().Clone() + ctx = sentry.SetHubOnContext(ctx, hub) + } + + options := []sentry.SpanOption{ + sentry.WithOpName("http.server"), + sentry.ContinueFromRequest(r), + sentry.WithTransactionSource(sentry.SourceURL), + } + // We don't mind getting an existing transaction back so we don't need to + // check if it is. + transaction := sentry.StartTransaction(ctx, + fmt.Sprintf("%s %s", r.Method, r.URL.Path), + options..., + ) + defer transaction.Finish() + r = r.WithContext(transaction.Context()) + hub.Scope().SetRequest(r) + defer recoverWithSentry(hub, r) + handler.ServeHTTP(ww, r) + + transaction.Status = httpStatusToSentryStatus(ww.Status()) + rctx := chi.RouteContext(r.Context()) + transaction.Name = rctx.RouteMethod + " " + rctx.RoutePattern() + }) +} + +func recoverWithSentry(hub *sentry.Hub, r *http.Request) { + if err := recover(); err != nil { + hub.RecoverWithContext( + context.WithValue(r.Context(), sentry.RequestContextKey, r), + err, + ) + } +} + +func httpStatusToSentryStatus(status int) sentry.SpanStatus { + // c.f. https://develop.sentry.dev/sdk/event-payloads/span/ + + if status >= 200 && status < 400 { + return sentry.SpanStatusOK + } + + switch status { + case 499: + return sentry.SpanStatusCanceled + case 500: + return sentry.SpanStatusInternalError + case 400: + return sentry.SpanStatusInvalidArgument + case 504: + return sentry.SpanStatusDeadlineExceeded + case 404: + return sentry.SpanStatusNotFound + case 409: + return sentry.SpanStatusAlreadyExists + case 403: + return sentry.SpanStatusPermissionDenied + case 429: + return sentry.SpanStatusResourceExhausted + case 501: + return sentry.SpanStatusUnimplemented + case 503: + return sentry.SpanStatusUnavailable + case 401: + return sentry.SpanStatusUnauthenticated + default: + return sentry.SpanStatusUnknown + } +} diff --git a/backend/server/server.go b/backend/server/server.go index 399ead0..30cd552 100644 --- a/backend/server/server.go +++ b/backend/server/server.go @@ -50,6 +50,9 @@ func New() (*Server, error) { s.Router.Use(middleware.Logger) } s.Router.Use(middleware.Recoverer) + // add Sentry tracing handler + s.Router.Use(s.sentry) + // add CORS s.Router.Use(cors.Handler(cors.Options{ AllowedOrigins: []string{"https://*", "http://*"}, @@ -97,23 +100,23 @@ func New() (*Server, error) { // set scopes // users - rateLimiter.Scope("GET", "/users/*", 60) - rateLimiter.Scope("PATCH", "/users/@me", 10) + _ = rateLimiter.Scope("GET", "/users/*", 60) + _ = rateLimiter.Scope("PATCH", "/users/@me", 10) // members - rateLimiter.Scope("GET", "/users/*/members", 60) - rateLimiter.Scope("GET", "/users/*/members/*", 60) + _ = rateLimiter.Scope("GET", "/users/*/members", 60) + _ = rateLimiter.Scope("GET", "/users/*/members/*", 60) - rateLimiter.Scope("POST", "/members", 10) - rateLimiter.Scope("GET", "/members/*", 60) - rateLimiter.Scope("PATCH", "/members/*", 20) - rateLimiter.Scope("DELETE", "/members/*", 5) + _ = rateLimiter.Scope("POST", "/members", 10) + _ = rateLimiter.Scope("GET", "/members/*", 60) + _ = rateLimiter.Scope("PATCH", "/members/*", 20) + _ = rateLimiter.Scope("DELETE", "/members/*", 5) // auth - rateLimiter.Scope("*", "/auth/*", 20) - rateLimiter.Scope("*", "/auth/tokens", 10) - rateLimiter.Scope("*", "/auth/invites", 10) - rateLimiter.Scope("POST", "/auth/discord/*", 10) + _ = rateLimiter.Scope("*", "/auth/*", 20) + _ = rateLimiter.Scope("*", "/auth/tokens", 10) + _ = rateLimiter.Scope("*", "/auth/invites", 10) + _ = rateLimiter.Scope("POST", "/auth/discord/*", 10) s.Router.Use(rateLimiter.Handler()) diff --git a/docs/api/endpoints/index.md b/docs/api/endpoints/index.md index 0b14002..556f1c9 100644 --- a/docs/api/endpoints/index.md +++ b/docs/api/endpoints/index.md @@ -45,9 +45,10 @@ A user can set custom word preferences, which can have custom icons and tooltips ## Pride flag -| Field | Type | Description | -| ----------- | ------- | ------------------------------------- | -| id | string | the flag's unique ID | -| hash | string | the flag's [image hash](/api/#images) | -| name | string | the flag's name | -| description | string? | the flag's description or alt text | +| Field | Type | Description | +| ----------- | --------- | ------------------------------------- | +| id | string | the flag's unique ID | +| id_new | snowflake | the flag's unique snowflake ID | +| hash | string | the flag's [image hash](/api/#images) | +| name | string | the flag's name | +| description | string? | the flag's description or alt text | diff --git a/docs/api/endpoints/members.md b/docs/api/endpoints/members.md index 2a41a4c..fb1438d 100644 --- a/docs/api/endpoints/members.md +++ b/docs/api/endpoints/members.md @@ -5,6 +5,7 @@ | Field | Type | Description | | ------------ | ---------------------------------------------------- | --------------------------------------------------------------------------------- | | id | string | the member's unique ID | +| id_new | snowflake | the member's unique snowflake ID | | sid | string | the member's 6-letter short ID | | name | string | the member's name | | display_name | string? | the member's display name or nickname | @@ -23,6 +24,7 @@ | Field | Type | Description | | ------------------ | ---------------------------------------------------- | -------------------------------------- | | id | string | the user's unique ID | +| id_new | snowflake | the user's unique snowflake ID | | name | string | the user's username | | display_name | string? | the user's display name or nickname | | avatar | string? | the user's [avatar hash](/api/#images) | @@ -95,18 +97,18 @@ Returns the updated [member](./members#member-object) on success. #### Request body parameters -| Field | Type | Description | -| ------------------ | -------------------- | --------------------------------------------------------------------------------------------------- | -| name | string | the member's new name. Must be unique per user, and be between 1 and 100 characters. | -| display_name | string | the member's new display name. Must be between 1 and 100 characters | -| bio | string | the member's new bio. Must be between 1 and 1000 characters | -| links | string[] | the member's new profile links. Maximum 25 links, and links must be between 1 and 256 characters | -| names | field_entry[] | the member's new preferred names | -| pronouns | pronoun_entry[] | the member's new preferred pronouns | -| fields | field[] | the member's new profile fields | -| flags | string[] | the member's new flags. This must be an array of [pride flag](./#pride-flag) IDs. | -| avatar | string | the member's new avatar. This must be a PNG, JPEG, or WebP image, encoded in base64 data URI format | -| unlisted | bool | whether or not the member should be hidden from the member list | +| Field | Type | Description | +| ------------ | --------------- | ------------------------------------------------------------------------------------------------------ | +| name | string | the member's new name. Must be unique per user, and be between 1 and 100 characters. | +| display_name | string | the member's new display name. Must be between 1 and 100 characters | +| bio | string | the member's new bio. Must be between 1 and 1000 characters | +| links | string[] | the member's new profile links. Maximum 25 links, and links must be between 1 and 256 characters | +| names | field_entry[] | the member's new preferred names | +| pronouns | pronoun_entry[] | the member's new preferred pronouns | +| fields | field[] | the member's new profile fields | +| flags | string[] | the member's new flags. This must be an array of [pride flag](./#pride-flag) IDs, _not_ snowflake IDs. | +| avatar | string | the member's new avatar. This must be a PNG, JPEG, or WebP image, encoded in base64 data URI format | +| unlisted | bool | whether or not the member should be hidden from the member list | ### Delete member diff --git a/docs/api/endpoints/users.md b/docs/api/endpoints/users.md index 961a0b2..b671f3a 100644 --- a/docs/api/endpoints/users.md +++ b/docs/api/endpoints/users.md @@ -5,6 +5,7 @@ | Field | Type | Description | | ------------------ | ---------------------------------------------------- | --------------------------------------------------------------------------- | | id | string | the user's unique ID | +| id_new | snowflake | the user's unique snowflake ID | | sid | string | the user's 5 letter short ID | | name | string | the user's username | | display_name | string? | the user's display name or nickname | @@ -45,6 +46,7 @@ | Field | Type | Description | | ------------ | ----------------------------------- | ---------------------------------------- | | id | string | the member's unique ID | +| id_new | snowflake | the member's unique snowflake ID | | sid | string | the member's 6-letter short ID | | name | string | the member's name | | display_name | string? | the member's display name or nickname | @@ -89,7 +91,7 @@ Returns the updated [user](./users#user-object) object on success. | names | field_entry[] | the user's new preferred names | | pronouns | pronoun_entry[] | the user's new preferred pronouns | | fields | field[] | the user's new profile fields | -| flags | string[] | the user's new flags. This must be an array of [pride flag](./#pride-flag) IDs. | +| flags | string[] | the user's new flags. This must be an array of [pride flag](./#pride-flag) IDs, _not_ snowflake IDs. | | avatar | string | the user's new avatar. This must be a PNG, JPEG, or WebP image, encoded in base64 data URI format | | timezone | string | the user's new timezone. Must be in IANA timezone database format | | list_private | bool | whether or not the user's member list should be hidden | diff --git a/docs/api/errors.md b/docs/api/errors.md index afb5245..dbb6cc0 100644 --- a/docs/api/errors.md +++ b/docs/api/errors.md @@ -2,12 +2,13 @@ If there is an error in your request, or the server encounters an error while processing it, an error object will be returned. -| Field | Type | Description | -| --------------- | ------- | --------------------------------------------------------------- | -| code | int | an [error code](./errors#error-codes) | -| message | ?string | a human-readable description of the error | -| details | ?string | more details about the error, most often for bad request errors | -| ratelimit_reset | ?int | the unix time when an expired rate limit will reset | +| Field | Type | Description | +| --------------- | ------- | ------------------------------------------------------------------- | +| code | int | an [error code](./errors#error-codes) | +| id | ?string | an opaque Sentry event ID, only returned for internal server errors | +| message | ?string | a human-readable description of the error | +| details | ?string | more details about the error, most often for bad request errors | +| ratelimit_reset | ?int | the unix time when an expired rate limit will reset | ### Error codes diff --git a/docs/api/index.md b/docs/api/index.md index 0db8486..51b9eec 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -62,19 +62,23 @@ The "type" column in tables is formatted as follows: ## IDs -::: info -pronouns.cc is [planning a transition](https://codeberg.org/pronounscc/pronouns.cc/issues/89) -to [Snowflake IDs](https://en.wikipedia.org/wiki/Snowflake_ID). -The information below pertains to the current ID format. -::: +### Snowflake IDs -The API uses [xid](https://github.com/rs/xid) for unique IDs. These are always serialized as strings. +For [multiple reasons](https://codeberg.org/pronounscc/pronouns.cc/issues/89), +pronouns.cc is transitioning to using snowflakes for unique IDs. These will become the default in the next API version, +but are already returned as `id_new` in the relevant objects (users, members, and flags). + +### xids + +[xid](https://github.com/rs/xid) is the previous unique ID format. These are always serialized as strings. Although xids have timestamp information embedded in them, this is non-trivial to extract. xids are unique across _all_ resources, they are never shared (for example, a user and a member cannot share the same ID). +### prns.cc IDs + Users and members also have an additional ID type, `sid`. These are randomly generated 5 or 6 letter strings, and are used for the prns.cc URL shortener. -They can be rerolled once per hour. +**These can change at any time**, as short IDs can be rerolled once per hour. ## Images diff --git a/frontend/.eslintrc.cjs b/frontend/.eslintrc.cjs index 09dcc6a..4b0d793 100644 --- a/frontend/.eslintrc.cjs +++ b/frontend/.eslintrc.cjs @@ -17,4 +17,15 @@ module.exports = { es2017: true, node: true, }, + rules: { + "no-unused-vars": "off", + "@typescript-eslint/no-unused-vars": [ + "error", + { + argsIgnorePattern: "^_", + destructuredArrayIgnorePattern: "^_", + varsIgnorePattern: "^_", + }, + ], + }, }; diff --git a/frontend/icons.js b/frontend/icons.js index 6b0e67f..844d640 100644 --- a/frontend/icons.js +++ b/frontend/icons.js @@ -16,26 +16,22 @@ writeFileSync("src/icons.ts", `const icons = ${output};\nexport default icons;`) const goCode1 = `// Generated code. DO NOT EDIT package icons -var icons = [...]string{ +var icons = map[string]struct{}{ `; const goCode2 = `} // IsValid returns true if the input is the name of a Bootstrap icon. func IsValid(name string) bool { - for i := range icons { - if icons[i] == name { - return true - } - } - return false + _, ok := icons[name] + return ok } `; let goOutput = goCode1; keys.forEach((element) => { - goOutput += ` "${element}",\n`; + goOutput += ` "${element}": {},\n`; }); goOutput += goCode2; diff --git a/frontend/package.json b/frontend/package.json index a460457..1628226 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,11 +12,13 @@ "format": "prettier --plugin-search-dir . --write ." }, "devDependencies": { - "@sveltejs/adapter-auto": "^2.0.0", - "@sveltejs/adapter-node": "^1.2.3", - "@sveltejs/kit": "^1.15.0", - "@types/luxon": "^3.2.2", - "@types/markdown-it": "^12.2.3", + "@sveltejs/adapter-auto": "^3.0.0", + "@sveltejs/adapter-node": "^2.0.0", + "@sveltejs/kit": "^2.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0", + "@sveltestrap/sveltestrap": "^6.0.5", + "@types/luxon": "^3.3.7", + "@types/markdown-it": "^13.0.7", "@types/node": "^18.15.11", "@types/sanitize-html": "^2.9.0", "@typescript-eslint/eslint-plugin": "^5.57.1", @@ -25,14 +27,13 @@ "eslint-config-prettier": "^8.8.0", "eslint-plugin-svelte3": "^4.0.0", "prettier": "^2.8.7", - "prettier-plugin-svelte": "^2.10.0", - "svelte": "^3.58.0", - "svelte-check": "^3.1.4", + "prettier-plugin-svelte": "^2.10.1", + "svelte": "^4.0.0", + "svelte-check": "^3.4.3", "svelte-hcaptcha": "^0.1.1", - "sveltestrap": "^5.10.0", "tslib": "^2.5.0", - "typescript": "^4.9.5", - "vite": "^4.2.1", + "typescript": "^5.0.0", + "vite": "^5.0.0", "vite-plugin-markdown": "^2.1.0" }, "type": "module", @@ -41,8 +42,8 @@ "@popperjs/core": "^2.11.7", "@sentry/node": "^7.46.0", "base64-arraybuffer": "^1.0.2", - "bootstrap": "5.3.0-alpha1", - "bootstrap-icons": "^1.10.4", + "bootstrap": "^5.3.2", + "bootstrap-icons": "^1.11.2", "jose": "^4.13.1", "luxon": "^3.3.0", "markdown-it": "^13.0.1", diff --git a/frontend/src/app.d.ts b/frontend/src/app.d.ts index d4759fd..9489da5 100644 --- a/frontend/src/app.d.ts +++ b/frontend/src/app.d.ts @@ -16,23 +16,4 @@ declare global { } } -declare module "svelte-hcaptcha" { - import type { SvelteComponent } from "svelte"; - - export interface HCaptchaProps { - sitekey?: string; - apihost?: string; - hl?: string; - reCaptchaCompat?: boolean; - theme?: CaptchaTheme; - size?: string; - } - - declare class HCaptcha extends SvelteComponent { - $$prop_def: HCaptchaProps; - } - - export default HCaptcha; -} - export {}; diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts index e073554..670f914 100644 --- a/frontend/src/hooks.server.ts +++ b/frontend/src/hooks.server.ts @@ -2,7 +2,9 @@ import { PRIVATE_SENTRY_DSN } from "$env/static/private"; import * as Sentry from "@sentry/node"; import type { HandleServerError } from "@sveltejs/kit"; -Sentry.init({ dsn: PRIVATE_SENTRY_DSN }); +if (PRIVATE_SENTRY_DSN) { + Sentry.init({ dsn: PRIVATE_SENTRY_DSN }); +} export const handleError = (({ error, event }) => { console.log(error); diff --git a/frontend/src/lib/api/entities.ts b/frontend/src/lib/api/entities.ts index e245522..5aa5b2a 100644 --- a/frontend/src/lib/api/entities.ts +++ b/frontend/src/lib/api/entities.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-unused-vars */ import { PUBLIC_BASE_URL, PUBLIC_MEDIA_URL } from "$env/static/public"; export const MAX_MEMBERS = 500; diff --git a/frontend/src/lib/components/ActiveLink.svelte b/frontend/src/lib/components/ActiveLink.svelte index d9bc887..969cfbd 100644 --- a/frontend/src/lib/components/ActiveLink.svelte +++ b/frontend/src/lib/components/ActiveLink.svelte @@ -1,5 +1,5 @@ diff --git a/frontend/src/lib/components/FallbackImage.svelte b/frontend/src/lib/components/FallbackImage.svelte index 9572e46..18d086f 100644 --- a/frontend/src/lib/components/FallbackImage.svelte +++ b/frontend/src/lib/components/FallbackImage.svelte @@ -4,6 +4,7 @@ export let urls: string[]; export let alt: string; export let width = 300; + export let lazyLoad = false; const contentTypeFor = (url: string) => { if (url.endsWith(".webp")) { @@ -31,6 +32,7 @@ src={urls[0] || defaultAvatars[0]} {alt} class="rounded-circle img-fluid" + loading={lazyLoad ? "lazy" : "eager"} /> {:else} diff --git a/frontend/src/lib/components/IconButton.svelte b/frontend/src/lib/components/IconButton.svelte index c131a4d..240339a 100644 --- a/frontend/src/lib/components/IconButton.svelte +++ b/frontend/src/lib/components/IconButton.svelte @@ -1,5 +1,5 @@ diff --git a/frontend/src/routes/@[username]/+page.server.ts b/frontend/src/routes/@[username]/+page.server.ts index 447c5e3..0ae4617 100644 --- a/frontend/src/routes/@[username]/+page.server.ts +++ b/frontend/src/routes/@[username]/+page.server.ts @@ -11,7 +11,7 @@ export const load = async ({ params }) => { return resp; } catch (e) { if ((e as APIError).code === ErrorCode.UserNotFound) { - throw error(404, e as APIError); + error(404, e as App.Error); } throw e; diff --git a/frontend/src/routes/@[username]/+page.svelte b/frontend/src/routes/@[username]/+page.svelte index 56542fc..a9fd5b4 100644 --- a/frontend/src/routes/@[username]/+page.svelte +++ b/frontend/src/routes/@[username]/+page.svelte @@ -4,7 +4,6 @@ import { Alert, - Badge, Button, ButtonGroup, Icon, @@ -14,8 +13,8 @@ ModalBody, ModalFooter, Tooltip, - } from "sveltestrap"; - import { DateTime, Duration, FixedOffsetZone, Zone } from "luxon"; + } from "@sveltestrap/sveltestrap"; + import { DateTime, FixedOffsetZone } from "luxon"; import FieldCard from "$lib/components/FieldCard.svelte"; import PronounLink from "$lib/components/PronounLink.svelte"; import PartialMemberCard from "$lib/components/PartialMemberCard.svelte"; @@ -46,6 +45,7 @@ import ProfileFlag from "./ProfileFlag.svelte"; import IconButton from "$lib/components/IconButton.svelte"; import Badges from "./badges/Badges.svelte"; + import PreferencesCheatsheet from "./PreferencesCheatsheet.svelte"; export let data: PageData; @@ -190,14 +190,15 @@ {/if} {#if data.utc_offset} Current time - {currentTime} (UTC{timezone}) + + {currentTime} (UTC{timezone}) {/if} {#if profileEmpty && $userStore?.id === data.id}

- Your profile is empty! You can customize it by going to the edit profileedit profile page. (only you can see this)

@@ -258,6 +259,12 @@ {/each} +
diff --git a/frontend/src/routes/@[username]/PreferencesCheatsheet.svelte b/frontend/src/routes/@[username]/PreferencesCheatsheet.svelte new file mode 100644 index 0000000..ef06482 --- /dev/null +++ b/frontend/src/routes/@[username]/PreferencesCheatsheet.svelte @@ -0,0 +1,65 @@ + + +
+
    + {#each usedDefaultPreferences as pref (pref.id)} +
  • + + {pref.preference.tooltip} +
  • + {/each} +
+ {#if usedCustomPreferences} +
    + {#each usedCustomPreferences as pref (pref.id)} +
  • + + {pref.preference.tooltip} +
  • + {/each} +
+ {/if} +
diff --git a/frontend/src/routes/@[username]/ProfileFlag.svelte b/frontend/src/routes/@[username]/ProfileFlag.svelte index 3ef4c63..6862770 100644 --- a/frontend/src/routes/@[username]/ProfileFlag.svelte +++ b/frontend/src/routes/@[username]/ProfileFlag.svelte @@ -1,6 +1,6 @@ diff --git a/frontend/src/routes/@[username]/[memberName]/edit/fields/+page.svelte b/frontend/src/routes/@[username]/[memberName]/edit/fields/+page.svelte index a77b13a..5cf0b62 100644 --- a/frontend/src/routes/@[username]/[memberName]/edit/fields/+page.svelte +++ b/frontend/src/routes/@[username]/[memberName]/edit/fields/+page.svelte @@ -1,7 +1,7 @@ diff --git a/frontend/src/routes/@[username]/edit/+layout.svelte b/frontend/src/routes/@[username]/edit/+layout.svelte index 6bcb23b..de6dbf3 100644 --- a/frontend/src/routes/@[username]/edit/+layout.svelte +++ b/frontend/src/routes/@[username]/edit/+layout.svelte @@ -2,7 +2,7 @@ import { setContext } from "svelte"; import { writable } from "svelte/store"; import type { LayoutData } from "./$types"; - import { Button, ButtonGroup, Icon, Nav, NavItem } from "sveltestrap"; + import { Button, ButtonGroup, Icon, Nav, NavItem } from "@sveltestrap/sveltestrap"; import type { MeUser, APIError } from "$lib/api/entities"; import ErrorAlert from "$lib/components/ErrorAlert.svelte"; import { addToast, delToast } from "$lib/toast"; diff --git a/frontend/src/routes/@[username]/edit/+layout.ts b/frontend/src/routes/@[username]/edit/+layout.ts index 2fd9c4c..950d945 100644 --- a/frontend/src/routes/@[username]/edit/+layout.ts +++ b/frontend/src/routes/@[username]/edit/+layout.ts @@ -1,6 +1,6 @@ -import type { PrideFlag, APIError, MeUser, PronounsJson } from "$lib/api/entities"; +import type { PrideFlag, MeUser, PronounsJson } from "$lib/api/entities"; import { apiFetchClient } from "$lib/api/fetch"; -import { error, redirect, type Redirect } from "@sveltejs/kit"; +import { error, redirect } from "@sveltejs/kit"; import pronounsRaw from "$lib/pronouns.json"; const pronouns = pronounsRaw as PronounsJson; @@ -13,7 +13,7 @@ export const load = async ({ params }) => { const flags = await apiFetchClient("/users/@me/flags"); if (params.username !== user.name) { - throw redirect(303, `/@${user.name}/edit`); + redirect(303, `/@${user.name}/edit`); } return { @@ -21,8 +21,9 @@ export const load = async ({ params }) => { pronouns: pronouns.autocomplete, flags, }; - } catch (e) { - if ("code" in e) throw error(500, e as APIError); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (e: any) { + if ("code" in e) error(500, e as App.Error); throw e; } }; diff --git a/frontend/src/routes/@[username]/edit/+page.svelte b/frontend/src/routes/@[username]/edit/+page.svelte index e727633..d5c2aeb 100644 --- a/frontend/src/routes/@[username]/edit/+page.svelte +++ b/frontend/src/routes/@[username]/edit/+page.svelte @@ -3,7 +3,7 @@ import type { Writable } from "svelte/store"; import prettyBytes from "pretty-bytes"; import { encode } from "base64-arraybuffer"; - import { FormGroup, Icon, Input } from "sveltestrap"; + import { FormGroup, Icon, Input } from "@sveltestrap/sveltestrap"; import { userAvatars, type MeUser } from "$lib/api/entities"; import FallbackImage from "$lib/components/FallbackImage.svelte"; import EditableName from "$lib/components/edit/EditableName.svelte"; diff --git a/frontend/src/routes/@[username]/edit/bio/+page.svelte b/frontend/src/routes/@[username]/edit/bio/+page.svelte index 2cbbf38..572fa35 100644 --- a/frontend/src/routes/@[username]/edit/bio/+page.svelte +++ b/frontend/src/routes/@[username]/edit/bio/+page.svelte @@ -4,7 +4,7 @@ import { MAX_DESCRIPTION_LENGTH, type MeUser } from "$lib/api/entities"; import { charCount, renderMarkdown } from "$lib/utils"; import MarkdownHelp from "$lib/components/edit/MarkdownHelp.svelte"; - import { Card, CardBody, CardHeader } from "sveltestrap"; + import { Card, CardBody, CardHeader } from "@sveltestrap/sveltestrap"; const user = getContext>("user"); diff --git a/frontend/src/routes/@[username]/edit/fields/+page.svelte b/frontend/src/routes/@[username]/edit/fields/+page.svelte index f09d89d..9ad8d0a 100644 --- a/frontend/src/routes/@[username]/edit/fields/+page.svelte +++ b/frontend/src/routes/@[username]/edit/fields/+page.svelte @@ -1,7 +1,7 @@ diff --git a/frontend/src/routes/page/changelog/+page.svelte b/frontend/src/routes/page/changelog/+page.svelte index 51cc848..bd98e56 100644 --- a/frontend/src/routes/page/changelog/+page.svelte +++ b/frontend/src/routes/page/changelog/+page.svelte @@ -1,5 +1,8 @@ diff --git a/frontend/src/routes/page/terms/+page.svelte b/frontend/src/routes/page/terms/+page.svelte index f56dd69..178e2e3 100644 --- a/frontend/src/routes/page/terms/+page.svelte +++ b/frontend/src/routes/page/terms/+page.svelte @@ -1,4 +1,7 @@ diff --git a/frontend/src/routes/pronouns/[...pronouns]/+page.svelte b/frontend/src/routes/pronouns/[...pronouns]/+page.svelte index fd9536f..09a4329 100644 --- a/frontend/src/routes/pronouns/[...pronouns]/+page.svelte +++ b/frontend/src/routes/pronouns/[...pronouns]/+page.svelte @@ -1,6 +1,6 @@ diff --git a/frontend/src/routes/settings/+layout.svelte b/frontend/src/routes/settings/+layout.svelte index fc225ee..1120447 100644 --- a/frontend/src/routes/settings/+layout.svelte +++ b/frontend/src/routes/settings/+layout.svelte @@ -9,7 +9,7 @@ Modal, ModalBody, ModalFooter, - } from "sveltestrap"; + } from "@sveltestrap/sveltestrap"; import { userStore } from "$lib/store"; import { goto } from "$app/navigation"; import { addToast } from "$lib/toast"; diff --git a/frontend/src/routes/settings/+layout.ts b/frontend/src/routes/settings/+layout.ts index 415bf6c..acf0e5e 100644 --- a/frontend/src/routes/settings/+layout.ts +++ b/frontend/src/routes/settings/+layout.ts @@ -40,7 +40,7 @@ export const load = (async ({ parent }) => { }; } catch (e) { if ((e as APIError).code !== ErrorCode.InternalServerError) { - throw redirect(303, "/auth/login"); + redirect(303, "/auth/login"); } throw e; } diff --git a/frontend/src/routes/settings/+page.svelte b/frontend/src/routes/settings/+page.svelte index 7e98e90..8853f64 100644 --- a/frontend/src/routes/settings/+page.svelte +++ b/frontend/src/routes/settings/+page.svelte @@ -23,9 +23,8 @@ ModalFooter, ModalHeader, Table, - } from "sveltestrap"; + } from "@sveltestrap/sveltestrap"; import type { PageData } from "./$types"; - import { onMount } from "svelte"; import { DateTime } from "luxon"; export let data: PageData; diff --git a/frontend/src/routes/settings/auth/+page.svelte b/frontend/src/routes/settings/auth/+page.svelte index 2d03bb5..c55d9bb 100644 --- a/frontend/src/routes/settings/auth/+page.svelte +++ b/frontend/src/routes/settings/auth/+page.svelte @@ -13,7 +13,7 @@ Modal, ModalBody, ModalFooter, - } from "sveltestrap"; + } from "@sveltestrap/sveltestrap"; import type { PageData } from "./$types"; export let data: PageData; diff --git a/frontend/src/routes/settings/export/+page.svelte b/frontend/src/routes/settings/export/+page.svelte index 31f87e7..97733f6 100644 --- a/frontend/src/routes/settings/export/+page.svelte +++ b/frontend/src/routes/settings/export/+page.svelte @@ -1,7 +1,7 @@