diff --git a/.agents/upstream-sync/new-api-sync.md b/.agents/upstream-sync/new-api-sync.md new file mode 100644 index 0000000..8610478 --- /dev/null +++ b/.agents/upstream-sync/new-api-sync.md @@ -0,0 +1,53 @@ +# New API Upstream Sync Log + +This file tracks selective synchronization from upstream New API into MAX-API. It is a durable project workflow record, not a temporary investigation note. + +## Current Baseline + +- Upstream checkout: `new-api/new-api` +- Upstream remote: `https://github.com/QuantumNous/new-api.git` +- Last reviewed upstream commit: `25f998595d2da4ac9c749f3eae8fffcf9047bc3e` +- Last reviewed upstream tag: `v1.0.0-rc.15` +- MAX-API branch reviewed: `cscitech` +- MAX-API commit reviewed: `477e4809bac3da3584dfec17be97c991fec78ba3` +- Last review date: `2026-06-29` + +## Sync Rules + +- Do not wholesale copy upstream files. +- Preserve MAX-API branding, package/import paths, billing, quota, relay retry, security, and UI customizations. +- Prioritize security fixes, billing/log correctness, relay compatibility, database migrations, and operational reliability. +- Keep scratch notes and generated diffs under `.tmp/`; keep this durable sync log in `.agents/upstream-sync/`. +- For frontend user-facing text, update all supported i18n locales when new strings are introduced. + +## Status Values + +- `pending`: not yet analyzed +- `accepted`: selected for porting +- `ported`: ported into MAX-API +- `skipped`: intentionally not merged +- `superseded`: MAX-API already has equivalent or stronger behavior +- `blocked`: needs a decision or prerequisite + +## Review Queue + +| Upstream commit/range | Category | Summary | Status | MAX action | Verification | Notes | +| --- | --- | --- | --- | --- | --- | --- | +| `0b48ad86`, `626dadb5` | security, frontend | Custom HTML/Markdown rendering now uses shared `RichContent`/`HtmlContent`; raw HTML is sanitized before rendering on Home, About, Legal, notification popover, and announcement detail content. | ported | Added MAX-API `HtmlContent` and `RichContent`, replaced unsafe `dangerouslySetInnerHTML` paths in public content pages, sandboxed public content iframes, and reused existing `getRenderableContentKind`. | `bun run typecheck`; targeted Node tests for `html-content`, `markdown`, and `renderable-content`. | Highest-priority fix from the 2026-06-29 upstream review. Preserve MAX-API public page layout and branding while porting. | +| `3a506f50`, `2d5a0416` | relay, compatibility | Harden Chat-to-Responses conversion and add Responses-to-Chat/Gemini Responses support. | pending | Evaluate after security rendering fix. | - | Likely conflicts with MAX-API empty completion retry and billing/log behavior. | +| `df44a75d` | bugfix, logs | Adapt ClickHouse log LIKE filters. | pending | Evaluate if ClickHouse log DB is enabled or supported in MAX-API deployments. | - | Current MAX-API still has generic `LIKE ? ESCAPE '!'` in `model/log.go`. | +| `d10fc762` | bugfix, tasks | Attribute async task usage log to the initiating node. | pending | Port narrowly through task private data and task billing log attribution. | - | Important for multi-node deployments. | +| `4aee5f7d` | permissions, admin | Better admin permissions/RBAC for channels and users. | pending | Needs design review before porting. | - | Large change touching backend authz, routes, models, and frontend permissions. | +| `966af88e` | frontend, playground | Improve Playground chat experience and Markdown rendering. | pending | Evaluate after preserving MAX-API one-click clear behavior from `477e4809`. | - | Large frontend refactor. | +| `25f99859`, `1d166532` | frontend, channels | Refine channel management UI and channel drawer layout. | pending | Evaluate after higher-priority fixes. | - | Large channel drawer diff; likely conflicts with MAX-API custom channel UX. | + +## Incremental Sync Procedure + +1. Fetch upstream in `new-api/new-api`. +2. Read `Last reviewed upstream commit` from this file. +3. Compare only `last_reviewed..origin/main`. +4. Classify each upstream commit into security, bugfix, relay, billing, database, frontend, ops, docs, or tooling. +5. Update the Review Queue with status and rationale before porting. +6. Port only `accepted` items, preserving MAX-API behavior. +7. Record verification commands and any skipped/conflicting areas. +8. Advance `Last reviewed upstream commit` only after the range has been analyzed. diff --git a/.gitignore b/.gitignore index d4bb9a3..a7adb6c 100644 --- a/.gitignore +++ b/.gitignore @@ -24,7 +24,9 @@ tiktoken_cache plans .claude .cursor -.agents +.agents/* +!.agents/upstream-sync/ +!.agents/upstream-sync/** .codex .tmp diff --git a/model/log.go b/model/log.go index 2425848..8c09db8 100644 --- a/model/log.go +++ b/model/log.go @@ -53,9 +53,27 @@ type Log struct { RequestId string `json:"request_id,omitempty" gorm:"type:varchar(64);index:idx_logs_request_id;default:''"` UpstreamRequestId string `json:"upstream_request_id,omitempty" gorm:"type:varchar(128);index:idx_logs_upstream_request_id;default:''"` Other string `json:"other"` + IsRetry bool `json:"is_retry" gorm:"default:false;index"` LogId int `json:"log_id,omitempty" gorm:"-"` } +func (log *Log) BeforeCreate(*gorm.DB) error { + log.syncRetryMarker() + return nil +} + +func (log *Log) BeforeSave(*gorm.DB) error { + log.syncRetryMarker() + return nil +} + +func (log *Log) syncRetryMarker() { + if log == nil { + return + } + log.IsRetry = logOtherHasRetryMarker(log.Other) +} + // don't use iota, avoid change log type value const ( LogTypeUnknown = 0 @@ -78,11 +96,7 @@ func applyLogTypeFilter(tx *gorm.DB, logType int) *gorm.DB { } func applyRetryLogFilter(tx *gorm.DB) *gorm.DB { - retryPatterns := []string{ - `%"retry_log":true%`, - `%"empty_retry":true%`, - } - return tx.Where("(logs.other LIKE ? OR logs.other LIKE ?)", retryPatterns[0], retryPatterns[1]) + return tx.Where("logs.is_retry = ?", true) } func applyLogFilter(tx *gorm.DB, filter string) *gorm.DB { @@ -164,6 +178,23 @@ func stripLogsAuditContent(logs []*Log) { } } +func logOtherHasRetryMarker(other string) bool { + if strings.TrimSpace(other) == "" { + return false + } + otherMap, err := common.StrToMap(other) + if err != nil || otherMap == nil { + return false + } + if retryLog, ok := otherMap["retry_log"].(bool); ok && retryLog { + return true + } + if emptyRetry, ok := otherMap["empty_retry"].(bool); ok && emptyRetry { + return true + } + return false +} + func formatUserLog(log *Log) { if log == nil { return diff --git a/model/log_test.go b/model/log_test.go index d48e1d6..26e9675 100644 --- a/model/log_test.go +++ b/model/log_test.go @@ -1,11 +1,14 @@ package model import ( + "os" "testing" "time" "github.com/MAX-API-Next/MAX-API/common" + "github.com/glebarez/sqlite" "github.com/stretchr/testify/require" + "gorm.io/gorm" ) func withLogAuditSettings(t *testing.T, requestEnabled bool, responseEnabled bool) { @@ -70,6 +73,245 @@ func TestSumUsedQuotaRetryFilter(t *testing.T) { require.Equal(t, 71, stat.Tpm) } +func TestRetryFilterIgnoresNestedRetryMarker(t *testing.T) { + require.NoError(t, LOG_DB.Where("1 = 1").Delete(&Log{}).Error) + t.Cleanup(func() { + require.NoError(t, LOG_DB.Where("1 = 1").Delete(&Log{}).Error) + }) + + logs := []Log{ + { + UserId: 1, + CreatedAt: time.Now().Unix() - 10, + Type: LogTypeConsume, + Quota: 300, + PromptTokens: 3, + CompletionTokens: 7, + Other: common.MapToJsonStr(map[string]interface{}{ + "admin_info": map[string]interface{}{ + "request_content": `user prompt literally contains "retry_log":true`, + "retry_log": true, + }, + }), + }, + { + UserId: 1, + CreatedAt: time.Now().Unix() - 20, + Type: LogTypeConsume, + Quota: 200, + PromptTokens: 5, + CompletionTokens: 11, + Other: common.MapToJsonStr(map[string]interface{}{ + "retry_log": true, + }), + }, + } + require.NoError(t, LOG_DB.Create(&logs).Error) + + got, total, err := GetAllLogs(LogTypeUnknown, LogFilterRetry, 0, 0, "", "", "", 0, 10, 0, "", "", "") + require.NoError(t, err) + require.EqualValues(t, 1, total) + require.Len(t, got, 1) + require.Equal(t, logs[1].Id, got[0].Id) + + stat, err := SumUsedQuota(LogTypeUnknown, LogFilterRetry, 0, 0, "", "", "", 0, "") + require.NoError(t, err) + require.Equal(t, 200, stat.Quota) + require.Equal(t, 1, stat.Rpm) + require.Equal(t, 16, stat.Tpm) +} + +func TestBackfillLogRetryMarkerUsesTopLevelMarkersOnly(t *testing.T) { + markerKey := logRetryMarkerBackfillCompletionKey() + require.NoError(t, LOG_DB.Where("1 = 1").Delete(&Log{}).Error) + require.NoError(t, DB.Where(commonKeyCol+" = ?", markerKey).Delete(&Option{}).Error) + t.Cleanup(func() { + require.NoError(t, LOG_DB.Where("1 = 1").Delete(&Log{}).Error) + require.NoError(t, DB.Where(commonKeyCol+" = ?", markerKey).Delete(&Option{}).Error) + }) + + logs := []Log{ + { + UserId: 1, + Type: LogTypeConsume, + Other: common.MapToJsonStr(map[string]interface{}{ + "retry_log": true, + }), + }, + { + UserId: 1, + Type: LogTypeConsume, + Other: common.MapToJsonStr(map[string]interface{}{ + "admin_info": map[string]interface{}{ + "retry_log": true, + }, + }), + }, + } + require.NoError(t, LOG_DB.Create(&logs).Error) + require.NoError(t, LOG_DB.Model(&Log{}).Where("1 = 1").UpdateColumn("is_retry", false).Error) + + require.NoError(t, backfillLogRetryMarker()) + + var reloaded []Log + require.NoError(t, LOG_DB.Order("id asc").Find(&reloaded).Error) + require.Len(t, reloaded, 2) + require.True(t, reloaded[0].IsRetry) + require.False(t, reloaded[1].IsRetry) +} + +func TestBackfillLogRetryMarkerSkipsAfterCompletionMarker(t *testing.T) { + markerKey := logRetryMarkerBackfillCompletionKey() + require.NoError(t, LOG_DB.Where("1 = 1").Delete(&Log{}).Error) + require.NoError(t, DB.Where(commonKeyCol+" = ?", markerKey).Delete(&Option{}).Error) + t.Cleanup(func() { + require.NoError(t, LOG_DB.Where("1 = 1").Delete(&Log{}).Error) + require.NoError(t, DB.Where(commonKeyCol+" = ?", markerKey).Delete(&Option{}).Error) + }) + + log := Log{ + UserId: 1, + Type: LogTypeConsume, + Other: common.MapToJsonStr(map[string]interface{}{ + "retry_log": true, + }), + } + require.NoError(t, LOG_DB.Create(&log).Error) + require.NoError(t, LOG_DB.Model(&Log{}).Where("1 = 1").UpdateColumn("is_retry", false).Error) + require.NoError(t, backfillLogRetryMarker()) + + var marker Option + require.NoError(t, DB.First(&marker, commonKeyCol+" = ?", markerKey).Error) + require.Equal(t, "true", marker.Value) + + require.NoError(t, LOG_DB.Model(&Log{}).Where("1 = 1").UpdateColumn("is_retry", false).Error) + require.NoError(t, backfillLogRetryMarker()) + + var reloaded Log + require.NoError(t, LOG_DB.First(&reloaded, log.Id).Error) + require.False(t, reloaded.IsRetry) +} + +func TestBackfillLogRetryMarkerCompletionIsScopedToLogDBIdentity(t *testing.T) { + originalDB := DB + originalLOGDB := LOG_DB + originalLogSQLType := common.LogSqlType + t.Cleanup(func() { + DB = originalDB + LOG_DB = originalLOGDB + common.LogSqlType = originalLogSQLType + initCol() + }) + + mainDB := newRetryBackfillTestDB(t, &Option{}) + logOneDB := newRetryBackfillTestDB(t, &Log{}) + logTwoDB := newRetryBackfillTestDB(t, &Log{}) + + DB = mainDB + common.LogSqlType = common.DatabaseTypeSQLite + + t.Setenv("LOG_SQL_DSN", "sqlite://log-one") + initCol() + LOG_DB = logOneDB + logOne := Log{ + UserId: 1, + Type: LogTypeConsume, + Other: common.MapToJsonStr(map[string]interface{}{ + "retry_log": true, + }), + } + require.NoError(t, LOG_DB.Create(&logOne).Error) + require.NoError(t, LOG_DB.Model(&Log{}).Where("1 = 1").UpdateColumn("is_retry", false).Error) + require.NoError(t, backfillLogRetryMarker()) + + var reloaded Log + require.NoError(t, logOneDB.First(&reloaded, logOne.Id).Error) + require.True(t, reloaded.IsRetry) + + require.NoError(t, os.Setenv("LOG_SQL_DSN", "sqlite://log-two")) + initCol() + LOG_DB = logTwoDB + logTwo := Log{ + UserId: 1, + Type: LogTypeConsume, + Other: common.MapToJsonStr(map[string]interface{}{ + "retry_log": true, + }), + } + require.NoError(t, LOG_DB.Create(&logTwo).Error) + require.NoError(t, LOG_DB.Model(&Log{}).Where("1 = 1").UpdateColumn("is_retry", false).Error) + + require.NoError(t, backfillLogRetryMarker()) + + reloaded = Log{} + require.NoError(t, logTwoDB.First(&reloaded, logTwo.Id).Error) + require.True(t, reloaded.IsRetry) +} + +func TestLogRetryMarkerCompletionKeyUsesHashedLogDBIdentity(t *testing.T) { + originalLogSQLType := common.LogSqlType + t.Cleanup(func() { + common.LogSqlType = originalLogSQLType + }) + + common.LogSqlType = common.DatabaseTypeMySQL + t.Setenv("LOG_SQL_DSN", "user:secret@tcp(log-one:3306)/logs") + firstKey := logRetryMarkerBackfillCompletionKey() + require.NotEqual(t, logRetryMarkerBackfillOptionKey, firstKey) + require.NotContains(t, firstKey, "secret") + require.NotContains(t, firstKey, "log-one") + + require.NoError(t, os.Setenv("LOG_SQL_DSN", "user:secret@tcp(log-two:3306)/logs")) + secondKey := logRetryMarkerBackfillCompletionKey() + require.NotEqual(t, firstKey, secondKey) +} + +func TestLogRetryMarkerIsRecomputedWhenOtherChanges(t *testing.T) { + require.NoError(t, LOG_DB.Where("1 = 1").Delete(&Log{}).Error) + t.Cleanup(func() { + require.NoError(t, LOG_DB.Where("1 = 1").Delete(&Log{}).Error) + }) + + log := Log{ + UserId: 1, + Type: LogTypeConsume, + Other: common.MapToJsonStr(map[string]interface{}{ + "retry_log": true, + }), + } + require.NoError(t, LOG_DB.Create(&log).Error) + require.True(t, log.IsRetry) + + log.Other = common.MapToJsonStr(map[string]interface{}{ + "admin_info": map[string]interface{}{ + "use_channel": []string{"1"}, + }, + }) + require.NoError(t, LOG_DB.Save(&log).Error) + + var reloaded Log + require.NoError(t, LOG_DB.First(&reloaded, log.Id).Error) + require.False(t, reloaded.IsRetry) +} + +func newRetryBackfillTestDB(t *testing.T, models ...interface{}) *gorm.DB { + t.Helper() + + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + + sqlDB, err := db.DB() + require.NoError(t, err) + sqlDB.SetMaxOpenConns(1) + t.Cleanup(func() { + require.NoError(t, sqlDB.Close()) + }) + + require.NoError(t, db.AutoMigrate(models...)) + + return db +} + func createRetryFilterLogs(t *testing.T) []Log { t.Helper() diff --git a/model/main.go b/model/main.go index a5ae77f..c8c5a0c 100644 --- a/model/main.go +++ b/model/main.go @@ -1,6 +1,7 @@ package model import ( + "errors" "fmt" "log" "os" @@ -25,6 +26,22 @@ var commonFalseVal string var logKeyCol string var logGroupCol string +const logRetryMarkerBackfillOptionKey = "LogRetryMarkerBackfillCompleted" + +func logRetryMarkerBackfillCompletionKey() string { + logDSN := strings.TrimSpace(os.Getenv("LOG_SQL_DSN")) + if logDSN == "" { + return logRetryMarkerBackfillOptionKey + } + + if strings.HasPrefix(logDSN, "local") { + logDSN = logDSN + "\n" + common.SQLitePath + } + + fingerprint := common.Sha1([]byte(common.LogSqlType + "\n" + logDSN)) + return logRetryMarkerBackfillOptionKey + ":" + fingerprint +} + func initCol() { // init common column names if common.UsingPostgreSQL { @@ -296,6 +313,12 @@ func migrateDB() error { return err } } + if os.Getenv("LOG_SQL_DSN") == "" { + LOG_DB = DB + if err := backfillLogRetryMarker(); err != nil { + return err + } + } return nil } @@ -376,9 +399,88 @@ func migrateLOGDB() error { if err = LOG_DB.AutoMigrate(&Log{}); err != nil { return err } + if err = backfillLogRetryMarker(); err != nil { + return err + } return nil } +func backfillLogRetryMarker() error { + if LOG_DB == nil || !LOG_DB.Migrator().HasColumn(&Log{}, "is_retry") { + return nil + } + if completed, err := isLogRetryMarkerBackfillCompleted(); err == nil && completed { + return nil + } else if err != nil { + common.SysLog("failed to check log retry marker backfill status: " + err.Error()) + } + + const batchSize = 500 + var lastId int + for { + var logs []Log + if err := LOG_DB. + Select("id", "other", "is_retry"). + Where("id > ? AND is_retry = ?", lastId, false). + Order("id asc"). + Limit(batchSize). + Find(&logs).Error; err != nil { + return err + } + if len(logs) == 0 { + return markLogRetryMarkerBackfillCompleted() + } + + retryIds := make([]int, 0, len(logs)) + for _, log := range logs { + lastId = log.Id + if log.IsRetry { + continue + } + if logOtherHasRetryMarker(log.Other) { + retryIds = append(retryIds, log.Id) + } + } + if len(retryIds) > 0 { + if err := LOG_DB.Model(&Log{}). + Where("id IN ?", retryIds). + Update("is_retry", true).Error; err != nil { + return err + } + } + } +} + +func isLogRetryMarkerBackfillCompleted() (bool, error) { + if DB == nil || !DB.Migrator().HasTable(&Option{}) { + return false, nil + } + + var option Option + err := DB.First(&option, commonKeyCol+" = ?", logRetryMarkerBackfillCompletionKey()).Error + if err == nil { + return option.Value == "true", nil + } + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, nil + } + return false, err +} + +func markLogRetryMarkerBackfillCompleted() error { + if DB == nil || !DB.Migrator().HasTable(&Option{}) { + return nil + } + + markerKey := logRetryMarkerBackfillCompletionKey() + option := Option{Key: markerKey} + if err := DB.FirstOrCreate(&option, Option{Key: markerKey}).Error; err != nil { + return err + } + option.Value = "true" + return DB.Save(&option).Error +} + type sqliteColumnDef struct { Name string DDL string diff --git a/model/task_cas_test.go b/model/task_cas_test.go index 3aff847..cb3686b 100644 --- a/model/task_cas_test.go +++ b/model/task_cas_test.go @@ -39,6 +39,7 @@ func TestMain(m *testing.M) { &User{}, &Token{}, &Log{}, + &Option{}, &Channel{}, &Ability{}, &Redemption{}, diff --git a/web/default/src/components/html-content.test.ts b/web/default/src/components/html-content.test.ts new file mode 100644 index 0000000..8e7133c --- /dev/null +++ b/web/default/src/components/html-content.test.ts @@ -0,0 +1,119 @@ +/* +Copyright (C) 2023-2026 MAX-API-Next + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues +*/ +import assert from 'node:assert/strict' +import { after, describe, test } from 'node:test' +import DOMPurify from 'dompurify' +import { JSDOM } from 'jsdom' +import { sanitizeHtmlContent } from '@/lib/html-sanitizer' + +const dom = new JSDOM('', { url: 'http://localhost/' }) +const previousWindowDescriptor = Object.getOwnPropertyDescriptor( + globalThis, + 'window' +) + +Object.defineProperty(globalThis, 'window', { + configurable: true, + value: dom.window, +}) + +after(() => { + if (previousWindowDescriptor) { + Object.defineProperty(globalThis, 'window', previousWindowDescriptor) + return + } + + delete (globalThis as Partial).window +}) + +describe('sanitizeHtmlContentForTest', () => { + test('removes executable attributes from custom HTML content', () => { + const html = sanitizeHtmlContent( + '
safe
' + ) + + assert.match(html, /safe/) + assert.match(html, / { + const originalIsSupported = Object.getOwnPropertyDescriptor( + DOMPurify, + 'isSupported' + ) + const originalSanitize = Object.getOwnPropertyDescriptor( + DOMPurify, + 'sanitize' + ) + + Object.defineProperty(DOMPurify, 'isSupported', { + configurable: true, + value: true, + }) + Object.defineProperty(DOMPurify, 'sanitize', { + configurable: true, + value: () => { + throw new Error('sanitize failed') + }, + }) + + try { + assert.equal(sanitizeHtmlContent('

safe

'), '') + } finally { + if (originalSanitize) { + Object.defineProperty(DOMPurify, 'sanitize', originalSanitize) + } else { + delete (DOMPurify as Partial).sanitize + } + + if (originalIsSupported) { + Object.defineProperty(DOMPurify, 'isSupported', originalIsSupported) + } else { + delete (DOMPurify as Partial).isSupported + } + } + }) + + test('fails closed when sanitizer factory setup throws', () => { + const windowDescriptor = Object.getOwnPropertyDescriptor( + globalThis, + 'window' + ) + + Object.defineProperty(globalThis, 'window', { + configurable: true, + get() { + throw new Error('window unavailable') + }, + }) + + try { + assert.equal(sanitizeHtmlContent('

safe

'), '') + } finally { + if (windowDescriptor) { + Object.defineProperty(globalThis, 'window', windowDescriptor) + } else { + delete (globalThis as Partial).window + } + } + }) +}) diff --git a/web/default/src/components/html-content.tsx b/web/default/src/components/html-content.tsx new file mode 100644 index 0000000..3ca2aee --- /dev/null +++ b/web/default/src/components/html-content.tsx @@ -0,0 +1,43 @@ +/* +Copyright (C) 2023-2026 MAX-API-Next + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues +*/ +import { useMemo } from 'react' +import { sanitizeHtmlContent } from '@/lib/html-sanitizer' +import { cn } from '@/lib/utils' + +interface HtmlContentProps { + content: string + className?: string +} + +export function HtmlContent(props: HtmlContentProps) { + const html = useMemo( + () => sanitizeHtmlContent(props.content), + [props.content] + ) + + return ( +
+ ) +} diff --git a/web/default/src/components/notification-popover.tsx b/web/default/src/components/notification-popover.tsx index dd1c7a1..4ad40c3 100644 --- a/web/default/src/components/notification-popover.tsx +++ b/web/default/src/components/notification-popover.tsx @@ -19,6 +19,7 @@ For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API import type { TFunction } from 'i18next' import { Bell, Megaphone } from 'lucide-react' import { useTranslation } from 'react-i18next' +import { RichContent } from '@/components/rich-content' import { getAnnouncementColorClass } from '@/lib/colors' import { formatDateTimeObject } from '@/lib/time' import { cn } from '@/lib/utils' @@ -31,7 +32,6 @@ import { EmptyMedia, EmptyTitle, } from '@/components/ui/empty' -import { Markdown } from '@/components/ui/markdown' import { Popover, PopoverContent, @@ -185,7 +185,7 @@ function NoticeContent({ return ( - {notice} + ) } @@ -239,12 +239,12 @@ function AnnouncementsContent({
- {item.content || ''} +
{item.extra ? (
- {item.extra} +
) : null} diff --git a/web/default/src/components/rich-content.tsx b/web/default/src/components/rich-content.tsx new file mode 100644 index 0000000..094b8e2 --- /dev/null +++ b/web/default/src/components/rich-content.tsx @@ -0,0 +1,36 @@ +/* +Copyright (C) 2023-2026 MAX-API-Next + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues +*/ +import { HtmlContent } from '@/components/html-content' +import { Markdown } from '@/components/ui/markdown' + +type RichContentMode = 'markdown' | 'html' + +interface RichContentProps { + content: string + mode?: RichContentMode + className?: string +} + +export function RichContent(props: RichContentProps) { + if (props.mode === 'html') { + return + } + + return {props.content} +} diff --git a/web/default/src/components/ui/markdown.test.ts b/web/default/src/components/ui/markdown.test.ts index a32080c..234bb42 100644 --- a/web/default/src/components/ui/markdown.test.ts +++ b/web/default/src/components/ui/markdown.test.ts @@ -17,19 +17,33 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ import assert from 'node:assert/strict' -import { describe, test } from 'node:test' +import { after, describe, test } from 'node:test' import { JSDOM } from 'jsdom' -import { renderMarkdownForTest } from './markdown' +import { renderMarkdown } from './markdown' + +const dom = new JSDOM('', { url: 'http://localhost/' }) +const previousWindowDescriptor = Object.getOwnPropertyDescriptor( + globalThis, + 'window' +) -const dom = new JSDOM('') Object.defineProperty(globalThis, 'window', { configurable: true, value: dom.window, }) -describe('renderMarkdownForTest', () => { +after(() => { + if (previousWindowDescriptor) { + Object.defineProperty(globalThis, 'window', previousWindowDescriptor) + return + } + + delete (globalThis as Partial).window +}) + +describe('renderMarkdown', () => { test('renders markdown links without losing the marked parser context', () => { - const html = renderMarkdownForTest('[MAX API](https://example.com)') + const html = renderMarkdown('[MAX API](https://example.com)') assert.match(html, / { test('falls back to link text for invalid href values', () => { const badHref = `http://example.com/${String.fromCharCode(0xd800)}` - const html = renderMarkdownForTest(`[Broken](${badHref})`) + const html = renderMarkdown(`[Broken](${badHref})`) assert.equal(html, '

Broken

\n') }) test('sanitizes hostile html when rendered outside the browser', () => { - const html = renderMarkdownForTest('') + const html = renderMarkdown('') assert.doesNotMatch(html, /onerror/i) assert.doesNotMatch(html, /alert\(1\)/i) @@ -66,7 +80,7 @@ describe('renderMarkdownForTest', () => { ] cases.forEach(({ label, markdown, unsafe }) => { - const html = renderMarkdownForTest(markdown) + const html = renderMarkdown(markdown) assert.doesNotMatch(html, unsafe) assert.doesNotMatch(html, /href=/i) @@ -82,7 +96,7 @@ describe('renderMarkdownForTest', () => { }) try { - assert.equal(renderMarkdownForTest(''), '') + assert.equal(renderMarkdown(''), '') } finally { if (windowDescriptor) { Object.defineProperty(globalThis, 'window', windowDescriptor) diff --git a/web/default/src/components/ui/markdown.tsx b/web/default/src/components/ui/markdown.tsx index 809a559..aa63caa 100644 --- a/web/default/src/components/ui/markdown.tsx +++ b/web/default/src/components/ui/markdown.tsx @@ -16,11 +16,11 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ -import DOMPurify from 'dompurify' import * as katex from 'katex' import 'katex/dist/katex.min.css' import { Marked, Renderer, type MarkedExtension, type Tokens } from 'marked' import { useMemo } from 'react' +import { sanitizeHtmlWithOptions } from '@/lib/sanitize-core' import { cn } from '@/lib/utils' interface MarkdownProps { @@ -129,13 +129,6 @@ const sanitizeOptions = { ADD_TAGS: allowedTags, } as const -type DomPurifySanitizer = { - isSupported?: boolean - sanitize: (dirty: string, config?: typeof sanitizeOptions) => string -} - -type DomPurifyFactory = (window: Window) => DomPurifySanitizer - type FlowNode = { id: string label: string @@ -194,36 +187,8 @@ function normalizeUrl(value: string): string | null { } } -function isUsableSanitizer(value: unknown): value is DomPurifySanitizer { - if ((typeof value !== 'object' && typeof value !== 'function') || value === null) { - return false - } - - const sanitizer = value as Partial - return ( - sanitizer.isSupported !== false && - typeof sanitizer.sanitize === 'function' - ) -} - function sanitizeHtml(html: string): string { - const purify = DOMPurify as unknown as - | DomPurifySanitizer - | DomPurifyFactory - - if (isUsableSanitizer(purify)) { - return purify.sanitize(html, sanitizeOptions) - } - - if (typeof window !== 'undefined' && typeof purify === 'function') { - const browserSanitizer = purify(window) - - if (isUsableSanitizer(browserSanitizer)) { - return browserSanitizer.sanitize(html, sanitizeOptions) - } - } - - return '' + return sanitizeHtmlWithOptions(html, sanitizeOptions) } function normalizeMathSource(source: string): string { @@ -755,17 +720,14 @@ function createMarkdownParser() { return parser } -export function renderMarkdownForTest(markdown: string): string { +export function renderMarkdown(markdown: string): string { const markdownParser = createMarkdownParser() const parsedHtml = markdownParser.parse(markdown, markdownOptions) return sanitizeHtml(parsedHtml) } export function Markdown(props: MarkdownProps) { - const html = useMemo( - () => renderMarkdownForTest(props.children), - [props.children] - ) + const html = useMemo(() => renderMarkdown(props.children), [props.children]) return (
/i.test(value) -} - function EmptyAboutState() { const { t } = useTranslation() const currentYear = new Date().getFullYear() @@ -131,8 +122,10 @@ export function About() { const rawContent = data?.data?.trim() ?? '' const hasContent = rawContent.length > 0 - const isUrl = hasContent && isValidUrl(rawContent) - const isHtml = hasContent && !isUrl && isLikelyHtml(rawContent) + const contentKind = hasContent + ? getRenderableContentKind(rawContent) + : 'markdown' + const iframeEmbedSrc = getSafeIframeEmbedSrc(rawContent) if (isLoading) { return ( @@ -155,13 +148,14 @@ export function About() { ) } - if (isUrl) { + if (iframeEmbedSrc) { return ( ' + ), + 'https://example.com/home.html' + ) + }) + + test('extracts an iframe src when the snippet only has wrapper elements', () => { + assert.equal( + getSafeIframeEmbedSrc( + '
' + ), + 'https://example.com/home.html' + ) + }) + + test('rejects iframe snippets with additional HTML content', () => { + assert.equal( + getSafeIframeEmbedSrc( + '

Welcome

' + ), + null + ) + }) + + test('rejects non-http iframe src values', () => { + assert.equal( + getSafeIframeEmbedSrc(''), + null + ) + }) +}) diff --git a/web/default/src/lib/renderable-content.ts b/web/default/src/lib/renderable-content.ts index 33c9392..6593940 100644 --- a/web/default/src/lib/renderable-content.ts +++ b/web/default/src/lib/renderable-content.ts @@ -32,6 +32,38 @@ export function getRenderableContentKind(value: string): RenderableContentKind { return 'markdown' } +export function getSafeIframeEmbedSrc(content: string): string | null { + const trimmed = content.trim() + + if (isHttpUrl(trimmed)) return trimmed + if (!/]/i.test(content)) return null + if (typeof DOMParser === 'undefined') return null + + const document = new DOMParser().parseFromString(trimmed, 'text/html') + const iframes = document.querySelectorAll('iframe') + + if (iframes.length !== 1) return null + + const iframe = iframes[0] + iframe.remove() + + if (document.body.textContent?.trim()) return null + if (!hasOnlyEmptyWrapperElements(document.body)) return null + + const src = iframe.getAttribute('src')?.trim() + if (!src) return null + + return isHttpUrl(src) ? src : null +} + +function hasOnlyEmptyWrapperElements(body: HTMLElement): boolean { + const allowedWrapperTags = new Set(['ARTICLE', 'DIV', 'MAIN', 'SECTION']) + + return [...body.querySelectorAll('*')].every((element) => + allowedWrapperTags.has(element.tagName) + ) +} + function isHttpUrl(value: string): boolean { try { const url = new URL(value) diff --git a/web/default/src/lib/sanitize-core.ts b/web/default/src/lib/sanitize-core.ts new file mode 100644 index 0000000..c093afe --- /dev/null +++ b/web/default/src/lib/sanitize-core.ts @@ -0,0 +1,68 @@ +/* +Copyright (C) 2023-2026 MAX-API-Next + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues +*/ +import DOMPurify from 'dompurify' + +type DomPurifyConfig = Parameters[1] + +type HtmlSanitizer = { + isSupported?: boolean + sanitize: (dirty: string, config?: DomPurifyConfig) => string +} + +type HtmlSanitizerFactory = (window: Window) => HtmlSanitizer + +function isUsableHtmlSanitizer(value: unknown): value is HtmlSanitizer { + if ( + (typeof value !== 'object' && typeof value !== 'function') || + value === null + ) { + return false + } + + const sanitizer = value as Partial + return ( + sanitizer.isSupported !== false && + typeof sanitizer.sanitize === 'function' + ) +} + +export function sanitizeHtmlWithOptions( + content: string, + options: DomPurifyConfig +): string { + const purify = DOMPurify as unknown as HtmlSanitizer | HtmlSanitizerFactory + + try { + if (isUsableHtmlSanitizer(purify)) { + return purify.sanitize(content, options) + } + + if (typeof window !== 'undefined' && typeof purify === 'function') { + const browserSanitizer = purify(window) + + if (isUsableHtmlSanitizer(browserSanitizer)) { + return browserSanitizer.sanitize(content, options) + } + } + } catch { + return '' + } + + return '' +} diff --git a/web/default/src/stores/notification-store.test.ts b/web/default/src/stores/notification-store.test.ts new file mode 100644 index 0000000..f46f403 --- /dev/null +++ b/web/default/src/stores/notification-store.test.ts @@ -0,0 +1,151 @@ +/* +Copyright (C) 2023-2026 MAX-API-Next + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues +*/ +import assert from 'node:assert/strict' +import { after, beforeEach, describe, test } from 'node:test' + +const storage = new Map() +const testLocalStorage = { + getItem: (key: string) => storage.get(key) ?? null, + removeItem: (key: string) => { + storage.delete(key) + }, + setItem: (key: string, value: string) => { + storage.set(key, value) + }, +} +const previousLocalStorageDescriptor = Object.getOwnPropertyDescriptor( + globalThis, + 'localStorage' +) +const previousWindowDescriptor = Object.getOwnPropertyDescriptor( + globalThis, + 'window' +) + +Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + value: testLocalStorage, +}) +Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + localStorage: testLocalStorage, + }, +}) + +const { useNotificationStore } = (await import( + './notification-store' +)) as typeof import('./notification-store') +/* eslint-disable no-console */ +const originalWarn = console.warn + +console.warn = (...args: unknown[]) => { + if ( + typeof args[0] === 'string' && + args[0].includes("Unable to update item 'notification-storage'") + ) { + return + } + + originalWarn(...args) +} + +after(() => { + console.warn = originalWarn + + if (previousLocalStorageDescriptor) { + Object.defineProperty( + globalThis, + 'localStorage', + previousLocalStorageDescriptor + ) + } else { + delete (globalThis as Partial).localStorage + } + + if (previousWindowDescriptor) { + Object.defineProperty(globalThis, 'window', previousWindowDescriptor) + } else { + delete (globalThis as Partial).window + } +}) +/* eslint-enable no-console */ + +describe('notification store', () => { + beforeEach(() => { + storage.clear() + useNotificationStore.setState({ + byUser: {}, + autoOpenedSignatures: [], + }) + }) + + test('keeps read announcement state scoped by user', () => { + const store = useNotificationStore.getState() + + store.markAnnouncementsRead('user:1', ['id:10']) + + assert.equal(store.isAnnouncementRead('user:1', 'id:10'), true) + assert.equal(store.isAnnouncementRead('user:2', 'id:10'), false) + }) + + test('keeps close-for-today state scoped by user', () => { + const today = new Date().toDateString() + const store = useNotificationStore.getState() + + store.setClosedUntilDate('user:1', today) + + assert.equal(store.isNoticeClosed('user:1'), true) + assert.equal(store.isNoticeClosed('user:2'), false) + }) + + test('migrates legacy persisted notification state to anonymous user scope', async () => { + storage.set( + 'notification-storage', + JSON.stringify({ + state: { + lastReadNotice: 'legacy notice', + readAnnouncementKeys: ['id:10'], + closedUntilDate: 'Mon Jun 29 2026', + }, + version: 0, + }) + ) + + await useNotificationStore.persist.rehydrate() + + const anonymousState = + useNotificationStore.getState().getUserState('anonymous') + assert.deepEqual(anonymousState, { + lastReadNotice: 'legacy notice', + readAnnouncementKeys: ['id:10'], + closedUntilDate: 'Mon Jun 29 2026', + }) + }) + + test('remembers every scoped auto-open signature seen in this page session', () => { + const store = useNotificationStore.getState() + const userOneSignature = 'user:1:new-notice' + const userTwoSignature = 'user:2:new-notice' + + assert.equal(store.rememberAutoOpenedSignature(userOneSignature), true) + assert.equal(store.rememberAutoOpenedSignature(userTwoSignature), true) + assert.equal(store.rememberAutoOpenedSignature(userOneSignature), false) + }) +}) diff --git a/web/default/src/stores/notification-store.ts b/web/default/src/stores/notification-store.ts index e4331e2..c5fff48 100644 --- a/web/default/src/stores/notification-store.ts +++ b/web/default/src/stores/notification-store.ts @@ -19,20 +19,82 @@ For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API import { create } from 'zustand' import { persist } from 'zustand/middleware' -interface NotificationState { - // Last read Notice content signature (full trimmed message) +interface NotificationUserState { lastReadNotice: string - // Array of read announcement keys (id or content hash) readAnnouncementKeys: string[] - // Timestamp of last "Close Today" action closedUntilDate: string | null +} + +interface NotificationState { + byUser: Record + // Auto-opened content signatures for the current page session. + autoOpenedSignatures: string[] // Actions - markNoticeRead: (noticeContent: string) => void - markAnnouncementsRead: (keys: string[]) => void - setClosedUntilDate: (date: string | null) => void - isAnnouncementRead: (key: string) => boolean - isNoticeClosed: () => boolean + markNoticeRead: (userScope: string, noticeContent: string) => void + markAnnouncementsRead: (userScope: string, keys: string[]) => void + rememberAutoOpenedSignature: (contentSignature: string) => boolean + setClosedUntilDate: (userScope: string, date: string | null) => void + getUserState: (userScope: string) => NotificationUserState + isAnnouncementRead: (userScope: string, key: string) => boolean + isNoticeClosed: (userScope: string) => boolean +} + +const defaultNotificationUserState: NotificationUserState = { + lastReadNotice: '', + readAnnouncementKeys: [], + closedUntilDate: null, +} + +function getStoredUserState( + byUser: Record, + userScope: string +): NotificationUserState { + return byUser[userScope] ?? defaultNotificationUserState +} + +function setUserState( + state: NotificationState, + userScope: string, + patch: Partial +): Pick { + const current = getStoredUserState(state.byUser, userScope) + + return { + byUser: { + ...state.byUser, + [userScope]: { + ...current, + ...patch, + }, + }, + } +} + +function migratePersistedNotificationState( + persisted: unknown, + version: number +): Partial { + if ( + version >= 1 || + !persisted || + typeof persisted !== 'object' || + 'byUser' in persisted + ) { + return persisted as Partial + } + + const legacy = persisted as Partial + + return { + byUser: { + anonymous: { + lastReadNotice: legacy.lastReadNotice ?? '', + readAnnouncementKeys: legacy.readAnnouncementKeys ?? [], + closedUntilDate: legacy.closedUntilDate ?? null, + }, + }, + } } /** @@ -42,34 +104,65 @@ interface NotificationState { export const useNotificationStore = create()( persist( (set, get) => ({ - lastReadNotice: '', - readAnnouncementKeys: [], - closedUntilDate: null, + byUser: {}, + autoOpenedSignatures: [], - markNoticeRead: (noticeContent: string) => { + markNoticeRead: (userScope: string, noticeContent: string) => { // Persist the full trimmed content so edits beyond 100 chars register const normalizedContent = noticeContent.trim() - set({ lastReadNotice: normalizedContent }) + set((state) => + setUserState(state, userScope, { + lastReadNotice: normalizedContent, + }) + ) + }, + + markAnnouncementsRead: (userScope: string, keys: string[]) => { + set((state) => { + const current = getStoredUserState(state.byUser, userScope) + + return setUserState(state, userScope, { + readAnnouncementKeys: [ + ...new Set([...current.readAnnouncementKeys, ...keys]), + ], + }) + }) }, - markAnnouncementsRead: (keys: string[]) => { + rememberAutoOpenedSignature: (contentSignature: string) => { + if (!contentSignature) { + return false + } + + if (get().autoOpenedSignatures.includes(contentSignature)) { + return false + } + set((state) => ({ - readAnnouncementKeys: [ - ...new Set([...state.readAnnouncementKeys, ...keys]), + autoOpenedSignatures: [ + ...state.autoOpenedSignatures, + contentSignature, ], })) + return true + }, + + setClosedUntilDate: (userScope: string, date: string | null) => { + set((state) => setUserState(state, userScope, { closedUntilDate: date })) }, - setClosedUntilDate: (date: string | null) => { - set({ closedUntilDate: date }) + getUserState: (userScope: string) => { + return getStoredUserState(get().byUser, userScope) }, - isAnnouncementRead: (key: string) => { - return get().readAnnouncementKeys.includes(key) + isAnnouncementRead: (userScope: string, key: string) => { + return get() + .getUserState(userScope) + .readAnnouncementKeys.includes(key) }, - isNoticeClosed: () => { - const { closedUntilDate } = get() + isNoticeClosed: (userScope: string) => { + const { closedUntilDate } = get().getUserState(userScope) if (!closedUntilDate) return false const today = new Date().toDateString() @@ -78,10 +171,10 @@ export const useNotificationStore = create()( }), { name: 'notification-storage', + version: 1, + migrate: migratePersistedNotificationState, partialize: (state) => ({ - lastReadNotice: state.lastReadNotice, - readAnnouncementKeys: state.readAnnouncementKeys, - closedUntilDate: state.closedUntilDate, + byUser: state.byUser, }), } )