clean up reusable consts, vars, functions

This commit is contained in:
david 2026-09-22 21:59:11 +03:00
parent 74389d81f6
commit 2e5b46098f
75 changed files with 1750 additions and 1610 deletions

View File

@ -19,9 +19,9 @@ auth-realtime: [auth-realtime/AGENTS.md](auth-realtime/AGENTS.md).
(`OXYNOTE_AUTH_REALTIME_BACKEND_URL`).
- `datagen/`: separate Go module synthesising demo Postgres/MariaDB content.
Go builds go through goreleaser: `make build` in `server/core` and `datagen`
runs `goreleaser release --snapshot --clean`, producing `bin/` and the
`ghcr.io/oxynote/{core,datagen}:dev` images the dev compose stack consumes.
Go builds go through goreleaser (`make build` in `server/core` and
`datagen`), producing `bin/` and the `ghcr.io/oxynote/{core,datagen}:dev`
images the dev compose stack consumes.
datagen shares core's test/lint workflow with its own copy of the
golangci-lint profile; its `pgdemo`/`mariademo` suites start throwaway
containers.
@ -55,11 +55,9 @@ GitHub and Slack callback URLs point at `:8080/core/api/apps/...`.
request is validated against `GET /api/internal/mcp/session` (JWKS +
consent row, so revocation 401s immediately) and scoped by
`documents:read`/`documents:write`/`data-sources:read`.
- `/api/ws` topics (`wetsocks/wsserver`): `change@document-tree`,
`change@tag-tree`,
`change@documents.{documentId}.comments|metadata|reviewers|maintainers|tags|hooks`,
`post@slack.messages`, `creation@notifications`, `ping@version`. Binders
are `Handler.BindXxx` under `internal/server/internal/...`.
- `/api/ws` topics (`wetsocks/wsserver`) are registered in `router.go` as
`<event>@<domain.path>`; binders are `Handler.BindXxx` under
`internal/server/internal/...`.
The README lists the public routes; update it when changing handlers.

View File

@ -1,9 +1,9 @@
# AGENTS.md — Go standards
Mandatory for all Go code in `server/core` and `datagen`; this file wins
over surrounding code for new and modified code. Shared helpers live in
`pkg/`; supervisors come from `github.com/jellydator/xync`. Where this file
names an exemplar file, copy its shape.
over surrounding code for new and modified code. Supervisors come from
`github.com/jellydator/xync`. Where this file names an exemplar file, copy
its shape.
```sh
make build # goreleaser snapshot -> bin/ + ghcr.io/oxynote/core:dev
@ -30,17 +30,17 @@ make lint # golangci-lint run --fix; check-lint = verify only
**Comments.** Every comment ends with a period (godot). Doc comments on
everything, unexported included: `// <Name> <indicative verb phrase>.`;
interface methods use `should`, implementations indicative. `// NOCOV:
<lowercase reason>.` is the first line of a branch that genuinely cannot be
reproduced; it is a reviewer covenant, not a tool directive, and never
excuses a testable path.
`// NOTE: <why>.` for design rationale. `//nolint:<linter> // <lowercase
reason>`, reason mandatory (deferred Rollback/Close: `errcheck // error
provides no meaningful info`). Comments never narrate the next line, justify
a diff, or point at design docs or agent-instruction files.
<lowercase reason>.` heads a branch that genuinely cannot be reproduced; it
never excuses a testable path. `// NOTE: <why>.` for design rationale.
`//nolint:<linter> // <lowercase reason>`, reason mandatory (deferred
Rollback/Close: `errcheck // error provides no meaningful info`). Comments
never narrate the next line, justify a diff, or point at design docs or
agent-instruction files.
**Globals.** Every unexported package-level var/const is `_`-prefixed and
doc-commented (`var` for values tests shorten). Exported globals: `Err*`
sentinels only. `init()` is banned except for build-info parsing.
sentinels and consts another package reads. `init()` is banned except for
build-info parsing.
Compile-time interface assertions (`var _ Iface = &Impl{}`) only when a
human asks: interfaces are consumer-declared, so the assertion imports the
consumer and risks a cycle.
@ -48,9 +48,8 @@ consumer and risks a cycle.
**Naming.** Receivers are 1–3 letters from the type's initials, consistent
per type. Shorts: `cfg`, `opts`, `inp`, `supv`, `res`, `evt`; secondary
errors `cerr`/`rerr`/`perr`. Acronyms stay uppercase (`httpClient`, `ID`),
except `Ws` in compounds. `any`, never `interface{}`. Unused parameters are
`_`. An exported identifier used only inside its package becomes unexported;
an unused one is deleted.
except `Ws` in compounds. Unused parameters are `_`. An exported identifier
used only inside its package becomes unexported; an unused one is deleted.
**Shape (wsl).** Guard clauses, no `else` after a returning `if`. `if err :=
f(); err != nil {` when `err` is not reused. Blank line before every `return`
@ -58,9 +57,8 @@ f(); err != nil {` when `err` is not reused. Blank line before every `return`
`Lock`/`defer Unlock` pair. Multi-argument calls one parameter per line.
Named returns only so a `defer` can observe or replace the result; naked
returns are banned. `defer cancel()`/`defer mu.Unlock()`/`defer
resp.Body.Close()` immediately after the acquiring call. Imports: one stdlib
block, then one block of everything else; alias collisions as lowercase
concatenations (`hookMan`), mocks as `<pkg>Mock`.
resp.Body.Close()` immediately after the acquiring call. Import alias
collisions are lowercase concatenations (`hookMan`), mocks `<pkg>Mock`.
## API design
@ -81,7 +79,7 @@ resource-owning type has `Close() error`.
configurable package, next to its constructor, every field doc-commented
with units and zero-value behaviour. Options nest to mirror the dependency
tree and `validate()` cascades (exported `Validate()` when a parent cascades
into it). Config is environment only, read once in `main`.
into it).
**Structs.** Service structs: unexported fields ordered `log *slog.Logger`,
dependencies, each mutex directly above the fields it guards (blank lines
@ -189,8 +187,7 @@ slice; always a deterministic tiebreaker after the user sort. IDs are
generated in the domain layer, no `RETURNING`. Soft deletes set
`deleted_at`; hard deletes happen in `Cleanup*` sweeps. A sqlhooks error
hook maps constraint violations → user errors; an empty result is a bare
`sql.ErrNoRows`, so callers use `errutil.IsNotFound`. Migrations are
`migrations/NNN_snake_case.sql`, embedded with `//go:embed`.
`sql.ErrNoRows`, so callers use `errutil.IsNotFound`.
## HTTP & WebSocket
@ -205,6 +202,9 @@ hook maps constraint violations → user errors; an empty result is a bare
`FetchX`/`FetchXs` (not `ListX`), action verbs (`DuplicateDocument`),
`CreateX`/`UpdateX`/`DeleteX`; `Handle` only where the route has no
entity (`HandleChat`).
- Handler packages hold no domain rules: a parser, allow-list, cap or key
layout lives in the domain package it describes; a handler decodes,
transacts, calls the domain and responds.
- A session handler's first statement is `auth.RequireSession(h.log, w, r)`.
Decoding only through `DecodeJSON`, `DecodeForm`, `ParseQuery`,
`ExtractTargetID`; responding only through `Respond` and `RespondError`,
@ -244,8 +244,8 @@ for known third-party leaks. Serialization types get `Test_X_UnmarshalJSON`,
before the result assertions.
- Case names are sentence case without punctuation (`"Successful
creation"`, `"Context cancelled"`); error paths name the collaborator
verbatim (`"Error returned by Tx.Commit"`). No `name` field.
creation"`); error paths name the collaborator verbatim (`"Error returned
by Tx.Commit"`). No `name` field.
- Case fields are PascalCase, inputs and collaborators first (`DB`, `Tx`,
`Inp`, `JSON`, `Context`), expectations (`Result`, `Err`, `Checks`,
`RespCode`, `RespJSON`) last.
@ -270,11 +270,11 @@ nondeterministic types; `assert.JSONEq` for JSON; decimals as strings.
**Mocks.** `//go:generate ../../scripts/codegen/mock -t internal DB db`
(in-package `DBMock`), `-t external` (importable `_mock/` package,
`mock.DB`), `-t both`. All use `-stub`, so `&DBMock{}` is a valid don't-care
collaborator that still records calls. `_mock/` holds only generated code;
third-party mocks go in `internal/_mock`. Configure by struct literal with
only the needed funcs; repeated shapes are local `stub*` closures
parameterized by the errors to return. Assert calls only through recorders
(`db.CreateItemCalls()`); test `On*` APIs by invoking the captured callback.
collaborator that still records calls. Third-party mocks go in
`internal/_mock`. Configure by struct literal with only the needed funcs;
repeated shapes are local `stub*` closures parameterized by the errors to
return. Assert calls only through recorders (`db.CreateItemCalls()`); test
`On*` APIs by invoking the captured callback.
**check/checks combinator.** For functions touching several mocks, a
`Checks []check` field replaces per-field expectations: `type check
@ -291,15 +291,14 @@ counts. Exemplar: `internal/assistant/tools/document_test.go`.
**Database-layer tests** (`internal/db/db_test.go`): `TestMain` starts one
gnomock Postgres per package run; `prepTempDB(t)` gives each subtest its own
migrated database, which is what makes `t.Parallel()` safe. Tables are maps
of case constructors `func(*testing.T, *DB) tcase`. Round times to
microseconds before insert; verify by re-querying through the production
select builders.
migrated database. Tables are maps of case constructors `func(*testing.T,
*DB) tcase`. Round times to microseconds before insert; verify by
re-querying through the production select builders.
**HTTP handlers.** Invoke methods directly, no router:
`httptest.NewRequest` against `http://test.com/`, chi params via
`testutil.AddChiCtx` (an `OmitID bool` case field drives the missing-param
path). Assert `rec.Code` and `assert.JSONEq` against verbatim envelopes;
`testutil.AddChiCtx`. Assert `rec.Code` and `assert.JSONEq` against verbatim
envelopes;
204s assert an empty body. Auth middleware is tested through real components
over mocked stores. WS binder tests stub `OnFirstSubFunc`/`OnLastUnsubFunc`
to invoke immediately, fire the captured callback, assert
@ -318,6 +317,4 @@ Logging is asserted, not mocked: `slog.NewTextHandler` over a `bytes.Buffer`
logs don't matter. Use `context.Background()`, not `t.Context()`.
**Hygiene.** Golden files in `testdata/`. Package-level fixtures are
`_`-prefixed. `t.Helper()` in named helpers. White-box construction is
normal. Giant test functions are fine; prefer one exhaustive function over
splitting a target's cases.
`_`-prefixed. `t.Helper()` in named helpers.

View File

@ -6,6 +6,7 @@ import (
"strings"
"github.com/oxynote/oxynote/server/core/internal/document"
"github.com/oxynote/oxynote/server/core/internal/search"
"github.com/rs/xid"
)
@ -35,6 +36,10 @@ func (a searchDocumentsArgs) Validate() error {
return errRequired("query")
}
if err := search.ValidateQuery(a.Query); err != nil {
return fmt.Errorf("query must be at most %d characters; search for the key terms instead", search.MaxQueryLength)
}
return nil
}

View File

@ -4,6 +4,7 @@ import (
"bytes"
"context"
"log/slog"
"strings"
"testing"
"github.com/oxynote/oxynote/server/core/internal/document"
@ -98,6 +99,12 @@ func Test_searchDocuments_Execute(t *testing.T) {
Args: `{}`,
Err: assert.AnError,
},
"Query over the cap": {
Searcher: hitSearcher(nil),
DB: &DBMock{},
Args: `{"query":"` + strings.Repeat("a", search.MaxQueryLength+1) + `"}`,
Err: assert.AnError,
},
"Error returned by search.SearchDocumentBlocks": {
Searcher: &SearcherMock{
SearchDocumentBlocksFunc: func(context.Context, string, string, int) ([]search.Block, error) {

View File

@ -60,6 +60,27 @@ func (info Info) Schema() map[string]any {
return out
}
// Available tool access constants.
const (
// AccessNone is held by no surface outside this process: the tool
// addresses conversation state only the assistant has.
AccessNone Access = "none"
// AccessRead reads the organization's documents.
AccessRead Access = "read"
// AccessWrite mutates the organization's documents.
AccessWrite Access = "write"
// AccessDataSource reads one of the organization's outbound
// data-source connections, which is not the same permission as
// reading its documents.
AccessDataSource Access = "data-source"
)
// Access is the permission a surface must hold to offer a tool.
type Access string
// Traits are the facts about a tool that decide how the assistant
// treats it. A tool states them in one place in its own file rather
// than being interrogated for them from several.
@ -102,6 +123,35 @@ type Traits struct {
Internal bool
}
// DestroysContent reports whether a write loses work the caller did not
// name: an outright removal, or an overwrite that takes nested blocks,
// uids, comments and hooks with it. Both are what a client deciding
// whether to confirm first wants to know.
func (t Traits) DestroysContent() bool {
return t.Destructive || t.Overwrites
}
// OpenWorld reports whether the tool reaches outside the organization's
// own documents: a data-source tool reaches whatever the connection
// points at.
func (t Traits) OpenWorld() bool {
return t.DataSource
}
// Access reports the permission a surface must hold to offer the tool.
func (t Traits) Access() Access {
switch {
case t.Internal:
return AccessNone
case t.DataSource:
return AccessDataSource
case t.Write:
return AccessWrite
default:
return AccessRead
}
}
// Tool is a tool the model can call. What a tool is — whether it
// mutates, whether it survives an approve-all, whether it belongs
// outside this process — it states in Traits rather than in the set of

View File

@ -25,6 +25,55 @@ func Test_plainTraits_Traits(t *testing.T) {
assert.Equal(t, Traits{}, plainTraits{}.Traits())
}
func Test_Traits_DestroysContent(t *testing.T) {
cc := map[string]struct {
Traits Traits
Result bool
}{
"Read": {},
"Plain write": {Traits: Traits{Write: true}},
"Destructive": {Traits: Traits{Write: true, Destructive: true}, Result: true},
"Overwriting": {Traits: Traits{Write: true, Overwrites: true}, Result: true},
}
for cn, c := range cc {
t.Run(cn, func(t *testing.T) {
t.Parallel()
assert.Equal(t, c.Result, c.Traits.DestroysContent())
})
}
}
func Test_Traits_OpenWorld(t *testing.T) {
t.Parallel()
assert.False(t, Traits{}.OpenWorld())
assert.True(t, Traits{DataSource: true}.OpenWorld())
}
func Test_Traits_Access(t *testing.T) {
cc := map[string]struct {
Traits Traits
Result Access
}{
"Read": {Result: AccessRead},
"Write": {Traits: Traits{Write: true}, Result: AccessWrite},
"Data source": {Traits: Traits{DataSource: true}, Result: AccessDataSource},
"Internal": {Traits: Traits{Internal: true}, Result: AccessNone},
"Internal write": {Traits: Traits{Write: true, Internal: true}, Result: AccessNone},
"Data source over write": {Traits: Traits{Write: true, DataSource: true}, Result: AccessDataSource},
}
for cn, c := range cc {
t.Run(cn, func(t *testing.T) {
t.Parallel()
assert.Equal(t, c.Result, c.Traits.Access())
})
}
}
func Test_plainTitle_Title(t *testing.T) {
t.Parallel()

View File

@ -6,6 +6,7 @@ import (
"time"
"github.com/guregu/null/v5"
"github.com/oxynote/oxynote/server/core/internal/datasource/demo"
"github.com/oxynote/oxynote/server/core/internal/datasource/processor"
"github.com/oxynote/oxynote/server/core/pkg/timeutil"
"github.com/rs/xid"
@ -72,6 +73,17 @@ func NewDataSource(ci CreateInput, organizationID string) *DataSource {
}
}
// NewDemoDataSource creates the demo Prometheus data source a fresh
// organization starts with.
func NewDemoDataSource(organizationID string) *DataSource {
return NewDataSource(CreateInput{
Type: TypePrometheus,
Name: "Demo",
URL: demo.URL,
Credentials: processor.NewCredentials([]byte(`{}`)),
}, organizationID)
}
// Info returns a subset of data source fields for external use.
func (ds *DataSource) Info() Info {
return Info{

View File

@ -6,6 +6,7 @@ import (
"time"
"github.com/guregu/null/v5"
"github.com/oxynote/oxynote/server/core/internal/datasource/demo"
"github.com/oxynote/oxynote/server/core/internal/datasource/processor"
"github.com/oxynote/oxynote/server/core/pkg/cryptoutil"
"github.com/oxynote/oxynote/server/core/pkg/testutil"
@ -56,6 +57,20 @@ func Test_NewDataSource(t *testing.T) {
assert.False(t, ds.UpdatedAt.Valid)
}
func Test_NewDemoDataSource(t *testing.T) {
t.Parallel()
ds := NewDemoDataSource("org-1")
require.NotNil(t, ds)
assert.False(t, ds.ID.IsNil())
assert.Equal(t, "org-1", ds.OrganizationID)
assert.Equal(t, "Demo", ds.Name)
assert.Equal(t, TypePrometheus, ds.Type)
assert.Equal(t, demo.URL, ds.URL)
assert.Equal(t, processor.NewCredentials([]byte(`{}`)), ds.Credentials)
}
func Test_DataSource_Info(t *testing.T) {
t.Parallel()

View File

@ -8,6 +8,10 @@ import (
"github.com/oxynote/oxynote/server/core/pkg/errutil"
)
// ErrInvalidChartType is returned when a chart type is not one the
// processors draw.
var ErrInvalidChartType = errutil.New(http.StatusBadRequest, "chart_type.invalid", "Invalid chart type. Must be one of: line, bar, gauge.")
// ChartType represents the type of chart visualization.
type ChartType string
@ -32,6 +36,16 @@ func (ct ChartType) IsValid() bool {
}
}
// Validate reports whether the chart type is one the processors draw;
// the empty one is not.
func (ct ChartType) Validate() error {
if !ct.IsValid() {
return ErrInvalidChartType
}
return nil
}
// UnmarshalText parses a chart type, refusing anything but the known
// kinds so a bad value is reported where it is decoded.
func (ct *ChartType) UnmarshalText(text []byte) error {

View File

@ -75,6 +75,27 @@ func Test_ChartType_IsValid(t *testing.T) {
}
}
func Test_ChartType_Validate(t *testing.T) {
cc := map[string]struct {
ChartType ChartType
Err error
}{
"Line chart": {ChartType: ChartTypeLine},
"Bar chart": {ChartType: ChartTypeBar},
"Gauge chart": {ChartType: ChartTypeGauge},
"Empty chart type": {Err: ErrInvalidChartType},
"Unknown chart type": {ChartType: ChartType("bogus"), Err: ErrInvalidChartType},
}
for cn, c := range cc {
t.Run(cn, func(t *testing.T) {
t.Parallel()
testutil.AssertEqualError(t, c.Err, c.ChartType.Validate())
})
}
}
func Test_QueryResult_HasData(t *testing.T) {
t.Parallel()

View File

@ -0,0 +1,83 @@
package datasource
import (
"context"
"net/http"
"github.com/oxynote/oxynote/server/core/internal/datasource/processor"
"github.com/oxynote/oxynote/server/core/pkg/errutil"
)
// ErrTypeNotSupported is returned when a data source speaks something
// the generic query cannot run.
var ErrTypeNotSupported = errutil.New(http.StatusBadRequest, "data_source.type_not_supported", "Generic query is not supported for this data source type.")
// Query runs q against the data source the runner operates and returns
// the result in the unified shape, whatever the source speaks. A query
// that answered nothing is normalised to QueryStatusNoData here, so no
// caller has to tell a nil result from an empty one.
func Query(
ctx context.Context,
r Runner,
q string,
tr processor.TimeRange,
ct processor.ChartType,
) (*processor.QueryResult, error) {
switch r.Type() {
case TypePrometheus:
client, err := r.Prometheus(ctx)
if err != nil {
return nil, err
}
res, err := client.QueryRange(ctx, q, tr)
if err != nil {
return nil, err
}
if res == nil {
return noData(), nil
}
return res.Transform(ct), nil
case TypePostgreSQL:
client, err := r.PostgreSQL(ctx)
if err != nil {
return nil, err
}
res, err := client.Query(ctx, q, tr)
if err != nil {
return nil, err
}
if res == nil {
return noData(), nil
}
return res.Transform(ct), nil
case TypeMariaDB, TypeMySQL:
client, err := r.MySQL(ctx)
if err != nil {
return nil, err
}
res, err := client.Query(ctx, q, tr)
if err != nil {
return nil, err
}
if res == nil {
return noData(), nil
}
return res.Transform(ct), nil
default:
return nil, ErrTypeNotSupported
}
}
// noData is the result of a query the source answered nothing to.
func noData() *processor.QueryResult {
return &processor.QueryResult{Status: processor.QueryStatusNoData}
}

View File

@ -0,0 +1,183 @@
package datasource
import (
"context"
"testing"
"time"
"github.com/oxynote/oxynote/server/core/internal/datasource/processor"
"github.com/oxynote/oxynote/server/core/pkg/testutil"
"github.com/prometheus/common/model"
"github.com/stretchr/testify/assert"
)
// stubRunner is a runner of one type whose clients answer the query with
// fixed values. The clients embed their interface, so only the query
// method exists; any other call is a test bug.
type stubRunner struct {
Runner
typ Type
err error
prometheus *processor.PrometheusQueryResult
postgreSQL *processor.PostgreSQLQueryResult
mySQL *processor.MySQLQueryResult
}
func (s stubRunner) Type() Type { return s.typ }
func (s stubRunner) Prometheus(context.Context) (Prometheus, error) {
return stubPrometheus{res: s.prometheus, err: s.err}, nil
}
func (s stubRunner) PostgreSQL(context.Context) (PostgreSQL, error) {
return stubPostgreSQL{res: s.postgreSQL, err: s.err}, nil
}
func (s stubRunner) MySQL(context.Context) (MySQL, error) {
return stubMySQL{res: s.mySQL, err: s.err}, nil
}
// refusingRunner is a runner that hands out no client at all.
type refusingRunner struct {
Runner
typ Type
}
func (r refusingRunner) Type() Type { return r.typ }
func (refusingRunner) Prometheus(context.Context) (Prometheus, error) { return nil, assert.AnError }
func (refusingRunner) PostgreSQL(context.Context) (PostgreSQL, error) { return nil, assert.AnError }
func (refusingRunner) MySQL(context.Context) (MySQL, error) { return nil, assert.AnError }
type stubPrometheus struct {
Prometheus
res *processor.PrometheusQueryResult
err error
}
func (s stubPrometheus) QueryRange(context.Context, string, processor.TimeRange) (*processor.PrometheusQueryResult, error) {
return s.res, s.err
}
type stubPostgreSQL struct {
PostgreSQL
res *processor.PostgreSQLQueryResult
err error
}
func (s stubPostgreSQL) Query(context.Context, string, processor.TimeRange) (*processor.PostgreSQLQueryResult, error) {
return s.res, s.err
}
type stubMySQL struct {
MySQL
res *processor.MySQLQueryResult
err error
}
func (s stubMySQL) Query(context.Context, string, processor.TimeRange) (*processor.MySQLQueryResult, error) {
return s.res, s.err
}
func Test_Query(t *testing.T) {
rows := &processor.PostgreSQLQueryResult{
Columns: []string{"time", "value"},
Rows: [][]any{{1700000000.0, 10.0}},
}
cc := map[string]struct {
Runner Runner
Result *processor.QueryResult
Err error
}{
"Unsupported type": {
Runner: stubRunner{typ: Type("bogus")},
Err: ErrTypeNotSupported,
},
"Error returned by Runner.Prometheus": {
Runner: refusingRunner{typ: TypePrometheus},
Err: assert.AnError,
},
"Error returned by Runner.PostgreSQL": {
Runner: refusingRunner{typ: TypePostgreSQL},
Err: assert.AnError,
},
"Error returned by Runner.MySQL": {
Runner: refusingRunner{typ: TypeMySQL},
Err: assert.AnError,
},
"Error returned by Prometheus.QueryRange": {
Runner: stubRunner{typ: TypePrometheus, err: assert.AnError},
Err: assert.AnError,
},
"Error returned by PostgreSQL.Query": {
Runner: stubRunner{typ: TypePostgreSQL, err: assert.AnError},
Err: assert.AnError,
},
"Error returned by MySQL.Query": {
Runner: stubRunner{typ: TypeMariaDB, err: assert.AnError},
Err: assert.AnError,
},
"Prometheus answered nothing": {
Runner: stubRunner{typ: TypePrometheus},
Result: &processor.QueryResult{Status: processor.QueryStatusNoData},
},
"PostgreSQL answered nothing": {
Runner: stubRunner{typ: TypePostgreSQL},
Result: &processor.QueryResult{Status: processor.QueryStatusNoData},
},
"MySQL answered nothing": {
Runner: stubRunner{typ: TypeMySQL},
Result: &processor.QueryResult{Status: processor.QueryStatusNoData},
},
"Prometheus result transformed": {
Runner: stubRunner{typ: TypePrometheus, prometheus: &processor.PrometheusQueryResult{
Type: model.ValMatrix,
Result: model.Matrix{
&model.SampleStream{
Metric: model.Metric{"job": "a"},
Values: []model.SamplePair{{Timestamp: model.Time(1700000000000), Value: 10}},
},
},
}},
Result: &processor.QueryResult{
Status: processor.QueryStatusOK,
Data: []processor.QueryResultSeries{
{Labels: map[string]string{"job": "a"}, Metrics: [][2]any{{int64(1700000000), 10.0}}},
},
},
},
"PostgreSQL result transformed": {
Runner: stubRunner{typ: TypePostgreSQL, postgreSQL: rows},
Result: (&processor.PostgreSQLQueryResult{Columns: rows.Columns, Rows: rows.Rows}).Transform(processor.ChartTypeLine),
},
"MySQL result transformed": {
Runner: stubRunner{typ: TypeMySQL, mySQL: &processor.MySQLQueryResult{Columns: rows.Columns, Rows: rows.Rows}},
Result: (&processor.MySQLQueryResult{Columns: rows.Columns, Rows: rows.Rows}).Transform(processor.ChartTypeLine),
},
}
for cn, c := range cc {
t.Run(cn, func(t *testing.T) {
t.Parallel()
tr := processor.TimeRange{From: time.Unix(1700000000, 0), To: time.Unix(1700003600, 0)}
res, err := Query(context.Background(), c.Runner, "up", tr, processor.ChartTypeLine)
testutil.AssertEqualError(t, c.Err, err)
if err != nil {
return
}
assert.Equal(t, c.Result, res)
})
}
}

View File

@ -209,8 +209,11 @@ func (c *Checker) checkMetricActive(
now := timeutil.Now()
tr := processor.TimeRange{From: now.Add(-probeSpan(block)), To: now}
// the probe transforms as a gauge because that shape accepts every
// result a source can answer with, scalar, vector and matrix alike,
// and only asks whether anything came back, not how it would be drawn.
for _, q := range extractQueries(block) {
res, qerr := c.query(ctx, *ds, q, tr)
res, qerr := datasourceCore.Query(ctx, c.runners.Runner(*ds), q, tr, processor.ChartTypeGauge)
if qerr != nil {
c.log.DebugContext(ctx, "the block's query did not answer", "error", qerr)
@ -249,61 +252,6 @@ func (c *Checker) fetchDataSource(
return c.db.FetchDataSource(ctx, id, organizationID)
}
// query runs one query against the data source and returns it in the
// unified shape, whatever the source speaks. It transforms as a gauge
// because that shape accepts every result a source can answer with —
// scalar, vector and matrix alike — and the probe only asks whether
// anything came back, not how it would be drawn.
func (c *Checker) query(
ctx context.Context,
ds datasourceCore.DataSource,
q string,
tr processor.TimeRange,
) (*processor.QueryResult, error) {
runner := c.runners.Runner(ds)
switch ds.Type {
case datasourceCore.TypePrometheus:
client, err := runner.Prometheus(ctx)
if err != nil {
return nil, err
}
res, err := client.QueryRange(ctx, q, tr)
if err != nil || res == nil {
return nil, err
}
return res.Transform(processor.ChartTypeGauge), nil
case datasourceCore.TypePostgreSQL:
client, err := runner.PostgreSQL(ctx)
if err != nil {
return nil, err
}
res, err := client.Query(ctx, q, tr)
if err != nil || res == nil {
return nil, err
}
return res.Transform(processor.ChartTypeGauge), nil
case datasourceCore.TypeMariaDB, datasourceCore.TypeMySQL:
client, err := runner.MySQL(ctx)
if err != nil {
return nil, err
}
res, err := client.Query(ctx, q, tr)
if err != nil || res == nil {
return nil, err
}
return res.Transform(processor.ChartTypeGauge), nil
default:
return nil, fmt.Errorf("data source type %q cannot be queried", ds.Type)
}
}
// clear switches the block's simulation off on the live document. The
// preset stays, so the block remembers which series it drew.
func (c *Checker) clear(

View File

@ -8,9 +8,8 @@ Cross-service storage rules (Hocuspocus, Yjs) live in
## Branches
- **The main branch is identified by its `default` flag, not its name**: the
tree join and `FetchMainBranchContent` key on it, and
`UpdateDocumentBranch` refuses to rename it. `NewDocument` and `Duplicate`
set `Default: true`; forks must not.
tree join keys on it, and `Document.AllowsBranchRename` refuses to rename
it. `NewDocument` and `Duplicate` set `Default: true`; forks must not.
- **Maintainers accumulate.** The `maintainers` field on a branch update is
who edited in that persist; `UpsertDocumentMaintainers` only adds. There
is no removal path.

View File

@ -0,0 +1,60 @@
package document
import (
"net/http"
"github.com/guregu/null/v5"
"github.com/oxynote/oxynote/server/core/pkg/errutil"
"github.com/rs/xid"
)
var (
// ErrDefaultBranchRename is returned when a rename targets the default
// branch.
ErrDefaultBranchRename = errutil.New(http.StatusBadRequest, "document.default_branch_rename", "the default branch cannot be renamed")
// ErrBranchSelfMerge is returned when a branch is merged into itself.
ErrBranchSelfMerge = errutil.New(http.StatusBadRequest, "document.branch_self_merge", "cannot merge a branch into itself")
// ErrDefaultBranchDelete is returned when a delete targets the default
// branch.
ErrDefaultBranchDelete = errutil.New(http.StatusConflict, "document.default_branch", "cannot delete the default branch")
// ErrLastBranchDelete is returned when a delete targets the only branch
// a document has left.
ErrLastBranchDelete = errutil.New(http.StatusConflict, "document.last_branch", "cannot delete the last branch")
)
// AllowsBranchRename reports whether the branch may take the given name.
// The default branch is what the tree and content queries return, and its
// name is what the user sees the document under; a rename would leave the
// two out of step.
func (d Document) AllowsBranchRename(name null.String) error {
if d.Default && name.Valid && name.String != d.BranchName {
return ErrDefaultBranchRename
}
return nil
}
// AllowsBranchDelete reports whether the branch may be deleted: the
// default branch never is.
func (d Document) AllowsBranchDelete() error {
if d.Default {
return ErrDefaultBranchDelete
}
return nil
}
// AllowsMerge reports whether one branch may be merged into another. A
// self-merge would soft-delete the branch's hooks and comments and then
// copy the hooks back from the same, now hook-less, branch, permanently
// destroying both.
func AllowsMerge(fromBranchID, toBranchID xid.ID) error {
if fromBranchID == toBranchID {
return ErrBranchSelfMerge
}
return nil
}

View File

@ -0,0 +1,46 @@
package document
import (
"testing"
"github.com/guregu/null/v5"
"github.com/oxynote/oxynote/server/core/pkg/testutil"
"github.com/rs/xid"
)
func Test_Document_AllowsBranchRename(t *testing.T) {
cc := map[string]struct {
Doc Document
Name null.String
Err error
}{
"Default branch renamed": {Doc: Document{Default: true, BranchName: DefaultBranch}, Name: null.StringFrom("Renamed"), Err: ErrDefaultBranchRename},
"Default branch keeps its name": {Doc: Document{Default: true, BranchName: DefaultBranch}, Name: null.StringFrom(DefaultBranch)},
"Default branch without a name": {Doc: Document{Default: true, BranchName: DefaultBranch}},
"Fork renamed": {Doc: Document{BranchName: "feature"}, Name: null.StringFrom("Renamed")},
}
for cn, c := range cc {
t.Run(cn, func(t *testing.T) {
t.Parallel()
testutil.AssertEqualError(t, c.Err, c.Doc.AllowsBranchRename(c.Name))
})
}
}
func Test_Document_AllowsBranchDelete(t *testing.T) {
t.Parallel()
testutil.AssertEqualError(t, ErrDefaultBranchDelete, Document{Default: true}.AllowsBranchDelete())
testutil.AssertEqualError(t, nil, Document{}.AllowsBranchDelete())
}
func Test_AllowsMerge(t *testing.T) {
t.Parallel()
from, to := xid.New(), xid.New()
testutil.AssertEqualError(t, ErrBranchSelfMerge, AllowsMerge(from, from))
testutil.AssertEqualError(t, nil, AllowsMerge(from, to))
}

View File

@ -6,6 +6,7 @@ import (
"encoding/json"
"fmt"
"net/http"
"slices"
"time"
"github.com/guregu/null/v5"
@ -203,6 +204,41 @@ func (c Comment) Replace() (Comment, bool) {
return nc, true
}
// Participants returns the users who wrote the comment or a reply to it,
// in that order, each once.
func (c Comment) Participants() []string {
var ids []string
if c.UserID.Valid {
ids = append(ids, c.UserID.String)
}
for _, r := range c.Replies {
if r.UserID.Valid && !slices.Contains(ids, r.UserID.String) {
ids = append(ids, r.UserID.String)
}
}
return ids
}
// Recipients reduces the candidate user ids to the ones a notification
// about the actor's change goes to: the actor is dropped, and so is
// every repeat.
func Recipients(userIDs []string, actor string) []string {
var out []string
for _, id := range userIDs {
if id == actor || slices.Contains(out, id) {
continue
}
out = append(out, id)
}
return out
}
// NewReply creates a new reply instance with the provided input.
func NewReply(inp ReplyInput, commentID xid.ID, userID, organizationID string) Reply {
return Reply{

View File

@ -138,6 +138,65 @@ func Test_Comment_Replace(t *testing.T) {
assert.Equal(t, empty, same)
}
func Test_Comment_Participants(t *testing.T) {
cc := map[string]struct {
Comment Comment
Result []string
}{
"Author alone": {
Comment: Comment{UserID: null.StringFrom("user-1")},
Result: []string{"user-1"},
},
"Author and repliers": {
Comment: Comment{
UserID: null.StringFrom("user-1"),
Replies: []Reply{
{UserID: null.StringFrom("user-2")},
{UserID: null.StringFrom("user-1")},
{UserID: null.String{}},
{UserID: null.StringFrom("user-3")},
{UserID: null.StringFrom("user-2")},
},
},
Result: []string{"user-1", "user-2", "user-3"},
},
"Deleted author": {
Comment: Comment{Replies: []Reply{{UserID: null.StringFrom("user-2")}}},
Result: []string{"user-2"},
},
"Nobody": {},
}
for cn, c := range cc {
t.Run(cn, func(t *testing.T) {
t.Parallel()
assert.Equal(t, c.Result, c.Comment.Participants())
})
}
}
func Test_Recipients(t *testing.T) {
cc := map[string]struct {
UserIDs []string
Actor string
Result []string
}{
"Actor dropped": {UserIDs: []string{"user-1", "user-2"}, Actor: "user-1", Result: []string{"user-2"}},
"Repeats dropped": {UserIDs: []string{"user-2", "user-3", "user-2"}, Actor: "user-1", Result: []string{"user-2", "user-3"}},
"Actor alone": {UserIDs: []string{"user-1"}, Actor: "user-1"},
"Nobody": {Actor: "user-1"},
}
for cn, c := range cc {
t.Run(cn, func(t *testing.T) {
t.Parallel()
assert.Equal(t, c.Result, Recipients(c.UserIDs, c.Actor))
})
}
}
func Test_NewReply(t *testing.T) {
t.Parallel()

View File

@ -8,6 +8,7 @@ import (
"fmt"
"maps"
"math/rand"
"slices"
"time"
"github.com/guregu/null/v5"
@ -348,6 +349,39 @@ type UpdateInput struct {
Branch string `json:"branch"`
}
// AddsMaintainers reports whether the update names anyone the stored
// maintainers do not include. The update carries the editors of one
// persist, so the set is only ever added to: diffing it against the
// stored maintainers would drop everyone who happens not to be editing
// right now.
func (ui UpdateInput) AddsMaintainers(stored []string) bool {
for _, maintainer := range ui.Maintainers {
if !slices.Contains(stored, maintainer) {
return true
}
}
return false
}
// NewWelcomeDocument creates the document a fresh organization starts
// with: the getting-started content, its charts reading the given data
// source or dropped when there is none.
func NewWelcomeDocument(organizationID, createdBy string, dataSourceID null.Value[xid.ID]) (Document, error) {
content, err := InitialDocumentContent(dataSourceID)
if err != nil {
return Document{}, err
}
doc := NewDocument(CreateInput{
Name: "Welcome to Oxynote!",
Icon: "mingcute:flag-4-fill",
}, organizationID, createdBy)
doc.Content = content
return doc, nil
}
// InitialDocumentContent returns the initial content for a new document.
func InitialDocumentContent(dataSourceID null.Value[xid.ID]) (RootBlock, error) {
var drb RootBlock

View File

@ -408,6 +408,42 @@ func Test_NewDocumentContent(t *testing.T) {
assert.Len(t, uid, 21)
}
func Test_UpdateInput_AddsMaintainers(t *testing.T) {
cc := map[string]struct {
Maintainers []string
Stored []string
Result bool
}{
"New maintainer": {Maintainers: []string{"u1", "u2"}, Stored: []string{"u1"}, Result: true},
"Known maintainers only": {Maintainers: []string{"u1"}, Stored: []string{"u1", "u2"}},
"No maintainers": {Stored: []string{"u1"}},
"Nobody stored yet": {Maintainers: []string{"u1"}, Result: true},
}
for cn, c := range cc {
t.Run(cn, func(t *testing.T) {
t.Parallel()
assert.Equal(t, c.Result, UpdateInput{Maintainers: c.Maintainers}.AddsMaintainers(c.Stored))
})
}
}
func Test_NewWelcomeDocument(t *testing.T) {
t.Parallel()
doc, err := NewWelcomeDocument("org1", "u1", null.Value[xid.ID]{})
require.NoError(t, err)
assert.Equal(t, "Welcome to Oxynote!", doc.DocumentName)
assert.Equal(t, "mingcute:flag-4-fill", doc.Icon)
assert.Equal(t, "org1", doc.OrganizationID)
assert.Equal(t, null.StringFrom("u1"), doc.CreatedBy)
assert.True(t, doc.Default)
assert.Equal(t, BlockNodeDoc, doc.Content.Type)
assert.NotEmpty(t, doc.Content.Content)
}
func Test_InitialDocumentContent(t *testing.T) {
t.Parallel()

View File

@ -7,6 +7,7 @@ import (
"path"
"strings"
"time"
"unicode/utf8"
"github.com/guregu/null/v5"
"github.com/oxynote/oxynote/server/core/pkg/timeutil"
@ -16,6 +17,13 @@ import (
// _folderFormat is the storage folder format for a document's files.
const _folderFormat = "organizations/%s/documents/%s/files"
// _maxNameLength caps the name recorded for an upload, in runes.
const _maxNameLength = 255
// _fallbackName names an upload whose multipart part carried no usable
// name.
const _fallbackName = "file"
// Folder returns the storage folder holding the given document's files.
func Folder(organizationID string, documentID xid.ID) string {
return fmt.Sprintf(_folderFormat, organizationID, documentID)
@ -155,3 +163,25 @@ func (f File) Disposition() string {
func (f File) Orphaned() bool {
return !f.DocumentID.Valid || !f.OrganizationID.Valid
}
// CleanName reduces the name a multipart part carried to a bare file
// name: a browser may send a path, Windows ones with backslashes, and
// the part may carry no name at all.
func CleanName(raw string) string {
name := path.Base(strings.ReplaceAll(raw, "\\", "/"))
name = strings.TrimSpace(name)
if name == "." || name == "/" {
name = ""
}
if utf8.RuneCountInString(name) > _maxNameLength {
name = string([]rune(name)[:_maxNameLength])
}
if name == "" {
return _fallbackName
}
return name
}

View File

@ -1,6 +1,7 @@
package file
import (
"strings"
"testing"
"github.com/guregu/null/v5"
@ -153,6 +154,34 @@ func Test_Key(t *testing.T) {
)
}
func Test_CleanName(t *testing.T) {
cc := map[string]struct {
Raw string
Result string
}{
"Plain name": {Raw: "notes.zip", Result: "notes.zip"},
"Unix path": {Raw: "/home/me/notes.zip", Result: "notes.zip"},
"Windows path": {Raw: `C:\Users\me\notes.zip`, Result: "notes.zip"},
"Surrounding whitespace": {Raw: " notes.zip ", Result: "notes.zip"},
"Empty name": {Raw: "", Result: "file"},
"Whitespace only": {Raw: " ", Result: "file"},
"Dot": {Raw: ".", Result: "file"},
"Root": {Raw: "/", Result: "file"},
"Non-ASCII name": {Raw: "résumé.pdf", Result: "résumé.pdf"},
"Name over the rune cap": {Raw: strings.Repeat("é", 300), Result: strings.Repeat("é", 255)},
"Name at the rune cap": {Raw: strings.Repeat("a", 255), Result: strings.Repeat("a", 255)},
"Path with a long base": {Raw: "dir/" + strings.Repeat("b", 256), Result: strings.Repeat("b", 255)},
}
for cn, c := range cc {
t.Run(cn, func(t *testing.T) {
t.Parallel()
assert.Equal(t, c.Result, CleanName(c.Raw))
})
}
}
func Test_File_Orphaned(t *testing.T) {
t.Parallel()

View File

@ -17,10 +17,10 @@ import (
// router mounts the file routes on this very format.
const FilePathFormat = "/api/documents/%s/files/%s"
// FileIDLength is the exact length of a file id: a 21-character nanoid.
// The id's alphabet includes the dash, so a "<id>-<file name>" segment is
// split by length, never at the first dash.
const FileIDLength = 21
// FileIDLength is the exact length of a file id, a nanoid. The id's
// alphabet includes the dash, so a "<id>-<file name>" segment is split by
// length, never at the first dash.
const FileIDLength = strutil.NanoIDLength
// FilePath returns the URL path a document's file is served under: the
// file route with the id followed by a dash and the file name, so the
@ -30,6 +30,24 @@ func FilePath(documentID xid.ID, id, name string) string {
return fmt.Sprintf(FilePathFormat, documentID, id+"-"+url.PathEscape(name))
}
// ParseFileRef cuts a "<id>-<file name>" route segment, as FilePath
// builds it, back into its id and name, reporting whether the segment
// has that shape. The id must be a nanoid: its charset shuts out the
// path separators and dot segments that would otherwise let an id
// escape its storage folder once joined into an object key.
func ParseFileRef(ref string) (id, name string, ok bool) {
if len(ref) <= FileIDLength || ref[FileIDLength] != '-' {
return "", "", false
}
id = ref[:FileIDLength]
if !strutil.IsNanoID(id) {
return "", "", false
}
return id, ref[FileIDLength+1:], true
}
// RootBlock represents a block in a document.
type RootBlock struct {
// Type is the type of the root block (always BlockNodeText's
@ -376,12 +394,11 @@ func (d *duplication) rewriteFileRef(attrs, newAttrs Attributes) {
return
}
if len(rest) <= FileIDLength || rest[FileIDLength] != '-' {
oldID, name, ok := ParseFileRef(rest)
if !ok {
return
}
oldID, name := rest[:FileIDLength], rest[FileIDLength+1:]
newID, ok := d.files[oldID]
if !ok {
newID = strutil.NanoID()

View File

@ -293,6 +293,36 @@ func Test_FilePath(t *testing.T) {
}
}
func Test_ParseFileRef(t *testing.T) {
cc := map[string]struct {
Ref string
ID string
Name string
OK bool
}{
"Id and name": {Ref: "f1xxxxxxxxxxxxxxxxxxx-notes.zip", ID: "f1xxxxxxxxxxxxxxxxxxx", Name: "notes.zip", OK: true},
"Name with dashes": {Ref: "f1xxxxxxxxxxxxxxxxxxx-a-b-c.zip", ID: "f1xxxxxxxxxxxxxxxxxxx", Name: "a-b-c.zip", OK: true},
"Id with dashes": {Ref: "f1-x_-xxxxxxxxxxxxxxx-notes.zip", ID: "f1-x_-xxxxxxxxxxxxxxx", Name: "notes.zip", OK: true},
"Empty name": {Ref: "f1xxxxxxxxxxxxxxxxxxx-", ID: "f1xxxxxxxxxxxxxxxxxxx", OK: true},
"No dash after the id": {Ref: "f1xxxxxxxxxxxxxxxxxxxxnotes.zip"},
"Id alone": {Ref: "f1xxxxxxxxxxxxxxxxxxx"},
"Short": {Ref: "f1-notes.zip"},
"Id with the wrong charset": {Ref: "f1.................xx-notes.zip"},
"Empty": {Ref: ""},
}
for cn, c := range cc {
t.Run(cn, func(t *testing.T) {
t.Parallel()
id, name, ok := ParseFileRef(c.Ref)
assert.Equal(t, c.OK, ok)
assert.Equal(t, c.ID, id)
assert.Equal(t, c.Name, name)
})
}
}
func Test_RootBlock_Duplicate(t *testing.T) {
t.Parallel()

View File

@ -238,98 +238,24 @@ func (s *Sender) deliver(ctx context.Context, msg *mail.Msg, toEmail, subject st
)
}
// SendEmailVerification sends an email regarding new email
// verification with the token, embedded into a full URL, to the
// specified email address.
func (s *Sender) SendEmailVerification(eml, link string) {
s.send(
eml,
"Verify your new email address",
TemplateEmailVerification,
map[string]string{
_linkKey: link,
},
)
}
// Send sends the given template to the recipient in d, rendered with the
// values d carries. Delivery runs on its own and never reports back: the
// only error is a template that cannot be sent.
func (s *Sender) Send(tmpl Template, d Data) error {
if err := tmpl.Validate(); err != nil {
return err
}
// SendEmailChangeConfirmation sends an email asking the current address to
// approve a change of address, with the approval link.
func (s *Sender) SendEmailChangeConfirmation(eml, link string) {
s.send(
eml,
"Approve your email address change",
TemplateEmailChangeConfirmation,
map[string]string{
_linkKey: link,
},
)
}
sp := _specs[tmpl]
// SendOrganizationInvitation sends an email regarding
// organization invitation with the token, embedded into a full URL, to the
// specified email address.
func (s *Sender) SendOrganizationInvitation(eml, org, link string) {
s.send(
eml,
fmt.Sprintf("Join %s on Oxynote", org),
TemplateOrganizationInvitation,
map[string]string{
_linkKey: link,
"organization": org,
},
)
}
subject := sp.subject
if sp.subjectFrom != nil {
subject = sp.subjectFrom(d)
}
// SendUserDeletionConfirmation sends an email to confirm account deletion
// with a verification link.
func (s *Sender) SendUserDeletionConfirmation(eml, link string) {
s.send(
eml,
"Confirm your account deletion",
TemplateUserDeletion,
map[string]string{
_linkKey: link,
},
)
}
s.send(d.Email, subject, tmpl, sp.args(d))
// SendPasswordReset sends an email with a password reset link to the
// specified email address.
func (s *Sender) SendPasswordReset(eml, link string) {
s.send(
eml,
"Reset your password",
TemplatePasswordReset,
map[string]string{
_linkKey: link,
},
)
}
// SendSignupVerification sends the initial account-activation email
// with a verification link to a freshly signed-up email address.
func (s *Sender) SendSignupVerification(eml, link string) {
s.send(
eml,
"Confirm your email address",
TemplateSignupVerification,
map[string]string{
_linkKey: link,
},
)
}
// SendAccountExists notifies the owner of an existing account that a
// signup was attempted with their email address, with a login link.
func (s *Sender) SendAccountExists(eml, link string) {
s.send(
eml,
"You already have an Oxynote account",
TemplateAccountExists,
map[string]string{
_linkKey: link,
},
)
return nil
}
// client is an interface that handles communication with the SMTP

View File

@ -221,119 +221,93 @@ func Test_Sender_send(t *testing.T) {
}
}
func Test_Sender_SendEmailVerification(t *testing.T) {
t.Parallel()
func Test_Sender_Send(t *testing.T) {
cc := map[string]struct {
Template Template
Data Data
Subject string
Contains []string
Err error
}{
"Unknown template": {
Template: Template("nonexistent"),
Data: Data{Email: "user@example.com"},
Err: ErrInvalidTemplate,
},
"Email verification": {
Template: TemplateEmailVerification,
Data: Data{Email: "user@example.com", Link: "https://example.com/verify"},
Subject: "Verify your new email address",
Contains: []string{"https://example.com/verify"},
},
"Email change confirmation": {
Template: TemplateEmailChangeConfirmation,
Data: Data{Email: "user@example.com", Link: "https://example.com/approve"},
Subject: "Approve your email address change",
Contains: []string{"https://example.com/approve"},
},
"Organization invitation": {
Template: TemplateOrganizationInvitation,
Data: Data{Email: "user@example.com", Organization: "Acme", Link: "https://example.com/join"},
Subject: "Join Acme on Oxynote",
Contains: []string{"https://example.com/join", "Acme"},
},
"User deletion confirmation": {
Template: TemplateUserDeletion,
Data: Data{Email: "user@example.com", Link: "https://example.com/delete"},
Subject: "Confirm your account deletion",
Contains: []string{"https://example.com/delete"},
},
"Password reset": {
Template: TemplatePasswordReset,
Data: Data{Email: "user@example.com", Link: "https://example.com/reset"},
Subject: "Reset your password",
Contains: []string{"https://example.com/reset"},
},
"Signup verification": {
Template: TemplateSignupVerification,
Data: Data{Email: "user@example.com", Link: "https://example.com/activate"},
Subject: "Confirm your email address",
Contains: []string{"https://example.com/activate"},
},
"Account exists": {
Template: TemplateAccountExists,
Data: Data{Email: "user@example.com", Link: "https://example.com/login"},
Subject: "You already have an Oxynote account",
Contains: []string{"https://example.com/login"},
},
}
client := &clientMock{}
s := stubSender(client)
for cn, c := range cc {
t.Run(cn, func(t *testing.T) {
t.Parallel()
s.SendEmailVerification("user@example.com", "https://example.com/verify")
client := &clientMock{}
s := stubSender(client)
s.supv.Wait()
err := s.Send(c.Template, c.Data)
msg := sentMsg(t, client)
assert.Equal(t, "user@example.com", msgTo(t, msg))
assert.Equal(t, []string{"Verify your new email address"}, msg.GetGenHeader(mail.HeaderSubject))
assert.Contains(t, msgBody(t, msg), "https://example.com/verify")
}
s.supv.Wait()
func Test_Sender_SendEmailChangeConfirmation(t *testing.T) {
t.Parallel()
testutil.AssertEqualError(t, c.Err, err)
client := &clientMock{}
s := stubSender(client)
if err != nil {
assert.Empty(t, client.DialAndSendWithContextCalls())
s.SendEmailChangeConfirmation("user@example.com", "https://example.com/approve")
return
}
s.supv.Wait()
msg := sentMsg(t, client)
assert.Equal(t, c.Data.Email, msgTo(t, msg))
assert.Equal(t, []string{c.Subject}, msg.GetGenHeader(mail.HeaderSubject))
msg := sentMsg(t, client)
assert.Equal(t, "user@example.com", msgTo(t, msg))
assert.Equal(t, []string{"Approve your email address change"}, msg.GetGenHeader(mail.HeaderSubject))
assert.Contains(t, msgBody(t, msg), "https://example.com/approve")
}
body := msgBody(t, msg)
func Test_Sender_SendOrganizationInvitation(t *testing.T) {
t.Parallel()
client := &clientMock{}
s := stubSender(client)
s.SendOrganizationInvitation("user@example.com", "Acme", "https://example.com/join")
s.supv.Wait()
msg := sentMsg(t, client)
assert.Equal(t, "user@example.com", msgTo(t, msg))
assert.Equal(t, []string{"Join Acme on Oxynote"}, msg.GetGenHeader(mail.HeaderSubject))
body := msgBody(t, msg)
assert.Contains(t, body, "https://example.com/join")
assert.Contains(t, body, "Acme")
}
func Test_Sender_SendUserDeletionConfirmation(t *testing.T) {
t.Parallel()
client := &clientMock{}
s := stubSender(client)
s.SendUserDeletionConfirmation("user@example.com", "https://example.com/delete")
s.supv.Wait()
msg := sentMsg(t, client)
assert.Equal(t, "user@example.com", msgTo(t, msg))
assert.Equal(t, []string{"Confirm your account deletion"}, msg.GetGenHeader(mail.HeaderSubject))
assert.Contains(t, msgBody(t, msg), "https://example.com/delete")
}
func Test_Sender_SendPasswordReset(t *testing.T) {
t.Parallel()
client := &clientMock{}
s := stubSender(client)
s.SendPasswordReset("user@example.com", "https://example.com/reset")
s.supv.Wait()
msg := sentMsg(t, client)
assert.Equal(t, "user@example.com", msgTo(t, msg))
assert.Equal(t, []string{"Reset your password"}, msg.GetGenHeader(mail.HeaderSubject))
assert.Contains(t, msgBody(t, msg), "https://example.com/reset")
}
func Test_Sender_SendSignupVerification(t *testing.T) {
t.Parallel()
client := &clientMock{}
s := stubSender(client)
s.SendSignupVerification("user@example.com", "https://example.com/activate")
s.supv.Wait()
msg := sentMsg(t, client)
assert.Equal(t, "user@example.com", msgTo(t, msg))
assert.Equal(t, []string{"Confirm your email address"}, msg.GetGenHeader(mail.HeaderSubject))
assert.Contains(t, msgBody(t, msg), "https://example.com/activate")
}
func Test_Sender_SendAccountExists(t *testing.T) {
t.Parallel()
client := &clientMock{}
s := stubSender(client)
s.SendAccountExists("user@example.com", "https://example.com/login")
s.supv.Wait()
msg := sentMsg(t, client)
assert.Equal(t, "user@example.com", msgTo(t, msg))
assert.Equal(t, []string{"You already have an Oxynote account"}, msg.GetGenHeader(mail.HeaderSubject))
assert.Contains(t, msgBody(t, msg), "https://example.com/login")
for _, sub := range c.Contains {
assert.Contains(t, body, sub)
}
})
}
}
func Test_Sender_Close(t *testing.T) {
@ -350,8 +324,7 @@ func Test_Sender_Close(t *testing.T) {
}
s := stubSender(client)
s.SendPasswordReset("user@example.com", "https://example.com/reset")
require.NoError(t, s.Send(TemplatePasswordReset, Data{Email: "user@example.com", Link: "https://example.com/reset"}))
require.NoError(t, s.Close())
// Close must drain the in-flight delivery before cancelling the

View File

@ -4,8 +4,11 @@ import (
"embed"
"fmt"
"html"
"net/http"
"strings"
texttemplate "text/template"
"github.com/oxynote/oxynote/server/core/pkg/errutil"
)
// All available email template constants.
@ -38,9 +41,48 @@ const (
TemplateSignupVerification Template = "signup_verification"
)
// Template identifies an email template. Its value matches the base
// name of the embedded HTML file the template is rendered from.
type Template string
// ErrInvalidTemplate is returned when the requested email template is
// not recognized.
var ErrInvalidTemplate = errutil.New(http.StatusBadRequest, "email.invalid_template", "Invalid email template.")
// _specs maps every template to the subject line it is sent under and
// the arguments its HTML file reads. A template absent from the map
// cannot be sent.
var _specs = map[Template]spec{
TemplateEmailVerification: {
subject: "Verify your new email address",
args: linkArgs,
},
TemplateEmailChangeConfirmation: {
subject: "Approve your email address change",
args: linkArgs,
},
TemplateOrganizationInvitation: {
subjectFrom: func(d Data) string { return fmt.Sprintf("Join %s on Oxynote", d.Organization) },
args: func(d Data) map[string]string {
return map[string]string{
_linkKey: d.Link,
"organization": d.Organization,
}
},
},
TemplateUserDeletion: {
subject: "Confirm your account deletion",
args: linkArgs,
},
TemplatePasswordReset: {
subject: "Reset your password",
args: linkArgs,
},
TemplateSignupVerification: {
subject: "Confirm your email address",
args: linkArgs,
},
TemplateAccountExists: {
subject: "You already have an Oxynote account",
args: linkArgs,
},
}
//go:embed templates/*.html
var _templateFS embed.FS
@ -64,6 +106,53 @@ var _templates = texttemplate.Must(
texttemplate.ParseFS(_templateFS, "templates/*.html"),
)
// Template identifies an email template. Its value matches the base
// name of the embedded HTML file the template is rendered from.
type Template string
// Validate reports whether the template is one that can be sent.
func (t Template) Validate() error {
if _, ok := _specs[t]; !ok {
return ErrInvalidTemplate
}
return nil
}
// Data carries what a template is rendered with: the recipient and the
// values the templates read. A template ignores the fields it has no
// use for.
type Data struct {
// Email is the recipient address.
Email string `json:"email"`
// Organization is the name of the organization an invitation is to.
Organization string `json:"organization"`
// Link is the action URL the email carries.
Link string `json:"link"`
}
// spec describes how a template is sent: its subject line and the
// arguments its HTML file reads.
type spec struct {
// subject is the subject line, when it is the same for every send.
subject string
// subjectFrom builds the subject line from the data, for a template
// whose subject names something in it. It takes precedence over
// subject.
subjectFrom func(Data) string
// args builds the template arguments.
args func(Data) map[string]string
}
// linkArgs builds the arguments of a template that reads only the link.
func linkArgs(d Data) map[string]string {
return map[string]string{_linkKey: d.Link}
}
// render executes the specified template with the provided arguments,
// HTML-escaping each argument value, and returns the resulting HTML
// body.

View File

@ -8,6 +8,31 @@ import (
"github.com/stretchr/testify/require"
)
func Test_Template_Validate(t *testing.T) {
cc := map[string]struct {
Template Template
Err error
}{
"Email verification": {Template: TemplateEmailVerification},
"Email change confirmation": {Template: TemplateEmailChangeConfirmation},
"Organization invitation": {Template: TemplateOrganizationInvitation},
"User deletion": {Template: TemplateUserDeletion},
"Password reset": {Template: TemplatePasswordReset},
"Signup verification": {Template: TemplateSignupVerification},
"Account exists": {Template: TemplateAccountExists},
"Unknown template": {Template: Template("nonexistent"), Err: ErrInvalidTemplate},
"Empty template": {Err: ErrInvalidTemplate},
}
for cn, c := range cc {
t.Run(cn, func(t *testing.T) {
t.Parallel()
testutil.AssertEqualError(t, c.Err, c.Template.Validate())
})
}
}
func Test_render(t *testing.T) {
cc := map[string]struct {
Template Template

View File

@ -0,0 +1,20 @@
// Package org defines what an organization owns outside of Postgres: the
// storage layout of its logo and the URL the logo is served under.
package org
import "fmt"
// _logoFolderFormat is the storage folder format for an organization's
// logo.
const _logoFolderFormat = "organizations/%s/logo"
// LogoPath is the URL path an organization's logo is served under. The
// router mounts the logo routes on this very path, and the value is stored
// on the organization row, so the two must not drift apart.
const LogoPath = "/api/organizations/logo"
// LogoFolder returns the storage folder holding the given organization's
// logo, which is stored under the organization's own id.
func LogoFolder(organizationID string) string {
return fmt.Sprintf(_logoFolderFormat, organizationID)
}

View File

@ -0,0 +1,18 @@
package org
import (
"testing"
"github.com/stretchr/testify/assert"
"go.uber.org/goleak"
)
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m)
}
func Test_LogoFolder(t *testing.T) {
t.Parallel()
assert.Equal(t, "organizations/org1/logo", LogoFolder("org1"))
}

View File

@ -4,15 +4,23 @@ import (
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"unicode/utf8"
"github.com/blevesearch/bleve/v2"
bleveSearch "github.com/blevesearch/bleve/v2/search"
"github.com/blevesearch/bleve/v2/search/query"
"github.com/oxynote/oxynote/server/core/pkg/errutil"
"github.com/rs/xid"
)
const (
// MaxQueryLength caps a search query in characters. Every term
// expands to a dictionary walk inside the process, so the query size
// bounds the work one request can ask for.
MaxQueryLength = 200
// _searchResultLimit caps the number of hits the search endpoint
// returns.
_searchResultLimit = 30
@ -45,6 +53,19 @@ const (
_maxTermSearchers = 1024
)
// ErrInvalidQuery is returned when a search query is empty or over
// MaxQueryLength.
var ErrInvalidQuery = errutil.New(http.StatusBadRequest, "document.invalid_search_query", "invalid search query")
// ValidateQuery reports whether the query is one the index will run.
func ValidateQuery(q string) error {
if q == "" || utf8.RuneCountInString(q) > MaxQueryLength {
return ErrInvalidQuery
}
return nil
}
// SearchDocuments searches the organization's entries and returns them as
// the search endpoint's JSON: the matched terms wrapped in mark tags and
// the text cropped to a fragment around the best match.
@ -72,6 +93,10 @@ func (i *Index) SearchDocumentBlocks(ctx context.Context, organizationID, q stri
// search runs the query and decodes the hits.
func (i *Index) search(ctx context.Context, organizationID, q string, limit int, highlight bool) ([]Block, error) {
if err := ValidateQuery(q); err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(ctx, _searchTimeout)
defer cancel()

View File

@ -9,6 +9,7 @@ import (
"testing"
"github.com/blevesearch/bleve/v2/search/query"
"github.com/oxynote/oxynote/server/core/pkg/testutil"
"github.com/rs/xid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@ -141,6 +142,26 @@ func Test_Index_SearchDocumentBlocks(t *testing.T) {
assert.Nil(t, blocks)
}
func Test_ValidateQuery(t *testing.T) {
cc := map[string]struct {
Query string
Err error
}{
"Empty query": {Err: ErrInvalidQuery},
"Overlong query": {Query: strings.Repeat("a", MaxQueryLength+1), Err: ErrInvalidQuery},
"Query at the cap": {Query: strings.Repeat("é", MaxQueryLength)},
"Plain query": {Query: "deploy"},
}
for cn, c := range cc {
t.Run(cn, func(t *testing.T) {
t.Parallel()
testutil.AssertEqualError(t, c.Err, ValidateQuery(c.Query))
})
}
}
func Test_Index_search(t *testing.T) {
t.Parallel()
@ -152,7 +173,14 @@ func Test_Index_search(t *testing.T) {
Limit int
Highlight bool
Check func(t *testing.T, blocks []Block)
Err error
}{
"Overlong query": {
OrganizationID: "org-1",
Query: strings.Repeat("a", MaxQueryLength+1),
Limit: 10,
Err: ErrInvalidQuery,
},
"Stemming matches another form of the word": {
OrganizationID: "org-1",
Query: "deploying",
@ -330,7 +358,11 @@ func Test_Index_search(t *testing.T) {
t.Parallel()
blocks, err := idx.search(context.Background(), c.OrganizationID, c.Query, c.Limit, c.Highlight)
require.NoError(t, err)
testutil.AssertEqualError(t, c.Err, err)
if err != nil {
return
}
c.Check(t, blocks)
})

View File

@ -126,6 +126,16 @@ func RequireSession(log *slog.Logger, w http.ResponseWriter, r *http.Request) (S
return session, true
}
// HasActiveOrganization reports whether a validated identity may reach
// the handlers: a user together with the organization their work is
// scoped to. A session without an active organization (e.g. right after
// signup, before one is selected) must not: every query is scoped by the
// organization id, and a write would create rows under an empty one. The
// cookie session path and the MCP bearer verifier both ask it.
func HasActiveOrganization(userID, organizationID string) bool {
return userID != "" && organizationID != ""
}
// getSession forwards the request to the Better Auth server to retrieve session information.
func getSession(ctx context.Context, client *http.Client, url string, cookies []*http.Cookie) (Session, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody)
@ -161,11 +171,7 @@ func getSession(ctx context.Context, client *http.Client, url string, cookies []
return Session{}, err
}
// a session without an active organization (e.g. right after signup,
// before one is selected) must not reach the handlers: every query is
// scoped by the organization id, and a write would create rows under an
// empty one. The MCP bearer verifier applies the same rule.
if payload.Session.UserID == "" || payload.Session.ActiveOrganizationID == "" {
if !HasActiveOrganization(payload.Session.UserID, payload.Session.ActiveOrganizationID) {
return Session{}, httpserver.ErrNotAuthenticated
}

View File

@ -185,6 +185,27 @@ func Test_Middleware(t *testing.T) {
}
}
func Test_HasActiveOrganization(t *testing.T) {
cc := map[string]struct {
UserID string
OrganizationID string
Result bool
}{
"User and organization": {UserID: "u1", OrganizationID: "org1", Result: true},
"Empty user": {OrganizationID: "org1"},
"Empty organization": {UserID: "u1"},
"Both empty": {},
}
for cn, c := range cc {
t.Run(cn, func(t *testing.T) {
t.Parallel()
assert.Equal(t, c.Result, HasActiveOrganization(c.UserID, c.OrganizationID))
})
}
}
func Test_FilterOrganization(t *testing.T) {
t.Parallel()

View File

@ -247,6 +247,9 @@ func newRunnerMock(clients *clientMocks) *runnerMock {
m := &runnerMock{clientMocks: clients}
m.runner = &datasourceMock.Runner{
TypeFunc: func() datasourceCore.Type {
return m.dataSources[len(m.dataSources)-1].Type
},
TestConnectionFunc: func(context.Context) (processor.ConnectionStatus, error) {
return clients.status, clients.statusErr
},

View File

@ -6,7 +6,6 @@ import (
datasourceCore "github.com/oxynote/oxynote/server/core/internal/datasource"
"github.com/oxynote/oxynote/server/core/internal/datasource/processor"
"github.com/oxynote/oxynote/server/core/internal/server/internal/auth"
"github.com/oxynote/oxynote/server/core/pkg/errutil"
"github.com/oxynote/oxynote/server/core/pkg/httpserver"
)
@ -37,103 +36,22 @@ func (h *Handler) QueryDataSource(w http.ResponseWriter, r *http.Request) {
}
ct := processor.ChartType(r.URL.Query().Get("chartType"))
if !ct.IsValid() {
httpserver.RespondError(h.log, w, errutil.New(http.StatusBadRequest, "chart_type.invalid", "Invalid chart type. Must be one of: line, bar, gauge."))
if err = ct.Validate(); err != nil {
httpserver.RespondError(h.log, w, err)
return
}
switch ds.Type {
case datasourceCore.TypePrometheus:
h.queryPrometheusGeneric(w, r, ds, query, ct)
case datasourceCore.TypePostgreSQL:
h.queryPostgreSQLGeneric(w, r, ds, query, ct)
case datasourceCore.TypeMariaDB, datasourceCore.TypeMySQL:
h.queryMySQLGeneric(w, r, ds, query, ct)
default:
httpserver.RespondError(h.log, w, errutil.New(http.StatusBadRequest, "data_source.type_not_supported", "Generic query is not supported for this data source type."))
}
}
// queryPrometheusGeneric executes a Prometheus query and returns a unified QueryResult.
func (h *Handler) queryPrometheusGeneric(w http.ResponseWriter, r *http.Request, ds *datasourceCore.DataSource, query string, ct processor.ChartType) {
tr, err := processor.ParseTimeRange(false, r.URL.Query())
if err != nil {
httpserver.RespondError(h.log, w, err)
return
}
client, err := h.runners.Runner(*ds).Prometheus(r.Context())
res, err := datasourceCore.Query(r.Context(), h.runners.Runner(*ds), query, *tr, ct)
if err != nil {
httpserver.RespondError(h.log, w, err)
return
}
result, err := client.QueryRange(r.Context(), query, *tr)
if err != nil {
httpserver.RespondError(h.log, w, err)
return
}
if result == nil {
httpserver.Respond(h.log, w, &processor.QueryResult{Status: processor.QueryStatusNoData}, http.StatusOK)
return
}
httpserver.Respond(h.log, w, result.Transform(ct), http.StatusOK)
}
// queryMySQLGeneric executes a MySQL query and returns a unified QueryResult.
func (h *Handler) queryMySQLGeneric(w http.ResponseWriter, r *http.Request, ds *datasourceCore.DataSource, query string, ct processor.ChartType) {
tr, err := processor.ParseTimeRange(false, r.URL.Query())
if err != nil {
httpserver.RespondError(h.log, w, err)
return
}
client, err := h.runners.Runner(*ds).MySQL(r.Context())
if err != nil {
httpserver.RespondError(h.log, w, err)
return
}
result, err := client.Query(r.Context(), query, *tr)
if err != nil {
httpserver.RespondError(h.log, w, err)
return
}
if result == nil {
httpserver.Respond(h.log, w, &processor.QueryResult{Status: processor.QueryStatusNoData}, http.StatusOK)
return
}
httpserver.Respond(h.log, w, result.Transform(ct), http.StatusOK)
}
// queryPostgreSQLGeneric executes a PostgreSQL query and returns a unified QueryResult.
func (h *Handler) queryPostgreSQLGeneric(w http.ResponseWriter, r *http.Request, ds *datasourceCore.DataSource, query string, ct processor.ChartType) {
tr, err := processor.ParseTimeRange(false, r.URL.Query())
if err != nil {
httpserver.RespondError(h.log, w, err)
return
}
client, err := h.runners.Runner(*ds).PostgreSQL(r.Context())
if err != nil {
httpserver.RespondError(h.log, w, err)
return
}
result, err := client.Query(r.Context(), query, *tr)
if err != nil {
httpserver.RespondError(h.log, w, err)
return
}
if result == nil {
httpserver.Respond(h.log, w, &processor.QueryResult{Status: processor.QueryStatusNoData}, http.StatusOK)
return
}
httpserver.Respond(h.log, w, result.Transform(ct), http.StatusOK)
httpserver.Respond(h.log, w, res, http.StatusOK)
}

View File

@ -119,6 +119,15 @@ func Test_Handler_QueryDataSource(t *testing.T) {
wasExecutorQueryCalled(0, 0, 0),
),
},
"Invalid time range": {
DB: stubDB(stubDataSource(datasourceCore.TypePrometheus), nil),
Target: "http://test.com/?q=up&chartType=line_chart&from=bogus",
ID: _testID.String(),
Checks: checks(
hasResp(http.StatusBadRequest, `{"code":"from.invalid","message":"From parameter must be a valid RFC3339 timestamp."}`),
wasExecutorQueryCalled(0, 0, 0),
),
},
"Unsupported data source type": {
DB: stubDB(stubDataSource(datasourceCore.Type("bogus")), nil),
Target: _testGenericQueryTarget,
@ -128,6 +137,46 @@ func Test_Handler_QueryDataSource(t *testing.T) {
wasExecutorQueryCalled(0, 0, 0),
),
},
"Error returned by the query": {
DB: stubDB(stubDataSource(datasourceCore.TypePrometheus), nil),
Clients: stubPrometheusQueryClients("", nil, assert.AnError),
Target: _testGenericQueryTarget,
ID: _testID.String(),
Checks: checks(
hasResp(http.StatusInternalServerError, `{"code":"general","message":"internal server error"}`),
wasExecutorQueryCalled(1, 0, 0),
),
},
"A connection the data source refused": {
DB: stubDB(stubDataSource(datasourceCore.TypePrometheus), nil),
Clients: stubPrometheusQueryClients(processor.ConnectionStatusUnreachable, nil, nil),
Target: _testGenericQueryTarget,
ID: _testID.String(),
Checks: checks(
hasResp(http.StatusBadRequest, `{"code":"data_source.unreachable","message":"The data source is unreachable."}`),
wasExecutorQueryCalled(0, 0, 0),
),
},
"Successful query": {
DB: stubDB(stubDataSource(datasourceCore.TypePrometheus), nil),
Clients: stubPrometheusQueryClients(processor.ConnectionStatusSuccess, &processor.PrometheusQueryResult{
Type: model.ValMatrix,
Result: model.Matrix{
&model.SampleStream{
Metric: model.Metric{"job": "a"},
Values: []model.SamplePair{
{Timestamp: model.Time(1700000000000), Value: 10},
},
},
},
}, nil),
Target: _testGenericQueryTarget,
ID: _testID.String(),
Checks: checks(
hasResp(http.StatusOK, `{"status":"ok","data":[{"labels":{"job":"a"},"metrics":[[1700000000,10]]}]}`),
wasExecutorQueryCalled(1, 0, 0),
),
},
"Prometheus dispatch": {
DB: stubDB(stubDataSource(datasourceCore.TypePrometheus), nil),
Clients: stubPrometheusQueryClients(processor.ConnectionStatusSuccess, nil, nil),
@ -187,214 +236,3 @@ func Test_Handler_QueryDataSource(t *testing.T) {
})
}
}
func Test_Handler_queryPrometheusGeneric(t *testing.T) {
cc := map[string]struct {
DB *DBMock
Clients *clientMocks
Target string
Checks []check
}{
"Invalid time range": {
Target: "http://test.com/?from=bogus",
Checks: checks(
hasResp(http.StatusBadRequest, `{"code":"from.invalid","message":"From parameter must be a valid RFC3339 timestamp."}`),
),
},
"Error returned by executor.PrometheusQuery": {
Clients: stubPrometheusQueryClients("", nil, assert.AnError),
Target: _testQueryTarget,
Checks: checks(
hasResp(http.StatusInternalServerError, `{"code":"general","message":"internal server error"}`),
wasDBUpdateDataSourceCalled(0),
),
},
"A connection the data source refused": {
Clients: stubPrometheusQueryClients(processor.ConnectionStatusUnreachable, nil, nil),
Target: _testQueryTarget,
Checks: checks(
hasResp(http.StatusBadRequest, `{"code":"data_source.unreachable","message":"The data source is unreachable."}`),
wasDBUpdateDataSourceCalled(0),
),
},
"Nil result returns no-data": {
Clients: stubPrometheusQueryClients(processor.ConnectionStatusSuccess, nil, nil),
Target: _testQueryTarget,
Checks: checks(
hasResp(http.StatusOK, `{"status":"no-data"}`),
wasDBUpdateDataSourceCalled(0),
),
},
"Successful query": {
Clients: stubPrometheusQueryClients(processor.ConnectionStatusSuccess, &processor.PrometheusQueryResult{
Type: model.ValMatrix,
Result: model.Matrix{
&model.SampleStream{
Metric: model.Metric{"job": "a"},
Values: []model.SamplePair{
{Timestamp: model.Time(1700000000000), Value: 10},
},
},
},
}, nil),
Target: _testQueryTarget,
Checks: checks(
hasResp(http.StatusOK, `{"status":"ok","data":[{"labels":{"job":"a"},"metrics":[[1700000000,10]]}]}`),
wasDBUpdateDataSourceCalled(0),
),
},
}
for cn, c := range cc {
t.Run(cn, func(t *testing.T) {
t.Parallel()
hdl, db, exec, logs := prepHandler(c.DB, c.Clients)
req := prepRequest("GET", c.Target, "", true, "")
rec := httptest.NewRecorder()
hdl.queryPrometheusGeneric(rec, req, stubDataSource(datasourceCore.TypePrometheus), "up", processor.ChartTypeLine)
for _, ch := range c.Checks {
ch(t, db, exec, rec, logs)
}
})
}
}
func Test_Handler_queryMySQLGeneric(t *testing.T) {
cc := map[string]struct {
DB *DBMock
Clients *clientMocks
Target string
Checks []check
}{
"Invalid time range": {
Target: "http://test.com/?from=bogus",
Checks: checks(
hasResp(http.StatusBadRequest, `{"code":"from.invalid","message":"From parameter must be a valid RFC3339 timestamp."}`),
),
},
"Error returned by executor.MySQLQuery": {
Clients: stubMySQLQueryClients("", nil, assert.AnError),
Target: _testQueryTarget,
Checks: checks(
hasResp(http.StatusInternalServerError, `{"code":"general","message":"internal server error"}`),
wasDBUpdateDataSourceCalled(0),
),
},
"A connection the data source refused": {
Clients: stubMySQLQueryClients(processor.ConnectionStatusUnreachable, nil, nil),
Target: _testQueryTarget,
Checks: checks(
hasResp(http.StatusBadRequest, `{"code":"data_source.unreachable","message":"The data source is unreachable."}`),
wasDBUpdateDataSourceCalled(0),
),
},
"Nil result returns no-data": {
Clients: stubMySQLQueryClients(processor.ConnectionStatusSuccess, nil, nil),
Target: _testQueryTarget,
Checks: checks(
hasResp(http.StatusOK, `{"status":"no-data"}`),
wasDBUpdateDataSourceCalled(0),
),
},
"Successful query": {
Clients: stubMySQLQueryClients(processor.ConnectionStatusSuccess, &processor.MySQLQueryResult{
Columns: []string{"time", "value"},
Rows: [][]any{{int64(1700000000), float64(10)}},
}, nil),
Target: _testQueryTarget,
Checks: checks(
hasResp(http.StatusOK, `{"status":"ok","data":[{"labels":{},"metrics":[[1700000000,10]]}]}`),
wasDBUpdateDataSourceCalled(0),
),
},
}
for cn, c := range cc {
t.Run(cn, func(t *testing.T) {
t.Parallel()
hdl, db, exec, logs := prepHandler(c.DB, c.Clients)
req := prepRequest("GET", c.Target, "", true, "")
rec := httptest.NewRecorder()
hdl.queryMySQLGeneric(rec, req, stubDataSource(datasourceCore.TypeMySQL), "SELECT 1", processor.ChartTypeLine)
for _, ch := range c.Checks {
ch(t, db, exec, rec, logs)
}
})
}
}
func Test_Handler_queryPostgreSQLGeneric(t *testing.T) {
cc := map[string]struct {
DB *DBMock
Clients *clientMocks
Target string
Checks []check
}{
"Invalid time range": {
Target: "http://test.com/?from=bogus",
Checks: checks(
hasResp(http.StatusBadRequest, `{"code":"from.invalid","message":"From parameter must be a valid RFC3339 timestamp."}`),
),
},
"Error returned by executor.PostgreSQLQuery": {
Clients: stubPostgreSQLQueryClients("", nil, assert.AnError),
Target: _testQueryTarget,
Checks: checks(
hasResp(http.StatusInternalServerError, `{"code":"general","message":"internal server error"}`),
wasDBUpdateDataSourceCalled(0),
),
},
"A connection the data source refused": {
Clients: stubPostgreSQLQueryClients(processor.ConnectionStatusUnreachable, nil, nil),
Target: _testQueryTarget,
Checks: checks(
hasResp(http.StatusBadRequest, `{"code":"data_source.unreachable","message":"The data source is unreachable."}`),
wasDBUpdateDataSourceCalled(0),
),
},
"Nil result returns no-data": {
Clients: stubPostgreSQLQueryClients(processor.ConnectionStatusSuccess, nil, nil),
Target: _testQueryTarget,
Checks: checks(
hasResp(http.StatusOK, `{"status":"no-data"}`),
wasDBUpdateDataSourceCalled(0),
),
},
"Successful query": {
Clients: stubPostgreSQLQueryClients(processor.ConnectionStatusSuccess, &processor.PostgreSQLQueryResult{
Columns: []string{"time", "value"},
Rows: [][]any{{1700000000.0, 10.0}},
}, nil),
Target: _testQueryTarget,
Checks: checks(
hasResp(http.StatusOK, `{"status":"ok","data":[{"labels":{},"metrics":[[1700000000,10]]}]}`),
wasDBUpdateDataSourceCalled(0),
),
},
}
for cn, c := range cc {
t.Run(cn, func(t *testing.T) {
t.Parallel()
hdl, db, exec, logs := prepHandler(c.DB, c.Clients)
req := prepRequest("GET", c.Target, "", true, "")
rec := httptest.NewRecorder()
hdl.queryPostgreSQLGeneric(rec, req, stubDataSource(datasourceCore.TypePostgreSQL), "SELECT 1", processor.ChartTypeLine)
for _, ch := range c.Checks {
ch(t, db, exec, rec, logs)
}
})
}
}

View File

@ -5,7 +5,6 @@ import (
"errors"
"log/slog"
"net/http"
"slices"
"github.com/guregu/null/v5"
"github.com/oxynote/oxynote/server/core/internal/apps/webchange"
@ -14,7 +13,6 @@ import (
"github.com/oxynote/oxynote/server/core/internal/document/hook"
"github.com/oxynote/oxynote/server/core/internal/search"
"github.com/oxynote/oxynote/server/core/internal/server/internal/auth"
"github.com/oxynote/oxynote/server/core/pkg/errutil"
"github.com/oxynote/oxynote/server/core/pkg/httpserver"
"github.com/oxynote/oxynote/server/core/pkg/logutil"
"github.com/oxynote/oxynote/server/core/pkg/timeutil"
@ -61,10 +59,8 @@ func (h *Handler) UpdateDocumentBranch(w http.ResponseWriter, r *http.Request) {
return
}
// the main branch is found by name in the tree and content queries, and
// a rename would make the document look nameless and contentless.
if doc.Default && ui.Name.Valid && ui.Name.String != doc.BranchName {
httpserver.RespondError(h.log, w, ErrDefaultBranchRename)
if err := doc.AllowsBranchRename(ui.Name); err != nil {
httpserver.RespondError(h.log, w, err)
return
}
@ -233,17 +229,7 @@ func (h *Handler) UpdateDocumentBranchByIDUnsafe(w http.ResponseWriter, r *http.
return
}
// the update carries the editors of one persist, so the set is only
// ever added to. Diffing it against the stored maintainers would drop
// everyone who happens not to be editing right now.
var maintainersAdded bool
for _, maintainer := range ui.Maintainers {
if !slices.Contains(maintainers, maintainer) {
maintainersAdded = true
break
}
}
maintainersAdded := ui.AddsMaintainers(maintainers)
if maintainersAdded {
if err = tx.UpsertDocumentMaintainers(
@ -312,11 +298,8 @@ func (h *Handler) MergeBranches(w http.ResponseWriter, r *http.Request) {
return
}
// a self-merge would soft-delete the branch's hooks and comments and
// then copy the hooks back from the same, now hook-less, branch —
// permanently destroying both.
if inp.FromBranchID == inp.ToBranchID {
httpserver.RespondError(h.log, w, errutil.New(http.StatusBadRequest, "document.branch_self_merge", "cannot merge a branch into itself"))
if err = documentCore.AllowsMerge(inp.FromBranchID, inp.ToBranchID); err != nil {
httpserver.RespondError(h.log, w, err)
return
}
@ -568,8 +551,8 @@ func (h *Handler) DeleteDocumentBranch(w http.ResponseWriter, r *http.Request) {
return
}
if branchDoc.Default {
httpserver.RespondError(h.log, w, errutil.New(http.StatusConflict, "document.default_branch", "cannot delete the default branch"))
if err = branchDoc.AllowsBranchDelete(); err != nil {
httpserver.RespondError(h.log, w, err)
return
}
@ -580,7 +563,7 @@ func (h *Handler) DeleteDocumentBranch(w http.ResponseWriter, r *http.Request) {
}
if count <= 1 {
httpserver.RespondError(h.log, w, errutil.New(http.StatusConflict, "document.last_branch", "cannot delete the last branch"))
httpserver.RespondError(h.log, w, documentCore.ErrLastBranchDelete)
return
}

View File

@ -5,7 +5,6 @@ import (
"context"
"log/slog"
"net/http"
"slices"
"github.com/oxynote/oxynote/server/core/internal/document"
commentCore "github.com/oxynote/oxynote/server/core/internal/document/comment"
@ -124,17 +123,7 @@ func (h *Handler) CreateDocumentComment(w http.ResponseWriter, r *http.Request)
maintainers = nil
}
var userIDs []string
for _, userID := range maintainers {
if userID == session.UserID {
continue
}
if !slices.Contains(userIDs, userID) {
userIDs = append(userIDs, userID)
}
}
userIDs := commentCore.Recipients(maintainers, session.UserID)
if len(userIDs) > 0 {
h.notifPub.PublishNotifications(
@ -219,22 +208,6 @@ func (h *Handler) CreateDocumentCommentReply(w http.ResponseWriter, r *http.Requ
})
}
var userIDs []string
if c.UserID.Valid && !slices.Contains(userIDs, c.UserID.String) && c.UserID.String != session.UserID {
userIDs = append(userIDs, c.UserID.String)
}
for _, r := range c.Replies {
if !r.UserID.Valid || r.UserID.String == session.UserID {
continue
}
if !slices.Contains(userIDs, r.UserID.String) {
userIDs = append(userIDs, r.UserID.String)
}
}
h.notifPub.PublishNotifications(
session.ActiveOrganizationID,
notification.NewDocumentNewCommentReplyNotification(
@ -245,7 +218,7 @@ func (h *Handler) CreateDocumentCommentReply(w http.ResponseWriter, r *http.Requ
c.AnchorBlockID,
c.BranchID,
),
userIDs...,
commentCore.Recipients(c.Participants(), session.UserID)...,
)
httpserver.Respond(

View File

@ -2,17 +2,13 @@ package comment
import (
"context"
"time"
"github.com/oxynote/oxynote/server/core/internal/server/internal/auth"
"github.com/oxynote/oxynote/server/core/pkg/httpserver"
"github.com/oxynote/wetsocks/wsserver"
"github.com/rs/xid"
)
// _publishTimeout bounds each WebSocket publish triggered by a domain
// callback.
const _publishTimeout = 5 * time.Second
// ChangeType represents the type of comment change.
type ChangeType string
@ -39,7 +35,7 @@ type ChangeMessage struct {
// BindCommentsChange binds a comment change event to the given topic.
func (h *Handler) BindCommentsChange(tpc wsserver.Topic) {
h.comments.changeCallback = func(organizationID string, documentId xid.ID, msg ChangeMessage) {
ctx, cancel := context.WithTimeout(context.Background(), _publishTimeout)
ctx, cancel := context.WithTimeout(context.Background(), httpserver.WSPublishTimeout)
defer cancel()
tpc.PublishMany(ctx, msg, auth.FilterOrganizationDocument(organizationID, documentId))

View File

@ -7,7 +7,6 @@ import (
"net/http"
"net/url"
"path"
"unicode/utf8"
"github.com/guregu/null/v5"
"github.com/oxynote/oxynote/server/core/internal/apps/github"
@ -26,21 +25,10 @@ import (
"github.com/rs/xid"
)
// _searchQueryMaxLength caps a search query in characters. Every term
// expands to a dictionary walk inside the process, so the query size
// bounds the work one request can ask for.
const _searchQueryMaxLength = 200
// ErrInvalidSearchQuery is returned when the search query is invalid.
var ErrInvalidSearchQuery = errutil.New(http.StatusBadRequest, "document.invalid_search_query", "invalid search query")
// ErrBranchMismatch is returned when the requested branch does not belong to
// the document identified by the request path.
var ErrBranchMismatch = errutil.New(http.StatusNotFound, "document.branch_mismatch", "branch does not belong to the document")
// ErrDefaultBranchRename is returned when a rename targets the default branch.
var ErrDefaultBranchRename = errutil.New(http.StatusBadRequest, "document.default_branch_rename", "the default branch cannot be renamed")
// ErrInvalidDocumentParent is returned when a document would be moved under
// itself or one of its own descendants.
var ErrInvalidDocumentParent = errutil.New(http.StatusBadRequest, "document.invalid_parent", "document cannot be moved under itself or its descendant")
@ -521,18 +509,7 @@ func (h *Handler) SearchDocuments(w http.ResponseWriter, r *http.Request) {
return
}
q := r.URL.Query().Get("q")
if q == "" || utf8.RuneCountInString(q) > _searchQueryMaxLength {
httpserver.RespondError(
h.log,
w,
ErrInvalidSearchQuery,
)
return
}
data, err := h.searcher.SearchDocuments(r.Context(), session.ActiveOrganizationID, q)
data, err := h.searcher.SearchDocuments(r.Context(), session.ActiveOrganizationID, r.URL.Query().Get("q"))
if err != nil {
httpserver.RespondError(h.log, w, err)
return

View File

@ -1180,13 +1180,12 @@ func Test_Handler_SearchDocuments(t *testing.T) {
Query: "?q=test",
RespCode: http.StatusUnauthorized,
},
"Empty search query": {
Searcher: &SearcherMock{},
RespCode: http.StatusBadRequest,
},
"Overlong search query": {
Searcher: &SearcherMock{},
Query: "?q=" + strings.Repeat("a", _searchQueryMaxLength+1),
"Invalid search query": {
Searcher: &SearcherMock{
SearchDocumentsFunc: func(context.Context, string, string) ([]byte, error) {
return nil, search.ErrInvalidQuery
},
},
RespCode: http.StatusBadRequest,
},
"Searcher error": {

View File

@ -6,9 +6,6 @@ import (
"log/slog"
"net/http"
"path"
"regexp"
"strings"
"unicode/utf8"
"github.com/oxynote/oxynote/server/core/internal/document"
"github.com/oxynote/oxynote/server/core/internal/document/file"
@ -16,21 +13,10 @@ import (
"github.com/oxynote/oxynote/server/core/internal/storage"
"github.com/oxynote/oxynote/server/core/pkg/errutil"
"github.com/oxynote/oxynote/server/core/pkg/httpserver"
"github.com/oxynote/oxynote/server/core/pkg/strutil"
"github.com/rs/xid"
)
// _fileIDPattern matches a file id. The charset shuts out the path
// separators and dot segments that would otherwise let an id escape its
// storage folder once joined into an object key.
var _fileIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{21}$`)
// _maxFileNameLength caps the name recorded for an upload, in runes.
const _maxFileNameLength = 255
// _fallbackFileName names an upload whose multipart part carried no
// usable name.
const _fallbackFileName = "file"
// Handler holds dependencies required for document file operations.
type Handler struct {
log *slog.Logger
@ -76,7 +62,7 @@ func (h *Handler) UploadDocumentFile(w http.ResponseWriter, r *http.Request) {
}
fileID := r.URL.Query().Get("id")
if !_fileIDPattern.MatchString(fileID) {
if !strutil.IsNanoID(fileID) {
httpserver.RespondError(h.log, w, httpserver.ErrInvalidForm)
return
}
@ -87,7 +73,7 @@ func (h *Handler) UploadDocumentFile(w http.ResponseWriter, r *http.Request) {
return
}
policy, ok := uploadPolicy(r.URL.Query().Get("kind"))
policy, ok := storage.PolicyForKind(r.URL.Query().Get("kind"))
if !ok {
httpserver.RespondError(h.log, w, httpserver.ErrInvalidForm)
return
@ -108,7 +94,7 @@ func (h *Handler) UploadDocumentFile(w http.ResponseWriter, r *http.Request) {
return
}
name := fileName(r.MultipartForm.File["file"][0].Filename)
name := file.CleanName(r.MultipartForm.File["file"][0].Filename)
f := file.NewFile(
fileID,
@ -164,57 +150,6 @@ func (h *Handler) UploadDocumentFile(w http.ResponseWriter, r *http.Request) {
)
}
// fileID cuts the id off a "<id>-<file name>" route segment, reporting
// whether the segment has that shape.
func fileID(ref string) (string, bool) {
if len(ref) <= document.FileIDLength || ref[document.FileIDLength] != '-' {
return "", false
}
id := ref[:document.FileIDLength]
if !_fileIDPattern.MatchString(id) {
return "", false
}
return id, true
}
// uploadPolicy maps the kind of block an upload belongs to onto the
// storage policy it is admitted under, reporting whether the kind is one
// at all.
func uploadPolicy(kind string) (storage.Policy, bool) {
switch kind {
case "image":
return storage.ImagePolicy, true
case "file":
return storage.FilePolicy, true
default:
return storage.Policy{}, false
}
}
// fileName reduces the name a multipart part carried to a bare file name:
// a browser may send a path, Windows ones with backslashes, and the part
// may carry no name at all.
func fileName(raw string) string {
name := path.Base(strings.ReplaceAll(raw, "\\", "/"))
name = strings.TrimSpace(name)
if name == "." || name == "/" {
name = ""
}
if utf8.RuneCountInString(name) > _maxFileNameLength {
name = string([]rune(name)[:_maxFileNameLength])
}
if name == "" {
return _fallbackFileName
}
return name
}
// RetrieveDocumentFile handles the retrieval of a file from a document.
// The route's last segment is "<id>-<file name>": only the id identifies
// the file, the name makes the address read as the file it is.
@ -236,7 +171,7 @@ func (h *Handler) RetrieveDocumentFile(w http.ResponseWriter, r *http.Request) {
return
}
id, ok := fileID(ref)
id, _, ok := document.ParseFileRef(ref)
if !ok {
httpserver.RespondError(h.log, w, errutil.ErrNotFound)
return

View File

@ -889,92 +889,3 @@ func Test_Handler_RetrieveDocumentFile(t *testing.T) {
})
}
}
func Test_fileID(t *testing.T) {
cc := map[string]struct {
Ref string
Result string
OK bool
}{
"Id and name": {Ref: "f1xxxxxxxxxxxxxxxxxxx-notes.zip", Result: "f1xxxxxxxxxxxxxxxxxxx", OK: true},
"Name with dashes": {Ref: "f1xxxxxxxxxxxxxxxxxxx-a-b-c.zip", Result: "f1xxxxxxxxxxxxxxxxxxx", OK: true},
"Id with dashes": {Ref: "f1-x_-xxxxxxxxxxxxxxx-notes.zip", Result: "f1-x_-xxxxxxxxxxxxxxx", OK: true},
"Empty name": {Ref: "f1xxxxxxxxxxxxxxxxxxx-", Result: "f1xxxxxxxxxxxxxxxxxxx", OK: true},
"No dash after the id": {Ref: "f1xxxxxxxxxxxxxxxxxxxxnotes.zip"},
"Id alone": {Ref: "f1xxxxxxxxxxxxxxxxxxx"},
"Short": {Ref: "f1-notes.zip"},
"Id with the wrong charset": {Ref: "f1.................xx-notes.zip"},
"Empty": {Ref: ""},
}
for cn, c := range cc {
t.Run(cn, func(t *testing.T) {
t.Parallel()
res, ok := fileID(c.Ref)
assert.Equal(t, c.OK, ok)
assert.Equal(t, c.Result, res)
})
}
}
func Test_uploadPolicy(t *testing.T) {
cc := map[string]struct {
Kind string
Result storage.Policy
OK bool
}{
"Image kind": {
Kind: "image",
Result: storage.ImagePolicy,
OK: true,
},
"File kind": {
Kind: "file",
Result: storage.FilePolicy,
OK: true,
},
"Unknown kind": {
Kind: "video",
},
"Empty kind": {},
}
for cn, c := range cc {
t.Run(cn, func(t *testing.T) {
t.Parallel()
res, ok := uploadPolicy(c.Kind)
assert.Equal(t, c.OK, ok)
assert.Equal(t, c.Result, res)
})
}
}
func Test_fileName(t *testing.T) {
cc := map[string]struct {
Raw string
Result string
}{
"Plain name": {Raw: "notes.zip", Result: "notes.zip"},
"Unix path": {Raw: "/home/me/notes.zip", Result: "notes.zip"},
"Windows path": {Raw: `C:\Users\me\notes.zip`, Result: "notes.zip"},
"Surrounding whitespace": {Raw: " notes.zip ", Result: "notes.zip"},
"Empty name": {Raw: "", Result: "file"},
"Whitespace only": {Raw: " ", Result: "file"},
"Dot": {Raw: ".", Result: "file"},
"Root": {Raw: "/", Result: "file"},
"Non-ASCII name": {Raw: "résumé.pdf", Result: "résumé.pdf"},
"Name over the rune cap": {Raw: strings.Repeat("é", 300), Result: strings.Repeat("é", 255)},
"Name at the rune cap": {Raw: strings.Repeat("a", 255), Result: strings.Repeat("a", 255)},
"Path with a long base": {Raw: "dir/" + strings.Repeat("b", 256), Result: strings.Repeat("b", 255)},
}
for cn, c := range cc {
t.Run(cn, func(t *testing.T) {
t.Parallel()
assert.Equal(t, c.Result, fileName(c.Raw))
})
}
}

View File

@ -2,18 +2,14 @@ package hook
import (
"context"
"time"
"github.com/guregu/null/v5"
"github.com/oxynote/oxynote/server/core/internal/server/internal/auth"
"github.com/oxynote/oxynote/server/core/pkg/httpserver"
"github.com/oxynote/wetsocks/wsserver"
"github.com/rs/xid"
)
// _publishTimeout bounds each WebSocket publish triggered by a domain
// callback.
const _publishTimeout = 5 * time.Second
// HooksChangeMessage represents a change in the hooks one branch of a
// document carries. It names the branch so a subscriber showing another
// branch of the same document can leave its own list alone.
@ -26,7 +22,7 @@ type HooksChangeMessage struct {
// is scoped to one document.
func (h *Handler) BindHooksChange(tpc wsserver.Topic) {
h.hooks.changeCallback = func(organizationID string, documentID, branchID xid.ID) {
ctx, cancel := context.WithTimeout(context.Background(), _publishTimeout)
ctx, cancel := context.WithTimeout(context.Background(), httpserver.WSPublishTimeout)
defer cancel()
tpc.PublishMany(

View File

@ -7,14 +7,11 @@ import (
"github.com/guregu/null/v5"
documentCore "github.com/oxynote/oxynote/server/core/internal/document"
"github.com/oxynote/oxynote/server/core/internal/server/internal/auth"
"github.com/oxynote/oxynote/server/core/pkg/httpserver"
"github.com/oxynote/wetsocks/wsserver"
"github.com/rs/xid"
)
// _publishTimeout bounds each WebSocket publish triggered by a domain
// callback.
const _publishTimeout = 5 * time.Second
// TreeChangeMessage represents a change in the tree of a document.
type TreeChangeMessage struct {
// ParentID is the id of the parent document that has changed.
@ -24,7 +21,7 @@ type TreeChangeMessage struct {
// BindTreeChange binds a tree change event to the given topic.
func (h *Handler) BindTreeChange(tpc wsserver.Topic) {
h.tree.changeCallback = func(organizationID string, parentId null.Value[xid.ID]) {
ctx, cancel := context.WithTimeout(context.Background(), _publishTimeout)
ctx, cancel := context.WithTimeout(context.Background(), httpserver.WSPublishTimeout)
defer cancel()
tpc.PublishMany(ctx, TreeChangeMessage{
@ -85,7 +82,7 @@ func (h *Handler) BindMaintainersChange(tpc wsserver.Topic) {
// callback slot it is assigned to.
func publishDocumentChange(tpc wsserver.Topic) func(organizationID string, documentID xid.ID) {
return func(organizationID string, documentID xid.ID) {
ctx, cancel := context.WithTimeout(context.Background(), _publishTimeout)
ctx, cancel := context.WithTimeout(context.Background(), httpserver.WSPublishTimeout)
defer cancel()
tpc.PublishMany(ctx, struct{}{}, auth.FilterOrganizationDocument(organizationID, documentID))
@ -128,7 +125,7 @@ func (h *Handler) BindMetadataChange(tpc wsserver.Topic) {
})
h.metadata.changeCallback = func(organizationID string, doc documentCore.Document) {
pubCtx, pubCancel := context.WithTimeout(context.Background(), _publishTimeout)
pubCtx, pubCancel := context.WithTimeout(context.Background(), httpserver.WSPublishTimeout)
defer pubCancel()
tpc.PublishMany(

View File

@ -5,6 +5,8 @@ package email
import (
"sync"
emailCore "github.com/oxynote/oxynote/server/core/internal/email"
)
// Ensure, that SenderMock does implement Sender.
@ -17,26 +19,8 @@ var _ Sender = &SenderMock{}
//
// // make and configure a mocked Sender
// mockedSender := &SenderMock{
// SendAccountExistsFunc: func(eml string, link string) {
// panic("mock out the SendAccountExists method")
// },
// SendEmailChangeConfirmationFunc: func(eml string, link string) {
// panic("mock out the SendEmailChangeConfirmation method")
// },
// SendEmailVerificationFunc: func(eml string, link string) {
// panic("mock out the SendEmailVerification method")
// },
// SendOrganizationInvitationFunc: func(eml string, org string, link string) {
// panic("mock out the SendOrganizationInvitation method")
// },
// SendPasswordResetFunc: func(eml string, link string) {
// panic("mock out the SendPasswordReset method")
// },
// SendSignupVerificationFunc: func(eml string, link string) {
// panic("mock out the SendSignupVerification method")
// },
// SendUserDeletionConfirmationFunc: func(eml string, link string) {
// panic("mock out the SendUserDeletionConfirmation method")
// SendFunc: func(tmpl emailCore.Template, d emailCore.Data) error {
// panic("mock out the Send method")
// },
// }
//
@ -45,342 +29,57 @@ var _ Sender = &SenderMock{}
//
// }
type SenderMock struct {
// SendAccountExistsFunc mocks the SendAccountExists method.
SendAccountExistsFunc func(eml string, link string)
// SendEmailChangeConfirmationFunc mocks the SendEmailChangeConfirmation method.
SendEmailChangeConfirmationFunc func(eml string, link string)
// SendEmailVerificationFunc mocks the SendEmailVerification method.
SendEmailVerificationFunc func(eml string, link string)
// SendOrganizationInvitationFunc mocks the SendOrganizationInvitation method.
SendOrganizationInvitationFunc func(eml string, org string, link string)
// SendPasswordResetFunc mocks the SendPasswordReset method.
SendPasswordResetFunc func(eml string, link string)
// SendSignupVerificationFunc mocks the SendSignupVerification method.
SendSignupVerificationFunc func(eml string, link string)
// SendUserDeletionConfirmationFunc mocks the SendUserDeletionConfirmation method.
SendUserDeletionConfirmationFunc func(eml string, link string)
// SendFunc mocks the Send method.
SendFunc func(tmpl emailCore.Template, d emailCore.Data) error
// calls tracks calls to the methods.
calls struct {
// SendAccountExists holds details about calls to the SendAccountExists method.
SendAccountExists []struct {
// Eml is the eml argument value.
Eml string
// Link is the link argument value.
Link string
}
// SendEmailChangeConfirmation holds details about calls to the SendEmailChangeConfirmation method.
SendEmailChangeConfirmation []struct {
// Eml is the eml argument value.
Eml string
// Link is the link argument value.
Link string
}
// SendEmailVerification holds details about calls to the SendEmailVerification method.
SendEmailVerification []struct {
// Eml is the eml argument value.
Eml string
// Link is the link argument value.
Link string
}
// SendOrganizationInvitation holds details about calls to the SendOrganizationInvitation method.
SendOrganizationInvitation []struct {
// Eml is the eml argument value.
Eml string
// Org is the org argument value.
Org string
// Link is the link argument value.
Link string
}
// SendPasswordReset holds details about calls to the SendPasswordReset method.
SendPasswordReset []struct {
// Eml is the eml argument value.
Eml string
// Link is the link argument value.
Link string
}
// SendSignupVerification holds details about calls to the SendSignupVerification method.
SendSignupVerification []struct {
// Eml is the eml argument value.
Eml string
// Link is the link argument value.
Link string
}
// SendUserDeletionConfirmation holds details about calls to the SendUserDeletionConfirmation method.
SendUserDeletionConfirmation []struct {
// Eml is the eml argument value.
Eml string
// Link is the link argument value.
Link string
// Send holds details about calls to the Send method.
Send []struct {
// Tmpl is the tmpl argument value.
Tmpl emailCore.Template
// D is the d argument value.
D emailCore.Data
}
}
lockSendAccountExists sync.RWMutex
lockSendEmailChangeConfirmation sync.RWMutex
lockSendEmailVerification sync.RWMutex
lockSendOrganizationInvitation sync.RWMutex
lockSendPasswordReset sync.RWMutex
lockSendSignupVerification sync.RWMutex
lockSendUserDeletionConfirmation sync.RWMutex
lockSend sync.RWMutex
}
// SendAccountExists calls SendAccountExistsFunc.
func (mock *SenderMock) SendAccountExists(eml string, link string) {
// Send calls SendFunc.
func (mock *SenderMock) Send(tmpl emailCore.Template, d emailCore.Data) error {
callInfo := struct {
Eml string
Link string
Tmpl emailCore.Template
D emailCore.Data
}{
Eml: eml,
Link: link,
Tmpl: tmpl,
D: d,
}
mock.lockSendAccountExists.Lock()
mock.calls.SendAccountExists = append(mock.calls.SendAccountExists, callInfo)
mock.lockSendAccountExists.Unlock()
if mock.SendAccountExistsFunc == nil {
return
mock.lockSend.Lock()
mock.calls.Send = append(mock.calls.Send, callInfo)
mock.lockSend.Unlock()
if mock.SendFunc == nil {
var (
errOut error
)
return errOut
}
mock.SendAccountExistsFunc(eml, link)
return mock.SendFunc(tmpl, d)
}
// SendAccountExistsCalls gets all the calls that were made to SendAccountExists.
// SendCalls gets all the calls that were made to Send.
// Check the length with:
//
// len(mockedSender.SendAccountExistsCalls())
func (mock *SenderMock) SendAccountExistsCalls() []struct {
Eml string
Link string
// len(mockedSender.SendCalls())
func (mock *SenderMock) SendCalls() []struct {
Tmpl emailCore.Template
D emailCore.Data
} {
var calls []struct {
Eml string
Link string
Tmpl emailCore.Template
D emailCore.Data
}
mock.lockSendAccountExists.RLock()
calls = mock.calls.SendAccountExists
mock.lockSendAccountExists.RUnlock()
return calls
}
// SendEmailChangeConfirmation calls SendEmailChangeConfirmationFunc.
func (mock *SenderMock) SendEmailChangeConfirmation(eml string, link string) {
callInfo := struct {
Eml string
Link string
}{
Eml: eml,
Link: link,
}
mock.lockSendEmailChangeConfirmation.Lock()
mock.calls.SendEmailChangeConfirmation = append(mock.calls.SendEmailChangeConfirmation, callInfo)
mock.lockSendEmailChangeConfirmation.Unlock()
if mock.SendEmailChangeConfirmationFunc == nil {
return
}
mock.SendEmailChangeConfirmationFunc(eml, link)
}
// SendEmailChangeConfirmationCalls gets all the calls that were made to SendEmailChangeConfirmation.
// Check the length with:
//
// len(mockedSender.SendEmailChangeConfirmationCalls())
func (mock *SenderMock) SendEmailChangeConfirmationCalls() []struct {
Eml string
Link string
} {
var calls []struct {
Eml string
Link string
}
mock.lockSendEmailChangeConfirmation.RLock()
calls = mock.calls.SendEmailChangeConfirmation
mock.lockSendEmailChangeConfirmation.RUnlock()
return calls
}
// SendEmailVerification calls SendEmailVerificationFunc.
func (mock *SenderMock) SendEmailVerification(eml string, link string) {
callInfo := struct {
Eml string
Link string
}{
Eml: eml,
Link: link,
}
mock.lockSendEmailVerification.Lock()
mock.calls.SendEmailVerification = append(mock.calls.SendEmailVerification, callInfo)
mock.lockSendEmailVerification.Unlock()
if mock.SendEmailVerificationFunc == nil {
return
}
mock.SendEmailVerificationFunc(eml, link)
}
// SendEmailVerificationCalls gets all the calls that were made to SendEmailVerification.
// Check the length with:
//
// len(mockedSender.SendEmailVerificationCalls())
func (mock *SenderMock) SendEmailVerificationCalls() []struct {
Eml string
Link string
} {
var calls []struct {
Eml string
Link string
}
mock.lockSendEmailVerification.RLock()
calls = mock.calls.SendEmailVerification
mock.lockSendEmailVerification.RUnlock()
return calls
}
// SendOrganizationInvitation calls SendOrganizationInvitationFunc.
func (mock *SenderMock) SendOrganizationInvitation(eml string, org string, link string) {
callInfo := struct {
Eml string
Org string
Link string
}{
Eml: eml,
Org: org,
Link: link,
}
mock.lockSendOrganizationInvitation.Lock()
mock.calls.SendOrganizationInvitation = append(mock.calls.SendOrganizationInvitation, callInfo)
mock.lockSendOrganizationInvitation.Unlock()
if mock.SendOrganizationInvitationFunc == nil {
return
}
mock.SendOrganizationInvitationFunc(eml, org, link)
}
// SendOrganizationInvitationCalls gets all the calls that were made to SendOrganizationInvitation.
// Check the length with:
//
// len(mockedSender.SendOrganizationInvitationCalls())
func (mock *SenderMock) SendOrganizationInvitationCalls() []struct {
Eml string
Org string
Link string
} {
var calls []struct {
Eml string
Org string
Link string
}
mock.lockSendOrganizationInvitation.RLock()
calls = mock.calls.SendOrganizationInvitation
mock.lockSendOrganizationInvitation.RUnlock()
return calls
}
// SendPasswordReset calls SendPasswordResetFunc.
func (mock *SenderMock) SendPasswordReset(eml string, link string) {
callInfo := struct {
Eml string
Link string
}{
Eml: eml,
Link: link,
}
mock.lockSendPasswordReset.Lock()
mock.calls.SendPasswordReset = append(mock.calls.SendPasswordReset, callInfo)
mock.lockSendPasswordReset.Unlock()
if mock.SendPasswordResetFunc == nil {
return
}
mock.SendPasswordResetFunc(eml, link)
}
// SendPasswordResetCalls gets all the calls that were made to SendPasswordReset.
// Check the length with:
//
// len(mockedSender.SendPasswordResetCalls())
func (mock *SenderMock) SendPasswordResetCalls() []struct {
Eml string
Link string
} {
var calls []struct {
Eml string
Link string
}
mock.lockSendPasswordReset.RLock()
calls = mock.calls.SendPasswordReset
mock.lockSendPasswordReset.RUnlock()
return calls
}
// SendSignupVerification calls SendSignupVerificationFunc.
func (mock *SenderMock) SendSignupVerification(eml string, link string) {
callInfo := struct {
Eml string
Link string
}{
Eml: eml,
Link: link,
}
mock.lockSendSignupVerification.Lock()
mock.calls.SendSignupVerification = append(mock.calls.SendSignupVerification, callInfo)
mock.lockSendSignupVerification.Unlock()
if mock.SendSignupVerificationFunc == nil {
return
}
mock.SendSignupVerificationFunc(eml, link)
}
// SendSignupVerificationCalls gets all the calls that were made to SendSignupVerification.
// Check the length with:
//
// len(mockedSender.SendSignupVerificationCalls())
func (mock *SenderMock) SendSignupVerificationCalls() []struct {
Eml string
Link string
} {
var calls []struct {
Eml string
Link string
}
mock.lockSendSignupVerification.RLock()
calls = mock.calls.SendSignupVerification
mock.lockSendSignupVerification.RUnlock()
return calls
}
// SendUserDeletionConfirmation calls SendUserDeletionConfirmationFunc.
func (mock *SenderMock) SendUserDeletionConfirmation(eml string, link string) {
callInfo := struct {
Eml string
Link string
}{
Eml: eml,
Link: link,
}
mock.lockSendUserDeletionConfirmation.Lock()
mock.calls.SendUserDeletionConfirmation = append(mock.calls.SendUserDeletionConfirmation, callInfo)
mock.lockSendUserDeletionConfirmation.Unlock()
if mock.SendUserDeletionConfirmationFunc == nil {
return
}
mock.SendUserDeletionConfirmationFunc(eml, link)
}
// SendUserDeletionConfirmationCalls gets all the calls that were made to SendUserDeletionConfirmation.
// Check the length with:
//
// len(mockedSender.SendUserDeletionConfirmationCalls())
func (mock *SenderMock) SendUserDeletionConfirmationCalls() []struct {
Eml string
Link string
} {
var calls []struct {
Eml string
Link string
}
mock.lockSendUserDeletionConfirmation.RLock()
calls = mock.calls.SendUserDeletionConfirmation
mock.lockSendUserDeletionConfirmation.RUnlock()
mock.lockSend.RLock()
calls = mock.calls.Send
mock.lockSend.RUnlock()
return calls
}

View File

@ -6,14 +6,9 @@ import (
"net/http"
emailCore "github.com/oxynote/oxynote/server/core/internal/email"
"github.com/oxynote/oxynote/server/core/pkg/errutil"
"github.com/oxynote/oxynote/server/core/pkg/httpserver"
)
// ErrInvalidTemplate is returned when the requested email template is
// not recognized.
var ErrInvalidTemplate = errutil.New(http.StatusBadRequest, "email.invalid_template", "Invalid email template.")
// Handler holds dependencies required for email operations.
type Handler struct {
log *slog.Logger
@ -35,11 +30,7 @@ func NewHandler(
func (h *Handler) SendEmail(w http.ResponseWriter, r *http.Request) {
var req struct {
Template emailCore.Template `json:"template"`
Data struct {
Email string `json:"email"`
Organization string `json:"organization"`
Link string `json:"link"`
} `json:"data"`
Data emailCore.Data `json:"data"`
}
if err := httpserver.DecodeJSON(r, &req); err != nil {
@ -47,23 +38,8 @@ func (h *Handler) SendEmail(w http.ResponseWriter, r *http.Request) {
return
}
switch req.Template {
case emailCore.TemplateEmailVerification:
h.sender.SendEmailVerification(req.Data.Email, req.Data.Link)
case emailCore.TemplateEmailChangeConfirmation:
h.sender.SendEmailChangeConfirmation(req.Data.Email, req.Data.Link)
case emailCore.TemplatePasswordReset:
h.sender.SendPasswordReset(req.Data.Email, req.Data.Link)
case emailCore.TemplateAccountExists:
h.sender.SendAccountExists(req.Data.Email, req.Data.Link)
case emailCore.TemplateSignupVerification:
h.sender.SendSignupVerification(req.Data.Email, req.Data.Link)
case emailCore.TemplateOrganizationInvitation:
h.sender.SendOrganizationInvitation(req.Data.Email, req.Data.Organization, req.Data.Link)
case emailCore.TemplateUserDeletion:
h.sender.SendUserDeletionConfirmation(req.Data.Email, req.Data.Link)
default:
httpserver.RespondError(h.log, w, ErrInvalidTemplate)
if err := h.sender.Send(req.Template, req.Data); err != nil {
httpserver.RespondError(h.log, w, err)
return
}
@ -74,30 +50,8 @@ func (h *Handler) SendEmail(w http.ResponseWriter, r *http.Request) {
//
//go:generate ../../../../scripts/codegen/mock -t internal Sender
type Sender interface {
// SendEmailVerification should send a new-email-address
// verification email.
SendEmailVerification(eml, link string)
// SendEmailChangeConfirmation should send the email asking the
// current address to approve a change of address.
SendEmailChangeConfirmation(eml, link string)
// SendPasswordReset should send a password reset email.
SendPasswordReset(eml, link string)
// SendAccountExists should send an account-exists notification
// email.
SendAccountExists(eml, link string)
// SendSignupVerification should send the account-activation email
// for a fresh signup.
SendSignupVerification(eml, link string)
// SendOrganizationInvitation should send an organization invitation
// email.
SendOrganizationInvitation(eml, org, link string)
// SendUserDeletionConfirmation should send a user deletion
// confirmation email.
SendUserDeletionConfirmation(eml, link string)
// Send should send the template to the recipient in the data,
// rendered with the values it carries, reporting only a template
// that cannot be sent.
Send(tmpl emailCore.Template, d emailCore.Data) error
}

View File

@ -7,6 +7,7 @@ import (
"strings"
"testing"
emailCore "github.com/oxynote/oxynote/server/core/internal/email"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/goleak"
@ -45,189 +46,55 @@ func Test_Handler_SendEmail(t *testing.T) {
}
}
wasNoSendCalled := func() check {
wasSendCalled := func(count int, tmpl emailCore.Template, d emailCore.Data) check {
return func(t *testing.T, sender *SenderMock, _ *httptest.ResponseRecorder) {
assert.Empty(t, sender.SendEmailVerificationCalls())
assert.Empty(t, sender.SendEmailChangeConfirmationCalls())
assert.Empty(t, sender.SendPasswordResetCalls())
assert.Empty(t, sender.SendAccountExistsCalls())
assert.Empty(t, sender.SendSignupVerificationCalls())
assert.Empty(t, sender.SendOrganizationInvitationCalls())
assert.Empty(t, sender.SendUserDeletionConfirmationCalls())
}
}
wasSendEmailVerificationCalled := func(count int, eml, link string) check {
return func(t *testing.T, sender *SenderMock, _ *httptest.ResponseRecorder) {
ff := sender.SendEmailVerificationCalls()
ff := sender.SendCalls()
require.Len(t, ff, count)
if count == 0 {
return
}
assert.Equal(t, eml, ff[0].Eml)
assert.Equal(t, link, ff[0].Link)
}
}
wasSendEmailChangeConfirmationCalled := func(count int, eml, link string) check {
return func(t *testing.T, sender *SenderMock, _ *httptest.ResponseRecorder) {
ff := sender.SendEmailChangeConfirmationCalls()
require.Len(t, ff, count)
if count == 0 {
return
}
assert.Equal(t, eml, ff[0].Eml)
assert.Equal(t, link, ff[0].Link)
}
}
wasSendPasswordResetCalled := func(count int, eml, link string) check {
return func(t *testing.T, sender *SenderMock, _ *httptest.ResponseRecorder) {
ff := sender.SendPasswordResetCalls()
require.Len(t, ff, count)
if count == 0 {
return
}
assert.Equal(t, eml, ff[0].Eml)
assert.Equal(t, link, ff[0].Link)
}
}
wasSendAccountExistsCalled := func(count int, eml, link string) check {
return func(t *testing.T, sender *SenderMock, _ *httptest.ResponseRecorder) {
ff := sender.SendAccountExistsCalls()
require.Len(t, ff, count)
if count == 0 {
return
}
assert.Equal(t, eml, ff[0].Eml)
assert.Equal(t, link, ff[0].Link)
}
}
wasSendSignupVerificationCalled := func(count int, eml, link string) check {
return func(t *testing.T, sender *SenderMock, _ *httptest.ResponseRecorder) {
ff := sender.SendSignupVerificationCalls()
require.Len(t, ff, count)
if count == 0 {
return
}
assert.Equal(t, eml, ff[0].Eml)
assert.Equal(t, link, ff[0].Link)
}
}
wasSendOrganizationInvitationCalled := func(count int, eml, org, link string) check {
return func(t *testing.T, sender *SenderMock, _ *httptest.ResponseRecorder) {
ff := sender.SendOrganizationInvitationCalls()
require.Len(t, ff, count)
if count == 0 {
return
}
assert.Equal(t, eml, ff[0].Eml)
assert.Equal(t, org, ff[0].Org)
assert.Equal(t, link, ff[0].Link)
}
}
wasSendUserDeletionConfirmationCalled := func(count int, eml, link string) check {
return func(t *testing.T, sender *SenderMock, _ *httptest.ResponseRecorder) {
ff := sender.SendUserDeletionConfirmationCalls()
require.Len(t, ff, count)
if count == 0 {
return
}
assert.Equal(t, eml, ff[0].Eml)
assert.Equal(t, link, ff[0].Link)
assert.Equal(t, tmpl, ff[0].Tmpl)
assert.Equal(t, d, ff[0].D)
}
}
cc := map[string]struct {
Sender *SenderMock
Body string
Checks []check
}{
"Invalid JSON body": {
Body: "{",
Sender: &SenderMock{},
Body: "{",
Checks: checks(
hasResp(http.StatusBadRequest, `{"code":"request.invalid_json","message":"invalid JSON body"}`),
wasNoSendCalled(),
wasSendCalled(0, "", emailCore.Data{}),
),
},
"Unknown template": {
"Error returned by Sender.Send": {
Sender: &SenderMock{
SendFunc: func(emailCore.Template, emailCore.Data) error {
return emailCore.ErrInvalidTemplate
},
},
Body: `{"template":"nonexistent","data":{"email":"user@example.com"}}`,
Checks: checks(
hasResp(http.StatusBadRequest, `{"code":"email.invalid_template","message":"Invalid email template."}`),
wasNoSendCalled(),
wasSendCalled(1, "nonexistent", emailCore.Data{Email: "user@example.com"}),
),
},
"Email verification sent": {
Body: `{"template":"email_verification","data":{"email":"user@example.com","link":"https://example.com/verify"}}`,
"Email sent": {
Sender: &SenderMock{},
Body: `{"template":"organization_invitation","data":{"email":"user@example.com","organization":"Acme","link":"https://example.com/join"}}`,
Checks: checks(
hasResp(http.StatusNoContent, ""),
wasSendEmailVerificationCalled(1, "user@example.com", "https://example.com/verify"),
),
},
"Email change confirmation sent": {
Body: `{"template":"email_change_confirmation","data":{"email":"user@example.com","link":"https://example.com/approve"}}`,
Checks: checks(
hasResp(http.StatusNoContent, ""),
wasSendEmailChangeConfirmationCalled(1, "user@example.com", "https://example.com/approve"),
),
},
"Password reset sent": {
Body: `{"template":"password_reset","data":{"email":"user@example.com","link":"https://example.com/reset"}}`,
Checks: checks(
hasResp(http.StatusNoContent, ""),
wasSendPasswordResetCalled(1, "user@example.com", "https://example.com/reset"),
),
},
"Account exists sent": {
Body: `{"template":"account_exists","data":{"email":"user@example.com","link":"https://example.com/login"}}`,
Checks: checks(
hasResp(http.StatusNoContent, ""),
wasSendAccountExistsCalled(1, "user@example.com", "https://example.com/login"),
),
},
"Signup verification sent": {
Body: `{"template":"signup_verification","data":{"email":"user@example.com","link":"https://example.com/activate"}}`,
Checks: checks(
hasResp(http.StatusNoContent, ""),
wasSendSignupVerificationCalled(1, "user@example.com", "https://example.com/activate"),
),
},
"Organization invitation sent": {
Body: `{"template":"organization_invitation","data":{"email":"user@example.com","organization":"Acme","link":"https://example.com/join"}}`,
Checks: checks(
hasResp(http.StatusNoContent, ""),
wasSendOrganizationInvitationCalled(1, "user@example.com", "Acme", "https://example.com/join"),
),
},
"User deletion confirmation sent": {
Body: `{"template":"user_deletion","data":{"email":"user@example.com","link":"https://example.com/delete"}}`,
Checks: checks(
hasResp(http.StatusNoContent, ""),
wasSendUserDeletionConfirmationCalled(1, "user@example.com", "https://example.com/delete"),
),
},
"User creation rejected": {
Body: `{"template":"user_creation","data":{"email":"user@example.com"}}`,
Checks: checks(
hasResp(http.StatusBadRequest, `{"code":"email.invalid_template","message":"Invalid email template."}`),
wasNoSendCalled(),
wasSendCalled(1, emailCore.TemplateOrganizationInvitation, emailCore.Data{
Email: "user@example.com",
Organization: "Acme",
Link: "https://example.com/join",
}),
),
},
}
@ -236,11 +103,9 @@ func Test_Handler_SendEmail(t *testing.T) {
t.Run(cn, func(t *testing.T) {
t.Parallel()
sender := &SenderMock{}
hdl := Handler{
log: slog.New(slog.DiscardHandler),
sender: sender,
sender: c.Sender,
}
req := httptest.NewRequest("POST", "http://test.com/", strings.NewReader(c.Body))
@ -249,7 +114,7 @@ func Test_Handler_SendEmail(t *testing.T) {
hdl.SendEmail(rec, req)
for _, ch := range c.Checks {
ch(t, sender, rec)
ch(t, c.Sender, rec)
}
})
}

View File

@ -9,6 +9,7 @@ import (
"time"
sdkauth "github.com/modelcontextprotocol/go-sdk/auth"
"github.com/oxynote/oxynote/server/core/internal/server/internal/auth"
"github.com/oxynote/oxynote/server/core/pkg/httpserver"
)
@ -114,7 +115,7 @@ func fetchSession(ctx context.Context, client *http.Client, url, token string) (
return Session{}, err
}
if session.UserID == "" || session.OrganizationID == "" {
if !auth.HasActiveOrganization(session.UserID, session.OrganizationID) {
return Session{}, fmt.Errorf("%w: incomplete session payload", sdkauth.ErrInvalidToken)
}

View File

@ -8,31 +8,18 @@ import (
"github.com/oxynote/oxynote/server/core/internal/assistant/tools"
)
// _resourceURIPrefix addresses a document in the oxynote resource
// scheme; the id after the prefix is the document's xid.
const _resourceURIPrefix = "oxynote://documents/"
// _resourceBranchSegment separates a document id from a branch id in a
// resource URI: oxynote://documents/{id}/branches/{branch_id} reads that
// branch, and every resource names one.
const _resourceBranchSegment = "/branches/"
// annotations declares a tool's intent so MCP clients can gate calls:
// reads are read-only, writes say whether they can destroy content. A
// write counts as destructive here when it removes content outright and
// also when it overwrites content the caller did not name — both lose
// work a client would want to confirm first, which is the question the
// hint answers. A document tool works on the organization's own
// documents, so its world is closed; a data-source tool reaches whatever
// the connection points at, which is not.
// reads are read-only, writes say whether they can destroy content, and
// every tool says whether it reaches outside the organization's own
// documents.
func annotations(e tools.Entry) *mcp.ToolAnnotations {
out := &mcp.ToolAnnotations{
ReadOnlyHint: !e.Write,
OpenWorldHint: new(e.DataSource),
OpenWorldHint: new(e.OpenWorld()),
}
if e.Write {
out.DestructiveHint = new(e.Destructive || e.Overwrites)
out.DestructiveHint = new(e.DestroysContent())
}
return out
@ -63,7 +50,7 @@ func (h *Handler) toolHandler(e tools.Entry) mcp.ToolHandler {
// can follow the edit straight to its target.
for _, t := range res.Documents {
content = append(content, &mcp.ResourceLink{
URI: _resourceURIPrefix + t.DocumentID.String() + _resourceBranchSegment + t.BranchID.String(),
URI: resourceURI(t.DocumentID, t.BranchID),
Name: t.DocumentID.String(),
MIMEType: _documentMIMEType,
})

View File

@ -36,12 +36,18 @@ func Test_annotations(t *testing.T) {
cc := map[string]struct {
Entry tools.Entry
ReadOnly bool
OpenWorld bool
Destructive *bool
}{
"Read tool": {
Entry: tools.Entry{},
ReadOnly: true,
},
"Data-source tool reaches an open world": {
Entry: tools.Entry{Traits: tools.Traits{DataSource: true}},
ReadOnly: true,
OpenWorld: true,
},
"Additive write tool": {
Entry: tools.Entry{Traits: tools.Traits{Write: true}},
Destructive: new(bool),
@ -71,7 +77,7 @@ func Test_annotations(t *testing.T) {
assert.Equal(t, c.ReadOnly, got.ReadOnlyHint)
assert.Equal(t, c.Destructive, got.DestructiveHint)
require.NotNil(t, got.OpenWorldHint)
assert.False(t, *got.OpenWorldHint)
assert.Equal(t, c.OpenWorld, *got.OpenWorldHint)
})
}
}
@ -115,13 +121,13 @@ func Test_Handler_toolHandler(t *testing.T) {
Entry: tools.Entry{Tool: &stubRunner{out: `{"ok":true}`, docs: []tools.Touched{{DocumentID: doc1, BranchID: branch1}}}, Traits: tools.Traits{Write: true}},
Args: `{"document_id":"` + doc1.String() + `"}`,
Text: `{"ok":true}`,
LinkURIs: []string{_resourceURIPrefix + doc1.String() + _resourceBranchSegment + branch1.String()},
LinkURIs: []string{resourceURI(doc1, branch1)},
},
"A call links every document it changed": {
Entry: tools.Entry{Tool: &stubRunner{out: `{"ok":true}`, docs: []tools.Touched{{DocumentID: doc1, BranchID: branch1}, {DocumentID: doc2, BranchID: branch2}}}, Traits: tools.Traits{Write: true}},
Args: `{}`,
Text: `{"ok":true}`,
LinkURIs: []string{_resourceURIPrefix + doc1.String() + _resourceBranchSegment + branch1.String(), _resourceURIPrefix + doc2.String() + _resourceBranchSegment + branch2.String()},
LinkURIs: []string{resourceURI(doc1, branch1), resourceURI(doc2, branch2)},
},
"A write that changed nothing has no link to offer": {
Entry: tools.Entry{Tool: &stubRunner{out: `{"ok":true}`}, Traits: tools.Traits{Write: true}},

View File

@ -0,0 +1,51 @@
package mcp
import (
"strings"
"github.com/rs/xid"
)
// _resourceURIPrefix addresses a document in the oxynote resource
// scheme; the id after the prefix is the document's xid.
const _resourceURIPrefix = "oxynote://documents/"
// _resourceBranchSegment separates a document id from a branch id in a
// resource URI: oxynote://documents/{id}/branches/{branch_id} reads that
// branch, and every resource names one.
const _resourceBranchSegment = "/branches/"
// _resourceURITemplate is the RFC 6570 template every branch resource
// URI matches.
const _resourceURITemplate = _resourceURIPrefix + "{id}" + _resourceBranchSegment + "{branch_id}"
// resourceURI addresses one branch of a document in the resource scheme.
func resourceURI(documentID, branchID xid.ID) string {
return _resourceURIPrefix + documentID.String() + _resourceBranchSegment + branchID.String()
}
// parseResourceURI reads back what resourceURI wrote, reporting whether
// the URI names a document branch at all.
func parseResourceURI(uri string) (documentID, branchID xid.ID, ok bool) {
rest, ok := strings.CutPrefix(uri, _resourceURIPrefix)
if !ok {
return xid.NilID(), xid.NilID(), false
}
rawDocumentID, rawBranchID, ok := strings.Cut(rest, _resourceBranchSegment)
if !ok {
return xid.NilID(), xid.NilID(), false
}
documentID, err := xid.FromString(rawDocumentID)
if err != nil {
return xid.NilID(), xid.NilID(), false
}
branchID, err = xid.FromString(rawBranchID)
if err != nil {
return xid.NilID(), xid.NilID(), false
}
return documentID, branchID, true
}

View File

@ -0,0 +1,47 @@
package mcp
import (
"testing"
"github.com/rs/xid"
"github.com/stretchr/testify/assert"
)
func Test_resourceURI(t *testing.T) {
t.Parallel()
documentID, branchID := xid.New(), xid.New()
assert.Equal(t, "oxynote://documents/"+documentID.String()+"/branches/"+branchID.String(), resourceURI(documentID, branchID))
}
func Test_parseResourceURI(t *testing.T) {
documentID, branchID := xid.New(), xid.New()
cc := map[string]struct {
URI string
DocumentID xid.ID
BranchID xid.ID
OK bool
}{
"Branch URI": {URI: resourceURI(documentID, branchID), DocumentID: documentID, BranchID: branchID, OK: true},
"URI outside the scheme": {URI: "file:///etc/passwd"},
"URI without an id": {URI: _resourceURIPrefix},
"URI without a branch": {URI: _resourceURIPrefix + documentID.String()},
"Branch URI without a document": {URI: _resourceURIPrefix + _resourceBranchSegment + branchID.String()},
"Document segment that is not an id": {URI: _resourceURIPrefix + "doc" + _resourceBranchSegment + branchID.String()},
"Branch segment that is not an id": {URI: _resourceURIPrefix + documentID.String() + _resourceBranchSegment + "draft"},
"Empty": {},
}
for cn, c := range cc {
t.Run(cn, func(t *testing.T) {
t.Parallel()
gotDocumentID, gotBranchID, ok := parseResourceURI(c.URI)
assert.Equal(t, c.OK, ok)
assert.Equal(t, c.DocumentID, gotDocumentID)
assert.Equal(t, c.BranchID, gotBranchID)
})
}
}

View File

@ -5,12 +5,10 @@ import (
"encoding/json"
"errors"
"log/slog"
"strings"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/oxynote/oxynote/server/core/internal/assistant/tools"
"github.com/oxynote/oxynote/server/core/pkg/errutil"
"github.com/rs/xid"
)
// _documentMIMEType is the media type of a document resource body: the
@ -34,7 +32,7 @@ func (h *Handler) addResources(
Title: "Oxynote document branch",
Description: "One branch of an Oxynote document, by document and branch id: its metadata, branches and blocks in canonical JSON form.",
MIMEType: _documentMIMEType,
URITemplate: _resourceURIPrefix + "{id}" + _resourceBranchSegment + "{branch_id}",
URITemplate: _resourceURITemplate,
}, read)
tree, err := h.db.FetchDocumentTree(ctx, session.OrganizationID)
@ -55,7 +53,7 @@ func (h *Handler) addResources(
// reader lands on without picking one.
for _, s := range tree.Descendants() {
srv.AddResource(&mcp.Resource{
URI: _resourceURIPrefix + s.ID.String() + _resourceBranchSegment + s.DefaultBranchID.String(),
URI: resourceURI(s.ID, s.DefaultBranchID),
Name: s.DocumentName,
MIMEType: _documentMIMEType,
}, read)
@ -67,24 +65,14 @@ func (h *Handler) addResources(
// output shape stay identical to a tool call.
func (h *Handler) readDocument(set *tools.Set) mcp.ResourceHandler {
return func(ctx context.Context, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) {
rest, ok := strings.CutPrefix(req.Params.URI, _resourceURIPrefix)
if !ok || rest == "" {
return nil, mcp.ResourceNotFoundError(req.Params.URI)
}
// a resource names a document and one of its branches; anything
// short of that, or a branch segment that is not an id, names
// nothing.
id, branchID, ok := strings.Cut(rest, _resourceBranchSegment)
if !ok || id == "" || branchID == "" {
// short of that names nothing.
id, branchID, ok := parseResourceURI(req.Params.URI)
if !ok {
return nil, mcp.ResourceNotFoundError(req.Params.URI)
}
if _, err := xid.FromString(branchID); err != nil {
return nil, mcp.ResourceNotFoundError(req.Params.URI)
}
raw, err := json.Marshal(map[string]string{"document_id": id, "branch_id": branchID})
raw, err := json.Marshal(map[string]string{"document_id": id.String(), "branch_id": branchID.String()})
if err != nil {
// NOCOV: two string fields always marshal.
return nil, err

View File

@ -179,8 +179,13 @@ func Test_Handler_readDocument(t *testing.T) {
ToolsDB: stubToolsDB(),
Err: mcp.ResourceNotFoundError(_resourceURIPrefix + docID.String()),
},
"Document segment that is not an id": {
URI: _resourceURIPrefix + "doc" + _resourceBranchSegment + _stubDraftBranchID.String(),
ToolsDB: stubToolsDB(),
Err: mcp.ResourceNotFoundError(_resourceURIPrefix + "doc" + _resourceBranchSegment + _stubDraftBranchID.String()),
},
"Internal tool failure passes through": {
URI: _resourceURIPrefix + docID.String() + _resourceBranchSegment + _stubDraftBranchID.String(),
URI: resourceURI(docID, _stubDraftBranchID),
ToolsDB: &toolsMock.DB{
FetchDocumentByBranchIDFunc: stubDraftDocument(docID),
FetchDocumentBranchesFunc: func(context.Context, xid.ID, string) ([]document.BranchSummary, error) {
@ -191,12 +196,12 @@ func Test_Handler_readDocument(t *testing.T) {
Passthru: true,
},
"Unknown branch maps to resource not found": {
URI: _resourceURIPrefix + docID.String() + _resourceBranchSegment + unknownBranchID.String(),
URI: resourceURI(docID, unknownBranchID),
ToolsDB: &toolsMock.DB{
FetchDocumentByBranchIDFunc: stubDraftDocument(docID),
FetchDocumentBranchesFunc: stubBranches,
},
Err: mcp.ResourceNotFoundError(_resourceURIPrefix + docID.String() + _resourceBranchSegment + unknownBranchID.String()),
Err: mcp.ResourceNotFoundError(resourceURI(docID, unknownBranchID)),
},
"Branch segment that is not an id": {
URI: _resourceURIPrefix + docID.String() + _resourceBranchSegment + "draft",
@ -209,7 +214,7 @@ func Test_Handler_readDocument(t *testing.T) {
Err: mcp.ResourceNotFoundError(_resourceURIPrefix + _resourceBranchSegment + _stubDraftBranchID.String()),
},
"Successful read": {
URI: _resourceURIPrefix + docID.String() + _resourceBranchSegment + _stubDraftBranchID.String(),
URI: resourceURI(docID, _stubDraftBranchID),
ToolsDB: &toolsMock.DB{
FetchDocumentByBranchIDFunc: stubDraftDocument(docID),
FetchDocumentBranchesFunc: stubBranches,

View File

@ -6,9 +6,18 @@ import (
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/oxynote/oxynote/server/core/internal/assistant"
"github.com/oxynote/oxynote/server/core/internal/assistant/tools"
"github.com/oxynote/oxynote/server/core/internal/buildinfo"
)
// _scopes is the OAuth scope each kind of tool access answers to. An
// access with no scope here is offered to no client.
var _scopes = map[tools.Access]string{
tools.AccessRead: ScopeDocumentRead,
tools.AccessWrite: ScopeDocumentWrite,
tools.AccessDataSource: ScopeDataSourceRead,
}
// server builds the MCP server for one request's identity: the tools
// the token's scopes allow, plus the organization's documents as
// resources. Stateless transport means every request gets a fresh
@ -44,25 +53,12 @@ func (h *Handler) server(r *http.Request) *mcp.Server {
set := h.man.ToolSet(session.OrganizationID, session.UserID)
read := slices.Contains(session.Scopes, ScopeDocumentRead)
write := slices.Contains(session.Scopes, ScopeDocumentWrite)
dataSources := slices.Contains(session.Scopes, ScopeDataSourceRead)
for _, e := range set.Entries() {
// an internal tool addresses conversation state this surface has
// none of, so offering it would only invite a call that cannot
// succeed.
if e.Internal {
continue
}
// a data-source tool reaches outside Oxynote entirely, so it
// answers to its own scope rather than to the document ones.
if e.DataSource {
if !dataSources {
continue
}
} else if e.Write && !write || !e.Write && !read {
// a tool no scope grants is not offered: an internal one addresses
// conversation state this surface has none of, and the rest answer
// to the scope their access maps onto.
scope, ok := _scopes[e.Access()]
if !ok || !slices.Contains(session.Scopes, scope) {
continue
}
@ -76,7 +72,7 @@ func (h *Handler) server(r *http.Request) *mcp.Server {
}, h.toolHandler(e))
}
if read {
if slices.Contains(session.Scopes, ScopeDocumentRead) {
h.addResources(r.Context(), srv, session, set)
}

View File

@ -3,7 +3,6 @@ package org
import (
"context"
"fmt"
"log/slog"
"net/http"
@ -11,10 +10,9 @@ import (
"github.com/oxynote/oxynote/server/core/internal/apps/github"
"github.com/oxynote/oxynote/server/core/internal/apps/webchange"
"github.com/oxynote/oxynote/server/core/internal/datasource"
"github.com/oxynote/oxynote/server/core/internal/datasource/demo"
"github.com/oxynote/oxynote/server/core/internal/datasource/processor"
"github.com/oxynote/oxynote/server/core/internal/document"
"github.com/oxynote/oxynote/server/core/internal/document/hook"
orgCore "github.com/oxynote/oxynote/server/core/internal/org"
"github.com/oxynote/oxynote/server/core/internal/search"
"github.com/oxynote/oxynote/server/core/internal/server/internal/auth"
"github.com/oxynote/oxynote/server/core/internal/storage"
@ -22,24 +20,12 @@ import (
"github.com/oxynote/oxynote/server/core/pkg/errutil"
"github.com/oxynote/oxynote/server/core/pkg/httpserver"
"github.com/oxynote/oxynote/server/core/pkg/sqlutil"
"github.com/oxynote/oxynote/server/core/pkg/timeutil"
"github.com/rs/xid"
)
// _organizationsLogoFolderFormat is the folder where organization logos are stored.
const _organizationsLogoFolderFormat = "organizations/%s/logo"
// ErrNoOrganizationMembers is returned when an organization has no members.
var ErrNoOrganizationMembers = errutil.New(http.StatusBadRequest, "organization.no_members", "organization has no members")
// _seedTags are the tags a fresh organization starts with, in display
// order. The welcome document goes under the first of them.
var _seedTags = []tag.CreateInput{
{TagName: "Production", Color: "#00a63e"},
{TagName: "Staging", Color: "#f54a00"},
{TagName: "Incidents", Color: "#155dfc"},
}
// Handler holds dependencies required for organization-related operations.
type Handler struct {
log *slog.Logger
@ -48,10 +34,11 @@ type Handler struct {
githubMan *github.Manager
webchangeClient *webchange.Client
searchTrigger SearchTrigger
logoLocation string
publicURL string
}
// NewHandler creates a new handler instance with the provided logger and database.
// NewHandler creates a new handler instance with the provided logger and
// database. publicURL is the origin the Location of a logo is built on.
func NewHandler(
log *slog.Logger,
db DB,
@ -59,7 +46,7 @@ func NewHandler(
githubMan *github.Manager,
webchangeClient *webchange.Client,
searchTrigger SearchTrigger,
logoLocationFormat string,
publicURL string,
) *Handler {
return &Handler{
log: log,
@ -68,7 +55,7 @@ func NewHandler(
githubMan: githubMan,
webchangeClient: webchangeClient,
searchTrigger: searchTrigger,
logoLocation: logoLocationFormat,
publicURL: publicURL,
}
}
@ -95,8 +82,18 @@ func (h *Handler) InitializeOrganization(w http.ResponseWriter, r *http.Request)
// failed statement aborts a Postgres transaction, so logging and
// carrying on inside one would only move the failure to the commit.
// The welcome document drops its charts when the source is missing
// rather than pointing them at an id that was never stored.
dataSourceID := h.insertDemoDataSource(r.Context(), id)
// rather than pointing them at an id that was never stored. The
// failure is logged rather than returned: the demo content is a
// nicety, and an organization without it is still fully initialized.
var dataSourceID null.Value[xid.ID]
ds := datasource.NewDemoDataSource(id)
if err = h.db.InsertDataSource(r.Context(), ds); err != nil {
h.log.Error("inserting demo data source", slog.String("error", err.Error()))
} else {
dataSourceID = null.ValueFrom(ds.ID)
}
var tx Tx
@ -108,19 +105,12 @@ func (h *Handler) InitializeOrganization(w http.ResponseWriter, r *http.Request)
defer tx.Rollback() //nolint:errcheck // error provides no meaningful info
doc := document.NewDocument(document.CreateInput{
Name: "Welcome to Oxynote!",
Icon: "mingcute:flag-4-fill",
}, id, members[0])
content, err := document.InitialDocumentContent(dataSourceID)
doc, err := document.NewWelcomeDocument(id, members[0], dataSourceID)
if err != nil {
httpserver.RespondError(h.log, w, err)
return
}
doc.Content = content
if err = tx.InsertDocument(r.Context(), doc); err != nil {
httpserver.RespondError(h.log, w, err)
return
@ -133,7 +123,7 @@ func (h *Handler) InitializeOrganization(w http.ResponseWriter, r *http.Request)
var productionID xid.ID
for i, inp := range _seedTags {
for i, inp := range tag.SeedTags() {
t := tag.NewTag(inp, id, members[0])
if i == 0 {
@ -192,7 +182,7 @@ func (h *Handler) UploadOrganizationLogo(w http.ResponseWriter, r *http.Request)
return
}
logoFolder := fmt.Sprintf(_organizationsLogoFolderFormat, session.ActiveOrganizationID)
logoFolder := orgCore.LogoFolder(session.ActiveOrganizationID)
err = h.storer.Upload(r.Context(), logoFolder, session.ActiveOrganizationID, data, contentType)
if err != nil {
@ -200,8 +190,7 @@ func (h *Handler) UploadOrganizationLogo(w http.ResponseWriter, r *http.Request)
return
}
// Append a timestamp to the URL to prevent caching issues.
logoLocation := h.logoLocation + "?v=" + timeutil.Now().Format("20060102150405")
logoLocation := httpserver.CacheBust(h.publicURL + orgCore.LogoPath)
err = h.db.UpdateOrganizationLogo(r.Context(), session.ActiveOrganizationID, logoLocation)
if err != nil {
@ -231,7 +220,7 @@ func (h *Handler) RetrieveOrganizationLogo(w http.ResponseWriter, r *http.Reques
return
}
logoFolder := fmt.Sprintf(_organizationsLogoFolderFormat, session.ActiveOrganizationID)
logoFolder := orgCore.LogoFolder(session.ActiveOrganizationID)
obj, found, err := h.storer.Retrieve(r.Context(), logoFolder, session.ActiveOrganizationID)
if err != nil {
@ -319,7 +308,7 @@ func (h *Handler) TeardownOrganization(w http.ResponseWriter, r *http.Request) {
// the logo is the organization's own object; the documents' files are
// left to the file manager, which reclaims them once the cascade nulls
// their foreign keys.
err = h.storer.Delete(r.Context(), fmt.Sprintf(_organizationsLogoFolderFormat, id), id)
err = h.storer.Delete(r.Context(), orgCore.LogoFolder(id), id)
if err != nil {
httpserver.RespondError(h.log, w, err)
return
@ -333,26 +322,6 @@ func (h *Handler) TeardownOrganization(w http.ResponseWriter, r *http.Request) {
)
}
// insertDemoDataSource inserts the demo Prometheus data source and reports
// its id. A failure is logged rather than returned: the demo content is a
// nicety, and an organization without it is still fully initialized.
func (h *Handler) insertDemoDataSource(ctx context.Context, organizationID string) null.Value[xid.ID] {
ds := datasource.NewDataSource(datasource.CreateInput{
Type: datasource.TypePrometheus,
Name: "Demo",
URL: demo.URL,
Credentials: processor.NewCredentials([]byte(`{}`)),
}, organizationID)
if err := h.db.InsertDataSource(ctx, ds); err != nil {
h.log.Error("inserting demo data source", slog.String("error", err.Error()))
return null.Value[xid.ID]{}
}
return null.ValueFrom(ds.ID)
}
// extractOrganizationParameter extracts the document ID from the request parameters.
func (h *Handler) extractOrganizationParameter(r *http.Request) (string, error) {
return httpserver.ExtractParam(r, "organizationId")

View File

@ -107,7 +107,7 @@ func Test_NewHandler(t *testing.T) {
assert.Same(t, githubMan, hdl.githubMan)
assert.Same(t, webchangeClient, hdl.webchangeClient)
assert.Same(t, searchTrigger, hdl.searchTrigger)
assert.Equal(t, "loc", hdl.logoLocation)
assert.Equal(t, "loc", hdl.publicURL)
}
// assertSeededTags checks that the seeded tags were inserted in their
@ -501,7 +501,7 @@ func Test_Handler_UploadOrganizationLogo(t *testing.T) {
}
assert.Equal(t, "org1", ff[0].OrganizationID)
assert.Regexp(t, `^loc\?v=\d{14}$`, ff[0].Logo)
assert.Regexp(t, `^loc/api/organizations/logo\?v=\d{14}$`, ff[0].Logo)
}
}
@ -598,7 +598,7 @@ func Test_Handler_UploadOrganizationLogo(t *testing.T) {
func(t *testing.T, _ *DBMock, _ *StorerMock, rec *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusCreated, rec.Code)
assert.Zero(t, rec.Body.Len(), rec.Body.String())
assert.Regexp(t, `^loc\?v=\d{14}$`, rec.Header().Get("Location"))
assert.Regexp(t, `^loc/api/organizations/logo\?v=\d{14}$`, rec.Header().Get("Location"))
},
wasUploadCalled(1),
wasUpdateLogoCalled(1),
@ -612,10 +612,10 @@ func Test_Handler_UploadOrganizationLogo(t *testing.T) {
t.Parallel()
hdl := Handler{
log: slog.New(slog.DiscardHandler),
db: c.DB,
storer: c.Storer,
logoLocation: "loc",
log: slog.New(slog.DiscardHandler),
db: c.DB,
storer: c.Storer,
publicURL: "loc",
}
var (

View File

@ -2,17 +2,13 @@ package slack
import (
"context"
"time"
"github.com/oxynote/oxynote/server/core/internal/server/internal/auth"
"github.com/oxynote/oxynote/server/core/pkg/httpserver"
"github.com/oxynote/wetsocks/wsserver"
"github.com/rs/xid"
)
// _publishTimeout bounds each WebSocket publish triggered by a domain
// callback.
const _publishTimeout = 5 * time.Second
// Message represents a new message.
type Message struct {
// ID is the unique identifier for the message.
@ -22,7 +18,7 @@ type Message struct {
// BindPostMessage binds the post message callback to a WebSocket topic.
func (h *Handler) BindPostMessage(tpc wsserver.Topic) {
h.message.postCallback = func(organizationID string, id xid.ID) {
ctx, cancel := context.WithTimeout(context.Background(), _publishTimeout)
ctx, cancel := context.WithTimeout(context.Background(), httpserver.WSPublishTimeout)
defer cancel()
tpc.PublishMany(ctx, Message{ID: id}, auth.FilterOrganization(organizationID))

View File

@ -2,17 +2,13 @@ package tag
import (
"context"
"time"
"github.com/oxynote/oxynote/server/core/internal/server/internal/auth"
"github.com/oxynote/oxynote/server/core/pkg/httpserver"
"github.com/oxynote/wetsocks/wsserver"
"github.com/rs/xid"
)
// _publishTimeout bounds each WebSocket publish triggered by a domain
// callback.
const _publishTimeout = 5 * time.Second
// TreeChangeMessage represents a change in the tree of tags. The tree is
// small and always fetched whole, so the message carries no payload: it
// tells a subscriber to refetch.
@ -29,13 +25,13 @@ type BranchTagsChangeMessage struct {
// BindTreeChange binds a tag tree change event to the given topic.
func (h *Handler) BindTreeChange(tpc wsserver.Topic) {
h.tree.changeCallback = func(organizationID string) {
ctx, cancel := context.WithTimeout(context.Background(), _publishTimeout)
ctx, cancel := context.WithTimeout(context.Background(), httpserver.WSPublishTimeout)
defer cancel()
tpc.PublishMany(ctx, TreeChangeMessage{}, auth.FilterOrganization(organizationID))
}
h.tree.userChangeCallback = func(organizationID, userID string) {
ctx, cancel := context.WithTimeout(context.Background(), _publishTimeout)
ctx, cancel := context.WithTimeout(context.Background(), httpserver.WSPublishTimeout)
defer cancel()
tpc.PublishMany(ctx, TreeChangeMessage{}, auth.FilterUser(organizationID, userID))
@ -46,7 +42,7 @@ func (h *Handler) BindTreeChange(tpc wsserver.Topic) {
// topic, which is scoped to one document.
func (h *Handler) BindBranchTagsChange(tpc wsserver.Topic) {
h.branchTags.changeCallback = func(organizationID string, documentID, branchID xid.ID) {
ctx, cancel := context.WithTimeout(context.Background(), _publishTimeout)
ctx, cancel := context.WithTimeout(context.Background(), httpserver.WSPublishTimeout)
defer cancel()
tpc.PublishMany(

View File

@ -3,42 +3,37 @@ package user
import (
"context"
"fmt"
"log/slog"
"net/http"
"github.com/oxynote/oxynote/server/core/internal/server/internal/auth"
"github.com/oxynote/oxynote/server/core/internal/storage"
userCore "github.com/oxynote/oxynote/server/core/internal/user"
"github.com/oxynote/oxynote/server/core/pkg/httpserver"
"github.com/oxynote/oxynote/server/core/pkg/sqlutil"
"github.com/oxynote/oxynote/server/core/pkg/timeutil"
)
// _userImageFolder is the folder where user images are stored. It is keyed
// by user only: the avatar lives on the global user row, so an org-scoped
// object would 404 for viewers with a different active organization.
const _userImageFolder = "users/images"
// Handler holds dependencies required for user-related operations.
type Handler struct {
log *slog.Logger
db DB
storer Storer
imageLocationFormat string
log *slog.Logger
db DB
storer Storer
publicURL string
}
// NewHandler creates a new handler instance with the provided logger and database.
// NewHandler creates a new handler instance with the provided logger and
// database. publicURL is the origin the Location of an image is built on.
func NewHandler(
log *slog.Logger,
db DB,
storer Storer,
imageLocationFormat string,
publicURL string,
) *Handler {
return &Handler{
log: log,
db: db,
storer: storer,
imageLocationFormat: imageLocationFormat,
log: log,
db: db,
storer: storer,
publicURL: publicURL,
}
}
@ -54,7 +49,7 @@ func (h *Handler) RetrieveUserImage(w http.ResponseWriter, r *http.Request) {
return
}
obj, found, err := h.storer.Retrieve(r.Context(), _userImageFolder, userID)
obj, found, err := h.storer.Retrieve(r.Context(), userCore.ImageFolder, userID)
if err != nil {
httpserver.RespondError(h.log, w, err)
return
@ -97,18 +92,17 @@ func (h *Handler) UploadUserImage(w http.ResponseWriter, r *http.Request) {
return
}
err = h.storer.Upload(r.Context(), _userImageFolder, session.UserID, data, contentType)
err = h.storer.Upload(r.Context(), userCore.ImageFolder, session.UserID, data, contentType)
if err != nil {
httpserver.RespondError(h.log, w, err)
return
}
// Append a timestamp to the URL to prevent caching issues.
imageLocation := fmt.Sprintf(h.imageLocationFormat, session.UserID) + "?v=" + timeutil.Now().Format("20060102150405")
imageLocation := httpserver.CacheBust(h.publicURL + userCore.ImagePath(session.UserID))
err = h.db.UpdateUserImage(r.Context(), session.UserID, imageLocation)
if err != nil {
derr := h.storer.Delete(r.Context(), _userImageFolder, session.UserID)
derr := h.storer.Delete(r.Context(), userCore.ImageFolder, session.UserID)
if derr != nil {
h.log.Error("deleting object after DB failure", slog.String("error", derr.Error()))
}

View File

@ -60,12 +60,12 @@ func Test_NewHandler(t *testing.T) {
db := &DBMock{}
storer := &StorerMock{}
hdl := NewHandler(slog.New(slog.DiscardHandler), db, storer, "loc/%s")
hdl := NewHandler(slog.New(slog.DiscardHandler), db, storer, "loc")
require.NotNil(t, hdl)
assert.NotNil(t, hdl.log)
assert.Same(t, db, hdl.db)
assert.Same(t, storer, hdl.storer)
assert.Equal(t, "loc/%s", hdl.imageLocationFormat)
assert.Equal(t, "loc", hdl.publicURL)
}
func Test_Handler_RetrieveUserImage(t *testing.T) {
@ -264,7 +264,7 @@ func Test_Handler_UploadUserImage(t *testing.T) {
}
assert.Equal(t, "u1", ff[0].UserID)
assert.Regexp(t, `^loc/u1\?v=\d{14}$`, ff[0].Image)
assert.Regexp(t, `^loc/api/users/u1/image\?v=\d{14}$`, ff[0].Image)
}
}
@ -374,7 +374,7 @@ func Test_Handler_UploadUserImage(t *testing.T) {
func(t *testing.T, _ *DBMock, _ *StorerMock, rec *httptest.ResponseRecorder) {
assert.Equal(t, http.StatusCreated, rec.Code)
assert.Zero(t, rec.Body.Len(), rec.Body.String())
assert.Regexp(t, `^loc/u1\?v=\d{14}$`, rec.Header().Get("Location"))
assert.Regexp(t, `^loc/api/users/u1/image\?v=\d{14}$`, rec.Header().Get("Location"))
},
wasUploadCalled(1),
wasUpdateUserImageCalled(1),
@ -388,10 +388,10 @@ func Test_Handler_UploadUserImage(t *testing.T) {
t.Parallel()
hdl := Handler{
log: slog.New(slog.DiscardHandler),
db: c.DB,
storer: c.Storer,
imageLocationFormat: "loc/%s",
log: slog.New(slog.DiscardHandler),
db: c.DB,
storer: c.Storer,
publicURL: "loc",
}
var (

View File

@ -15,12 +15,6 @@ import (
)
const (
// _userImageLocationFormat is the URL format for user images.
_userImageLocationFormat = "/api/users/%s/image"
// _organizationLogoLocation is the URL path for organization logos.
_organizationLogoLocation = "/api/organizations/logo"
// _requestTimeout bounds the processing of a single HTTP request.
_requestTimeout = 30 * time.Second
@ -241,6 +235,8 @@ func (s *Server) router() chi.Router {
})
})
// the image and logo routes are mounted on user.ImagePathFormat and
// org.LogoPath: the rows store those paths.
r.Route("/users", func(sr chi.Router) {
sr.Put("/image", s.handlers.user.UploadUserImage)
sr.Get("/{userId}/image", s.handlers.user.RetrieveUserImage)

View File

@ -165,7 +165,7 @@ func NewServer(
},
}
srv.handlers.user = user.NewHandler(log, db, storageClient, opts.PublicURL+_userImageLocationFormat)
srv.handlers.user = user.NewHandler(log, db, storageClient, opts.PublicURL)
srv.handlers.organization = org.NewHandler(
log,
db,
@ -173,7 +173,7 @@ func NewServer(
githubMan,
webchangeClient,
searchTrigger,
opts.PublicURL+_organizationLogoLocation,
opts.PublicURL,
)
srv.handlers.document = document.NewHandler(log, db, githubMan, webchangeClient, searcher, searchTrigger, notifier, storageClient)
srv.handlers.tag = tag.NewHandler(log, db)

View File

@ -67,6 +67,19 @@ func (p Policy) MaxUploadBytes() int64 {
return p.MaxSize + _uploadHeadroom
}
// PolicyForKind maps the kind of block an upload belongs to onto the
// policy it is admitted under, reporting whether the kind is one at all.
func PolicyForKind(kind string) (Policy, bool) {
switch kind {
case "image":
return ImagePolicy, true
case "file":
return FilePolicy, true
default:
return Policy{}, false
}
}
// ObjectInfo contains metadata about a retrieved object.
type ObjectInfo struct {
// Body is the object's data stream.

View File

@ -32,6 +32,29 @@ func Test_Policy_MaxUploadBytes(t *testing.T) {
assert.EqualValues(t, 25*1024*1024+64*1024, FilePolicy.MaxUploadBytes())
}
func Test_PolicyForKind(t *testing.T) {
cc := map[string]struct {
Kind string
Result Policy
OK bool
}{
"Image kind": {Kind: "image", Result: ImagePolicy, OK: true},
"File kind": {Kind: "file", Result: FilePolicy, OK: true},
"Unknown kind": {Kind: "video"},
"Empty kind": {},
}
for cn, c := range cc {
t.Run(cn, func(t *testing.T) {
t.Parallel()
res, ok := PolicyForKind(c.Kind)
assert.Equal(t, c.OK, ok)
assert.Equal(t, c.Result, res)
})
}
}
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m)
}

View File

@ -0,0 +1,11 @@
package tag
// SeedTags returns the tags a fresh organization starts with, in display
// order. The welcome document goes under the first of them.
func SeedTags() []CreateInput {
return []CreateInput{
{TagName: "Production", Color: ColorHex("green")},
{TagName: "Staging", Color: ColorHex("orange")},
{TagName: "Incidents", Color: ColorHex("blue")},
}
}

View File

@ -0,0 +1,24 @@
package tag
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_SeedTags(t *testing.T) {
t.Parallel()
tags := SeedTags()
require.Len(t, tags, 3)
names := make([]string, 0, len(tags))
for _, inp := range tags {
names = append(names, inp.TagName)
assert.NoError(t, inp.Validate())
}
assert.Equal(t, []string{"Production", "Staging", "Incidents"}, names)
}

View File

@ -0,0 +1,22 @@
// Package user defines what a user owns outside of Postgres: the storage
// layout of their image and the URL the image is served under.
package user
import "fmt"
// ImageFolder is the storage folder holding every user's image, each under
// its user id. It is keyed by user only: the avatar lives on the global
// user row, so an org-scoped object would 404 for viewers with a different
// active organization.
const ImageFolder = "users/images"
// ImagePathFormat is the URL path format a user's image is served under,
// taking the user id. The router mounts the image routes on this very
// format, and the value is stored on the user row, so the two must not
// drift apart.
const ImagePathFormat = "/api/users/%s/image"
// ImagePath returns the URL path the given user's image is served under.
func ImagePath(userID string) string {
return fmt.Sprintf(ImagePathFormat, userID)
}

View File

@ -0,0 +1,18 @@
package user
import (
"testing"
"github.com/stretchr/testify/assert"
"go.uber.org/goleak"
)
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m)
}
func Test_ImagePath(t *testing.T) {
t.Parallel()
assert.Equal(t, "/api/users/u1/image", ImagePath("u1"))
}

View File

@ -17,6 +17,7 @@ import (
"github.com/gorilla/schema"
"github.com/oxynote/oxynote/server/core/pkg/errutil"
"github.com/oxynote/oxynote/server/core/pkg/logutil"
"github.com/oxynote/oxynote/server/core/pkg/timeutil"
"github.com/rs/xid"
)
@ -24,6 +25,10 @@ import (
// that critical reports point at the handler instead of this helper.
const _criticalSkipFrames = 2
// WSPublishTimeout bounds each WebSocket publish triggered by a domain
// callback.
const WSPublishTimeout = 5 * time.Second
var (
// ErrNotAuthenticated is returned when authorization process fails.
ErrNotAuthenticated = errutil.New(http.StatusUnauthorized, "account.not_authenticated", "not authenticated")
@ -312,6 +317,13 @@ func ExtractQueryID(r *http.Request, name string) (xid.ID, error) {
return id, nil
}
// CacheBust appends a version query carrying the current time to a
// location that has no query of its own, so a browser refetches an
// object that was replaced under an unchanged URL.
func CacheBust(location string) string {
return location + "?v=" + timeutil.Now().Format("20060102150405")
}
// Timeout is a middleware that cancels request's context after a given
// time period.
func Timeout(timeout time.Duration) func(next http.Handler) http.Handler {

View File

@ -521,6 +521,12 @@ func Test_ExtractQueryID(t *testing.T) {
assert.NoError(t, err)
}
func Test_CacheBust(t *testing.T) {
t.Parallel()
assert.Regexp(t, `^/api/users/u1/image\?v=\d{14}$`, CacheBust("/api/users/u1/image"))
}
func Test_Timeout(t *testing.T) {
Timeout(time.Millisecond)(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
<-r.Context().Done()

View File

@ -1,18 +1,21 @@
package strutil
import "crypto/rand"
import (
"crypto/rand"
"strings"
)
// _nanoidAlphabet is the default alphabet used by nanoid.
// This matches the Node.js nanoid default: A-Za-z0-9_-.
const _nanoidAlphabet = "useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict"
// _nanoidLength is the default length of nanoid IDs (21 characters).
const _nanoidLength = 21
// NanoIDLength is the default length of nanoid IDs (21 characters).
const NanoIDLength = 21
// NanoID generates a nanoid-compatible unique identifier. The output is 1:1
// compatible with the Node.js nanoid() function using default settings.
func NanoID() string {
bytes := make([]byte, _nanoidLength)
bytes := make([]byte, NanoIDLength)
_, err := rand.Read(bytes)
if err != nil {
@ -20,11 +23,27 @@ func NanoID() string {
panic(err)
}
id := make([]byte, _nanoidLength)
id := make([]byte, NanoIDLength)
for i := range _nanoidLength {
for i := range NanoIDLength {
id[i] = _nanoidAlphabet[bytes[i]&63]
}
return string(id)
}
// IsNanoID reports whether s has the exact shape NanoID produces: the
// default length, every byte from the default alphabet.
func IsNanoID(s string) bool {
if len(s) != NanoIDLength {
return false
}
for i := range len(s) {
if !strings.ContainsRune(_nanoidAlphabet, rune(s[i])) {
return false
}
}
return true
}

View File

@ -15,7 +15,7 @@ func Test_NanoID(t *testing.T) {
for range 100 {
id := NanoID()
assert.Len(t, id, _nanoidLength)
assert.Len(t, id, NanoIDLength)
for _, r := range id {
assert.True(
@ -30,3 +30,26 @@ func Test_NanoID(t *testing.T) {
assert.Len(t, seen, 100, "generated IDs should be unique")
}
func Test_IsNanoID(t *testing.T) {
cc := map[string]struct {
ID string
Result bool
}{
"Generated id": {ID: NanoID(), Result: true},
"Id with dashes": {ID: "f1-x_-xxxxxxxxxxxxxxx", Result: true},
"Short": {ID: "f1"},
"Long": {ID: "f1xxxxxxxxxxxxxxxxxxxx"},
"Wrong charset": {ID: "f1.................xx"},
"Multi-byte runes": {ID: "é" + "xxxxxxxxxxxxxxxxxxx"},
"Empty": {},
}
for cn, c := range cc {
t.Run(cn, func(t *testing.T) {
t.Parallel()
assert.Equal(t, c.Result, IsNanoID(c.ID))
})
}
}