fix: complete audit security and frontend gaps
This commit is contained in:
@@ -26,6 +26,11 @@ The product is a private-deployment, project-centered workbench for individual k
|
|||||||
- Keep task sharing conservative: assignees see the task and only explicitly shared linked objects.
|
- Keep task sharing conservative: assignees see the task and only explicitly shared linked objects.
|
||||||
- AI must not create official objects without user confirmation.
|
- AI must not create official objects without user confirmation.
|
||||||
- AI calls must record provider, key type, action, status, and errors.
|
- AI calls must record provider, key type, action, status, and errors.
|
||||||
|
- Auth session tokens must be signed, expiring bearer tokens; do not accept forgeable `user:<id>` strings.
|
||||||
|
- HTTP handlers must read the current user from auth middleware and must not hard-code user IDs.
|
||||||
|
- Generated tasks, notes, and sources from inbox confirmation should retain their source inbox item ID.
|
||||||
|
- Task sharing must verify that the shared note/source belongs to the same project as the task.
|
||||||
|
- User AI keys must be encrypted at rest and AI calls should pass through rate-limit checks before provider execution.
|
||||||
|
|
||||||
## Frontend Rules
|
## Frontend Rules
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,14 @@ go run ./cmd/api
|
|||||||
|
|
||||||
The web client uses Svelte and TypeScript only. The login shell lets users enter a server IP address or domain name, which is saved as the API base URL.
|
The web client uses Svelte and TypeScript only. The login shell lets users enter a server IP address or domain name, which is saved as the API base URL.
|
||||||
|
|
||||||
|
## Backend Configuration
|
||||||
|
|
||||||
|
- `PORT`: API listen port, default `8080`.
|
||||||
|
- `DATABASE_URL`: PostgreSQL connection string. Do not commit real credentials.
|
||||||
|
- `AUTH_SECRET`: HMAC secret for invite and session tokens.
|
||||||
|
- `SYSTEM_AI_KEY`: optional fallback AI provider key.
|
||||||
|
- `AI_KEY_ENCRYPTION_SECRET`: secret used to encrypt user AI keys at rest.
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
See `docs/mvp-verification.md` for backend, web, desktop, PostgreSQL, and manual MVP verification steps.
|
See `docs/mvp-verification.md` for backend, web, desktop, PostgreSQL, and manual MVP verification steps.
|
||||||
|
|||||||
@@ -7,3 +7,11 @@ test('project workbench shell renders', async ({ page }) => {
|
|||||||
await expect(page.getByLabel('服务器 IP 或域名')).toBeVisible();
|
await expect(page.getByLabel('服务器 IP 或域名')).toBeVisible();
|
||||||
await expect(page.getByLabel('项目总览')).toBeVisible();
|
await expect(page.getByLabel('项目总览')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('inbox suggestions require explicit confirmation', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.getByLabel('选择 跟进报价').check();
|
||||||
|
await page.getByRole('button', { name: '创建选中项' }).click();
|
||||||
|
|
||||||
|
await expect(page.getByText('已选择 1 项')).toBeVisible();
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,12 +1,60 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
import ProjectDashboard from '../features/projects/ProjectDashboard.svelte';
|
import ProjectDashboard from '../features/projects/ProjectDashboard.svelte';
|
||||||
import ServerLogin from '../features/auth/ServerLogin.svelte';
|
import ServerLogin from '../features/auth/ServerLogin.svelte';
|
||||||
|
import SuggestionList from '../features/inbox/SuggestionList.svelte';
|
||||||
|
import type { Suggestion } from '../features/inbox/types';
|
||||||
|
import { createApi, type ProjectDashboardSummary } from '../lib/api';
|
||||||
|
|
||||||
|
const emptyDashboard: ProjectDashboardSummary = {
|
||||||
|
project_id: 1,
|
||||||
|
pending_inbox_count: 0,
|
||||||
|
open_task_count: 0,
|
||||||
|
recent_note_count: 0,
|
||||||
|
recent_session_count: 0,
|
||||||
|
};
|
||||||
|
|
||||||
let apiBase = localStorage.getItem('apiBase') ?? 'http://localhost:8080';
|
let apiBase = localStorage.getItem('apiBase') ?? 'http://localhost:8080';
|
||||||
|
let dashboard = emptyDashboard;
|
||||||
|
let connectionStatus = '未连接';
|
||||||
|
let selectedSuggestionCount = 0;
|
||||||
|
|
||||||
|
const sampleSuggestions: Suggestion[] = [
|
||||||
|
{
|
||||||
|
kind: 'task',
|
||||||
|
title: '跟进报价',
|
||||||
|
body: '从项目 inbox 确认后创建任务,并保留来源记录。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'note',
|
||||||
|
title: '客户背景',
|
||||||
|
body: '把对话中的背景信息沉淀成项目笔记。',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
void loadDashboard();
|
||||||
|
});
|
||||||
|
|
||||||
function handleServerChange(event: CustomEvent<{ apiBase: string }>) {
|
function handleServerChange(event: CustomEvent<{ apiBase: string }>) {
|
||||||
apiBase = event.detail.apiBase;
|
apiBase = event.detail.apiBase;
|
||||||
localStorage.setItem('apiBase', apiBase);
|
localStorage.setItem('apiBase', apiBase);
|
||||||
|
void loadDashboard();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDashboard() {
|
||||||
|
connectionStatus = '连接中';
|
||||||
|
try {
|
||||||
|
dashboard = await createApi(apiBase).getProjectDashboard(1);
|
||||||
|
connectionStatus = '已连接';
|
||||||
|
} catch {
|
||||||
|
dashboard = emptyDashboard;
|
||||||
|
connectionStatus = '无法连接服务器';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleConfirm(selected: Suggestion[]) {
|
||||||
|
selectedSuggestionCount = selected.length;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -14,17 +62,15 @@
|
|||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<h1>项目工作台</h1>
|
<h1>项目工作台</h1>
|
||||||
<ServerLogin {apiBase} on:serverChange={handleServerChange} />
|
<ServerLogin {apiBase} on:serverChange={handleServerChange} />
|
||||||
|
<p class="connection-status" aria-live="polite">{connectionStatus}</p>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<section class="workspace">
|
<section class="workspace">
|
||||||
<ProjectDashboard
|
<ProjectDashboard summary={dashboard} />
|
||||||
summary={{
|
<section class="inbox-review" aria-label="项目 inbox">
|
||||||
project_id: 1,
|
<h2>AI 整理建议</h2>
|
||||||
pending_inbox_count: 0,
|
<SuggestionList suggestions={sampleSuggestions} onConfirm={handleConfirm} />
|
||||||
open_task_count: 0,
|
<p class="selection-status" aria-live="polite">已选择 {selectedSuggestionCount} 项</p>
|
||||||
recent_note_count: 0,
|
</section>
|
||||||
recent_session_count: 0,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
export type Suggestion = {
|
import type { Suggestion } from './types';
|
||||||
kind: 'task' | 'note' | 'source';
|
|
||||||
title: string;
|
|
||||||
body: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export let suggestions: Suggestion[] = [];
|
export let suggestions: Suggestion[] = [];
|
||||||
export let onConfirm: (selected: Suggestion[]) => void;
|
export let onConfirm: (selected: Suggestion[]) => void;
|
||||||
|
|||||||
5
apps/web/src/features/inbox/types.ts
Normal file
5
apps/web/src/features/inbox/types.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export type Suggestion = {
|
||||||
|
kind: 'task' | 'note' | 'source';
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
};
|
||||||
@@ -41,9 +41,18 @@ select {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.workspace {
|
.workspace {
|
||||||
|
display: grid;
|
||||||
|
gap: 24px;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.connection-status,
|
||||||
|
.selection-status {
|
||||||
|
margin: 12px 0 0;
|
||||||
|
color: #5f6673;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
.server-login {
|
.server-login {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -95,6 +104,51 @@ select {
|
|||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.inbox-review {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inbox-review h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestions {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 20px 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: start;
|
||||||
|
border: 1px solid #d9dde3;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: white;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-body {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-body small {
|
||||||
|
color: #5f6673;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestions button {
|
||||||
|
justify-self: start;
|
||||||
|
border: 1px solid #1f2937;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #1f2937;
|
||||||
|
color: white;
|
||||||
|
padding: 9px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
.shell {
|
.shell {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ export type ProjectDashboardSummary = {
|
|||||||
recent_session_count: number;
|
recent_session_count: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createApi(apiBase: string) {
|
export function createApi(apiBase: string, token?: string) {
|
||||||
async function request<T>(path: string): Promise<T> {
|
async function request<T>(path: string): Promise<T> {
|
||||||
const response = await fetch(`${apiBase}${path}`);
|
const response = await fetch(`${apiBase}/api${path}`, {
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
|
});
|
||||||
if (!response.ok) throw new Error(`API request failed: ${response.status}`);
|
if (!response.ok) throw new Error(`API request failed: ${response.status}`);
|
||||||
return response.json() as Promise<T>;
|
return response.json() as Promise<T>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,5 @@
|
|||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"strict": true
|
"strict": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.d.ts", "src/**/*.ts", "src/**/*.js", "src/**/*.svelte"],
|
"include": ["src/**/*.d.ts", "src/**/*.ts", "src/**/*.js", "src/**/*.svelte"]
|
||||||
"references": [{ "path": "./tsconfig.node.json" }]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
|
|
||||||
|
"senlinai-agent/backend/internal/auth"
|
||||||
"senlinai-agent/backend/internal/config"
|
"senlinai-agent/backend/internal/config"
|
||||||
"senlinai-agent/backend/internal/db"
|
"senlinai-agent/backend/internal/db"
|
||||||
"senlinai-agent/backend/internal/domain"
|
"senlinai-agent/backend/internal/domain"
|
||||||
@@ -21,10 +22,11 @@ func main() {
|
|||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
authService := auth.NewService(database, cfg.AuthSecret)
|
||||||
projectHandler := projects.NewHandler(projects.NewService(database))
|
projectHandler := projects.NewHandler(projects.NewService(database))
|
||||||
inboxHandler := inbox.NewHandler(inbox.NewService(database, inbox.StaticAnalyzer{}))
|
inboxHandler := inbox.NewHandler(inbox.NewService(database, inbox.StaticAnalyzer{}))
|
||||||
router := httpx.NewRouter(cfg, projectHandler, inboxHandler)
|
router := httpx.NewProtectedRouter(cfg, authService.VerifySession, projectHandler, inboxHandler)
|
||||||
if err := router.Run(":8080"); err != nil {
|
if err := router.Run(":" + cfg.Port); err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,24 @@
|
|||||||
package ai
|
package ai
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
|
"io"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
"senlinai-agent/backend/internal/domain"
|
"senlinai-agent/backend/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Gateway struct {
|
type Gateway struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
systemKey string
|
systemKey string
|
||||||
|
encryptionSecret string
|
||||||
}
|
}
|
||||||
|
|
||||||
type SelectedKey struct {
|
type SelectedKey struct {
|
||||||
@@ -19,13 +28,33 @@ type SelectedKey struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewGateway(database *gorm.DB, systemKey string) *Gateway {
|
func NewGateway(database *gorm.DB, systemKey string) *Gateway {
|
||||||
return &Gateway{db: database, systemKey: systemKey}
|
return NewGatewayWithSecret(database, systemKey, "development-ai-key-secret-change-me")
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewGatewayWithSecret(database *gorm.DB, systemKey string, encryptionSecret string) *Gateway {
|
||||||
|
return &Gateway{db: database, systemKey: systemKey, encryptionSecret: encryptionSecret}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *Gateway) SaveUserKey(userID uint, provider string, apiKey string) error {
|
||||||
|
encrypted, err := encryptAPIKey(apiKey, g.encryptionSecret)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
key := domain.AIKey{UserID: userID, Provider: provider, EncryptedAPIKey: encrypted}
|
||||||
|
return g.db.Clauses(clause.OnConflict{
|
||||||
|
Columns: []clause.Column{{Name: "user_id"}},
|
||||||
|
DoUpdates: clause.AssignmentColumns([]string{"provider", "encrypted_api_key", "updated_at"}),
|
||||||
|
}).Create(&key).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *Gateway) SelectKey(userID uint) (SelectedKey, error) {
|
func (g *Gateway) SelectKey(userID uint) (SelectedKey, error) {
|
||||||
var userKey domain.AIKey
|
var userKey domain.AIKey
|
||||||
if err := g.db.Where("user_id = ?", userID).First(&userKey).Error; err == nil {
|
if err := g.db.Where("user_id = ?", userID).First(&userKey).Error; err == nil {
|
||||||
return SelectedKey{Provider: userKey.Provider, APIKey: userKey.EncryptedAPIKey, KeyType: "user"}, nil
|
apiKey, err := decryptAPIKey(userKey.EncryptedAPIKey, g.encryptionSecret)
|
||||||
|
if err != nil {
|
||||||
|
return SelectedKey{}, err
|
||||||
|
}
|
||||||
|
return SelectedKey{Provider: userKey.Provider, APIKey: apiKey, KeyType: "user"}, nil
|
||||||
}
|
}
|
||||||
if g.systemKey == "" {
|
if g.systemKey == "" {
|
||||||
return SelectedKey{}, errors.New("no ai key available")
|
return SelectedKey{}, errors.New("no ai key available")
|
||||||
@@ -43,3 +72,72 @@ func (g *Gateway) RecordCall(userID uint, provider string, usedKeyType string, a
|
|||||||
Error: errText,
|
Error: errText,
|
||||||
}).Error
|
}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (g *Gateway) CheckRateLimit(userID uint, action string, limit int, window time.Duration) error {
|
||||||
|
if limit <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var count int64
|
||||||
|
if err := g.db.Model(&domain.AICallLog{}).
|
||||||
|
Where("user_id = ? AND action = ? AND created_at >= ?", userID, action, time.Now().Add(-window)).
|
||||||
|
Count(&count).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count >= int64(limit) {
|
||||||
|
return errors.New("ai rate limit exceeded")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func encryptAPIKey(apiKey string, secret string) (string, error) {
|
||||||
|
if apiKey == "" {
|
||||||
|
return "", errors.New("api key is required")
|
||||||
|
}
|
||||||
|
block, err := aes.NewCipher(encryptionKey(secret))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
nonce := make([]byte, gcm.NonceSize())
|
||||||
|
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
ciphertext := gcm.Seal(nil, nonce, []byte(apiKey), nil)
|
||||||
|
return "v1:" + base64.RawURLEncoding.EncodeToString(append(nonce, ciphertext...)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decryptAPIKey(encrypted string, secret string) (string, error) {
|
||||||
|
if len(encrypted) < 3 || encrypted[:3] != "v1:" {
|
||||||
|
return encrypted, nil
|
||||||
|
}
|
||||||
|
raw, err := base64.RawURLEncoding.DecodeString(encrypted[3:])
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.New("invalid encrypted api key")
|
||||||
|
}
|
||||||
|
block, err := aes.NewCipher(encryptionKey(secret))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if len(raw) < gcm.NonceSize() {
|
||||||
|
return "", errors.New("invalid encrypted api key")
|
||||||
|
}
|
||||||
|
nonce := raw[:gcm.NonceSize()]
|
||||||
|
ciphertext := raw[gcm.NonceSize():]
|
||||||
|
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.New("invalid encrypted api key")
|
||||||
|
}
|
||||||
|
return string(plaintext), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func encryptionKey(secret string) []byte {
|
||||||
|
sum := sha256.Sum256([]byte(secret))
|
||||||
|
return sum[:]
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package ai
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/glebarez/sqlite"
|
"github.com/glebarez/sqlite"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -22,6 +23,21 @@ func TestSelectKeyPrefersUserKey(t *testing.T) {
|
|||||||
require.Equal(t, "user-key", selected.APIKey)
|
require.Equal(t, "user-key", selected.APIKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSaveUserKeyEncryptsStoredKey(t *testing.T) {
|
||||||
|
database := newTestDB(t)
|
||||||
|
gateway := NewGatewayWithSecret(database, "system-key", "test-encryption-secret")
|
||||||
|
|
||||||
|
require.NoError(t, gateway.SaveUserKey(3, "openai", "user-key"))
|
||||||
|
|
||||||
|
var stored domain.AIKey
|
||||||
|
require.NoError(t, database.Where("user_id = ?", 3).First(&stored).Error)
|
||||||
|
require.NotEqual(t, "user-key", stored.EncryptedAPIKey)
|
||||||
|
require.Contains(t, stored.EncryptedAPIKey, "v1:")
|
||||||
|
selected, err := gateway.SelectKey(3)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "user-key", selected.APIKey)
|
||||||
|
}
|
||||||
|
|
||||||
func TestSelectKeyFallsBackToSystemKey(t *testing.T) {
|
func TestSelectKeyFallsBackToSystemKey(t *testing.T) {
|
||||||
database := newTestDB(t)
|
database := newTestDB(t)
|
||||||
gateway := NewGateway(database, "system-key")
|
gateway := NewGateway(database, "system-key")
|
||||||
@@ -58,6 +74,16 @@ func TestRecordCallStoresAuditFields(t *testing.T) {
|
|||||||
require.Equal(t, "rate limited", log.Error)
|
require.Equal(t, "rate limited", log.Error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCheckRateLimitRejectsCallsOverWindow(t *testing.T) {
|
||||||
|
database := newTestDB(t)
|
||||||
|
gateway := NewGateway(database, "system-key")
|
||||||
|
require.NoError(t, gateway.RecordCall(3, "openai", "system", "inbox_analyze", "succeeded", ""))
|
||||||
|
|
||||||
|
err := gateway.CheckRateLimit(3, "inbox_analyze", 1, time.Hour)
|
||||||
|
|
||||||
|
require.ErrorContains(t, err, "ai rate limit exceeded")
|
||||||
|
}
|
||||||
|
|
||||||
func TestCreateAISession(t *testing.T) {
|
func TestCreateAISession(t *testing.T) {
|
||||||
database := newTestDB(t)
|
database := newTestDB(t)
|
||||||
service := NewSessionService(database)
|
service := NewSessionService(database)
|
||||||
|
|||||||
@@ -26,3 +26,12 @@ func RequireUser(tokenVerifier func(string) (uint, error)) gin.HandlerFunc {
|
|||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func CurrentUserID(c *gin.Context) (uint, bool) {
|
||||||
|
value, ok := c.Get(CurrentUserIDKey)
|
||||||
|
if !ok {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
userID, ok := value.(uint)
|
||||||
|
return userID, ok && userID > 0
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,10 +3,13 @@ package auth
|
|||||||
import (
|
import (
|
||||||
"crypto/hmac"
|
"crypto/hmac"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -14,12 +17,14 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
secret string
|
secret string
|
||||||
|
inviteTokenTTL time.Duration
|
||||||
|
sessionTTL time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(database *gorm.DB, secret string) *Service {
|
func NewService(database *gorm.DB, secret string) *Service {
|
||||||
return &Service{db: database, secret: secret}
|
return &Service{db: database, secret: secret, inviteTokenTTL: 7 * 24 * time.Hour, sessionTTL: 24 * time.Hour}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) CreateInvite(adminID uint, email string) (string, error) {
|
func (s *Service) CreateInvite(adminID uint, email string) (string, error) {
|
||||||
@@ -27,20 +32,20 @@ func (s *Service) CreateInvite(adminID uint, email string) (string, error) {
|
|||||||
if normalized == "" {
|
if normalized == "" {
|
||||||
return "", errors.New("email is required")
|
return "", errors.New("email is required")
|
||||||
}
|
}
|
||||||
return normalized + "." + s.sign(normalized), nil
|
return s.signToken(tokenPayload{
|
||||||
|
Type: "invite",
|
||||||
|
Subject: normalized,
|
||||||
|
IssuerID: adminID,
|
||||||
|
ExpiresAt: time.Now().Add(s.inviteTokenTTL).Unix(),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) RegisterWithInvite(token, email, displayName, password string) (*domain.User, error) {
|
func (s *Service) RegisterWithInvite(token, email, displayName, password string) (*domain.User, error) {
|
||||||
separator := strings.LastIndex(token, ".")
|
payload, err := s.verifyToken(token, "invite")
|
||||||
if separator < 1 || separator == len(token)-1 {
|
if err != nil {
|
||||||
return nil, errors.New("invalid invite token")
|
return nil, err
|
||||||
}
|
}
|
||||||
inviteEmail := token[:separator]
|
if payload.Subject != strings.ToLower(strings.TrimSpace(email)) {
|
||||||
signature := token[separator+1:]
|
|
||||||
if !hmac.Equal([]byte(signature), []byte(s.sign(inviteEmail))) {
|
|
||||||
return nil, errors.New("invalid invite signature")
|
|
||||||
}
|
|
||||||
if inviteEmail != strings.ToLower(strings.TrimSpace(email)) {
|
|
||||||
return nil, errors.New("invite email mismatch")
|
return nil, errors.New("invite email mismatch")
|
||||||
}
|
}
|
||||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
@@ -48,7 +53,7 @@ func (s *Service) RegisterWithInvite(token, email, displayName, password string)
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
user := &domain.User{
|
user := &domain.User{
|
||||||
Email: inviteEmail,
|
Email: payload.Subject,
|
||||||
DisplayName: strings.TrimSpace(displayName),
|
DisplayName: strings.TrimSpace(displayName),
|
||||||
PasswordHash: string(hash),
|
PasswordHash: string(hash),
|
||||||
Role: "user",
|
Role: "user",
|
||||||
@@ -64,7 +69,66 @@ func (s *Service) Login(email, password string) (string, error) {
|
|||||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
|
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
|
||||||
return "", errors.New("invalid credentials")
|
return "", errors.New("invalid credentials")
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("user:%d", user.ID), nil
|
return s.signToken(tokenPayload{
|
||||||
|
Type: "session",
|
||||||
|
Subject: fmt.Sprintf("%d", user.ID),
|
||||||
|
ExpiresAt: time.Now().Add(s.sessionTTL).Unix(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) VerifySession(token string) (uint, error) {
|
||||||
|
payload, err := s.verifyToken(token, "session")
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
var userID uint
|
||||||
|
if _, err := fmt.Sscanf(payload.Subject, "%d", &userID); err != nil || userID == 0 {
|
||||||
|
return 0, errors.New("invalid session subject")
|
||||||
|
}
|
||||||
|
return userID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type tokenPayload struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
IssuerID uint `json:"issuer_id,omitempty"`
|
||||||
|
ExpiresAt int64 `json:"expires_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) signToken(payload tokenPayload) (string, error) {
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
encoded := base64.RawURLEncoding.EncodeToString(body)
|
||||||
|
return encoded + "." + s.sign(encoded), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) verifyToken(token string, expectedType string) (tokenPayload, error) {
|
||||||
|
separator := strings.LastIndex(token, ".")
|
||||||
|
if separator < 1 || separator == len(token)-1 {
|
||||||
|
return tokenPayload{}, errors.New("invalid token")
|
||||||
|
}
|
||||||
|
encoded := token[:separator]
|
||||||
|
signature := token[separator+1:]
|
||||||
|
if !hmac.Equal([]byte(signature), []byte(s.sign(encoded))) {
|
||||||
|
return tokenPayload{}, errors.New("invalid token signature")
|
||||||
|
}
|
||||||
|
body, err := base64.RawURLEncoding.DecodeString(encoded)
|
||||||
|
if err != nil {
|
||||||
|
return tokenPayload{}, errors.New("invalid token payload")
|
||||||
|
}
|
||||||
|
var payload tokenPayload
|
||||||
|
if err := json.Unmarshal(body, &payload); err != nil {
|
||||||
|
return tokenPayload{}, errors.New("invalid token payload")
|
||||||
|
}
|
||||||
|
if payload.Type != expectedType {
|
||||||
|
return tokenPayload{}, errors.New("invalid token type")
|
||||||
|
}
|
||||||
|
if payload.ExpiresAt <= time.Now().Unix() {
|
||||||
|
return tokenPayload{}, errors.New("token expired")
|
||||||
|
}
|
||||||
|
return payload, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) sign(value string) string {
|
func (s *Service) sign(value string) string {
|
||||||
|
|||||||
@@ -48,6 +48,32 @@ func TestLoginRejectsInvalidPassword(t *testing.T) {
|
|||||||
require.ErrorContains(t, err, "invalid credentials")
|
require.ErrorContains(t, err, "invalid credentials")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoginReturnsSignedSessionToken(t *testing.T) {
|
||||||
|
database := newTestDB(t)
|
||||||
|
service := NewService(database, "test-secret")
|
||||||
|
token, err := service.CreateInvite(1, "lead@example.com")
|
||||||
|
require.NoError(t, err)
|
||||||
|
user, err := service.RegisterWithInvite(token, "lead@example.com", "Lead", "password123")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
sessionToken, err := service.Login("lead@example.com", "password123")
|
||||||
|
require.NoError(t, err)
|
||||||
|
userID, err := service.VerifySession(sessionToken)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, user.ID, userID)
|
||||||
|
require.NotEqual(t, fmt.Sprintf("user:%d", user.ID), sessionToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifySessionRejectsForgeableLegacyToken(t *testing.T) {
|
||||||
|
database := newTestDB(t)
|
||||||
|
service := NewService(database, "test-secret")
|
||||||
|
|
||||||
|
_, err := service.VerifySession("user:1")
|
||||||
|
|
||||||
|
require.ErrorContains(t, err, "invalid token")
|
||||||
|
}
|
||||||
|
|
||||||
func newTestDB(t *testing.T) *gorm.DB {
|
func newTestDB(t *testing.T) *gorm.DB {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
database, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
|
database, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
|
||||||
|
|||||||
@@ -3,18 +3,24 @@ package config
|
|||||||
import "os"
|
import "os"
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Env string
|
Env string
|
||||||
DatabaseURL string
|
Port string
|
||||||
StorageDir string
|
DatabaseURL string
|
||||||
SystemAIKey string
|
StorageDir string
|
||||||
|
AuthSecret string
|
||||||
|
SystemAIKey string
|
||||||
|
AIKeyEncryptionSecret string
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() Config {
|
func Load() Config {
|
||||||
return Config{
|
return Config{
|
||||||
Env: getenv("APP_ENV", "development"),
|
Env: getenv("APP_ENV", "development"),
|
||||||
DatabaseURL: getenv("DATABASE_URL", "postgres://agent:agent@localhost:5432/agent?sslmode=disable"),
|
Port: getenv("PORT", "8080"),
|
||||||
StorageDir: getenv("STORAGE_DIR", "./data/files"),
|
DatabaseURL: getenv("DATABASE_URL", "postgres://agent:agent@localhost:5432/agent?sslmode=disable"),
|
||||||
SystemAIKey: os.Getenv("SYSTEM_AI_KEY"),
|
StorageDir: getenv("STORAGE_DIR", "./data/files"),
|
||||||
|
AuthSecret: getenv("AUTH_SECRET", "development-auth-secret-change-me"),
|
||||||
|
SystemAIKey: os.Getenv("SYSTEM_AI_KEY"),
|
||||||
|
AIKeyEncryptionSecret: getenv("AI_KEY_ENCRYPTION_SECRET", "development-ai-key-secret-change-me"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,40 +38,43 @@ type InboxItem struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Task struct {
|
type Task struct {
|
||||||
ID uint `gorm:"primaryKey"`
|
ID uint `gorm:"primaryKey"`
|
||||||
ProjectID uint `gorm:"index;not null"`
|
ProjectID uint `gorm:"index;not null"`
|
||||||
CreatedBy uint `gorm:"index;not null"`
|
CreatedBy uint `gorm:"index;not null"`
|
||||||
AssigneeID *uint `gorm:"index"`
|
AssigneeID *uint `gorm:"index"`
|
||||||
Title string `gorm:"not null"`
|
SourceInboxItemID *uint `gorm:"index"`
|
||||||
Description string
|
Title string `gorm:"not null"`
|
||||||
Status string `gorm:"not null;default:open"`
|
Description string
|
||||||
SortOrder int `gorm:"not null;default:0"`
|
Status string `gorm:"not null;default:open"`
|
||||||
DueAt *time.Time
|
SortOrder int `gorm:"not null;default:0"`
|
||||||
CreatedAt time.Time
|
DueAt *time.Time
|
||||||
UpdatedAt time.Time
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type Note struct {
|
type Note struct {
|
||||||
ID uint `gorm:"primaryKey"`
|
ID uint `gorm:"primaryKey"`
|
||||||
ProjectID uint `gorm:"index;not null"`
|
ProjectID uint `gorm:"index;not null"`
|
||||||
CreatedBy uint `gorm:"index;not null"`
|
CreatedBy uint `gorm:"index;not null"`
|
||||||
Title string `gorm:"not null"`
|
SourceInboxItemID *uint `gorm:"index"`
|
||||||
Markdown string `gorm:"type:text"`
|
Title string `gorm:"not null"`
|
||||||
CreatedAt time.Time
|
Markdown string `gorm:"type:text"`
|
||||||
UpdatedAt time.Time
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type Source struct {
|
type Source struct {
|
||||||
ID uint `gorm:"primaryKey"`
|
ID uint `gorm:"primaryKey"`
|
||||||
ProjectID uint `gorm:"index;not null"`
|
ProjectID uint `gorm:"index;not null"`
|
||||||
CreatedBy uint `gorm:"index;not null"`
|
CreatedBy uint `gorm:"index;not null"`
|
||||||
Kind string `gorm:"not null"`
|
SourceInboxItemID *uint `gorm:"index"`
|
||||||
Title string `gorm:"not null"`
|
Kind string `gorm:"not null"`
|
||||||
URL string
|
Title string `gorm:"not null"`
|
||||||
FilePath string
|
URL string
|
||||||
ContentText string `gorm:"type:text"`
|
FilePath string
|
||||||
CreatedAt time.Time
|
ContentText string `gorm:"type:text"`
|
||||||
UpdatedAt time.Time
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type AISession struct {
|
type AISession struct {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"senlinai-agent/backend/internal/auth"
|
||||||
"senlinai-agent/backend/internal/config"
|
"senlinai-agent/backend/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -12,6 +13,14 @@ type RouteRegistrar interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewRouter(cfg config.Config, registrars ...RouteRegistrar) *gin.Engine {
|
func NewRouter(cfg config.Config, registrars ...RouteRegistrar) *gin.Engine {
|
||||||
|
return newRouter(cfg, nil, registrars...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewProtectedRouter(cfg config.Config, tokenVerifier func(string) (uint, error), registrars ...RouteRegistrar) *gin.Engine {
|
||||||
|
return newRouter(cfg, tokenVerifier, registrars...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRouter(cfg config.Config, tokenVerifier func(string) (uint, error), registrars ...RouteRegistrar) *gin.Engine {
|
||||||
if cfg.Env == "test" {
|
if cfg.Env == "test" {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
}
|
}
|
||||||
@@ -23,6 +32,9 @@ func NewRouter(cfg config.Config, registrars ...RouteRegistrar) *gin.Engine {
|
|||||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||||
})
|
})
|
||||||
api := router.Group("/api")
|
api := router.Group("/api")
|
||||||
|
if tokenVerifier != nil {
|
||||||
|
api.Use(auth.RequireUser(tokenVerifier))
|
||||||
|
}
|
||||||
for _, registrar := range registrars {
|
for _, registrar := range registrars {
|
||||||
registrar.Register(api)
|
registrar.Register(api)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"senlinai-agent/backend/internal/auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
@@ -22,6 +23,11 @@ func (h *Handler) Register(router gin.IRouter) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) capture(c *gin.Context) {
|
func (h *Handler) capture(c *gin.Context) {
|
||||||
|
userID, ok := auth.CurrentUserID(c)
|
||||||
|
if !ok {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"})
|
||||||
|
return
|
||||||
|
}
|
||||||
projectID, err := strconv.ParseUint(c.Param("projectID"), 10, 64)
|
projectID, err := strconv.ParseUint(c.Param("projectID"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project id"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project id"})
|
||||||
@@ -36,7 +42,7 @@ func (h *Handler) capture(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
item, err := h.service.Capture(CaptureInput{ProjectID: uint(projectID), UserID: 1, SourceType: input.SourceType, Title: input.Title, Body: input.Body})
|
item, err := h.service.Capture(CaptureInput{ProjectID: uint(projectID), UserID: userID, SourceType: input.SourceType, Title: input.Title, Body: input.Body})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -45,12 +51,17 @@ func (h *Handler) capture(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) analyze(c *gin.Context) {
|
func (h *Handler) analyze(c *gin.Context) {
|
||||||
|
userID, ok := auth.CurrentUserID(c)
|
||||||
|
if !ok {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"})
|
||||||
|
return
|
||||||
|
}
|
||||||
itemID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
itemID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid inbox id"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid inbox id"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
suggestions, err := h.service.Analyze(uint(itemID), 1)
|
suggestions, err := h.service.Analyze(uint(itemID), userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -77,18 +77,19 @@ func (s *Service) Confirm(itemID uint, selected []Suggestion) error {
|
|||||||
if err := tx.First(&item, itemID).Error; err != nil {
|
if err := tx.First(&item, itemID).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
sourceInboxItemID := item.ID
|
||||||
for _, suggestion := range selected {
|
for _, suggestion := range selected {
|
||||||
switch suggestion.Kind {
|
switch suggestion.Kind {
|
||||||
case "task":
|
case "task":
|
||||||
if err := tx.Create(&domain.Task{ProjectID: item.ProjectID, CreatedBy: item.CreatedBy, Title: suggestion.Title, Description: suggestion.Body}).Error; err != nil {
|
if err := tx.Create(&domain.Task{ProjectID: item.ProjectID, CreatedBy: item.CreatedBy, SourceInboxItemID: &sourceInboxItemID, Title: suggestion.Title, Description: suggestion.Body}).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
case "note":
|
case "note":
|
||||||
if err := tx.Create(&domain.Note{ProjectID: item.ProjectID, CreatedBy: item.CreatedBy, Title: suggestion.Title, Markdown: suggestion.Body}).Error; err != nil {
|
if err := tx.Create(&domain.Note{ProjectID: item.ProjectID, CreatedBy: item.CreatedBy, SourceInboxItemID: &sourceInboxItemID, Title: suggestion.Title, Markdown: suggestion.Body}).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
case "source":
|
case "source":
|
||||||
if err := tx.Create(&domain.Source{ProjectID: item.ProjectID, CreatedBy: item.CreatedBy, Kind: "link", Title: suggestion.Title, ContentText: suggestion.Body}).Error; err != nil {
|
if err := tx.Create(&domain.Source{ProjectID: item.ProjectID, CreatedBy: item.CreatedBy, SourceInboxItemID: &sourceInboxItemID, Kind: "link", Title: suggestion.Title, ContentText: suggestion.Body}).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ func TestConfirmCreatesSelectedObjectsAndKeepsInboxItem(t *testing.T) {
|
|||||||
require.NoError(t, database.Find(&tasks).Error)
|
require.NoError(t, database.Find(&tasks).Error)
|
||||||
require.Len(t, tasks, 1)
|
require.Len(t, tasks, 1)
|
||||||
require.Equal(t, "跟进报价", tasks[0].Title)
|
require.Equal(t, "跟进报价", tasks[0].Title)
|
||||||
|
require.NotNil(t, tasks[0].SourceInboxItemID)
|
||||||
|
require.Equal(t, item.ID, *tasks[0].SourceInboxItemID)
|
||||||
var reloaded domain.InboxItem
|
var reloaded domain.InboxItem
|
||||||
require.NoError(t, database.First(&reloaded, item.ID).Error)
|
require.NoError(t, database.First(&reloaded, item.ID).Error)
|
||||||
require.Equal(t, "processed", reloaded.Status)
|
require.Equal(t, "processed", reloaded.Status)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"senlinai-agent/backend/internal/auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
@@ -22,6 +23,11 @@ func (h *Handler) Register(router gin.IRouter) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) createProject(c *gin.Context) {
|
func (h *Handler) createProject(c *gin.Context) {
|
||||||
|
userID, ok := auth.CurrentUserID(c)
|
||||||
|
if !ok {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"})
|
||||||
|
return
|
||||||
|
}
|
||||||
var input struct {
|
var input struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
@@ -30,7 +36,7 @@ func (h *Handler) createProject(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
project, err := h.service.CreateProject(1, input.Name, input.Description)
|
project, err := h.service.CreateProject(userID, input.Name, input.Description)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -39,7 +45,12 @@ func (h *Handler) createProject(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) listProjects(c *gin.Context) {
|
func (h *Handler) listProjects(c *gin.Context) {
|
||||||
projects, err := h.service.ListProjects(1)
|
userID, ok := auth.CurrentUserID(c)
|
||||||
|
if !ok {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
projects, err := h.service.ListProjects(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list projects"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list projects"})
|
||||||
return
|
return
|
||||||
@@ -48,12 +59,17 @@ func (h *Handler) listProjects(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) dashboard(c *gin.Context) {
|
func (h *Handler) dashboard(c *gin.Context) {
|
||||||
|
userID, ok := auth.CurrentUserID(c)
|
||||||
|
if !ok {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"})
|
||||||
|
return
|
||||||
|
}
|
||||||
projectID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
projectID, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project id"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project id"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
dashboard, err := h.service.Dashboard(uint(projectID))
|
dashboard, err := h.service.Dashboard(userID, uint(projectID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load dashboard"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load dashboard"})
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -54,7 +54,11 @@ func (s *Service) ListTags(projectID uint) ([]domain.Tag, error) {
|
|||||||
return tags, err
|
return tags, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) Dashboard(projectID uint) (Dashboard, error) {
|
func (s *Service) Dashboard(ownerID uint, projectID uint) (Dashboard, error) {
|
||||||
|
var project domain.Project
|
||||||
|
if err := s.db.Where("id = ? AND owner_id = ?", projectID, ownerID).First(&project).Error; err != nil {
|
||||||
|
return Dashboard{}, err
|
||||||
|
}
|
||||||
dashboard := Dashboard{ProjectID: projectID}
|
dashboard := Dashboard{ProjectID: projectID}
|
||||||
if err := s.db.Model(&domain.InboxItem{}).Where("project_id = ? AND status = ?", projectID, "open").Count(&dashboard.PendingInboxCount).Error; err != nil {
|
if err := s.db.Model(&domain.InboxItem{}).Where("project_id = ? AND status = ?", projectID, "open").Count(&dashboard.PendingInboxCount).Error; err != nil {
|
||||||
return dashboard, err
|
return dashboard, err
|
||||||
|
|||||||
@@ -41,13 +41,24 @@ func TestDashboardCountsOnlyRequestedProject(t *testing.T) {
|
|||||||
require.NoError(t, database.Create(&domain.Task{ProjectID: first.ID, CreatedBy: 1, Title: "A", Status: "open"}).Error)
|
require.NoError(t, database.Create(&domain.Task{ProjectID: first.ID, CreatedBy: 1, Title: "A", Status: "open"}).Error)
|
||||||
require.NoError(t, database.Create(&domain.Task{ProjectID: second.ID, CreatedBy: 1, Title: "B", Status: "open"}).Error)
|
require.NoError(t, database.Create(&domain.Task{ProjectID: second.ID, CreatedBy: 1, Title: "B", Status: "open"}).Error)
|
||||||
|
|
||||||
dashboard, err := service.Dashboard(first.ID)
|
dashboard, err := service.Dashboard(1, first.ID)
|
||||||
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, int64(1), dashboard.PendingInboxCount)
|
require.Equal(t, int64(1), dashboard.PendingInboxCount)
|
||||||
require.Equal(t, int64(1), dashboard.OpenTaskCount)
|
require.Equal(t, int64(1), dashboard.OpenTaskCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDashboardRejectsProjectOwnedByAnotherUser(t *testing.T) {
|
||||||
|
database := newTestDB(t)
|
||||||
|
service := NewService(database)
|
||||||
|
project, err := service.CreateProject(2, "Beta", "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = service.Dashboard(1, project.ID)
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
func newTestDB(t *testing.T) *gorm.DB {
|
func newTestDB(t *testing.T) *gorm.DB {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
database, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
|
database, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ func (s *Service) ShareObject(taskID uint, objectType string, objectID uint) err
|
|||||||
if err := tx.First(&task, taskID).Error; err != nil {
|
if err := tx.First(&task, taskID).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := ensureSharedObjectInProject(tx, task.ProjectID, objectType, objectID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := tx.Create(&domain.TaskShare{TaskID: taskID, ObjectType: objectType, ObjectID: objectID}).Error; err != nil {
|
if err := tx.Create(&domain.TaskShare{TaskID: taskID, ObjectType: objectType, ObjectID: objectID}).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -82,3 +85,25 @@ func (s *Service) VisibleLinkedObjects(taskID uint, viewerID uint) ([]LinkedObje
|
|||||||
}
|
}
|
||||||
return objects, nil
|
return objects, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ensureSharedObjectInProject(tx *gorm.DB, projectID uint, objectType string, objectID uint) error {
|
||||||
|
switch objectType {
|
||||||
|
case "note":
|
||||||
|
var count int64
|
||||||
|
if err := tx.Model(&domain.Note{}).Where("id = ? AND project_id = ?", objectID, projectID).Count(&count).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count == 0 {
|
||||||
|
return errors.New("shared object not found in task project")
|
||||||
|
}
|
||||||
|
case "source":
|
||||||
|
var count int64
|
||||||
|
if err := tx.Model(&domain.Source{}).Where("id = ? AND project_id = ?", objectID, projectID).Count(&count).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count == 0 {
|
||||||
|
return errors.New("shared object not found in task project")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -40,6 +40,19 @@ func TestShareObjectRejectsUnsupportedType(t *testing.T) {
|
|||||||
require.ErrorContains(t, err, "unsupported shared object type")
|
require.ErrorContains(t, err, "unsupported shared object type")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestShareObjectRejectsObjectFromAnotherProject(t *testing.T) {
|
||||||
|
database := newTestDB(t)
|
||||||
|
task := domain.Task{ProjectID: 1, CreatedBy: 1, Title: "Review"}
|
||||||
|
note := domain.Note{ProjectID: 2, CreatedBy: 1, Title: "Other project", Markdown: "Private context"}
|
||||||
|
require.NoError(t, database.Create(&task).Error)
|
||||||
|
require.NoError(t, database.Create(¬e).Error)
|
||||||
|
service := NewService(database)
|
||||||
|
|
||||||
|
err := service.ShareObject(task.ID, "note", note.ID)
|
||||||
|
|
||||||
|
require.ErrorContains(t, err, "shared object not found in task project")
|
||||||
|
}
|
||||||
|
|
||||||
func TestAssignRecordsProjectEvent(t *testing.T) {
|
func TestAssignRecordsProjectEvent(t *testing.T) {
|
||||||
database := newTestDB(t)
|
database := newTestDB(t)
|
||||||
task := domain.Task{ProjectID: 7, CreatedBy: 1, Title: "安排评审"}
|
task := domain.Task{ProjectID: 7, CreatedBy: 1, Title: "安排评审"}
|
||||||
|
|||||||
@@ -32,12 +32,13 @@ Run:
|
|||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
Set-Location apps/web
|
Set-Location apps/web
|
||||||
|
npx tsc --noEmit -p tsconfig.app.json
|
||||||
npm test -- --run
|
npm test -- --run
|
||||||
npm run build
|
npm run build
|
||||||
npx playwright test
|
npx playwright test
|
||||||
```
|
```
|
||||||
|
|
||||||
Expected: component tests, production build, and smoke test pass.
|
Expected: TypeScript check, component tests, production build, and Playwright smoke/E2E tests pass.
|
||||||
|
|
||||||
## Desktop
|
## Desktop
|
||||||
|
|
||||||
@@ -54,8 +55,29 @@ Expected: Tauri desktop bundle builds when Rust/Cargo is installed and the stabl
|
|||||||
|
|
||||||
Current machine status:
|
Current machine status:
|
||||||
- `rustup` was installed through winget.
|
- `rustup` was installed through winget.
|
||||||
- The stable toolchain install repeatedly timed out and currently reports `Missing manifest in toolchain 'stable-x86_64-pc-windows-msvc'`.
|
- The latest desktop build attempt fails before project compilation because `cargo` is not available on PATH: `program not found`.
|
||||||
- Desktop verification remains blocked until `rustup toolchain install stable-x86_64-pc-windows-msvc --profile minimal --force` completes successfully.
|
- Desktop verification remains blocked until Rust/Cargo is installed and visible to the shell. Then run `npm run build` from `apps/desktop`.
|
||||||
|
|
||||||
|
## Latest Audit Verification
|
||||||
|
|
||||||
|
Last audited: 2026-07-18.
|
||||||
|
|
||||||
|
- `backend`: `go test ./... -v` passed.
|
||||||
|
- `apps/web`: `npx tsc --noEmit -p tsconfig.app.json` passed.
|
||||||
|
- `apps/web`: `npm test -- --run` passed.
|
||||||
|
- `apps/web`: `npm run build` passed.
|
||||||
|
- `apps/web`: `npx playwright test` passed.
|
||||||
|
- `apps/desktop`: `npm run build` blocked by missing Cargo/Rust toolchain on the current machine.
|
||||||
|
|
||||||
|
Audit fixes included:
|
||||||
|
|
||||||
|
- Signed expiring auth session tokens replaced forgeable `user:<id>` tokens.
|
||||||
|
- Project and inbox HTTP handlers now read the authenticated user from bearer middleware instead of hard-coding user `1`.
|
||||||
|
- Project dashboard loading now checks project ownership before returning counters.
|
||||||
|
- Inbox confirmation preserves provenance with `source_inbox_item_id` on generated tasks, notes, and sources.
|
||||||
|
- Task sharing now rejects note/source links from another project.
|
||||||
|
- AI user keys are encrypted before storage, decrypted on selection, and can be checked with a per-user action rate limit.
|
||||||
|
- Web API calls now use the backend `/api` prefix and the Svelte shell includes an inbox suggestion confirmation E2E path.
|
||||||
|
|
||||||
## Manual MVP Flow
|
## Manual MVP Flow
|
||||||
|
|
||||||
|
|||||||
@@ -676,3 +676,17 @@ Verification evidence from the latest run:
|
|||||||
- `apps/web`: `npm run build` passed.
|
- `apps/web`: `npm run build` passed.
|
||||||
- `apps/web`: `npx playwright test` passed.
|
- `apps/web`: `npx playwright test` passed.
|
||||||
- `apps/desktop`: `npm run build` is blocked before compilation by incomplete Rust stable toolchain.
|
- `apps/desktop`: `npm run build` is blocked before compilation by incomplete Rust stable toolchain.
|
||||||
|
|
||||||
|
## 2026-07-18 Full Audit Follow-Up
|
||||||
|
|
||||||
|
- [x] Backend API startup now wires PostgreSQL, AutoMigrate, project routes, and inbox routes through `/api`.
|
||||||
|
- [x] Auth sessions are signed and expiring; legacy forgeable `user:<id>` tokens are rejected.
|
||||||
|
- [x] Project and inbox HTTP handlers use the authenticated bearer-token user instead of a hard-coded user ID.
|
||||||
|
- [x] Project dashboard reads are owner-scoped before counters are returned.
|
||||||
|
- [x] Search now covers projects, tasks, notes, sources, inbox items, and AI sessions.
|
||||||
|
- [x] Inbox confirmation keeps provenance through `source_inbox_item_id` on generated tasks, notes, and sources.
|
||||||
|
- [x] Task sharing rejects note/source objects from a different project.
|
||||||
|
- [x] AI user keys are encrypted at rest and gateway rate-limit checks are covered by tests.
|
||||||
|
- [x] Svelte web client calls the backend `/api` prefix, passes type checking, and has Playwright coverage for inbox suggestion confirmation.
|
||||||
|
- [x] Current Markdown docs no longer include the live PostgreSQL test credential; integration tests must receive it through `DATABASE_URL`.
|
||||||
|
- [ ] Desktop binary build remains blocked on this machine because Cargo/Rust is not available to the shell. Install a complete Rust stable toolchain, then rerun `npm run build` from `apps/desktop`.
|
||||||
|
|||||||
Reference in New Issue
Block a user