Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 137 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,139 @@
# nexo
# Nexo

Nexo is a self-hosted alternative to Notion. Organize documents, build flexible databases with multiple views (table, board, calendar, gallery), and automate workflows — all under your control.

## Getting Started

### Prerequisites

- Go 1.22+
- SQLite (default) or PostgreSQL

### Installation

```bash
go build -o nexo ./cmd
```

### Running

```bash
# Minimal — SQLite, required vars only
SESSION_SECRET_KEY="your-secret-at-least-32-chars-long" ./nexo server

# With config file
./nexo server --config config
```

---

## Configuration

All options can be set via environment variable, CLI flag, or `config.yaml`.
Priority: **env var > CLI flag > config file > default**.

### Required

| Env var | Description |
|---------|-------------|
| `SESSION_SECRET_KEY` | JWT signing key. **Minimum 32 characters.** The server refuses to start if this is shorter or unset. Generate with: `openssl rand -base64 48` |

### Server

| Env var | CLI flag | Default | Description |
|---------|----------|---------|-------------|
| `HTTP_PORT` | `--http.port` | `8080` | Listening port |
| `HTTP_LOGS` | `--http.logs` | `false` | Enable HTTP access logs |
| `HTTP_CORS_ALLOW_ORIGINS` | `--http.cors_allow_origins` | `*` | Comma-separated list of allowed CORS origins. **Set this in production** (e.g. `https://app.example.com`). Use `*` only for local dev. |

### Database

| Env var | CLI flag | Default | Description |
|---------|----------|---------|-------------|
| `DATABASE_DIALECT` | `--database.dialect` | `sqlite` | `sqlite` or `postgres` |
| `DATABASE_DSN` | `--database.dsn` | `./database.sqlite` | SQLite file path or PostgreSQL DSN |

### Session / JWT

| Env var | CLI flag | Default | Description |
|---------|----------|---------|-------------|
| `SESSION_SECRET_KEY` | `--session.secret_key` | *(none)* | **Required.** ≥ 32 chars |
| `SESSION_EXPIRATION_MINUTES` | `--session.expiration_minutes` | `43200` (30 days) | Token lifetime in minutes |
| `SESSION_ISSUER` | `--session.issuer` | `nexo` | JWT `iss` claim |

### Logger

| Env var | CLI flag | Default | Description |
|---------|----------|---------|-------------|
| `LOGGER_LEVEL` | `--logger.level` | `info` | `debug`, `info`, `warn`, `error` |
| `LOGGER_PRETTY` | `--logger.pretty` | `false` | Human-readable logs (dev only) |

---

## Config file (`config.yaml`)

```yaml
http:
port: 8080
logs: true
cors_allow_origins: "https://app.example.com"

logger:
level: info
pretty: false

database:
dialect: sqlite
dsn: ./database.sqlite

session:
# Required — do not commit real values to source control
# Generate: openssl rand -base64 48
secret_key: "CHANGE_ME_AT_LEAST_32_CHARACTERS_LONG"
expiration_minutes: 43200
issuer: nexo
```

See `config-example.yaml` for a minimal working example.

---

## Docker

```bash
docker build -t nexo .
docker run -p 8080:8080 \
-e SESSION_SECRET_KEY="$(openssl rand -base64 48)" \
-e HTTP_CORS_ALLOW_ORIGINS="https://app.example.com" \
-e DATABASE_DSN="/data/nexo.sqlite" \
-v nexo_data:/data \
nexo
```

Or with `docker-compose.yaml`:

```bash
# Copy and fill in secrets
cp .env.example .env
docker compose up -d
```

---

## WebSocket collaboration

The collaboration endpoint at `/ws/collab/<roomId>` requires a valid JWT passed as the `token` query parameter. Every connection is authorized against the resource identified by the room ID:

| Room prefix | Resource checked |
|-------------|-----------------|
| `document:{id}` | Document permissions |
| `drawing:{id}` | Drawing → space permissions |
| `row:{dbId}:{rowId}` | Database → space permissions |

Connections with an invalid token, unknown room format, or insufficient permissions are rejected.

---

## License

MIT
21 changes: 18 additions & 3 deletions application/action/execute_actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,15 @@ func (app *ActionApplication) ExecuteActions(input dto.ExecuteActionInput) {
}

for _, action := range actions {
go app.executeAction(action, input.TriggerData)
a := action
go func() {
defer func() {
if r := recover(); r != nil {
app.Logger.Error().Any("panic", r).Str("action_id", a.Id).Msg("action execution panicked")
}
}()
app.executeAction(a, input.TriggerData)
}()
}
}

Expand All @@ -40,8 +48,15 @@ func (app *ActionApplication) executeAction(action domain.Action, triggerData ma
// Parse steps
var steps []dto.ActionStep
if action.Steps != nil {
stepsJSON, _ := json.Marshal(action.Steps)
json.Unmarshal(stepsJSON, &steps)
stepsJSON, err := json.Marshal(action.Steps)
if err != nil {
logger.Error().Err(err).Msg("failed to marshal action steps")
return
}
if err := json.Unmarshal(stepsJSON, &steps); err != nil {
logger.Error().Err(err).Msg("failed to unmarshal action steps")
return
}
}

// Execute each step
Expand Down
6 changes: 3 additions & 3 deletions application/session/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,18 @@ func (c *SessionApplication) ValidateToken(input dto.ValidateTokenInput) (*dto.V

sessionId, err := tokenutil.GetSessionIdFromToken(input.Token, c.Config)
if err != nil {
logger.Error().Err(err).Str("token", input.Token).Msg("failed to get session id from token")
logger.Error().Err(err).Msg("failed to get session id from token")
return nil, apperrors.ErrInvalidToken
}

session, err := c.SessionPers.GetById(sessionId)
if err != nil {
logger.Error().Err(err).Str("token", input.Token).Msg("failed to get session by token")
logger.Error().Err(err).Str("session_id", sessionId).Msg("failed to get session by id")
return nil, apperrors.ErrInvalidToken
}

if session.ExpiresAt.Before(time.Now()) {
logger.Warn().Str("token", input.Token).Msg("session has expired")
logger.Warn().Str("session_id", sessionId).Msg("session has expired")
return nil, apperrors.ErrSessionExpired
}

Expand Down
19 changes: 14 additions & 5 deletions application/webhook/trigger_webhooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"time"

Expand All @@ -23,7 +24,15 @@ func (app *WebhookApplication) TriggerWebhooks(input dto.TriggerWebhookInput) {
}

for _, webhook := range webhooks {
go app.deliverWebhook(webhook, input.Event, input.Payload)
w := webhook
go func() {
defer func() {
if r := recover(); r != nil {
app.Logger.Error().Any("panic", r).Str("webhook_id", w.Id).Msg("webhook delivery panicked")
}
}()
app.deliverWebhook(w, input.Event, input.Payload)
}()
}
}

Expand Down Expand Up @@ -61,10 +70,11 @@ func (app *WebhookApplication) deliverWebhook(webhook domain.Webhook, event stri
return
}

deliveryId, _ := webhookPayload["id"].(string)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Webhook-Signature", signature)
req.Header.Set("X-Webhook-Event", event)
req.Header.Set("X-Webhook-Delivery", webhookPayload["id"].(string))
req.Header.Set("X-Webhook-Delivery", deliveryId)

client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
Expand All @@ -81,9 +91,8 @@ func (app *WebhookApplication) deliverWebhook(webhook domain.Webhook, event stri
success := resp.StatusCode >= 200 && resp.StatusCode < 300
responseBody := ""
if !success {
buf := new(bytes.Buffer)
buf.ReadFrom(resp.Body)
responseBody = buf.String()
limited, _ := io.ReadAll(io.LimitReader(resp.Body, 512*1024))
responseBody = string(limited)
if len(responseBody) > 500 {
responseBody = responseBody[:500]
}
Expand Down
18 changes: 17 additions & 1 deletion config-example.yaml
Original file line number Diff line number Diff line change
@@ -1,9 +1,25 @@
http:
port: 8080
logs: true
# Comma-separated list of allowed CORS origins.
# Use * for local dev only. Set to your frontend URL in production.
cors_allow_origins: "*"

logger:
level: info
pretty: false

database:
dialect: sqlite
dsn: ./database.sqlite
dsn: ./database.sqlite
# PostgreSQL example:
# dialect: postgres
# dsn: "host=localhost user=nexo password=nexo dbname=nexo sslmode=disable"

session:
# Required — must be at least 32 characters.
# Generate: openssl rand -base64 48
# Can also be set via SESSION_SECRET_KEY env var (recommended in production).
secret_key: "CHANGE_ME_USE_openssl_rand_base64_48"
expiration_minutes: 43200 # 30 days
issuer: nexo
13 changes: 11 additions & 2 deletions domain/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package domain
import (
"database/sql/driver"
"encoding/json"
"fmt"
)

// JSONB is a map of strings to interfaces
Expand All @@ -20,7 +21,11 @@ func (j *JSONB) Scan(value any) error {
*j = nil
return nil
}
return json.Unmarshal([]byte(value.(string)), j)
s, ok := value.(string)
if !ok {
return fmt.Errorf("JSONB.Scan: expected string, got %T", value)
}
return json.Unmarshal([]byte(s), j)
}

// JSONBArray is a slice that can be stored as JSONB in PostgreSQL
Expand All @@ -38,5 +43,9 @@ func (j *JSONBArray) Scan(value any) error {
*j = nil
return nil
}
return json.Unmarshal([]byte(value.(string)), j)
s, ok := value.(string)
if !ok {
return fmt.Errorf("JSONBArray.Scan: expected string, got %T", value)
}
return json.Unmarshal([]byte(s), j)
}
Loading
Loading