| 1 | diff --git a/internal/daemon/custom_views.go b/internal/daemon/custom_views.go |
| 2 | new file mode 100644 |
| 3 | index 0000000..9f0976f |
| 4 | --- /dev/null |
| 5 | +++ b/internal/daemon/custom_views.go |
| 6 | @@ -0,0 +1,606 @@ |
| 7 | +package daemon |
| 8 | + |
| 9 | +// Custom views hand fenced code blocks to an external transform and keep |
| 10 | +// what comes back as relay-signed artifacts attached to the source event. |
| 11 | +// This file holds the definition, its storage and the owner's management |
| 12 | +// methods; custom_views_transform.go holds the block extraction, the POST |
| 13 | +// and the artifacts, and custom_views_http.go serves them. |
| 14 | + |
| 15 | +import ( |
| 16 | + "context" |
| 17 | + "database/sql" |
| 18 | + "encoding/json" |
| 19 | + "errors" |
| 20 | + "fmt" |
| 21 | + "sort" |
| 22 | + "strconv" |
| 23 | + "strings" |
| 24 | + "time" |
| 25 | + |
| 26 | + "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 27 | + "github.com/FelineStateMachine/tinyrelay/internal/storage" |
| 28 | + "github.com/FelineStateMachine/tinyrelay/internal/views" |
| 29 | +) |
| 30 | + |
| 31 | +const ( |
| 32 | + viewTransform = "view-transform" |
| 33 | + viewKindMax = 16 |
| 34 | + viewLanguageMax = 32 |
| 35 | + viewLanguageLength = 32 |
| 36 | + viewDefaultMaxBytes = 1 << 20 |
| 37 | + viewMaxBytesCap = 4 << 20 |
| 38 | + viewTimeout = 30 * time.Second |
| 39 | + viewMaxAttempts = 3 |
| 40 | + viewPauseFailures = 20 |
| 41 | + viewBackfillLimit = 500 |
| 42 | + viewHourlyPeriod = int64(3600) |
| 43 | + viewResponseMaxBytes = int64(viewMaxBytesCap*4 + 1<<20) |
| 44 | +) |
| 45 | + |
| 46 | +// viewBackoff is the wait before the second and third attempt. |
| 47 | +var viewBackoff = []time.Duration{time.Minute, 5 * time.Minute} |
| 48 | + |
| 49 | +const customViewSchema = `CREATE TABLE IF NOT EXISTS custom_views(name TEXT PRIMARY KEY, kinds TEXT NOT NULL, transform TEXT NOT NULL, trigger TEXT NOT NULL, audience TEXT NOT NULL, languages TEXT NOT NULL, max_bytes INTEGER NOT NULL, secret TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1, created_at INTEGER NOT NULL, last_run_at INTEGER NOT NULL DEFAULT 0, last_status TEXT NOT NULL DEFAULT '', failures INTEGER NOT NULL DEFAULT 0); |
| 50 | +CREATE TABLE IF NOT EXISTS custom_view_artifacts(view TEXT NOT NULL, hash TEXT NOT NULL, event_id TEXT NOT NULL, type TEXT NOT NULL, engine TEXT NOT NULL DEFAULT '', audience TEXT NOT NULL, content TEXT NOT NULL, raw TEXT NOT NULL, created_at INTEGER NOT NULL, PRIMARY KEY(view,hash)); |
| 51 | +CREATE TABLE IF NOT EXISTS custom_view_sources(view TEXT NOT NULL, hash TEXT NOT NULL, source TEXT NOT NULL, block INTEGER NOT NULL, expires INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(view,hash,source)); |
| 52 | +CREATE INDEX IF NOT EXISTS custom_view_sources_source ON custom_view_sources(view,source);` |
| 53 | + |
| 54 | +// customView is one stored definition. The secret never leaves the tenant |
| 55 | +// except once, in the addcustomview answer. |
| 56 | +type customView struct { |
| 57 | + Name string `json:"name"` |
| 58 | + Kinds []int `json:"kinds"` |
| 59 | + Transform string `json:"transform"` |
| 60 | + Trigger string `json:"trigger"` |
| 61 | + Audience string `json:"audience"` |
| 62 | + Languages []string `json:"languages"` |
| 63 | + MaxBytes int `json:"maxBytes"` |
| 64 | + Enabled bool `json:"enabled"` |
| 65 | + CreatedAt int64 `json:"created"` |
| 66 | + LastRunAt int64 `json:"lastRun"` |
| 67 | + LastStatus string `json:"lastStatus"` |
| 68 | + Failures int `json:"failures"` |
| 69 | + secret string |
| 70 | +} |
| 71 | + |
| 72 | +func (v customView) summary() map[string]any { |
| 73 | + state := "active" |
| 74 | + if !v.Enabled { |
| 75 | + state = "paused" |
| 76 | + } |
| 77 | + return map[string]any{"name": v.Name, "kinds": v.Kinds, "transform": v.Transform, "host": callbackHost(v.Transform), "trigger": v.Trigger, "audience": v.Audience, "languages": v.Languages, "maxBytes": v.MaxBytes, "enabled": v.Enabled, "state": state, "created": v.CreatedAt, "lastRun": v.LastRunAt, "lastStatus": v.LastStatus, "failures": v.Failures} |
| 78 | +} |
| 79 | + |
| 80 | +func (v customView) watches(kind int) bool { |
| 81 | + for _, k := range v.Kinds { |
| 82 | + if k == kind { |
| 83 | + return true |
| 84 | + } |
| 85 | + } |
| 86 | + return false |
| 87 | +} |
| 88 | + |
| 89 | +func (t *Tenant) initCustomViews(ctx context.Context) error { |
| 90 | + _, err := t.store.DB().ExecContext(ctx, customViewSchema) |
| 91 | + return err |
| 92 | +} |
| 93 | + |
| 94 | +func customViewMethod(method string) bool { |
| 95 | + return containsString([]string{"listcustomviews", "addcustomview", "removecustomview", "runcustomview", "pausecustomview", "resumecustomview"}, method) |
| 96 | +} |
| 97 | + |
| 98 | +// customViewExecute serves the owner's custom view methods under the view |
| 99 | +// operation. Every change is recorded in the audit log. |
| 100 | +func (t *Tenant) customViewExecute(ctx context.Context, actor, method string, params []json.RawMessage) (result any, err error) { |
| 101 | + ctx, finish := t.app.telemetry.Start(ctx, "view") |
| 102 | + outcome := "error" |
| 103 | + defer func() { |
| 104 | + if err != nil { |
| 105 | + outcome = callbackOutcome(err) |
| 106 | + } |
| 107 | + finish(outcome) |
| 108 | + }() |
| 109 | + role, err := t.community.Role(ctx, actor) |
| 110 | + if err != nil { |
| 111 | + return nil, err |
| 112 | + } |
| 113 | + if role != "owner" { |
| 114 | + return nil, fmt.Errorf("restricted: only the owner may %s", method) |
| 115 | + } |
| 116 | + switch method { |
| 117 | + case "listcustomviews": |
| 118 | + rows, err := t.customViewRows(ctx) |
| 119 | + if err != nil { |
| 120 | + return nil, err |
| 121 | + } |
| 122 | + out := make([]map[string]any, 0, len(rows)) |
| 123 | + for _, row := range rows { |
| 124 | + out = append(out, row.summary()) |
| 125 | + } |
| 126 | + outcome = "ok" |
| 127 | + return out, nil |
| 128 | + case "addcustomview": |
| 129 | + result, err = t.addCustomView(ctx, actor, params) |
| 130 | + default: |
| 131 | + result, err = t.changeCustomView(ctx, actor, method, params) |
| 132 | + } |
| 133 | + if err == nil { |
| 134 | + outcome = "ok" |
| 135 | + } |
| 136 | + return result, err |
| 137 | +} |
| 138 | + |
| 139 | +// customViewOptions is the addcustomview parameter. Kinds and languages may |
| 140 | +// arrive as lists or as comma-separated strings from a plain form. |
| 141 | +type customViewOptions struct { |
| 142 | + Name string `json:"name"` |
| 143 | + Kinds json.RawMessage `json:"kinds"` |
| 144 | + Transform string `json:"transform"` |
| 145 | + Trigger string `json:"trigger"` |
| 146 | + Audience string `json:"audience"` |
| 147 | + Languages json.RawMessage `json:"languages"` |
| 148 | + MaxBytes json.RawMessage `json:"max_bytes"` |
| 149 | + Secret string `json:"secret"` |
| 150 | +} |
| 151 | + |
| 152 | +func (t *Tenant) addCustomView(ctx context.Context, actor string, params []json.RawMessage) (any, error) { |
| 153 | + if len(params) != 1 { |
| 154 | + return nil, errors.New("invalid: addcustomview expects one object with name, kinds, transform, trigger, audience, languages and optional max_bytes and secret") |
| 155 | + } |
| 156 | + var options customViewOptions |
| 157 | + if err := json.Unmarshal(params[0], &options); err != nil { |
| 158 | + return nil, errors.New("invalid: addcustomview expects one object with name, kinds, transform, trigger, audience, languages and optional max_bytes and secret") |
| 159 | + } |
| 160 | + view, err := t.parseCustomView(options) |
| 161 | + if err != nil { |
| 162 | + return nil, err |
| 163 | + } |
| 164 | + generated := false |
| 165 | + secret := strings.TrimSpace(options.Secret) |
| 166 | + if secret == "" { |
| 167 | + secret, err = randomHex(32) |
| 168 | + if err != nil { |
| 169 | + return nil, err |
| 170 | + } |
| 171 | + generated = true |
| 172 | + } else if len(secret) < callbackSecretMin || len(secret) > callbackSecretMax || strings.Trim(secret, "!\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~") != "" { |
| 173 | + return nil, fmt.Errorf("invalid: secret must be %d to %d printable ASCII characters", callbackSecretMin, callbackSecretMax) |
| 174 | + } |
| 175 | + view.secret = secret |
| 176 | + view.Enabled = true |
| 177 | + view.CreatedAt = time.Now().Unix() |
| 178 | + kinds, _ := json.Marshal(view.Kinds) |
| 179 | + languages, _ := json.Marshal(view.Languages) |
| 180 | + _, err = t.store.DB().ExecContext(ctx, `INSERT INTO custom_views(name,kinds,transform,trigger,audience,languages,max_bytes,secret,enabled,created_at) VALUES(?,?,?,?,?,?,?,?,1,?)`, view.Name, string(kinds), view.Transform, view.Trigger, view.Audience, string(languages), view.MaxBytes, secret, view.CreatedAt) |
| 181 | + if err != nil { |
| 182 | + if strings.Contains(err.Error(), "UNIQUE") { |
| 183 | + return nil, errors.New("invalid: a view with that name exists") |
| 184 | + } |
| 185 | + return nil, err |
| 186 | + } |
| 187 | + t.invalidateCustomViews() |
| 188 | + if err := t.community.Record(ctx, actor, "addcustomview", view.Name, ""); err != nil { |
| 189 | + return nil, err |
| 190 | + } |
| 191 | + // Counts only: the transform host, path and secret stay out of the log. |
| 192 | + t.app.telemetry.Logger().Info("custom view added", "tenant", t.meta.Name, "kinds", len(view.Kinds), "languages", len(view.Languages), "generated", generated) |
| 193 | + out := view.summary() |
| 194 | + out["secret"] = secret |
| 195 | + return out, nil |
| 196 | +} |
| 197 | + |
| 198 | +// parseCustomView validates a definition: the name, the kinds it watches, |
| 199 | +// the https transform, the trigger, the audience, the languages and the |
| 200 | +// artifact size limit. |
| 201 | +func (t *Tenant) parseCustomView(options customViewOptions) (customView, error) { |
| 202 | + view := customView{Name: strings.TrimSpace(options.Name), Transform: strings.TrimSpace(options.Transform), Trigger: strings.TrimSpace(options.Trigger), Audience: strings.TrimSpace(options.Audience), MaxBytes: viewDefaultMaxBytes} |
| 203 | + if !views.NamePattern.MatchString(view.Name) { |
| 204 | + return customView{}, errors.New("invalid: name must be 1 to 32 lowercase letters, digits or hyphens") |
| 205 | + } |
| 206 | + kinds, err := intList(options.Kinds) |
| 207 | + if err != nil || len(kinds) == 0 || len(kinds) > viewKindMax { |
| 208 | + return customView{}, fmt.Errorf("invalid: kinds must list 1 to %d event kinds", viewKindMax) |
| 209 | + } |
| 210 | + for _, kind := range kinds { |
| 211 | + if kind < 0 || kind > 65535 { |
| 212 | + return customView{}, errors.New("invalid: kinds must be between 0 and 65535") |
| 213 | + } |
| 214 | + } |
| 215 | + sort.Ints(kinds) |
| 216 | + view.Kinds = dedupeInts(kinds) |
| 217 | + if err := t.checkCallbackURL(view.Transform); err != nil { |
| 218 | + return customView{}, fmt.Errorf("invalid: transform %s", strings.TrimPrefix(err.Error(), "invalid: ")) |
| 219 | + } |
| 220 | + if view.Trigger == "" { |
| 221 | + view.Trigger = "write" |
| 222 | + } |
| 223 | + if view.Trigger != "write" && view.Trigger != "hourly" { |
| 224 | + return customView{}, errors.New("invalid: trigger must be write or hourly") |
| 225 | + } |
| 226 | + if view.Audience == "" { |
| 227 | + view.Audience = "public" |
| 228 | + } |
| 229 | + if view.Audience != "public" && view.Audience != "members" { |
| 230 | + return customView{}, errors.New("invalid: audience must be public or members") |
| 231 | + } |
| 232 | + languages, err := stringList(options.Languages) |
| 233 | + if err != nil || len(languages) == 0 || len(languages) > viewLanguageMax { |
| 234 | + return customView{}, fmt.Errorf("invalid: languages must list 1 to %d fenced block languages", viewLanguageMax) |
| 235 | + } |
| 236 | + seen := map[string]bool{} |
| 237 | + view.Languages = nil |
| 238 | + for _, lang := range languages { |
| 239 | + lang = strings.ToLower(strings.TrimSpace(lang)) |
| 240 | + if lang == "" || len(lang) > viewLanguageLength || strings.Trim(lang, "abcdefghijklmnopqrstuvwxyz0123456789+-_.#") != "" { |
| 241 | + return customView{}, errors.New("invalid: languages must be lowercase words of letters, digits, +, -, _, . or #") |
| 242 | + } |
| 243 | + if !seen[lang] { |
| 244 | + seen[lang] = true |
| 245 | + view.Languages = append(view.Languages, lang) |
| 246 | + } |
| 247 | + } |
| 248 | + if len(options.MaxBytes) > 0 && string(options.MaxBytes) != "null" && string(options.MaxBytes) != `""` { |
| 249 | + var size int |
| 250 | + var text string |
| 251 | + if json.Unmarshal(options.MaxBytes, &size) != nil { |
| 252 | + if json.Unmarshal(options.MaxBytes, &text) != nil { |
| 253 | + return customView{}, errors.New("invalid: max_bytes must be a number") |
| 254 | + } |
| 255 | + if size, err = strconv.Atoi(strings.TrimSpace(text)); err != nil { |
| 256 | + return customView{}, errors.New("invalid: max_bytes must be a number") |
| 257 | + } |
| 258 | + } |
| 259 | + if size < 1 || size > viewMaxBytesCap { |
| 260 | + return customView{}, fmt.Errorf("invalid: max_bytes must be between 1 and %d", viewMaxBytesCap) |
| 261 | + } |
| 262 | + view.MaxBytes = size |
| 263 | + } |
| 264 | + return view, nil |
| 265 | +} |
| 266 | + |
| 267 | +// intList reads a JSON list of integers, a list of numeric strings or a |
| 268 | +// comma-separated string. |
| 269 | +func intList(raw json.RawMessage) ([]int, error) { |
| 270 | + if len(raw) == 0 { |
| 271 | + return nil, nil |
| 272 | + } |
| 273 | + var out []int |
| 274 | + if json.Unmarshal(raw, &out) == nil { |
| 275 | + return out, nil |
| 276 | + } |
| 277 | + items, err := stringList(raw) |
| 278 | + if err != nil { |
| 279 | + return nil, err |
| 280 | + } |
| 281 | + for _, item := range items { |
| 282 | + value, err := strconv.Atoi(strings.TrimSpace(item)) |
| 283 | + if err != nil { |
| 284 | + return nil, err |
| 285 | + } |
| 286 | + out = append(out, value) |
| 287 | + } |
| 288 | + return out, nil |
| 289 | +} |
| 290 | + |
| 291 | +// stringList reads a JSON list of strings or a comma-separated string. |
| 292 | +func stringList(raw json.RawMessage) ([]string, error) { |
| 293 | + if len(raw) == 0 { |
| 294 | + return nil, nil |
| 295 | + } |
| 296 | + var out []string |
| 297 | + if json.Unmarshal(raw, &out) == nil { |
| 298 | + return out, nil |
| 299 | + } |
| 300 | + var text string |
| 301 | + if err := json.Unmarshal(raw, &text); err != nil { |
| 302 | + return nil, err |
| 303 | + } |
| 304 | + for _, item := range strings.FieldsFunc(text, func(r rune) bool { return r == ',' || r == ' ' || r == '\n' }) { |
| 305 | + if item = strings.TrimSpace(item); item != "" { |
| 306 | + out = append(out, item) |
| 307 | + } |
| 308 | + } |
| 309 | + return out, nil |
| 310 | +} |
| 311 | + |
| 312 | +func dedupeInts(values []int) []int { |
| 313 | + out := values[:0] |
| 314 | + for i, value := range values { |
| 315 | + if i == 0 || value != values[i-1] { |
| 316 | + out = append(out, value) |
| 317 | + } |
| 318 | + } |
| 319 | + return out |
| 320 | +} |
| 321 | + |
| 322 | +func (t *Tenant) changeCustomView(ctx context.Context, actor, method string, params []json.RawMessage) (any, error) { |
| 323 | + name := customViewNameParam(params) |
| 324 | + if name == "" { |
| 325 | + return nil, errors.New("invalid: view name required") |
| 326 | + } |
| 327 | + view, err := t.customViewByName(ctx, name) |
| 328 | + if err != nil { |
| 329 | + return nil, err |
| 330 | + } |
| 331 | + switch method { |
| 332 | + case "removecustomview": |
| 333 | + if err := t.removeCustomView(ctx, view); err != nil { |
| 334 | + return nil, err |
| 335 | + } |
| 336 | + case "pausecustomview": |
| 337 | + _, err = t.store.DB().ExecContext(ctx, `UPDATE custom_views SET enabled=0, last_status='paused' WHERE name=?`, name) |
| 338 | + view.Enabled, view.LastStatus = false, "paused" |
| 339 | + case "resumecustomview": |
| 340 | + _, err = t.store.DB().ExecContext(ctx, `UPDATE custom_views SET enabled=1, failures=0, last_status='' WHERE name=?`, name) |
| 341 | + view.Enabled, view.Failures, view.LastStatus = true, 0, "" |
| 342 | + case "runcustomview": |
| 343 | + if !view.Enabled { |
| 344 | + return nil, errors.New("invalid: resume the view before running it") |
| 345 | + } |
| 346 | + queued, err := t.queueCustomViewBackfill(ctx, view, 0, strconv.FormatInt(time.Now().UnixNano(), 10)) |
| 347 | + if err != nil { |
| 348 | + return nil, err |
| 349 | + } |
| 350 | + if err := t.community.Record(ctx, actor, method, name, strconv.Itoa(queued)); err != nil { |
| 351 | + return nil, err |
| 352 | + } |
| 353 | + t.app.telemetry.Logger().Info("custom view run queued", "tenant", t.meta.Name, "queued", queued) |
| 354 | + out := view.summary() |
| 355 | + out["queued"] = queued |
| 356 | + return out, nil |
| 357 | + default: |
| 358 | + return nil, fmt.Errorf("unsupported: management method %q", method) |
| 359 | + } |
| 360 | + if err != nil { |
| 361 | + return nil, err |
| 362 | + } |
| 363 | + t.invalidateCustomViews() |
| 364 | + if err := t.community.Record(ctx, actor, method, name, ""); err != nil { |
| 365 | + return nil, err |
| 366 | + } |
| 367 | + if method == "removecustomview" { |
| 368 | + return map[string]any{"name": name, "removed": true}, nil |
| 369 | + } |
| 370 | + return view.summary(), nil |
| 371 | +} |
| 372 | + |
| 373 | +// removeCustomView deletes the definition and every artifact it produced, |
| 374 | +// including the stored public records. |
| 375 | +func (t *Tenant) removeCustomView(ctx context.Context, view customView) error { |
| 376 | + ids, err := t.customViewArtifactIDs(ctx, `SELECT event_id FROM custom_view_artifacts WHERE view=?`, view.Name) |
| 377 | + if err != nil { |
| 378 | + return err |
| 379 | + } |
| 380 | + if err := t.store.WithTx(ctx, func(tx *sql.Tx) error { |
| 381 | + for _, table := range []string{"custom_view_sources", "custom_view_artifacts", "custom_views"} { |
| 382 | + column := "view" |
| 383 | + if table == "custom_views" { |
| 384 | + column = "name" |
| 385 | + } |
| 386 | + if _, err := tx.ExecContext(ctx, "DELETE FROM "+table+" WHERE "+column+"=?", view.Name); err != nil { |
| 387 | + return err |
| 388 | + } |
| 389 | + } |
| 390 | + return nil |
| 391 | + }); err != nil { |
| 392 | + return err |
| 393 | + } |
| 394 | + return t.deleteStoredArtifacts(ctx, ids) |
| 395 | +} |
| 396 | + |
| 397 | +// customViewNameParam reads the name from a bare string or from {"name": ...}. |
| 398 | +func customViewNameParam(params []json.RawMessage) string { |
| 399 | + if len(params) == 0 { |
| 400 | + return "" |
| 401 | + } |
| 402 | + if name := stringParam(params, 0); name != "" { |
| 403 | + return name |
| 404 | + } |
| 405 | + var options struct { |
| 406 | + Name string `json:"name"` |
| 407 | + } |
| 408 | + _ = json.Unmarshal(params[0], &options) |
| 409 | + return strings.TrimSpace(options.Name) |
| 410 | +} |
| 411 | + |
| 412 | +func (t *Tenant) customViewByName(ctx context.Context, name string) (customView, error) { |
| 413 | + rows, err := t.customViewRows(ctx, name) |
| 414 | + if err != nil { |
| 415 | + return customView{}, err |
| 416 | + } |
| 417 | + if len(rows) == 0 { |
| 418 | + return customView{}, errors.New("not found: view") |
| 419 | + } |
| 420 | + return rows[0], nil |
| 421 | +} |
| 422 | + |
| 423 | +// customViewRows lists definitions, narrowed to one name. |
| 424 | +func (t *Tenant) customViewRows(ctx context.Context, names ...string) ([]customView, error) { |
| 425 | + query := `SELECT name,kinds,transform,trigger,audience,languages,max_bytes,secret,enabled,created_at,last_run_at,last_status,failures FROM custom_views` |
| 426 | + args := []any{} |
| 427 | + if len(names) > 0 { |
| 428 | + query += ` WHERE name=?` |
| 429 | + args = append(args, names[0]) |
| 430 | + } |
| 431 | + query += ` ORDER BY created_at,name` |
| 432 | + rows, err := t.store.DB().QueryContext(ctx, query, args...) |
| 433 | + if err != nil { |
| 434 | + return nil, fmt.Errorf("list custom views: %w", err) |
| 435 | + } |
| 436 | + defer rows.Close() |
| 437 | + var out []customView |
| 438 | + for rows.Next() { |
| 439 | + var item customView |
| 440 | + var kinds, languages string |
| 441 | + var enabled int |
| 442 | + if err := rows.Scan(&item.Name, &kinds, &item.Transform, &item.Trigger, &item.Audience, &languages, &item.MaxBytes, &item.secret, &enabled, &item.CreatedAt, &item.LastRunAt, &item.LastStatus, &item.Failures); err != nil { |
| 443 | + return nil, err |
| 444 | + } |
| 445 | + item.Enabled = enabled != 0 |
| 446 | + if err := json.Unmarshal([]byte(kinds), &item.Kinds); err != nil { |
| 447 | + return nil, fmt.Errorf("custom view kinds: %w", err) |
| 448 | + } |
| 449 | + if err := json.Unmarshal([]byte(languages), &item.Languages); err != nil { |
| 450 | + return nil, fmt.Errorf("custom view languages: %w", err) |
| 451 | + } |
| 452 | + out = append(out, item) |
| 453 | + } |
| 454 | + return out, rows.Err() |
| 455 | +} |
| 456 | + |
| 457 | +// customViewIndex returns the enabled views indexed by kind, built on first |
| 458 | +// use and dropped whenever a view changes. |
| 459 | +func (t *Tenant) customViewIndex(ctx context.Context) map[int][]customView { |
| 460 | + t.viewMu.Lock() |
| 461 | + defer t.viewMu.Unlock() |
| 462 | + if t.viewIndex != nil { |
| 463 | + return t.viewIndex |
| 464 | + } |
| 465 | + index := map[int][]customView{} |
| 466 | + rows, err := t.customViewRows(ctx) |
| 467 | + if err != nil { |
| 468 | + t.app.telemetry.Logger().Error("load custom views", "tenant", t.meta.Name, "error", err) |
| 469 | + return index |
| 470 | + } |
| 471 | + for _, row := range rows { |
| 472 | + if !row.Enabled { |
| 473 | + continue |
| 474 | + } |
| 475 | + for _, kind := range row.Kinds { |
| 476 | + index[kind] = append(index[kind], row) |
| 477 | + } |
| 478 | + } |
| 479 | + t.viewIndex = index |
| 480 | + return index |
| 481 | +} |
| 482 | + |
| 483 | +func (t *Tenant) invalidateCustomViews() { |
| 484 | + t.viewMu.Lock() |
| 485 | + t.viewIndex = nil |
| 486 | + t.viewMu.Unlock() |
| 487 | +} |
| 488 | + |
| 489 | +// CustomViews is the summary the web UI renders with: the enabled views' |
| 490 | +// names and languages, nothing else. |
| 491 | +func (t *Tenant) CustomViews() []views.View { |
| 492 | + seen := map[string]bool{} |
| 493 | + var out []views.View |
| 494 | + for _, list := range t.customViewIndex(context.Background()) { |
| 495 | + for _, view := range list { |
| 496 | + if !seen[view.Name] { |
| 497 | + seen[view.Name] = true |
| 498 | + out = append(out, views.View{Name: view.Name, Languages: append([]string(nil), view.Languages...)}) |
| 499 | + } |
| 500 | + } |
| 501 | + } |
| 502 | + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) |
| 503 | + return out |
| 504 | +} |
| 505 | + |
| 506 | +// queueCustomViews runs after an event is stored or released. Each enabled |
| 507 | +// write-triggered view that watches the kind and finds a block it renders |
| 508 | +// gets one transform intent. It never fails the publish. |
| 509 | +func (t *Tenant) queueCustomViews(ctx context.Context, e event.Event) { |
| 510 | + candidates := t.customViewIndex(ctx)[e.Kind] |
| 511 | + if len(candidates) == 0 { |
| 512 | + return |
| 513 | + } |
| 514 | + if e.Kind == event.KIND_REPO_STATE && (t.git == nil || t.git.IsPending(e.ID)) { |
| 515 | + // The README is read from the objects, which have not arrived yet; |
| 516 | + // releaseGit queues the state once they have. |
| 517 | + return |
| 518 | + } |
| 519 | + var intents []storage.Intent |
| 520 | + for _, view := range candidates { |
| 521 | + if view.Trigger != "write" { |
| 522 | + continue |
| 523 | + } |
| 524 | + if e.Kind != event.KIND_REPO_STATE && len(views.Matching(views.Blocks(e.Content), view.Languages)) == 0 { |
| 525 | + continue |
| 526 | + } |
| 527 | + intents = append(intents, t.viewIntent(view, e, "")) |
| 528 | + } |
| 529 | + if len(intents) == 0 { |
| 530 | + return |
| 531 | + } |
| 532 | + err := t.store.WithTx(ctx, func(tx *sql.Tx) error { |
| 533 | + return storage.AddIntents(ctx, tx, intents, time.Now().Unix()) |
| 534 | + }) |
| 535 | + if err != nil { |
| 536 | + t.app.telemetry.Logger().Error("view transforms not queued", "tenant", t.meta.Name, "error", err) |
| 537 | + return |
| 538 | + } |
| 539 | + t.app.telemetry.Logger().Debug("view transforms queued", "tenant", t.meta.Name, "queued", len(intents)) |
| 540 | +} |
| 541 | + |
| 542 | +// viewIntent is the transform intent for one source. The event travels in |
| 543 | +// the payload; run makes a backfill or an hourly pass distinct from the |
| 544 | +// write-time intent for the same event. |
| 545 | +func (t *Tenant) viewIntent(view customView, e event.Event, run string) storage.Intent { |
| 546 | + payload, _ := json.Marshal(viewPayload{Event: e, Attempt: 1, Run: run}) |
| 547 | + id := e.ID |
| 548 | + if run != "" { |
| 549 | + id += "@" + run |
| 550 | + } |
| 551 | + return storage.Intent{Kind: viewTransform, EventID: id, Target: view.Name, Payload: string(payload)} |
| 552 | +} |
| 553 | + |
| 554 | +// queueCustomViewBackfill queues the newest sources of a view's kinds, at |
| 555 | +// most 500, written since the given time. Repository states are queued |
| 556 | +// whether or not their README has blocks; the handler reads it. |
| 557 | +func (t *Tenant) queueCustomViewBackfill(ctx context.Context, view customView, since int64, run string) (int, error) { |
| 558 | + rows, err := t.store.Query(ctx, event.Filter{Kinds: view.Kinds, Tags: map[string][]string{}}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}, Limit: viewBackfillLimit}) |
| 559 | + if err != nil { |
| 560 | + return 0, err |
| 561 | + } |
| 562 | + var intents []storage.Intent |
| 563 | + for _, e := range rows.Events { |
| 564 | + if e.CreatedAt < since { |
| 565 | + continue |
| 566 | + } |
| 567 | + if e.Kind == event.KIND_REPO_STATE { |
| 568 | + if t.git == nil || t.git.IsPending(e.ID) { |
| 569 | + continue |
| 570 | + } |
| 571 | + } else if len(views.Matching(views.Blocks(e.Content), view.Languages)) == 0 { |
| 572 | + continue |
| 573 | + } |
| 574 | + intents = append(intents, t.viewIntent(view, e, run)) |
| 575 | + } |
| 576 | + if len(intents) == 0 { |
| 577 | + return 0, nil |
| 578 | + } |
| 579 | + err = t.store.WithTx(ctx, func(tx *sql.Tx) error { |
| 580 | + return storage.AddIntents(ctx, tx, intents, time.Now().Unix()) |
| 581 | + }) |
| 582 | + return len(intents), err |
| 583 | +} |
| 584 | + |
| 585 | +// tickCustomViews queues the hourly views that are due. It is called from |
| 586 | +// the tenant scheduler on its own timer. |
| 587 | +func (t *Tenant) tickCustomViews(ctx context.Context, now int64) error { |
| 588 | + rows, err := t.customViewRows(ctx) |
| 589 | + if err != nil { |
| 590 | + return err |
| 591 | + } |
| 592 | + for _, view := range rows { |
| 593 | + if !view.Enabled || view.Trigger != "hourly" || now-view.LastRunAt < viewHourlyPeriod { |
| 594 | + continue |
| 595 | + } |
| 596 | + since := view.LastRunAt - 300 |
| 597 | + if view.LastRunAt == 0 { |
| 598 | + since = 0 |
| 599 | + } |
| 600 | + queued, err := t.queueCustomViewBackfill(ctx, view, since, strconv.FormatInt(now/viewHourlyPeriod, 10)) |
| 601 | + if err != nil { |
| 602 | + return err |
| 603 | + } |
| 604 | + if _, err := t.store.DB().ExecContext(ctx, `UPDATE custom_views SET last_run_at=? WHERE name=?`, now, view.Name); err != nil { |
| 605 | + return err |
| 606 | + } |
| 607 | + if queued > 0 { |
| 608 | + t.app.telemetry.Logger().Info("custom view hourly run queued", "tenant", t.meta.Name, "queued", queued) |
| 609 | + } |
| 610 | + } |
| 611 | + return nil |
| 612 | +} |
| 613 | diff --git a/internal/daemon/custom_views_http.go b/internal/daemon/custom_views_http.go |
| 614 | new file mode 100644 |
| 615 | index 0000000..4ba3ebc |
| 616 | --- /dev/null |
| 617 | +++ b/internal/daemon/custom_views_http.go |
| 618 | @@ -0,0 +1,105 @@ |
| 619 | +package daemon |
| 620 | + |
| 621 | +// GET /views/<name>/<hash>.<svg|png> serves a custom view artifact under a |
| 622 | +// sandbox policy. A members-only view answers only members; a public one |
| 623 | +// follows the relay's read rule. |
| 624 | + |
| 625 | +import ( |
| 626 | + "net/http" |
| 627 | + "strconv" |
| 628 | + "strings" |
| 629 | + |
| 630 | + "github.com/FelineStateMachine/tinyrelay/internal/views" |
| 631 | +) |
| 632 | + |
| 633 | +func isViewArtifactPath(path string) bool { |
| 634 | + return strings.HasPrefix(path, "/views/") |
| 635 | +} |
| 636 | + |
| 637 | +func (t *Tenant) viewArtifactHTTP(w http.ResponseWriter, r *http.Request) { |
| 638 | + ctx, finish := t.app.telemetry.Start(r.Context(), "view") |
| 639 | + r = r.WithContext(ctx) |
| 640 | + outcome := "error" |
| 641 | + defer func() { finish(outcome) }() |
| 642 | + if r.Method != http.MethodGet && r.Method != http.MethodHead { |
| 643 | + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) |
| 644 | + outcome = "invalid" |
| 645 | + return |
| 646 | + } |
| 647 | + parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/views/"), "/") |
| 648 | + if len(parts) != 2 { |
| 649 | + http.NotFound(w, r) |
| 650 | + outcome = "invalid" |
| 651 | + return |
| 652 | + } |
| 653 | + name := parts[0] |
| 654 | + hash, extension, _ := strings.Cut(parts[1], ".") |
| 655 | + if !views.NamePattern.MatchString(name) || !views.HashPattern.MatchString(hash) || (extension != "svg" && extension != "png") { |
| 656 | + http.NotFound(w, r) |
| 657 | + outcome = "invalid" |
| 658 | + return |
| 659 | + } |
| 660 | + actor, err := t.resolveUIActor(r) |
| 661 | + if err != nil { |
| 662 | + http.Error(w, err.Error(), http.StatusUnauthorized) |
| 663 | + outcome = "unauthorized" |
| 664 | + return |
| 665 | + } |
| 666 | + record, err := t.customViewArtifact(ctx, name, hash) |
| 667 | + if err != nil { |
| 668 | + if strings.HasPrefix(err.Error(), "not found:") { |
| 669 | + http.NotFound(w, r) |
| 670 | + outcome = "invalid" |
| 671 | + return |
| 672 | + } |
| 673 | + http.Error(w, err.Error(), http.StatusInternalServerError) |
| 674 | + return |
| 675 | + } |
| 676 | + wantType := map[string]string{"svg": "image/svg+xml", "png": "image/png"}[extension] |
| 677 | + if record.Type != wantType { |
| 678 | + http.NotFound(w, r) |
| 679 | + outcome = "invalid" |
| 680 | + return |
| 681 | + } |
| 682 | + if record.Audience == "members" { |
| 683 | + role, err := t.community.Role(ctx, actor) |
| 684 | + if err != nil { |
| 685 | + http.Error(w, err.Error(), http.StatusInternalServerError) |
| 686 | + return |
| 687 | + } |
| 688 | + if actor == "" { |
| 689 | + http.Error(w, "auth-required: this view is for members", http.StatusUnauthorized) |
| 690 | + outcome = "unauthorized" |
| 691 | + return |
| 692 | + } |
| 693 | + if role != "member" && role != "moderator" && role != "owner" { |
| 694 | + http.Error(w, "restricted: this view is for members", http.StatusForbidden) |
| 695 | + outcome = "unauthorized" |
| 696 | + return |
| 697 | + } |
| 698 | + } else if err := t.browseRead(ctx, actor); err != nil { |
| 699 | + browseHTTPError(w, err) |
| 700 | + outcome = "unauthorized" |
| 701 | + return |
| 702 | + } |
| 703 | + body, err := record.body() |
| 704 | + if err != nil { |
| 705 | + http.Error(w, "artifact is unreadable", http.StatusInternalServerError) |
| 706 | + return |
| 707 | + } |
| 708 | + w.Header().Set("Content-Type", record.Type) |
| 709 | + w.Header().Set("Content-Length", strconv.Itoa(len(body))) |
| 710 | + w.Header().Set("Content-Security-Policy", "sandbox; default-src 'none'; style-src 'unsafe-inline'") |
| 711 | + w.Header().Set("X-Content-Type-Options", "nosniff") |
| 712 | + if record.Audience == "members" { |
| 713 | + w.Header().Set("Cache-Control", "private, max-age=31536000, immutable") |
| 714 | + } else { |
| 715 | + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") |
| 716 | + } |
| 717 | + outcome = "ok" |
| 718 | + if r.Method == http.MethodHead { |
| 719 | + w.WriteHeader(http.StatusOK) |
| 720 | + return |
| 721 | + } |
| 722 | + _, _ = w.Write(body) |
| 723 | +} |
| 724 | diff --git a/internal/daemon/custom_views_transform.go b/internal/daemon/custom_views_transform.go |
| 725 | new file mode 100644 |
| 726 | index 0000000..9b33471 |
| 727 | --- /dev/null |
| 728 | +++ b/internal/daemon/custom_views_transform.go |
| 729 | @@ -0,0 +1,819 @@ |
| 730 | +package daemon |
| 731 | + |
| 732 | +// Custom view transforms. Each source event is one POST of its fenced |
| 733 | +// blocks to the view's transform, signed with the view's secret. What comes |
| 734 | +// back is checked, signed by the relay and attached to the source. A failed |
| 735 | +// POST is tried again after one minute and then after five; after 20 |
| 736 | +// failures in a row the view is paused until the owner resumes it. |
| 737 | + |
| 738 | +import ( |
| 739 | + "bytes" |
| 740 | + "context" |
| 741 | + "crypto/sha256" |
| 742 | + "crypto/tls" |
| 743 | + "database/sql" |
| 744 | + "encoding/base64" |
| 745 | + "encoding/hex" |
| 746 | + "encoding/json" |
| 747 | + "errors" |
| 748 | + "fmt" |
| 749 | + "io" |
| 750 | + "net/http" |
| 751 | + "regexp" |
| 752 | + "sort" |
| 753 | + "strconv" |
| 754 | + "strings" |
| 755 | + "sync" |
| 756 | + "time" |
| 757 | + |
| 758 | + "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 759 | + "github.com/FelineStateMachine/tinyrelay/internal/gitrelay" |
| 760 | + "github.com/FelineStateMachine/tinyrelay/internal/replication" |
| 761 | + "github.com/FelineStateMachine/tinyrelay/internal/views" |
| 762 | + "github.com/FelineStateMachine/tinyrelay/internal/work" |
| 763 | +) |
| 764 | + |
| 765 | +// viewPayload is the work intent body: the source event, so the run does |
| 766 | +// not depend on the event still being stored, the attempt number and the |
| 767 | +// run that queued it. |
| 768 | +type viewPayload struct { |
| 769 | + Event event.Event `json:"event"` |
| 770 | + Attempt int `json:"attempt"` |
| 771 | + Run string `json:"run,omitempty"` |
| 772 | +} |
| 773 | + |
| 774 | +// viewRequest is the body posted to a transform: only the blocks, with the |
| 775 | +// source named so the transform can correlate. No event content, tags, |
| 776 | +// author or time leaves the relay. |
| 777 | +type viewRequest struct { |
| 778 | + Relay string `json:"relay"` |
| 779 | + View string `json:"view"` |
| 780 | + Source viewSource `json:"source"` |
| 781 | + Blocks []views.Block `json:"blocks"` |
| 782 | +} |
| 783 | + |
| 784 | +type viewSource struct { |
| 785 | + ID string `json:"id"` |
| 786 | + Kind int `json:"kind"` |
| 787 | +} |
| 788 | + |
| 789 | +// viewResponse is what a transform answers with. |
| 790 | +type viewResponse struct { |
| 791 | + Artifacts []viewArtifact `json:"artifacts"` |
| 792 | + Errors []viewError `json:"errors"` |
| 793 | +} |
| 794 | + |
| 795 | +type viewArtifact struct { |
| 796 | + Block int `json:"block"` |
| 797 | + Type string `json:"type"` |
| 798 | + Body string `json:"body"` |
| 799 | + Engine string `json:"engine"` |
| 800 | +} |
| 801 | + |
| 802 | +type viewError struct { |
| 803 | + Block int `json:"block"` |
| 804 | + Error string `json:"error"` |
| 805 | +} |
| 806 | + |
| 807 | +// viewSourceKey names a source in the artifact tables: the event id, or the |
| 808 | +// coordinate of a repository state so a new state replaces its README. |
| 809 | +func viewSourceKey(e event.Event) string { |
| 810 | + if e.Kind == event.KIND_REPO_STATE { |
| 811 | + return strconv.Itoa(e.Kind) + ":" + e.PubKey + ":" + event.Tag(e, "d") |
| 812 | + } |
| 813 | + return e.ID |
| 814 | +} |
| 815 | + |
| 816 | +// viewSourceTag is the tag an artifact carries for a source. |
| 817 | +func viewSourceTag(source string) []string { |
| 818 | + if strings.HasPrefix(source, strconv.Itoa(event.KIND_REPO_STATE)+":") { |
| 819 | + return []string{"a", source} |
| 820 | + } |
| 821 | + return []string{"e", source} |
| 822 | +} |
| 823 | + |
| 824 | +// viewHTTPClient refuses private addresses and redirects unless the relay |
| 825 | +// itself runs on a loopback address. |
| 826 | +func (t *Tenant) viewHTTPClient() *http.Client { |
| 827 | + if t.viewClient != nil { |
| 828 | + return t.viewClient |
| 829 | + } |
| 830 | + if t.loopbackRelay() { |
| 831 | + return &http.Client{Timeout: viewTimeout, Transport: &http.Transport{TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12}}, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} |
| 832 | + } |
| 833 | + return replication.NewPinnedClient(viewTimeout) |
| 834 | +} |
| 835 | + |
| 836 | +// viewLock bounds work to one transform run per view at a time. |
| 837 | +func (t *Tenant) viewLock(name string) *sync.Mutex { |
| 838 | + t.viewMu.Lock() |
| 839 | + defer t.viewMu.Unlock() |
| 840 | + if t.viewLocks == nil { |
| 841 | + t.viewLocks = map[string]*sync.Mutex{} |
| 842 | + } |
| 843 | + lock := t.viewLocks[name] |
| 844 | + if lock == nil { |
| 845 | + lock = &sync.Mutex{} |
| 846 | + t.viewLocks[name] = lock |
| 847 | + } |
| 848 | + return lock |
| 849 | +} |
| 850 | + |
| 851 | +// sourceBlocks finds the blocks of a source: the fenced blocks of its |
| 852 | +// content, or of the README at the head of a repository state. |
| 853 | +func (t *Tenant) sourceBlocks(ctx context.Context, e event.Event) ([]views.Block, error) { |
| 854 | + if e.Kind != event.KIND_REPO_STATE { |
| 855 | + return views.Blocks(e.Content), nil |
| 856 | + } |
| 857 | + readme, err := t.stateReadme(ctx, e) |
| 858 | + if err != nil { |
| 859 | + return nil, err |
| 860 | + } |
| 861 | + return views.Blocks(readme), nil |
| 862 | +} |
| 863 | + |
| 864 | +// stateReadme reads README.md at the head named by a repository state. A |
| 865 | +// missing README yields no content. |
| 866 | +func (t *Tenant) stateReadme(ctx context.Context, e event.Event) (string, error) { |
| 867 | + if t.git == nil { |
| 868 | + return "", nil |
| 869 | + } |
| 870 | + head := strings.TrimPrefix(event.Tag(e, "HEAD"), "ref: ") |
| 871 | + commit := "" |
| 872 | + for _, tag := range e.Tags { |
| 873 | + if len(tag) >= 2 && tag[0] == head { |
| 874 | + commit = tag[1] |
| 875 | + } |
| 876 | + } |
| 877 | + if head == "" || commit == "" { |
| 878 | + return "", nil |
| 879 | + } |
| 880 | + for _, name := range []string{"README.md", "readme.md", "README"} { |
| 881 | + page, err := t.git.Browse(ctx, gitrelay.BrowseRequest{Owner: e.PubKey, Repo: event.Tag(e, "d"), Ref: commit, Path: name, View: "file", Limit: 1}) |
| 882 | + if err != nil { |
| 883 | + if strings.HasPrefix(err.Error(), "not found:") || strings.HasPrefix(err.Error(), "invalid:") { |
| 884 | + continue |
| 885 | + } |
| 886 | + return "", err |
| 887 | + } |
| 888 | + if page.Binary || page.Content == "" { |
| 889 | + continue |
| 890 | + } |
| 891 | + return page.Content, nil |
| 892 | + } |
| 893 | + return "", nil |
| 894 | +} |
| 895 | + |
| 896 | +// sourceCurrent reports whether the source is still the stored event: an |
| 897 | +// event that was deleted, or a state that a newer state replaced, renders |
| 898 | +// nothing. |
| 899 | +func (t *Tenant) sourceCurrent(ctx context.Context, e event.Event) (bool, error) { |
| 900 | + var id string |
| 901 | + var err error |
| 902 | + if e.Kind == event.KIND_REPO_STATE { |
| 903 | + err = t.store.DB().QueryRowContext(ctx, `SELECT id FROM events WHERE kind=? AND pubkey=? AND d=?`, e.Kind, e.PubKey, event.Tag(e, "d")).Scan(&id) |
| 904 | + } else { |
| 905 | + err = t.store.DB().QueryRowContext(ctx, `SELECT id FROM events WHERE id=?`, e.ID).Scan(&id) |
| 906 | + } |
| 907 | + if errors.Is(err, sql.ErrNoRows) { |
| 908 | + return false, nil |
| 909 | + } |
| 910 | + return id == e.ID, err |
| 911 | +} |
| 912 | + |
| 913 | +// handleViewTransform serves one view-transform intent. It rechecks the |
| 914 | +// view and the source right before the POST, attaches artifacts that |
| 915 | +// already exist for a block's hash and posts only the blocks that need |
| 916 | +// rendering. |
| 917 | +func (t *Tenant) handleViewTransform(ctx context.Context, intent work.Intent) (err error) { |
| 918 | + ctx, finish := t.app.telemetry.Start(ctx, "view") |
| 919 | + outcome := "error" |
| 920 | + defer func() { finish(outcome) }() |
| 921 | + var payload viewPayload |
| 922 | + if err := json.Unmarshal([]byte(intent.Payload), &payload); err != nil || payload.Event.ID == "" { |
| 923 | + outcome = "invalid" |
| 924 | + return errors.New("view-transform: invalid payload") |
| 925 | + } |
| 926 | + if payload.Attempt < 1 { |
| 927 | + payload.Attempt = 1 |
| 928 | + } |
| 929 | + lock := t.viewLock(intent.Target) |
| 930 | + lock.Lock() |
| 931 | + defer lock.Unlock() |
| 932 | + view, err := t.customViewByName(ctx, intent.Target) |
| 933 | + if err != nil { |
| 934 | + if strings.HasPrefix(err.Error(), "not found:") { |
| 935 | + outcome = "paused" |
| 936 | + return nil |
| 937 | + } |
| 938 | + return err |
| 939 | + } |
| 940 | + if !view.Enabled { |
| 941 | + outcome = "paused" |
| 942 | + return nil |
| 943 | + } |
| 944 | + current, err := t.sourceCurrent(ctx, payload.Event) |
| 945 | + if err != nil { |
| 946 | + return err |
| 947 | + } |
| 948 | + if !current { |
| 949 | + outcome = "invalid" |
| 950 | + return nil |
| 951 | + } |
| 952 | + blocks, err := t.sourceBlocks(ctx, payload.Event) |
| 953 | + if err != nil { |
| 954 | + return err |
| 955 | + } |
| 956 | + blocks = views.Matching(blocks, view.Languages) |
| 957 | + source := viewSourceKey(payload.Event) |
| 958 | + expires := event.Expiration(payload.Event) |
| 959 | + // Attach the blocks that already have an artifact and keep the rest. |
| 960 | + var pending []views.Block |
| 961 | + hashes := make([]string, 0, len(blocks)) |
| 962 | + for _, block := range blocks { |
| 963 | + hash := views.Hash(block.Lang, block.Source) |
| 964 | + hashes = append(hashes, hash) |
| 965 | + exists, err := t.attachArtifact(ctx, view, hash, source, block.Index, expires) |
| 966 | + if err != nil { |
| 967 | + return err |
| 968 | + } |
| 969 | + if !exists { |
| 970 | + pending = append(pending, block) |
| 971 | + } |
| 972 | + } |
| 973 | + if err := t.detachStaleArtifacts(ctx, view.Name, source, hashes); err != nil { |
| 974 | + return err |
| 975 | + } |
| 976 | + if len(pending) == 0 { |
| 977 | + outcome = "ok" |
| 978 | + return t.pruneViewArtifacts(ctx, view.Name, intent.ID) |
| 979 | + } |
| 980 | + status, response, postErr := t.postViewTransform(ctx, view, payload.Event, pending) |
| 981 | + now := time.Now().Unix() |
| 982 | + if postErr == nil { |
| 983 | + stored, refused := 0, 0 |
| 984 | + sent := map[int]views.Block{} |
| 985 | + for _, block := range pending { |
| 986 | + sent[block.Index] = block |
| 987 | + } |
| 988 | + for _, artifact := range response.Artifacts { |
| 989 | + block, ok := sent[artifact.Block] |
| 990 | + if !ok { |
| 991 | + refused++ |
| 992 | + continue |
| 993 | + } |
| 994 | + if err := t.storeArtifact(ctx, view, block, artifact, source, expires, now); err != nil { |
| 995 | + if strings.HasPrefix(err.Error(), "invalid:") { |
| 996 | + refused++ |
| 997 | + continue |
| 998 | + } |
| 999 | + return err |
| 1000 | + } |
| 1001 | + stored++ |
| 1002 | + } |
| 1003 | + outcome = "ok" |
| 1004 | + if refused > 0 { |
| 1005 | + outcome = "invalid" |
| 1006 | + } |
| 1007 | + if _, err := t.store.DB().ExecContext(ctx, `UPDATE custom_views SET last_run_at=?, last_status='ok', failures=0 WHERE name=?`, now, view.Name); err != nil { |
| 1008 | + return err |
| 1009 | + } |
| 1010 | + // Counts only: no source, block or body leaves the relay in a log. |
| 1011 | + t.app.telemetry.Logger().Info("view transform delivered", "tenant", t.meta.Name, "attempt", payload.Attempt, "status", status, "blocks", len(pending), "artifacts", stored, "refused", refused, "errors", len(response.Errors)) |
| 1012 | + return t.pruneViewArtifacts(ctx, view.Name, intent.ID) |
| 1013 | + } |
| 1014 | + reason := postErr.Error() |
| 1015 | + failures := view.Failures + 1 |
| 1016 | + paused := failures >= viewPauseFailures |
| 1017 | + if paused { |
| 1018 | + outcome = "paused" |
| 1019 | + if err := t.pauseCustomView(ctx, view.Name, failures, fmt.Sprintf("paused after %d failures: %s", failures, reason)); err != nil { |
| 1020 | + return err |
| 1021 | + } |
| 1022 | + } else if _, err := t.store.DB().ExecContext(ctx, `UPDATE custom_views SET last_run_at=?, failures=?, last_status=? WHERE name=?`, now, failures, reason, view.Name); err != nil { |
| 1023 | + return err |
| 1024 | + } |
| 1025 | + t.app.telemetry.Logger().Info("view transform failed", "tenant", t.meta.Name, "attempt", payload.Attempt, "status", status, "failures", failures, "paused", paused) |
| 1026 | + if paused || payload.Attempt >= viewMaxAttempts { |
| 1027 | + return nil |
| 1028 | + } |
| 1029 | + return t.retryViewTransform(ctx, intent, payload) |
| 1030 | +} |
| 1031 | + |
| 1032 | +// pauseCustomView stops the view and records the count and reason, then |
| 1033 | +// drops the index so no new intents are queued for it. |
| 1034 | +func (t *Tenant) pauseCustomView(ctx context.Context, name string, failures int, status string) error { |
| 1035 | + if _, err := t.store.DB().ExecContext(ctx, `UPDATE custom_views SET enabled=0, failures=?, last_status=? WHERE name=?`, failures, status, name); err != nil { |
| 1036 | + return err |
| 1037 | + } |
| 1038 | + t.invalidateCustomViews() |
| 1039 | + return nil |
| 1040 | +} |
| 1041 | + |
| 1042 | +// retryViewTransform queues the next attempt after its backoff as its own |
| 1043 | +// intent so the durable queue's ordering and fencing still apply. |
| 1044 | +func (t *Tenant) retryViewTransform(ctx context.Context, intent work.Intent, payload viewPayload) error { |
| 1045 | + delay := viewBackoff[len(viewBackoff)-1] |
| 1046 | + if payload.Attempt-1 < len(viewBackoff) { |
| 1047 | + delay = viewBackoff[payload.Attempt-1] |
| 1048 | + } |
| 1049 | + payload.Attempt++ |
| 1050 | + encoded, err := json.Marshal(payload) |
| 1051 | + if err != nil { |
| 1052 | + return err |
| 1053 | + } |
| 1054 | + now := time.Now().Unix() |
| 1055 | + key := sha256.Sum256([]byte(viewTransform + "\x00" + intent.EventID + "\x00" + intent.Target + "\x00" + fmt.Sprint(payload.Attempt))) |
| 1056 | + return t.store.WithTx(ctx, func(tx *sql.Tx) error { |
| 1057 | + _, err := tx.ExecContext(ctx, `INSERT OR IGNORE INTO work_intents(id,kind,event_id,target,payload,next_at,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)`, hex.EncodeToString(key[:]), viewTransform, intent.EventID+"#"+fmt.Sprint(payload.Attempt), intent.Target, string(encoded), now+int64(delay/time.Second), now, now) |
| 1058 | + return err |
| 1059 | + }) |
| 1060 | +} |
| 1061 | + |
| 1062 | +// postViewTransform sends the blocks and reads the answer. It returns the |
| 1063 | +// HTTP status with a bounded reason on failure; a response that is not |
| 1064 | +// JSON counts as a failure. |
| 1065 | +func (t *Tenant) postViewTransform(ctx context.Context, view customView, e event.Event, blocks []views.Block) (int, viewResponse, error) { |
| 1066 | + body, err := json.Marshal(viewRequest{Relay: t.publicURL, View: view.Name, Source: viewSource{ID: viewSourceKey(e), Kind: e.Kind}, Blocks: blocks}) |
| 1067 | + if err != nil { |
| 1068 | + return 0, viewResponse{}, errors.New("encode request") |
| 1069 | + } |
| 1070 | + ctx, cancel := context.WithTimeout(ctx, viewTimeout) |
| 1071 | + defer cancel() |
| 1072 | + request, err := http.NewRequestWithContext(ctx, http.MethodPost, view.Transform, bytes.NewReader(body)) |
| 1073 | + if err != nil { |
| 1074 | + return 0, viewResponse{}, errors.New("bad url") |
| 1075 | + } |
| 1076 | + request.Header.Set("Content-Type", "application/json") |
| 1077 | + request.Header.Set("Accept", "application/json") |
| 1078 | + request.Header.Set("X-Tiny-View", view.Name) |
| 1079 | + request.Header.Set("X-Tiny-Relay", t.publicURL) |
| 1080 | + request.Header.Set("X-Tiny-Signature", callbackSignature(view.secret, body)) |
| 1081 | + request.Header.Set("User-Agent", "tinyrelay") |
| 1082 | + response, err := t.viewHTTPClient().Do(request) |
| 1083 | + if err != nil { |
| 1084 | + if errors.Is(err, context.DeadlineExceeded) || ctx.Err() != nil { |
| 1085 | + return 0, viewResponse{}, errors.New("timeout") |
| 1086 | + } |
| 1087 | + return 0, viewResponse{}, errors.New("connection failed") |
| 1088 | + } |
| 1089 | + defer response.Body.Close() |
| 1090 | + raw, err := io.ReadAll(io.LimitReader(response.Body, viewResponseMaxBytes)) |
| 1091 | + if err != nil { |
| 1092 | + return response.StatusCode, viewResponse{}, errors.New("read failed") |
| 1093 | + } |
| 1094 | + if response.StatusCode < 200 || response.StatusCode >= 300 { |
| 1095 | + return response.StatusCode, viewResponse{}, fmt.Errorf("HTTP %d", response.StatusCode) |
| 1096 | + } |
| 1097 | + var decoded viewResponse |
| 1098 | + if err := json.Unmarshal(raw, &decoded); err != nil { |
| 1099 | + return response.StatusCode, viewResponse{}, errors.New("invalid response") |
| 1100 | + } |
| 1101 | + return response.StatusCode, decoded, nil |
| 1102 | +} |
| 1103 | + |
| 1104 | +var ( |
| 1105 | + svgScript = regexp.MustCompile(`(?i)<script`) |
| 1106 | + svgHandler = regexp.MustCompile(`(?i)\bon[a-z]+\s*=`) |
| 1107 | + svgForeignObject = regexp.MustCompile(`(?i)<foreignObject`) |
| 1108 | + svgReference = regexp.MustCompile(`(?i)(?:xlink:)?href\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))`) |
| 1109 | + pngSignature = []byte("\x89PNG\r\n\x1a\n") |
| 1110 | +) |
| 1111 | + |
| 1112 | +// checkArtifact validates a transform's artifact: an accepted type, a body |
| 1113 | +// within the view's size limit, a PNG that decodes from base64 and an SVG |
| 1114 | +// without script, handlers, foreign objects or references outside itself. |
| 1115 | +// It returns the body as stored: the SVG text or the PNG in base64. |
| 1116 | +func checkArtifact(artifact viewArtifact, maxBytes int) (string, error) { |
| 1117 | + switch artifact.Type { |
| 1118 | + case "image/svg+xml": |
| 1119 | + if len(artifact.Body) > maxBytes { |
| 1120 | + return "", errors.New("invalid: artifact exceeds the size limit") |
| 1121 | + } |
| 1122 | + if err := checkSVG(artifact.Body); err != nil { |
| 1123 | + return "", err |
| 1124 | + } |
| 1125 | + return artifact.Body, nil |
| 1126 | + case "image/png": |
| 1127 | + body := strings.TrimSpace(artifact.Body) |
| 1128 | + decoded, err := base64.StdEncoding.DecodeString(body) |
| 1129 | + if err != nil { |
| 1130 | + if decoded, err = base64.RawStdEncoding.DecodeString(body); err != nil { |
| 1131 | + return "", errors.New("invalid: png body must be base64") |
| 1132 | + } |
| 1133 | + } |
| 1134 | + if len(decoded) > maxBytes { |
| 1135 | + return "", errors.New("invalid: artifact exceeds the size limit") |
| 1136 | + } |
| 1137 | + if !bytes.HasPrefix(decoded, pngSignature) { |
| 1138 | + return "", errors.New("invalid: png body is not a PNG") |
| 1139 | + } |
| 1140 | + return base64.StdEncoding.EncodeToString(decoded), nil |
| 1141 | + default: |
| 1142 | + return "", errors.New("invalid: artifact type must be image/svg+xml or image/png") |
| 1143 | + } |
| 1144 | +} |
| 1145 | + |
| 1146 | +func checkSVG(body string) error { |
| 1147 | + if !strings.Contains(strings.ToLower(body), "<svg") { |
| 1148 | + return errors.New("invalid: svg body has no svg element") |
| 1149 | + } |
| 1150 | + if svgScript.MatchString(body) { |
| 1151 | + return errors.New("invalid: svg contains script") |
| 1152 | + } |
| 1153 | + if svgHandler.MatchString(body) { |
| 1154 | + return errors.New("invalid: svg contains an event handler") |
| 1155 | + } |
| 1156 | + if svgForeignObject.MatchString(body) { |
| 1157 | + return errors.New("invalid: svg contains a foreign object") |
| 1158 | + } |
| 1159 | + for _, match := range svgReference.FindAllStringSubmatch(body, -1) { |
| 1160 | + target := strings.TrimSpace(match[1] + match[2] + match[3]) |
| 1161 | + if target == "" || strings.HasPrefix(target, "#") { |
| 1162 | + continue |
| 1163 | + } |
| 1164 | + if strings.HasPrefix(strings.ToLower(target), "javascript:") { |
| 1165 | + return errors.New("invalid: svg contains a javascript reference") |
| 1166 | + } |
| 1167 | + return errors.New("invalid: svg references an external target") |
| 1168 | + } |
| 1169 | + return nil |
| 1170 | +} |
| 1171 | + |
| 1172 | +// storeArtifact checks one artifact and keeps it as a signed record |
| 1173 | +// attached to its source. |
| 1174 | +func (t *Tenant) storeArtifact(ctx context.Context, view customView, block views.Block, artifact viewArtifact, source string, expires int64, now int64) error { |
| 1175 | + content, err := checkArtifact(artifact, view.MaxBytes) |
| 1176 | + if err != nil { |
| 1177 | + return err |
| 1178 | + } |
| 1179 | + engine := strings.TrimSpace(artifact.Engine) |
| 1180 | + if len(engine) > 64 { |
| 1181 | + engine = engine[:64] |
| 1182 | + } |
| 1183 | + hash := views.Hash(block.Lang, block.Source) |
| 1184 | + err = t.store.WithTx(ctx, func(tx *sql.Tx) error { |
| 1185 | + if _, err := tx.ExecContext(ctx, `INSERT INTO custom_view_artifacts(view,hash,event_id,type,engine,audience,content,raw,created_at) VALUES(?,?,'',?,?,?,?,'',?) ON CONFLICT(view,hash) DO UPDATE SET type=excluded.type, engine=excluded.engine, audience=excluded.audience, content=excluded.content, created_at=excluded.created_at`, view.Name, hash, artifact.Type, engine, view.Audience, content, now); err != nil { |
| 1186 | + return err |
| 1187 | + } |
| 1188 | + _, err := tx.ExecContext(ctx, `INSERT INTO custom_view_sources(view,hash,source,block,expires) VALUES(?,?,?,?,?) ON CONFLICT(view,hash,source) DO UPDATE SET block=excluded.block, expires=excluded.expires`, view.Name, hash, source, block.Index, expires) |
| 1189 | + return err |
| 1190 | + }) |
| 1191 | + if err != nil { |
| 1192 | + return err |
| 1193 | + } |
| 1194 | + return t.signArtifact(ctx, view, hash, now) |
| 1195 | +} |
| 1196 | + |
| 1197 | +// attachArtifact reports whether an artifact exists for the hash and, when |
| 1198 | +// it does, attaches the source to it through a fresh signed record. |
| 1199 | +func (t *Tenant) attachArtifact(ctx context.Context, view customView, hash, source string, block int, expires int64) (bool, error) { |
| 1200 | + var exists int |
| 1201 | + err := t.store.DB().QueryRowContext(ctx, `SELECT 1 FROM custom_view_artifacts WHERE view=? AND hash=?`, view.Name, hash).Scan(&exists) |
| 1202 | + if errors.Is(err, sql.ErrNoRows) { |
| 1203 | + return false, nil |
| 1204 | + } |
| 1205 | + if err != nil { |
| 1206 | + return false, err |
| 1207 | + } |
| 1208 | + var current int |
| 1209 | + var currentBlock int |
| 1210 | + var currentExpires int64 |
| 1211 | + err = t.store.DB().QueryRowContext(ctx, `SELECT 1,block,expires FROM custom_view_sources WHERE view=? AND hash=? AND source=?`, view.Name, hash, source).Scan(¤t, ¤tBlock, ¤tExpires) |
| 1212 | + if err == nil && currentBlock == block && currentExpires == expires { |
| 1213 | + return true, nil |
| 1214 | + } |
| 1215 | + if err != nil && !errors.Is(err, sql.ErrNoRows) { |
| 1216 | + return false, err |
| 1217 | + } |
| 1218 | + if _, err := t.store.DB().ExecContext(ctx, `INSERT INTO custom_view_sources(view,hash,source,block,expires) VALUES(?,?,?,?,?) ON CONFLICT(view,hash,source) DO UPDATE SET block=excluded.block, expires=excluded.expires`, view.Name, hash, source, block, expires); err != nil { |
| 1219 | + return false, err |
| 1220 | + } |
| 1221 | + return true, t.signArtifact(ctx, view, hash, time.Now().Unix()) |
| 1222 | +} |
| 1223 | + |
| 1224 | +// signArtifact signs the artifact with its current sources as a kind 30078 |
| 1225 | +// record and stores the record when the view is public. |
| 1226 | +func (t *Tenant) signArtifact(ctx context.Context, view customView, hash string, now int64) error { |
| 1227 | + var typ, engine, content string |
| 1228 | + if err := t.store.DB().QueryRowContext(ctx, `SELECT type,engine,content FROM custom_view_artifacts WHERE view=? AND hash=?`, view.Name, hash).Scan(&typ, &engine, &content); err != nil { |
| 1229 | + return err |
| 1230 | + } |
| 1231 | + rows, err := t.store.DB().QueryContext(ctx, `SELECT source,block,expires FROM custom_view_sources WHERE view=? AND hash=? ORDER BY rowid`, view.Name, hash) |
| 1232 | + if err != nil { |
| 1233 | + return err |
| 1234 | + } |
| 1235 | + tags := [][]string{{"-"}, {"d", "bind.ws/view/" + view.Name + "/" + hash}, {"view", view.Name}} |
| 1236 | + var blocks [][]string |
| 1237 | + expires := int64(0) |
| 1238 | + unlimited := false |
| 1239 | + for rows.Next() { |
| 1240 | + var source string |
| 1241 | + var block int |
| 1242 | + var sourceExpires int64 |
| 1243 | + if err := rows.Scan(&source, &block, &sourceExpires); err != nil { |
| 1244 | + rows.Close() |
| 1245 | + return err |
| 1246 | + } |
| 1247 | + tags = append(tags, viewSourceTag(source)) |
| 1248 | + blocks = append(blocks, []string{"block", strconv.Itoa(block)}) |
| 1249 | + if sourceExpires == 0 { |
| 1250 | + unlimited = true |
| 1251 | + } else if sourceExpires > expires { |
| 1252 | + expires = sourceExpires |
| 1253 | + } |
| 1254 | + } |
| 1255 | + if err := errors.Join(rows.Err(), rows.Close()); err != nil { |
| 1256 | + return err |
| 1257 | + } |
| 1258 | + tags = append(tags, blocks...) |
| 1259 | + tags = append(tags, []string{"type", typ}) |
| 1260 | + if engine != "" { |
| 1261 | + tags = append(tags, []string{"engine", engine}) |
| 1262 | + } |
| 1263 | + if expires > 0 && !unlimited { |
| 1264 | + tags = append(tags, []string{"expiration", strconv.FormatInt(expires, 10)}) |
| 1265 | + } |
| 1266 | + var signed event.Event |
| 1267 | + if view.Audience == "public" { |
| 1268 | + signed, err = t.records.Generate(ctx, event.KIND_VIEW, tags, content, now) |
| 1269 | + } else { |
| 1270 | + signed, err = t.records.Sign(ctx, event.KIND_VIEW, tags, content, now) |
| 1271 | + } |
| 1272 | + if err != nil { |
| 1273 | + return err |
| 1274 | + } |
| 1275 | + raw, err := json.Marshal(signed) |
| 1276 | + if err != nil { |
| 1277 | + return err |
| 1278 | + } |
| 1279 | + _, err = t.store.DB().ExecContext(ctx, `UPDATE custom_view_artifacts SET event_id=?, raw=? WHERE view=? AND hash=?`, signed.ID, string(raw), view.Name, hash) |
| 1280 | + return err |
| 1281 | +} |
| 1282 | + |
| 1283 | +// detachStaleArtifacts drops the source from artifacts whose block it no |
| 1284 | +// longer carries, then removes artifacts left without a source. |
| 1285 | +func (t *Tenant) detachStaleArtifacts(ctx context.Context, name, source string, hashes []string) error { |
| 1286 | + rows, err := t.store.DB().QueryContext(ctx, `SELECT hash FROM custom_view_sources WHERE view=? AND source=?`, name, source) |
| 1287 | + if err != nil { |
| 1288 | + return err |
| 1289 | + } |
| 1290 | + keep := map[string]bool{} |
| 1291 | + for _, hash := range hashes { |
| 1292 | + keep[hash] = true |
| 1293 | + } |
| 1294 | + var stale []string |
| 1295 | + for rows.Next() { |
| 1296 | + var hash string |
| 1297 | + if err := rows.Scan(&hash); err != nil { |
| 1298 | + rows.Close() |
| 1299 | + return err |
| 1300 | + } |
| 1301 | + if !keep[hash] { |
| 1302 | + stale = append(stale, hash) |
| 1303 | + } |
| 1304 | + } |
| 1305 | + if err := errors.Join(rows.Err(), rows.Close()); err != nil { |
| 1306 | + return err |
| 1307 | + } |
| 1308 | + for _, hash := range stale { |
| 1309 | + if _, err := t.store.DB().ExecContext(ctx, `DELETE FROM custom_view_sources WHERE view=? AND hash=? AND source=?`, name, hash, source); err != nil { |
| 1310 | + return err |
| 1311 | + } |
| 1312 | + } |
| 1313 | + return t.dropOrphanArtifacts(ctx, name) |
| 1314 | +} |
| 1315 | + |
| 1316 | +// pruneViewArtifacts detaches sources that no longer exist and removes the |
| 1317 | +// artifacts that lose their last source. A view with transform work still |
| 1318 | +// queued is left alone so a replaced source can reattach its blocks first. |
| 1319 | +func (t *Tenant) pruneViewArtifacts(ctx context.Context, name, except string) error { |
| 1320 | + var pending int |
| 1321 | + if err := t.store.DB().QueryRowContext(ctx, `SELECT count(*) FROM work_intents WHERE kind=? AND target=? AND state IN ('pending','running') AND id<>?`, viewTransform, name, except).Scan(&pending); err != nil { |
| 1322 | + return err |
| 1323 | + } |
| 1324 | + if pending > 0 { |
| 1325 | + return nil |
| 1326 | + } |
| 1327 | + rows, err := t.store.DB().QueryContext(ctx, `SELECT DISTINCT source FROM custom_view_sources WHERE view=?`, name) |
| 1328 | + if err != nil { |
| 1329 | + return err |
| 1330 | + } |
| 1331 | + var sources []string |
| 1332 | + for rows.Next() { |
| 1333 | + var source string |
| 1334 | + if err := rows.Scan(&source); err != nil { |
| 1335 | + rows.Close() |
| 1336 | + return err |
| 1337 | + } |
| 1338 | + sources = append(sources, source) |
| 1339 | + } |
| 1340 | + if err := errors.Join(rows.Err(), rows.Close()); err != nil { |
| 1341 | + return err |
| 1342 | + } |
| 1343 | + removed := 0 |
| 1344 | + for _, source := range sources { |
| 1345 | + present, err := t.sourcePresent(ctx, source) |
| 1346 | + if err != nil { |
| 1347 | + return err |
| 1348 | + } |
| 1349 | + if present { |
| 1350 | + continue |
| 1351 | + } |
| 1352 | + if _, err := t.store.DB().ExecContext(ctx, `DELETE FROM custom_view_sources WHERE view=? AND source=?`, name, source); err != nil { |
| 1353 | + return err |
| 1354 | + } |
| 1355 | + removed++ |
| 1356 | + } |
| 1357 | + if removed == 0 { |
| 1358 | + return nil |
| 1359 | + } |
| 1360 | + return t.dropOrphanArtifacts(ctx, name) |
| 1361 | +} |
| 1362 | + |
| 1363 | +// pruneAllViewArtifacts runs the prune for every view; the maintenance |
| 1364 | +// sweep calls it after expired events are removed. |
| 1365 | +func (t *Tenant) pruneAllViewArtifacts(ctx context.Context) error { |
| 1366 | + rows, err := t.customViewRows(ctx) |
| 1367 | + if err != nil { |
| 1368 | + return err |
| 1369 | + } |
| 1370 | + for _, view := range rows { |
| 1371 | + if err := t.pruneViewArtifacts(ctx, view.Name, ""); err != nil { |
| 1372 | + return err |
| 1373 | + } |
| 1374 | + } |
| 1375 | + return nil |
| 1376 | +} |
| 1377 | + |
| 1378 | +func (t *Tenant) sourcePresent(ctx context.Context, source string) (bool, error) { |
| 1379 | + var one int |
| 1380 | + var err error |
| 1381 | + if tag := viewSourceTag(source); tag[0] == "a" { |
| 1382 | + parts := strings.SplitN(source, ":", 3) |
| 1383 | + if len(parts) != 3 { |
| 1384 | + return false, nil |
| 1385 | + } |
| 1386 | + err = t.store.DB().QueryRowContext(ctx, `SELECT 1 FROM events WHERE kind=? AND pubkey=? AND d=? LIMIT 1`, event.KIND_REPO_STATE, parts[1], parts[2]).Scan(&one) |
| 1387 | + } else { |
| 1388 | + err = t.store.DB().QueryRowContext(ctx, `SELECT 1 FROM events WHERE id=? AND (expires=0 OR expires>?) LIMIT 1`, source, time.Now().Unix()).Scan(&one) |
| 1389 | + } |
| 1390 | + if errors.Is(err, sql.ErrNoRows) { |
| 1391 | + return false, nil |
| 1392 | + } |
| 1393 | + return err == nil, err |
| 1394 | +} |
| 1395 | + |
| 1396 | +// dropOrphanArtifacts removes artifacts without a source, and their stored |
| 1397 | +// records, then re-signs the artifacts whose source list shrank. |
| 1398 | +func (t *Tenant) dropOrphanArtifacts(ctx context.Context, name string) error { |
| 1399 | + ids, err := t.customViewArtifactIDs(ctx, `SELECT event_id FROM custom_view_artifacts WHERE view=? AND NOT EXISTS(SELECT 1 FROM custom_view_sources WHERE custom_view_sources.view=custom_view_artifacts.view AND custom_view_sources.hash=custom_view_artifacts.hash)`, name) |
| 1400 | + if err != nil { |
| 1401 | + return err |
| 1402 | + } |
| 1403 | + if _, err := t.store.DB().ExecContext(ctx, `DELETE FROM custom_view_artifacts WHERE view=? AND NOT EXISTS(SELECT 1 FROM custom_view_sources WHERE custom_view_sources.view=custom_view_artifacts.view AND custom_view_sources.hash=custom_view_artifacts.hash)`, name); err != nil { |
| 1404 | + return err |
| 1405 | + } |
| 1406 | + if err := t.deleteStoredArtifacts(ctx, ids); err != nil { |
| 1407 | + return err |
| 1408 | + } |
| 1409 | + if len(ids) > 0 { |
| 1410 | + t.app.telemetry.Logger().Info("view artifacts removed", "tenant", t.meta.Name, "removed", len(ids)) |
| 1411 | + } |
| 1412 | + // Artifacts that kept a source but lost one carry a tag for it; a fresh |
| 1413 | + // record drops it. |
| 1414 | + view, err := t.customViewByName(ctx, name) |
| 1415 | + if err != nil { |
| 1416 | + return nil |
| 1417 | + } |
| 1418 | + rows, err := t.store.DB().QueryContext(ctx, `SELECT a.hash, a.raw FROM custom_view_artifacts a WHERE a.view=?`, name) |
| 1419 | + if err != nil { |
| 1420 | + return err |
| 1421 | + } |
| 1422 | + type stale struct{ hash string } |
| 1423 | + var refresh []stale |
| 1424 | + for rows.Next() { |
| 1425 | + var hash, raw string |
| 1426 | + if err := rows.Scan(&hash, &raw); err != nil { |
| 1427 | + rows.Close() |
| 1428 | + return err |
| 1429 | + } |
| 1430 | + var signed event.Event |
| 1431 | + if json.Unmarshal([]byte(raw), &signed) != nil { |
| 1432 | + continue |
| 1433 | + } |
| 1434 | + var tagged []string |
| 1435 | + for _, tag := range signed.Tags { |
| 1436 | + if len(tag) == 2 && (tag[0] == "e" || tag[0] == "a") { |
| 1437 | + tagged = append(tagged, tag[1]) |
| 1438 | + } |
| 1439 | + } |
| 1440 | + current, err := t.artifactSources(ctx, name, hash) |
| 1441 | + if err != nil { |
| 1442 | + rows.Close() |
| 1443 | + return err |
| 1444 | + } |
| 1445 | + sort.Strings(tagged) |
| 1446 | + sort.Strings(current) |
| 1447 | + if strings.Join(tagged, "\n") != strings.Join(current, "\n") { |
| 1448 | + refresh = append(refresh, stale{hash}) |
| 1449 | + } |
| 1450 | + } |
| 1451 | + if err := errors.Join(rows.Err(), rows.Close()); err != nil { |
| 1452 | + return err |
| 1453 | + } |
| 1454 | + for _, item := range refresh { |
| 1455 | + if err := t.signArtifact(ctx, view, item.hash, time.Now().Unix()); err != nil { |
| 1456 | + return err |
| 1457 | + } |
| 1458 | + } |
| 1459 | + return nil |
| 1460 | +} |
| 1461 | + |
| 1462 | +func (t *Tenant) artifactSources(ctx context.Context, name, hash string) ([]string, error) { |
| 1463 | + rows, err := t.store.DB().QueryContext(ctx, `SELECT source FROM custom_view_sources WHERE view=? AND hash=?`, name, hash) |
| 1464 | + if err != nil { |
| 1465 | + return nil, err |
| 1466 | + } |
| 1467 | + defer rows.Close() |
| 1468 | + var out []string |
| 1469 | + for rows.Next() { |
| 1470 | + var source string |
| 1471 | + if err := rows.Scan(&source); err != nil { |
| 1472 | + return nil, err |
| 1473 | + } |
| 1474 | + out = append(out, source) |
| 1475 | + } |
| 1476 | + return out, rows.Err() |
| 1477 | +} |
| 1478 | + |
| 1479 | +func (t *Tenant) customViewArtifactIDs(ctx context.Context, query string, args ...any) ([]string, error) { |
| 1480 | + rows, err := t.store.DB().QueryContext(ctx, query, args...) |
| 1481 | + if err != nil { |
| 1482 | + return nil, err |
| 1483 | + } |
| 1484 | + defer rows.Close() |
| 1485 | + var ids []string |
| 1486 | + for rows.Next() { |
| 1487 | + var id string |
| 1488 | + if err := rows.Scan(&id); err != nil { |
| 1489 | + return nil, err |
| 1490 | + } |
| 1491 | + if id != "" { |
| 1492 | + ids = append(ids, id) |
| 1493 | + } |
| 1494 | + } |
| 1495 | + return ids, rows.Err() |
| 1496 | +} |
| 1497 | + |
| 1498 | +// deleteStoredArtifacts removes the public records of artifacts by id. |
| 1499 | +func (t *Tenant) deleteStoredArtifacts(ctx context.Context, ids []string) error { |
| 1500 | + for _, id := range ids { |
| 1501 | + if _, err := t.store.DeleteEvent(ctx, id); err != nil { |
| 1502 | + return err |
| 1503 | + } |
| 1504 | + } |
| 1505 | + return nil |
| 1506 | +} |
| 1507 | + |
| 1508 | +// cascadeViewDeletion runs after a kind 5 deletion is stored: artifacts |
| 1509 | +// attached to the deleted events or addresses lose that source at once. |
| 1510 | +func (t *Tenant) cascadeViewDeletion(ctx context.Context, e event.Event) { |
| 1511 | + if e.Kind != event.KIND_DELETION { |
| 1512 | + return |
| 1513 | + } |
| 1514 | + rows, err := t.customViewRows(ctx) |
| 1515 | + if err != nil || len(rows) == 0 { |
| 1516 | + return |
| 1517 | + } |
| 1518 | + for _, view := range rows { |
| 1519 | + if err := t.pruneViewArtifacts(ctx, view.Name, ""); err != nil { |
| 1520 | + t.app.telemetry.Logger().Error("view artifacts not pruned", "tenant", t.meta.Name, "error", err) |
| 1521 | + } |
| 1522 | + } |
| 1523 | +} |
| 1524 | + |
| 1525 | +// artifactRecord is one stored artifact as the route serves it. |
| 1526 | +type artifactRecord struct { |
| 1527 | + Type string |
| 1528 | + Audience string |
| 1529 | + Content string |
| 1530 | + EventID string |
| 1531 | +} |
| 1532 | + |
| 1533 | +func (t *Tenant) customViewArtifact(ctx context.Context, name, hash string) (artifactRecord, error) { |
| 1534 | + var record artifactRecord |
| 1535 | + err := t.store.DB().QueryRowContext(ctx, `SELECT type,audience,content,event_id FROM custom_view_artifacts WHERE view=? AND hash=?`, name, hash).Scan(&record.Type, &record.Audience, &record.Content, &record.EventID) |
| 1536 | + if errors.Is(err, sql.ErrNoRows) { |
| 1537 | + return artifactRecord{}, errors.New("not found: artifact") |
| 1538 | + } |
| 1539 | + return record, err |
| 1540 | +} |
| 1541 | + |
| 1542 | +// body returns the bytes served for the artifact. |
| 1543 | +func (r artifactRecord) body() ([]byte, error) { |
| 1544 | + if r.Type == "image/png" { |
| 1545 | + return base64.StdEncoding.DecodeString(r.Content) |
| 1546 | + } |
| 1547 | + return []byte(r.Content), nil |
| 1548 | +} |
| 1549 | diff --git a/internal/daemon/http.go b/internal/daemon/http.go |
| 1550 | index f5ab888..9c47d81 100644 |
| 1551 | --- a/internal/daemon/http.go |
| 1552 | +++ b/internal/daemon/http.go |
| 1553 | @@ -83,6 +83,10 @@ func (t *Tenant) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| 1554 | if t.tryBrowseHTTP(w, r) { |
| 1555 | return |
| 1556 | } |
| 1557 | + if isViewArtifactPath(r.URL.Path) { |
| 1558 | + t.viewArtifactHTTP(w, r) |
| 1559 | + return |
| 1560 | + } |
| 1561 | if t.sites != nil && t.sites.MatchesHost(r.Host) { |
| 1562 | t.sites.Handler().ServeHTTP(w, r) |
| 1563 | return |
| 1564 | diff --git a/internal/daemon/management.go b/internal/daemon/management.go |
| 1565 | index 4262486..81d2e7d 100644 |
| 1566 | --- a/internal/daemon/management.go |
| 1567 | +++ b/internal/daemon/management.go |
| 1568 | @@ -50,6 +50,10 @@ func (t *Tenant) executeManagement(ctx context.Context, actor, method string, pa |
| 1569 | result, err = t.callbackExecute(ctx, actor, method, params) |
| 1570 | return result, true, err |
| 1571 | } |
| 1572 | + if customViewMethod(method) { |
| 1573 | + result, err = t.customViewExecute(ctx, actor, method, params) |
| 1574 | + return result, true, err |
| 1575 | + } |
| 1576 | if !managementAdapterMethod(method) { |
| 1577 | return nil, false, nil |
| 1578 | } |
| 1579 | @@ -138,7 +142,7 @@ func publicManagementMethod(method string) bool { |
| 1580 | // ManagementMethods is the source-compatible NIP-86 registry. The hosted |
| 1581 | // lease, trial, fuel, and entitlement controls are intentionally absent. |
| 1582 | func ManagementMethods() []string { |
| 1583 | - return []string{"supportedmethods", "listaudit", "stats", "getpolicy", "setpolicy", "listviews", "banpubkey", "setblockedwords", "allowpubkey", "setmember", "unrulepubkey", "removemember", "listbannedpubkeys", "listallowedpubkeys", "listmembers", "listpeople", "listjoinrequests", "approvejoin", "denyjoin", "createinvite", "listinvites", "revokeinvite", "listclaims", "listlisthistory", "restorelist", "createclaim", "deleteclaim", "removesubtree", "banevent", "allowevent", "listeventsneedingmoderation", "blockip", "unblockip", "listblockedips", "listreports", "resolvereport", "exportconfig", "importconfig", "deleterelay", "listblobs", "listsites", "deleteblob", "deleteevent", "listrecentevents", "searchevents", "pinevent", "unpinevent", "listpins", "allowkind", "disallowkind", "unrulekind", "storagestats", "gitstorage", "setretention", "listretention", "purgekind", "listallowedkinds", "listblockedkinds", "notifytest", "resetrules", "listpresets", "listconnectiontemplates", "listconnections", "setconnections", "applypreset", "forkrelay", "pullfrom", "pullstatus", "listjobs", "deliverystatus", "addjob", "removejob", "runjob", "backfill", "transferowner", "listdumps", "deletedump", "dumpnow", "backupnow", "listbackups", "deletebackup", "setsuccession", "clearsuccession", "successionstatus", "changerelayname", "changerelaydescription", "changerelayicon", "adddomain", "setdomainsite", "checkdomain", "removedomain", "listdomains", "listagents", "pauseagent", "resumeagent", "revokeagent", "pauseallagents", "resumeallagents", "listcallbacks", "addcallback", "removecallback", "pausecallback", "resumecallback"} |
| 1584 | + return []string{"supportedmethods", "listaudit", "stats", "getpolicy", "setpolicy", "listviews", "banpubkey", "setblockedwords", "allowpubkey", "setmember", "unrulepubkey", "removemember", "listbannedpubkeys", "listallowedpubkeys", "listmembers", "listpeople", "listjoinrequests", "approvejoin", "denyjoin", "createinvite", "listinvites", "revokeinvite", "listclaims", "listlisthistory", "restorelist", "createclaim", "deleteclaim", "removesubtree", "banevent", "allowevent", "listeventsneedingmoderation", "blockip", "unblockip", "listblockedips", "listreports", "resolvereport", "exportconfig", "importconfig", "deleterelay", "listblobs", "listsites", "deleteblob", "deleteevent", "listrecentevents", "searchevents", "pinevent", "unpinevent", "listpins", "allowkind", "disallowkind", "unrulekind", "storagestats", "gitstorage", "setretention", "listretention", "purgekind", "listallowedkinds", "listblockedkinds", "notifytest", "resetrules", "listpresets", "listconnectiontemplates", "listconnections", "setconnections", "applypreset", "forkrelay", "pullfrom", "pullstatus", "listjobs", "deliverystatus", "addjob", "removejob", "runjob", "backfill", "transferowner", "listdumps", "deletedump", "dumpnow", "backupnow", "listbackups", "deletebackup", "setsuccession", "clearsuccession", "successionstatus", "changerelayname", "changerelaydescription", "changerelayicon", "adddomain", "setdomainsite", "checkdomain", "removedomain", "listdomains", "listagents", "pauseagent", "resumeagent", "revokeagent", "pauseallagents", "resumeallagents", "listcallbacks", "addcallback", "removecallback", "pausecallback", "resumecallback", "listcustomviews", "addcustomview", "removecustomview", "runcustomview", "pausecustomview", "resumecustomview"} |
| 1585 | } |
| 1586 | |
| 1587 | func managementRoleAllows(method, role string) bool { |
| 1588 | diff --git a/internal/daemon/scheduler.go b/internal/daemon/scheduler.go |
| 1589 | index 596ab32..5514377 100644 |
| 1590 | --- a/internal/daemon/scheduler.go |
| 1591 | +++ b/internal/daemon/scheduler.go |
| 1592 | @@ -44,6 +44,7 @@ func (t *Tenant) runScheduler() { |
| 1593 | } |
| 1594 | if !wake { |
| 1595 | report("records", t.records.Tick(ctx, now.Unix())) |
| 1596 | + report("custom views", t.tickCustomViews(ctx, now.Unix())) |
| 1597 | if !now.Before(nextMaintenance) { |
| 1598 | if t.blobs != nil { |
| 1599 | report("partial uploads", t.blobs.CleanupMultipart(ctx)) |
| 1600 | diff --git a/internal/daemon/services.go b/internal/daemon/services.go |
| 1601 | index 0e07ec5..d760074 100644 |
| 1602 | --- a/internal/daemon/services.go |
| 1603 | +++ b/internal/daemon/services.go |
| 1604 | @@ -28,6 +28,7 @@ import ( |
| 1605 | "github.com/FelineStateMachine/tinyrelay/internal/replication" |
| 1606 | "github.com/FelineStateMachine/tinyrelay/internal/sites" |
| 1607 | "github.com/FelineStateMachine/tinyrelay/internal/storage" |
| 1608 | + "github.com/FelineStateMachine/tinyrelay/internal/views" |
| 1609 | "github.com/FelineStateMachine/tinyrelay/internal/webui" |
| 1610 | "github.com/FelineStateMachine/tinyrelay/internal/work" |
| 1611 | ) |
| 1612 | @@ -46,6 +47,9 @@ func (t *Tenant) initServices(ctx context.Context) error { |
| 1613 | if err := t.initCallbacks(ctx); err != nil { |
| 1614 | return err |
| 1615 | } |
| 1616 | + if err := t.initCustomViews(ctx); err != nil { |
| 1617 | + return err |
| 1618 | + } |
| 1619 | t.blobs, err = blob.New(ctx, blob.Config{ |
| 1620 | Root: t.meta.Paths.Root, PublicURL: t.publicURL, Store: t.store, Authorize: t.authorizeBlob, UploadTerms: t.blobUploadTerms, |
| 1621 | Limits: func() blob.Limits { |
| 1622 | @@ -192,6 +196,10 @@ func (b backend) URL() string { return b.tenant.publicURL } |
| 1623 | func (b backend) Slug() string { return b.tenant.meta.Name } |
| 1624 | func (b backend) Identity() string { return b.tenant.records.PublicKey() } |
| 1625 | |
| 1626 | +// CustomViews gives the page renderers the enabled custom views' names and |
| 1627 | +// languages, never a transform or a secret. |
| 1628 | +func (b backend) CustomViews() []views.View { return b.tenant.CustomViews() } |
| 1629 | + |
| 1630 | // ReadAllowed is consumed by the web UI private boundary. It deliberately |
| 1631 | // delegates to the same uncached membership check used by private Git and |
| 1632 | // NIP-42, so a revoked browser session cannot retain page access. |
| 1633 | @@ -581,6 +589,7 @@ func (t *Tenant) workHandlers() map[string]work.Handler { |
| 1634 | } |
| 1635 | handlers[notificationPush] = t.handleNotificationPush |
| 1636 | handlers[callbackDelivery] = t.handleCallbackDelivery |
| 1637 | + handlers[viewTransform] = t.handleViewTransform |
| 1638 | return handlers |
| 1639 | } |
| 1640 | |
| 1641 | @@ -616,6 +625,7 @@ func (t *Tenant) releaseGit(ctx context.Context, id string) error { |
| 1642 | // A repository state is hidden until its objects arrive, so agents |
| 1643 | // that wait for pushes are woken here rather than at publish time. |
| 1644 | t.notifyCallbacks(ctx, released) |
| 1645 | + t.queueCustomViews(ctx, released) |
| 1646 | } |
| 1647 | return err |
| 1648 | } |
| 1649 | @@ -657,8 +667,10 @@ func (t *Tenant) sweep(ctx context.Context, now int64) error { |
| 1650 | if err != nil { |
| 1651 | return err |
| 1652 | } |
| 1653 | - _, err = t.store.SweepRetention(ctx, opts) |
| 1654 | - return err |
| 1655 | + if _, err := t.store.SweepRetention(ctx, opts); err != nil { |
| 1656 | + return err |
| 1657 | + } |
| 1658 | + return t.pruneAllViewArtifacts(ctx) |
| 1659 | } |
| 1660 | func (t *Tenant) replacePolicy(p policy.Policy) { |
| 1661 | t.mu.Lock() |
| 1662 | @@ -755,6 +767,8 @@ func (t *Tenant) commitImported(ctx context.Context, e event.Event, origin repli |
| 1663 | if err == nil { |
| 1664 | t.notifyDevices(ctx, e) |
| 1665 | t.notifyCallbacks(ctx, e) |
| 1666 | + t.queueCustomViews(ctx, e) |
| 1667 | + t.cascadeViewDeletion(ctx, e) |
| 1668 | t.notifyWikiMerge(ctx, e) |
| 1669 | t.notifyWikiProposal(ctx, e) |
| 1670 | } |
| 1671 | diff --git a/internal/daemon/tenant.go b/internal/daemon/tenant.go |
| 1672 | index 52203c2..3aa6931 100644 |
| 1673 | --- a/internal/daemon/tenant.go |
| 1674 | +++ b/internal/daemon/tenant.go |
| 1675 | @@ -76,6 +76,10 @@ type Tenant struct { |
| 1676 | callbackIndex map[int][]callbackRecord |
| 1677 | callbackLocks map[string]*sync.Mutex |
| 1678 | callbackClient *http.Client |
| 1679 | + viewMu sync.Mutex |
| 1680 | + viewIndex map[int][]customView |
| 1681 | + viewLocks map[string]*sync.Mutex |
| 1682 | + viewClient *http.Client |
| 1683 | } |
| 1684 | |
| 1685 | func newTenant(ctx context.Context, cfg tenantConfig) (*Tenant, error) { |
| 1686 | @@ -396,6 +400,8 @@ func (t *Tenant) Publish(ctx context.Context, e event.Event, s relay.Session) (s |
| 1687 | } |
| 1688 | t.notifyDevices(ctx, e) |
| 1689 | t.notifyCallbacks(ctx, e) |
| 1690 | + t.queueCustomViews(ctx, e) |
| 1691 | + t.cascadeViewDeletion(ctx, e) |
| 1692 | t.notifyWikiMerge(ctx, e) |
| 1693 | t.notifyWikiProposal(ctx, e) |
| 1694 | // Stage Git metadata before acknowledging the event. This closes the |
| 1695 | diff --git a/internal/records/records.go b/internal/records/records.go |
| 1696 | index 444d99e..197408b 100644 |
| 1697 | --- a/internal/records/records.go |
| 1698 | +++ b/internal/records/records.go |
| 1699 | @@ -197,6 +197,12 @@ func (s *Service) Generate(ctx context.Context, kind int, tags [][]string, conte |
| 1700 | return s.signed(ctx, kind, tags, content, now) |
| 1701 | } |
| 1702 | |
| 1703 | +// Sign signs a record without storing it, for records whose audience is |
| 1704 | +// decided when they are served, such as members-only view artifacts. |
| 1705 | +func (s *Service) Sign(ctx context.Context, kind int, tags [][]string, content string, now int64) (event.Event, error) { |
| 1706 | + return s.signedOnly(ctx, kind, tags, content, now) |
| 1707 | +} |
| 1708 | + |
| 1709 | func (s *Service) profile(ctx context.Context, now int64) (event.Event, error) { |
| 1710 | p := s.policy() |
| 1711 | b, _ := json.Marshal(map[string]string{"name": p.Name, "about": p.Description, "picture": p.Icon}) |
| 1712 | diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go |
| 1713 | index c21e2cf..26c7a8f 100644 |
| 1714 | --- a/internal/telemetry/telemetry.go |
| 1715 | +++ b/internal/telemetry/telemetry.go |
| 1716 | @@ -236,10 +236,10 @@ func latencyBuckets() []float64 { |
| 1717 | return []float64{.0005, .001, .005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10} |
| 1718 | } |
| 1719 | |
| 1720 | -var operationNames = map[string]struct{}{"http": {}, "websocket": {}, "publish": {}, "subscribe": {}, "query": {}, "count": {}, "fanout": {}, "delivery": {}, "commit": {}, "save": {}, "parse": {}, "authorize": {}, "upgrade": {}, "callback": {}, "delivery-discovery": {}, "notification-broadcast": {}, "browserepos": {}, "browserepo": {}, "browsefiles": {}, "browsefile": {}, "browsestatus": {}, "browseissues": {}, "browsepulls": {}, "browseissue": {}, "browsepull": {}, "browse-download": {}, "push": {}, "mcp": {}, "rooms": {}, "blob-expiry": {}} |
| 1721 | +var operationNames = map[string]struct{}{"http": {}, "websocket": {}, "publish": {}, "subscribe": {}, "query": {}, "count": {}, "fanout": {}, "delivery": {}, "commit": {}, "save": {}, "parse": {}, "authorize": {}, "upgrade": {}, "callback": {}, "delivery-discovery": {}, "notification-broadcast": {}, "browserepos": {}, "browserepo": {}, "browsefiles": {}, "browsefile": {}, "browsestatus": {}, "browseissues": {}, "browsepulls": {}, "browseissue": {}, "browsepull": {}, "browse-download": {}, "push": {}, "mcp": {}, "rooms": {}, "blob-expiry": {}, "view": {}} |
| 1722 | var outcomeNames = map[string]struct{}{"success": {}, "ok": {}, "invalid": {}, "unauthorized": {}, "timeout": {}, "busy": {}, "closed": {}, "error": {}, "paused": {}} |
| 1723 | var queueNames = map[string]struct{}{"inbox": {}, "outbox": {}, "write": {}, "delivery": {}} |
| 1724 | -var workNames = map[string]struct{}{"delivery": {}, "push": {}, "replication": {}, "webhook": {}, "job": {}, "records-projection": {}, "site-mirror": {}, "git-metadata": {}, "catalog-owner": {}, "callback": {}, "delivery-discovery": {}, "notification-delivery": {}, "notification-broadcast": {}, "notification-push": {}, "notification": {}, "callback-delivery": {}} |
| 1725 | +var workNames = map[string]struct{}{"delivery": {}, "push": {}, "replication": {}, "webhook": {}, "job": {}, "records-projection": {}, "site-mirror": {}, "git-metadata": {}, "catalog-owner": {}, "callback": {}, "delivery-discovery": {}, "notification-delivery": {}, "notification-broadcast": {}, "notification-push": {}, "notification": {}, "callback-delivery": {}, "view-transform": {}} |
| 1726 | |
| 1727 | func bounded(value string, allowed map[string]struct{}) string { |
| 1728 | if _, ok := allowed[value]; ok { |
| 1729 | diff --git a/internal/views/views.go b/internal/views/views.go |
| 1730 | new file mode 100644 |
| 1731 | index 0000000..273a26d |
| 1732 | --- /dev/null |
| 1733 | +++ b/internal/views/views.go |
| 1734 | @@ -0,0 +1,155 @@ |
| 1735 | +// Package views holds the parts of custom views that the renderers and the |
| 1736 | +// relay share: finding fenced code blocks in text, naming a block by its |
| 1737 | +// hash and the markup that shows an artifact in place of a block. It knows |
| 1738 | +// nothing about any particular transform. |
| 1739 | +package views |
| 1740 | + |
| 1741 | +import ( |
| 1742 | + "crypto/sha256" |
| 1743 | + "encoding/hex" |
| 1744 | + "html" |
| 1745 | + "regexp" |
| 1746 | + "strings" |
| 1747 | +) |
| 1748 | + |
| 1749 | +// Block is one fenced code block. Index counts every fenced block in the |
| 1750 | +// text from zero, whether or not it carries a language. |
| 1751 | +type Block struct { |
| 1752 | + Index int `json:"index"` |
| 1753 | + Lang string `json:"lang"` |
| 1754 | + Source string `json:"source"` |
| 1755 | +} |
| 1756 | + |
| 1757 | +// NamePattern is the shape of a custom view name. |
| 1758 | +var NamePattern = regexp.MustCompile(`^[a-z0-9-]{1,32}$`) |
| 1759 | + |
| 1760 | +// OpenFence reports whether a line opens a fenced code block: three or more |
| 1761 | +// backticks or tildes, with the language as the first word after them. The |
| 1762 | +// marker returned is the fence run, so the closing line must use the same |
| 1763 | +// character. |
| 1764 | +func OpenFence(line string) (marker, lang string, ok bool) { |
| 1765 | + trimmed := strings.TrimSpace(line) |
| 1766 | + if len(trimmed) < 3 || (trimmed[0] != '`' && trimmed[0] != '~') { |
| 1767 | + return "", "", false |
| 1768 | + } |
| 1769 | + run := len(trimmed) - len(strings.TrimLeft(trimmed, trimmed[:1])) |
| 1770 | + if run < 3 { |
| 1771 | + return "", "", false |
| 1772 | + } |
| 1773 | + info := strings.Fields(trimmed[run:]) |
| 1774 | + if len(info) > 0 { |
| 1775 | + lang = strings.ToLower(info[0]) |
| 1776 | + } |
| 1777 | + return trimmed[:run], lang, true |
| 1778 | +} |
| 1779 | + |
| 1780 | +// ClosesFence reports whether a line closes the fence opened with marker: a |
| 1781 | +// run of at least as many of the same character and nothing else. |
| 1782 | +func ClosesFence(line, marker string) bool { |
| 1783 | + trimmed := strings.TrimSpace(line) |
| 1784 | + return len(trimmed) >= len(marker) && strings.Trim(trimmed, marker[:1]) == "" |
| 1785 | +} |
| 1786 | + |
| 1787 | +// Blocks lists the fenced code blocks of a text in order. The source is the |
| 1788 | +// lines between the fences joined by newlines, without a trailing newline. |
| 1789 | +func Blocks(content string) []Block { |
| 1790 | + lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n") |
| 1791 | + var out []Block |
| 1792 | + for i := 0; i < len(lines); i++ { |
| 1793 | + marker, lang, ok := OpenFence(lines[i]) |
| 1794 | + if !ok { |
| 1795 | + continue |
| 1796 | + } |
| 1797 | + var body []string |
| 1798 | + for i++; i < len(lines) && !ClosesFence(lines[i], marker); i++ { |
| 1799 | + body = append(body, lines[i]) |
| 1800 | + } |
| 1801 | + out = append(out, Block{Index: len(out), Lang: lang, Source: strings.Join(body, "\n")}) |
| 1802 | + } |
| 1803 | + return out |
| 1804 | +} |
| 1805 | + |
| 1806 | +// Matching keeps the blocks whose language is one of the given languages. |
| 1807 | +func Matching(blocks []Block, languages []string) []Block { |
| 1808 | + var out []Block |
| 1809 | + for _, block := range blocks { |
| 1810 | + if block.Lang != "" && contains(languages, block.Lang) { |
| 1811 | + out = append(out, block) |
| 1812 | + } |
| 1813 | + } |
| 1814 | + return out |
| 1815 | +} |
| 1816 | + |
| 1817 | +// Hash names a block: the SHA-256 of the language, a newline and the |
| 1818 | +// source, in hex. The same block in two texts has the same hash. |
| 1819 | +func Hash(lang, source string) string { |
| 1820 | + sum := sha256.Sum256([]byte(lang + "\n" + source)) |
| 1821 | + return hex.EncodeToString(sum[:]) |
| 1822 | +} |
| 1823 | + |
| 1824 | +// HashPattern is the shape of a block hash in a path. |
| 1825 | +var HashPattern = regexp.MustCompile(`^[0-9a-f]{64}$`) |
| 1826 | + |
| 1827 | +// Path is the address an artifact is served at. |
| 1828 | +func Path(view, hash, extension string) string { |
| 1829 | + return "/views/" + view + "/" + hash + "." + extension |
| 1830 | +} |
| 1831 | + |
| 1832 | +// Figure is the markup for a block a custom view renders: the artifact as |
| 1833 | +// an object with the escaped code block inside it, so a missing or failed |
| 1834 | +// artifact shows the code without any script. |
| 1835 | +func Figure(view, lang, source string) string { |
| 1836 | + var b strings.Builder |
| 1837 | + b.WriteString(`<figure data-view="` + html.EscapeString(view) + `"><object data="` + Path(view, Hash(lang, source), "svg") + `" type="image/svg+xml"><pre><code`) |
| 1838 | + if lang != "" { |
| 1839 | + b.WriteString(` data-lang="` + html.EscapeString(lang) + `"`) |
| 1840 | + } |
| 1841 | + b.WriteString(">" + html.EscapeString(source) + "</code></pre></object></figure>\n") |
| 1842 | + return b.String() |
| 1843 | +} |
| 1844 | + |
| 1845 | +// Renderer decides how a fenced block is shown: the view that renders the |
| 1846 | +// language, if any. Languages maps a lowercase language to a view name. |
| 1847 | +type Renderer struct { |
| 1848 | + Languages map[string]string |
| 1849 | +} |
| 1850 | + |
| 1851 | +// NewRenderer indexes views by the languages they render. The first view |
| 1852 | +// that names a language renders it. |
| 1853 | +func NewRenderer(views []View) Renderer { |
| 1854 | + index := map[string]string{} |
| 1855 | + for _, view := range views { |
| 1856 | + for _, lang := range view.Languages { |
| 1857 | + lang = strings.ToLower(lang) |
| 1858 | + if _, taken := index[lang]; !taken { |
| 1859 | + index[lang] = view.Name |
| 1860 | + } |
| 1861 | + } |
| 1862 | + } |
| 1863 | + return Renderer{Languages: index} |
| 1864 | +} |
| 1865 | + |
| 1866 | +// View is the summary a renderer needs: the name and the languages. |
| 1867 | +type View struct { |
| 1868 | + Name string `json:"name"` |
| 1869 | + Languages []string `json:"languages"` |
| 1870 | +} |
| 1871 | + |
| 1872 | +// Block returns the figure markup for a block when a view renders its |
| 1873 | +// language. |
| 1874 | +func (r Renderer) Block(lang, source string) (string, bool) { |
| 1875 | + view, ok := r.Languages[strings.ToLower(lang)] |
| 1876 | + if !ok || view == "" { |
| 1877 | + return "", false |
| 1878 | + } |
| 1879 | + return Figure(view, strings.ToLower(lang), source), true |
| 1880 | +} |
| 1881 | + |
| 1882 | +func contains(values []string, value string) bool { |
| 1883 | + for _, item := range values { |
| 1884 | + if item == value { |
| 1885 | + return true |
| 1886 | + } |
| 1887 | + } |
| 1888 | + return false |
| 1889 | +} |
| 1890 | diff --git a/internal/views/views_test.go b/internal/views/views_test.go |
| 1891 | new file mode 100644 |
| 1892 | index 0000000..3b730f3 |
| 1893 | --- /dev/null |
| 1894 | +++ b/internal/views/views_test.go |
| 1895 | @@ -0,0 +1,90 @@ |
| 1896 | +package views |
| 1897 | + |
| 1898 | +import ( |
| 1899 | + "crypto/sha256" |
| 1900 | + "encoding/hex" |
| 1901 | + "strings" |
| 1902 | + "testing" |
| 1903 | +) |
| 1904 | + |
| 1905 | +func TestBlocksFindFencesInOrderWithLanguages(t *testing.T) { |
| 1906 | + content := "Intro\n\n```mermaid\ngraph TD;\n A-->B;\n```\n\nText\n\n~~~\nplain\n~~~\n\n ```` Python extra\nprint(1)\n```\nstill python\n````\n\n```dot\n" |
| 1907 | + blocks := Blocks(content) |
| 1908 | + if len(blocks) != 4 { |
| 1909 | + t.Fatalf("blocks = %+v", blocks) |
| 1910 | + } |
| 1911 | + for i, want := range []Block{ |
| 1912 | + {0, "mermaid", "graph TD;\n A-->B;"}, |
| 1913 | + {1, "", "plain"}, |
| 1914 | + {2, "python", "print(1)\n```\nstill python"}, |
| 1915 | + {3, "dot", ""}, |
| 1916 | + } { |
| 1917 | + if blocks[i] != want { |
| 1918 | + t.Errorf("block %d = %+v, want %+v", i, blocks[i], want) |
| 1919 | + } |
| 1920 | + } |
| 1921 | + matched := Matching(blocks, []string{"mermaid", "dot"}) |
| 1922 | + if len(matched) != 2 || matched[0].Index != 0 || matched[1].Index != 3 { |
| 1923 | + t.Fatalf("matching = %+v", matched) |
| 1924 | + } |
| 1925 | + if got := Matching(blocks, []string{"go"}); got != nil { |
| 1926 | + t.Fatalf("unexpected match %+v", got) |
| 1927 | + } |
| 1928 | +} |
| 1929 | + |
| 1930 | +func TestFenceHelpersRejectShortAndMixedFences(t *testing.T) { |
| 1931 | + if _, _, ok := OpenFence("``"); ok { |
| 1932 | + t.Fatal("two backticks opened a fence") |
| 1933 | + } |
| 1934 | + if _, _, ok := OpenFence("text ```"); ok { |
| 1935 | + t.Fatal("fence in the middle of a line opened a fence") |
| 1936 | + } |
| 1937 | + marker, lang, ok := OpenFence("~~~~ Mermaid title") |
| 1938 | + if !ok || marker != "~~~~" || lang != "mermaid" { |
| 1939 | + t.Fatalf("open = %q %q %v", marker, lang, ok) |
| 1940 | + } |
| 1941 | + if ClosesFence("~~~", marker) || ClosesFence("````", marker) || !ClosesFence(" ~~~~~ ", marker) { |
| 1942 | + t.Fatal("closing fence rules") |
| 1943 | + } |
| 1944 | +} |
| 1945 | + |
| 1946 | +func TestHashCoversLanguageAndSource(t *testing.T) { |
| 1947 | + sum := sha256.Sum256([]byte("mermaid\ngraph TD")) |
| 1948 | + if Hash("mermaid", "graph TD") != hex.EncodeToString(sum[:]) { |
| 1949 | + t.Fatal("hash differs from sha256(lang + newline + source)") |
| 1950 | + } |
| 1951 | + if Hash("mermaid", "graph TD") == Hash("dot", "graph TD") { |
| 1952 | + t.Fatal("language is not part of the hash") |
| 1953 | + } |
| 1954 | + if Blocks("```mermaid\na\n```")[0].Source != Blocks("```mermaid\r\na\r\n```")[0].Source { |
| 1955 | + t.Fatal("line endings change the source") |
| 1956 | + } |
| 1957 | +} |
| 1958 | + |
| 1959 | +func TestFigureEmbedsTheArtifactWithTheCodeAsFallback(t *testing.T) { |
| 1960 | + got := Figure("diagrams", "mermaid", "graph TD;\n A-->B & <C>;") |
| 1961 | + hash := Hash("mermaid", "graph TD;\n A-->B & <C>;") |
| 1962 | + want := `<figure data-view="diagrams"><object data="/views/diagrams/` + hash + `.svg" type="image/svg+xml"><pre><code data-lang="mermaid">graph TD;` + "\n" + ` A-->B & <C>;</code></pre></object></figure>` + "\n" |
| 1963 | + if got != want { |
| 1964 | + t.Fatalf("figure =\n%s\nwant\n%s", got, want) |
| 1965 | + } |
| 1966 | + if strings.Contains(got, "class=") { |
| 1967 | + t.Fatal("figure carries a class attribute") |
| 1968 | + } |
| 1969 | +} |
| 1970 | + |
| 1971 | +func TestRendererPicksTheFirstViewForALanguage(t *testing.T) { |
| 1972 | + r := NewRenderer([]View{{Name: "first", Languages: []string{"mermaid"}}, {Name: "second", Languages: []string{"Mermaid", "dot"}}}) |
| 1973 | + if html, ok := r.Block("MERMAID", "x"); !ok || !strings.Contains(html, `data-view="first"`) { |
| 1974 | + t.Fatalf("mermaid = %q %v", html, ok) |
| 1975 | + } |
| 1976 | + if html, ok := r.Block("dot", "x"); !ok || !strings.Contains(html, `data-view="second"`) { |
| 1977 | + t.Fatalf("dot = %q %v", html, ok) |
| 1978 | + } |
| 1979 | + if _, ok := r.Block("go", "x"); ok { |
| 1980 | + t.Fatal("go rendered") |
| 1981 | + } |
| 1982 | + if _, ok := (Renderer{}).Block("mermaid", "x"); ok { |
| 1983 | + t.Fatal("empty renderer rendered") |
| 1984 | + } |
| 1985 | +} |