feat: bounty board, task lifecycle, AI work performer flow, notifications (phase 8)

- whitelist HTML sanitizer (stdlib tokenizer) with XSS vector tests
- developer board: pool-scoped visibility, customer/search/minBounty/sort
  filters, stale-task age badges, competing-claims visibility setting,
  saved filters in profile extras
- claims: request/withdraw (developer), approve/decline (consultant) with
  notifications to winners and losers; unassign/abandon back to board
- work tracking: start, sanitized comments with @mention notifications,
  time logging, submit for review
- review queue + review with per-AC checklist stored on the timeline;
  approve writes the immutable bountyAwards row (human assignees only)
- assign-to-AI: §5.2 job submission, HMAC-verified callback endpoint,
  idempotent by jobId, artifacts downloaded into GridFS, failure path
  keeps the task assigned with timeline + notification
- notifications API + bell with unread badge, dropdown, page, WS toasts
- pages: bounty board, my-tasks kanban, task detail (role-driven actions,
  review dialog, AI dialog), review queue, developer pool

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
etalon
2026-06-12 20:11:05 +02:00
parent f87b954f27
commit 70a813edfa
28 changed files with 2764 additions and 14 deletions
+168
View File
@@ -0,0 +1,168 @@
// Package chat implements messaging primitives. sanitize.go is the
// server-side rich-text whitelist sanitizer (§2.2): allowed tags are
// b i u s a code pre ul ol li br p blockquote; only <a> keeps an attribute
// (href, http/https/mailto only). Everything else is escaped as text.
// Implemented with a small hand-written tokenizer — no parser dependency.
package chat
import (
"html"
"strings"
)
var allowedTags = map[string]bool{
"b": true, "i": true, "u": true, "s": true, "a": true, "code": true,
"pre": true, "ul": true, "ol": true, "li": true, "br": true, "p": true,
"blockquote": true,
}
var voidTags = map[string]bool{"br": true}
// SanitizeHTML rewrites untrusted rich text into safe HTML. Unknown tags are
// dropped (their text content kept), attributes are stripped (except a safe
// href on <a>), tags are re-balanced so output never leaks open elements.
func SanitizeHTML(in string) string {
var out strings.Builder
var stack []string
i := 0
for i < len(in) {
c := in[i]
if c != '<' {
j := strings.IndexByte(in[i:], '<')
if j < 0 {
out.WriteString(html.EscapeString(in[i:]))
break
}
out.WriteString(html.EscapeString(in[i : i+j]))
i += j
continue
}
// find the end of the tag
end := strings.IndexByte(in[i:], '>')
if end < 0 {
out.WriteString(html.EscapeString(in[i:]))
break
}
raw := in[i+1 : i+end] // between < and >
i += end + 1
closing := strings.HasPrefix(raw, "/")
raw = strings.TrimPrefix(raw, "/")
raw = strings.TrimSuffix(strings.TrimSpace(raw), "/")
name, attrs := splitTag(raw)
name = strings.ToLower(name)
if !allowedTags[name] {
continue // drop the tag entirely, keep surrounding text
}
if closing {
// close up to the matching open tag, if any
for n := len(stack) - 1; n >= 0; n-- {
if stack[n] == name {
for len(stack) > n {
top := stack[len(stack)-1]
stack = stack[:len(stack)-1]
out.WriteString("</" + top + ">")
}
break
}
}
continue
}
if voidTags[name] {
out.WriteString("<" + name + ">")
continue
}
if name == "a" {
href := safeHref(attrs)
if href == "" {
out.WriteString("<a>")
} else {
out.WriteString(`<a href="` + html.EscapeString(href) + `" rel="noopener noreferrer" target="_blank">`)
}
} else {
out.WriteString("<" + name + ">")
}
stack = append(stack, name)
}
// balance anything left open
for n := len(stack) - 1; n >= 0; n-- {
out.WriteString("</" + stack[n] + ">")
}
return out.String()
}
// splitTag separates the tag name from its raw attribute string.
func splitTag(raw string) (string, string) {
for i := 0; i < len(raw); i++ {
switch raw[i] {
case ' ', '\t', '\n', '\r':
return raw[:i], raw[i+1:]
}
}
return raw, ""
}
// safeHref extracts href and accepts only http, https, or mailto schemes.
func safeHref(attrs string) string {
lower := strings.ToLower(attrs)
idx := strings.Index(lower, "href")
if idx < 0 {
return ""
}
rest := strings.TrimSpace(attrs[idx+4:])
if !strings.HasPrefix(rest, "=") {
return ""
}
rest = strings.TrimSpace(rest[1:])
var val string
switch {
case strings.HasPrefix(rest, `"`):
if end := strings.IndexByte(rest[1:], '"'); end >= 0 {
val = rest[1 : 1+end]
}
case strings.HasPrefix(rest, "'"):
if end := strings.IndexByte(rest[1:], '\''); end >= 0 {
val = rest[1 : 1+end]
}
default:
val = rest
if sp := strings.IndexAny(val, " \t\n"); sp >= 0 {
val = val[:sp]
}
}
val = strings.TrimSpace(val)
low := strings.ToLower(val)
if strings.HasPrefix(low, "http://") || strings.HasPrefix(low, "https://") ||
strings.HasPrefix(low, "mailto:") {
return val
}
return ""
}
// SanitizePlain strips ALL tags, returning escaped plain text (used where
// rich text is not allowed).
func SanitizePlain(in string) string {
var out strings.Builder
i := 0
for i < len(in) {
if in[i] != '<' {
j := strings.IndexByte(in[i:], '<')
if j < 0 {
out.WriteString(html.EscapeString(in[i:]))
break
}
out.WriteString(html.EscapeString(in[i : i+j]))
i += j
continue
}
end := strings.IndexByte(in[i:], '>')
if end < 0 {
out.WriteString(html.EscapeString(in[i:]))
break
}
i += end + 1
}
return out.String()
}