init codebase
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
AJET__DB__PASSWORD=change-me-in-production
|
||||
GITHUB_CLIENT_ID=your-github-oauth-app-client-id
|
||||
GITHUB_CLIENT_SECRET=your-github-oauth-app-client-secret
|
||||
MINIO_ACCESS_KEY=minioadmin
|
||||
MINIO_SECRET_KEY=minioadmin
|
||||
@@ -30,17 +30,54 @@ CLI — — yes (external, via Auth GW)
|
||||
|
||||
## Common Commands
|
||||
|
||||
### Task Runner (Babashka)
|
||||
|
||||
Preferred way to run tasks — `bb` wraps `clj` commands and manages Docker infra automatically.
|
||||
|
||||
```bash
|
||||
bb tasks # List all available tasks
|
||||
|
||||
# Development
|
||||
bb dev # Start dev infra + nREPL (all modules)
|
||||
bb infra:dev # Start dev Docker infra only
|
||||
bb infra:dev:stop # Stop dev Docker infra
|
||||
|
||||
# Testing
|
||||
bb test # All tests (starts test infra)
|
||||
bb test:unit # Unit tests only (no Docker needed)
|
||||
bb test:integration # Integration tests (starts test infra)
|
||||
bb test:e2e # E2E tests (starts test infra)
|
||||
|
||||
# Per-module testing
|
||||
bb test:shared # All shared tests
|
||||
bb test:shared:unit # Shared unit tests only
|
||||
bb test:api # All API tests
|
||||
bb test:api:unit # API unit tests only
|
||||
bb test:auth-gw # All auth-gw tests
|
||||
|
||||
# Per-module bb.edn (from module directory)
|
||||
cd shared && bb test:unit # Delegates to root bb.edn
|
||||
|
||||
# Build & Deploy
|
||||
bb build api # Build uberjar for a module
|
||||
bb clean api # Clean module artifacts (or bb clean for all)
|
||||
bb prod # Start production stack (docker compose)
|
||||
bb prod:stop # Stop production stack
|
||||
bb prod:logs # Tail production logs
|
||||
|
||||
# Database
|
||||
bb db:reset-test # Drop & recreate test DB schema
|
||||
```
|
||||
|
||||
### Running Services (REPL-driven)
|
||||
|
||||
```bash
|
||||
# Single REPL with all modules
|
||||
clj -A:dev:api:web-sm:tui-sm:auth-gw
|
||||
# Via bb (recommended)
|
||||
bb dev # nREPL with all modules + dev infra
|
||||
|
||||
# Individual service REPLs
|
||||
clj -M:dev:api # API service
|
||||
clj -M:dev:web-sm # Web session manager
|
||||
clj -M:dev:tui-sm # TUI session manager
|
||||
clj -M:dev:auth-gw # Auth gateway
|
||||
# Via clj directly
|
||||
clj -A:dev:api:web-sm:tui-sm:auth-gw # Single REPL with all modules
|
||||
clj -M:dev:api # Individual service REPL
|
||||
```
|
||||
|
||||
Services expose `(start!)` / `(stop!)` / `(reset!)` in their REPL namespaces.
|
||||
@@ -48,10 +85,15 @@ Services expose `(start!)` / `(stop!)` / `(reset!)` in their REPL namespaces.
|
||||
### Testing (Kaocha)
|
||||
|
||||
```bash
|
||||
clj -M:test/unit # Unit tests — no Docker needed
|
||||
clj -M:test/integration # Integration — requires Docker (Postgres + MinIO + NATS)
|
||||
clj -M:test/e2e # E2E — requires full stack in Docker
|
||||
clj -M:test/all # All tiers
|
||||
# Via bb (recommended — manages Docker infra automatically)
|
||||
bb test:unit # Unit tests — no Docker needed
|
||||
bb test:integration # Integration — starts Docker automatically
|
||||
bb test # All tiers
|
||||
|
||||
# Via clj directly (legacy — requires manual Docker management)
|
||||
clj -M:test/base:test/unit # Unit tests
|
||||
clj -M:test/base:test/integration # Integration tests
|
||||
clj -M:test -m kaocha.runner # All tests (using unified :test alias)
|
||||
```
|
||||
|
||||
Docker infra for integration tests: `docker compose -f docker-compose.test.yml up -d`
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
FROM clojure:temurin-21-tools-deps AS builder
|
||||
WORKDIR /app
|
||||
COPY deps.edn build.clj ./
|
||||
COPY shared/ shared/
|
||||
COPY api/ api/
|
||||
RUN clj -T:build uber :module api
|
||||
|
||||
FROM eclipse-temurin:21-jre-alpine
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/api/target/api.jar app.jar
|
||||
EXPOSE 3001
|
||||
CMD ["java", "-jar", "app.jar"]
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{:tasks
|
||||
{test
|
||||
{:doc "Run all API module tests"
|
||||
:task (shell {:dir ".."} "bb test:api")}
|
||||
|
||||
test:unit
|
||||
{:doc "Run API unit tests only"
|
||||
:task (shell {:dir ".."} "bb test:api:unit")}
|
||||
|
||||
test:integration
|
||||
{:doc "Run API integration tests"
|
||||
:task (shell {:dir ".."} "bb test:api:integration")}}}
|
||||
@@ -0,0 +1,23 @@
|
||||
{:server {:host "0.0.0.0" :port 3001}
|
||||
:db {:host "localhost" :port 5432 :dbname "ajet_chat"
|
||||
:user "ajet" :password "ajet_dev" :pool-size 10
|
||||
:migrations {:enabled true :location "migrations"}}
|
||||
:nats {:url "nats://localhost:4222"
|
||||
:stream-name "ajet-events"
|
||||
:publish-timeout-ms 5000}
|
||||
:minio {:endpoint "http://localhost:9000"
|
||||
:access-key "minioadmin" :secret-key "minioadmin"
|
||||
:bucket "ajet-chat"}
|
||||
:limits {:max-message-length 4000
|
||||
:max-upload-size 10485760
|
||||
:edit-window-minutes 60
|
||||
:default-page-size 50
|
||||
:max-page-size 100}
|
||||
|
||||
:profiles
|
||||
{:test {:db {:host "localhost" :port 5433 :dbname "ajet_chat_test"
|
||||
:password "ajet_test"}
|
||||
:nats {:url "nats://localhost:4223"}
|
||||
:minio {:endpoint "http://localhost:9002"}}
|
||||
:prod {:db {:pool-size 20}
|
||||
:nats {:publish-timeout-ms 10000}}}}
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS users;
|
||||
@@ -0,0 +1,10 @@
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
username text UNIQUE NOT NULL,
|
||||
display_name text,
|
||||
email text,
|
||||
avatar_url text,
|
||||
status_text text,
|
||||
last_seen_at timestamptz,
|
||||
created_at timestamptz DEFAULT now()
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS oauth_accounts;
|
||||
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE IF NOT EXISTS oauth_accounts (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||
provider text NOT NULL,
|
||||
provider_user_id text NOT NULL,
|
||||
provider_username text,
|
||||
created_at timestamptz DEFAULT now(),
|
||||
UNIQUE (provider, provider_user_id)
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS communities;
|
||||
@@ -0,0 +1,6 @@
|
||||
CREATE TABLE IF NOT EXISTS communities (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name text NOT NULL,
|
||||
slug text UNIQUE NOT NULL,
|
||||
created_at timestamptz DEFAULT now()
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS community_members;
|
||||
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE IF NOT EXISTS community_members (
|
||||
community_id uuid NOT NULL REFERENCES communities (id) ON DELETE CASCADE,
|
||||
user_id uuid NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||
role text NOT NULL CHECK (role IN ('owner', 'admin', 'member')),
|
||||
nickname text,
|
||||
avatar_url text,
|
||||
joined_at timestamptz DEFAULT now(),
|
||||
PRIMARY KEY (community_id, user_id)
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS channel_categories;
|
||||
@@ -0,0 +1,6 @@
|
||||
CREATE TABLE IF NOT EXISTS channel_categories (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
community_id uuid NOT NULL REFERENCES communities (id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
position int NOT NULL DEFAULT 0
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS channels;
|
||||
@@ -0,0 +1,10 @@
|
||||
CREATE TABLE IF NOT EXISTS channels (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
community_id uuid REFERENCES communities (id) ON DELETE CASCADE,
|
||||
category_id uuid REFERENCES channel_categories (id) ON DELETE SET NULL,
|
||||
name text NOT NULL,
|
||||
type text NOT NULL CHECK (type IN ('text', 'dm', 'group_dm')),
|
||||
visibility text NOT NULL DEFAULT 'public' CHECK (visibility IN ('public', 'private')),
|
||||
topic text,
|
||||
created_at timestamptz DEFAULT now()
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS channel_members;
|
||||
@@ -0,0 +1,7 @@
|
||||
CREATE TABLE IF NOT EXISTS channel_members (
|
||||
channel_id uuid NOT NULL REFERENCES channels (id) ON DELETE CASCADE,
|
||||
user_id uuid NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||
joined_at timestamptz DEFAULT now(),
|
||||
last_read_message_id uuid,
|
||||
PRIMARY KEY (channel_id, user_id)
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS messages;
|
||||
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
channel_id uuid NOT NULL REFERENCES channels (id) ON DELETE CASCADE,
|
||||
user_id uuid REFERENCES users (id) ON DELETE SET NULL,
|
||||
parent_id uuid REFERENCES messages (id) ON DELETE SET NULL,
|
||||
body_md text NOT NULL,
|
||||
created_at timestamptz DEFAULT now(),
|
||||
edited_at timestamptz
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS attachments;
|
||||
@@ -0,0 +1,8 @@
|
||||
CREATE TABLE IF NOT EXISTS attachments (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
message_id uuid NOT NULL REFERENCES messages (id) ON DELETE CASCADE,
|
||||
filename text NOT NULL,
|
||||
content_type text NOT NULL,
|
||||
size_bytes bigint NOT NULL,
|
||||
storage_key text NOT NULL
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS reactions;
|
||||
@@ -0,0 +1,7 @@
|
||||
CREATE TABLE IF NOT EXISTS reactions (
|
||||
message_id uuid NOT NULL REFERENCES messages (id) ON DELETE CASCADE,
|
||||
user_id uuid NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||
emoji text NOT NULL,
|
||||
created_at timestamptz DEFAULT now(),
|
||||
PRIMARY KEY (message_id, user_id, emoji)
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS mentions;
|
||||
@@ -0,0 +1,6 @@
|
||||
CREATE TABLE IF NOT EXISTS mentions (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
message_id uuid NOT NULL REFERENCES messages (id) ON DELETE CASCADE,
|
||||
target_type text NOT NULL CHECK (target_type IN ('user', 'channel', 'here')),
|
||||
target_id uuid
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS notifications;
|
||||
@@ -0,0 +1,8 @@
|
||||
CREATE TABLE IF NOT EXISTS notifications (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||
type text NOT NULL CHECK (type IN ('mention', 'dm', 'thread_reply', 'invite', 'system')),
|
||||
source_id uuid,
|
||||
read boolean DEFAULT false,
|
||||
created_at timestamptz DEFAULT now()
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS sessions;
|
||||
@@ -0,0 +1,7 @@
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||
token_hash text NOT NULL,
|
||||
expires_at timestamptz NOT NULL,
|
||||
created_at timestamptz DEFAULT now()
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS api_users;
|
||||
@@ -0,0 +1,7 @@
|
||||
CREATE TABLE IF NOT EXISTS api_users (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name text NOT NULL,
|
||||
community_id uuid NOT NULL REFERENCES communities (id) ON DELETE CASCADE,
|
||||
created_by uuid REFERENCES users (id),
|
||||
created_at timestamptz DEFAULT now()
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS api_tokens;
|
||||
@@ -0,0 +1,8 @@
|
||||
CREATE TABLE IF NOT EXISTS api_tokens (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
api_user_id uuid NOT NULL REFERENCES api_users (id) ON DELETE CASCADE,
|
||||
token_hash text NOT NULL,
|
||||
scopes text[] DEFAULT '{}',
|
||||
expires_at timestamptz,
|
||||
created_at timestamptz DEFAULT now()
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS webhooks;
|
||||
@@ -0,0 +1,10 @@
|
||||
CREATE TABLE IF NOT EXISTS webhooks (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
community_id uuid NOT NULL REFERENCES communities (id) ON DELETE CASCADE,
|
||||
channel_id uuid NOT NULL REFERENCES channels (id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
avatar_url text,
|
||||
token_hash text NOT NULL,
|
||||
created_by uuid REFERENCES users (id),
|
||||
created_at timestamptz DEFAULT now()
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS invites;
|
||||
@@ -0,0 +1,10 @@
|
||||
CREATE TABLE IF NOT EXISTS invites (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
community_id uuid NOT NULL REFERENCES communities (id) ON DELETE CASCADE,
|
||||
created_by uuid REFERENCES users (id),
|
||||
code text UNIQUE NOT NULL,
|
||||
max_uses int,
|
||||
uses int DEFAULT 0,
|
||||
expires_at timestamptz,
|
||||
created_at timestamptz DEFAULT now()
|
||||
);
|
||||
@@ -0,0 +1,6 @@
|
||||
DROP INDEX IF EXISTS idx_messages_channel_created;
|
||||
DROP INDEX IF EXISTS idx_messages_parent;
|
||||
DROP INDEX IF EXISTS idx_messages_search;
|
||||
DROP INDEX IF EXISTS idx_notifications_user_unread;
|
||||
DROP INDEX IF EXISTS idx_channel_members_user;
|
||||
DROP INDEX IF EXISTS idx_community_members_user;
|
||||
@@ -0,0 +1,20 @@
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_channel_created
|
||||
ON messages (channel_id, created_at);
|
||||
--;;
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_parent
|
||||
ON messages (parent_id)
|
||||
WHERE parent_id IS NOT NULL;
|
||||
--;;
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_search
|
||||
ON messages
|
||||
USING GIN (to_tsvector('english', body_md));
|
||||
--;;
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_user_unread
|
||||
ON notifications (user_id, created_at)
|
||||
WHERE read = false;
|
||||
--;;
|
||||
CREATE INDEX IF NOT EXISTS idx_channel_members_user
|
||||
ON channel_members (user_id);
|
||||
--;;
|
||||
CREATE INDEX IF NOT EXISTS idx_community_members_user
|
||||
ON community_members (user_id);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS bans;
|
||||
@@ -0,0 +1,8 @@
|
||||
CREATE TABLE IF NOT EXISTS bans (
|
||||
community_id uuid NOT NULL REFERENCES communities (id) ON DELETE CASCADE,
|
||||
user_id uuid NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||
reason text,
|
||||
banned_by uuid REFERENCES users (id),
|
||||
created_at timestamptz DEFAULT now(),
|
||||
PRIMARY KEY (community_id, user_id)
|
||||
);
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP INDEX IF EXISTS idx_mutes_expires;
|
||||
DROP TABLE IF EXISTS mutes;
|
||||
@@ -0,0 +1,12 @@
|
||||
CREATE TABLE IF NOT EXISTS mutes (
|
||||
community_id uuid NOT NULL REFERENCES communities (id) ON DELETE CASCADE,
|
||||
user_id uuid NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||
expires_at timestamptz,
|
||||
muted_by uuid REFERENCES users (id),
|
||||
created_at timestamptz DEFAULT now(),
|
||||
PRIMARY KEY (community_id, user_id)
|
||||
);
|
||||
--;;
|
||||
CREATE INDEX IF NOT EXISTS idx_mutes_expires
|
||||
ON mutes (expires_at)
|
||||
WHERE expires_at IS NOT NULL;
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS oauth_providers;
|
||||
@@ -0,0 +1,16 @@
|
||||
CREATE TABLE oauth_providers (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
provider_type text NOT NULL CHECK (provider_type IN ('github', 'gitea', 'oidc')),
|
||||
display_name text NOT NULL,
|
||||
slug text UNIQUE NOT NULL,
|
||||
client_id text NOT NULL,
|
||||
client_secret text NOT NULL,
|
||||
base_url text,
|
||||
issuer_url text,
|
||||
enabled boolean NOT NULL DEFAULT true,
|
||||
sort_order integer NOT NULL DEFAULT 0,
|
||||
created_at timestamptz DEFAULT now(),
|
||||
updated_at timestamptz DEFAULT now()
|
||||
);
|
||||
--;;
|
||||
CREATE INDEX idx_oauth_providers_enabled ON oauth_providers (enabled, sort_order);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS system_settings;
|
||||
@@ -0,0 +1,8 @@
|
||||
CREATE TABLE system_settings (
|
||||
key text PRIMARY KEY,
|
||||
value text NOT NULL,
|
||||
updated_at timestamptz DEFAULT now()
|
||||
);
|
||||
--;;
|
||||
INSERT INTO system_settings (key, value) VALUES ('setup_completed', 'false')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
@@ -1,5 +1,127 @@
|
||||
(ns ajet.chat.api.core
|
||||
"REST API service — http-kit + reitit.")
|
||||
"REST API service — http-kit + reitit.
|
||||
|
||||
Manages the full lifecycle: DB pool, NATS, MinIO, HTTP server.
|
||||
System state held in a single atom for REPL-driven development."
|
||||
(:refer-clojure :exclude [reset!])
|
||||
(:require [clojure.tools.logging :as log]
|
||||
[org.httpkit.server :as http-kit]
|
||||
[ajet.chat.shared.config :as config]
|
||||
[ajet.chat.shared.db :as db]
|
||||
[ajet.chat.shared.eventbus :as eventbus]
|
||||
[ajet.chat.shared.storage :as storage]
|
||||
[ajet.chat.api.routes :as routes])
|
||||
(:gen-class))
|
||||
|
||||
(defonce system (atom nil))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Lifecycle
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn start!
|
||||
"Start the API service. Connects to PG, NATS, MinIO and starts HTTP server."
|
||||
[& [{:keys [config-overrides]}]]
|
||||
(when @system
|
||||
(log/warn "System already started — call (stop!) first")
|
||||
(throw (ex-info "System already running" {})))
|
||||
(let [config (config/load-config {:resource "api-config.edn"})
|
||||
config (if config-overrides
|
||||
(merge config config-overrides)
|
||||
config)
|
||||
_ (log/info "Loaded config:" (config/redact config))
|
||||
|
||||
;; Database
|
||||
ds (db/make-datasource (:db config))
|
||||
_ (log/info "Database connection pool created")
|
||||
_ (when (get-in config [:db :migrations :enabled] true)
|
||||
(db/migrate! ds (get-in config [:db :migrations])))
|
||||
|
||||
;; NATS
|
||||
nats (eventbus/connect! (:nats config))
|
||||
_ (log/info "Connected to NATS")
|
||||
_ (eventbus/ensure-stream! nats)
|
||||
|
||||
;; MinIO / S3
|
||||
s3-client (storage/make-client (:minio config))
|
||||
bucket (get-in config [:minio :bucket] "ajet-chat")
|
||||
_ (storage/ensure-bucket! s3-client bucket)
|
||||
_ (log/info "MinIO connected, bucket ensured:" bucket)
|
||||
|
||||
;; System map
|
||||
sys {:config config
|
||||
:ds ds
|
||||
:nats nats
|
||||
:s3 s3-client
|
||||
:bucket bucket}
|
||||
|
||||
;; HTTP server
|
||||
handler (routes/app sys)
|
||||
port (get-in config [:server :port] 3001)
|
||||
host (get-in config [:server :host] "0.0.0.0")
|
||||
server (http-kit/run-server handler {:port port :ip host
|
||||
:max-body (* 12 1024 1024)})]
|
||||
(clojure.core/reset! system (assoc sys :server server :port port))
|
||||
(log/info (str "API service started on port " port))
|
||||
@system))
|
||||
|
||||
(defn stop!
|
||||
"Stop the API service. Shuts down HTTP, NATS, DB pool in order."
|
||||
[]
|
||||
(when-let [sys @system]
|
||||
(log/info "Shutting down API service...")
|
||||
|
||||
;; Stop HTTP server (wait up to 30s for in-flight requests)
|
||||
(when-let [server (:server sys)]
|
||||
(server :timeout 30000)
|
||||
(log/info "HTTP server stopped"))
|
||||
|
||||
;; Close NATS
|
||||
(when-let [nats (:nats sys)]
|
||||
(try
|
||||
(eventbus/close! nats)
|
||||
(log/info "NATS connection closed")
|
||||
(catch Exception e
|
||||
(log/error e "Error closing NATS connection"))))
|
||||
|
||||
;; Close S3 client
|
||||
(when-let [s3 (:s3 sys)]
|
||||
(try
|
||||
(storage/close-client s3)
|
||||
(log/info "S3 client closed")
|
||||
(catch Exception e
|
||||
(log/error e "Error closing S3 client"))))
|
||||
|
||||
;; Close DB pool
|
||||
(when-let [ds (:ds sys)]
|
||||
(try
|
||||
(db/close-datasource ds)
|
||||
(log/info "Database connection pool closed")
|
||||
(catch Exception e
|
||||
(log/error e "Error closing database pool"))))
|
||||
|
||||
(clojure.core/reset! system nil)
|
||||
(log/info "API service stopped")))
|
||||
|
||||
(defn reset!
|
||||
"Stop then start the system (REPL convenience)."
|
||||
[]
|
||||
(stop!)
|
||||
(start!))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Entry point
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn -main [& _args]
|
||||
(println "ajet-chat API starting..."))
|
||||
(start!)
|
||||
|
||||
;; Graceful shutdown hook
|
||||
(.addShutdownHook
|
||||
(Runtime/getRuntime)
|
||||
(Thread. ^Runnable (fn []
|
||||
(log/info "Shutdown hook triggered")
|
||||
(stop!))))
|
||||
|
||||
;; Block main thread
|
||||
@(promise))
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
(ns ajet.chat.api.handlers.admin
|
||||
"Admin endpoints for managing OAuth providers.
|
||||
|
||||
Authorization: user must be owner of at least one community.
|
||||
These endpoints allow post-setup management of OAuth providers
|
||||
stored in the database."
|
||||
(:require [clojure.string :as str]
|
||||
[clojure.tools.logging :as log]
|
||||
[ajet.chat.shared.db :as db]
|
||||
[ajet.chat.api.middleware :as mw])
|
||||
(:import [java.util UUID]))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Helpers
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- require-admin!
|
||||
"Verify the user is an owner of at least one community.
|
||||
Returns the user-id or throws 403."
|
||||
[request]
|
||||
(let [user-id (or (:user-id request)
|
||||
(throw (ex-info "Authentication required"
|
||||
{:type :ajet.chat/forbidden})))
|
||||
ds (get-in request [:system :ds])
|
||||
owner? (db/execute-one! ds
|
||||
{:select [[:1 :exists]]
|
||||
:from [:community-members]
|
||||
:where [:and
|
||||
[:= :user-id [:cast user-id :uuid]]
|
||||
[:= :role "owner"]]})]
|
||||
(when-not owner?
|
||||
(throw (ex-info "Admin access required (must be community owner)"
|
||||
{:type :ajet.chat/forbidden})))
|
||||
user-id))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Handlers
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn list-providers
|
||||
"GET /api/admin/oauth-providers — list all OAuth providers (including disabled)."
|
||||
[request]
|
||||
(require-admin! request)
|
||||
(let [ds (get-in request [:system :ds])
|
||||
providers (db/execute! ds
|
||||
{:select [:id :provider-type :display-name :slug
|
||||
:client-id :base-url :issuer-url
|
||||
:enabled :sort-order :created-at :updated-at]
|
||||
:from [:oauth-providers]
|
||||
:order-by [[:sort-order :asc] [:created-at :asc]]})]
|
||||
(mw/json-response providers)))
|
||||
|
||||
(defn create-provider
|
||||
"POST /api/admin/oauth-providers — create a new OAuth provider."
|
||||
[request]
|
||||
(require-admin! request)
|
||||
(let [ds (get-in request [:system :ds])
|
||||
params (:body-params request)
|
||||
{:keys [provider-type display-name slug client-id client-secret
|
||||
base-url issuer-url enabled sort-order]} params]
|
||||
(when (or (str/blank? provider-type) (str/blank? display-name)
|
||||
(str/blank? slug) (str/blank? client-id) (str/blank? client-secret))
|
||||
(throw (ex-info "provider-type, display-name, slug, client-id, and client-secret are required"
|
||||
{:type :ajet.chat/validation-error})))
|
||||
(when-not (#{"github" "gitea" "oidc"} provider-type)
|
||||
(throw (ex-info "provider-type must be github, gitea, or oidc"
|
||||
{:type :ajet.chat/validation-error})))
|
||||
(let [provider (db/execute-one! ds
|
||||
{:insert-into :oauth-providers
|
||||
:values [(cond-> {:provider-type provider-type
|
||||
:display-name display-name
|
||||
:slug slug
|
||||
:client-id client-id
|
||||
:client-secret client-secret}
|
||||
base-url (assoc :base-url base-url)
|
||||
issuer-url (assoc :issuer-url issuer-url)
|
||||
(some? enabled) (assoc :enabled enabled)
|
||||
(some? sort-order) (assoc :sort-order sort-order))]
|
||||
:returning [:id :provider-type :display-name :slug
|
||||
:client-id :base-url :issuer-url
|
||||
:enabled :sort-order :created-at :updated-at]})]
|
||||
(log/info "Admin created OAuth provider" slug provider-type)
|
||||
(mw/json-response 201 provider))))
|
||||
|
||||
(defn update-provider
|
||||
"PUT /api/admin/oauth-providers/:id — update an existing OAuth provider."
|
||||
[request]
|
||||
(require-admin! request)
|
||||
(let [ds (get-in request [:system :ds])
|
||||
provider-id (get-in request [:path-params :id])
|
||||
params (:body-params request)
|
||||
updates (cond-> {}
|
||||
(:display-name params) (assoc :display-name (:display-name params))
|
||||
(:slug params) (assoc :slug (:slug params))
|
||||
(:client-id params) (assoc :client-id (:client-id params))
|
||||
(:client-secret params) (assoc :client-secret (:client-secret params))
|
||||
(:base-url params) (assoc :base-url (:base-url params))
|
||||
(:issuer-url params) (assoc :issuer-url (:issuer-url params))
|
||||
(some? (:enabled params)) (assoc :enabled (:enabled params))
|
||||
(some? (:sort-order params)) (assoc :sort-order (:sort-order params)))]
|
||||
(when (empty? updates)
|
||||
(throw (ex-info "No fields to update" {:type :ajet.chat/validation-error})))
|
||||
(let [result (db/execute-one! ds
|
||||
{:update :oauth-providers
|
||||
:set (assoc updates :updated-at [:now])
|
||||
:where [:= :id [:cast provider-id :uuid]]
|
||||
:returning [:id :provider-type :display-name :slug
|
||||
:client-id :base-url :issuer-url
|
||||
:enabled :sort-order :created-at :updated-at]})]
|
||||
(if result
|
||||
(do (log/info "Admin updated OAuth provider" provider-id)
|
||||
(mw/json-response result))
|
||||
(mw/error-response 404 "NOT_FOUND" "OAuth provider not found")))))
|
||||
|
||||
(defn delete-provider
|
||||
"DELETE /api/admin/oauth-providers/:id — delete an OAuth provider."
|
||||
[request]
|
||||
(require-admin! request)
|
||||
(let [ds (get-in request [:system :ds])
|
||||
provider-id (get-in request [:path-params :id])]
|
||||
(db/execute! ds
|
||||
{:delete-from :oauth-providers
|
||||
:where [:= :id [:cast provider-id :uuid]]})
|
||||
(log/info "Admin deleted OAuth provider" provider-id)
|
||||
(mw/json-response 204 nil)))
|
||||
@@ -0,0 +1,149 @@
|
||||
(ns ajet.chat.api.handlers.categories
|
||||
"Channel category CRUD handlers.
|
||||
|
||||
Categories organize channels within a community. Each has a name
|
||||
and a position for ordering. Deleting a category sets channels
|
||||
in it to uncategorized (category_id = NULL)."
|
||||
(:require [clojure.string :as str]
|
||||
[clojure.tools.logging :as log]
|
||||
[ajet.chat.shared.db :as db]
|
||||
[ajet.chat.api.middleware :as mw])
|
||||
(:import [java.util UUID]))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Helpers
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- uuid [] (str (UUID/randomUUID)))
|
||||
|
||||
(defn- require-user! [request]
|
||||
(or (:user-id request)
|
||||
(throw (ex-info "Authentication required" {:type :ajet.chat/forbidden}))))
|
||||
|
||||
(defn- check-membership! [ds community-id user-id]
|
||||
(or (db/execute-one! ds
|
||||
{:select [:*]
|
||||
:from [:community-members]
|
||||
:where [:and
|
||||
[:= :community-id [:cast community-id :uuid]]
|
||||
[:= :user-id [:cast user-id :uuid]]]})
|
||||
(throw (ex-info "Not a member of this community"
|
||||
{:type :ajet.chat/forbidden}))))
|
||||
|
||||
(defn- check-role! [ds community-id user-id required-role]
|
||||
(let [member (check-membership! ds community-id user-id)
|
||||
role (:role member)
|
||||
hierarchy {"owner" 3 "admin" 2 "member" 1}
|
||||
has-level (get hierarchy role 0)
|
||||
need-level (get hierarchy required-role 0)]
|
||||
(when (< has-level need-level)
|
||||
(throw (ex-info (str "Requires " required-role " role or higher")
|
||||
{:type :ajet.chat/forbidden})))
|
||||
member))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Handlers
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn list-categories
|
||||
"GET /api/communities/:cid/categories
|
||||
Returns categories ordered by position."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
community-id (get-in request [:path-params :cid])
|
||||
_ (check-membership! ds community-id user-id)
|
||||
categories (db/execute! ds
|
||||
{:select [:*]
|
||||
:from [:channel-categories]
|
||||
:where [:= :community-id [:cast community-id :uuid]]
|
||||
:order-by [[:position :asc]]})]
|
||||
(mw/json-response categories)))
|
||||
|
||||
(defn create-category
|
||||
"POST /api/communities/:cid/categories
|
||||
Creates a new category. Admin+ only."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
community-id (get-in request [:path-params :cid])
|
||||
params (:body-params request)
|
||||
name-v (:name params)
|
||||
position (or (:position params) 0)]
|
||||
|
||||
(check-role! ds community-id user-id "admin")
|
||||
|
||||
(when (str/blank? name-v)
|
||||
(throw (ex-info "Category name is required"
|
||||
{:type :ajet.chat/validation-error})))
|
||||
|
||||
(let [category-id (uuid)]
|
||||
(db/execute! ds
|
||||
{:insert-into :channel-categories
|
||||
:values [{:id [:cast category-id :uuid]
|
||||
:community-id [:cast community-id :uuid]
|
||||
:name name-v
|
||||
:position position}]})
|
||||
|
||||
(let [category (db/execute-one! ds
|
||||
{:select [:*]
|
||||
:from [:channel-categories]
|
||||
:where [:= :id [:cast category-id :uuid]]})]
|
||||
(mw/json-response 201 category)))))
|
||||
|
||||
(defn update-category
|
||||
"PUT /api/categories/:id
|
||||
Updates category name and/or position. Admin+ only."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
category-id (get-in request [:path-params :id])
|
||||
params (:body-params request)
|
||||
category (or (db/execute-one! ds
|
||||
{:select [:*]
|
||||
:from [:channel-categories]
|
||||
:where [:= :id [:cast category-id :uuid]]})
|
||||
(throw (ex-info "Category not found"
|
||||
{:type :ajet.chat/not-found})))
|
||||
community-id (str (:community-id category))]
|
||||
|
||||
(check-role! ds community-id user-id "admin")
|
||||
|
||||
(let [updates (cond-> {}
|
||||
(:name params) (assoc :name (:name params))
|
||||
(contains? params :position) (assoc :position (:position params)))]
|
||||
(when (seq updates)
|
||||
(db/execute! ds
|
||||
{:update :channel-categories
|
||||
:set updates
|
||||
:where [:= :id [:cast category-id :uuid]]}))
|
||||
|
||||
(let [updated (db/execute-one! ds
|
||||
{:select [:*]
|
||||
:from [:channel-categories]
|
||||
:where [:= :id [:cast category-id :uuid]]})]
|
||||
(mw/json-response updated)))))
|
||||
|
||||
(defn delete-category
|
||||
"DELETE /api/categories/:id
|
||||
Deletes category. Channels become uncategorized (ON DELETE SET NULL). Admin+ only."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
category-id (get-in request [:path-params :id])
|
||||
category (or (db/execute-one! ds
|
||||
{:select [:*]
|
||||
:from [:channel-categories]
|
||||
:where [:= :id [:cast category-id :uuid]]})
|
||||
(throw (ex-info "Category not found"
|
||||
{:type :ajet.chat/not-found})))
|
||||
community-id (str (:community-id category))]
|
||||
|
||||
(check-role! ds community-id user-id "admin")
|
||||
|
||||
;; ON DELETE SET NULL on channels.category_id handles the uncategorizing
|
||||
(db/execute! ds
|
||||
{:delete-from :channel-categories
|
||||
:where [:= :id [:cast category-id :uuid]]})
|
||||
|
||||
(mw/json-response 204 nil)))
|
||||
@@ -0,0 +1,331 @@
|
||||
(ns ajet.chat.api.handlers.channels
|
||||
"Channel CRUD, join/leave, and member listing handlers.
|
||||
|
||||
Channels belong to a community. DM channels have nil community_id
|
||||
and are handled separately in the DMs handler."
|
||||
(:require [clojure.string :as str]
|
||||
[clojure.tools.logging :as log]
|
||||
[ajet.chat.shared.db :as db]
|
||||
[ajet.chat.shared.eventbus :as eventbus]
|
||||
[ajet.chat.api.middleware :as mw])
|
||||
(:import [java.util UUID]))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Helpers
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- uuid [] (str (UUID/randomUUID)))
|
||||
|
||||
(defn- require-user! [request]
|
||||
(or (:user-id request)
|
||||
(throw (ex-info "Authentication required" {:type :ajet.chat/forbidden}))))
|
||||
|
||||
(defn- check-membership! [ds community-id user-id]
|
||||
(or (db/execute-one! ds
|
||||
{:select [:*]
|
||||
:from [:community-members]
|
||||
:where [:and
|
||||
[:= :community-id [:cast community-id :uuid]]
|
||||
[:= :user-id [:cast user-id :uuid]]]})
|
||||
(throw (ex-info "Not a member of this community"
|
||||
{:type :ajet.chat/forbidden}))))
|
||||
|
||||
(defn- check-role! [ds community-id user-id required-role]
|
||||
(let [member (check-membership! ds community-id user-id)
|
||||
role (:role member)
|
||||
hierarchy {"owner" 3 "admin" 2 "member" 1}
|
||||
has-level (get hierarchy role 0)
|
||||
need-level (get hierarchy required-role 0)]
|
||||
(when (< has-level need-level)
|
||||
(throw (ex-info (str "Requires " required-role " role or higher")
|
||||
{:type :ajet.chat/forbidden})))
|
||||
member))
|
||||
|
||||
(defn- check-channel-member! [ds channel-id user-id]
|
||||
(or (db/execute-one! ds
|
||||
{:select [:*]
|
||||
:from [:channel-members]
|
||||
:where [:and
|
||||
[:= :channel-id [:cast channel-id :uuid]]
|
||||
[:= :user-id [:cast user-id :uuid]]]})
|
||||
(throw (ex-info "Not a member of this channel"
|
||||
{:type :ajet.chat/forbidden}))))
|
||||
|
||||
(defn- get-channel-row [ds channel-id]
|
||||
(or (db/execute-one! ds
|
||||
{:select [:*]
|
||||
:from [:channels]
|
||||
:where [:= :id [:cast channel-id :uuid]]})
|
||||
(throw (ex-info "Channel not found" {:type :ajet.chat/not-found}))))
|
||||
|
||||
(defn- publish-event! [nats subject event-type payload]
|
||||
(try
|
||||
(eventbus/publish! nats subject event-type payload)
|
||||
(catch Exception e
|
||||
(log/error e "Failed to publish event" event-type "to" subject))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Handlers
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn list-channels
|
||||
"GET /api/communities/:cid/channels
|
||||
Returns public channels + private channels user is a member of."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
community-id (get-in request [:path-params :cid])
|
||||
_ (check-membership! ds community-id user-id)
|
||||
channels (db/execute! ds
|
||||
{:select-distinct [:c.*]
|
||||
:from [[:channels :c]]
|
||||
:left-join [[:channel-members :cm]
|
||||
[:and
|
||||
[:= :cm.channel-id :c.id]
|
||||
[:= :cm.user-id [:cast user-id :uuid]]]]
|
||||
:where [:and
|
||||
[:= :c.community-id [:cast community-id :uuid]]
|
||||
[:or
|
||||
[:= :c.visibility "public"]
|
||||
[:!= :cm.user-id nil]]]
|
||||
:order-by [[:c.name :asc]]})]
|
||||
(mw/json-response channels)))
|
||||
|
||||
(defn create-channel
|
||||
"POST /api/communities/:cid/channels
|
||||
Creates a channel. Admin+ only. Adds creator as member."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
nats (get-in request [:system :nats])
|
||||
community-id (get-in request [:path-params :cid])
|
||||
params (:body-params request)
|
||||
name-v (:name params)
|
||||
ch-type (or (:type params) "text")
|
||||
visibility (or (:visibility params) "public")
|
||||
topic (:topic params)
|
||||
category-id (:category_id params)]
|
||||
|
||||
(check-role! ds community-id user-id "admin")
|
||||
|
||||
(when (str/blank? name-v)
|
||||
(throw (ex-info "Channel name is required"
|
||||
{:type :ajet.chat/validation-error})))
|
||||
|
||||
;; Check for duplicate name in community
|
||||
(when (db/execute-one! ds
|
||||
{:select [:id]
|
||||
:from [:channels]
|
||||
:where [:and
|
||||
[:= :community-id [:cast community-id :uuid]]
|
||||
[:= :name name-v]]})
|
||||
(throw (ex-info "A channel with this name already exists in this community"
|
||||
{:type :ajet.chat/conflict})))
|
||||
|
||||
(let [channel-id (uuid)]
|
||||
(db/with-transaction [tx ds]
|
||||
(db/execute! tx
|
||||
{:insert-into :channels
|
||||
:values [(cond-> {:id [:cast channel-id :uuid]
|
||||
:community-id [:cast community-id :uuid]
|
||||
:name name-v
|
||||
:type ch-type
|
||||
:visibility visibility}
|
||||
topic (assoc :topic topic)
|
||||
category-id (assoc :category-id [:cast category-id :uuid]))]})
|
||||
|
||||
;; Add creator as channel member
|
||||
(db/execute! tx
|
||||
{:insert-into :channel-members
|
||||
:values [{:channel-id [:cast channel-id :uuid]
|
||||
:user-id [:cast user-id :uuid]}]}))
|
||||
|
||||
(let [channel (get-channel-row ds channel-id)]
|
||||
(publish-event! nats
|
||||
(str "chat.events." community-id)
|
||||
:channel/created
|
||||
{:channel-id channel-id
|
||||
:community-id community-id
|
||||
:name name-v
|
||||
:type ch-type
|
||||
:visibility visibility
|
||||
:created-by user-id})
|
||||
|
||||
(mw/json-response 201 channel)))))
|
||||
|
||||
(defn get-channel
|
||||
"GET /api/channels/:id
|
||||
Returns channel details. Accessible to channel members, or community members for public channels."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
channel-id (get-in request [:path-params :id])
|
||||
channel (get-channel-row ds channel-id)]
|
||||
|
||||
;; Access check: channel member OR (public channel + community member)
|
||||
(let [is-channel-member (db/execute-one! ds
|
||||
{:select [:user-id]
|
||||
:from [:channel-members]
|
||||
:where [:and
|
||||
[:= :channel-id [:cast channel-id :uuid]]
|
||||
[:= :user-id [:cast user-id :uuid]]]})]
|
||||
(when-not is-channel-member
|
||||
(if (and (= "public" (:visibility channel))
|
||||
(:community-id channel))
|
||||
(check-membership! ds (str (:community-id channel)) user-id)
|
||||
(throw (ex-info "Not a member of this channel"
|
||||
{:type :ajet.chat/forbidden})))))
|
||||
|
||||
(mw/json-response channel)))
|
||||
|
||||
(defn update-channel
|
||||
"PUT /api/channels/:id
|
||||
Updates channel name, topic, category. Admin+ of the community."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
nats (get-in request [:system :nats])
|
||||
channel-id (get-in request [:path-params :id])
|
||||
params (:body-params request)
|
||||
channel (get-channel-row ds channel-id)
|
||||
community-id (str (:community-id channel))]
|
||||
|
||||
(check-role! ds community-id user-id "admin")
|
||||
|
||||
(let [updates (cond-> {}
|
||||
(:name params) (assoc :name (:name params))
|
||||
(:topic params) (assoc :topic (:topic params))
|
||||
(contains? params :category_id)
|
||||
(assoc :category-id (when (:category_id params)
|
||||
[:cast (:category_id params) :uuid]))
|
||||
(:visibility params) (assoc :visibility (:visibility params)))]
|
||||
(when (seq updates)
|
||||
(db/execute! ds
|
||||
{:update :channels
|
||||
:set updates
|
||||
:where [:= :id [:cast channel-id :uuid]]}))
|
||||
|
||||
(let [updated (get-channel-row ds channel-id)]
|
||||
(publish-event! nats
|
||||
(str "chat.events." community-id)
|
||||
:channel/updated
|
||||
{:channel-id channel-id
|
||||
:community-id community-id
|
||||
:updated-by user-id
|
||||
:changes (dissoc updates :category-id)})
|
||||
|
||||
(mw/json-response updated)))))
|
||||
|
||||
(defn delete-channel
|
||||
"DELETE /api/channels/:id
|
||||
Deletes channel and all messages. Admin+ of the community."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
nats (get-in request [:system :nats])
|
||||
channel-id (get-in request [:path-params :id])
|
||||
channel (get-channel-row ds channel-id)
|
||||
community-id (str (:community-id channel))]
|
||||
|
||||
(check-role! ds community-id user-id "admin")
|
||||
|
||||
;; CASCADE handles cleanup of messages, members, etc.
|
||||
(db/execute! ds
|
||||
{:delete-from :channels
|
||||
:where [:= :id [:cast channel-id :uuid]]})
|
||||
|
||||
(publish-event! nats
|
||||
(str "chat.events." community-id)
|
||||
:channel/deleted
|
||||
{:channel-id channel-id
|
||||
:community-id community-id
|
||||
:name (:name channel)
|
||||
:deleted-by user-id})
|
||||
|
||||
(mw/json-response 204 nil)))
|
||||
|
||||
(defn join-channel
|
||||
"POST /api/channels/:id/join
|
||||
Joins a public channel. Community membership required."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
nats (get-in request [:system :nats])
|
||||
channel-id (get-in request [:path-params :id])
|
||||
channel (get-channel-row ds channel-id)
|
||||
community-id (str (:community-id channel))]
|
||||
|
||||
(check-membership! ds community-id user-id)
|
||||
|
||||
(when (= "private" (:visibility channel))
|
||||
(throw (ex-info "Cannot join a private channel without an invite"
|
||||
{:type :ajet.chat/forbidden})))
|
||||
|
||||
;; Idempotent: ignore if already a member
|
||||
(let [existing (db/execute-one! ds
|
||||
{:select [:user-id]
|
||||
:from [:channel-members]
|
||||
:where [:and
|
||||
[:= :channel-id [:cast channel-id :uuid]]
|
||||
[:= :user-id [:cast user-id :uuid]]]})]
|
||||
(when-not existing
|
||||
(db/execute! ds
|
||||
{:insert-into :channel-members
|
||||
:values [{:channel-id [:cast channel-id :uuid]
|
||||
:user-id [:cast user-id :uuid]}]})
|
||||
|
||||
(publish-event! nats
|
||||
(str "chat.events." community-id)
|
||||
:member/joined
|
||||
{:channel-id channel-id
|
||||
:community-id community-id
|
||||
:user-id user-id})))
|
||||
|
||||
(mw/json-response {:status "joined" :channel-id channel-id})))
|
||||
|
||||
(defn leave-channel
|
||||
"POST /api/channels/:id/leave
|
||||
Leaves a channel."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
nats (get-in request [:system :nats])
|
||||
channel-id (get-in request [:path-params :id])
|
||||
channel (get-channel-row ds channel-id)
|
||||
community-id (when (:community-id channel)
|
||||
(str (:community-id channel)))]
|
||||
|
||||
(db/execute! ds
|
||||
{:delete-from :channel-members
|
||||
:where [:and
|
||||
[:= :channel-id [:cast channel-id :uuid]]
|
||||
[:= :user-id [:cast user-id :uuid]]]})
|
||||
|
||||
(when community-id
|
||||
(publish-event! nats
|
||||
(str "chat.events." community-id)
|
||||
:member/left
|
||||
{:channel-id channel-id
|
||||
:community-id community-id
|
||||
:user-id user-id}))
|
||||
|
||||
(mw/json-response {:status "left" :channel-id channel-id})))
|
||||
|
||||
(defn list-channel-members
|
||||
"GET /api/channels/:id/members
|
||||
Lists members of a channel. Requires channel membership."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
channel-id (get-in request [:path-params :id])]
|
||||
|
||||
(check-channel-member! ds channel-id user-id)
|
||||
|
||||
(let [members (db/execute! ds
|
||||
{:select [:u.id :u.username :u.display-name :u.avatar-url
|
||||
:u.status-text :cm.joined-at]
|
||||
:from [[:channel-members :cm]]
|
||||
:join [[:users :u] [:= :u.id :cm.user-id]]
|
||||
:where [:= :cm.channel-id [:cast channel-id :uuid]]
|
||||
:order-by [[:u.username :asc]]})]
|
||||
(mw/json-response members))))
|
||||
@@ -0,0 +1,451 @@
|
||||
(ns ajet.chat.api.handlers.commands
|
||||
"Slash command dispatcher.
|
||||
|
||||
Parses command strings and dispatches to the appropriate handler.
|
||||
Commands: /help, /topic, /nick, /invite, /kick, /ban, /mute,
|
||||
/token, /webhook, /status"
|
||||
(:require [clojure.string :as str]
|
||||
[clojure.tools.logging :as log]
|
||||
[ajet.chat.shared.db :as db]
|
||||
[ajet.chat.shared.eventbus :as eventbus]
|
||||
[ajet.chat.api.middleware :as mw])
|
||||
(:import [java.util UUID]
|
||||
[java.security SecureRandom MessageDigest]
|
||||
[java.util Base64]))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Helpers
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- uuid [] (str (UUID/randomUUID)))
|
||||
|
||||
(defn- require-user! [request]
|
||||
(or (:user-id request)
|
||||
(throw (ex-info "Authentication required" {:type :ajet.chat/forbidden}))))
|
||||
|
||||
(defn- check-role! [ds community-id user-id required-role]
|
||||
(let [member (or (db/execute-one! ds
|
||||
{:select [:*]
|
||||
:from [:community-members]
|
||||
:where [:and
|
||||
[:= :community-id [:cast community-id :uuid]]
|
||||
[:= :user-id [:cast user-id :uuid]]]})
|
||||
(throw (ex-info "Not a member of this community"
|
||||
{:type :ajet.chat/forbidden})))
|
||||
role (:role member)
|
||||
hierarchy {"owner" 3 "admin" 2 "member" 1}
|
||||
has-level (get hierarchy role 0)
|
||||
need-level (get hierarchy required-role 0)]
|
||||
(when (< has-level need-level)
|
||||
(throw (ex-info (str "Requires " required-role " role or higher")
|
||||
{:type :ajet.chat/forbidden})))
|
||||
member))
|
||||
|
||||
(defn- parse-user-mention
|
||||
"Extract user UUID from @<user:uuid> syntax."
|
||||
[s]
|
||||
(when-let [[_ uid] (re-matches #"@<user:([0-9a-f-]+)>" s)]
|
||||
uid))
|
||||
|
||||
(defn- publish-event! [nats subject event-type payload]
|
||||
(try
|
||||
(eventbus/publish! nats subject event-type payload)
|
||||
(catch Exception e
|
||||
(log/error e "Failed to publish event" event-type "to" subject))))
|
||||
|
||||
(defn- generate-token []
|
||||
(let [bytes (byte-array 32)
|
||||
sr (SecureRandom.)]
|
||||
(.nextBytes sr bytes)
|
||||
(.encodeToString (Base64/getUrlEncoder) bytes)))
|
||||
|
||||
(defn- hash-token [token]
|
||||
(let [md (MessageDigest/getInstance "SHA-256")
|
||||
bytes (.digest md (.getBytes token "UTF-8"))]
|
||||
(.encodeToString (Base64/getUrlEncoder) bytes)))
|
||||
|
||||
(defn- generate-invite-code []
|
||||
(let [chars "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
sr (SecureRandom.)
|
||||
sb (StringBuilder. 8)]
|
||||
(dotimes [_ 8]
|
||||
(.append sb (.charAt chars (.nextInt sr (count chars)))))
|
||||
(.toString sb)))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Command Handlers
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(def ^:private help-text
|
||||
{"help" {:description "Show available commands" :usage "/help [command]" :permission "All"}
|
||||
"topic" {:description "Set channel topic" :usage "/topic <text>" :permission "Admin+"}
|
||||
"nick" {:description "Set community nickname" :usage "/nick <nickname>" :permission "All"}
|
||||
"invite" {:description "Generate invite link" :usage "/invite [max_uses] [expires_hours]" :permission "Admin+"}
|
||||
"kick" {:description "Kick user from community" :usage "/kick @<user>" :permission "Admin+"}
|
||||
"ban" {:description "Ban user from community" :usage "/ban @<user>" :permission "Admin+"}
|
||||
"mute" {:description "Mute user for duration" :usage "/mute @<user> <duration>" :permission "Admin+"}
|
||||
"token" {:description "Manage API tokens" :usage "/token create|revoke|list" :permission "Owner"}
|
||||
"webhook" {:description "Manage webhooks" :usage "/webhook create|delete|list" :permission "Admin+"}
|
||||
"status" {:description "Set status text" :usage "/status <text>" :permission "All"}})
|
||||
|
||||
(defn- cmd-help [_ds _nats _user-id _community-id _channel-id args]
|
||||
(if (and (seq args) (get help-text (first args)))
|
||||
(let [cmd (get help-text (first args))]
|
||||
{:result (str "**/" (first args) "** - " (:description cmd)
|
||||
"\nUsage: `" (:usage cmd) "`"
|
||||
"\nPermission: " (:permission cmd))})
|
||||
{:result (str "**Available commands:**\n"
|
||||
(str/join "\n"
|
||||
(map (fn [[name {:keys [description]}]]
|
||||
(str " `/" name "` - " description))
|
||||
(sort-by key help-text))))}))
|
||||
|
||||
(defn- cmd-topic [ds nats user-id community-id channel-id args]
|
||||
(check-role! ds community-id user-id "admin")
|
||||
(when (empty? args)
|
||||
(throw (ex-info "Usage: /topic <text>" {:type :ajet.chat/validation-error})))
|
||||
(let [topic (str/join " " args)]
|
||||
(db/execute! ds
|
||||
{:update :channels
|
||||
:set {:topic topic}
|
||||
:where [:= :id [:cast channel-id :uuid]]})
|
||||
(publish-event! nats
|
||||
(str "chat.events." community-id)
|
||||
:channel/updated
|
||||
{:channel-id channel-id :topic topic :updated-by user-id})
|
||||
{:result (str "Channel topic set to: " topic)}))
|
||||
|
||||
(defn- cmd-nick [ds _nats user-id community-id _channel-id args]
|
||||
(when (empty? args)
|
||||
(throw (ex-info "Usage: /nick <nickname>" {:type :ajet.chat/validation-error})))
|
||||
(let [nickname (str/join " " args)]
|
||||
(db/execute! ds
|
||||
{:update :community-members
|
||||
:set {:nickname nickname}
|
||||
:where [:and
|
||||
[:= :community-id [:cast community-id :uuid]]
|
||||
[:= :user-id [:cast user-id :uuid]]]})
|
||||
{:result (str "Nickname set to: " nickname)}))
|
||||
|
||||
(defn- cmd-invite [ds _nats user-id community-id _channel-id args]
|
||||
(check-role! ds community-id user-id "admin")
|
||||
(let [max-uses (some-> (first args) parse-long)
|
||||
expires-hrs (some-> (second args) parse-long)
|
||||
invite-id (uuid)
|
||||
code (generate-invite-code)
|
||||
values (cond-> {:id [:cast invite-id :uuid]
|
||||
:community-id [:cast community-id :uuid]
|
||||
:created-by [:cast user-id :uuid]
|
||||
:code code}
|
||||
max-uses (assoc :max-uses max-uses)
|
||||
expires-hrs (assoc :expires-at
|
||||
[:raw (str "now() + interval '" expires-hrs " hours'")]))]
|
||||
(db/execute! ds
|
||||
{:insert-into :invites
|
||||
:values [values]})
|
||||
{:result (str "Invite created: `/invite/" code "`"
|
||||
(when max-uses (str " (max " max-uses " uses)"))
|
||||
(when expires-hrs (str " (expires in " expires-hrs "h)")))}))
|
||||
|
||||
(defn- cmd-kick [ds nats user-id community-id _channel-id args]
|
||||
(check-role! ds community-id user-id "admin")
|
||||
(when (empty? args)
|
||||
(throw (ex-info "Usage: /kick @<user>" {:type :ajet.chat/validation-error})))
|
||||
(let [target-uid (or (parse-user-mention (first args))
|
||||
(throw (ex-info "Invalid user mention. Use @<user:uuid> format."
|
||||
{:type :ajet.chat/validation-error})))
|
||||
target (or (db/execute-one! ds
|
||||
{:select [:role]
|
||||
:from [:community-members]
|
||||
:where [:and
|
||||
[:= :community-id [:cast community-id :uuid]]
|
||||
[:= :user-id [:cast target-uid :uuid]]]})
|
||||
(throw (ex-info "User is not a member of this community"
|
||||
{:type :ajet.chat/not-found})))]
|
||||
(when (= (:role target) "owner")
|
||||
(throw (ex-info "Cannot kick the community owner"
|
||||
{:type :ajet.chat/forbidden})))
|
||||
;; Remove from channels
|
||||
(db/execute-sql! ds
|
||||
["DELETE FROM channel_members WHERE user_id = ?::uuid AND channel_id IN (SELECT id FROM channels WHERE community_id = ?::uuid)"
|
||||
target-uid community-id])
|
||||
;; Remove community membership
|
||||
(db/execute! ds
|
||||
{:delete-from :community-members
|
||||
:where [:and
|
||||
[:= :community-id [:cast community-id :uuid]]
|
||||
[:= :user-id [:cast target-uid :uuid]]]})
|
||||
(publish-event! nats
|
||||
(str "chat.events." community-id)
|
||||
:member/kicked
|
||||
{:community-id community-id :user-id target-uid :kicked-by user-id})
|
||||
{:result (str "User " target-uid " has been kicked from the community.")}))
|
||||
|
||||
(defn- cmd-ban [ds nats user-id community-id _channel-id args]
|
||||
(check-role! ds community-id user-id "admin")
|
||||
(when (empty? args)
|
||||
(throw (ex-info "Usage: /ban @<user>" {:type :ajet.chat/validation-error})))
|
||||
(let [target-uid (or (parse-user-mention (first args))
|
||||
(throw (ex-info "Invalid user mention. Use @<user:uuid> format."
|
||||
{:type :ajet.chat/validation-error})))
|
||||
target (db/execute-one! ds
|
||||
{:select [:role]
|
||||
:from [:community-members]
|
||||
:where [:and
|
||||
[:= :community-id [:cast community-id :uuid]]
|
||||
[:= :user-id [:cast target-uid :uuid]]]})]
|
||||
(when (and target (= (:role target) "owner"))
|
||||
(throw (ex-info "Cannot ban the community owner"
|
||||
{:type :ajet.chat/forbidden})))
|
||||
(let [reason (when (> (count args) 1) (str/join " " (rest args)))]
|
||||
;; Create ban record
|
||||
(db/execute! ds
|
||||
{:insert-into :bans
|
||||
:values [(cond-> {:community-id [:cast community-id :uuid]
|
||||
:user-id [:cast target-uid :uuid]
|
||||
:banned-by [:cast user-id :uuid]}
|
||||
reason (assoc :reason reason))]
|
||||
:on-conflict [:community-id :user-id]
|
||||
:do-nothing true})
|
||||
;; Remove from channels
|
||||
(db/execute-sql! ds
|
||||
["DELETE FROM channel_members WHERE user_id = ?::uuid AND channel_id IN (SELECT id FROM channels WHERE community_id = ?::uuid)"
|
||||
target-uid community-id])
|
||||
;; Remove community membership
|
||||
(db/execute! ds
|
||||
{:delete-from :community-members
|
||||
:where [:and
|
||||
[:= :community-id [:cast community-id :uuid]]
|
||||
[:= :user-id [:cast target-uid :uuid]]]})
|
||||
(publish-event! nats
|
||||
(str "chat.events." community-id)
|
||||
:member/banned
|
||||
{:community-id community-id :user-id target-uid :banned-by user-id :reason reason})
|
||||
{:result (str "User " target-uid " has been banned from the community.")})))
|
||||
|
||||
(defn- parse-duration
|
||||
"Parse duration string like '10m', '1h', '24h', '7d' to SQL interval."
|
||||
[s]
|
||||
(when-let [[_ n unit] (re-matches #"(\d+)(m|h|d)" s)]
|
||||
(let [unit-name (case unit "m" "minutes" "h" "hours" "d" "days")]
|
||||
(str n " " unit-name))))
|
||||
|
||||
(defn- cmd-mute [ds nats user-id community-id _channel-id args]
|
||||
(check-role! ds community-id user-id "admin")
|
||||
(when (< (count args) 2)
|
||||
(throw (ex-info "Usage: /mute @<user> <duration> (e.g. 10m, 1h, 24h, 7d)"
|
||||
{:type :ajet.chat/validation-error})))
|
||||
(let [target-uid (or (parse-user-mention (first args))
|
||||
(throw (ex-info "Invalid user mention. Use @<user:uuid> format."
|
||||
{:type :ajet.chat/validation-error})))
|
||||
duration-str (second args)
|
||||
interval (or (parse-duration duration-str)
|
||||
(throw (ex-info "Invalid duration. Use format like: 10m, 1h, 24h, 7d"
|
||||
{:type :ajet.chat/validation-error})))]
|
||||
;; Check target is not owner
|
||||
(let [target (db/execute-one! ds
|
||||
{:select [:role]
|
||||
:from [:community-members]
|
||||
:where [:and
|
||||
[:= :community-id [:cast community-id :uuid]]
|
||||
[:= :user-id [:cast target-uid :uuid]]]})]
|
||||
(when (and target (= (:role target) "owner"))
|
||||
(throw (ex-info "Cannot mute the community owner"
|
||||
{:type :ajet.chat/forbidden}))))
|
||||
|
||||
;; Upsert mute record
|
||||
(db/execute-sql! ds
|
||||
[(str "INSERT INTO mutes (community_id, user_id, expires_at, muted_by) "
|
||||
"VALUES (?::uuid, ?::uuid, now() + interval '" interval "', ?::uuid) "
|
||||
"ON CONFLICT (community_id, user_id) "
|
||||
"DO UPDATE SET expires_at = now() + interval '" interval "', muted_by = ?::uuid")
|
||||
community-id target-uid user-id user-id])
|
||||
|
||||
{:result (str "User " target-uid " has been muted for " duration-str ".")}))
|
||||
|
||||
(defn- cmd-token [ds _nats user-id community-id _channel-id args]
|
||||
(check-role! ds community-id user-id "owner")
|
||||
(let [sub-cmd (first args)]
|
||||
(case sub-cmd
|
||||
"create"
|
||||
(let [name-v (or (second args)
|
||||
(throw (ex-info "Usage: /token create <name>"
|
||||
{:type :ajet.chat/validation-error})))
|
||||
api-user-id (uuid)
|
||||
token-id (uuid)
|
||||
token (generate-token)
|
||||
token-h (hash-token token)]
|
||||
(db/with-transaction [tx ds]
|
||||
(db/execute! tx
|
||||
{:insert-into :api-users
|
||||
:values [{:id [:cast api-user-id :uuid]
|
||||
:name name-v
|
||||
:community-id [:cast community-id :uuid]
|
||||
:created-by [:cast user-id :uuid]}]})
|
||||
(db/execute! tx
|
||||
{:insert-into :api-tokens
|
||||
:values [{:id [:cast token-id :uuid]
|
||||
:api-user-id [:cast api-user-id :uuid]
|
||||
:token-hash token-h}]}))
|
||||
{:result (str "API token created for **" name-v "**\n"
|
||||
"Token: `" token "`\n"
|
||||
"This token will only be shown once. Save it securely.")})
|
||||
|
||||
"revoke"
|
||||
(let [name-v (or (second args)
|
||||
(throw (ex-info "Usage: /token revoke <name>"
|
||||
{:type :ajet.chat/validation-error})))
|
||||
api-user (db/execute-one! ds
|
||||
{:select [:id]
|
||||
:from [:api-users]
|
||||
:where [:and
|
||||
[:= :name name-v]
|
||||
[:= :community-id [:cast community-id :uuid]]]})]
|
||||
(when-not api-user
|
||||
(throw (ex-info (str "API user not found: " name-v)
|
||||
{:type :ajet.chat/not-found})))
|
||||
;; Delete the api_user (CASCADE deletes tokens)
|
||||
(db/execute! ds
|
||||
{:delete-from :api-users
|
||||
:where [:= :id (:id api-user)]})
|
||||
{:result (str "API token for **" name-v "** has been revoked.")})
|
||||
|
||||
"list"
|
||||
(let [api-users (db/execute! ds
|
||||
{:select [:au.id :au.name :au.created-at]
|
||||
:from [[:api-users :au]]
|
||||
:where [:= :au.community-id [:cast community-id :uuid]]
|
||||
:order-by [[:au.created-at :desc]]})]
|
||||
(if (empty? api-users)
|
||||
{:result "No API tokens configured."}
|
||||
{:result (str "**API Tokens:**\n"
|
||||
(str/join "\n" (map #(str " - " (:name %) " (created " (:created-at %) ")")
|
||||
api-users)))}))
|
||||
|
||||
;; default
|
||||
(throw (ex-info "Usage: /token create|revoke|list <name>"
|
||||
{:type :ajet.chat/validation-error})))))
|
||||
|
||||
(defn- cmd-webhook [ds _nats user-id community-id channel-id args]
|
||||
(check-role! ds community-id user-id "admin")
|
||||
(let [sub-cmd (first args)]
|
||||
(case sub-cmd
|
||||
"create"
|
||||
(let [name-v (or (second args)
|
||||
(throw (ex-info "Usage: /webhook create <name> [channel-id]"
|
||||
{:type :ajet.chat/validation-error})))
|
||||
target-ch (or (nth args 2 nil) channel-id)
|
||||
webhook-id (uuid)
|
||||
token (generate-token)
|
||||
token-h (hash-token token)]
|
||||
(db/execute! ds
|
||||
{:insert-into :webhooks
|
||||
:values [{:id [:cast webhook-id :uuid]
|
||||
:community-id [:cast community-id :uuid]
|
||||
:channel-id [:cast target-ch :uuid]
|
||||
:name name-v
|
||||
:token-hash token-h
|
||||
:created-by [:cast user-id :uuid]}]})
|
||||
{:result (str "Webhook **" name-v "** created.\n"
|
||||
"URL: `/api/webhooks/" webhook-id "/incoming`\n"
|
||||
"Token: `" token "`\n"
|
||||
"This token will only be shown once.")})
|
||||
|
||||
"delete"
|
||||
(let [name-v (or (second args)
|
||||
(throw (ex-info "Usage: /webhook delete <name>"
|
||||
{:type :ajet.chat/validation-error})))
|
||||
webhook (db/execute-one! ds
|
||||
{:select [:id]
|
||||
:from [:webhooks]
|
||||
:where [:and
|
||||
[:= :name name-v]
|
||||
[:= :community-id [:cast community-id :uuid]]]})]
|
||||
(when-not webhook
|
||||
(throw (ex-info (str "Webhook not found: " name-v)
|
||||
{:type :ajet.chat/not-found})))
|
||||
(db/execute! ds
|
||||
{:delete-from :webhooks
|
||||
:where [:= :id (:id webhook)]})
|
||||
{:result (str "Webhook **" name-v "** deleted.")})
|
||||
|
||||
"list"
|
||||
(let [webhooks (db/execute! ds
|
||||
{:select [:name :channel-id :created-at]
|
||||
:from [:webhooks]
|
||||
:where [:= :community-id [:cast community-id :uuid]]
|
||||
:order-by [[:created-at :desc]]})]
|
||||
(if (empty? webhooks)
|
||||
{:result "No webhooks configured."}
|
||||
{:result (str "**Webhooks:**\n"
|
||||
(str/join "\n" (map #(str " - " (:name %)
|
||||
" -> channel " (:channel-id %))
|
||||
webhooks)))}))
|
||||
|
||||
;; default
|
||||
(throw (ex-info "Usage: /webhook create|delete|list <name>"
|
||||
{:type :ajet.chat/validation-error})))))
|
||||
|
||||
(defn- cmd-status [ds _nats user-id _community-id _channel-id args]
|
||||
(let [status-text (str/join " " args)]
|
||||
(db/execute! ds
|
||||
{:update :users
|
||||
:set {:status-text (when (seq status-text) status-text)}
|
||||
:where [:= :id [:cast user-id :uuid]]})
|
||||
{:result (if (seq status-text)
|
||||
(str "Status set to: " status-text)
|
||||
"Status cleared.")}))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Dispatcher
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(def ^:private command-handlers
|
||||
{"help" cmd-help
|
||||
"topic" cmd-topic
|
||||
"nick" cmd-nick
|
||||
"invite" cmd-invite
|
||||
"kick" cmd-kick
|
||||
"ban" cmd-ban
|
||||
"mute" cmd-mute
|
||||
"token" cmd-token
|
||||
"webhook" cmd-webhook
|
||||
"status" cmd-status})
|
||||
|
||||
(defn execute-command
|
||||
"POST /api/commands
|
||||
Parses and dispatches a slash command.
|
||||
Body: {\"command\": \"/kick @<user:uuid>\", \"channel_id\": \"uuid\", \"community_id\": \"uuid\"}"
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
nats (get-in request [:system :nats])
|
||||
params (:body-params request)
|
||||
command-str (:command params)
|
||||
channel-id (:channel_id params)
|
||||
community-id (:community_id params)]
|
||||
|
||||
(when (str/blank? command-str)
|
||||
(throw (ex-info "command is required"
|
||||
{:type :ajet.chat/validation-error})))
|
||||
|
||||
(when (str/blank? community-id)
|
||||
(throw (ex-info "community_id is required"
|
||||
{:type :ajet.chat/validation-error})))
|
||||
|
||||
;; Parse command: "/cmd arg1 arg2 ..."
|
||||
(let [parts (str/split (str/trim command-str) #"\s+")
|
||||
cmd-name (when (str/starts-with? (first parts) "/")
|
||||
(subs (first parts) 1))
|
||||
args (rest parts)]
|
||||
|
||||
(when-not cmd-name
|
||||
(throw (ex-info "Command must start with /"
|
||||
{:type :ajet.chat/validation-error})))
|
||||
|
||||
(if-let [handler-fn (get command-handlers cmd-name)]
|
||||
(let [result (handler-fn ds nats user-id community-id channel-id args)]
|
||||
(mw/json-response result))
|
||||
|
||||
;; Unknown command
|
||||
(mw/json-response {:result (str "Unknown command: /" cmd-name ". Type /help for available commands.")})))))
|
||||
@@ -0,0 +1,242 @@
|
||||
(ns ajet.chat.api.handlers.communities
|
||||
"Community CRUD handlers.
|
||||
|
||||
Communities are the top-level organizational unit. Creating a community
|
||||
makes the creator the owner and bootstraps a #general channel."
|
||||
(:require [clojure.string :as str]
|
||||
[clojure.tools.logging :as log]
|
||||
[ajet.chat.shared.db :as db]
|
||||
[ajet.chat.shared.eventbus :as eventbus]
|
||||
[ajet.chat.api.middleware :as mw])
|
||||
(:import [java.util UUID]))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Helpers
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- uuid []
|
||||
(str (UUID/randomUUID)))
|
||||
|
||||
(defn- require-user! [request]
|
||||
(or (:user-id request)
|
||||
(throw (ex-info "Authentication required"
|
||||
{:type :ajet.chat/forbidden}))))
|
||||
|
||||
(defn- check-membership!
|
||||
"Verify user is a member of the community. Returns the membership row."
|
||||
[ds community-id user-id]
|
||||
(or (db/execute-one! ds
|
||||
{:select [:*]
|
||||
:from [:community-members]
|
||||
:where [:and
|
||||
[:= :community-id [:cast community-id :uuid]]
|
||||
[:= :user-id [:cast user-id :uuid]]]})
|
||||
(throw (ex-info "Not a member of this community"
|
||||
{:type :ajet.chat/forbidden}))))
|
||||
|
||||
(defn- check-role!
|
||||
"Verify user has the required role or higher. Returns membership row."
|
||||
[ds community-id user-id required-role]
|
||||
(let [member (check-membership! ds community-id user-id)
|
||||
role (:role member)
|
||||
hierarchy {"owner" 3 "admin" 2 "member" 1}
|
||||
has-level (get hierarchy role 0)
|
||||
need-level (get hierarchy required-role 0)]
|
||||
(when (< has-level need-level)
|
||||
(throw (ex-info (str "Requires " required-role " role or higher")
|
||||
{:type :ajet.chat/forbidden})))
|
||||
member))
|
||||
|
||||
(defn- publish-event! [nats subject event-type payload]
|
||||
(try
|
||||
(eventbus/publish! nats subject event-type payload)
|
||||
(catch Exception e
|
||||
(log/error e "Failed to publish event" event-type "to" subject))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Handlers
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn create-community
|
||||
"POST /api/communities
|
||||
Creates community, adds user as owner, creates #general channel."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
nats (get-in request [:system :nats])
|
||||
params (:body-params request)
|
||||
name-v (:name params)
|
||||
slug (:slug params)]
|
||||
|
||||
;; Validate required fields
|
||||
(when (or (str/blank? name-v) (str/blank? slug))
|
||||
(throw (ex-info "Name and slug are required"
|
||||
{:type :ajet.chat/validation-error})))
|
||||
|
||||
;; Validate slug format
|
||||
(when-not (re-matches #"[a-z0-9][a-z0-9-]*[a-z0-9]" slug)
|
||||
(throw (ex-info "Slug must be lowercase letters, digits, and hyphens"
|
||||
{:type :ajet.chat/validation-error})))
|
||||
|
||||
;; Check slug uniqueness
|
||||
(when (db/execute-one! ds
|
||||
{:select [:id]
|
||||
:from [:communities]
|
||||
:where [:= :slug slug]})
|
||||
(throw (ex-info "A community with this slug already exists"
|
||||
{:type :ajet.chat/conflict})))
|
||||
|
||||
(let [community-id (uuid)
|
||||
channel-id (uuid)]
|
||||
(db/with-transaction [tx ds]
|
||||
;; Create community
|
||||
(db/execute! tx
|
||||
{:insert-into :communities
|
||||
:values [{:id [:cast community-id :uuid]
|
||||
:name name-v
|
||||
:slug slug}]})
|
||||
|
||||
;; Add creator as owner
|
||||
(db/execute! tx
|
||||
{:insert-into :community-members
|
||||
:values [{:community-id [:cast community-id :uuid]
|
||||
:user-id [:cast user-id :uuid]
|
||||
:role "owner"}]})
|
||||
|
||||
;; Create #general channel
|
||||
(db/execute! tx
|
||||
{:insert-into :channels
|
||||
:values [{:id [:cast channel-id :uuid]
|
||||
:community-id [:cast community-id :uuid]
|
||||
:name "general"
|
||||
:type "text"
|
||||
:visibility "public"}]})
|
||||
|
||||
;; Add creator to #general
|
||||
(db/execute! tx
|
||||
{:insert-into :channel-members
|
||||
:values [{:channel-id [:cast channel-id :uuid]
|
||||
:user-id [:cast user-id :uuid]}]}))
|
||||
|
||||
;; Fetch the created community
|
||||
(let [community (db/execute-one! ds
|
||||
{:select [:*]
|
||||
:from [:communities]
|
||||
:where [:= :id [:cast community-id :uuid]]})]
|
||||
|
||||
;; Publish event
|
||||
(publish-event! nats
|
||||
(str "chat.events." community-id)
|
||||
:community/created
|
||||
{:community-id community-id
|
||||
:name name-v
|
||||
:slug slug
|
||||
:created-by user-id})
|
||||
|
||||
(mw/json-response 201 community)))))
|
||||
|
||||
(defn list-communities
|
||||
"GET /api/communities
|
||||
Returns communities the authenticated user belongs to."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
communities (db/execute! ds
|
||||
{:select [:c.*]
|
||||
:from [[:communities :c]]
|
||||
:join [[:community-members :cm]
|
||||
[:= :c.id :cm.community-id]]
|
||||
:where [:= :cm.user-id [:cast user-id :uuid]]
|
||||
:order-by [[:c.name :asc]]})]
|
||||
(mw/json-response communities)))
|
||||
|
||||
(defn get-community
|
||||
"GET /api/communities/:id
|
||||
Returns community details. Requires membership."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
community-id (get-in request [:path-params :id])
|
||||
_ (check-membership! ds community-id user-id)
|
||||
community (db/execute-one! ds
|
||||
{:select [:*]
|
||||
:from [:communities]
|
||||
:where [:= :id [:cast community-id :uuid]]})]
|
||||
(if community
|
||||
(mw/json-response community)
|
||||
(mw/error-response 404 "NOT_FOUND" "Community not found"))))
|
||||
|
||||
(defn update-community
|
||||
"PUT /api/communities/:id
|
||||
Updates community name/slug. Owner only."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
nats (get-in request [:system :nats])
|
||||
community-id (get-in request [:path-params :id])
|
||||
params (:body-params request)]
|
||||
|
||||
(check-role! ds community-id user-id "owner")
|
||||
|
||||
;; Validate slug format if provided
|
||||
(when (and (:slug params) (not (re-matches #"[a-z0-9][a-z0-9-]*[a-z0-9]" (:slug params))))
|
||||
(throw (ex-info "Slug must be lowercase letters, digits, and hyphens"
|
||||
{:type :ajet.chat/validation-error})))
|
||||
|
||||
;; Check slug uniqueness if changing
|
||||
(when (:slug params)
|
||||
(let [existing (db/execute-one! ds
|
||||
{:select [:id]
|
||||
:from [:communities]
|
||||
:where [:and
|
||||
[:= :slug (:slug params)]
|
||||
[:!= :id [:cast community-id :uuid]]]})]
|
||||
(when existing
|
||||
(throw (ex-info "A community with this slug already exists"
|
||||
{:type :ajet.chat/conflict})))))
|
||||
|
||||
(let [updates (cond-> {}
|
||||
(:name params) (assoc :name (:name params))
|
||||
(:slug params) (assoc :slug (:slug params)))]
|
||||
(when (seq updates)
|
||||
(db/execute! ds
|
||||
{:update :communities
|
||||
:set updates
|
||||
:where [:= :id [:cast community-id :uuid]]}))
|
||||
|
||||
(let [community (db/execute-one! ds
|
||||
{:select [:*]
|
||||
:from [:communities]
|
||||
:where [:= :id [:cast community-id :uuid]]})]
|
||||
(publish-event! nats
|
||||
(str "chat.events." community-id)
|
||||
:community/updated
|
||||
{:community-id community-id
|
||||
:updated-by user-id
|
||||
:changes updates})
|
||||
|
||||
(mw/json-response community)))))
|
||||
|
||||
(defn delete-community
|
||||
"DELETE /api/communities/:id
|
||||
Deletes community and all associated data. Owner only."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
nats (get-in request [:system :nats])
|
||||
community-id (get-in request [:path-params :id])]
|
||||
|
||||
(check-role! ds community-id user-id "owner")
|
||||
|
||||
;; CASCADE handles cleanup of members, channels, messages, etc.
|
||||
(db/execute! ds
|
||||
{:delete-from :communities
|
||||
:where [:= :id [:cast community-id :uuid]]})
|
||||
|
||||
(publish-event! nats
|
||||
(str "chat.events." community-id)
|
||||
:community/deleted
|
||||
{:community-id community-id
|
||||
:deleted-by user-id})
|
||||
|
||||
(mw/json-response 204 nil)))
|
||||
@@ -0,0 +1,169 @@
|
||||
(ns ajet.chat.api.handlers.dms
|
||||
"DM (Direct Message) handlers.
|
||||
|
||||
DMs are channels with type 'dm' or 'group_dm' and a NULL community_id.
|
||||
Creating a 1:1 DM is idempotent - returns existing if one exists
|
||||
between the two users."
|
||||
(:require [clojure.string :as str]
|
||||
[clojure.tools.logging :as log]
|
||||
[ajet.chat.shared.db :as db]
|
||||
[ajet.chat.api.middleware :as mw])
|
||||
(:import [java.util UUID]))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Helpers
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- uuid [] (str (UUID/randomUUID)))
|
||||
|
||||
(defn- require-user! [request]
|
||||
(or (:user-id request)
|
||||
(throw (ex-info "Authentication required" {:type :ajet.chat/forbidden}))))
|
||||
|
||||
(defn- channel-with-members
|
||||
"Fetch a DM channel with its members."
|
||||
[ds channel-id]
|
||||
(let [channel (db/execute-one! ds
|
||||
{:select [:*]
|
||||
:from [:channels]
|
||||
:where [:= :id [:cast channel-id :uuid]]})
|
||||
members (db/execute! ds
|
||||
{:select [:u.id :u.username :u.display-name :u.avatar-url]
|
||||
:from [[:channel-members :cm]]
|
||||
:join [[:users :u] [:= :u.id :cm.user-id]]
|
||||
:where [:= :cm.channel-id [:cast channel-id :uuid]]})]
|
||||
(assoc channel :members members)))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Handlers
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn list-dms
|
||||
"GET /api/dms
|
||||
Lists all DM/group_dm channels the user is a member of.
|
||||
Ordered by most recent message activity."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
;; Get DM channels the user is in, ordered by latest message
|
||||
channels (db/execute! ds
|
||||
{:select [:c.*]
|
||||
:from [[:channels :c]]
|
||||
:join [[:channel-members :cm]
|
||||
[:= :cm.channel-id :c.id]]
|
||||
:where [:and
|
||||
[:= :cm.user-id [:cast user-id :uuid]]
|
||||
[:in :c.type ["dm" "group_dm"]]]
|
||||
:order-by [[:c.created-at :desc]]})
|
||||
;; Enrich with member info
|
||||
result (mapv (fn [ch]
|
||||
(let [members (db/execute! ds
|
||||
{:select [:u.id :u.username :u.display-name :u.avatar-url]
|
||||
:from [[:channel-members :chm]]
|
||||
:join [[:users :u] [:= :u.id :chm.user-id]]
|
||||
:where [:= :chm.channel-id (:id ch)]})]
|
||||
(assoc ch :members members)))
|
||||
channels)]
|
||||
(mw/json-response result)))
|
||||
|
||||
(defn create-dm
|
||||
"POST /api/dms
|
||||
Creates a 1:1 DM channel, or returns existing one if it already exists."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
params (:body-params request)
|
||||
target-id (:user_id params)]
|
||||
|
||||
(when (str/blank? target-id)
|
||||
(throw (ex-info "user_id is required"
|
||||
{:type :ajet.chat/validation-error})))
|
||||
|
||||
(when (= user-id target-id)
|
||||
(throw (ex-info "Cannot create a DM with yourself"
|
||||
{:type :ajet.chat/validation-error})))
|
||||
|
||||
;; Check target user exists
|
||||
(when-not (db/execute-one! ds
|
||||
{:select [:id]
|
||||
:from [:users]
|
||||
:where [:= :id [:cast target-id :uuid]]})
|
||||
(throw (ex-info "Target user not found"
|
||||
{:type :ajet.chat/not-found})))
|
||||
|
||||
;; Check for existing DM between these two users
|
||||
(let [existing (db/execute-one! ds
|
||||
{:select [:c.id]
|
||||
:from [[:channels :c]]
|
||||
:join [[:channel-members :cm1]
|
||||
[:= :cm1.channel-id :c.id]
|
||||
[:channel-members :cm2]
|
||||
[:= :cm2.channel-id :c.id]]
|
||||
:where [:and
|
||||
[:= :c.type "dm"]
|
||||
[:= :cm1.user-id [:cast user-id :uuid]]
|
||||
[:= :cm2.user-id [:cast target-id :uuid]]]})]
|
||||
(if existing
|
||||
;; Return existing DM
|
||||
(mw/json-response (channel-with-members ds (str (:id existing))))
|
||||
|
||||
;; Create new DM
|
||||
(let [channel-id (uuid)]
|
||||
(db/with-transaction [tx ds]
|
||||
(db/execute! tx
|
||||
{:insert-into :channels
|
||||
:values [{:id [:cast channel-id :uuid]
|
||||
:name "dm"
|
||||
:type "dm"
|
||||
:visibility "private"}]})
|
||||
(db/execute! tx
|
||||
{:insert-into :channel-members
|
||||
:values [{:channel-id [:cast channel-id :uuid]
|
||||
:user-id [:cast user-id :uuid]}
|
||||
{:channel-id [:cast channel-id :uuid]
|
||||
:user-id [:cast target-id :uuid]}]}))
|
||||
|
||||
(mw/json-response 201 (channel-with-members ds channel-id)))))))
|
||||
|
||||
(defn create-group-dm
|
||||
"POST /api/dms/group
|
||||
Creates a group DM with multiple users."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
params (:body-params request)
|
||||
user-ids (:user_ids params)]
|
||||
|
||||
(when (or (nil? user-ids) (< (count user-ids) 2))
|
||||
(throw (ex-info "At least 2 other user IDs are required for a group DM"
|
||||
{:type :ajet.chat/validation-error})))
|
||||
|
||||
;; Ensure all members include the creator
|
||||
(let [all-member-ids (distinct (cons user-id user-ids))
|
||||
channel-id (uuid)]
|
||||
|
||||
;; Verify all users exist
|
||||
(doseq [uid user-ids]
|
||||
(when-not (db/execute-one! ds
|
||||
{:select [:id]
|
||||
:from [:users]
|
||||
:where [:= :id [:cast uid :uuid]]})
|
||||
(throw (ex-info (str "User not found: " uid)
|
||||
{:type :ajet.chat/not-found}))))
|
||||
|
||||
(db/with-transaction [tx ds]
|
||||
(db/execute! tx
|
||||
{:insert-into :channels
|
||||
:values [{:id [:cast channel-id :uuid]
|
||||
:name "group-dm"
|
||||
:type "group_dm"
|
||||
:visibility "private"}]})
|
||||
|
||||
(db/execute! tx
|
||||
{:insert-into :channel-members
|
||||
:values (mapv (fn [uid]
|
||||
{:channel-id [:cast channel-id :uuid]
|
||||
:user-id [:cast uid :uuid]})
|
||||
all-member-ids)}))
|
||||
|
||||
(mw/json-response 201 (channel-with-members ds channel-id)))))
|
||||
@@ -0,0 +1,73 @@
|
||||
(ns ajet.chat.api.handlers.health
|
||||
"Health check handler.
|
||||
|
||||
Checks connectivity to DB, NATS, and MinIO. Returns 200 if all healthy,
|
||||
503 if any check fails (degraded mode)."
|
||||
(:require [clojure.tools.logging :as log]
|
||||
[ajet.chat.shared.db :as db]
|
||||
[ajet.chat.shared.eventbus :as eventbus]
|
||||
[ajet.chat.api.middleware :as mw])
|
||||
(:import [software.amazon.awssdk.services.s3 S3Client]
|
||||
[software.amazon.awssdk.services.s3.model HeadBucketRequest]))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Health Checks
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- check-db
|
||||
"Test database connectivity with a simple query."
|
||||
[ds]
|
||||
(try
|
||||
(db/execute-sql! ds ["SELECT 1"])
|
||||
"ok"
|
||||
(catch Exception e
|
||||
(log/error e "DB health check failed")
|
||||
"error")))
|
||||
|
||||
(defn- check-nats
|
||||
"Test NATS connection status."
|
||||
[nats]
|
||||
(try
|
||||
(if (and nats (eventbus/connected? nats))
|
||||
"ok"
|
||||
"error")
|
||||
(catch Exception e
|
||||
(log/error e "NATS health check failed")
|
||||
"error")))
|
||||
|
||||
(defn- check-minio
|
||||
"Test MinIO/S3 connectivity by checking if the bucket exists."
|
||||
[^S3Client s3 bucket]
|
||||
(try
|
||||
(.headBucket s3 (-> (HeadBucketRequest/builder)
|
||||
(.bucket bucket)
|
||||
(.build)))
|
||||
"ok"
|
||||
(catch Exception e
|
||||
(log/error e "MinIO health check failed")
|
||||
"error")))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Handler
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn health-check
|
||||
"GET /api/health
|
||||
Returns health status for all dependencies.
|
||||
200 = all ok, 503 = at least one check failed."
|
||||
[request]
|
||||
(let [ds (get-in request [:system :ds])
|
||||
nats (get-in request [:system :nats])
|
||||
s3 (get-in request [:system :s3])
|
||||
bucket (get-in request [:system :bucket])
|
||||
|
||||
db-status (check-db ds)
|
||||
nats-status (check-nats nats)
|
||||
minio-status (check-minio s3 bucket)
|
||||
|
||||
checks {:db db-status :nats nats-status :minio minio-status}
|
||||
all-ok (every? #(= "ok" %) (vals checks))
|
||||
status (if all-ok "ok" "degraded")
|
||||
code (if all-ok 200 503)]
|
||||
|
||||
(mw/json-response code {:status status :checks checks})))
|
||||
@@ -0,0 +1,297 @@
|
||||
(ns ajet.chat.api.handlers.invites
|
||||
"Invite management handlers.
|
||||
|
||||
Invites are links with unique codes that allow users to join a community.
|
||||
They can have optional max_uses and expiry. Direct invites create a
|
||||
notification for the target user."
|
||||
(:require [clojure.string :as str]
|
||||
[clojure.tools.logging :as log]
|
||||
[ajet.chat.shared.db :as db]
|
||||
[ajet.chat.shared.eventbus :as eventbus]
|
||||
[ajet.chat.api.middleware :as mw])
|
||||
(:import [java.util UUID]
|
||||
[java.security SecureRandom]))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Helpers
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- uuid [] (str (UUID/randomUUID)))
|
||||
|
||||
(defn- require-user! [request]
|
||||
(or (:user-id request)
|
||||
(throw (ex-info "Authentication required" {:type :ajet.chat/forbidden}))))
|
||||
|
||||
(defn- check-membership! [ds community-id user-id]
|
||||
(or (db/execute-one! ds
|
||||
{:select [:*]
|
||||
:from [:community-members]
|
||||
:where [:and
|
||||
[:= :community-id [:cast community-id :uuid]]
|
||||
[:= :user-id [:cast user-id :uuid]]]})
|
||||
(throw (ex-info "Not a member of this community"
|
||||
{:type :ajet.chat/forbidden}))))
|
||||
|
||||
(defn- check-role! [ds community-id user-id required-role]
|
||||
(let [member (check-membership! ds community-id user-id)
|
||||
role (:role member)
|
||||
hierarchy {"owner" 3 "admin" 2 "member" 1}
|
||||
has-level (get hierarchy role 0)
|
||||
need-level (get hierarchy required-role 0)]
|
||||
(when (< has-level need-level)
|
||||
(throw (ex-info (str "Requires " required-role " role or higher")
|
||||
{:type :ajet.chat/forbidden})))
|
||||
member))
|
||||
|
||||
(defn- generate-invite-code
|
||||
"Generate a random alphanumeric invite code."
|
||||
[]
|
||||
(let [chars "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
sr (SecureRandom.)
|
||||
sb (StringBuilder. 8)]
|
||||
(dotimes [_ 8]
|
||||
(.append sb (.charAt chars (.nextInt sr (count chars)))))
|
||||
(.toString sb)))
|
||||
|
||||
(defn- publish-event! [nats subject event-type payload]
|
||||
(try
|
||||
(eventbus/publish! nats subject event-type payload)
|
||||
(catch Exception e
|
||||
(log/error e "Failed to publish event" event-type "to" subject))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Handlers
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn create-invite
|
||||
"POST /api/communities/:cid/invites
|
||||
Creates an invite link. Admin+ only."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
community-id (get-in request [:path-params :cid])
|
||||
params (:body-params request)
|
||||
max-uses (:max_uses params)
|
||||
expires-hrs (:expires_in_hours params)]
|
||||
|
||||
(check-role! ds community-id user-id "admin")
|
||||
|
||||
(let [invite-id (uuid)
|
||||
code (generate-invite-code)
|
||||
values (cond-> {:id [:cast invite-id :uuid]
|
||||
:community-id [:cast community-id :uuid]
|
||||
:created-by [:cast user-id :uuid]
|
||||
:code code}
|
||||
max-uses (assoc :max-uses max-uses)
|
||||
expires-hrs (assoc :expires-at
|
||||
[:raw (str "now() + interval '" expires-hrs " hours'")]))]
|
||||
|
||||
(db/execute! ds
|
||||
{:insert-into :invites
|
||||
:values [values]})
|
||||
|
||||
(let [invite (db/execute-one! ds
|
||||
{:select [:*]
|
||||
:from [:invites]
|
||||
:where [:= :id [:cast invite-id :uuid]]})]
|
||||
(mw/json-response 201 (assoc invite :url (str "/invite/" code)))))))
|
||||
|
||||
(defn list-invites
|
||||
"GET /api/communities/:cid/invites
|
||||
Lists active (non-expired, non-exhausted) invites. Admin+ only."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
community-id (get-in request [:path-params :cid])]
|
||||
|
||||
(check-role! ds community-id user-id "admin")
|
||||
|
||||
(let [invites (db/execute! ds
|
||||
{:select [:*]
|
||||
:from [:invites]
|
||||
:where [:and
|
||||
[:= :community-id [:cast community-id :uuid]]
|
||||
[:or
|
||||
[:= :expires-at nil]
|
||||
[:> :expires-at [:now]]]
|
||||
[:or
|
||||
[:= :max-uses nil]
|
||||
[:< :uses :max-uses]]]
|
||||
:order-by [[:created-at :desc]]})]
|
||||
(mw/json-response invites))))
|
||||
|
||||
(defn revoke-invite
|
||||
"DELETE /api/invites/:id
|
||||
Deletes an invite. Admin+ of the invite's community."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
invite-id (get-in request [:path-params :id])
|
||||
invite (or (db/execute-one! ds
|
||||
{:select [:*]
|
||||
:from [:invites]
|
||||
:where [:= :id [:cast invite-id :uuid]]})
|
||||
(throw (ex-info "Invite not found"
|
||||
{:type :ajet.chat/not-found})))]
|
||||
|
||||
(check-role! ds (str (:community-id invite)) user-id "admin")
|
||||
|
||||
(db/execute! ds
|
||||
{:delete-from :invites
|
||||
:where [:= :id [:cast invite-id :uuid]]})
|
||||
|
||||
(mw/json-response 204 nil)))
|
||||
|
||||
(defn accept-invite
|
||||
"POST /api/invites/:code/accept
|
||||
Accepts an invite and joins the community. Idempotent if already a member."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
nats (get-in request [:system :nats])
|
||||
code (get-in request [:path-params :code])
|
||||
invite (or (db/execute-one! ds
|
||||
{:select [:*]
|
||||
:from [:invites]
|
||||
:where [:= :code code]})
|
||||
(throw (ex-info "Invite not found"
|
||||
{:type :ajet.chat/not-found})))
|
||||
community-id (str (:community-id invite))]
|
||||
|
||||
;; Check expiry
|
||||
(when (:expires-at invite)
|
||||
(let [expires-at (:expires-at invite)
|
||||
now (java.time.Instant/now)
|
||||
exp-inst (if (instance? java.time.Instant expires-at)
|
||||
expires-at
|
||||
(.toInstant expires-at))]
|
||||
(when (.isAfter now exp-inst)
|
||||
(throw (ex-info "This invite has expired"
|
||||
{:type :ajet.chat/gone})))))
|
||||
|
||||
;; Check max uses
|
||||
(when (and (:max-uses invite) (>= (:uses invite) (:max-uses invite)))
|
||||
(throw (ex-info "This invite has reached its maximum number of uses"
|
||||
{:type :ajet.chat/gone})))
|
||||
|
||||
;; Check if user is banned
|
||||
(when (db/execute-one! ds
|
||||
{:select [[:1 :exists]]
|
||||
:from [:bans]
|
||||
:where [:and
|
||||
[:= :community-id [:cast community-id :uuid]]
|
||||
[:= :user-id [:cast user-id :uuid]]]})
|
||||
(throw (ex-info "You are banned from this community"
|
||||
{:type :ajet.chat/forbidden})))
|
||||
|
||||
;; Check if already a member (idempotent)
|
||||
(let [already-member (db/execute-one! ds
|
||||
{:select [:user-id]
|
||||
:from [:community-members]
|
||||
:where [:and
|
||||
[:= :community-id [:cast community-id :uuid]]
|
||||
[:= :user-id [:cast user-id :uuid]]]})]
|
||||
(if already-member
|
||||
(mw/json-response {:status "already_member" :community-id community-id})
|
||||
|
||||
(do
|
||||
(db/with-transaction [tx ds]
|
||||
;; Add as member
|
||||
(db/execute! tx
|
||||
{:insert-into :community-members
|
||||
:values [{:community-id [:cast community-id :uuid]
|
||||
:user-id [:cast user-id :uuid]
|
||||
:role "member"}]})
|
||||
|
||||
;; Join #general channel
|
||||
(when-let [general (db/execute-one! tx
|
||||
{:select [:id]
|
||||
:from [:channels]
|
||||
:where [:and
|
||||
[:= :community-id [:cast community-id :uuid]]
|
||||
[:= :name "general"]]})]
|
||||
(db/execute! tx
|
||||
{:insert-into :channel-members
|
||||
:values [{:channel-id (:id general)
|
||||
:user-id [:cast user-id :uuid]}]
|
||||
:on-conflict [:channel-id :user-id]
|
||||
:do-nothing true}))
|
||||
|
||||
;; Increment invite uses
|
||||
(db/execute! tx
|
||||
{:update :invites
|
||||
:set {:uses [:+ :uses 1]}
|
||||
:where [:= :id (:id invite)]}))
|
||||
|
||||
(publish-event! nats
|
||||
(str "chat.events." community-id)
|
||||
:member/joined
|
||||
{:community-id community-id
|
||||
:user-id user-id
|
||||
:invite-code code})
|
||||
|
||||
(mw/json-response {:status "joined" :community-id community-id}))))))
|
||||
|
||||
(defn direct-invite
|
||||
"POST /api/communities/:cid/invites/direct
|
||||
Creates a direct invite for a specific user by ID. Admin+ only.
|
||||
Creates an invite + a notification for the target user."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
nats (get-in request [:system :nats])
|
||||
community-id (get-in request [:path-params :cid])
|
||||
params (:body-params request)
|
||||
target-uid (:user_id params)]
|
||||
|
||||
(check-role! ds community-id user-id "admin")
|
||||
|
||||
(when (str/blank? target-uid)
|
||||
(throw (ex-info "user_id is required"
|
||||
{:type :ajet.chat/validation-error})))
|
||||
|
||||
;; Verify target user exists
|
||||
(when-not (db/execute-one! ds
|
||||
{:select [:id]
|
||||
:from [:users]
|
||||
:where [:= :id [:cast target-uid :uuid]]})
|
||||
(throw (ex-info "Target user not found"
|
||||
{:type :ajet.chat/not-found})))
|
||||
|
||||
(let [invite-id (uuid)
|
||||
code (generate-invite-code)
|
||||
notif-id (uuid)]
|
||||
|
||||
(db/with-transaction [tx ds]
|
||||
;; Create invite (single use)
|
||||
(db/execute! tx
|
||||
{:insert-into :invites
|
||||
:values [{:id [:cast invite-id :uuid]
|
||||
:community-id [:cast community-id :uuid]
|
||||
:created-by [:cast user-id :uuid]
|
||||
:code code
|
||||
:max-uses 1}]})
|
||||
|
||||
;; Create notification for target user
|
||||
(db/execute! tx
|
||||
{:insert-into :notifications
|
||||
:values [{:id [:cast notif-id :uuid]
|
||||
:user-id [:cast target-uid :uuid]
|
||||
:type "invite"
|
||||
:source-id [:cast invite-id :uuid]}]}))
|
||||
|
||||
;; Publish notification event
|
||||
(publish-event! nats
|
||||
(str "chat.notifications." target-uid)
|
||||
:notification/new
|
||||
{:user-id target-uid
|
||||
:type "invite"
|
||||
:invite-code code
|
||||
:community-id community-id
|
||||
:invited-by user-id})
|
||||
|
||||
(let [invite (db/execute-one! ds
|
||||
{:select [:*]
|
||||
:from [:invites]
|
||||
:where [:= :id [:cast invite-id :uuid]]})]
|
||||
(mw/json-response 201 (assoc invite :url (str "/invite/" code)))))))
|
||||
@@ -0,0 +1,395 @@
|
||||
(ns ajet.chat.api.handlers.messages
|
||||
"Message handlers: send, list, get, edit, delete, thread, and read-tracking.
|
||||
|
||||
Messages use cursor-based pagination. The send handler parses mentions,
|
||||
creates mention records, creates notifications for @mentioned users,
|
||||
and publishes events to the correct NATS subject."
|
||||
(:require [clojure.string :as str]
|
||||
[clojure.tools.logging :as log]
|
||||
[ajet.chat.shared.db :as db]
|
||||
[ajet.chat.shared.eventbus :as eventbus]
|
||||
[ajet.chat.shared.mentions :as mentions]
|
||||
[ajet.chat.api.middleware :as mw])
|
||||
(:import [java.util UUID]
|
||||
[java.time Instant Duration]))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Helpers
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- uuid [] (str (UUID/randomUUID)))
|
||||
|
||||
(defn- require-user! [request]
|
||||
(or (:user-id request)
|
||||
(throw (ex-info "Authentication required" {:type :ajet.chat/forbidden}))))
|
||||
|
||||
(defn- check-channel-member! [ds channel-id user-id]
|
||||
(or (db/execute-one! ds
|
||||
{:select [:*]
|
||||
:from [:channel-members]
|
||||
:where [:and
|
||||
[:= :channel-id [:cast channel-id :uuid]]
|
||||
[:= :user-id [:cast user-id :uuid]]]})
|
||||
(throw (ex-info "Not a member of this channel"
|
||||
{:type :ajet.chat/forbidden}))))
|
||||
|
||||
(defn- get-channel-row [ds channel-id]
|
||||
(or (db/execute-one! ds
|
||||
{:select [:*]
|
||||
:from [:channels]
|
||||
:where [:= :id [:cast channel-id :uuid]]})
|
||||
(throw (ex-info "Channel not found" {:type :ajet.chat/not-found}))))
|
||||
|
||||
(defn- get-message-row [ds message-id]
|
||||
(or (db/execute-one! ds
|
||||
{:select [:*]
|
||||
:from [:messages]
|
||||
:where [:= :id [:cast message-id :uuid]]})
|
||||
(throw (ex-info "Message not found" {:type :ajet.chat/not-found}))))
|
||||
|
||||
(defn- nats-subject-for-channel
|
||||
"Determine the NATS subject based on channel type.
|
||||
Community channels -> chat.events.{community-id}
|
||||
DM channels -> chat.dm.{channel-id}"
|
||||
[channel]
|
||||
(if (:community-id channel)
|
||||
(str "chat.events." (:community-id channel))
|
||||
(str "chat.dm." (:id channel))))
|
||||
|
||||
(defn- publish-event! [nats subject event-type payload]
|
||||
(try
|
||||
(eventbus/publish! nats subject event-type payload)
|
||||
(catch Exception e
|
||||
(log/error e "Failed to publish event" event-type "to" subject))))
|
||||
|
||||
(defn- create-mention-records!
|
||||
"Parse mentions from message body, create mention rows, and create notifications."
|
||||
[ds message-id body-md channel-id user-id]
|
||||
(let [parsed (mentions/parse body-md)]
|
||||
(doseq [{:keys [type id]} parsed]
|
||||
(let [mention-id (uuid)]
|
||||
(db/execute! ds
|
||||
{:insert-into :mentions
|
||||
:values [(cond-> {:id [:cast mention-id :uuid]
|
||||
:message-id [:cast message-id :uuid]
|
||||
:target-type (name type)}
|
||||
id (assoc :target-id [:cast id :uuid]))]}))
|
||||
|
||||
;; Create notifications for user mentions
|
||||
(when (and (= type :user) id)
|
||||
(let [notif-id (uuid)]
|
||||
(db/execute! ds
|
||||
{:insert-into :notifications
|
||||
:values [{:id [:cast notif-id :uuid]
|
||||
:user-id [:cast id :uuid]
|
||||
:type "mention"
|
||||
:source-id [:cast message-id :uuid]}]})))
|
||||
|
||||
;; For @here mentions, notify all channel members (except sender)
|
||||
(when (= type :here)
|
||||
(let [members (db/execute! ds
|
||||
{:select [:user-id]
|
||||
:from [:channel-members]
|
||||
:where [:and
|
||||
[:= :channel-id [:cast channel-id :uuid]]
|
||||
[:!= :user-id [:cast user-id :uuid]]]})]
|
||||
(doseq [member members]
|
||||
(let [notif-id (uuid)]
|
||||
(db/execute! ds
|
||||
{:insert-into :notifications
|
||||
:values [{:id [:cast notif-id :uuid]
|
||||
:user-id (:user-id member)
|
||||
:type "mention"
|
||||
:source-id [:cast message-id :uuid]}]}))))))))
|
||||
|
||||
(defn- create-thread-notifications!
|
||||
"Notify thread participants (except the sender) when a thread reply is created."
|
||||
[ds message-id parent-id user-id]
|
||||
(let [participants (db/execute! ds
|
||||
{:select-distinct [:user-id]
|
||||
:from [:messages]
|
||||
:where [:and
|
||||
[:or
|
||||
[:= :id [:cast parent-id :uuid]]
|
||||
[:= :parent-id [:cast parent-id :uuid]]]
|
||||
[:!= :user-id [:cast user-id :uuid]]]})]
|
||||
(doseq [participant participants]
|
||||
(let [notif-id (uuid)]
|
||||
(db/execute! ds
|
||||
{:insert-into :notifications
|
||||
:values [{:id [:cast notif-id :uuid]
|
||||
:user-id (:user-id participant)
|
||||
:type "thread_reply"
|
||||
:source-id [:cast message-id :uuid]}]})))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Handlers
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn list-messages
|
||||
"GET /api/channels/:id/messages?before=<uuid>&after=<uuid>&limit=N
|
||||
Cursor-based pagination. Default limit 50, max 100."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
channel-id (get-in request [:path-params :id])
|
||||
params (:query-params request)
|
||||
before (get params "before")
|
||||
after (get params "after")
|
||||
limit (min (or (some-> (get params "limit") parse-long) 50) 100)]
|
||||
|
||||
(check-channel-member! ds channel-id user-id)
|
||||
|
||||
(let [base-where [:= :channel-id [:cast channel-id :uuid]]
|
||||
;; Cursor-based pagination using subquery on created_at
|
||||
where-clause (cond
|
||||
before
|
||||
[:and base-where
|
||||
[:< :created-at
|
||||
{:select [:created-at]
|
||||
:from [:messages]
|
||||
:where [:= :id [:cast before :uuid]]}]]
|
||||
|
||||
after
|
||||
[:and base-where
|
||||
[:> :created-at
|
||||
{:select [:created-at]
|
||||
:from [:messages]
|
||||
:where [:= :id [:cast after :uuid]]}]]
|
||||
|
||||
:else base-where)
|
||||
order-dir (if after :asc :desc)
|
||||
messages (db/execute! ds
|
||||
{:select [:*]
|
||||
:from [:messages]
|
||||
:where where-clause
|
||||
:order-by [[:created-at order-dir]]
|
||||
:limit limit})
|
||||
;; Always return newest-last ordering
|
||||
messages (if (= order-dir :desc)
|
||||
(vec (reverse messages))
|
||||
messages)]
|
||||
(mw/json-response messages))))
|
||||
|
||||
(defn send-message
|
||||
"POST /api/channels/:id/messages
|
||||
Creates message, parses mentions, creates notifications, publishes event."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
nats (get-in request [:system :nats])
|
||||
channel-id (get-in request [:path-params :id])
|
||||
params (:body-params request)
|
||||
body-md (:body_md params)
|
||||
parent-id (:parent_id params)]
|
||||
|
||||
(check-channel-member! ds channel-id user-id)
|
||||
|
||||
(when (str/blank? body-md)
|
||||
(throw (ex-info "Message body is required"
|
||||
{:type :ajet.chat/validation-error})))
|
||||
|
||||
;; If reply, resolve parent to root (no nested threads)
|
||||
(let [resolved-parent-id
|
||||
(when parent-id
|
||||
(let [parent (get-message-row ds parent-id)]
|
||||
(or (:parent-id parent) (str (:id parent)))))
|
||||
message-id (uuid)]
|
||||
|
||||
(db/with-transaction [tx ds]
|
||||
(db/execute! tx
|
||||
{:insert-into :messages
|
||||
:values [(cond-> {:id [:cast message-id :uuid]
|
||||
:channel-id [:cast channel-id :uuid]
|
||||
:user-id [:cast user-id :uuid]
|
||||
:body-md body-md}
|
||||
resolved-parent-id
|
||||
(assoc :parent-id [:cast resolved-parent-id :uuid]))]})
|
||||
|
||||
;; Create mention records and notifications
|
||||
(create-mention-records! tx message-id body-md channel-id user-id)
|
||||
|
||||
;; Create thread notifications if this is a reply
|
||||
(when resolved-parent-id
|
||||
(create-thread-notifications! tx message-id resolved-parent-id user-id)))
|
||||
|
||||
;; Publish event
|
||||
(let [channel (get-channel-row ds channel-id)
|
||||
subject (nats-subject-for-channel channel)
|
||||
message (get-message-row ds message-id)]
|
||||
|
||||
(publish-event! nats subject :message/created
|
||||
{:message-id message-id
|
||||
:channel-id channel-id
|
||||
:user-id user-id
|
||||
:body-md body-md
|
||||
:parent-id resolved-parent-id
|
||||
:community-id (when (:community-id channel)
|
||||
(str (:community-id channel)))})
|
||||
|
||||
(mw/json-response 201 message)))))
|
||||
|
||||
(defn get-message
|
||||
"GET /api/messages/:id
|
||||
Returns a single message. Requires channel membership."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
message-id (get-in request [:path-params :id])
|
||||
message (get-message-row ds message-id)]
|
||||
|
||||
(check-channel-member! ds (str (:channel-id message)) user-id)
|
||||
(mw/json-response message)))
|
||||
|
||||
(defn edit-message
|
||||
"PUT /api/messages/:id
|
||||
Edits message body. Author only, within 1-hour window.
|
||||
Re-parses mentions and publishes :message/edited."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
nats (get-in request [:system :nats])
|
||||
message-id (get-in request [:path-params :id])
|
||||
params (:body-params request)
|
||||
new-body (:body_md params)
|
||||
message (get-message-row ds message-id)]
|
||||
|
||||
;; Author check
|
||||
(when (not= (str (:user-id message)) user-id)
|
||||
(throw (ex-info "Only the author can edit this message"
|
||||
{:type :ajet.chat/forbidden})))
|
||||
|
||||
;; 1-hour edit window
|
||||
(let [created-at (:created-at message)
|
||||
created-inst (if (instance? java.time.Instant created-at)
|
||||
created-at
|
||||
(.toInstant created-at))
|
||||
one-hour-later (.plus created-inst (Duration/ofMinutes 60))
|
||||
now (Instant/now)]
|
||||
(when (.isAfter now one-hour-later)
|
||||
(throw (ex-info "Edit window has expired (1 hour)"
|
||||
{:type :ajet.chat/forbidden}))))
|
||||
|
||||
(when (str/blank? new-body)
|
||||
(throw (ex-info "Message body is required"
|
||||
{:type :ajet.chat/validation-error})))
|
||||
|
||||
(db/with-transaction [tx ds]
|
||||
;; Update message
|
||||
(db/execute! tx
|
||||
{:update :messages
|
||||
:set {:body-md new-body
|
||||
:edited-at [:now]}
|
||||
:where [:= :id [:cast message-id :uuid]]})
|
||||
|
||||
;; Delete old mentions and re-create
|
||||
(db/execute! tx
|
||||
{:delete-from :mentions
|
||||
:where [:= :message-id [:cast message-id :uuid]]})
|
||||
|
||||
(create-mention-records! tx message-id new-body
|
||||
(str (:channel-id message)) user-id))
|
||||
|
||||
(let [updated (get-message-row ds message-id)
|
||||
channel (get-channel-row ds (str (:channel-id message)))
|
||||
subject (nats-subject-for-channel channel)]
|
||||
|
||||
(publish-event! nats subject :message/edited
|
||||
{:message-id message-id
|
||||
:channel-id (str (:channel-id message))
|
||||
:user-id user-id
|
||||
:body-md new-body
|
||||
:community-id (when (:community-id channel)
|
||||
(str (:community-id channel)))})
|
||||
|
||||
(mw/json-response updated))))
|
||||
|
||||
(defn delete-message
|
||||
"DELETE /api/messages/:id
|
||||
Deletes message. Author or admin+ of the community."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
nats (get-in request [:system :nats])
|
||||
message-id (get-in request [:path-params :id])
|
||||
message (get-message-row ds message-id)
|
||||
channel (get-channel-row ds (str (:channel-id message)))
|
||||
is-author (= (str (:user-id message)) user-id)]
|
||||
|
||||
;; Check permission: author or admin+ in the community
|
||||
(when-not is-author
|
||||
(if (:community-id channel)
|
||||
(let [member (db/execute-one! ds
|
||||
{:select [:role]
|
||||
:from [:community-members]
|
||||
:where [:and
|
||||
[:= :community-id (:community-id channel)]
|
||||
[:= :user-id [:cast user-id :uuid]]]})
|
||||
role (:role member)
|
||||
hierarchy {"owner" 3 "admin" 2 "member" 1}]
|
||||
(when (< (get hierarchy role 0) (get hierarchy "admin" 0))
|
||||
(throw (ex-info "Only the author or an admin can delete this message"
|
||||
{:type :ajet.chat/forbidden}))))
|
||||
;; For DMs, only the author can delete
|
||||
(throw (ex-info "Only the author can delete this message"
|
||||
{:type :ajet.chat/forbidden}))))
|
||||
|
||||
;; CASCADE handles mentions, reactions, attachments
|
||||
(db/execute! ds
|
||||
{:delete-from :messages
|
||||
:where [:= :id [:cast message-id :uuid]]})
|
||||
|
||||
(let [subject (nats-subject-for-channel channel)]
|
||||
(publish-event! nats subject :message/deleted
|
||||
{:message-id message-id
|
||||
:channel-id (str (:channel-id message))
|
||||
:user-id user-id
|
||||
:community-id (when (:community-id channel)
|
||||
(str (:community-id channel)))}))
|
||||
|
||||
(mw/json-response 204 nil)))
|
||||
|
||||
(defn get-thread
|
||||
"GET /api/messages/:id/thread
|
||||
Returns all replies to a message, ordered by created_at."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
message-id (get-in request [:path-params :id])
|
||||
message (get-message-row ds message-id)]
|
||||
|
||||
(check-channel-member! ds (str (:channel-id message)) user-id)
|
||||
|
||||
;; Get root message + all replies
|
||||
(let [replies (db/execute! ds
|
||||
{:select [:*]
|
||||
:from [:messages]
|
||||
:where [:= :parent-id [:cast message-id :uuid]]
|
||||
:order-by [[:created-at :asc]]})]
|
||||
(mw/json-response {:root message
|
||||
:replies replies}))))
|
||||
|
||||
(defn mark-read
|
||||
"POST /api/channels/:id/read
|
||||
Updates last_read_message_id for the user in this channel."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
channel-id (get-in request [:path-params :id])
|
||||
params (:body-params request)
|
||||
msg-id (:last_read_message_id params)]
|
||||
|
||||
(check-channel-member! ds channel-id user-id)
|
||||
|
||||
(when (str/blank? msg-id)
|
||||
(throw (ex-info "last_read_message_id is required"
|
||||
{:type :ajet.chat/validation-error})))
|
||||
|
||||
(db/execute! ds
|
||||
{:update :channel-members
|
||||
:set {:last-read-message-id [:cast msg-id :uuid]}
|
||||
:where [:and
|
||||
[:= :channel-id [:cast channel-id :uuid]]
|
||||
[:= :user-id [:cast user-id :uuid]]]})
|
||||
|
||||
(mw/json-response {:status "ok"})))
|
||||
@@ -0,0 +1,111 @@
|
||||
(ns ajet.chat.api.handlers.notifications
|
||||
"Notification handlers: list, mark read, unread count.
|
||||
|
||||
Notifications are created by message handlers (mentions, DMs, thread replies)
|
||||
and invite handlers. This namespace handles reading and managing them."
|
||||
(:require [ajet.chat.shared.db :as db]
|
||||
[ajet.chat.api.middleware :as mw]))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Helpers
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- require-user! [request]
|
||||
(or (:user-id request)
|
||||
(throw (ex-info "Authentication required" {:type :ajet.chat/forbidden}))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Handlers
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn list-notifications
|
||||
"GET /api/notifications?after=<uuid>&limit=N&unread=true
|
||||
Returns notifications for the authenticated user, newest first."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
params (:query-params request)
|
||||
after (get params "after")
|
||||
limit (min (or (some-> (get params "limit") parse-long) 50) 100)
|
||||
unread (= "true" (get params "unread"))
|
||||
|
||||
base-where [:= :user-id [:cast user-id :uuid]]
|
||||
where-clause (cond-> base-where
|
||||
unread
|
||||
(vector :and [:= :read false])
|
||||
|
||||
after
|
||||
(vector :and
|
||||
[:< :created-at
|
||||
{:select [:created-at]
|
||||
:from [:notifications]
|
||||
:where [:= :id [:cast after :uuid]]}]))
|
||||
|
||||
;; Build the full where clause properly
|
||||
final-where (cond
|
||||
(and unread after)
|
||||
[:and
|
||||
base-where
|
||||
[:= :read false]
|
||||
[:< :created-at
|
||||
{:select [:created-at]
|
||||
:from [:notifications]
|
||||
:where [:= :id [:cast after :uuid]]}]]
|
||||
|
||||
unread
|
||||
[:and base-where [:= :read false]]
|
||||
|
||||
after
|
||||
[:and base-where
|
||||
[:< :created-at
|
||||
{:select [:created-at]
|
||||
:from [:notifications]
|
||||
:where [:= :id [:cast after :uuid]]}]]
|
||||
|
||||
:else base-where)
|
||||
|
||||
notifications (db/execute! ds
|
||||
{:select [:*]
|
||||
:from [:notifications]
|
||||
:where final-where
|
||||
:order-by [[:created-at :desc]]
|
||||
:limit limit})]
|
||||
(mw/json-response notifications)))
|
||||
|
||||
(defn mark-read
|
||||
"POST /api/notifications/read
|
||||
Marks specified notifications as read.
|
||||
Body: {\"notification_ids\": [\"uuid\", ...]}"
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
params (:body-params request)
|
||||
ids (:notification_ids params)]
|
||||
|
||||
(when (or (nil? ids) (empty? ids))
|
||||
(throw (ex-info "notification_ids is required"
|
||||
{:type :ajet.chat/validation-error})))
|
||||
|
||||
(let [uuid-ids (mapv (fn [id] [:cast id :uuid]) ids)]
|
||||
(db/execute! ds
|
||||
{:update :notifications
|
||||
:set {:read true}
|
||||
:where [:and
|
||||
[:= :user-id [:cast user-id :uuid]]
|
||||
[:in :id uuid-ids]]}))
|
||||
|
||||
(mw/json-response {:status "ok" :marked-read (count ids)})))
|
||||
|
||||
(defn unread-count
|
||||
"GET /api/notifications/unread-count
|
||||
Returns the count of unread notifications."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
result (db/execute-one! ds
|
||||
{:select [[[:count :*] :count]]
|
||||
:from [:notifications]
|
||||
:where [:and
|
||||
[:= :user-id [:cast user-id :uuid]]
|
||||
[:= :read false]]})]
|
||||
(mw/json-response {:unread-count (:count result 0)})))
|
||||
@@ -0,0 +1,90 @@
|
||||
(ns ajet.chat.api.handlers.presence
|
||||
"Presence and heartbeat handlers.
|
||||
|
||||
Users send heartbeats every 60 seconds. A user is considered online
|
||||
if their last heartbeat was within 2 minutes. Heartbeat responses
|
||||
are immediate; DB update and NATS publishing happen asynchronously."
|
||||
(:require [clojure.tools.logging :as log]
|
||||
[ajet.chat.shared.db :as db]
|
||||
[ajet.chat.shared.eventbus :as eventbus]
|
||||
[ajet.chat.api.middleware :as mw]))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Helpers
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- require-user! [request]
|
||||
(or (:user-id request)
|
||||
(throw (ex-info "Authentication required" {:type :ajet.chat/forbidden}))))
|
||||
|
||||
(defn- check-membership! [ds community-id user-id]
|
||||
(or (db/execute-one! ds
|
||||
{:select [:user-id]
|
||||
:from [:community-members]
|
||||
:where [:and
|
||||
[:= :community-id [:cast community-id :uuid]]
|
||||
[:= :user-id [:cast user-id :uuid]]]})
|
||||
(throw (ex-info "Not a member of this community"
|
||||
{:type :ajet.chat/forbidden}))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Handlers
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn heartbeat
|
||||
"POST /api/heartbeat
|
||||
Reports online status. Responds immediately, updates DB async."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
nats (get-in request [:system :nats])]
|
||||
|
||||
;; Update last_seen_at asynchronously
|
||||
(future
|
||||
(try
|
||||
(db/execute! ds
|
||||
{:update :users
|
||||
:set {:last-seen-at [:now]}
|
||||
:where [:= :id [:cast user-id :uuid]]})
|
||||
|
||||
;; Publish presence event to all communities the user belongs to
|
||||
(let [communities (db/execute! ds
|
||||
{:select [:community-id]
|
||||
:from [:community-members]
|
||||
:where [:= :user-id [:cast user-id :uuid]]})]
|
||||
(doseq [{:keys [community-id]} communities]
|
||||
(try
|
||||
(eventbus/publish! nats
|
||||
(str "chat.presence." community-id)
|
||||
:presence/online
|
||||
{:user-id user-id
|
||||
:community-id (str community-id)})
|
||||
(catch Exception e
|
||||
(log/error e "Failed to publish presence event")))))
|
||||
(catch Exception e
|
||||
(log/error e "Failed to process heartbeat for user" user-id))))
|
||||
|
||||
;; Respond immediately
|
||||
(mw/json-response {:status "ok"})))
|
||||
|
||||
(defn get-presence
|
||||
"GET /api/communities/:cid/presence
|
||||
Returns list of online members (last heartbeat within 2 minutes)."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
community-id (get-in request [:path-params :cid])
|
||||
_ (check-membership! ds community-id user-id)
|
||||
|
||||
;; Online = last_seen_at within 2 minutes
|
||||
online-members (db/execute! ds
|
||||
{:select [:u.id :u.username :u.display-name :u.avatar-url
|
||||
:u.status-text :u.last-seen-at]
|
||||
:from [[:community-members :cm]]
|
||||
:join [[:users :u] [:= :u.id :cm.user-id]]
|
||||
:where [:and
|
||||
[:= :cm.community-id [:cast community-id :uuid]]
|
||||
[:!= :u.last-seen-at nil]
|
||||
[:> :u.last-seen-at
|
||||
[:raw "now() - interval '2 minutes'"]]]})]
|
||||
(mw/json-response online-members)))
|
||||
@@ -0,0 +1,152 @@
|
||||
(ns ajet.chat.api.handlers.reactions
|
||||
"Reaction handlers: add, remove, and list reactions on messages.
|
||||
|
||||
Reactions use a composite PK (message_id, user_id, emoji) so each
|
||||
user can only have one reaction per emoji per message."
|
||||
(:require [clojure.tools.logging :as log]
|
||||
[ajet.chat.shared.db :as db]
|
||||
[ajet.chat.shared.eventbus :as eventbus]
|
||||
[ajet.chat.api.middleware :as mw]))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Helpers
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- require-user! [request]
|
||||
(or (:user-id request)
|
||||
(throw (ex-info "Authentication required" {:type :ajet.chat/forbidden}))))
|
||||
|
||||
(defn- check-channel-member! [ds channel-id user-id]
|
||||
(or (db/execute-one! ds
|
||||
{:select [:user-id]
|
||||
:from [:channel-members]
|
||||
:where [:and
|
||||
[:= :channel-id [:cast channel-id :uuid]]
|
||||
[:= :user-id [:cast user-id :uuid]]]})
|
||||
(throw (ex-info "Not a member of this channel"
|
||||
{:type :ajet.chat/forbidden}))))
|
||||
|
||||
(defn- get-message-row [ds message-id]
|
||||
(or (db/execute-one! ds
|
||||
{:select [:*]
|
||||
:from [:messages]
|
||||
:where [:= :id [:cast message-id :uuid]]})
|
||||
(throw (ex-info "Message not found" {:type :ajet.chat/not-found}))))
|
||||
|
||||
(defn- get-channel-row [ds channel-id]
|
||||
(db/execute-one! ds
|
||||
{:select [:*]
|
||||
:from [:channels]
|
||||
:where [:= :id [:cast channel-id :uuid]]}))
|
||||
|
||||
(defn- publish-event! [nats subject event-type payload]
|
||||
(try
|
||||
(eventbus/publish! nats subject event-type payload)
|
||||
(catch Exception e
|
||||
(log/error e "Failed to publish event" event-type "to" subject))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Handlers
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn add-reaction
|
||||
"PUT /api/messages/:id/reactions/:emoji
|
||||
Adds a reaction. Idempotent (no error if already exists)."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
nats (get-in request [:system :nats])
|
||||
message-id (get-in request [:path-params :id])
|
||||
emoji (get-in request [:path-params :emoji])
|
||||
message (get-message-row ds message-id)
|
||||
channel-id (str (:channel-id message))]
|
||||
|
||||
(check-channel-member! ds channel-id user-id)
|
||||
|
||||
;; Upsert: insert if not exists (ON CONFLICT DO NOTHING)
|
||||
(db/execute! ds
|
||||
{:insert-into :reactions
|
||||
:values [{:message-id [:cast message-id :uuid]
|
||||
:user-id [:cast user-id :uuid]
|
||||
:emoji emoji}]
|
||||
:on-conflict [:message-id :user-id :emoji]
|
||||
:do-nothing true})
|
||||
|
||||
(let [channel (get-channel-row ds channel-id)
|
||||
subject (if (:community-id channel)
|
||||
(str "chat.events." (:community-id channel))
|
||||
(str "chat.dm." channel-id))]
|
||||
(publish-event! nats subject :reaction/added
|
||||
{:message-id message-id
|
||||
:channel-id channel-id
|
||||
:user-id user-id
|
||||
:emoji emoji}))
|
||||
|
||||
(mw/json-response {:status "ok" :emoji emoji})))
|
||||
|
||||
(defn remove-reaction
|
||||
"DELETE /api/messages/:id/reactions/:emoji
|
||||
Removes a reaction by the current user."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
nats (get-in request [:system :nats])
|
||||
message-id (get-in request [:path-params :id])
|
||||
emoji (get-in request [:path-params :emoji])
|
||||
message (get-message-row ds message-id)
|
||||
channel-id (str (:channel-id message))]
|
||||
|
||||
(let [deleted (db/execute! ds
|
||||
{:delete-from :reactions
|
||||
:where [:and
|
||||
[:= :message-id [:cast message-id :uuid]]
|
||||
[:= :user-id [:cast user-id :uuid]]
|
||||
[:= :emoji emoji]]})]
|
||||
;; Check if anything was deleted
|
||||
(when (zero? (:next.jdbc/update-count (first deleted) 0))
|
||||
(throw (ex-info "Reaction not found"
|
||||
{:type :ajet.chat/not-found}))))
|
||||
|
||||
(let [channel (get-channel-row ds channel-id)
|
||||
subject (if (:community-id channel)
|
||||
(str "chat.events." (:community-id channel))
|
||||
(str "chat.dm." channel-id))]
|
||||
(publish-event! nats subject :reaction/removed
|
||||
{:message-id message-id
|
||||
:channel-id channel-id
|
||||
:user-id user-id
|
||||
:emoji emoji}))
|
||||
|
||||
(mw/json-response 204 nil)))
|
||||
|
||||
(defn list-reactions
|
||||
"GET /api/messages/:id/reactions
|
||||
Returns reactions grouped by emoji with user lists."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
message-id (get-in request [:path-params :id])
|
||||
message (get-message-row ds message-id)]
|
||||
|
||||
(check-channel-member! ds (str (:channel-id message)) user-id)
|
||||
|
||||
(let [reactions (db/execute! ds
|
||||
{:select [:r.emoji :r.user-id :u.username :u.display-name]
|
||||
:from [[:reactions :r]]
|
||||
:join [[:users :u] [:= :u.id :r.user-id]]
|
||||
:where [:= :r.message-id [:cast message-id :uuid]]
|
||||
:order-by [[:r.emoji :asc] [:r.created-at :asc]]})
|
||||
;; Group by emoji
|
||||
grouped (reduce (fn [acc reaction]
|
||||
(update acc (:emoji reaction)
|
||||
(fnil conj [])
|
||||
{:user-id (str (:user-id reaction))
|
||||
:username (:username reaction)
|
||||
:display-name (:display-name reaction)}))
|
||||
{} reactions)
|
||||
result (mapv (fn [[emoji users]]
|
||||
{:emoji emoji
|
||||
:count (count users)
|
||||
:users users})
|
||||
grouped)]
|
||||
(mw/json-response result))))
|
||||
@@ -0,0 +1,132 @@
|
||||
(ns ajet.chat.api.handlers.search
|
||||
"Full-text search handler using PostgreSQL tsvector.
|
||||
|
||||
Supports searching messages, channels, and users with various filters:
|
||||
community, channel, author, date range, and cursor-based pagination."
|
||||
(:require [clojure.string :as str]
|
||||
[ajet.chat.shared.db :as db]
|
||||
[ajet.chat.api.middleware :as mw]))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Helpers
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- require-user! [request]
|
||||
(or (:user-id request)
|
||||
(throw (ex-info "Authentication required" {:type :ajet.chat/forbidden}))))
|
||||
|
||||
(defn- search-messages
|
||||
"Search messages using PostgreSQL full-text search with tsvector.
|
||||
Only returns messages in channels the user has access to."
|
||||
[ds user-id query opts]
|
||||
(let [{:keys [community-id channel-id from after before cursor limit]} opts
|
||||
;; Build SQL with tsvector search
|
||||
base-sql (str
|
||||
"SELECT m.id, m.channel_id, m.user_id, m.body_md, m.created_at, "
|
||||
"u.username, u.display_name, c.name as channel_name "
|
||||
"FROM messages m "
|
||||
"JOIN channels c ON c.id = m.channel_id "
|
||||
"JOIN users u ON u.id = m.user_id "
|
||||
"JOIN channel_members cm ON cm.channel_id = m.channel_id AND cm.user_id = ?::uuid "
|
||||
"WHERE to_tsvector('english', m.body_md) @@ plainto_tsquery('english', ?) ")
|
||||
params (atom [user-id query])
|
||||
clauses (atom [])]
|
||||
|
||||
(when community-id
|
||||
(swap! clauses conj "AND c.community_id = ?::uuid")
|
||||
(swap! params conj community-id))
|
||||
|
||||
(when channel-id
|
||||
(swap! clauses conj "AND m.channel_id = ?::uuid")
|
||||
(swap! params conj channel-id))
|
||||
|
||||
(when from
|
||||
(swap! clauses conj "AND m.user_id = ?::uuid")
|
||||
(swap! params conj from))
|
||||
|
||||
(when after
|
||||
(swap! clauses conj "AND m.created_at > ?::timestamptz")
|
||||
(swap! params conj after))
|
||||
|
||||
(when before
|
||||
(swap! clauses conj "AND m.created_at < ?::timestamptz")
|
||||
(swap! params conj before))
|
||||
|
||||
(when cursor
|
||||
(swap! clauses conj "AND m.created_at < (SELECT created_at FROM messages WHERE id = ?::uuid)")
|
||||
(swap! params conj cursor))
|
||||
|
||||
(let [full-sql (str base-sql
|
||||
(str/join " " @clauses)
|
||||
" ORDER BY m.created_at DESC LIMIT ?")
|
||||
_ (swap! params conj limit)]
|
||||
(db/execute-sql! ds (into [full-sql] @params)))))
|
||||
|
||||
(defn- search-channels
|
||||
"Search channels by name substring within user's communities."
|
||||
[ds user-id query opts]
|
||||
(let [{:keys [community-id limit]} opts
|
||||
base-where [:and
|
||||
[:like [:lower :c.name] [:lower (str "%" query "%")]]
|
||||
[:in :c.community-id
|
||||
{:select [:community-id]
|
||||
:from [:community-members]
|
||||
:where [:= :user-id [:cast user-id :uuid]]}]]
|
||||
where-clause (if community-id
|
||||
[:and base-where
|
||||
[:= :c.community-id [:cast community-id :uuid]]]
|
||||
base-where)]
|
||||
(db/execute! ds
|
||||
{:select [:c.*]
|
||||
:from [[:channels :c]]
|
||||
:where where-clause
|
||||
:order-by [[:c.name :asc]]
|
||||
:limit limit})))
|
||||
|
||||
(defn- search-users
|
||||
"Search users by username or display_name substring."
|
||||
[ds _user-id query opts]
|
||||
(let [{:keys [limit]} opts]
|
||||
(db/execute! ds
|
||||
{:select [:id :username :display-name :avatar-url :status-text]
|
||||
:from [:users]
|
||||
:where [:or
|
||||
[:like [:lower :username] [:lower (str "%" query "%")]]
|
||||
[:like [:lower :display-name] [:lower (str "%" query "%")]]]
|
||||
:order-by [[:username :asc]]
|
||||
:limit limit})))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Handler
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn search-handler
|
||||
"GET /api/search?q=term&type=messages|channels|users&community_id=...&channel_id=...&from=...&after=...&before=...&cursor=...&limit=20"
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
params (:query-params request)
|
||||
query (get params "q")
|
||||
type (get params "type")
|
||||
limit (min (or (some-> (get params "limit") parse-long) 20) 100)
|
||||
opts {:community-id (get params "community_id")
|
||||
:channel-id (get params "channel_id")
|
||||
:from (get params "from")
|
||||
:after (get params "after")
|
||||
:before (get params "before")
|
||||
:cursor (get params "cursor")
|
||||
:limit limit}]
|
||||
|
||||
(when (str/blank? query)
|
||||
(throw (ex-info "Search query is required"
|
||||
{:type :ajet.chat/validation-error})))
|
||||
|
||||
(let [result (case type
|
||||
"messages" {:messages (search-messages ds user-id query opts)}
|
||||
"channels" {:channels (search-channels ds user-id query opts)}
|
||||
"users" {:users (search-users ds user-id query opts)}
|
||||
;; Default: search all types
|
||||
{:messages (search-messages ds user-id query opts)
|
||||
:channels (search-channels ds user-id query opts)
|
||||
:users (search-users ds user-id query opts)})]
|
||||
(mw/json-response result))))
|
||||
@@ -0,0 +1,95 @@
|
||||
(ns ajet.chat.api.handlers.upload
|
||||
"File upload handler.
|
||||
|
||||
Accepts multipart form data with an image file. Validates content-type
|
||||
and size, uploads to MinIO/S3, creates an attachment record in the DB,
|
||||
and returns the attachment metadata."
|
||||
(:require [clojure.java.io :as io]
|
||||
[clojure.string :as str]
|
||||
[clojure.tools.logging :as log]
|
||||
[ajet.chat.shared.db :as db]
|
||||
[ajet.chat.shared.storage :as storage]
|
||||
[ajet.chat.api.middleware :as mw])
|
||||
(:import [java.util UUID]))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Helpers
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- uuid [] (str (UUID/randomUUID)))
|
||||
|
||||
(defn- require-user! [request]
|
||||
(or (:user-id request)
|
||||
(throw (ex-info "Authentication required" {:type :ajet.chat/forbidden}))))
|
||||
|
||||
(defn- check-channel-member! [ds channel-id user-id]
|
||||
(or (db/execute-one! ds
|
||||
{:select [:user-id]
|
||||
:from [:channel-members]
|
||||
:where [:and
|
||||
[:= :channel-id [:cast channel-id :uuid]]
|
||||
[:= :user-id [:cast user-id :uuid]]]})
|
||||
(throw (ex-info "Not a member of this channel"
|
||||
{:type :ajet.chat/forbidden}))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Handler
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn upload-file
|
||||
"POST /api/channels/:id/upload
|
||||
Upload an image file. Returns attachment metadata.
|
||||
|
||||
Expects a multipart form with a 'file' part."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
s3 (get-in request [:system :s3])
|
||||
bucket (get-in request [:system :bucket])
|
||||
channel-id (get-in request [:path-params :id])]
|
||||
|
||||
(check-channel-member! ds channel-id user-id)
|
||||
|
||||
;; Extract multipart file from request
|
||||
;; Ring multipart puts files in :multipart-params or :params with :tempfile
|
||||
(let [file-param (or (get-in request [:multipart-params "file"])
|
||||
(get-in request [:params :file]))
|
||||
_ (when-not file-param
|
||||
(throw (ex-info "No file uploaded. Send a multipart form with a 'file' field."
|
||||
{:type :ajet.chat/validation-error})))
|
||||
{:keys [filename content-type tempfile size]} file-param
|
||||
size-bytes (or size (when tempfile (.length tempfile)))]
|
||||
|
||||
;; Validate content type and size
|
||||
(storage/validate-upload! content-type size-bytes)
|
||||
|
||||
;; Generate storage key
|
||||
(let [attachment-id (uuid)
|
||||
key (storage/storage-key attachment-id filename)
|
||||
file-bytes (if tempfile
|
||||
(let [data (byte-array size-bytes)]
|
||||
(with-open [is (io/input-stream tempfile)]
|
||||
(.read is data))
|
||||
data)
|
||||
(throw (ex-info "Uploaded file is missing tempfile"
|
||||
{:type :ajet.chat/validation-error})))]
|
||||
|
||||
;; Upload to S3
|
||||
(storage/upload! s3 bucket key file-bytes content-type)
|
||||
|
||||
;; Create attachment record (not linked to a message yet)
|
||||
(db/execute! ds
|
||||
{:insert-into :attachments
|
||||
:values [{:id [:cast attachment-id :uuid]
|
||||
:message-id [:cast "00000000-0000-0000-0000-000000000000" :uuid] ;; placeholder
|
||||
:filename filename
|
||||
:content-type content-type
|
||||
:size-bytes size-bytes
|
||||
:storage-key key}]})
|
||||
|
||||
(mw/json-response 201
|
||||
{:id attachment-id
|
||||
:filename filename
|
||||
:content-type content-type
|
||||
:size-bytes size-bytes
|
||||
:url (str "/files/" attachment-id "/" filename)})))))
|
||||
@@ -0,0 +1,202 @@
|
||||
(ns ajet.chat.api.handlers.users
|
||||
"User profile and community member management handlers.
|
||||
|
||||
Covers: get/update own profile, view other users, list community members,
|
||||
update member roles/nicknames, and kick members."
|
||||
(:require [clojure.tools.logging :as log]
|
||||
[ajet.chat.shared.db :as db]
|
||||
[ajet.chat.shared.eventbus :as eventbus]
|
||||
[ajet.chat.api.middleware :as mw]))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Helpers
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- require-user! [request]
|
||||
(or (:user-id request)
|
||||
(throw (ex-info "Authentication required" {:type :ajet.chat/forbidden}))))
|
||||
|
||||
(defn- check-membership! [ds community-id user-id]
|
||||
(or (db/execute-one! ds
|
||||
{:select [:*]
|
||||
:from [:community-members]
|
||||
:where [:and
|
||||
[:= :community-id [:cast community-id :uuid]]
|
||||
[:= :user-id [:cast user-id :uuid]]]})
|
||||
(throw (ex-info "Not a member of this community"
|
||||
{:type :ajet.chat/forbidden}))))
|
||||
|
||||
(defn- check-role! [ds community-id user-id required-role]
|
||||
(let [member (check-membership! ds community-id user-id)
|
||||
role (:role member)
|
||||
hierarchy {"owner" 3 "admin" 2 "member" 1}
|
||||
has-level (get hierarchy role 0)
|
||||
need-level (get hierarchy required-role 0)]
|
||||
(when (< has-level need-level)
|
||||
(throw (ex-info (str "Requires " required-role " role or higher")
|
||||
{:type :ajet.chat/forbidden})))
|
||||
member))
|
||||
|
||||
(defn- publish-event! [nats subject event-type payload]
|
||||
(try
|
||||
(eventbus/publish! nats subject event-type payload)
|
||||
(catch Exception e
|
||||
(log/error e "Failed to publish event" event-type "to" subject))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Handlers
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn get-me
|
||||
"GET /api/me
|
||||
Returns the authenticated user's profile."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
user (db/execute-one! ds
|
||||
{:select [:id :username :display-name :email :avatar-url
|
||||
:status-text :created-at]
|
||||
:from [:users]
|
||||
:where [:= :id [:cast user-id :uuid]]})]
|
||||
(if user
|
||||
(mw/json-response user)
|
||||
(mw/error-response 404 "NOT_FOUND" "User not found"))))
|
||||
|
||||
(defn update-me
|
||||
"PUT /api/me
|
||||
Updates display_name and/or status_text."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
params (:body-params request)
|
||||
updates (cond-> {}
|
||||
(contains? params :display_name) (assoc :display-name (:display_name params))
|
||||
(contains? params :status_text) (assoc :status-text (:status_text params)))]
|
||||
(when (seq updates)
|
||||
(db/execute! ds
|
||||
{:update :users
|
||||
:set updates
|
||||
:where [:= :id [:cast user-id :uuid]]}))
|
||||
|
||||
(let [user (db/execute-one! ds
|
||||
{:select [:id :username :display-name :email :avatar-url
|
||||
:status-text :created-at]
|
||||
:from [:users]
|
||||
:where [:= :id [:cast user-id :uuid]]})]
|
||||
(mw/json-response user))))
|
||||
|
||||
(defn get-user
|
||||
"GET /api/users/:id
|
||||
Returns public profile for any user."
|
||||
[request]
|
||||
(let [_ (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
user-id (get-in request [:path-params :id])
|
||||
user (db/execute-one! ds
|
||||
{:select [:id :username :display-name :avatar-url :status-text :created-at]
|
||||
:from [:users]
|
||||
:where [:= :id [:cast user-id :uuid]]})]
|
||||
(if user
|
||||
(mw/json-response user)
|
||||
(mw/error-response 404 "NOT_FOUND" "User not found"))))
|
||||
|
||||
(defn list-community-members
|
||||
"GET /api/communities/:cid/members
|
||||
Lists all members of a community with roles and nicknames."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
community-id (get-in request [:path-params :cid])
|
||||
_ (check-membership! ds community-id user-id)
|
||||
members (db/execute! ds
|
||||
{:select [:u.id :u.username :u.display-name :u.avatar-url
|
||||
:u.status-text :cm.role :cm.nickname :cm.joined-at]
|
||||
:from [[:community-members :cm]]
|
||||
:join [[:users :u] [:= :u.id :cm.user-id]]
|
||||
:where [:= :cm.community-id [:cast community-id :uuid]]
|
||||
:order-by [[:u.username :asc]]})]
|
||||
(mw/json-response members)))
|
||||
|
||||
(defn update-community-member
|
||||
"PUT /api/communities/:cid/members/:uid
|
||||
Update a member's nickname or role. Admin+ for nickname, Owner for role."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
nats (get-in request [:system :nats])
|
||||
community-id (get-in request [:path-params :cid])
|
||||
target-uid (get-in request [:path-params :uid])
|
||||
params (:body-params request)
|
||||
actor-member (check-role! ds community-id user-id "admin")]
|
||||
|
||||
;; Verify target is a member
|
||||
(let [target-member (check-membership! ds community-id target-uid)]
|
||||
|
||||
;; Role changes require owner permission
|
||||
(when (contains? params :role)
|
||||
(when (not= (:role actor-member) "owner")
|
||||
(throw (ex-info "Only the owner can change roles"
|
||||
{:type :ajet.chat/forbidden})))
|
||||
;; Cannot change owner's role
|
||||
(when (= (:role target-member) "owner")
|
||||
(throw (ex-info "Cannot change the owner's role"
|
||||
{:type :ajet.chat/forbidden}))))
|
||||
|
||||
(let [updates (cond-> {}
|
||||
(contains? params :nickname) (assoc :nickname (:nickname params))
|
||||
(contains? params :role) (assoc :role (:role params)))]
|
||||
(when (seq updates)
|
||||
(db/execute! ds
|
||||
{:update :community-members
|
||||
:set updates
|
||||
:where [:and
|
||||
[:= :community-id [:cast community-id :uuid]]
|
||||
[:= :user-id [:cast target-uid :uuid]]]}))
|
||||
|
||||
(let [updated (db/execute-one! ds
|
||||
{:select [:u.id :u.username :u.display-name :cm.role :cm.nickname]
|
||||
:from [[:community-members :cm]]
|
||||
:join [[:users :u] [:= :u.id :cm.user-id]]
|
||||
:where [:and
|
||||
[:= :cm.community-id [:cast community-id :uuid]]
|
||||
[:= :cm.user-id [:cast target-uid :uuid]]]})]
|
||||
(mw/json-response updated))))))
|
||||
|
||||
(defn kick-member
|
||||
"DELETE /api/communities/:cid/members/:uid
|
||||
Removes a member from the community. Admin+. Cannot kick owner."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
nats (get-in request [:system :nats])
|
||||
community-id (get-in request [:path-params :cid])
|
||||
target-uid (get-in request [:path-params :uid])]
|
||||
|
||||
(check-role! ds community-id user-id "admin")
|
||||
|
||||
;; Cannot kick owner
|
||||
(let [target-member (check-membership! ds community-id target-uid)]
|
||||
(when (= (:role target-member) "owner")
|
||||
(throw (ex-info "Cannot kick the community owner"
|
||||
{:type :ajet.chat/forbidden}))))
|
||||
|
||||
;; Remove from all channels in this community
|
||||
(db/execute-sql! ds
|
||||
["DELETE FROM channel_members WHERE user_id = ?::uuid AND channel_id IN (SELECT id FROM channels WHERE community_id = ?::uuid)"
|
||||
target-uid community-id])
|
||||
|
||||
;; Remove community membership
|
||||
(db/execute! ds
|
||||
{:delete-from :community-members
|
||||
:where [:and
|
||||
[:= :community-id [:cast community-id :uuid]]
|
||||
[:= :user-id [:cast target-uid :uuid]]]})
|
||||
|
||||
(publish-event! nats
|
||||
(str "chat.events." community-id)
|
||||
:member/kicked
|
||||
{:community-id community-id
|
||||
:user-id target-uid
|
||||
:kicked-by user-id})
|
||||
|
||||
(mw/json-response 204 nil)))
|
||||
@@ -0,0 +1,219 @@
|
||||
(ns ajet.chat.api.handlers.webhooks
|
||||
"Webhook handlers: create, list, delete, and incoming message posting.
|
||||
|
||||
Webhooks allow external services to post messages to channels.
|
||||
Each webhook has a secret token used for authentication."
|
||||
(:require [clojure.string :as str]
|
||||
[clojure.tools.logging :as log]
|
||||
[ajet.chat.shared.db :as db]
|
||||
[ajet.chat.shared.eventbus :as eventbus]
|
||||
[ajet.chat.api.middleware :as mw])
|
||||
(:import [java.util UUID]
|
||||
[java.security SecureRandom MessageDigest]
|
||||
[java.util Base64]))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Helpers
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- uuid [] (str (UUID/randomUUID)))
|
||||
|
||||
(defn- require-user! [request]
|
||||
(or (:user-id request)
|
||||
(throw (ex-info "Authentication required" {:type :ajet.chat/forbidden}))))
|
||||
|
||||
(defn- check-role! [ds community-id user-id required-role]
|
||||
(let [member (or (db/execute-one! ds
|
||||
{:select [:*]
|
||||
:from [:community-members]
|
||||
:where [:and
|
||||
[:= :community-id [:cast community-id :uuid]]
|
||||
[:= :user-id [:cast user-id :uuid]]]})
|
||||
(throw (ex-info "Not a member of this community"
|
||||
{:type :ajet.chat/forbidden})))
|
||||
role (:role member)
|
||||
hierarchy {"owner" 3 "admin" 2 "member" 1}
|
||||
has-level (get hierarchy role 0)
|
||||
need-level (get hierarchy required-role 0)]
|
||||
(when (< has-level need-level)
|
||||
(throw (ex-info (str "Requires " required-role " role or higher")
|
||||
{:type :ajet.chat/forbidden})))
|
||||
member))
|
||||
|
||||
(defn- generate-token
|
||||
"Generate a random token string."
|
||||
[]
|
||||
(let [bytes (byte-array 32)
|
||||
sr (SecureRandom.)]
|
||||
(.nextBytes sr bytes)
|
||||
(.encodeToString (Base64/getUrlEncoder) bytes)))
|
||||
|
||||
(defn- hash-token
|
||||
"SHA-256 hash a token for storage."
|
||||
[token]
|
||||
(let [md (MessageDigest/getInstance "SHA-256")
|
||||
bytes (.digest md (.getBytes token "UTF-8"))]
|
||||
(.encodeToString (Base64/getUrlEncoder) bytes)))
|
||||
|
||||
(defn- publish-event! [nats subject event-type payload]
|
||||
(try
|
||||
(eventbus/publish! nats subject event-type payload)
|
||||
(catch Exception e
|
||||
(log/error e "Failed to publish event" event-type "to" subject))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Handlers
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn create-webhook
|
||||
"POST /api/communities/:cid/webhooks
|
||||
Creates a webhook. Admin+ only. Returns the token once."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
community-id (get-in request [:path-params :cid])
|
||||
params (:body-params request)
|
||||
name-v (:name params)
|
||||
channel-id (:channel_id params)
|
||||
avatar-url (:avatar_url params)]
|
||||
|
||||
(check-role! ds community-id user-id "admin")
|
||||
|
||||
(when (str/blank? name-v)
|
||||
(throw (ex-info "Webhook name is required"
|
||||
{:type :ajet.chat/validation-error})))
|
||||
|
||||
(when (str/blank? channel-id)
|
||||
(throw (ex-info "channel_id is required"
|
||||
{:type :ajet.chat/validation-error})))
|
||||
|
||||
;; Verify channel exists and belongs to community
|
||||
(let [channel (db/execute-one! ds
|
||||
{:select [:id :community-id]
|
||||
:from [:channels]
|
||||
:where [:and
|
||||
[:= :id [:cast channel-id :uuid]]
|
||||
[:= :community-id [:cast community-id :uuid]]]})]
|
||||
(when-not channel
|
||||
(throw (ex-info "Channel not found in this community"
|
||||
{:type :ajet.chat/not-found}))))
|
||||
|
||||
(let [webhook-id (uuid)
|
||||
token (generate-token)
|
||||
token-h (hash-token token)]
|
||||
|
||||
(db/execute! ds
|
||||
{:insert-into :webhooks
|
||||
:values [(cond-> {:id [:cast webhook-id :uuid]
|
||||
:community-id [:cast community-id :uuid]
|
||||
:channel-id [:cast channel-id :uuid]
|
||||
:name name-v
|
||||
:token-hash token-h
|
||||
:created-by [:cast user-id :uuid]}
|
||||
avatar-url (assoc :avatar-url avatar-url))]})
|
||||
|
||||
(let [webhook (db/execute-one! ds
|
||||
{:select [:id :community-id :channel-id :name :avatar-url :created-at]
|
||||
:from [:webhooks]
|
||||
:where [:= :id [:cast webhook-id :uuid]]})]
|
||||
;; Return the token once (not stored in plaintext)
|
||||
(mw/json-response 201 (assoc webhook :token token))))))
|
||||
|
||||
(defn list-webhooks
|
||||
"GET /api/communities/:cid/webhooks
|
||||
Lists webhooks (without tokens). Admin+ only."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
community-id (get-in request [:path-params :cid])]
|
||||
|
||||
(check-role! ds community-id user-id "admin")
|
||||
|
||||
(let [webhooks (db/execute! ds
|
||||
{:select [:id :community-id :channel-id :name :avatar-url
|
||||
:created-by :created-at]
|
||||
:from [:webhooks]
|
||||
:where [:= :community-id [:cast community-id :uuid]]
|
||||
:order-by [[:created-at :desc]]})]
|
||||
(mw/json-response webhooks))))
|
||||
|
||||
(defn delete-webhook
|
||||
"DELETE /api/webhooks/:id
|
||||
Deletes a webhook. Admin+ of the webhook's community."
|
||||
[request]
|
||||
(let [user-id (require-user! request)
|
||||
ds (get-in request [:system :ds])
|
||||
webhook-id (get-in request [:path-params :id])
|
||||
webhook (or (db/execute-one! ds
|
||||
{:select [:*]
|
||||
:from [:webhooks]
|
||||
:where [:= :id [:cast webhook-id :uuid]]})
|
||||
(throw (ex-info "Webhook not found"
|
||||
{:type :ajet.chat/not-found})))]
|
||||
|
||||
(check-role! ds (str (:community-id webhook)) user-id "admin")
|
||||
|
||||
(db/execute! ds
|
||||
{:delete-from :webhooks
|
||||
:where [:= :id [:cast webhook-id :uuid]]})
|
||||
|
||||
(mw/json-response 204 nil)))
|
||||
|
||||
(defn incoming-webhook
|
||||
"POST /api/webhooks/:id/incoming
|
||||
Post a message via webhook. Authenticates via Bearer token."
|
||||
[request]
|
||||
(let [ds (get-in request [:system :ds])
|
||||
nats (get-in request [:system :nats])
|
||||
webhook-id (get-in request [:path-params :id])
|
||||
params (:body-params request)
|
||||
content (:content params)
|
||||
username (:username params)
|
||||
avatar-url (:avatar_url params)]
|
||||
|
||||
;; Authenticate via Bearer token
|
||||
(let [auth-header (get-in request [:headers "authorization"])
|
||||
token (when (and auth-header (str/starts-with? auth-header "Bearer "))
|
||||
(subs auth-header 7))
|
||||
_ (when-not token
|
||||
(throw (ex-info "Authorization required"
|
||||
{:type :ajet.chat/forbidden})))
|
||||
token-h (hash-token token)
|
||||
webhook (or (db/execute-one! ds
|
||||
{:select [:*]
|
||||
:from [:webhooks]
|
||||
:where [:and
|
||||
[:= :id [:cast webhook-id :uuid]]
|
||||
[:= :token-hash token-h]]})
|
||||
(throw (ex-info "Invalid webhook token"
|
||||
{:type :ajet.chat/forbidden})))]
|
||||
|
||||
(when (str/blank? content)
|
||||
(throw (ex-info "content is required"
|
||||
{:type :ajet.chat/validation-error})))
|
||||
|
||||
;; Create message in the webhook's channel
|
||||
(let [message-id (uuid)
|
||||
channel-id (str (:channel-id webhook))
|
||||
community-id (str (:community-id webhook))
|
||||
body-md (str content)]
|
||||
|
||||
(db/execute! ds
|
||||
{:insert-into :messages
|
||||
:values [{:id [:cast message-id :uuid]
|
||||
:channel-id [:cast channel-id :uuid]
|
||||
;; webhook messages have nil user_id (webhook is the author)
|
||||
:body-md body-md}]})
|
||||
|
||||
(publish-event! nats
|
||||
(str "chat.events." community-id)
|
||||
:message/created
|
||||
{:message-id message-id
|
||||
:channel-id channel-id
|
||||
:community-id community-id
|
||||
:body-md body-md
|
||||
:webhook-id webhook-id
|
||||
:webhook-name (or username (:name webhook))
|
||||
:avatar-url (or avatar-url (:avatar-url webhook))})
|
||||
|
||||
(mw/json-response 204 nil)))))
|
||||
@@ -0,0 +1,177 @@
|
||||
(ns ajet.chat.api.middleware
|
||||
"Ring middleware pipeline for the API service.
|
||||
|
||||
Provides:
|
||||
- Exception handling (catch-all -> 500)
|
||||
- User context extraction from Auth GW headers
|
||||
- Ban check for community-scoped endpoints
|
||||
- Mute check for write endpoints
|
||||
- JSON response helpers"
|
||||
(:require [clojure.data.json :as json]
|
||||
[clojure.string :as str]
|
||||
[clojure.tools.logging :as log]
|
||||
[ajet.chat.shared.db :as db]))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Response Helpers
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn json-response
|
||||
"Build a Ring response with JSON content type."
|
||||
([status body]
|
||||
{:status status
|
||||
:headers {"Content-Type" "application/json"}
|
||||
:body (json/write-str body)})
|
||||
([body]
|
||||
(json-response 200 body)))
|
||||
|
||||
(defn error-response
|
||||
"Build a JSON error response.
|
||||
code: string like \"NOT_FOUND\", \"FORBIDDEN\"
|
||||
message: human-readable string
|
||||
details: optional map of additional context"
|
||||
([status code message]
|
||||
(error-response status code message {}))
|
||||
([status code message details]
|
||||
(json-response status {:error {:code code
|
||||
:message message
|
||||
:details details}})))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Middleware: Exception Handler
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn wrap-exception-handler
|
||||
"Catch-all exception handler. Returns 500 JSON error for unhandled exceptions."
|
||||
[handler]
|
||||
(fn [request]
|
||||
(try
|
||||
(handler request)
|
||||
(catch clojure.lang.ExceptionInfo e
|
||||
(let [data (ex-data e)
|
||||
typ (:type data)]
|
||||
(cond
|
||||
(= typ :ajet.chat/validation-error)
|
||||
(error-response 422 "VALIDATION_ERROR" (ex-message e) (dissoc data :type))
|
||||
|
||||
(= typ :ajet.chat/not-found)
|
||||
(error-response 404 "NOT_FOUND" (ex-message e) (dissoc data :type))
|
||||
|
||||
(= typ :ajet.chat/forbidden)
|
||||
(error-response 403 "FORBIDDEN" (ex-message e) (dissoc data :type))
|
||||
|
||||
(= typ :ajet.chat/conflict)
|
||||
(error-response 409 "CONFLICT" (ex-message e) (dissoc data :type))
|
||||
|
||||
(= typ :ajet.chat/gone)
|
||||
(error-response 410 "GONE" (ex-message e) (dissoc data :type))
|
||||
|
||||
:else
|
||||
(do
|
||||
(log/error e "Unhandled exception" (pr-str data))
|
||||
(error-response 500 "INTERNAL_ERROR" "An internal error occurred")))))
|
||||
(catch Exception e
|
||||
(log/error e "Unhandled exception")
|
||||
(error-response 500 "INTERNAL_ERROR" "An internal error occurred")))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Middleware: User Context
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn wrap-user-context
|
||||
"Extract X-User-Id, X-User-Role, X-Community-Id from request headers
|
||||
into the request map as :user-id, :user-role, :community-id."
|
||||
[handler]
|
||||
(fn [request]
|
||||
(let [headers (:headers request)
|
||||
user-id (get headers "x-user-id")
|
||||
user-role (get headers "x-user-role")
|
||||
community-id (get headers "x-community-id")]
|
||||
(handler (cond-> request
|
||||
user-id (assoc :user-id user-id)
|
||||
user-role (assoc :user-role user-role)
|
||||
community-id (assoc :community-id community-id))))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Middleware: System Injection
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn wrap-system
|
||||
"Inject system components into the request map under :system."
|
||||
[handler system]
|
||||
(fn [request]
|
||||
(handler (assoc request :system system))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Middleware: Ban Check
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn wrap-ban-check
|
||||
"For community-scoped endpoints, check if the user is banned.
|
||||
Rejects with 403 if banned. Requires :user-id and :community-id on request."
|
||||
[handler]
|
||||
(fn [request]
|
||||
(let [user-id (:user-id request)
|
||||
community-id (or (:community-id request)
|
||||
(get-in request [:path-params :cid]))
|
||||
ds (get-in request [:system :ds])]
|
||||
(if (and user-id community-id ds)
|
||||
(let [banned? (db/execute-one! ds
|
||||
{:select [[:1 :exists]]
|
||||
:from [:bans]
|
||||
:where [:and
|
||||
[:= :community-id [:cast community-id :uuid]]
|
||||
[:= :user-id [:cast user-id :uuid]]]})]
|
||||
(if banned?
|
||||
(error-response 403 "BANNED" "You are banned from this community")
|
||||
(handler request)))
|
||||
(handler request)))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Middleware: Mute Check
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn wrap-mute-check
|
||||
"For write endpoints (POST/PUT/DELETE on messages, reactions, etc.),
|
||||
check if the user is muted. Rejects with 403 if actively muted."
|
||||
[handler]
|
||||
(fn [request]
|
||||
(let [user-id (:user-id request)
|
||||
community-id (or (:community-id request)
|
||||
(get-in request [:path-params :cid]))
|
||||
ds (get-in request [:system :ds])]
|
||||
(if (and user-id community-id ds)
|
||||
(let [muted? (db/execute-one! ds
|
||||
{:select [[:1 :exists]]
|
||||
:from [:mutes]
|
||||
:where [:and
|
||||
[:= :community-id [:cast community-id :uuid]]
|
||||
[:= :user-id [:cast user-id :uuid]]
|
||||
[:or
|
||||
[:= :expires-at nil]
|
||||
[:> :expires-at [:now]]]]})]
|
||||
(if muted?
|
||||
(error-response 403 "MUTED" "You are muted in this community")
|
||||
(handler request)))
|
||||
(handler request)))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Middleware: JSON Body Parsing
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn wrap-json-body
|
||||
"Parse JSON request body into :body-params."
|
||||
[handler]
|
||||
(fn [request]
|
||||
(let [content-type (get-in request [:headers "content-type"] "")]
|
||||
(if (and (:body request)
|
||||
(or (.contains content-type "application/json")
|
||||
(.contains content-type "text/json")))
|
||||
(try
|
||||
(let [body-str (slurp (:body request))
|
||||
parsed (when-not (str/blank? body-str)
|
||||
(json/read-str body-str :key-fn keyword))]
|
||||
(handler (assoc request :body-params (or parsed {}))))
|
||||
(catch Exception _
|
||||
(error-response 400 "INVALID_JSON" "Request body is not valid JSON")))
|
||||
(handler (assoc request :body-params {}))))))
|
||||
@@ -0,0 +1,177 @@
|
||||
(ns ajet.chat.api.routes
|
||||
"Reitit router with all API endpoint groups.
|
||||
|
||||
All routes organized by feature domain. Middleware pipeline applied
|
||||
in the order specified by the PRD."
|
||||
(:require [reitit.ring :as ring]
|
||||
[reitit.ring.middleware.parameters :as parameters]
|
||||
[reitit.ring.middleware.muuntaja :as muuntaja]
|
||||
[ajet.chat.api.middleware :as mw]
|
||||
[ajet.chat.shared.logging :as logging]
|
||||
[ajet.chat.api.handlers.communities :as communities]
|
||||
[ajet.chat.api.handlers.channels :as channels]
|
||||
[ajet.chat.api.handlers.categories :as categories]
|
||||
[ajet.chat.api.handlers.messages :as messages]
|
||||
[ajet.chat.api.handlers.reactions :as reactions]
|
||||
[ajet.chat.api.handlers.dms :as dms]
|
||||
[ajet.chat.api.handlers.users :as users]
|
||||
[ajet.chat.api.handlers.notifications :as notifications]
|
||||
[ajet.chat.api.handlers.presence :as presence]
|
||||
[ajet.chat.api.handlers.search :as search]
|
||||
[ajet.chat.api.handlers.invites :as invites]
|
||||
[ajet.chat.api.handlers.webhooks :as webhooks]
|
||||
[ajet.chat.api.handlers.commands :as commands]
|
||||
[ajet.chat.api.handlers.upload :as upload]
|
||||
[ajet.chat.api.handlers.health :as health]
|
||||
[ajet.chat.api.handlers.admin :as admin]))
|
||||
|
||||
(defn router
|
||||
"Build the reitit router with all API routes."
|
||||
[]
|
||||
(ring/router
|
||||
[["/api"
|
||||
|
||||
;; Health check - no auth, no ban/mute check
|
||||
["/health" {:get {:handler health/health-check}}]
|
||||
|
||||
;; Communities
|
||||
["/communities"
|
||||
["" {:get {:handler communities/list-communities}
|
||||
:post {:handler communities/create-community}}]
|
||||
["/:id" {:get {:handler communities/get-community}
|
||||
:put {:handler communities/update-community}
|
||||
:delete {:handler communities/delete-community}}]
|
||||
|
||||
;; Community-scoped channels
|
||||
["/:cid/channels" {:get {:handler channels/list-channels
|
||||
:middleware [mw/wrap-ban-check]}
|
||||
:post {:handler channels/create-channel
|
||||
:middleware [mw/wrap-ban-check mw/wrap-mute-check]}}]
|
||||
|
||||
;; Community-scoped categories
|
||||
["/:cid/categories" {:get {:handler categories/list-categories
|
||||
:middleware [mw/wrap-ban-check]}
|
||||
:post {:handler categories/create-category
|
||||
:middleware [mw/wrap-ban-check mw/wrap-mute-check]}}]
|
||||
|
||||
;; Community members
|
||||
["/:cid/members"
|
||||
["" {:get {:handler users/list-community-members
|
||||
:middleware [mw/wrap-ban-check]}}]
|
||||
["/:uid" {:put {:handler users/update-community-member
|
||||
:middleware [mw/wrap-ban-check]}
|
||||
:delete {:handler users/kick-member
|
||||
:middleware [mw/wrap-ban-check]}}]]
|
||||
|
||||
;; Presence
|
||||
["/:cid/presence" {:get {:handler presence/get-presence
|
||||
:middleware [mw/wrap-ban-check]}}]
|
||||
|
||||
;; Invites
|
||||
["/:cid/invites"
|
||||
["" {:post {:handler invites/create-invite
|
||||
:middleware [mw/wrap-ban-check]}
|
||||
:get {:handler invites/list-invites
|
||||
:middleware [mw/wrap-ban-check]}}]
|
||||
["/direct" {:post {:handler invites/direct-invite
|
||||
:middleware [mw/wrap-ban-check]}}]]
|
||||
|
||||
;; Webhooks
|
||||
["/:cid/webhooks" {:post {:handler webhooks/create-webhook
|
||||
:middleware [mw/wrap-ban-check]}
|
||||
:get {:handler webhooks/list-webhooks
|
||||
:middleware [mw/wrap-ban-check]}}]]
|
||||
|
||||
;; Channels (by channel ID)
|
||||
["/channels/:id"
|
||||
["" {:get {:handler channels/get-channel}
|
||||
:put {:handler channels/update-channel}
|
||||
:delete {:handler channels/delete-channel}}]
|
||||
["/join" {:post {:handler channels/join-channel
|
||||
:middleware [mw/wrap-ban-check]}}]
|
||||
["/leave" {:post {:handler channels/leave-channel}}]
|
||||
["/members" {:get {:handler channels/list-channel-members}}]
|
||||
["/messages" {:get {:handler messages/list-messages}
|
||||
:post {:handler messages/send-message
|
||||
:middleware [mw/wrap-mute-check]}}]
|
||||
["/upload" {:post {:handler upload/upload-file}}]
|
||||
["/read" {:post {:handler messages/mark-read}}]]
|
||||
|
||||
;; Categories (by category ID)
|
||||
["/categories/:id" {:put {:handler categories/update-category}
|
||||
:delete {:handler categories/delete-category}}]
|
||||
|
||||
;; Messages (by message ID)
|
||||
["/messages/:id"
|
||||
["" {:get {:handler messages/get-message}
|
||||
:put {:handler messages/edit-message
|
||||
:middleware [mw/wrap-mute-check]}
|
||||
:delete {:handler messages/delete-message}}]
|
||||
["/thread" {:get {:handler messages/get-thread}}]
|
||||
["/reactions"
|
||||
["" {:get {:handler reactions/list-reactions}}]
|
||||
["/:emoji" {:put {:handler reactions/add-reaction
|
||||
:middleware [mw/wrap-mute-check]}
|
||||
:delete {:handler reactions/remove-reaction}}]]]
|
||||
|
||||
;; DMs
|
||||
["/dms"
|
||||
["" {:get {:handler dms/list-dms}
|
||||
:post {:handler dms/create-dm}}]
|
||||
["/group" {:post {:handler dms/create-group-dm}}]]
|
||||
|
||||
;; Users & Profile
|
||||
["/me" {:get {:handler users/get-me}
|
||||
:put {:handler users/update-me}}]
|
||||
["/users/:id" {:get {:handler users/get-user}}]
|
||||
|
||||
;; Notifications
|
||||
["/notifications"
|
||||
["" {:get {:handler notifications/list-notifications}}]
|
||||
["/read" {:post {:handler notifications/mark-read}}]
|
||||
["/unread-count" {:get {:handler notifications/unread-count}}]]
|
||||
|
||||
;; Presence & Heartbeat
|
||||
["/heartbeat" {:post {:handler presence/heartbeat}}]
|
||||
|
||||
;; Search
|
||||
["/search" {:get {:handler search/search-handler}}]
|
||||
|
||||
;; Invites (by invite ID / code)
|
||||
["/invites/:id" {:delete {:handler invites/revoke-invite}}]
|
||||
["/invites/:code/accept" {:post {:handler invites/accept-invite}}]
|
||||
|
||||
;; Webhooks (by webhook ID)
|
||||
["/webhooks/:id"
|
||||
["" {:delete {:handler webhooks/delete-webhook}}]
|
||||
["/incoming" {:post {:handler webhooks/incoming-webhook}}]]
|
||||
|
||||
;; Slash commands
|
||||
["/commands" {:post {:handler commands/execute-command}}]
|
||||
|
||||
;; Admin — OAuth provider management
|
||||
["/admin/oauth-providers"
|
||||
["" {:get {:handler admin/list-providers}
|
||||
:post {:handler admin/create-provider}}]
|
||||
["/:id" {:put {:handler admin/update-provider}
|
||||
:delete {:handler admin/delete-provider}}]]]]
|
||||
|
||||
{:data {:middleware [parameters/parameters-middleware]}}))
|
||||
|
||||
(defn app
|
||||
"Build the full Ring handler with middleware stack."
|
||||
[system]
|
||||
(let [handler (ring/ring-handler
|
||||
(router)
|
||||
;; Default handler for unmatched routes
|
||||
(ring/create-default-handler
|
||||
{:not-found
|
||||
(constantly (mw/error-response 404 "NOT_FOUND" "Route not found"))
|
||||
:method-not-allowed
|
||||
(constantly (mw/error-response 405 "METHOD_NOT_ALLOWED" "Method not allowed"))}))]
|
||||
(-> handler
|
||||
mw/wrap-user-context
|
||||
mw/wrap-json-body
|
||||
(mw/wrap-system system)
|
||||
logging/wrap-request-logging
|
||||
mw/wrap-exception-handler)))
|
||||
@@ -0,0 +1,12 @@
|
||||
FROM clojure:temurin-21-tools-deps AS builder
|
||||
WORKDIR /app
|
||||
COPY deps.edn build.clj ./
|
||||
COPY shared/ shared/
|
||||
COPY auth-gw/ auth-gw/
|
||||
RUN clj -T:build uber :module auth-gw
|
||||
|
||||
FROM eclipse-temurin:21-jre-alpine
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/auth-gw/target/auth-gw.jar app.jar
|
||||
EXPOSE 3000
|
||||
CMD ["java", "-jar", "app.jar"]
|
||||
@@ -0,0 +1,8 @@
|
||||
{:tasks
|
||||
{test
|
||||
{:doc "Run all auth-gw module tests"
|
||||
:task (shell {:dir ".."} "bb test:auth-gw")}
|
||||
|
||||
test:integration
|
||||
{:doc "Run auth-gw integration tests"
|
||||
:task (shell {:dir ".."} "bb test:auth-gw:integration")}}}
|
||||
+4
-1
@@ -3,7 +3,10 @@
|
||||
http-kit/http-kit {:mvn/version "2.8.0"}
|
||||
metosin/reitit {:mvn/version "0.7.2"}
|
||||
ring/ring-core {:mvn/version "1.13.0"}
|
||||
ajet/chat-shared {:local/root "../shared"}}
|
||||
ajet/chat-shared {:local/root "../shared"}
|
||||
hiccup/hiccup {:mvn/version "2.0.0-RC4"}
|
||||
at.favre.lib/bcrypt {:mvn/version "0.10.2"}
|
||||
ring/ring-codec {:mvn/version "1.2.0"}}
|
||||
:aliases
|
||||
{:run {:main-opts ["-m" "ajet.chat.auth-gw.core"]}
|
||||
:dev {:extra-paths ["dev"]
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
{:server {:host "0.0.0.0" :port 3000}
|
||||
:db {:host "localhost" :port 5432 :dbname "ajet_chat"
|
||||
:user "ajet" :password "ajet_dev" :pool-size 5}
|
||||
:oauth {:github {:client-id "" :client-secret "" :enabled false}
|
||||
:gitea {:client-id "" :client-secret "" :base-url "" :enabled false}
|
||||
:oidc {:client-id "" :client-secret "" :issuer-url "" :enabled false}}
|
||||
:services {:api {:host "localhost" :port 3001}
|
||||
:web-sm {:host "localhost" :port 3002}
|
||||
:tui-sm {:host "localhost" :port 3003}}
|
||||
:session {:ttl-days 30
|
||||
:cookie-name "ajet_session"
|
||||
:cookie-secure false}
|
||||
:rate-limit {:enabled true}
|
||||
:cors {:allowed-origins ["http://localhost:3000" "http://localhost:3002"]
|
||||
:allowed-methods [:get :post :put :delete :options]
|
||||
:allowed-headers ["Content-Type" "Authorization" "X-Trace-Id"]
|
||||
:max-age 86400}
|
||||
|
||||
:profiles
|
||||
{:test {:db {:host "localhost" :port 5433 :dbname "ajet_chat_test"
|
||||
:password "ajet_test"}}
|
||||
:prod {:session {:cookie-secure true}
|
||||
:cors {:allowed-origins ["https://chat.example.com"]}}}}
|
||||
@@ -0,0 +1,437 @@
|
||||
(ns ajet.chat.auth-gw.auth
|
||||
"Authentication — session and token validation, creation, and destruction.
|
||||
|
||||
Token format: 32 random bytes -> base64url encoded (43 chars).
|
||||
Stored as bcrypt hash in the database.
|
||||
Sessions use rolling expiry (default 30 days), extended on each valid request."
|
||||
(:require [clojure.string :as str]
|
||||
[clojure.tools.logging :as log]
|
||||
[ajet.chat.shared.db :as db])
|
||||
(:import [at.favre.lib.crypto.bcrypt BCrypt]
|
||||
[java.security SecureRandom]
|
||||
[java.util Base64]
|
||||
[java.time Instant Duration]
|
||||
[java.sql Timestamp]))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Token generation
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(def ^:private secure-random (SecureRandom.))
|
||||
(def ^:private token-byte-length 32)
|
||||
(def ^:private bcrypt-cost 12)
|
||||
|
||||
(defn- generate-token-bytes
|
||||
"Generate cryptographically random bytes."
|
||||
^bytes []
|
||||
(let [buf (byte-array token-byte-length)]
|
||||
(.nextBytes secure-random buf)
|
||||
buf))
|
||||
|
||||
(defn- base64url-encode
|
||||
"Encode bytes to base64url string (no padding)."
|
||||
^String [^bytes data]
|
||||
(.encodeToString (.withoutPadding (Base64/getUrlEncoder)) data))
|
||||
|
||||
(defn- base64url-decode
|
||||
"Decode a base64url string to bytes."
|
||||
^bytes [^String s]
|
||||
(.decode (Base64/getUrlDecoder) s))
|
||||
|
||||
(defn- bcrypt-hash-bytes
|
||||
"Hash raw token chars with bcrypt, returning the hash as a string."
|
||||
^String [^chars token-chars]
|
||||
(.hashToString (BCrypt/withDefaults) bcrypt-cost token-chars))
|
||||
|
||||
(defn- bcrypt-verify
|
||||
"Verify a raw token string against a bcrypt hash. Returns true if match."
|
||||
[^String token ^String hash]
|
||||
(let [result (.verify (BCrypt/verifyer) (.getBytes token "UTF-8") (.getBytes hash "UTF-8"))]
|
||||
(.verified result)))
|
||||
|
||||
(defn- generate-raw-token
|
||||
"Generate a random token. Returns the raw base64url-encoded string."
|
||||
[]
|
||||
(base64url-encode (generate-token-bytes)))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Timestamp helpers
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- now-instant []
|
||||
(Instant/now))
|
||||
|
||||
(defn- instant->timestamp [^Instant inst]
|
||||
(Timestamp/from inst))
|
||||
|
||||
(defn- session-expiry
|
||||
"Calculate session expiry from now + ttl-days."
|
||||
[ttl-days]
|
||||
(instant->timestamp (.plus (now-instant) (Duration/ofDays ttl-days))))
|
||||
|
||||
(defn- expired?
|
||||
"Check if a timestamp is in the past."
|
||||
[^Timestamp ts]
|
||||
(.before ts (instant->timestamp (now-instant))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Session validation
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn validate-session
|
||||
"Extract and validate the session cookie.
|
||||
|
||||
Looks up the session by token hash in the sessions table, verifies the
|
||||
bcrypt hash matches, and checks expiry.
|
||||
|
||||
Returns a map with user info on success:
|
||||
{:user-id ... :user-role ... :session-id ...}
|
||||
Returns nil on failure (invalid/expired/missing)."
|
||||
[ds cookie-value]
|
||||
(when (and cookie-value (not (empty? cookie-value)))
|
||||
(try
|
||||
;; Look up all non-expired sessions and verify against each
|
||||
;; In practice, we rely on bcrypt verification being the gate
|
||||
(let [sessions (db/execute! ds
|
||||
{:select [:s.id :s.user-id :s.token-hash :s.expires-at
|
||||
:u.username :u.display-name :u.email]
|
||||
:from [[:sessions :s]]
|
||||
:join [[:users :u] [:= :s.user-id :u.id]]
|
||||
:where [:> :s.expires-at (instant->timestamp (now-instant))]})]
|
||||
;; Find the session whose hash matches the provided token
|
||||
(some (fn [session]
|
||||
(when (bcrypt-verify cookie-value (:token-hash session))
|
||||
{:session-id (:id session)
|
||||
:user-id (str (:user-id session))
|
||||
:username (:username session)
|
||||
:display-name (:display-name session)
|
||||
:email (:email session)}))
|
||||
sessions))
|
||||
(catch Exception e
|
||||
(log/error e "Error validating session")
|
||||
nil))))
|
||||
|
||||
(defn validate-api-token
|
||||
"Extract and validate a Bearer token from the Authorization header.
|
||||
|
||||
Looks up the token in the api_tokens table, verifies bcrypt hash,
|
||||
checks expiry.
|
||||
|
||||
Returns a map on success:
|
||||
{:api-user-id ... :user-id ... :scopes [...]}
|
||||
Returns nil on failure."
|
||||
[ds authorization-header]
|
||||
(when authorization-header
|
||||
(let [parts (str/split authorization-header #"\s+" 2)]
|
||||
(when (and (= "Bearer" (first parts)) (second parts))
|
||||
(let [raw-token (second parts)]
|
||||
(try
|
||||
(let [tokens (db/execute! ds
|
||||
{:select [:at.id :at.api-user-id :at.token-hash
|
||||
:at.expires-at :at.scopes
|
||||
:au.user-id :au.community-id]
|
||||
:from [[:api_tokens :at]]
|
||||
:join [[:api_users :au] [:= :at.api-user-id :au.id]]
|
||||
:where [:or
|
||||
[:= :at.expires-at nil]
|
||||
[:> :at.expires-at (instant->timestamp (now-instant))]]})]
|
||||
(some (fn [token-row]
|
||||
(when (bcrypt-verify raw-token (:token-hash token-row))
|
||||
{:api-user-id (str (:api-user-id token-row))
|
||||
:user-id (str (:user-id token-row))
|
||||
:community-id (str (:community-id token-row))
|
||||
:scopes (:scopes token-row)}))
|
||||
tokens))
|
||||
(catch Exception e
|
||||
(log/error e "Error validating API token")
|
||||
nil)))))))
|
||||
|
||||
(defn validate-webhook-token
|
||||
"Validate a Bearer token for webhook incoming requests.
|
||||
|
||||
Looks up the token in the webhooks table and verifies bcrypt hash.
|
||||
|
||||
Returns a map on success:
|
||||
{:webhook-id ... :community-id ... :channel-id ...}
|
||||
Returns nil on failure."
|
||||
[ds authorization-header]
|
||||
(when authorization-header
|
||||
(let [parts (str/split authorization-header #"\s+" 2)]
|
||||
(when (and (= "Bearer" (first parts)) (second parts))
|
||||
(let [raw-token (second parts)]
|
||||
(try
|
||||
(let [webhooks (db/execute! ds
|
||||
{:select [:id :community-id :channel-id
|
||||
:token-hash :name]
|
||||
:from [:webhooks]})]
|
||||
(some (fn [wh]
|
||||
(when (bcrypt-verify raw-token (:token-hash wh))
|
||||
{:webhook-id (str (:id wh))
|
||||
:community-id (str (:community-id wh))
|
||||
:channel-id (str (:channel-id wh))
|
||||
:webhook-name (:name wh)}))
|
||||
webhooks))
|
||||
(catch Exception e
|
||||
(log/error e "Error validating webhook token")
|
||||
nil)))))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Session TTL extension
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn extend-session-ttl!
|
||||
"Asynchronously extend the session's expires_at. Fire-and-forget via future."
|
||||
[ds session-id ttl-days]
|
||||
(future
|
||||
(try
|
||||
(db/execute! ds
|
||||
{:update :sessions
|
||||
:set {:expires-at (session-expiry ttl-days)}
|
||||
:where [:= :id session-id]})
|
||||
(catch Exception e
|
||||
(log/warn e "Failed to extend session TTL for" session-id)))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Session creation
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn create-session!
|
||||
"Create a new session for a user.
|
||||
|
||||
Generates a random token, bcrypt hashes it, inserts into sessions table.
|
||||
Returns the raw token (to be set as cookie value)."
|
||||
[ds user-id ttl-days]
|
||||
(let [raw-token (generate-raw-token)
|
||||
token-hash (bcrypt-hash-bytes (.toCharArray raw-token))
|
||||
session-id (java.util.UUID/randomUUID)
|
||||
now-ts (instant->timestamp (now-instant))
|
||||
expires (session-expiry ttl-days)]
|
||||
(db/execute! ds
|
||||
{:insert-into :sessions
|
||||
:values [{:id session-id
|
||||
:user-id (if (instance? java.util.UUID user-id)
|
||||
user-id
|
||||
(java.util.UUID/fromString (str user-id)))
|
||||
:token-hash token-hash
|
||||
:expires-at expires
|
||||
:created-at now-ts}]})
|
||||
(log/info "Created session" session-id "for user" user-id)
|
||||
raw-token))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Session destruction
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn destroy-session!
|
||||
"Destroy a session by deleting from the database.
|
||||
|
||||
Returns a Ring response map fragment with cleared cookie."
|
||||
[ds cookie-value cookie-name cookie-secure?]
|
||||
(when (and cookie-value (not (empty? cookie-value)))
|
||||
;; Find and delete matching session
|
||||
(try
|
||||
(let [sessions (db/execute! ds
|
||||
{:select [:id :token-hash]
|
||||
:from [:sessions]})]
|
||||
(doseq [session sessions]
|
||||
(when (bcrypt-verify cookie-value (:token-hash session))
|
||||
(db/execute! ds
|
||||
{:delete-from :sessions
|
||||
:where [:= :id (:id session)]})
|
||||
(log/info "Destroyed session" (:id session)))))
|
||||
(catch Exception e
|
||||
(log/warn e "Error destroying session"))))
|
||||
;; Return cleared cookie header
|
||||
{:cookies {cookie-name {:value ""
|
||||
:path "/"
|
||||
:max-age 0
|
||||
:http-only true
|
||||
:secure cookie-secure?
|
||||
:same-site :lax}}})
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; User lookup / creation for OAuth
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn find-user-by-oauth
|
||||
"Look up a user by OAuth provider and provider user ID.
|
||||
|
||||
Returns the user map or nil."
|
||||
[ds provider provider-user-id]
|
||||
(db/execute-one! ds
|
||||
{:select [:u.id :u.username :u.display-name :u.email :u.avatar-url :u.created-at]
|
||||
:from [[:users :u]]
|
||||
:join [[:oauth_accounts :oa] [:= :oa.user-id :u.id]]
|
||||
:where [:and
|
||||
[:= :oa.provider (name provider)]
|
||||
[:= :oa.provider-user-id (str provider-user-id)]]}))
|
||||
|
||||
(defn create-user-from-oauth!
|
||||
"Create a new user and linked OAuth account from provider profile data.
|
||||
|
||||
profile: {:username, :display-name, :email, :avatar-url,
|
||||
:provider, :provider-user-id, :provider-username}
|
||||
|
||||
Returns the created user map."
|
||||
[ds profile]
|
||||
(let [user-id (java.util.UUID/randomUUID)
|
||||
oauth-id (java.util.UUID/randomUUID)
|
||||
now-ts (instant->timestamp (now-instant))
|
||||
;; Ensure unique username — append random suffix if collision
|
||||
base-username (:username profile)
|
||||
user (db/execute-one! ds
|
||||
{:insert-into :users
|
||||
:values [{:id user-id
|
||||
:username base-username
|
||||
:display-name (or (:display-name profile) base-username)
|
||||
:email (or (:email profile) "")
|
||||
:avatar-url (:avatar-url profile)
|
||||
:created-at now-ts}]
|
||||
:returning [:*]})]
|
||||
;; Create OAuth account link
|
||||
(db/execute! ds
|
||||
{:insert-into :oauth_accounts
|
||||
:values [{:id oauth-id
|
||||
:user-id user-id
|
||||
:provider (name (:provider profile))
|
||||
:provider-user-id (str (:provider-user-id profile))
|
||||
:provider-username (:provider-username profile)
|
||||
:created-at now-ts}]})
|
||||
(log/info "Created user" user-id "via OAuth" (name (:provider profile))
|
||||
"provider-user-id" (:provider-user-id profile))
|
||||
user))
|
||||
|
||||
(defn count-users
|
||||
"Return the total number of users in the database."
|
||||
[ds]
|
||||
(let [result (db/execute-one! ds
|
||||
{:select [[[:count :*] :cnt]]
|
||||
:from [:users]})]
|
||||
(or (:cnt result) 0)))
|
||||
|
||||
(defn find-user-by-username
|
||||
"Look up a user by username. Returns the user map or nil."
|
||||
[ds username]
|
||||
(db/execute-one! ds
|
||||
{:select [:*]
|
||||
:from [:users]
|
||||
:where [:= :username username]}))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; OAuth Provider queries (DB-stored providers)
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn list-oauth-providers
|
||||
"Return all enabled OAuth providers from the database, ordered by sort_order."
|
||||
[ds]
|
||||
(db/execute! ds
|
||||
{:select [:*]
|
||||
:from [:oauth-providers]
|
||||
:where [:= :enabled true]
|
||||
:order-by [[:sort-order :asc] [:created-at :asc]]}))
|
||||
|
||||
(defn list-all-oauth-providers
|
||||
"Return all OAuth providers from the database (including disabled)."
|
||||
[ds]
|
||||
(db/execute! ds
|
||||
{:select [:*]
|
||||
:from [:oauth-providers]
|
||||
:order-by [[:sort-order :asc] [:created-at :asc]]}))
|
||||
|
||||
(defn count-oauth-providers
|
||||
"Count enabled OAuth providers."
|
||||
[ds]
|
||||
(let [result (db/execute-one! ds
|
||||
{:select [[[:count :*] :cnt]]
|
||||
:from [:oauth-providers]
|
||||
:where [:= :enabled true]})]
|
||||
(or (:cnt result) 0)))
|
||||
|
||||
(defn find-oauth-provider-by-slug
|
||||
"Look up an OAuth provider by slug. Returns the provider row or nil."
|
||||
[ds slug]
|
||||
(db/execute-one! ds
|
||||
{:select [:*]
|
||||
:from [:oauth-providers]
|
||||
:where [:and [:= :slug slug] [:= :enabled true]]}))
|
||||
|
||||
(defn insert-oauth-provider!
|
||||
"Insert a new OAuth provider. Returns the created row."
|
||||
[ds provider-map]
|
||||
(db/execute-one! ds
|
||||
{:insert-into :oauth-providers
|
||||
:values [provider-map]
|
||||
:returning [:*]}))
|
||||
|
||||
(defn delete-oauth-provider!
|
||||
"Delete an OAuth provider by ID."
|
||||
[ds id]
|
||||
(db/execute! ds
|
||||
{:delete-from :oauth-providers
|
||||
:where [:= :id id]}))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; System settings
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn get-system-setting
|
||||
"Read a system setting value by key. Returns the string value or nil."
|
||||
[ds key]
|
||||
(:value (db/execute-one! ds
|
||||
{:select [:value]
|
||||
:from [:system-settings]
|
||||
:where [:= :key key]})))
|
||||
|
||||
(defn set-system-setting!
|
||||
"Upsert a system setting."
|
||||
[ds key value]
|
||||
(db/execute! ds
|
||||
{:insert-into :system-settings
|
||||
:values [{:key key :value value :updated-at (instant->timestamp (now-instant))}]
|
||||
:on-conflict [:key]
|
||||
:do-update-set {:value value :updated-at (instant->timestamp (now-instant))}}))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Invite helpers
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn find-invite-by-code
|
||||
"Look up an invite by code. Returns the invite map or nil."
|
||||
[ds code]
|
||||
(db/execute-one! ds
|
||||
{:select [:i.id :i.community-id :i.code :i.max-uses :i.uses
|
||||
:i.expires-at :i.created-at
|
||||
:c.name :c.slug]
|
||||
:from [[:invites :i]]
|
||||
:join [[:communities :c] [:= :i.community-id :c.id]]
|
||||
:where [:= :i.code code]}))
|
||||
|
||||
(defn invite-valid?
|
||||
"Check if an invite is still valid (not expired, not exhausted)."
|
||||
[invite]
|
||||
(and invite
|
||||
;; Not expired
|
||||
(or (nil? (:expires-at invite))
|
||||
(not (expired? (:expires-at invite))))
|
||||
;; Not exhausted
|
||||
(or (nil? (:max-uses invite))
|
||||
(< (:uses invite) (:max-uses invite)))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Cookie helpers
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn session-cookie
|
||||
"Build a session cookie map for Ring."
|
||||
[cookie-name raw-token ttl-days secure?]
|
||||
{cookie-name {:value raw-token
|
||||
:path "/"
|
||||
:max-age (* ttl-days 24 60 60)
|
||||
:http-only true
|
||||
:secure secure?
|
||||
:same-site :lax}})
|
||||
|
||||
(defn extract-session-cookie
|
||||
"Extract the session token from Ring request cookies."
|
||||
[request cookie-name]
|
||||
(get-in request [:cookies cookie-name :value]))
|
||||
@@ -1,5 +1,166 @@
|
||||
(ns ajet.chat.auth-gw.core
|
||||
"Auth gateway — http-kit reverse proxy with authn/authz.")
|
||||
"Auth Gateway — http-kit reverse proxy with authentication.
|
||||
|
||||
Single edge entry point for all client traffic. Terminates sessions,
|
||||
validates tokens, and proxies authenticated requests to internal
|
||||
services (API, Web SM, TUI SM).
|
||||
|
||||
System state held in a single atom for REPL-driven development."
|
||||
(:refer-clojure :exclude [reset!])
|
||||
(:require [clojure.tools.logging :as log]
|
||||
[org.httpkit.server :as http-kit]
|
||||
[ajet.chat.shared.config :as config]
|
||||
[ajet.chat.shared.db :as db]
|
||||
[ajet.chat.auth-gw.auth :as auth]
|
||||
[ajet.chat.auth-gw.routes :as routes]
|
||||
[ajet.chat.auth-gw.rate-limit :as rate-limit]
|
||||
[ajet.chat.auth-gw.setup :as setup])
|
||||
(:gen-class))
|
||||
|
||||
(defonce system (atom nil))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Provider migration (env config → DB)
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- migrate-config-providers-to-db!
|
||||
"On first startup, if the DB has no OAuth providers but the config file
|
||||
has enabled providers, auto-migrate them to the DB for backward compat."
|
||||
[ds oauth-config]
|
||||
(when (zero? (auth/count-oauth-providers ds))
|
||||
(doseq [[provider-kw pcfg] oauth-config
|
||||
:when (:enabled pcfg)]
|
||||
(let [ptype (name provider-kw)]
|
||||
(log/info "Auto-migrating OAuth provider from config to DB:" ptype)
|
||||
(auth/insert-oauth-provider! ds
|
||||
(cond-> {:provider-type ptype
|
||||
:display-name (case provider-kw
|
||||
:github "GitHub"
|
||||
:gitea "Gitea"
|
||||
:oidc "SSO"
|
||||
ptype)
|
||||
:slug ptype
|
||||
:client-id (:client-id pcfg)
|
||||
:client-secret (:client-secret pcfg)}
|
||||
(:base-url pcfg) (assoc :base-url (:base-url pcfg))
|
||||
(:issuer-url pcfg) (assoc :issuer-url (:issuer-url pcfg))))))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Lifecycle
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn start!
|
||||
"Start the Auth Gateway service.
|
||||
|
||||
1. Load config (EDN + env vars)
|
||||
2. Create DB connection pool (HikariCP)
|
||||
3. Run migrations
|
||||
4. Initialize OAuth providers atom (from DB)
|
||||
5. Initialize setup-complete? atom
|
||||
6. Initialize rate limiter (in-memory atom)
|
||||
7. Start http-kit server with reitit router
|
||||
8. Log startup"
|
||||
[& [{:keys [config-overrides]}]]
|
||||
(when @system
|
||||
(log/warn "System already started — call (stop!) first")
|
||||
(throw (ex-info "System already running" {})))
|
||||
(let [config (config/load-config {:resource "auth-gw-config.edn"})
|
||||
config (if config-overrides
|
||||
(merge config config-overrides)
|
||||
config)
|
||||
_ (log/info "Loaded config:" (config/redact config))
|
||||
|
||||
;; Database — Auth GW has direct PG access for sessions/tokens
|
||||
ds (db/make-datasource (:db config))
|
||||
_ (log/info "Database connection pool created")
|
||||
_ (when (get-in config [:db :migrations :enabled] true)
|
||||
(db/migrate! ds (get-in config [:db :migrations])))
|
||||
|
||||
;; Auto-migrate OAuth providers from config to DB (backward compat)
|
||||
_ (migrate-config-providers-to-db! ds (:oauth config))
|
||||
|
||||
;; OAuth providers — loaded from DB, cached in atom
|
||||
oauth-provs (atom (auth/list-oauth-providers ds))
|
||||
_ (log/info "Loaded" (count @oauth-provs) "OAuth providers from DB")
|
||||
|
||||
;; Setup completion status — cached in atom
|
||||
setup-atom (atom nil)
|
||||
|
||||
;; Rate limiter
|
||||
limiter (rate-limit/make-limiter)
|
||||
cleanup-fn (rate-limit/start-cleanup-task! limiter)
|
||||
_ (log/info "Rate limiter initialized")
|
||||
|
||||
;; System map
|
||||
sys {:config config
|
||||
:ds ds
|
||||
:limiter limiter
|
||||
:oauth-providers-atom oauth-provs
|
||||
:setup-complete-atom setup-atom}
|
||||
|
||||
;; HTTP server
|
||||
handler (routes/app sys)
|
||||
port (get-in config [:server :port] 3000)
|
||||
host (get-in config [:server :host] "0.0.0.0")
|
||||
server (http-kit/run-server handler
|
||||
{:port port
|
||||
:ip host
|
||||
:max-body (* 12 1024 1024)
|
||||
;; Don't buffer SSE responses
|
||||
:server-header "ajet-auth-gw"})]
|
||||
(clojure.core/reset! system (assoc sys
|
||||
:server server
|
||||
:port port
|
||||
:cleanup-fn cleanup-fn))
|
||||
(log/info (str "Auth Gateway started on " host ":" port))
|
||||
@system))
|
||||
|
||||
(defn stop!
|
||||
"Stop the Auth Gateway. Shuts down HTTP server, DB pool in order."
|
||||
[]
|
||||
(when-let [sys @system]
|
||||
(log/info "Shutting down Auth Gateway...")
|
||||
|
||||
;; Stop HTTP server (wait up to 30s for in-flight requests)
|
||||
(when-let [server (:server sys)]
|
||||
(server :timeout 30000)
|
||||
(log/info "HTTP server stopped"))
|
||||
|
||||
;; Cancel rate limiter cleanup task
|
||||
(when-let [cleanup-fn (:cleanup-fn sys)]
|
||||
(future-cancel cleanup-fn)
|
||||
(log/info "Rate limiter cleanup task stopped"))
|
||||
|
||||
;; Close DB pool
|
||||
(when-let [ds (:ds sys)]
|
||||
(try
|
||||
(db/close-datasource ds)
|
||||
(log/info "Database connection pool closed")
|
||||
(catch Exception e
|
||||
(log/error e "Error closing database pool"))))
|
||||
|
||||
(clojure.core/reset! system nil)
|
||||
(log/info "Auth Gateway stopped")))
|
||||
|
||||
(defn reset!
|
||||
"Stop then start the system (REPL convenience)."
|
||||
[]
|
||||
(stop!)
|
||||
(start!))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Entry point
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn -main [& _args]
|
||||
(println "ajet-chat auth gateway starting..."))
|
||||
(start!)
|
||||
|
||||
;; Graceful shutdown hook
|
||||
(.addShutdownHook
|
||||
(Runtime/getRuntime)
|
||||
(Thread. ^Runnable (fn []
|
||||
(log/info "Shutdown hook triggered")
|
||||
(stop!))))
|
||||
|
||||
;; Block main thread
|
||||
@(promise))
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
(ns ajet.chat.auth-gw.middleware
|
||||
"Ring middleware for the Auth Gateway.
|
||||
|
||||
Pipeline order (outermost first):
|
||||
1. wrap-exception-handler — catch-all error handler
|
||||
2. wrap-cors — CORS headers and OPTIONS preflight
|
||||
3. wrap-trace-id — ensure X-Trace-Id on every request
|
||||
4. wrap-rate-limit — token-bucket rate limiting"
|
||||
(:require [clojure.string :as str]
|
||||
[clojure.tools.logging :as log]
|
||||
[ajet.chat.auth-gw.rate-limit :as rl]
|
||||
[ajet.chat.auth-gw.pages :as pages])
|
||||
(:import [java.io ByteArrayInputStream InputStream]))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Body buffering (for proxy + wrap-params coexistence)
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn wrap-buffer-body
|
||||
"Buffer the request body so it can be read by both wrap-params and the proxy.
|
||||
Without this, wrap-params consumes the InputStream and the proxy gets an empty body."
|
||||
[handler]
|
||||
(fn [request]
|
||||
(let [body (:body request)]
|
||||
(if (instance? InputStream body)
|
||||
(let [bytes (.readAllBytes ^InputStream body)
|
||||
request (assoc request
|
||||
:body (ByteArrayInputStream. bytes)
|
||||
:raw-body bytes)]
|
||||
(handler request))
|
||||
(handler request)))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Exception handler
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn wrap-exception-handler
|
||||
"Catch-all middleware that turns unhandled exceptions into 500 responses."
|
||||
[handler]
|
||||
(fn [request]
|
||||
(try
|
||||
(handler request)
|
||||
(catch Exception e
|
||||
(let [trace-id (get-in request [:headers "x-trace-id"] "unknown")]
|
||||
(log/error e "Unhandled exception" {:trace-id trace-id
|
||||
:uri (:uri request)
|
||||
:method (:request-method request)})
|
||||
{:status 500
|
||||
:headers {"Content-Type" "text/html; charset=utf-8"
|
||||
"X-Trace-Id" trace-id}
|
||||
:body (pages/error-page {:status 500
|
||||
:title "Internal Server Error"
|
||||
:message "An unexpected error occurred. Please try again later."})})))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; CORS
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- origin-allowed?
|
||||
"Check if the request Origin is in the allowed list.
|
||||
In dev mode, allow any localhost origin."
|
||||
[origin allowed-origins dev-mode?]
|
||||
(cond
|
||||
(str/blank? origin) false
|
||||
dev-mode? (or (str/starts-with? origin "http://localhost")
|
||||
(str/starts-with? origin "http://127.0.0.1")
|
||||
(contains? (set allowed-origins) origin))
|
||||
:else (contains? (set allowed-origins) origin)))
|
||||
|
||||
(defn- cors-headers
|
||||
"Build CORS response headers."
|
||||
[origin config]
|
||||
(let [methods (or (:allowed-methods config)
|
||||
[:get :post :put :delete :options])
|
||||
headers-list (or (:allowed-headers config)
|
||||
["Content-Type" "Authorization" "X-Trace-Id"])
|
||||
max-age (or (:max-age config) 86400)]
|
||||
{"Access-Control-Allow-Origin" origin
|
||||
"Access-Control-Allow-Methods" (str/join ", " (map #(str/upper-case (name %)) methods))
|
||||
"Access-Control-Allow-Headers" (str/join ", " headers-list)
|
||||
"Access-Control-Allow-Credentials" "true"
|
||||
"Access-Control-Max-Age" (str max-age)}))
|
||||
|
||||
(defn wrap-cors
|
||||
"CORS middleware — adds CORS headers and handles OPTIONS preflight requests."
|
||||
[handler {:keys [cors] :as config}]
|
||||
(let [allowed-origins (:allowed-origins cors)
|
||||
dev-mode? (not (get-in config [:session :cookie-secure] true))]
|
||||
(fn [request]
|
||||
(let [origin (get-in request [:headers "origin"])]
|
||||
(if (and (= :options (:request-method request)) origin)
|
||||
;; Preflight request — respond immediately
|
||||
(if (origin-allowed? origin allowed-origins dev-mode?)
|
||||
{:status 204
|
||||
:headers (cors-headers origin cors)
|
||||
:body ""}
|
||||
{:status 403
|
||||
:headers {"Content-Type" "text/plain"}
|
||||
:body "CORS origin not allowed"})
|
||||
;; Normal request — add CORS headers to response
|
||||
(let [response (handler request)]
|
||||
(if (and origin (origin-allowed? origin allowed-origins dev-mode?))
|
||||
(update response :headers merge (cors-headers origin cors))
|
||||
response)))))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Trace ID
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn wrap-trace-id
|
||||
"Ensure every request has an X-Trace-Id header. Generates one if missing.
|
||||
Also adds the trace ID to the response."
|
||||
[handler]
|
||||
(fn [request]
|
||||
(let [existing (get-in request [:headers "x-trace-id"])
|
||||
trace-id (or existing (str (java.util.UUID/randomUUID)))
|
||||
request (if existing
|
||||
request
|
||||
(assoc-in request [:headers "x-trace-id"] trace-id))
|
||||
response (handler request)]
|
||||
(assoc-in response [:headers "X-Trace-Id"] trace-id))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Rate limiting
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn wrap-rate-limit
|
||||
"Apply rate limiting based on route classification.
|
||||
|
||||
The limiter-atom is created at startup and shared across all requests.
|
||||
Rate limit categories are determined by the request URI and method."
|
||||
[handler limiter-atom rate-limit-config]
|
||||
(if-not (:enabled rate-limit-config)
|
||||
handler ;; Rate limiting disabled
|
||||
(fn [request]
|
||||
(let [classification (rl/classify-request request)]
|
||||
(if-not classification
|
||||
;; No rate limit applies
|
||||
(handler request)
|
||||
(let [[category identity-key] classification
|
||||
result (rl/check-rate-limit! limiter-atom category identity-key)]
|
||||
(if (:allowed? result)
|
||||
(handler request)
|
||||
;; Rate limited
|
||||
(let [retry-after-s (max 1 (long (Math/ceil (/ (:retry-after-ms result 1000) 1000.0))))]
|
||||
(log/warn "Rate limited" category identity-key
|
||||
"retry-after" retry-after-s "s")
|
||||
{:status 429
|
||||
:headers {"Content-Type" "text/html; charset=utf-8"
|
||||
"Retry-After" (str retry-after-s)}
|
||||
:body (pages/error-page
|
||||
{:status 429
|
||||
:title "Too Many Requests"
|
||||
:message "You're making requests too quickly. Please slow down."
|
||||
:retry-after retry-after-s})}))))))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Request logging
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn wrap-request-logging
|
||||
"Log each request with method, path, status, and duration."
|
||||
[handler]
|
||||
(fn [request]
|
||||
(let [method (str/upper-case (name (:request-method request)))
|
||||
path (:uri request)
|
||||
trace-id (get-in request [:headers "x-trace-id"] "?")
|
||||
start (System/nanoTime)
|
||||
response (handler request)
|
||||
duration (/ (- (System/nanoTime) start) 1e6)]
|
||||
(log/info (format "[%s] %s %s %d (%.0fms)"
|
||||
trace-id method path
|
||||
(:status response 500) duration))
|
||||
response)))
|
||||
@@ -0,0 +1,520 @@
|
||||
(ns ajet.chat.auth-gw.oauth
|
||||
"OAuth login flows — GitHub, Gitea, and generic OIDC.
|
||||
|
||||
Handles the full OAuth2 authorization code flow:
|
||||
1. Generate authorize URL with state parameter (CSRF protection)
|
||||
2. Exchange authorization code for access token
|
||||
3. Fetch user profile from provider
|
||||
4. Find or create local user + oauth_account
|
||||
5. Create session and redirect
|
||||
|
||||
Providers are loaded dynamically from the database (not static config)."
|
||||
(:require [babashka.http-client :as http]
|
||||
[clojure.data.json :as json]
|
||||
[clojure.string :as str]
|
||||
[clojure.tools.logging :as log]
|
||||
[ring.util.codec :as codec]
|
||||
[ajet.chat.auth-gw.auth :as auth]
|
||||
[ajet.chat.auth-gw.pages :as pages]
|
||||
[ajet.chat.auth-gw.setup :as setup])
|
||||
(:import [java.security SecureRandom]
|
||||
[java.util Base64]))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; State parameter (CSRF protection)
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(def ^:private secure-random (SecureRandom.))
|
||||
|
||||
(defn- generate-state
|
||||
"Generate a random state parameter for CSRF protection."
|
||||
[]
|
||||
(let [buf (byte-array 16)]
|
||||
(.nextBytes secure-random buf)
|
||||
(.encodeToString (.withoutPadding (Base64/getUrlEncoder)) buf)))
|
||||
|
||||
;; In-memory state store with expiry (5 minutes)
|
||||
(def ^:private state-store (atom {}))
|
||||
|
||||
(defn- store-state!
|
||||
"Store a state parameter with metadata. Returns the state string."
|
||||
[metadata]
|
||||
(let [state (generate-state)
|
||||
expiry (+ (System/currentTimeMillis) (* 5 60 1000))]
|
||||
;; Clean up expired states while we're at it
|
||||
(swap! state-store
|
||||
(fn [store]
|
||||
(let [now (System/currentTimeMillis)
|
||||
cleaned (into {} (filter (fn [[_ v]] (> (:expiry v) now))) store)]
|
||||
(assoc cleaned state (merge metadata {:expiry expiry})))))
|
||||
state))
|
||||
|
||||
(defn- consume-state!
|
||||
"Validate and consume a state parameter. Returns the metadata or nil."
|
||||
[state]
|
||||
(when state
|
||||
(let [result (atom nil)]
|
||||
(swap! state-store
|
||||
(fn [store]
|
||||
(let [entry (get store state)]
|
||||
(if (and entry (> (:expiry entry) (System/currentTimeMillis)))
|
||||
(do (reset! result (dissoc entry :expiry))
|
||||
(dissoc store state))
|
||||
(do (reset! result nil)
|
||||
store)))))
|
||||
@result)))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; GitHub OAuth
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(def ^:private github-authorize-url "https://github.com/login/oauth/authorize")
|
||||
(def ^:private github-token-url "https://github.com/login/oauth/access_token")
|
||||
(def ^:private github-user-url "https://api.github.com/user")
|
||||
(def ^:private github-emails-url "https://api.github.com/user/emails")
|
||||
|
||||
(defn- github-authorize-redirect
|
||||
"Build the GitHub OAuth authorize redirect URL."
|
||||
[client-id state redirect-uri]
|
||||
(str github-authorize-url "?"
|
||||
(codec/form-encode {"client_id" client-id
|
||||
"redirect_uri" redirect-uri
|
||||
"scope" "read:user user:email"
|
||||
"state" state})))
|
||||
|
||||
(defn- github-exchange-code
|
||||
"Exchange an authorization code for an access token with GitHub."
|
||||
[client-id client-secret code redirect-uri]
|
||||
(let [resp (http/post github-token-url
|
||||
{:headers {"Accept" "application/json"
|
||||
"Content-Type" "application/x-www-form-urlencoded"}
|
||||
:body (codec/form-encode {"client_id" client-id
|
||||
"client_secret" client-secret
|
||||
"code" code
|
||||
"redirect_uri" redirect-uri})
|
||||
:throw false
|
||||
:timeout 10000})]
|
||||
(when (= 200 (:status resp))
|
||||
(let [body (json/read-str (:body resp) :key-fn keyword)]
|
||||
(:access_token body)))))
|
||||
|
||||
(defn- github-fetch-profile
|
||||
"Fetch the user profile from GitHub using the access token."
|
||||
[access-token]
|
||||
(let [user-resp (http/get github-user-url
|
||||
{:headers {"Authorization" (str "Bearer " access-token)
|
||||
"Accept" "application/json"}
|
||||
:throw false
|
||||
:timeout 10000})
|
||||
emails-resp (http/get github-emails-url
|
||||
{:headers {"Authorization" (str "Bearer " access-token)
|
||||
"Accept" "application/json"}
|
||||
:throw false
|
||||
:timeout 10000})]
|
||||
(when (= 200 (:status user-resp))
|
||||
(let [user (json/read-str (:body user-resp) :key-fn keyword)
|
||||
emails (when (= 200 (:status emails-resp))
|
||||
(json/read-str (:body emails-resp) :key-fn keyword))
|
||||
primary-email (or (->> emails
|
||||
(filter :primary)
|
||||
first
|
||||
:email)
|
||||
(:email user))]
|
||||
{:provider :github
|
||||
:provider-user-id (str (:id user))
|
||||
:provider-username (:login user)
|
||||
:username (:login user)
|
||||
:display-name (or (:name user) (:login user))
|
||||
:email (or primary-email "")
|
||||
:avatar-url (:avatar_url user)}))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Gitea OAuth
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- gitea-authorize-redirect
|
||||
"Build the Gitea OAuth authorize redirect URL."
|
||||
[base-url client-id state redirect-uri]
|
||||
(let [authorize-url (str (str/replace base-url #"/+$" "") "/login/oauth/authorize")]
|
||||
(str authorize-url "?"
|
||||
(codec/form-encode {"client_id" client-id
|
||||
"redirect_uri" redirect-uri
|
||||
"response_type" "code"
|
||||
"scope" ""
|
||||
"state" state}))))
|
||||
|
||||
(defn- gitea-exchange-code
|
||||
"Exchange an authorization code for an access token with Gitea."
|
||||
[base-url client-id client-secret code redirect-uri]
|
||||
(let [token-url (str (str/replace base-url #"/+$" "") "/login/oauth/access_token")
|
||||
resp (http/post token-url
|
||||
{:headers {"Accept" "application/json"
|
||||
"Content-Type" "application/x-www-form-urlencoded"}
|
||||
:body (codec/form-encode {"client_id" client-id
|
||||
"client_secret" client-secret
|
||||
"code" code
|
||||
"grant_type" "authorization_code"
|
||||
"redirect_uri" redirect-uri})
|
||||
:throw false
|
||||
:timeout 10000})]
|
||||
(when (= 200 (:status resp))
|
||||
(let [body (json/read-str (:body resp) :key-fn keyword)]
|
||||
(:access_token body)))))
|
||||
|
||||
(defn- gitea-fetch-profile
|
||||
"Fetch the user profile from Gitea using the access token."
|
||||
[base-url access-token]
|
||||
(let [user-url (str (str/replace base-url #"/+$" "") "/api/v1/user")
|
||||
resp (http/get user-url
|
||||
{:headers {"Authorization" (str "Bearer " access-token)
|
||||
"Accept" "application/json"}
|
||||
:throw false
|
||||
:timeout 10000})]
|
||||
(when (= 200 (:status resp))
|
||||
(let [user (json/read-str (:body resp) :key-fn keyword)]
|
||||
{:provider :gitea
|
||||
:provider-user-id (str (:id user))
|
||||
:provider-username (:login user)
|
||||
:username (:login user)
|
||||
:display-name (or (:full_name user) (:login user))
|
||||
:email (or (:email user) "")
|
||||
:avatar-url (:avatar_url user)}))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; OIDC (OpenID Connect)
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- oidc-discover
|
||||
"Fetch the OIDC discovery document from the issuer's well-known URL."
|
||||
[issuer-url]
|
||||
(let [discovery-url (str (str/replace issuer-url #"/+$" "") "/.well-known/openid-configuration")
|
||||
resp (http/get discovery-url
|
||||
{:headers {"Accept" "application/json"}
|
||||
:throw false
|
||||
:timeout 10000})]
|
||||
(when (= 200 (:status resp))
|
||||
(json/read-str (:body resp) :key-fn keyword))))
|
||||
|
||||
(defn- oidc-authorize-redirect
|
||||
"Build the OIDC authorize redirect URL."
|
||||
[discovery client-id state redirect-uri]
|
||||
(let [authorize-url (:authorization_endpoint discovery)]
|
||||
(str authorize-url "?"
|
||||
(codec/form-encode {"client_id" client-id
|
||||
"redirect_uri" redirect-uri
|
||||
"response_type" "code"
|
||||
"scope" "openid profile email"
|
||||
"state" state}))))
|
||||
|
||||
(defn- oidc-exchange-code
|
||||
"Exchange an authorization code for tokens with the OIDC provider."
|
||||
[discovery client-id client-secret code redirect-uri]
|
||||
(let [token-url (:token_endpoint discovery)
|
||||
resp (http/post token-url
|
||||
{:headers {"Accept" "application/json"
|
||||
"Content-Type" "application/x-www-form-urlencoded"}
|
||||
:body (codec/form-encode {"client_id" client-id
|
||||
"client_secret" client-secret
|
||||
"code" code
|
||||
"grant_type" "authorization_code"
|
||||
"redirect_uri" redirect-uri})
|
||||
:throw false
|
||||
:timeout 10000})]
|
||||
(when (= 200 (:status resp))
|
||||
(let [body (json/read-str (:body resp) :key-fn keyword)]
|
||||
(:access_token body)))))
|
||||
|
||||
(defn- oidc-fetch-profile
|
||||
"Fetch the user profile from the OIDC userinfo endpoint."
|
||||
[discovery access-token]
|
||||
(let [userinfo-url (:userinfo_endpoint discovery)
|
||||
resp (http/get userinfo-url
|
||||
{:headers {"Authorization" (str "Bearer " access-token)
|
||||
"Accept" "application/json"}
|
||||
:throw false
|
||||
:timeout 10000})]
|
||||
(when (= 200 (:status resp))
|
||||
(let [user (json/read-str (:body resp) :key-fn keyword)]
|
||||
{:provider :oidc
|
||||
:provider-user-id (str (:sub user))
|
||||
:provider-username (or (:preferred_username user) (:sub user))
|
||||
:username (or (:preferred_username user)
|
||||
(:nickname user)
|
||||
(first (str/split (or (:email user) "user") #"@")))
|
||||
:display-name (or (:name user)
|
||||
(:preferred_username user)
|
||||
(:sub user))
|
||||
:email (or (:email user) "")
|
||||
:avatar-url (:picture user)}))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Handlers
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn login-page-handler
|
||||
"Handle GET /auth/login — render the login page.
|
||||
|
||||
If setup is not complete, redirect to /setup.
|
||||
If a `provider` query param is present, redirect to that provider's
|
||||
authorize URL. Otherwise render the login page with provider buttons."
|
||||
[{:keys [ds config oauth-providers-atom] :as sys} request]
|
||||
(let [first-user? (zero? (auth/count-users ds))
|
||||
setup-done? (setup/setup-complete? sys)
|
||||
providers @oauth-providers-atom]
|
||||
;; Redirect to setup wizard when: no users, no providers, setup not done
|
||||
;; (If providers exist, show login page so admin can create their account via OAuth)
|
||||
(if (and first-user? (not setup-done?) (empty? providers))
|
||||
{:status 302
|
||||
:headers {"Location" "/setup"}
|
||||
:body ""}
|
||||
;; Normal login page
|
||||
(let [providers providers
|
||||
params (:query-params request)
|
||||
provider-slug (get params "provider")
|
||||
error (get params "error")
|
||||
invite-code (or (get params "invite")
|
||||
(get-in request [:cookies "ajet_invite" :value]))
|
||||
base-url (str "http"
|
||||
(when (get-in config [:session :cookie-secure]) "s")
|
||||
"://"
|
||||
(get-in request [:headers "host"]))
|
||||
invite-info (when invite-code
|
||||
(let [invite (auth/find-invite-by-code ds invite-code)]
|
||||
(when (auth/invite-valid? invite)
|
||||
{:community-name (:name invite)})))]
|
||||
(if provider-slug
|
||||
;; Redirect to OAuth provider
|
||||
(let [provider-row (some #(when (= (:slug %) provider-slug) %) providers)]
|
||||
(if provider-row
|
||||
(let [redirect-uri (str base-url "/auth/callback/" provider-slug)
|
||||
state (store-state! {:provider-slug provider-slug
|
||||
:invite-code invite-code})
|
||||
redirect-url
|
||||
(case (:provider-type provider-row)
|
||||
"github" (github-authorize-redirect
|
||||
(:client-id provider-row) state redirect-uri)
|
||||
"gitea" (gitea-authorize-redirect
|
||||
(:base-url provider-row)
|
||||
(:client-id provider-row) state redirect-uri)
|
||||
"oidc" (let [discovery (oidc-discover (:issuer-url provider-row))]
|
||||
(if discovery
|
||||
(oidc-authorize-redirect
|
||||
discovery (:client-id provider-row) state redirect-uri)
|
||||
(do (log/error "OIDC discovery failed for" (:issuer-url provider-row))
|
||||
nil)))
|
||||
nil)]
|
||||
(if redirect-url
|
||||
{:status 302
|
||||
:headers {"Location" redirect-url}
|
||||
:body ""}
|
||||
{:status 302
|
||||
:headers {"Location" (str "/auth/login?error="
|
||||
(codec/url-encode "Provider configuration error"))}
|
||||
:body ""}))
|
||||
;; Unknown provider slug
|
||||
{:status 302
|
||||
:headers {"Location" (str "/auth/login?error="
|
||||
(codec/url-encode "Unknown provider"))}
|
||||
:body ""}))
|
||||
;; Render login page
|
||||
{:status 200
|
||||
:headers {"Content-Type" "text/html; charset=utf-8"
|
||||
"Cache-Control" "no-store"}
|
||||
:body (pages/login-page {:providers providers
|
||||
:error error
|
||||
:invite-info invite-info
|
||||
:first-user? first-user?
|
||||
})})))))
|
||||
|
||||
(defn- handle-oauth-success
|
||||
"Common handler after successful OAuth profile fetch.
|
||||
|
||||
Finds or creates the user, creates a session, handles invite flow,
|
||||
and redirects appropriately."
|
||||
[ds config sys profile invite-code]
|
||||
(let [ttl-days (get-in config [:session :ttl-days] 30)
|
||||
cookie-name (get-in config [:session :cookie-name] "ajet_session")
|
||||
cookie-secure (get-in config [:session :cookie-secure] true)
|
||||
;; Find or create user
|
||||
existing-user (auth/find-user-by-oauth ds (:provider profile) (:provider-user-id profile))
|
||||
user (or existing-user
|
||||
(auth/create-user-from-oauth! ds profile))
|
||||
user-id (:id user)
|
||||
;; Create session
|
||||
raw-token (auth/create-session! ds user-id ttl-days)
|
||||
;; Determine redirect
|
||||
setup-done? (setup/setup-complete? sys)
|
||||
first-user? (and (nil? existing-user)
|
||||
(= 1 (auth/count-users ds)))
|
||||
redirect-to (cond
|
||||
;; First-user bootstrap: redirect to setup if not complete
|
||||
(and first-user? (not setup-done?)) "/setup"
|
||||
;; Invite flow: accept invite then redirect to community
|
||||
invite-code (str "/invite/" (codec/url-encode invite-code) "/accept")
|
||||
;; Normal: redirect to app
|
||||
:else "/")]
|
||||
(log/info "OAuth login success for user" user-id
|
||||
"provider" (name (:provider profile))
|
||||
(if existing-user "existing" "new") "user"
|
||||
"redirect-to" redirect-to)
|
||||
(cond-> {:status 302
|
||||
:headers {"Location" redirect-to}
|
||||
:body ""
|
||||
:cookies (auth/session-cookie cookie-name raw-token ttl-days cookie-secure)}
|
||||
;; Clear invite cookie after use
|
||||
invite-code
|
||||
(update :cookies assoc "ajet_invite" {:value ""
|
||||
:path "/"
|
||||
:max-age 0}))))
|
||||
|
||||
(defn callback-handler
|
||||
"Handle GET /auth/callback/:provider — OAuth callback.
|
||||
|
||||
Validates the state parameter, exchanges the code for an access token,
|
||||
fetches the user profile, and completes the login flow.
|
||||
|
||||
Looks up the provider by slug from the DB-backed atom."
|
||||
[{:keys [ds config oauth-providers-atom] :as sys} request]
|
||||
(let [provider-slug (get-in request [:path-params :provider])
|
||||
params (:query-params request)
|
||||
code (get params "code")
|
||||
state (get params "state")
|
||||
error-param (get params "error")
|
||||
;; Look up provider from DB-backed atom
|
||||
provider-row (some #(when (= (:slug %) provider-slug) %) @oauth-providers-atom)
|
||||
base-url (str "http"
|
||||
(when (get-in config [:session :cookie-secure]) "s")
|
||||
"://"
|
||||
(get-in request [:headers "host"]))
|
||||
redirect-uri (str base-url "/auth/callback/" provider-slug)]
|
||||
|
||||
(cond
|
||||
;; Provider returned an error
|
||||
error-param
|
||||
(do (log/warn "OAuth error from provider" provider-slug ":" error-param)
|
||||
{:status 302
|
||||
:headers {"Location" (str "/auth/login?error="
|
||||
(codec/url-encode (str "Login failed: " error-param)))}
|
||||
:body ""})
|
||||
|
||||
;; Missing code or state
|
||||
(or (str/blank? code) (str/blank? state))
|
||||
{:status 302
|
||||
:headers {"Location" (str "/auth/login?error="
|
||||
(codec/url-encode "Invalid OAuth callback — missing parameters"))}
|
||||
:body ""}
|
||||
|
||||
;; Unknown provider
|
||||
(nil? provider-row)
|
||||
{:status 302
|
||||
:headers {"Location" (str "/auth/login?error="
|
||||
(codec/url-encode "Unknown OAuth provider"))}
|
||||
:body ""}
|
||||
|
||||
;; Invalid or expired state (CSRF check)
|
||||
:else
|
||||
(let [state-meta (consume-state! state)]
|
||||
(if-not state-meta
|
||||
{:status 302
|
||||
:headers {"Location" (str "/auth/login?error="
|
||||
(codec/url-encode "Invalid or expired login session. Please try again."))}
|
||||
:body ""}
|
||||
|
||||
;; Exchange code and fetch profile
|
||||
(let [invite-code (:invite-code state-meta)
|
||||
ptype (:provider-type provider-row)]
|
||||
(try
|
||||
(let [[access-token profile]
|
||||
(case ptype
|
||||
"github"
|
||||
(let [token (github-exchange-code
|
||||
(:client-id provider-row)
|
||||
(:client-secret provider-row)
|
||||
code redirect-uri)]
|
||||
[token (when token (github-fetch-profile token))])
|
||||
|
||||
"gitea"
|
||||
(let [;; Server-side URL may differ from browser-facing base-url
|
||||
;; (e.g. Docker host.docker.internal vs localhost)
|
||||
server-url (or (get-in config [:oauth :gitea-server-base-url])
|
||||
(:base-url provider-row))
|
||||
token (gitea-exchange-code
|
||||
server-url
|
||||
(:client-id provider-row)
|
||||
(:client-secret provider-row)
|
||||
code redirect-uri)]
|
||||
[token (when token (gitea-fetch-profile server-url token))])
|
||||
|
||||
"oidc"
|
||||
(let [discovery (oidc-discover (:issuer-url provider-row))
|
||||
token (when discovery
|
||||
(oidc-exchange-code
|
||||
discovery
|
||||
(:client-id provider-row)
|
||||
(:client-secret provider-row)
|
||||
code redirect-uri))]
|
||||
[token (when token (oidc-fetch-profile discovery token))])
|
||||
|
||||
;; Unknown provider type
|
||||
[nil nil])]
|
||||
(if profile
|
||||
(handle-oauth-success ds config sys profile invite-code)
|
||||
(do (log/warn "OAuth flow failed — could not obtain profile from" provider-slug)
|
||||
{:status 302
|
||||
:headers {"Location" (str "/auth/login?error="
|
||||
(codec/url-encode "Could not authenticate with provider. Please try again."))}
|
||||
:body ""})))
|
||||
(catch Exception e
|
||||
(log/error e "OAuth callback error for provider" provider-slug)
|
||||
{:status 302
|
||||
:headers {"Location" (str "/auth/login?error="
|
||||
(codec/url-encode "Authentication service error. Please try again."))}
|
||||
:body ""}))))))))
|
||||
|
||||
(defn logout-handler
|
||||
"Handle POST /auth/logout — destroy session and clear cookie."
|
||||
[{:keys [ds config]} request]
|
||||
(let [cookie-name (get-in config [:session :cookie-name] "ajet_session")
|
||||
cookie-secure (get-in config [:session :cookie-secure] true)
|
||||
cookie-value (auth/extract-session-cookie request cookie-name)
|
||||
cookie-result (auth/destroy-session! ds cookie-value cookie-name cookie-secure)]
|
||||
(log/info "User logged out")
|
||||
(merge {:status 302
|
||||
:headers {"Location" "/auth/login"}
|
||||
:body ""}
|
||||
cookie-result)))
|
||||
|
||||
(defn invite-landing-handler
|
||||
"Handle GET /invite/:code — render invite landing page or error."
|
||||
[{:keys [ds config]} request]
|
||||
(let [code (get-in request [:path-params :code])
|
||||
invite (auth/find-invite-by-code ds code)]
|
||||
(cond
|
||||
;; Invite not found
|
||||
(nil? invite)
|
||||
{:status 404
|
||||
:headers {"Content-Type" "text/html; charset=utf-8"}
|
||||
:body (pages/invite-page {:community-name "Unknown"
|
||||
:invite-code code
|
||||
:error "This invite link is invalid or has been revoked."})}
|
||||
|
||||
;; Invite expired or exhausted
|
||||
(not (auth/invite-valid? invite))
|
||||
{:status 410
|
||||
:headers {"Content-Type" "text/html; charset=utf-8"}
|
||||
:body (pages/invite-page {:community-name (:name invite)
|
||||
:invite-code code
|
||||
:error "This invite has expired or reached its maximum uses."})}
|
||||
|
||||
;; Valid invite — show landing page, set invite cookie
|
||||
:else
|
||||
{:status 200
|
||||
:headers {"Content-Type" "text/html; charset=utf-8"
|
||||
"Cache-Control" "no-store"}
|
||||
:cookies {"ajet_invite" {:value code
|
||||
:path "/"
|
||||
:max-age (* 30 60) ;; 30 minutes
|
||||
:http-only true
|
||||
:same-site :lax}}
|
||||
:body (pages/invite-page {:community-name (:name invite)
|
||||
:invite-code code})})))
|
||||
@@ -0,0 +1,321 @@
|
||||
(ns ajet.chat.auth-gw.pages
|
||||
"HTML pages rendered by the Auth Gateway using Hiccup.
|
||||
|
||||
Renders login, error, invite landing, and setup wizard pages.
|
||||
Uses Tailwind CSS via CDN for styling."
|
||||
(:require [hiccup2.core :as h]
|
||||
[hiccup.util :as hu]))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Layout
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- page-shell
|
||||
"Base HTML shell with Tailwind CDN and dark theme."
|
||||
[title & body]
|
||||
(str
|
||||
"<!DOCTYPE html>"
|
||||
(h/html
|
||||
[:html {:lang "en" :class "dark"}
|
||||
[:head
|
||||
[:meta {:charset "UTF-8"}]
|
||||
[:meta {:name "viewport" :content "width=device-width, initial-scale=1.0"}]
|
||||
[:title (str title " - ajet chat")]
|
||||
[:script {:src "https://cdn.tailwindcss.com"}]
|
||||
[:script
|
||||
(h/raw "tailwindcss.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: { 50: '#eef2ff', 100: '#e0e7ff', 200: '#c7d2fe', 500: '#6366f1', 600: '#4f46e5', 700: '#4338ca', 800: '#3730a3', 900: '#312e81' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}")]]
|
||||
[:body {:class "bg-gray-950 text-gray-100 min-h-screen flex items-center justify-center antialiased"}
|
||||
[:div {:class "w-full max-w-md mx-auto px-4"}
|
||||
body]]])))
|
||||
|
||||
(defn- provider-button
|
||||
"Render an OAuth provider login button."
|
||||
[slug label icon-svg]
|
||||
[:a {:href (str "/auth/login?provider=" slug)
|
||||
:class "flex items-center justify-center gap-3 w-full px-4 py-3 rounded-lg bg-gray-800 hover:bg-gray-700 border border-gray-700 hover:border-gray-600 text-gray-100 font-medium transition-colors duration-150 no-underline"}
|
||||
(h/raw icon-svg)
|
||||
[:span (str "Continue with " label)]])
|
||||
|
||||
(def ^:private provider-icons
|
||||
{"github" "<svg class=\"w-5 h-5\" fill=\"currentColor\" viewBox=\"0 0 24 24\"><path d=\"M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z\"/></svg>"
|
||||
"gitea" "<svg class=\"w-5 h-5\" fill=\"currentColor\" viewBox=\"0 0 24 24\"><path d=\"M4.209 4.603c-.247 0-.525.02-.84.088-.333.07-1.28.283-2.054 1.027C-.403 6.407.140 7.954.140 7.954c.224.594.47.972.736 1.249-.192.294-.39.633-.543 1.049-.265.723-.264 1.528.007 2.238.271.71.755 1.295 1.379 1.695-.177.556-.193 1.173-.044 1.742.198.755.672 1.39 1.311 1.773.16.096.331.177.509.244-.077.394-.07.808.022 1.207.135.585.426 1.112.834 1.525.407.413.928.705 1.506.844.354.086.714.124 1.074.112a3.77 3.77 0 0 0 1.18-.262c.387.35.851.593 1.35.715.499.122 1.02.107 1.512-.044.397-.122.765-.327 1.073-.602.195.06.396.098.601.112.609.042 1.218-.109 1.728-.43.51-.32.905-.78 1.132-1.325a3.09 3.09 0 0 0 .226-1.195c.503-.21.95-.533 1.302-.946.352-.413.597-.907.711-1.433.115-.526.095-1.072-.057-1.587a3.19 3.19 0 0 0-.794-1.303 3.17 3.17 0 0 0 .382-1.665 3.19 3.19 0 0 0-.677-1.764 3.23 3.23 0 0 0-1.458-1.043 3.25 3.25 0 0 0-1.8-.138c-.39-.535-.903-.95-1.487-1.21a3.36 3.36 0 0 0-1.818-.279c-.594.073-1.154.318-1.618.696a3.22 3.22 0 0 0-1.028-.63 3.29 3.29 0 0 0-1.202-.168 3.27 3.27 0 0 0-1.558.519 3.24 3.24 0 0 0-1.074 1.263c-.203-.064-.412-.1-.622-.112zM12 6.5a5.5 5.5 0 1 1 0 11 5.5 5.5 0 0 1 0-11z\"/></svg>"
|
||||
"oidc" "<svg class=\"w-5 h-5\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" viewBox=\"0 0 24 24\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z\"/></svg>"})
|
||||
|
||||
(defn- icon-for-provider-type [provider-type]
|
||||
(get provider-icons provider-type (get provider-icons "oidc")))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Common UI components
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- error-banner [error]
|
||||
(when error
|
||||
[:div {:class "rounded-lg bg-red-900/40 border border-red-800 px-4 py-3 text-red-300 text-sm"}
|
||||
error]))
|
||||
|
||||
(defn- text-input [{:keys [name type placeholder value required autocomplete]}]
|
||||
[:input {:type (or type "text") :name name :required required
|
||||
:placeholder placeholder :value (or value "")
|
||||
:autocomplete (or autocomplete "off")
|
||||
:class "w-full px-3 py-2 rounded-lg bg-gray-800 border border-gray-700 text-gray-100 placeholder-gray-500 focus:outline-none focus:border-brand-500"}])
|
||||
|
||||
(defn- submit-button [label]
|
||||
[:button {:type "submit"
|
||||
:class "flex items-center justify-center w-full px-4 py-3 rounded-lg bg-brand-600 hover:bg-brand-700 text-white font-medium transition-colors"}
|
||||
label])
|
||||
|
||||
(defn- step-indicator
|
||||
"Render a step progress indicator. current is 1-based."
|
||||
[current total]
|
||||
[:div {:class "flex items-center justify-center gap-2 mb-6"}
|
||||
(for [i (range 1 (inc total))]
|
||||
[:div {:class (str "w-2.5 h-2.5 rounded-full "
|
||||
(if (= i current)
|
||||
"bg-brand-500"
|
||||
(if (< i current)
|
||||
"bg-brand-700"
|
||||
"bg-gray-700")))}])])
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Login Page
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn login-page
|
||||
"Render the login page with dynamic OAuth provider buttons and optional password form.
|
||||
|
||||
opts:
|
||||
:providers - vector of provider maps from DB [{:slug :display-name :provider-type ...}]
|
||||
:error - optional error message string
|
||||
:invite-info - optional map {:community-name \"...\"} for invite flow
|
||||
:first-user? - true if no users exist yet (bootstrap mode)"
|
||||
[{:keys [providers error invite-info first-user?]}]
|
||||
(page-shell "Sign In"
|
||||
[:div {:class "text-center space-y-8 py-12"}
|
||||
;; Logo / branding
|
||||
[:div {:class "space-y-2"}
|
||||
[:h1 {:class "text-3xl font-bold tracking-tight text-brand-200"} "ajet chat"]
|
||||
[:p {:class "text-gray-400 text-sm"}
|
||||
(if first-user?
|
||||
"Welcome! Sign in to set up your community."
|
||||
"Sign in to continue")]]
|
||||
|
||||
;; Error message
|
||||
(error-banner error)
|
||||
|
||||
;; OAuth provider buttons (dynamic from DB)
|
||||
(when (seq providers)
|
||||
[:div {:class "space-y-3"}
|
||||
(for [{:keys [slug display-name provider-type]} providers]
|
||||
(provider-button slug display-name (icon-for-provider-type provider-type)))])
|
||||
|
||||
;; Invite info
|
||||
(when invite-info
|
||||
[:div {:class "border-t border-gray-800 pt-4 space-y-2"}
|
||||
[:p {:class "text-gray-500 text-xs uppercase tracking-wider"} "Accepting invite"]
|
||||
[:p {:class "text-gray-300 font-medium"}
|
||||
(str "Joining: " (:community-name invite-info))]])
|
||||
|
||||
;; First user hint
|
||||
(when first-user?
|
||||
[:div {:class "border-t border-gray-800 pt-4"}
|
||||
[:p {:class "text-gray-500 text-sm"}
|
||||
"You'll be the first user and community owner."]])]))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Error Page
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn error-page
|
||||
"Render a generic error page.
|
||||
|
||||
opts:
|
||||
:status - HTTP status code (401, 403, 404, 429, 502, 503)
|
||||
:title - error title
|
||||
:message - error description
|
||||
:retry-after - optional seconds for 429 responses"
|
||||
[{:keys [status title message retry-after]}]
|
||||
(let [status-text (case status
|
||||
401 "Unauthorized"
|
||||
403 "Forbidden"
|
||||
404 "Not Found"
|
||||
429 "Too Many Requests"
|
||||
502 "Bad Gateway"
|
||||
503 "Service Unavailable"
|
||||
"Error")
|
||||
display-title (or title status-text)]
|
||||
(page-shell display-title
|
||||
[:div {:class "text-center space-y-6 py-16"}
|
||||
[:div {:class "space-y-2"}
|
||||
[:p {:class "text-6xl font-bold text-gray-700"} (str status)]
|
||||
[:h1 {:class "text-xl font-semibold text-gray-300"} display-title]]
|
||||
[:p {:class "text-gray-500 text-sm max-w-sm mx-auto"}
|
||||
(or message "Something went wrong. Please try again.")]
|
||||
(when retry-after
|
||||
[:div {:class "space-y-2"}
|
||||
[:p {:class "text-gray-500 text-sm"}
|
||||
(str "Please wait " retry-after " seconds before retrying.")]
|
||||
[:div {:class "w-full bg-gray-800 rounded-full h-1.5"}
|
||||
[:div {:class "bg-brand-500 h-1.5 rounded-full transition-all duration-1000"
|
||||
:style "width: 0%"
|
||||
:id "retry-bar"}]]
|
||||
[:script
|
||||
(h/raw (format "(() => { let s=%d, el=document.getElementById('retry-bar'), iv=setInterval(() => { s--; el.style.width=(((%d-s)/%d)*100)+'%%'; if(s<=0){clearInterval(iv);location.reload();}},1000);})()"
|
||||
retry-after retry-after retry-after))]])
|
||||
[:div {:class "pt-4 space-x-4"}
|
||||
[:a {:href "/"
|
||||
:class "inline-block px-4 py-2 rounded-lg bg-brand-600 hover:bg-brand-700 text-white text-sm font-medium transition-colors no-underline"}
|
||||
"Go Home"]
|
||||
(when (#{401 403} status)
|
||||
[:a {:href "/auth/login"
|
||||
:class "inline-block px-4 py-2 rounded-lg bg-gray-800 hover:bg-gray-700 border border-gray-700 text-gray-300 text-sm font-medium transition-colors no-underline"}
|
||||
"Sign In"])]])))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Invite Landing Page
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn invite-page
|
||||
"Render the invite landing page.
|
||||
|
||||
opts:
|
||||
:community-name - name of the community being joined
|
||||
:invite-code - the invite code
|
||||
:error - optional error message"
|
||||
[{:keys [community-name invite-code error]}]
|
||||
(page-shell "Join Community"
|
||||
[:div {:class "text-center space-y-8 py-12"}
|
||||
;; Logo
|
||||
[:div {:class "space-y-2"}
|
||||
[:h1 {:class "text-3xl font-bold tracking-tight text-brand-200"} "ajet chat"]]
|
||||
|
||||
;; Error
|
||||
(error-banner error)
|
||||
|
||||
;; Invite info
|
||||
[:div {:class "space-y-4"}
|
||||
[:div {:class "rounded-lg bg-gray-900 border border-gray-800 p-6 space-y-3"}
|
||||
[:p {:class "text-gray-400 text-sm"} "You've been invited to join"]
|
||||
[:p {:class "text-2xl font-bold text-gray-100"} community-name]]
|
||||
|
||||
[:a {:href (str "/auth/login?invite=" (hu/url-encode invite-code))
|
||||
:class "inline-block w-full px-4 py-3 rounded-lg bg-brand-600 hover:bg-brand-700 text-white font-medium transition-colors text-center no-underline"}
|
||||
"Accept Invite & Sign In"]]]))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Setup Wizard Pages
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
(defn setup-configure-providers-page
|
||||
"Render the OAuth provider configuration form (wizard step 2).
|
||||
Shows existing providers with delete buttons and a form to add new ones.
|
||||
Providers require: type, display name, slug, client ID/secret, and
|
||||
type-specific URLs (base-url for Gitea, issuer-url for OIDC).
|
||||
|
||||
opts:
|
||||
:providers - vector of existing provider rows from DB
|
||||
:error - optional validation error message"
|
||||
[{:keys [providers error]}]
|
||||
(page-shell "Setup - OAuth Providers"
|
||||
[:div {:class "space-y-8 py-12"}
|
||||
[:div {:class "text-center space-y-2"}
|
||||
[:h1 {:class "text-3xl font-bold tracking-tight text-brand-200"} "ajet chat"]
|
||||
[:p {:class "text-gray-400 text-sm"} "Configure at least one OAuth login provider to get started."]]
|
||||
|
||||
(step-indicator 1 2)
|
||||
|
||||
(error-banner error)
|
||||
|
||||
;; Existing providers
|
||||
(when (seq providers)
|
||||
[:div {:class "space-y-2"}
|
||||
[:h2 {:class "text-sm font-medium text-gray-400 uppercase tracking-wider"} "Configured Providers"]
|
||||
(for [{:keys [id display-name provider-type slug]} providers]
|
||||
[:div {:class "flex items-center justify-between bg-gray-900 border border-gray-800 rounded-lg p-3"}
|
||||
[:div {:class "flex items-center gap-3"}
|
||||
[:span {:class "text-xs font-mono text-gray-500 uppercase"} provider-type]
|
||||
[:span {:class "text-gray-100 font-medium"} display-name]
|
||||
[:span {:class "text-gray-500 text-xs"} (str "(" slug ")")]]
|
||||
[:form {:method "post" :action (str "/setup/providers/" id "/delete")}
|
||||
[:button {:type "submit"
|
||||
:class "text-red-400 hover:text-red-300 text-sm px-2 py-1 rounded hover:bg-red-900/30 transition-colors"}
|
||||
"Remove"]]])])
|
||||
|
||||
;; Add provider form
|
||||
[:div {:class "space-y-3 text-left"}
|
||||
[:h2 {:class "text-sm font-medium text-gray-400 uppercase tracking-wider"} "Add Provider"]
|
||||
[:form {:method "post" :action "/setup/providers" :class "space-y-3"}
|
||||
[:div
|
||||
[:label {:class "block text-sm text-gray-400 mb-1"} "Provider Type"]
|
||||
[:select {:name "provider-type"
|
||||
:class "w-full px-3 py-2 rounded-lg bg-gray-800 border border-gray-700 text-gray-100 focus:outline-none focus:border-brand-500"}
|
||||
[:option {:value "github"} "GitHub"]
|
||||
[:option {:value "gitea"} "Gitea"]
|
||||
[:option {:value "oidc"} "OIDC (OpenID Connect)"]]]
|
||||
|
||||
[:label {:class "block text-sm text-gray-400"} "Display Name"]
|
||||
(text-input {:name "display-name" :placeholder "e.g. GitHub"})
|
||||
|
||||
[:label {:class "block text-sm text-gray-400"} "Slug (URL-safe identifier)"]
|
||||
(text-input {:name "slug" :placeholder "e.g. github"})
|
||||
|
||||
[:label {:class "block text-sm text-gray-400"} "Client ID"]
|
||||
(text-input {:name "client-id" :placeholder "OAuth client ID"})
|
||||
|
||||
[:label {:class "block text-sm text-gray-400"} "Client Secret"]
|
||||
(text-input {:name "client-secret" :type "password" :placeholder "OAuth client secret"})
|
||||
|
||||
[:label {:class "block text-sm text-gray-400"} "Base URL (Gitea only)"]
|
||||
(text-input {:name "base-url" :placeholder "e.g. https://gitea.example.com"})
|
||||
|
||||
[:label {:class "block text-sm text-gray-400"} "Issuer URL (OIDC only)"]
|
||||
(text-input {:name "issuer-url" :placeholder "e.g. https://auth.example.com"})
|
||||
|
||||
(submit-button "Add Provider")]]
|
||||
|
||||
;; Navigation — require at least one provider before proceeding
|
||||
(when (seq providers)
|
||||
[:div {:class "pt-4"}
|
||||
[:a {:href "/auth/login"
|
||||
:class "flex items-center justify-center w-full px-4 py-3 rounded-lg bg-brand-600 hover:bg-brand-700 text-white font-medium transition-colors no-underline"}
|
||||
"Continue — Sign in with your provider"]])]))
|
||||
|
||||
(defn setup-create-community-page
|
||||
"Render the community creation form (wizard step 3).
|
||||
Collects community name and slug. Slug must be lowercase alphanumeric
|
||||
with hyphens (min 2 chars). On success, completes the setup wizard.
|
||||
|
||||
opts:
|
||||
:error - optional validation/API error message
|
||||
:name - prefill community name on re-render after error
|
||||
:slug - prefill community slug on re-render after error"
|
||||
[{:keys [error name slug]}]
|
||||
(page-shell "Setup - Create Community"
|
||||
[:div {:class "text-center space-y-8 py-12"}
|
||||
[:div {:class "space-y-2"}
|
||||
[:h1 {:class "text-3xl font-bold tracking-tight text-brand-200"} "ajet chat"]
|
||||
[:p {:class "text-gray-400 text-sm"} "Create your first community."]]
|
||||
|
||||
(step-indicator 2 2)
|
||||
|
||||
(error-banner error)
|
||||
|
||||
[:form {:method "post" :action "/setup/create-community" :class "space-y-3 text-left"}
|
||||
[:label {:class "block text-sm text-gray-400"} "Community Name"]
|
||||
(text-input {:name "name" :placeholder "My Team" :value name :required true})
|
||||
|
||||
[:label {:class "block text-sm text-gray-400 pt-2"} "Slug"]
|
||||
(text-input {:name "slug" :placeholder "my-team" :value slug :required true})
|
||||
[:p {:class "text-gray-500 text-xs"} "Lowercase letters, digits, and hyphens only."]
|
||||
|
||||
[:div {:class "pt-2"}
|
||||
(submit-button "Create Community & Finish Setup")]]]))
|
||||
@@ -0,0 +1,258 @@
|
||||
(ns ajet.chat.auth-gw.proxy
|
||||
"Reverse proxy — forwards authenticated requests to internal services.
|
||||
|
||||
Uses babashka.http-client for synchronous proxying and http-kit's
|
||||
async channel for SSE pass-through streaming."
|
||||
(:require [babashka.http-client :as http]
|
||||
[clojure.string :as str]
|
||||
[clojure.tools.logging :as log]
|
||||
[org.httpkit.server :as hk])
|
||||
(:import [java.io InputStream]))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Service resolution
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- service-url
|
||||
"Build the base URL for a service from config."
|
||||
[{:keys [host port] :or {host "localhost"}}]
|
||||
(str "http://" host ":" port))
|
||||
|
||||
(defn resolve-target
|
||||
"Determine the target service and rewritten path for a request URI.
|
||||
|
||||
Returns [service-key path] or nil if no match.
|
||||
|
||||
Service routing:
|
||||
- /app/* /sse/* /web/* / -> :web-sm
|
||||
- /api/* -> :api
|
||||
- /tui/* /tui/sse/* -> :tui-sm"
|
||||
[uri]
|
||||
(cond
|
||||
;; Web SM routes
|
||||
(= uri "/")
|
||||
[:web-sm "/"]
|
||||
|
||||
(= uri "/setup")
|
||||
[:web-sm "/setup"]
|
||||
|
||||
(= uri "/app")
|
||||
[:web-sm "/app"]
|
||||
|
||||
(str/starts-with? uri "/app/")
|
||||
[:web-sm uri]
|
||||
|
||||
(str/starts-with? uri "/sse/")
|
||||
[:web-sm uri]
|
||||
|
||||
(str/starts-with? uri "/web/")
|
||||
[:web-sm uri]
|
||||
|
||||
;; API routes
|
||||
(str/starts-with? uri "/api/")
|
||||
[:api uri]
|
||||
|
||||
;; TUI SM routes
|
||||
(str/starts-with? uri "/tui/")
|
||||
[:tui-sm uri]
|
||||
|
||||
:else nil))
|
||||
|
||||
(defn- build-target-url
|
||||
"Build the full target URL from service config and request."
|
||||
[services service-key path query-string]
|
||||
(let [base (service-url (get services service-key))]
|
||||
(if (str/blank? query-string)
|
||||
(str base path)
|
||||
(str base path "?" query-string))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Header manipulation
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(def ^:private stripped-request-headers
|
||||
"Headers to strip from proxied requests (prevent spoofing)."
|
||||
#{"x-user-id" "x-user-role" "x-community-id" "x-trace-id"
|
||||
"x-forwarded-for" "x-forwarded-proto" "x-forwarded-host"
|
||||
"host" "connection" "keep-alive" "transfer-encoding"
|
||||
"te" "trailer" "upgrade" "content-length"})
|
||||
|
||||
(defn- clean-request-headers
|
||||
"Strip auth/hop-by-hop headers and normalize values for java.net.http."
|
||||
[headers]
|
||||
(into {}
|
||||
(comp (remove (fn [[k _]] (contains? stripped-request-headers (str/lower-case k))))
|
||||
(map (fn [[k v]] [k (str/replace (str v) #"\r?\n" ", ")])))
|
||||
headers))
|
||||
|
||||
(defn- inject-proxy-headers
|
||||
"Add auth and tracing headers for the upstream service."
|
||||
[headers {:keys [user-id user-role community-id trace-id remote-addr]}]
|
||||
(cond-> headers
|
||||
user-id (assoc "X-User-Id" user-id)
|
||||
user-role (assoc "X-User-Role" user-role)
|
||||
community-id (assoc "X-Community-Id" community-id)
|
||||
trace-id (assoc "X-Trace-Id" trace-id)
|
||||
remote-addr (assoc "X-Forwarded-For" remote-addr)))
|
||||
|
||||
(defn- is-sse-request?
|
||||
"Check if the request is for an SSE endpoint."
|
||||
[uri]
|
||||
(or (str/starts-with? uri "/sse/")
|
||||
(str/starts-with? uri "/tui/sse/")))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Synchronous proxy (non-SSE)
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(def ^:private proxy-client
|
||||
"HTTP client that never follows redirects — upstream redirects must be
|
||||
passed through to the client untouched."
|
||||
(http/client {:follow-redirects :never}))
|
||||
|
||||
(defn- proxy-sync
|
||||
"Forward a non-SSE request synchronously via babashka.http-client."
|
||||
[target-url method headers body]
|
||||
(try
|
||||
(let [opts (cond-> {:uri target-url
|
||||
:method method
|
||||
:headers headers
|
||||
:throw false
|
||||
:timeout 30000
|
||||
:client proxy-client}
|
||||
body (assoc :body body))
|
||||
resp (http/request opts)]
|
||||
{:status (:status resp)
|
||||
:headers (-> (:headers resp)
|
||||
(dissoc "transfer-encoding" "connection")
|
||||
;; Ensure headers are string->string
|
||||
(->> (into {}
|
||||
(map (fn [[k v]]
|
||||
[(name k)
|
||||
(if (sequential? v) (str/join ", " v) (str v))])))))
|
||||
:body (:body resp)})
|
||||
(catch java.io.IOException e
|
||||
;; Java 21's HttpClient rejects 204/304 responses that include
|
||||
;; a Content-Length header (strict HTTP compliance). Detect and
|
||||
;; return the correct no-content status directly.
|
||||
(let [msg (str (.getMessage e))]
|
||||
(if-let [[_ status] (re-find #"content length header with (\d+) response" msg)]
|
||||
{:status (Integer/parseInt status)
|
||||
:headers {}
|
||||
:body nil}
|
||||
(do (log/error e "Proxy IO error to" target-url)
|
||||
{:status 502
|
||||
:headers {"Content-Type" "text/plain"}
|
||||
:body "Bad Gateway — upstream service unavailable"}))))
|
||||
(catch Exception e
|
||||
(log/error e "Proxy error to" target-url)
|
||||
{:status 502
|
||||
:headers {"Content-Type" "text/plain"}
|
||||
:body "Bad Gateway — upstream service unavailable"})))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; SSE streaming proxy
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- proxy-sse
|
||||
"Forward an SSE request using http-kit async channel for streaming.
|
||||
|
||||
Opens an http-kit async response, makes a streaming request to the
|
||||
upstream, and pipes chunks through without buffering."
|
||||
[target-url headers hk-channel]
|
||||
(try
|
||||
(let [resp (http/get target-url
|
||||
{:headers (assoc headers "Accept" "text/event-stream")
|
||||
:throw false
|
||||
:as :stream})]
|
||||
(if (= 200 (:status resp))
|
||||
(do
|
||||
;; Send initial response headers
|
||||
(hk/send! hk-channel
|
||||
{:status 200
|
||||
:headers {"Content-Type" "text/event-stream"
|
||||
"Cache-Control" "no-cache, no-store"
|
||||
"Connection" "keep-alive"
|
||||
"X-Accel-Buffering" "no"}}
|
||||
false)
|
||||
;; Stream body in a background thread
|
||||
(future
|
||||
(try
|
||||
(let [^InputStream is (:body resp)
|
||||
buf (byte-array 4096)]
|
||||
(loop []
|
||||
(let [n (.read is buf)]
|
||||
(when (and (pos? n) (hk/open? hk-channel))
|
||||
(hk/send! hk-channel
|
||||
(String. buf 0 n "UTF-8")
|
||||
false)
|
||||
(recur))))
|
||||
;; Upstream closed
|
||||
(hk/close hk-channel))
|
||||
(catch Exception e
|
||||
(when-not (instance? java.io.IOException e)
|
||||
(log/warn e "SSE stream error"))
|
||||
(hk/close hk-channel)))))
|
||||
;; Upstream error
|
||||
(do
|
||||
(hk/send! hk-channel
|
||||
{:status (:status resp)
|
||||
:headers {"Content-Type" "text/plain"}
|
||||
:body "Upstream SSE connection failed"}
|
||||
true))))
|
||||
(catch Exception e
|
||||
(log/error e "SSE proxy connection error to" target-url)
|
||||
(hk/send! hk-channel
|
||||
{:status 502
|
||||
:headers {"Content-Type" "text/plain"}
|
||||
:body "Bad Gateway — upstream service unavailable"}
|
||||
true))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Public API
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn proxy-request
|
||||
"Proxy a Ring request to the appropriate internal service.
|
||||
|
||||
services: map from config {:api {:host .. :port ..} :web-sm ... :tui-sm ...}
|
||||
auth-info: map of {:user-id :user-role :community-id :trace-id :remote-addr}
|
||||
request: Ring request map
|
||||
|
||||
Returns a Ring response map (or uses http-kit async for SSE)."
|
||||
[services auth-info request]
|
||||
(let [uri (:uri request)
|
||||
resolution (resolve-target uri)]
|
||||
(if-not resolution
|
||||
{:status 404
|
||||
:headers {"Content-Type" "text/plain"}
|
||||
:body "Not Found"}
|
||||
(let [[service-key path] resolution
|
||||
service-cfg (get services service-key)]
|
||||
(if-not service-cfg
|
||||
(do (log/error "No service config for" service-key)
|
||||
{:status 502
|
||||
:headers {"Content-Type" "text/plain"}
|
||||
:body "Bad Gateway — service not configured"})
|
||||
(let [target-url (build-target-url services service-key path (:query-string request))
|
||||
headers (-> (:headers request)
|
||||
clean-request-headers
|
||||
(inject-proxy-headers auth-info))]
|
||||
(if (is-sse-request? uri)
|
||||
;; SSE: use http-kit async channel
|
||||
(hk/with-channel request hk-channel
|
||||
(hk/on-close hk-channel
|
||||
(fn [_status]
|
||||
(log/debug "SSE client disconnected from" uri)))
|
||||
(proxy-sse target-url headers hk-channel))
|
||||
;; Non-SSE: synchronous proxy
|
||||
;; Use :raw-body (buffered bytes) if available, otherwise :body
|
||||
;; :raw-body is set by wrap-buffer-body so the proxy has the
|
||||
;; original body even after wrap-params consumed the InputStream
|
||||
(let [body (or (when-let [raw (:raw-body request)]
|
||||
(when (pos? (alength raw)) raw))
|
||||
(:body request))]
|
||||
(proxy-sync target-url
|
||||
(:request-method request)
|
||||
headers
|
||||
body)))))))))
|
||||
@@ -0,0 +1,183 @@
|
||||
(ns ajet.chat.auth-gw.rate-limit
|
||||
"In-memory token bucket rate limiter.
|
||||
|
||||
Each bucket is keyed by [category identity-key] and tracks remaining tokens
|
||||
plus the last refill timestamp. Expired/stale buckets are cleaned up
|
||||
periodically by a background thread."
|
||||
(:require [clojure.tools.logging :as log])
|
||||
(:import [java.time Instant Duration]))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Configuration
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(def ^:private rate-rules
|
||||
"Rate limit rules keyed by category keyword.
|
||||
:capacity — max tokens (requests) in the window
|
||||
:refill-ms — window size in milliseconds"
|
||||
{:auth-callback {:capacity 10 :refill-ms 60000} ; 10/min per IP
|
||||
:api-write {:capacity 60 :refill-ms 60000} ; 60/min per user
|
||||
:api-read {:capacity 120 :refill-ms 60000} ; 120/min per user
|
||||
:webhook {:capacity 30 :refill-ms 60000} ; 30/min per webhook
|
||||
:sse {:capacity 5 :refill-ms 60000}}) ; 5/min per user
|
||||
|
||||
(def ^:private stale-threshold-ms
|
||||
"Remove buckets that haven't been touched in 10 minutes."
|
||||
600000)
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Bucket state
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn make-limiter
|
||||
"Create a new rate limiter state atom. Returns the atom."
|
||||
[]
|
||||
(atom {}))
|
||||
|
||||
(defn- now-ms []
|
||||
(System/currentTimeMillis))
|
||||
|
||||
(defn- refill-bucket
|
||||
"Refill tokens based on elapsed time since last refill."
|
||||
[{:keys [tokens last-refill-ms] :as bucket} {:keys [capacity refill-ms]}]
|
||||
(let [elapsed (- (now-ms) last-refill-ms)]
|
||||
(if (>= elapsed refill-ms)
|
||||
;; Full window elapsed — reset to capacity
|
||||
(assoc bucket
|
||||
:tokens capacity
|
||||
:last-refill-ms (now-ms))
|
||||
;; Partial refill — add proportional tokens
|
||||
(let [fraction (/ (double elapsed) (double refill-ms))
|
||||
added (* fraction capacity)
|
||||
new-tokens (min capacity (+ tokens added))]
|
||||
(assoc bucket
|
||||
:tokens new-tokens
|
||||
:last-refill-ms (now-ms))))))
|
||||
|
||||
(defn- get-or-create-bucket
|
||||
"Get an existing bucket or create a fresh one at capacity."
|
||||
[buckets bucket-key rule]
|
||||
(or (get buckets bucket-key)
|
||||
{:tokens (:capacity rule)
|
||||
:last-refill-ms (now-ms)}))
|
||||
|
||||
(defn check-rate-limit!
|
||||
"Check and consume a token from the bucket for [category identity-key].
|
||||
|
||||
Returns {:allowed? true} if the request is permitted, or
|
||||
{:allowed? false :retry-after-ms N} if rate limited.
|
||||
|
||||
category: one of :auth-callback :api-write :api-read :webhook :sse
|
||||
identity-key: string (IP address, user-id, or webhook-id)"
|
||||
[limiter-atom category identity-key]
|
||||
(let [rule (get rate-rules category)]
|
||||
(if-not rule
|
||||
;; Unknown category — allow (no rule defined)
|
||||
{:allowed? true}
|
||||
(let [bucket-key [category identity-key]
|
||||
result (atom nil)]
|
||||
(swap! limiter-atom
|
||||
(fn [buckets]
|
||||
(let [bucket (get-or-create-bucket buckets bucket-key rule)
|
||||
refilled (refill-bucket bucket rule)
|
||||
tokens (:tokens refilled)]
|
||||
(if (>= tokens 1.0)
|
||||
;; Allow — consume a token
|
||||
(do (reset! result {:allowed? true})
|
||||
(assoc buckets bucket-key
|
||||
(assoc refilled
|
||||
:tokens (dec tokens)
|
||||
:last-access-ms (now-ms))))
|
||||
;; Deny — calculate retry-after
|
||||
(let [deficit (- 1.0 tokens)
|
||||
refill-rate (/ (double (:capacity rule))
|
||||
(double (:refill-ms rule)))
|
||||
retry-after (long (Math/ceil (/ deficit refill-rate)))]
|
||||
(reset! result {:allowed? false
|
||||
:retry-after-ms retry-after})
|
||||
(assoc buckets bucket-key
|
||||
(assoc refilled :last-access-ms (now-ms))))))))
|
||||
@result))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Cleanup
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn cleanup-stale!
|
||||
"Remove buckets that haven't been accessed in over `stale-threshold-ms`."
|
||||
[limiter-atom]
|
||||
(let [cutoff (- (now-ms) stale-threshold-ms)
|
||||
removed (atom 0)]
|
||||
(swap! limiter-atom
|
||||
(fn [buckets]
|
||||
(let [active (into {}
|
||||
(filter (fn [[_ b]]
|
||||
(> (get b :last-access-ms 0) cutoff)))
|
||||
buckets)]
|
||||
(reset! removed (- (count buckets) (count active)))
|
||||
active)))
|
||||
(when (pos? @removed)
|
||||
(log/debug "Rate limiter cleanup: removed" @removed "stale buckets"))))
|
||||
|
||||
(defn start-cleanup-task!
|
||||
"Start a background thread that periodically cleans up stale buckets.
|
||||
Returns the future (cancel with `future-cancel`)."
|
||||
[limiter-atom]
|
||||
(future
|
||||
(log/info "Rate limiter cleanup task started")
|
||||
(try
|
||||
(loop []
|
||||
(Thread/sleep 60000) ;; every minute
|
||||
(cleanup-stale! limiter-atom)
|
||||
(recur))
|
||||
(catch InterruptedException _
|
||||
(log/info "Rate limiter cleanup task stopped"))
|
||||
(catch Exception e
|
||||
(log/error e "Rate limiter cleanup task error")))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Route classification
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn classify-request
|
||||
"Determine the rate limit category and identity key for a request.
|
||||
|
||||
Returns [category identity-key] or nil if no rate limit applies.
|
||||
|
||||
request: Ring request map (expects :uri, :request-method, and optionally
|
||||
::user-id, ::remote-addr, ::webhook-id keys set by auth middleware)."
|
||||
[request]
|
||||
(let [uri (:uri request)
|
||||
method (:request-method request)]
|
||||
(cond
|
||||
;; OAuth callback — rate limit by IP
|
||||
(and (= method :post)
|
||||
(re-matches #"/auth/callback/.*" uri))
|
||||
[:auth-callback (or (::remote-addr request)
|
||||
(get-in request [:headers "x-forwarded-for"])
|
||||
(:remote-addr request)
|
||||
"unknown")]
|
||||
|
||||
;; Webhook incoming — rate limit by webhook ID
|
||||
(and (= method :post)
|
||||
(re-matches #"/api/webhooks/.*/incoming" uri))
|
||||
[:webhook (or (::webhook-id request) "unknown")]
|
||||
|
||||
;; SSE connections — rate limit by user
|
||||
(and (= method :get)
|
||||
(or (re-matches #"/sse/.*" uri)
|
||||
(re-matches #"/tui/sse/.*" uri)))
|
||||
[:sse (or (::user-id request) "anonymous")]
|
||||
|
||||
;; API writes — rate limit by user
|
||||
(and (= method :post)
|
||||
(re-matches #"/api/.*" uri))
|
||||
[:api-write (or (::user-id request) "anonymous")]
|
||||
|
||||
;; API reads — rate limit by user
|
||||
(and (= method :get)
|
||||
(re-matches #"/api/.*" uri))
|
||||
[:api-read (or (::user-id request) "anonymous")]
|
||||
|
||||
;; No rate limit for other routes
|
||||
:else nil)))
|
||||
@@ -0,0 +1,307 @@
|
||||
(ns ajet.chat.auth-gw.routes
|
||||
"Reitit router for the Auth Gateway.
|
||||
|
||||
Routes are divided into:
|
||||
- Self-handled: auth pages, health check, invite landing, setup wizard
|
||||
- Proxied: all other routes forwarded to internal services
|
||||
with session/token validation and header injection"
|
||||
(:require [clojure.string :as str]
|
||||
[clojure.tools.logging :as log]
|
||||
[reitit.ring :as ring]
|
||||
[ring.middleware.cookies :refer [wrap-cookies]]
|
||||
[ring.middleware.params :refer [wrap-params]]
|
||||
[ajet.chat.auth-gw.auth :as auth]
|
||||
[ajet.chat.auth-gw.oauth :as oauth]
|
||||
[ajet.chat.auth-gw.proxy :as proxy]
|
||||
[ajet.chat.auth-gw.pages :as pages]
|
||||
[ajet.chat.auth-gw.middleware :as mw]
|
||||
[ajet.chat.auth-gw.rate-limit :as rl]
|
||||
[ajet.chat.auth-gw.setup :as setup]))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Auth helpers
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- extract-remote-addr
|
||||
"Get the client's IP address from the request."
|
||||
[request]
|
||||
(or (get-in request [:headers "x-forwarded-for"])
|
||||
(:remote-addr request)
|
||||
"unknown"))
|
||||
|
||||
(defn- session-auth
|
||||
"Validate session cookie. Returns auth-info map or nil."
|
||||
[ds request cookie-name]
|
||||
(let [token (auth/extract-session-cookie request cookie-name)]
|
||||
(auth/validate-session ds token)))
|
||||
|
||||
(defn- api-token-auth
|
||||
"Validate Bearer token from Authorization header. Returns auth-info map or nil."
|
||||
[ds request]
|
||||
(let [auth-header (get-in request [:headers "authorization"])]
|
||||
(auth/validate-api-token ds auth-header)))
|
||||
|
||||
(defn- webhook-auth
|
||||
"Validate webhook Bearer token. Returns auth-info map or nil."
|
||||
[ds request]
|
||||
(let [auth-header (get-in request [:headers "authorization"])]
|
||||
(auth/validate-webhook-token ds auth-header)))
|
||||
|
||||
(defn- web-redirect-to-login
|
||||
"Redirect to login page (for web browser requests that fail auth)."
|
||||
[]
|
||||
{:status 302
|
||||
:headers {"Location" "/auth/login"}
|
||||
:body ""})
|
||||
|
||||
(defn- json-401
|
||||
"Return a 401 JSON response (for API/TUI requests that fail auth)."
|
||||
[trace-id]
|
||||
{:status 401
|
||||
:headers {"Content-Type" "application/json"
|
||||
"X-Trace-Id" (or trace-id "")}
|
||||
:body "{\"error\":\"unauthorized\",\"message\":\"Invalid or missing authentication\"}"})
|
||||
|
||||
(defn- is-web-route?
|
||||
"Check if the URI is a web-browser route (should redirect on auth failure)."
|
||||
[uri]
|
||||
(or (= uri "/")
|
||||
(= uri "/app")
|
||||
(= uri "/setup")
|
||||
(str/starts-with? uri "/app/")
|
||||
(str/starts-with? uri "/sse/")
|
||||
(str/starts-with? uri "/web/")))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Proxy handlers with auth
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- make-session-proxy-handler
|
||||
"Create a handler that validates session auth and proxies to a service."
|
||||
[{:keys [ds config]}]
|
||||
(let [services (:services config)
|
||||
cookie-name (get-in config [:session :cookie-name] "ajet_session")
|
||||
ttl-days (get-in config [:session :ttl-days] 30)]
|
||||
(fn [request]
|
||||
(let [auth-info (session-auth ds request cookie-name)
|
||||
trace-id (get-in request [:headers "x-trace-id"])]
|
||||
(if auth-info
|
||||
(do
|
||||
;; Extend session TTL asynchronously
|
||||
(auth/extend-session-ttl! ds (:session-id auth-info) ttl-days)
|
||||
;; Proxy the request
|
||||
(proxy/proxy-request services
|
||||
(assoc auth-info
|
||||
:trace-id trace-id
|
||||
:remote-addr (extract-remote-addr request))
|
||||
request))
|
||||
;; Auth failed
|
||||
(if (is-web-route? (:uri request))
|
||||
(web-redirect-to-login)
|
||||
(json-401 trace-id)))))))
|
||||
|
||||
(defn- make-api-proxy-handler
|
||||
"Create a handler that validates session OR API token auth and proxies to API."
|
||||
[{:keys [ds config]}]
|
||||
(let [services (:services config)
|
||||
cookie-name (get-in config [:session :cookie-name] "ajet_session")
|
||||
ttl-days (get-in config [:session :ttl-days] 30)]
|
||||
(fn [request]
|
||||
(let [trace-id (get-in request [:headers "x-trace-id"])
|
||||
;; Try session auth first, then API token
|
||||
session (session-auth ds request cookie-name)
|
||||
api-token (when-not session (api-token-auth ds request))
|
||||
auth-info (or session api-token)]
|
||||
(if auth-info
|
||||
(do
|
||||
;; Extend session TTL if session auth was used
|
||||
(when (and session (:session-id session))
|
||||
(auth/extend-session-ttl! ds (:session-id session) ttl-days))
|
||||
(proxy/proxy-request services
|
||||
(assoc auth-info
|
||||
:trace-id trace-id
|
||||
:remote-addr (extract-remote-addr request))
|
||||
request))
|
||||
(json-401 trace-id))))))
|
||||
|
||||
(defn- make-webhook-proxy-handler
|
||||
"Create a handler that validates webhook token and proxies to API."
|
||||
[{:keys [ds config]}]
|
||||
(let [services (:services config)]
|
||||
(fn [request]
|
||||
(let [trace-id (get-in request [:headers "x-trace-id"])
|
||||
auth-info (webhook-auth ds request)]
|
||||
(if auth-info
|
||||
(proxy/proxy-request services
|
||||
(assoc auth-info
|
||||
:trace-id trace-id
|
||||
:remote-addr (extract-remote-addr request))
|
||||
request)
|
||||
(json-401 trace-id))))))
|
||||
|
||||
(defn- make-tui-proxy-handler
|
||||
"Create a handler that validates session auth and proxies to TUI SM."
|
||||
[{:keys [ds config]}]
|
||||
(let [services (:services config)
|
||||
cookie-name (get-in config [:session :cookie-name] "ajet_session")
|
||||
ttl-days (get-in config [:session :ttl-days] 30)]
|
||||
(fn [request]
|
||||
(let [auth-info (session-auth ds request cookie-name)
|
||||
trace-id (get-in request [:headers "x-trace-id"])]
|
||||
(if auth-info
|
||||
(do
|
||||
(auth/extend-session-ttl! ds (:session-id auth-info) ttl-days)
|
||||
(proxy/proxy-request services
|
||||
(assoc auth-info
|
||||
:trace-id trace-id
|
||||
:remote-addr (extract-remote-addr request))
|
||||
request))
|
||||
(json-401 trace-id))))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Health check
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- health-handler
|
||||
"Health check endpoint. Verifies DB connectivity."
|
||||
[{:keys [ds]}]
|
||||
(fn [_request]
|
||||
(try
|
||||
(let [result (ajet.chat.shared.db/execute-one! ds
|
||||
{:select [[[:raw "1"] :ok]]})]
|
||||
{:status 200
|
||||
:headers {"Content-Type" "application/json"}
|
||||
:body "{\"status\":\"ok\",\"db\":\"connected\"}"})
|
||||
(catch Exception e
|
||||
(log/warn e "Health check — DB unreachable")
|
||||
{:status 503
|
||||
:headers {"Content-Type" "application/json"}
|
||||
:body "{\"status\":\"degraded\",\"db\":\"disconnected\"}"}))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Router
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn app
|
||||
"Build the full Ring handler with reitit router and middleware stack.
|
||||
|
||||
sys: system map {:ds, :config, :limiter, :oauth-providers-atom, :setup-complete-atom}"
|
||||
[sys]
|
||||
(let [session-proxy (make-session-proxy-handler sys)
|
||||
api-proxy (make-api-proxy-handler sys)
|
||||
webhook-proxy (make-webhook-proxy-handler sys)
|
||||
tui-proxy (make-tui-proxy-handler sys)
|
||||
config (:config sys)
|
||||
limiter (:limiter sys)]
|
||||
(ring/ring-handler
|
||||
(ring/router
|
||||
[;; --- Self-handled: Auth ---
|
||||
["/auth/login"
|
||||
{:get {:handler (fn [request]
|
||||
(oauth/login-page-handler sys request))}}]
|
||||
|
||||
["/auth/callback/:provider"
|
||||
{:get {:handler (fn [request]
|
||||
(oauth/callback-handler sys request))}}]
|
||||
|
||||
["/auth/logout"
|
||||
{:post {:handler (fn [request]
|
||||
(oauth/logout-handler sys request))}}]
|
||||
|
||||
;; --- Self-handled: Invite ---
|
||||
["/invite/:code"
|
||||
{:get {:handler (fn [request]
|
||||
(oauth/invite-landing-handler sys request))}}]
|
||||
|
||||
;; --- Self-handled: Health ---
|
||||
["/health"
|
||||
{:get {:handler (health-handler sys)}}]
|
||||
|
||||
;; --- Self-handled: Setup Wizard ---
|
||||
;; Conditional: if setup incomplete, Auth GW renders wizard pages.
|
||||
;; If setup complete, /setup proxies to Web SM.
|
||||
["/setup"
|
||||
{:get {:handler (fn [request]
|
||||
(if (setup/setup-complete? sys)
|
||||
(session-proxy request)
|
||||
(setup/wizard-page-handler sys request)))}}]
|
||||
|
||||
["/setup/create-community"
|
||||
{:get {:handler (fn [request]
|
||||
(setup/create-community-page-handler sys request))}
|
||||
:post {:handler (fn [request]
|
||||
(setup/create-community-handler sys request))}}]
|
||||
|
||||
["/setup/providers"
|
||||
{:post {:handler (fn [request]
|
||||
(setup/add-provider-handler sys request))}}]
|
||||
|
||||
["/setup/providers/:id/delete"
|
||||
{:post {:handler (fn [request]
|
||||
(setup/delete-provider-handler sys request))}}]
|
||||
|
||||
;; --- Webhook proxy (before /api/* to match first) ---
|
||||
["/api/webhooks/:webhook-id/incoming"
|
||||
{:post {:handler webhook-proxy}}]
|
||||
|
||||
;; --- API proxy (session or API token) ---
|
||||
["/api/*"
|
||||
{:get {:handler api-proxy}
|
||||
:post {:handler api-proxy}
|
||||
:put {:handler api-proxy}
|
||||
:delete {:handler api-proxy}}]
|
||||
|
||||
;; --- TUI SM proxy ---
|
||||
["/tui/sse/*"
|
||||
{:get {:handler tui-proxy}}]
|
||||
|
||||
["/tui/*"
|
||||
{:post {:handler tui-proxy}}]
|
||||
|
||||
;; --- Web SM proxy (SSE) ---
|
||||
["/sse/*"
|
||||
{:get {:handler session-proxy}}]
|
||||
|
||||
;; --- Web SM proxy (form posts) ---
|
||||
["/web/*"
|
||||
{:post {:handler session-proxy}}]
|
||||
|
||||
;; --- Web SM proxy (app pages) ---
|
||||
["/app"
|
||||
{:get {:handler session-proxy}}]
|
||||
["/app/*"
|
||||
{:get {:handler session-proxy}}]
|
||||
|
||||
;; --- Web SM proxy (root) ---
|
||||
["/"
|
||||
{:get {:handler session-proxy}}]]
|
||||
|
||||
;; Router options
|
||||
{:conflicts nil}) ;; Suppress conflict warnings for catch-all routes
|
||||
|
||||
;; Default handler for unmatched routes
|
||||
(ring/create-default-handler
|
||||
{:not-found (constantly
|
||||
{:status 404
|
||||
:headers {"Content-Type" "text/html; charset=utf-8"}
|
||||
:body (pages/error-page {:status 404
|
||||
:title "Not Found"
|
||||
:message "The page you're looking for doesn't exist."})})})
|
||||
|
||||
;; Middleware stack (applied outermost first)
|
||||
{:middleware [;; Parse cookies
|
||||
wrap-cookies
|
||||
;; Buffer body so both wrap-params and proxy can read it
|
||||
mw/wrap-buffer-body
|
||||
;; Parse query params and form body
|
||||
wrap-params
|
||||
;; Catch-all exception handler (outermost)
|
||||
mw/wrap-exception-handler
|
||||
;; CORS
|
||||
[mw/wrap-cors config]
|
||||
;; Trace ID generation
|
||||
mw/wrap-trace-id
|
||||
;; Request logging
|
||||
mw/wrap-request-logging
|
||||
;; Rate limiting
|
||||
[mw/wrap-rate-limit limiter (get config :rate-limit {:enabled true})]]})))
|
||||
@@ -0,0 +1,234 @@
|
||||
(ns ajet.chat.auth-gw.setup
|
||||
"Admin setup wizard — multi-step first-deployment bootstrap.
|
||||
|
||||
Flow:
|
||||
1. Configure OAuth providers (no auth needed — no users exist yet)
|
||||
2. Admin logs in via one of the configured providers (normal OAuth flow)
|
||||
3. Create first community (auth required — admin is now logged in)
|
||||
|
||||
After community creation, setup_completed is set to true and /setup
|
||||
proxies to Web SM for subsequent community creation."
|
||||
(:require [clojure.string :as str]
|
||||
[clojure.tools.logging :as log]
|
||||
[babashka.http-client :as http]
|
||||
[clojure.data.json :as json]
|
||||
[ajet.chat.auth-gw.auth :as auth]
|
||||
[ajet.chat.auth-gw.pages :as pages]))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Setup state helpers
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn setup-complete?
|
||||
"Check if initial setup has been completed.
|
||||
Uses the cached atom if available, otherwise queries the DB."
|
||||
[{:keys [ds setup-complete-atom]}]
|
||||
(if-let [cached @setup-complete-atom]
|
||||
(= cached :true)
|
||||
(let [val (auth/get-system-setting ds "setup_completed")
|
||||
complete? (= val "true")]
|
||||
(reset! setup-complete-atom (if complete? :true :false))
|
||||
complete?)))
|
||||
|
||||
(defn- mark-setup-complete!
|
||||
"Mark setup as completed in the database and update the cache."
|
||||
[{:keys [ds setup-complete-atom]}]
|
||||
(auth/set-system-setting! ds "setup_completed" "true")
|
||||
(reset! setup-complete-atom :true)
|
||||
(log/info "Setup wizard marked as complete"))
|
||||
|
||||
(defn reload-providers!
|
||||
"Reload OAuth providers from DB into the cached atom."
|
||||
[{:keys [ds oauth-providers-atom]}]
|
||||
(let [providers (auth/list-oauth-providers ds)]
|
||||
(reset! oauth-providers-atom providers)
|
||||
(log/info "Reloaded" (count providers) "OAuth providers from DB")
|
||||
providers))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Wizard step determination
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- current-step
|
||||
"Determine the current wizard step based on DB state.
|
||||
- No enabled OAuth providers → :configure-providers
|
||||
- Providers exist but no users → :awaiting-login (redirect to login)
|
||||
- Users exist but setup not complete → :create-community"
|
||||
[ds]
|
||||
(let [provider-count (auth/count-oauth-providers ds)
|
||||
user-count (auth/count-users ds)]
|
||||
(cond
|
||||
(zero? provider-count) :configure-providers
|
||||
(zero? user-count) :awaiting-login
|
||||
:else :create-community)))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Response helpers
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- require-wizard-auth
|
||||
"Validate session for authenticated wizard steps. Returns auth-info or nil."
|
||||
[{:keys [ds config]} request]
|
||||
(let [cookie-name (get-in config [:session :cookie-name] "ajet_session")]
|
||||
(auth/validate-session ds (auth/extract-session-cookie request cookie-name))))
|
||||
|
||||
(def ^:private redirect-to-login
|
||||
{:status 302 :headers {"Location" "/auth/login"} :body ""})
|
||||
|
||||
(def ^:private redirect-to-home
|
||||
{:status 302 :headers {"Location" "/"} :body ""})
|
||||
|
||||
(def ^:private redirect-to-setup
|
||||
{:status 302 :headers {"Location" "/setup"} :body ""})
|
||||
|
||||
(defn- html-response [body]
|
||||
{:status 200
|
||||
:headers {"Content-Type" "text/html; charset=utf-8"
|
||||
"Cache-Control" "no-store"}
|
||||
:body body})
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Handlers
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn wizard-page-handler
|
||||
"GET /setup — render the appropriate wizard step.
|
||||
- No providers configured: show OAuth provider configuration form
|
||||
- Providers exist, no users: redirect to /auth/login for first OAuth login
|
||||
- User authenticated, setup incomplete: show community creation form"
|
||||
[{:keys [ds] :as sys} request]
|
||||
(if (setup-complete? sys)
|
||||
redirect-to-home
|
||||
(let [step (current-step ds)]
|
||||
(case step
|
||||
:configure-providers
|
||||
(let [providers (auth/list-all-oauth-providers ds)]
|
||||
(html-response (pages/setup-configure-providers-page {:providers providers})))
|
||||
|
||||
:awaiting-login
|
||||
redirect-to-login
|
||||
|
||||
:create-community
|
||||
(if-let [_auth (require-wizard-auth sys request)]
|
||||
(html-response (pages/setup-create-community-page {}))
|
||||
redirect-to-login)))))
|
||||
|
||||
(defn create-community-page-handler
|
||||
"GET /setup/create-community — render the community creation form directly.
|
||||
Used when navigating from provider config step via 'Next' link."
|
||||
[{:keys [ds] :as sys} request]
|
||||
(if (setup-complete? sys)
|
||||
redirect-to-home
|
||||
(if-let [_auth (require-wizard-auth sys request)]
|
||||
(html-response (pages/setup-create-community-page {}))
|
||||
redirect-to-login)))
|
||||
|
||||
(defn add-provider-handler
|
||||
"POST /setup/providers — add an OAuth provider during initial setup.
|
||||
No auth required (no users exist yet). Validates provider fields:
|
||||
type must be github/gitea/oidc, display name + slug + client credentials
|
||||
required, base-url required for Gitea, issuer-url required for OIDC."
|
||||
[{:keys [ds] :as sys} request]
|
||||
(if (setup-complete? sys)
|
||||
redirect-to-home
|
||||
(let [params (:params request)
|
||||
provider-type (get params "provider-type")
|
||||
display-name (some-> (get params "display-name") str/trim)
|
||||
slug (some-> (get params "slug") str/trim)
|
||||
client-id (some-> (get params "client-id") str/trim)
|
||||
client-secret (some-> (get params "client-secret") str/trim)
|
||||
base-url (some-> (get params "base-url") str/trim)
|
||||
issuer-url (some-> (get params "issuer-url") str/trim)
|
||||
providers (auth/list-all-oauth-providers ds)
|
||||
render-error (fn [msg]
|
||||
(html-response
|
||||
(pages/setup-configure-providers-page
|
||||
{:providers providers :error msg})))]
|
||||
(cond
|
||||
(not (#{"github" "gitea" "oidc"} provider-type))
|
||||
(render-error "Invalid provider type")
|
||||
|
||||
(or (str/blank? display-name) (str/blank? slug)
|
||||
(str/blank? client-id) (str/blank? client-secret))
|
||||
(render-error "Display name, slug, client ID, and client secret are required")
|
||||
|
||||
(and (= provider-type "gitea") (str/blank? base-url))
|
||||
(render-error "Base URL is required for Gitea providers")
|
||||
|
||||
(and (= provider-type "oidc") (str/blank? issuer-url))
|
||||
(render-error "Issuer URL is required for OIDC providers")
|
||||
|
||||
:else
|
||||
(do
|
||||
(auth/insert-oauth-provider! ds
|
||||
(cond-> {:provider-type provider-type
|
||||
:display-name display-name
|
||||
:slug slug
|
||||
:client-id client-id
|
||||
:client-secret client-secret}
|
||||
(not (str/blank? base-url)) (assoc :base-url base-url)
|
||||
(not (str/blank? issuer-url)) (assoc :issuer-url issuer-url)))
|
||||
(reload-providers! sys)
|
||||
(log/info "Setup wizard: OAuth provider added" provider-type slug)
|
||||
redirect-to-setup)))))
|
||||
|
||||
(defn delete-provider-handler
|
||||
"POST /setup/providers/:id/delete — remove an OAuth provider during setup.
|
||||
No auth required (called during initial setup before any users exist)."
|
||||
[{:keys [ds] :as sys} request]
|
||||
(if (setup-complete? sys)
|
||||
redirect-to-home
|
||||
(let [provider-id (get-in request [:path-params :id])]
|
||||
(auth/delete-oauth-provider! ds
|
||||
(if (instance? java.util.UUID provider-id)
|
||||
provider-id
|
||||
(java.util.UUID/fromString (str provider-id))))
|
||||
(reload-providers! sys)
|
||||
(log/info "Setup wizard: OAuth provider deleted" provider-id)
|
||||
redirect-to-setup)))
|
||||
|
||||
(defn create-community-handler
|
||||
"POST /setup/create-community — create the first community and complete setup.
|
||||
Requires authentication (admin logged in via OAuth). Validates community
|
||||
name and slug, creates via internal API call, marks setup_completed=true."
|
||||
[{:keys [ds config] :as sys} request]
|
||||
(if (setup-complete? sys)
|
||||
redirect-to-home
|
||||
(if-let [auth-info (require-wizard-auth sys request)]
|
||||
(let [params (:params request)
|
||||
community-name (some-> (get params "name") str/trim)
|
||||
community-slug (some-> (get params "slug") str/trim)]
|
||||
(cond
|
||||
(or (str/blank? community-name) (str/blank? community-slug))
|
||||
(html-response (pages/setup-create-community-page
|
||||
{:error "Community name and slug are required"}))
|
||||
|
||||
(not (re-matches #"[a-z0-9][a-z0-9-]*[a-z0-9]" community-slug))
|
||||
(html-response (pages/setup-create-community-page
|
||||
{:error "Slug must be lowercase letters, digits, and hyphens (min 2 chars)"
|
||||
:name community-name :slug community-slug}))
|
||||
|
||||
:else
|
||||
(let [api-host (get-in config [:services :api :host] "localhost")
|
||||
api-port (get-in config [:services :api :port] 3001)
|
||||
api-url (str "http://" api-host ":" api-port "/api/communities")
|
||||
resp (http/post api-url
|
||||
{:headers {"Content-Type" "application/json"
|
||||
"X-User-Id" (str (:user-id auth-info))}
|
||||
:body (json/write-str {:name community-name
|
||||
:slug community-slug})
|
||||
:throw false
|
||||
:timeout 10000})]
|
||||
(if (= 201 (:status resp))
|
||||
(do
|
||||
(mark-setup-complete! sys)
|
||||
(reload-providers! sys)
|
||||
(log/info "Setup wizard: complete. Community created:" community-slug)
|
||||
redirect-to-home)
|
||||
(let [body (try (json/read-str (:body resp) :key-fn keyword) (catch Exception _ nil))
|
||||
errmsg (or (get-in body [:error :message]) "Failed to create community")]
|
||||
(html-response (pages/setup-create-community-page
|
||||
{:error errmsg
|
||||
:name community-name
|
||||
:slug community-slug})))))))
|
||||
redirect-to-login)))
|
||||
@@ -0,0 +1,141 @@
|
||||
{:tasks
|
||||
{;; ---------------------------------------------------------------------------
|
||||
;; Dev infrastructure (Docker)
|
||||
;; ---------------------------------------------------------------------------
|
||||
infra:dev
|
||||
{:doc "Start dev infrastructure (Postgres, NATS, MinIO)"
|
||||
:task (shell "docker compose -f docker-compose.dev.yml up -d")}
|
||||
|
||||
infra:dev:stop
|
||||
{:doc "Stop dev infrastructure"
|
||||
:task (shell "docker compose -f docker-compose.dev.yml down")}
|
||||
|
||||
infra:test
|
||||
{:doc "Start test infrastructure (Postgres, NATS, MinIO on test ports)"
|
||||
:task (shell "docker compose -f docker-compose.test.yml up -d")}
|
||||
|
||||
infra:test:stop
|
||||
{:doc "Stop test infrastructure"
|
||||
:task (shell "docker compose -f docker-compose.test.yml down")}
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Development
|
||||
;; ---------------------------------------------------------------------------
|
||||
dev
|
||||
{:doc "Start dev infra + nREPL with all modules on the classpath"
|
||||
:task (do (run 'infra:dev)
|
||||
(shell "clj -M:dev -m nrepl.cmdline --middleware '[cider.nrepl/cider-middleware refactor-nrepl.middleware/wrap-refactor]'"))}
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Testing
|
||||
;; ---------------------------------------------------------------------------
|
||||
test
|
||||
{:doc "Run all tests (starts test infra if needed)"
|
||||
:task (do (run 'infra:test)
|
||||
(shell "clj -M:test -m kaocha.runner"))}
|
||||
|
||||
test:unit
|
||||
{:doc "Run unit tests only (no Docker needed)"
|
||||
:task (shell "clj -M:test -m kaocha.runner --focus unit")}
|
||||
|
||||
test:integration
|
||||
{:doc "Run integration tests (starts test infra if needed)"
|
||||
:task (do (run 'infra:test)
|
||||
(shell "clj -M:test -m kaocha.runner --focus integration"))}
|
||||
|
||||
test:e2e
|
||||
{:doc "Run e2e tests (starts test infra if needed)"
|
||||
:task (do (run 'infra:test)
|
||||
(shell "clj -M:test -m kaocha.runner --focus e2e"))}
|
||||
|
||||
test:e2e:browser
|
||||
{:doc "Run Playwright browser E2E tests (nbb + ClojureScript)"
|
||||
:task (do
|
||||
;; Start full e2e stack (infra + app services + Gitea)
|
||||
(shell "docker compose -f docker-compose.test.yml --profile e2e up -d --build")
|
||||
;; Install npm deps and Playwright browser
|
||||
(shell "cd e2e && npm install && npx playwright install chromium")
|
||||
;; Run the nbb test suite
|
||||
(let [result (shell {:continue true} "cd e2e && npx nbb -cp src -m ajet-chat.e2e.runner")]
|
||||
;; Tear down the stack
|
||||
(shell "docker compose -f docker-compose.test.yml --profile e2e down -v")
|
||||
(System/exit (:exit result))))}
|
||||
|
||||
;; Per-module test tasks
|
||||
test:shared
|
||||
{:doc "Run all shared module tests"
|
||||
:task (do (run 'infra:test)
|
||||
(shell "clj -M:test -m kaocha.runner --focus shared"))}
|
||||
|
||||
test:shared:unit
|
||||
{:doc "Run shared unit tests only"
|
||||
:task (shell "clj -M:test -m kaocha.runner --focus shared-unit")}
|
||||
|
||||
test:shared:integration
|
||||
{:doc "Run shared integration tests"
|
||||
:task (do (run 'infra:test)
|
||||
(shell "clj -M:test -m kaocha.runner --focus shared-integration"))}
|
||||
|
||||
test:api
|
||||
{:doc "Run all API module tests"
|
||||
:task (do (run 'infra:test)
|
||||
(shell "clj -M:test -m kaocha.runner --focus api"))}
|
||||
|
||||
test:api:unit
|
||||
{:doc "Run API unit tests only"
|
||||
:task (shell "clj -M:test -m kaocha.runner --focus api-unit")}
|
||||
|
||||
test:api:integration
|
||||
{:doc "Run API integration tests"
|
||||
:task (do (run 'infra:test)
|
||||
(shell "clj -M:test -m kaocha.runner --focus api-integration"))}
|
||||
|
||||
test:auth-gw
|
||||
{:doc "Run all auth-gw module tests"
|
||||
:task (do (run 'infra:test)
|
||||
(shell "clj -M:test -m kaocha.runner --focus auth-gw"))}
|
||||
|
||||
test:auth-gw:integration
|
||||
{:doc "Run auth-gw integration tests"
|
||||
:task (do (run 'infra:test)
|
||||
(shell "clj -M:test -m kaocha.runner --focus auth-gw-integration"))}
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Build
|
||||
;; ---------------------------------------------------------------------------
|
||||
build
|
||||
{:doc "Build uberjar for a module. Usage: bb build <module>"
|
||||
:task (let [module (first *command-line-args*)]
|
||||
(when-not module
|
||||
(println "Usage: bb build <module> (api | auth-gw | web-sm | tui-sm | cli)")
|
||||
(System/exit 1))
|
||||
(shell (str "clj -T:build uber :module " module)))}
|
||||
|
||||
clean
|
||||
{:doc "Clean build artifacts for a module. Usage: bb clean <module>"
|
||||
:task (let [module (first *command-line-args*)]
|
||||
(if module
|
||||
(shell (str "clj -T:build clean :module " module))
|
||||
(shell "clj -T:build clean-all")))}
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Production (Docker Compose)
|
||||
;; ---------------------------------------------------------------------------
|
||||
prod
|
||||
{:doc "Start production stack (docker compose)"
|
||||
:task (shell "docker compose up -d --build")}
|
||||
|
||||
prod:stop
|
||||
{:doc "Stop production stack"
|
||||
:task (shell "docker compose down")}
|
||||
|
||||
prod:logs
|
||||
{:doc "Tail production logs"
|
||||
:task (shell "docker compose logs -f")}
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Database utilities
|
||||
;; ---------------------------------------------------------------------------
|
||||
db:reset-test
|
||||
{:doc "Reset test database (drop & recreate public schema)"
|
||||
:task (shell "docker exec ajet-chat-postgres-test-1 psql -U ajet -d ajet_chat_test -c 'DROP SCHEMA public CASCADE; CREATE SCHEMA public;'")}}}
|
||||
@@ -0,0 +1,53 @@
|
||||
(ns build
|
||||
"tools.build configuration for building uberjars for each service."
|
||||
(:require [clojure.tools.build.api :as b]))
|
||||
|
||||
(def version "0.1.0")
|
||||
|
||||
(defn- module-config [module]
|
||||
(let [class-dir (str module "/target/classes")
|
||||
basis (b/create-basis {:aliases [(keyword module)]})]
|
||||
{:module module
|
||||
:class-dir class-dir
|
||||
:uber-file (str module "/target/" module ".jar")
|
||||
:basis basis
|
||||
:main (symbol (str "ajet.chat."
|
||||
(case module
|
||||
"api" "api.core"
|
||||
"auth-gw" "auth-gw.core"
|
||||
"web-sm" "web.core"
|
||||
"tui-sm" "tui-sm.core"
|
||||
"cli" "cli.core")))}))
|
||||
|
||||
(defn uber
|
||||
"Build an uberjar for a service module.
|
||||
|
||||
Usage: clj -T:build uber :module api
|
||||
clj -T:build uber :module auth-gw"
|
||||
[{:keys [module]}]
|
||||
(let [{:keys [class-dir uber-file basis main]} (module-config (name module))]
|
||||
(println "Building uberjar for" (name module) "...")
|
||||
(b/delete {:path class-dir})
|
||||
(b/copy-dir {:src-dirs ["shared/src" "shared/resources"
|
||||
(str (name module) "/src")
|
||||
(str (name module) "/resources")]
|
||||
:target-dir class-dir})
|
||||
(b/compile-clj {:basis basis
|
||||
:src-dirs ["shared/src" (str (name module) "/src")]
|
||||
:class-dir class-dir})
|
||||
(b/uber {:class-dir class-dir
|
||||
:uber-file uber-file
|
||||
:basis basis
|
||||
:main main})
|
||||
(println "Built:" uber-file)))
|
||||
|
||||
(defn clean
|
||||
"Clean build artifacts for a module."
|
||||
[{:keys [module]}]
|
||||
(b/delete {:path (str (name module) "/target")}))
|
||||
|
||||
(defn clean-all
|
||||
"Clean all build artifacts."
|
||||
[_]
|
||||
(doseq [m ["api" "auth-gw" "web-sm" "tui-sm" "cli"]]
|
||||
(b/delete {:path (str m "/target")})))
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
# CLI & TUI Client
|
||||
|
||||
Terminal client for ajet-chat. Provides two modes:
|
||||
- **CLI Mode** — stateless one-shot commands for scripting and quick interactions
|
||||
- **TUI Mode** — full interactive terminal application with split panes, markdown rendering, and real-time updates
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# CLI commands
|
||||
ajet login # OAuth login
|
||||
ajet communities # List communities
|
||||
ajet channels # List channels
|
||||
ajet read general # Read messages
|
||||
ajet send general "hello" # Send a message
|
||||
ajet tui # Launch interactive TUI
|
||||
|
||||
# TUI with options
|
||||
ajet tui --community my-team # Open to specific community
|
||||
ajet tui --channel general # Open to specific channel
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `clojure-tui` — local dep at `../../clojure-tui` (Elm architecture TUI framework)
|
||||
- `babashka.http-client` — HTTP client for API calls
|
||||
- `clojure.data.json` — JSON parsing
|
||||
- `clojure.tools.cli` — CLI argument parsing
|
||||
- Shared modules: `api-client`, `markdown`, `mentions`
|
||||
|
||||
## TODO: clojure-tui Gaps
|
||||
|
||||
The TUI (`tui.clj`) uses clojure-tui's Elm architecture (init/update/view) for state management and rendering. However, several PRD features require capabilities that clojure-tui does not yet provide. SSE integration is worked around via a shared `LinkedBlockingQueue` polled every 100ms with `delayed-event`. Below is the complete list of gaps between the PRD requirements and clojure-tui's current capabilities.
|
||||
|
||||
### 1. Mouse Support (PRD 4.4)
|
||||
|
||||
**PRD requires:** Mouse click to select channel/message/button, mouse scroll in message list.
|
||||
|
||||
**Gap:** clojure-tui has no mouse tracking escape sequences in `terminal.clj` and no mouse event parsing in `input.clj`. The library only handles keyboard input.
|
||||
|
||||
**Workaround:** Keyboard-only navigation (Ctrl+N/P for channels, Tab for focus, arrow keys for scrolling).
|
||||
|
||||
**To resolve:** Add SGR mouse tracking (`\033[?1000h\033[?1006h`) to `terminal.clj` and mouse event parsing (button, position, scroll) to `input.clj`.
|
||||
|
||||
### 2. Inline Image Rendering (PRD 4.5)
|
||||
|
||||
**PRD requires:** Render images inline in message list via timg, sixel, or kitty graphics protocol.
|
||||
|
||||
**Gap:** clojure-tui has no image rendering support. Its render pipeline outputs ANSI text only.
|
||||
|
||||
**Workaround:** Images display as `[image: filename.png]` text placeholder.
|
||||
|
||||
**To resolve:** Add a `:image` render primitive that shells out to `timg` or emits sixel/kitty escape sequences. Requires terminal capability detection.
|
||||
|
||||
### 3. Multiline Text Input (PRD 4.4)
|
||||
|
||||
**PRD requires:** Shift+Enter or Alt+Enter inserts a newline in the input field.
|
||||
|
||||
**Gap:** clojure-tui's `:input` widget is single-line only. It handles backspace and character insertion but has no concept of line breaks within the input buffer.
|
||||
|
||||
**Workaround:** Messages are single-line only. No multiline composition.
|
||||
|
||||
**To resolve:** Extend `:input` widget to support a multi-line buffer with cursor movement across lines, or create a new `:textarea` widget.
|
||||
|
||||
### 4. Autocomplete Dropdowns (PRD 4.4)
|
||||
|
||||
**PRD requires:** Typing `@` shows user mention dropdown, `#` shows channel dropdown, `/` shows slash command list. Tab to select.
|
||||
|
||||
**Gap:** clojure-tui has no autocomplete or dropdown widget. It has `:modal` and `:scroll` primitives but no composition for filtered-list-as-you-type behavior.
|
||||
|
||||
**Workaround:** @mentions, #channels, and /commands are typed manually without autocomplete.
|
||||
|
||||
**To resolve:** Build an autocomplete widget by composing `:modal` + `:scroll` + filtered list, with keyboard navigation. This is application-level code that could be contributed back to clojure-tui.
|
||||
|
||||
### 5. SSE Client Integration (PRD 4.7)
|
||||
|
||||
**PRD requires:** Real-time event stream from TUI session manager via Server-Sent Events.
|
||||
|
||||
**Gap:** clojure-tui's event loop (`core.clj`) only processes keyboard input events. It has no mechanism to inject external events (HTTP responses, SSE data) into the Elm update cycle.
|
||||
|
||||
**Workaround:** A background thread reads SSE via `HttpURLConnection` and writes parsed events to a shared `LinkedBlockingQueue`. The Elm loop polls this queue every 100ms via `delayed-event`, draining events and processing them in the `:update` function. This works but adds up to 100ms latency.
|
||||
|
||||
**To resolve:** Add an external event channel to clojure-tui's `run` function (e.g., accept a `core.async` channel that the event loop merges with stdin input via `alt!`). This would eliminate polling and allow SSE events to flow through `:update` with zero latency.
|
||||
|
||||
### 6. Terminal Bell (PRD 4.8)
|
||||
|
||||
**PRD requires:** Terminal bell (`\a`) on new @mention or DM.
|
||||
|
||||
**Gap:** clojure-tui's render pipeline doesn't include bell output. Trivial to implement but not part of the library's event/render model.
|
||||
|
||||
**Workaround:** Not yet implemented. Can be added as `(print "\u0007") (flush)` in the message event handler.
|
||||
|
||||
**To resolve:** Either add a `:bell` event type to clojure-tui, or just emit the bell character directly in application code (outside the render cycle).
|
||||
|
||||
### 7. OSC 8 Hyperlinks (PRD 4.6)
|
||||
|
||||
**PRD requires:** URLs in messages render as clickable hyperlinks using OSC 8 escape sequences (`\033]8;;URL\033\\text\033]8;;\033\\`).
|
||||
|
||||
**Gap:** clojure-tui's `ansi.clj` has ANSI color/style codes but no OSC 8 hyperlink support.
|
||||
|
||||
**Workaround:** URLs render as plain underlined text without click behavior.
|
||||
|
||||
**To resolve:** Add OSC 8 hyperlink escape sequences to `ansi.clj` and integrate into the `:text` render primitive when a `:href` attribute is present.
|
||||
|
||||
### 8. Spoiler Text Reveal (PRD 4.6)
|
||||
|
||||
**PRD requires:** `||spoiler||` text renders hidden (e.g., as block characters) until user presses Enter on the selected message to reveal.
|
||||
|
||||
**Gap:** This is an application-level feature requiring per-message hidden/revealed state and keypress handling. clojure-tui doesn't prevent this but provides no specific support.
|
||||
|
||||
**Workaround:** Spoiler text renders as plain text (not hidden).
|
||||
|
||||
**To resolve:** Track revealed-spoiler state per message ID in app state. Render spoiler spans as `\u2588` block characters when hidden, original text when revealed. Toggle on Enter keypress when message is selected.
|
||||
|
||||
### Summary Table
|
||||
|
||||
| Feature | PRD Section | Status | Blocked By |
|
||||
|---------|------------|--------|------------|
|
||||
| Mouse support | 4.4 | Not implemented | clojure-tui: no mouse input |
|
||||
| Inline images | 4.5 | Placeholder only | clojure-tui: no image rendering |
|
||||
| Multiline input | 4.4 | Single-line only | clojure-tui: :input is single-line |
|
||||
| Autocomplete | 4.4 | Not implemented | clojure-tui: no dropdown widget |
|
||||
| SSE integration | 4.7 | Queue polling (100ms) | clojure-tui: no external event injection |
|
||||
| Terminal bell | 4.8 | Not implemented | Trivial — just needs `\a` output |
|
||||
| OSC 8 hyperlinks | 4.6 | Not implemented | clojure-tui: no OSC 8 support |
|
||||
| Spoiler reveal | 4.6 | Plain text | Application-level (not blocked) |
|
||||
@@ -1,5 +1,6 @@
|
||||
{:paths ["src"]
|
||||
:deps {org.clojure/clojure {:mvn/version "1.12.0"}
|
||||
org.clojure/tools.cli {:mvn/version "1.1.230"}
|
||||
ajet/chat-shared {:local/root "../shared"}
|
||||
ajet/clojure-tui {:local/root "../../clojure-tui"}}
|
||||
:aliases
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
(ns ajet.chat.cli.auth
|
||||
"Authentication commands for the CLI client.
|
||||
|
||||
Supports two login modes:
|
||||
- Interactive OAuth: opens browser, starts temp HTTP callback server
|
||||
- Token: saves an API token directly for scripting
|
||||
|
||||
Login flow:
|
||||
1. Start a temporary local HTTP server on a random port
|
||||
2. Open browser to auth gateway login with redirect to localhost callback
|
||||
3. Capture session token from callback query params
|
||||
4. Save to session.edn
|
||||
5. Fetch /api/me to confirm and print user info
|
||||
|
||||
Fallback: if browser cannot open, print URL and prompt for manual token paste."
|
||||
(:require [clojure.string :as str]
|
||||
[ajet.chat.cli.config :as config]
|
||||
[ajet.chat.cli.output :as output]
|
||||
[ajet.chat.shared.api-client :as api])
|
||||
(:import [java.net ServerSocket URLDecoder]
|
||||
[java.io BufferedReader InputStreamReader PrintWriter]
|
||||
[java.time Instant Duration]))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Local callback server
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- parse-query-params
|
||||
"Parse query string into a map."
|
||||
[query-string]
|
||||
(when (and query-string (not (str/blank? query-string)))
|
||||
(into {}
|
||||
(map (fn [pair]
|
||||
(let [parts (str/split pair #"=" 2)]
|
||||
[(URLDecoder/decode (first parts) "UTF-8")
|
||||
(when (second parts)
|
||||
(URLDecoder/decode (second parts) "UTF-8"))]))
|
||||
(str/split query-string #"&")))))
|
||||
|
||||
(defn- extract-request-info
|
||||
"Extract method, path, and query params from an HTTP request line."
|
||||
[request-line]
|
||||
(when request-line
|
||||
(let [parts (str/split request-line #"\s+")
|
||||
method (first parts)
|
||||
full-path (second parts)
|
||||
[path query] (str/split (or full-path "/") #"\?" 2)]
|
||||
{:method method
|
||||
:path path
|
||||
:query (parse-query-params query)})))
|
||||
|
||||
(defn- send-http-response
|
||||
"Send a simple HTTP response on a PrintWriter."
|
||||
[^PrintWriter writer status-code body]
|
||||
(.println writer (str "HTTP/1.1 " status-code " OK"))
|
||||
(.println writer "Content-Type: text/html; charset=utf-8")
|
||||
(.println writer "Connection: close")
|
||||
(.println writer (str "Content-Length: " (count (.getBytes ^String body "UTF-8"))))
|
||||
(.println writer "")
|
||||
(.print writer body)
|
||||
(.flush writer))
|
||||
|
||||
(def ^:private success-page
|
||||
"<html><body style=\"font-family:sans-serif;text-align:center;padding:60px;\">
|
||||
<h1>Logged in!</h1>
|
||||
<p>You can close this tab and return to your terminal.</p>
|
||||
</body></html>")
|
||||
|
||||
(def ^:private error-page
|
||||
"<html><body style=\"font-family:sans-serif;text-align:center;padding:60px;\">
|
||||
<h1>Login failed</h1>
|
||||
<p>Something went wrong. Please try again.</p>
|
||||
</body></html>")
|
||||
|
||||
(defn- start-callback-server
|
||||
"Start a temporary HTTP server on a random port. Blocks until a callback
|
||||
is received or timeout (120s). Returns the captured query params or nil."
|
||||
[]
|
||||
(let [server (ServerSocket. 0)
|
||||
port (.getLocalPort server)
|
||||
result (promise)]
|
||||
(.setSoTimeout server 120000) ;; 2 minute timeout
|
||||
(future
|
||||
(try
|
||||
(let [socket (.accept server)
|
||||
reader (BufferedReader. (InputStreamReader. (.getInputStream socket)))
|
||||
writer (PrintWriter. (.getOutputStream socket) true)
|
||||
line (.readLine reader)
|
||||
info (extract-request-info line)]
|
||||
(if (and info (get-in info [:query "token"]))
|
||||
(do
|
||||
(send-http-response writer 200 success-page)
|
||||
(deliver result (:query info)))
|
||||
(do
|
||||
(send-http-response writer 400 error-page)
|
||||
(deliver result nil)))
|
||||
(.close socket))
|
||||
(catch java.net.SocketTimeoutException _
|
||||
(deliver result nil))
|
||||
(catch Exception _e
|
||||
(deliver result nil))
|
||||
(finally
|
||||
(.close server))))
|
||||
{:port port :result result}))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Browser opening
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- open-browser
|
||||
"Attempt to open a URL in the default browser. Returns true on success."
|
||||
[url]
|
||||
(try
|
||||
(let [os-name (str/lower-case (System/getProperty "os.name"))]
|
||||
(cond
|
||||
(str/includes? os-name "linux")
|
||||
(do (.start (ProcessBuilder. ["xdg-open" url])) true)
|
||||
|
||||
(str/includes? os-name "mac")
|
||||
(do (.start (ProcessBuilder. ["open" url])) true)
|
||||
|
||||
(str/includes? os-name "windows")
|
||||
(do (.start (ProcessBuilder. ["cmd" "/c" "start" url])) true)
|
||||
|
||||
:else false))
|
||||
(catch Exception _
|
||||
false)))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Login commands
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn login-interactive
|
||||
"Interactive OAuth login flow.
|
||||
|
||||
1. Starts a temporary local HTTP server on a random port
|
||||
2. Opens browser to auth gateway login URL
|
||||
3. Waits for callback with session token
|
||||
4. Saves session and fetches user info"
|
||||
[& [{:keys [json?]}]]
|
||||
(let [server-url (config/get-server-url)
|
||||
{:keys [port result]} (start-callback-server)
|
||||
callback-url (str "http://localhost:" port "/callback")
|
||||
login-url (str server-url "/auth/login?redirect=" callback-url "&cli=true")
|
||||
browser-ok? (open-browser login-url)]
|
||||
|
||||
(if browser-ok?
|
||||
(output/print-info "Opening browser for login...")
|
||||
(output/print-info (str "Open this URL in your browser:\n " login-url)))
|
||||
|
||||
(output/print-info "\nIf your browser didn't open, visit:")
|
||||
(output/print-info (str " " login-url))
|
||||
(output/print-info "\nOr paste your session token below:")
|
||||
(output/print-info "(Waiting for callback...)")
|
||||
|
||||
;; Start a parallel thread to accept manual token paste
|
||||
(let [manual-token (promise)]
|
||||
(future
|
||||
(try
|
||||
(when-let [line (read-line)]
|
||||
(when-not (str/blank? line)
|
||||
(deliver manual-token (str/trim line))))
|
||||
(catch Exception _ nil)))
|
||||
|
||||
;; Wait for either callback or manual paste
|
||||
(let [callback-result (deref result 120000 nil)
|
||||
token (or (get callback-result "token")
|
||||
(deref manual-token 100 nil))]
|
||||
(if-not token
|
||||
(do
|
||||
(output/print-error "Login timed out"
|
||||
"No callback received within 2 minutes."
|
||||
"Try 'ajet login --token <token>' instead")
|
||||
3)
|
||||
;; Got a token - save session and verify
|
||||
(let [expires-at (or (get callback-result "expires_at")
|
||||
(str (.plus (Instant/now) (Duration/ofDays 30))))
|
||||
user-id (get callback-result "user_id")
|
||||
username (get callback-result "username")]
|
||||
;; Save initial session
|
||||
(config/save-session! {:token token
|
||||
:user-id user-id
|
||||
:username username
|
||||
:expires-at expires-at})
|
||||
;; Try to verify by fetching /api/me
|
||||
(try
|
||||
(let [ctx (config/make-ctx)
|
||||
me (api/get-me ctx)]
|
||||
(config/save-session! {:token token
|
||||
:user-id (or (:id me) user-id)
|
||||
:username (or (:username me) username)
|
||||
:expires-at expires-at})
|
||||
(if json?
|
||||
(output/print-json me)
|
||||
(output/print-success
|
||||
(str "Logged in as "
|
||||
(or (:display-name me) (:username me))
|
||||
" (" (:username me) ")")))
|
||||
0)
|
||||
(catch Exception _
|
||||
;; Token saved but couldn't verify - that's OK
|
||||
(if json?
|
||||
(output/print-json {:token token :username username})
|
||||
(output/print-success
|
||||
(str "Logged in" (when username (str " as " username)))))
|
||||
0))))))))
|
||||
|
||||
(defn login-token
|
||||
"Login with an API token directly (for scripting).
|
||||
|
||||
Saves the token and verifies it by calling /api/me."
|
||||
[token & [{:keys [json?]}]]
|
||||
(config/save-session! {:token token
|
||||
:user-id nil
|
||||
:username nil
|
||||
:expires-at (str (.plus (Instant/now) (Duration/ofDays 365)))})
|
||||
(try
|
||||
(let [ctx (config/make-ctx)
|
||||
me (api/get-me ctx)]
|
||||
(config/save-session! {:token token
|
||||
:user-id (:id me)
|
||||
:username (:username me)
|
||||
:expires-at (str (.plus (Instant/now) (Duration/ofDays 365)))})
|
||||
(if json?
|
||||
(output/print-json me)
|
||||
(output/print-success
|
||||
(str "Logged in as "
|
||||
(or (:display-name me) (:username me))
|
||||
" (" (:username me) ")")))
|
||||
0)
|
||||
(catch Exception e
|
||||
(let [data (ex-data e)]
|
||||
(if (= :ajet.chat/api-error (:type data))
|
||||
(do
|
||||
(output/print-error "Invalid token"
|
||||
"The provided token was rejected by the server."
|
||||
"Check the token and try again")
|
||||
(config/clear-session!)
|
||||
3)
|
||||
(do
|
||||
(output/print-error "Could not verify token"
|
||||
(.getMessage e)
|
||||
"Token saved, but server may be unreachable")
|
||||
0))))))
|
||||
|
||||
(defn logout
|
||||
"Clear the current session."
|
||||
[& [{:keys [json?]}]]
|
||||
(config/clear-session!)
|
||||
(if json?
|
||||
(output/print-json {:status "logged_out"})
|
||||
(output/print-success "Logged out"))
|
||||
0)
|
||||
|
||||
(defn whoami
|
||||
"Fetch and display current user info."
|
||||
[& [{:keys [json?]}]]
|
||||
(let [ctx (config/make-ctx)
|
||||
me (api/get-me ctx)]
|
||||
(if json?
|
||||
(output/print-json me)
|
||||
(do
|
||||
(output/print-info (str "Username: " (:username me)))
|
||||
(output/print-info (str "Display Name: " (or (:display-name me) "-")))
|
||||
(output/print-info (str "Email: " (or (:email me) "-")))
|
||||
(output/print-info (str "User ID: " (:id me)))
|
||||
(when (:status-text me)
|
||||
(output/print-info (str "Status: " (:status-text me))))))
|
||||
0))
|
||||
@@ -0,0 +1,589 @@
|
||||
(ns ajet.chat.cli.commands
|
||||
"All CLI command implementations.
|
||||
|
||||
Every public function in this namespace follows the pattern:
|
||||
(fn [args opts] ...) -> exit-code (int)
|
||||
|
||||
args: parsed positional arguments (vector of strings)
|
||||
opts: parsed options map (includes :json? flag)
|
||||
|
||||
All API calls go through ajet.chat.shared.api-client.
|
||||
All output goes through ajet.chat.cli.output."
|
||||
(:require [clojure.string :as str]
|
||||
[ajet.chat.cli.config :as config]
|
||||
[ajet.chat.cli.output :as output]
|
||||
[ajet.chat.shared.api-client :as api])
|
||||
(:import [java.io BufferedReader InputStreamReader]))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Helpers
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- resolve-community-id
|
||||
"Resolve a community slug or UUID to a community ID.
|
||||
If community-slug is nil, uses the default community from config/state.
|
||||
Returns the community map or throws."
|
||||
[ctx community-slug]
|
||||
(let [communities (:communities (api/get-communities ctx))
|
||||
slug (or community-slug (config/get-default-community))]
|
||||
(when-not slug
|
||||
(throw (ex-info "No community specified"
|
||||
{:type :ajet.chat/usage-error
|
||||
:hint "Use --community <slug> or set a default with 'ajet config set default-community <slug>'"})))
|
||||
(let [match (first (filter #(or (= (:slug %) slug)
|
||||
(= (str (:id %)) slug))
|
||||
communities))]
|
||||
(when-not match
|
||||
(throw (ex-info (str "Community not found: " slug)
|
||||
{:type :ajet.chat/not-found
|
||||
:hint "Run 'ajet communities' to see available communities"})))
|
||||
match)))
|
||||
|
||||
(defn- resolve-channel
|
||||
"Resolve a channel name to a channel map within a community.
|
||||
Returns the channel map or throws."
|
||||
[ctx community-id channel-name]
|
||||
(let [channels (:channels (api/get-channels ctx community-id))
|
||||
match (first (filter #(or (= (:name %) channel-name)
|
||||
(= (str (:id %)) channel-name))
|
||||
channels))]
|
||||
(when-not match
|
||||
(throw (ex-info (str "Channel not found: #" channel-name)
|
||||
{:type :ajet.chat/not-found
|
||||
:hint "Run 'ajet channels' to see available channels"})))
|
||||
match))
|
||||
|
||||
(defn- read-stdin
|
||||
"Read all of stdin and return as a string."
|
||||
[]
|
||||
(let [reader (BufferedReader. (InputStreamReader. System/in))
|
||||
sb (StringBuilder.)]
|
||||
(loop []
|
||||
(let [line (.readLine reader)]
|
||||
(when line
|
||||
(when (pos? (.length sb))
|
||||
(.append sb "\n"))
|
||||
(.append sb line)
|
||||
(recur))))
|
||||
(str sb)))
|
||||
|
||||
(defn- save-last-community!
|
||||
"Save the last-used community to state."
|
||||
[community-id]
|
||||
(let [state (config/load-state)]
|
||||
(config/save-state! (assoc state :last-community (str community-id)))))
|
||||
|
||||
(defn- save-last-channel!
|
||||
"Save the last-used channel for a community to state."
|
||||
[community-id channel-id]
|
||||
(let [state (config/load-state)]
|
||||
(config/save-state!
|
||||
(assoc-in state [:last-channels (str community-id)] (str channel-id)))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Communities
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn communities
|
||||
"List communities the current user belongs to."
|
||||
[_args {:keys [json?]}]
|
||||
(let [ctx (config/make-ctx)
|
||||
result (api/get-communities ctx)
|
||||
comms (or (:communities result) result)]
|
||||
(if json?
|
||||
(output/print-json result)
|
||||
(if (empty? comms)
|
||||
(output/print-info "You are not a member of any communities.")
|
||||
(output/print-table
|
||||
[["Name" :name] ["Slug" :slug] ["Role" :role] ["Members" :member-count]]
|
||||
comms)))
|
||||
0))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Channels
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn channels
|
||||
"List, join, or leave channels in a community."
|
||||
[_args {:keys [json? community join leave]}]
|
||||
(let [ctx (config/make-ctx)
|
||||
comm (resolve-community-id ctx community)
|
||||
comm-id (:id comm)]
|
||||
(save-last-community! comm-id)
|
||||
(cond
|
||||
join
|
||||
(let [ch (resolve-channel ctx comm-id join)]
|
||||
(api/join-channel ctx (:id ch))
|
||||
(if json?
|
||||
(output/print-json {:joined (:name ch)})
|
||||
(output/print-success (str "Joined #" (:name ch))))
|
||||
0)
|
||||
|
||||
leave
|
||||
(let [ch (resolve-channel ctx comm-id leave)]
|
||||
(api/leave-channel ctx (:id ch))
|
||||
(if json?
|
||||
(output/print-json {:left (:name ch)})
|
||||
(output/print-success (str "Left #" (:name ch))))
|
||||
0)
|
||||
|
||||
:else
|
||||
(let [result (api/get-channels ctx comm-id)
|
||||
chs (or (:channels result) result)]
|
||||
(if json?
|
||||
(output/print-json result)
|
||||
(if (empty? chs)
|
||||
(output/print-info "No channels found.")
|
||||
(output/print-table
|
||||
[["Name" :name] ["Type" :type] ["Topic" :topic] ["Members" :member-count]]
|
||||
(map (fn [ch]
|
||||
(-> ch
|
||||
(update :name #(str "#" %))
|
||||
(update :type #(or (some-> % name) "text"))
|
||||
(update :topic #(or % ""))))
|
||||
chs))))
|
||||
0))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Messages
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn read-messages
|
||||
"Read messages from a channel."
|
||||
[args {:keys [json? community limit before thread]}]
|
||||
(let [channel-name (first args)]
|
||||
(when-not channel-name
|
||||
(throw (ex-info "Channel name required"
|
||||
{:type :ajet.chat/usage-error
|
||||
:hint "Usage: ajet read <channel> [--limit N] [--before ID] [--thread ID]"})))
|
||||
(let [ctx (config/make-ctx)
|
||||
comm (resolve-community-id ctx community)
|
||||
comm-id (:id comm)
|
||||
ch (resolve-channel ctx comm-id channel-name)
|
||||
ch-id (:id ch)]
|
||||
(save-last-community! comm-id)
|
||||
(save-last-channel! comm-id ch-id)
|
||||
(if thread
|
||||
;; Read thread replies
|
||||
(let [result (api/get-thread ctx thread)]
|
||||
(if json?
|
||||
(output/print-json result)
|
||||
(let [messages (or (:messages result) result)]
|
||||
(output/print-info (str "Thread in #" (:name ch)))
|
||||
(output/print-info "")
|
||||
(output/print-messages messages))))
|
||||
;; Read channel messages
|
||||
(let [opts (cond-> {}
|
||||
limit (assoc :limit (parse-long (str limit)))
|
||||
before (assoc :before before))
|
||||
result (api/get-messages ctx ch-id opts)]
|
||||
(if json?
|
||||
(output/print-json result)
|
||||
(let [messages (or (:messages result) result)]
|
||||
(output/print-messages messages
|
||||
{:channel-name (:name ch)
|
||||
:channel-topic (:topic ch)})))))
|
||||
0)))
|
||||
|
||||
(defn send-message
|
||||
"Send a message to a channel."
|
||||
[args {:keys [json? community stdin image]}]
|
||||
(let [channel-name (first args)
|
||||
message-text (if stdin
|
||||
(read-stdin)
|
||||
(str/join " " (rest args)))]
|
||||
(when-not channel-name
|
||||
(throw (ex-info "Channel name required"
|
||||
{:type :ajet.chat/usage-error
|
||||
:hint "Usage: ajet send <channel> <message> [--stdin] [--image <path>]"})))
|
||||
(when (and (not stdin) (str/blank? message-text))
|
||||
(throw (ex-info "Message text required"
|
||||
{:type :ajet.chat/usage-error
|
||||
:hint "Usage: ajet send <channel> <message> or echo 'msg' | ajet send <channel> --stdin"})))
|
||||
(let [ctx (config/make-ctx)
|
||||
comm (resolve-community-id ctx community)
|
||||
comm-id (:id comm)
|
||||
ch (resolve-channel ctx comm-id channel-name)
|
||||
ch-id (:id ch)]
|
||||
(save-last-community! comm-id)
|
||||
(save-last-channel! comm-id ch-id)
|
||||
;; Upload image first if specified
|
||||
(when image
|
||||
(let [file (java.io.File. ^String image)]
|
||||
(when-not (.exists file)
|
||||
(throw (ex-info (str "File not found: " image)
|
||||
{:type :ajet.chat/usage-error})))
|
||||
(let [ext (str/lower-case (or (last (str/split image #"\.")) ""))
|
||||
ct (case ext
|
||||
"png" "image/png"
|
||||
"jpg" "image/jpeg"
|
||||
"jpeg" "image/jpeg"
|
||||
"gif" "image/gif"
|
||||
"webp" "image/webp"
|
||||
(throw (ex-info (str "Unsupported image format: " ext)
|
||||
{:type :ajet.chat/usage-error
|
||||
:hint "Supported: png, jpg, jpeg, gif, webp"})))]
|
||||
(api/upload-file ctx ch-id image ct))))
|
||||
(let [body {:body-md message-text}
|
||||
result (api/send-message ctx ch-id body)]
|
||||
(if json?
|
||||
(output/print-json result)
|
||||
(output/print-success (str "Message sent to #" (:name ch))))
|
||||
0))))
|
||||
|
||||
(defn edit-message
|
||||
"Edit a message by ID."
|
||||
[args {:keys [json?]}]
|
||||
(let [msg-id (first args)
|
||||
new-text (str/join " " (rest args))]
|
||||
(when-not msg-id
|
||||
(throw (ex-info "Message ID required"
|
||||
{:type :ajet.chat/usage-error
|
||||
:hint "Usage: ajet edit <message-id> <new-text>"})))
|
||||
(when (str/blank? new-text)
|
||||
(throw (ex-info "New message text required"
|
||||
{:type :ajet.chat/usage-error
|
||||
:hint "Usage: ajet edit <message-id> <new-text>"})))
|
||||
(let [ctx (config/make-ctx)
|
||||
result (api/edit-message ctx msg-id {:body-md new-text})]
|
||||
(if json?
|
||||
(output/print-json result)
|
||||
(output/print-success "Message edited"))
|
||||
0)))
|
||||
|
||||
(defn delete-message
|
||||
"Delete a message by ID (with confirmation)."
|
||||
[args {:keys [json? force]}]
|
||||
(let [msg-id (first args)]
|
||||
(when-not msg-id
|
||||
(throw (ex-info "Message ID required"
|
||||
{:type :ajet.chat/usage-error
|
||||
:hint "Usage: ajet delete <message-id>"})))
|
||||
(when (and (not json?) (not force))
|
||||
(when-not (output/confirm? "Are you sure you want to delete this message?")
|
||||
(output/print-info "Cancelled.")
|
||||
(throw (ex-info "Cancelled" {:type :ajet.chat/cancelled}))))
|
||||
(let [ctx (config/make-ctx)
|
||||
result (api/delete-message ctx msg-id)]
|
||||
(if json?
|
||||
(output/print-json (or result {:deleted msg-id}))
|
||||
(output/print-success "Message deleted"))
|
||||
0)))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; DMs
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn list-dms
|
||||
"List DM channels."
|
||||
[_args {:keys [json?]}]
|
||||
(let [ctx (config/make-ctx)
|
||||
result (api/get-dms ctx)
|
||||
dms (or (:dms result) (:channels result) result)]
|
||||
(if json?
|
||||
(output/print-json result)
|
||||
(if (empty? dms)
|
||||
(output/print-info "No DM conversations.")
|
||||
(output/print-table
|
||||
[["User" :display-name] ["Username" :username] ["Last Message" :last-message-preview] ["Time" :last-message-at]]
|
||||
(map (fn [dm]
|
||||
(-> dm
|
||||
(update :display-name #(or % (:username dm) ""))
|
||||
(update :username #(or % ""))
|
||||
(update :last-message-preview #(or % ""))
|
||||
(update :last-message-at #(if % (output/relative-time %) ""))))
|
||||
dms))))
|
||||
0))
|
||||
|
||||
(defn send-dm
|
||||
"Send a DM to a user by username."
|
||||
[args {:keys [json? read]}]
|
||||
(let [username (first args)]
|
||||
(when-not username
|
||||
(throw (ex-info "Username required"
|
||||
{:type :ajet.chat/usage-error
|
||||
:hint "Usage: ajet dm <username> <message> or ajet dm <username> --read"})))
|
||||
(let [ctx (config/make-ctx)]
|
||||
(if read
|
||||
;; Read DM conversation
|
||||
(let [dm-result (api/create-dm ctx {:username username})
|
||||
ch-id (or (:id dm-result) (:channel-id dm-result))
|
||||
messages (api/get-messages ctx ch-id {:limit 50})]
|
||||
(if json?
|
||||
(output/print-json messages)
|
||||
(let [msgs (or (:messages messages) messages)]
|
||||
(output/print-info (str "DM with @" username))
|
||||
(output/print-info "")
|
||||
(output/print-messages msgs)))
|
||||
0)
|
||||
;; Send DM
|
||||
(let [message-text (str/join " " (rest args))]
|
||||
(when (str/blank? message-text)
|
||||
(throw (ex-info "Message text required"
|
||||
{:type :ajet.chat/usage-error
|
||||
:hint "Usage: ajet dm <username> <message> or ajet dm <username> --read"})))
|
||||
(let [dm-result (api/create-dm ctx {:username username})
|
||||
ch-id (or (:id dm-result) (:channel-id dm-result))
|
||||
result (api/send-message ctx ch-id {:body-md message-text})]
|
||||
(if json?
|
||||
(output/print-json result)
|
||||
(output/print-success (str "DM sent to @" username)))
|
||||
0))))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Notifications
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn notifications
|
||||
"List or manage notifications."
|
||||
[_args {:keys [json? all mark-read]}]
|
||||
(let [ctx (config/make-ctx)]
|
||||
(if mark-read
|
||||
;; Mark all as read
|
||||
(let [result (api/mark-notifications-read ctx {:all true})]
|
||||
(if json?
|
||||
(output/print-json (or result {:marked-read true}))
|
||||
(output/print-success "All notifications marked as read"))
|
||||
0)
|
||||
;; List notifications
|
||||
(let [opts (if all {} {:unread true})
|
||||
result (api/get-notifications ctx opts)
|
||||
notifs (or (:notifications result) result)]
|
||||
(if json?
|
||||
(output/print-json result)
|
||||
(if (empty? notifs)
|
||||
(output/print-info (if all "No notifications." "No unread notifications."))
|
||||
(doseq [n notifs]
|
||||
(let [type-str (case (keyword (name (or (:type n) "")))
|
||||
:mention "@mention"
|
||||
:dm "DM"
|
||||
:thread-reply "thread reply"
|
||||
:invite "invite"
|
||||
(str (:type n)))
|
||||
read? (:read n)
|
||||
marker (if read? " " "* ")
|
||||
time-str (output/relative-time (:created-at n))]
|
||||
(output/print-info
|
||||
(str marker type-str " - " (or (:preview n) (:source-id n))
|
||||
" " time-str))))))
|
||||
0))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Search
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn search
|
||||
"Search messages and channels with filters."
|
||||
[args {:keys [json? community channel from type]}]
|
||||
(let [query (str/join " " args)]
|
||||
(when (str/blank? query)
|
||||
(throw (ex-info "Search query required"
|
||||
{:type :ajet.chat/usage-error
|
||||
:hint "Usage: ajet search <query> [--channel <ch>] [--from <user>] [--type messages|channels]"})))
|
||||
(let [ctx (config/make-ctx)
|
||||
comm (when (or community channel)
|
||||
(resolve-community-id ctx community))
|
||||
comm-id (when comm (:id comm))
|
||||
ch (when (and channel comm-id)
|
||||
(resolve-channel ctx comm-id channel))
|
||||
opts (cond-> {:q query}
|
||||
comm-id (assoc :community-id comm-id)
|
||||
ch (assoc :channel-id (:id ch))
|
||||
from (assoc :from from)
|
||||
type (assoc :type (keyword type)))
|
||||
result (api/search ctx opts)]
|
||||
(if json?
|
||||
(output/print-json result)
|
||||
(let [results (or (:results result) (:messages result) result)]
|
||||
(if (empty? results)
|
||||
(output/print-info "No results found.")
|
||||
(do
|
||||
(output/print-info (str "Search results for: " query))
|
||||
(output/print-info "")
|
||||
(output/print-messages results {:show-channel true})))))
|
||||
0)))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Presence & Status
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn set-status
|
||||
"Set or show the user's status text."
|
||||
[args {:keys [json?]}]
|
||||
(let [ctx (config/make-ctx)
|
||||
status-text (str/join " " args)]
|
||||
(if (str/blank? status-text)
|
||||
;; Show current status
|
||||
(let [me (api/get-me ctx)]
|
||||
(if json?
|
||||
(output/print-json {:status-text (:status-text me)})
|
||||
(output/print-info
|
||||
(if (:status-text me)
|
||||
(str "Status: " (:status-text me))
|
||||
"No status set.")))
|
||||
0)
|
||||
;; Set status
|
||||
(let [result (api/update-me ctx {:status-text status-text})]
|
||||
(if json?
|
||||
(output/print-json result)
|
||||
(output/print-success (str "Status set to: " status-text)))
|
||||
0))))
|
||||
|
||||
(defn who-online
|
||||
"Show online users in the current community."
|
||||
[_args {:keys [json? community]}]
|
||||
(let [ctx (config/make-ctx)
|
||||
comm (resolve-community-id ctx community)
|
||||
comm-id (:id comm)
|
||||
result (api/get-presence ctx comm-id)
|
||||
users (or (:users result) (:online result) result)]
|
||||
(save-last-community! comm-id)
|
||||
(if json?
|
||||
(output/print-json result)
|
||||
(if (empty? users)
|
||||
(output/print-info "No users online.")
|
||||
(do
|
||||
(output/print-info (str "Online in " (:name comm) ":"))
|
||||
(output/print-info "")
|
||||
(output/print-table
|
||||
[["User" :display-name] ["Username" :username] ["Status" :status-text]]
|
||||
(map (fn [u]
|
||||
(-> u
|
||||
(update :display-name #(or % (:username u) ""))
|
||||
(update :username #(or % ""))
|
||||
(update :status-text #(or % ""))))
|
||||
users)))))
|
||||
0))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Invites
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn invite
|
||||
"Create, list, or revoke invites."
|
||||
[args {:keys [json? community max-uses]}]
|
||||
(let [subcommand (first args)]
|
||||
(case subcommand
|
||||
"create"
|
||||
(let [ctx (config/make-ctx)
|
||||
comm (resolve-community-id ctx community)
|
||||
comm-id (:id comm)
|
||||
body (cond-> {}
|
||||
max-uses (assoc :max-uses (parse-long (str max-uses))))
|
||||
result (api/create-invite ctx comm-id body)]
|
||||
(save-last-community! comm-id)
|
||||
(if json?
|
||||
(output/print-json result)
|
||||
(let [code (or (:code result) (:id result))
|
||||
server-url (config/get-server-url)]
|
||||
(output/print-success "Invite created")
|
||||
(output/print-info (str " Link: " server-url "/invite/" code))
|
||||
(output/print-info (str " Code: " code))
|
||||
(when max-uses
|
||||
(output/print-info (str " Max uses: " max-uses)))))
|
||||
0)
|
||||
|
||||
"list"
|
||||
(let [ctx (config/make-ctx)
|
||||
comm (resolve-community-id ctx community)
|
||||
comm-id (:id comm)
|
||||
result (api/get-invites ctx comm-id)
|
||||
invites (or (:invites result) result)]
|
||||
(save-last-community! comm-id)
|
||||
(if json?
|
||||
(output/print-json result)
|
||||
(if (empty? invites)
|
||||
(output/print-info "No active invites.")
|
||||
(output/print-table
|
||||
[["Code" :code] ["Uses" :uses] ["Max" :max-uses] ["Created" :created-at] ["Expires" :expires-at]]
|
||||
(map (fn [inv]
|
||||
(-> inv
|
||||
(update :uses #(or (str %) "0"))
|
||||
(update :max-uses #(if % (str %) "unlimited"))
|
||||
(update :created-at #(if % (output/relative-time %) ""))
|
||||
(update :expires-at #(if % (output/relative-time %) "never"))))
|
||||
invites))))
|
||||
0)
|
||||
|
||||
"revoke"
|
||||
(let [invite-id (second args)]
|
||||
(when-not invite-id
|
||||
(throw (ex-info "Invite ID required"
|
||||
{:type :ajet.chat/usage-error
|
||||
:hint "Usage: ajet invite revoke <invite-id>"})))
|
||||
(let [ctx (config/make-ctx)
|
||||
result (api/revoke-invite ctx invite-id)]
|
||||
(if json?
|
||||
(output/print-json (or result {:revoked invite-id}))
|
||||
(output/print-success (str "Invite revoked: " invite-id)))
|
||||
0))
|
||||
|
||||
;; Default: unknown subcommand
|
||||
(throw (ex-info (str "Unknown invite subcommand: " (or subcommand ""))
|
||||
{:type :ajet.chat/usage-error
|
||||
:hint "Usage: ajet invite <create|list|revoke>"})))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Config
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn config-cmd
|
||||
"Show or set configuration values."
|
||||
[args {:keys [json?]}]
|
||||
(let [subcommand (first args)]
|
||||
(case subcommand
|
||||
"set"
|
||||
(let [key-name (second args)
|
||||
value (str/join " " (drop 2 args))]
|
||||
(when-not key-name
|
||||
(throw (ex-info "Config key required"
|
||||
{:type :ajet.chat/usage-error
|
||||
:hint "Usage: ajet config set <key> <value>"})))
|
||||
(when (str/blank? value)
|
||||
(throw (ex-info "Config value required"
|
||||
{:type :ajet.chat/usage-error
|
||||
:hint "Usage: ajet config set <key> <value>"})))
|
||||
(let [cfg (config/load-config)
|
||||
kw-key (keyword key-name)
|
||||
;; Coerce value
|
||||
coerced (cond
|
||||
(= "true" value) true
|
||||
(= "false" value) false
|
||||
(re-matches #"\d+" value) (parse-long value)
|
||||
:else value)
|
||||
new-cfg (assoc cfg kw-key coerced)]
|
||||
(config/save-config! new-cfg)
|
||||
(if json?
|
||||
(output/print-json {kw-key coerced})
|
||||
(output/print-success (str "Set " key-name " = " (pr-str coerced))))
|
||||
0))
|
||||
|
||||
"server"
|
||||
(let [url (second args)]
|
||||
(when-not url
|
||||
(throw (ex-info "Server URL required"
|
||||
{:type :ajet.chat/usage-error
|
||||
:hint "Usage: ajet config server <url>"})))
|
||||
(let [cfg (config/load-config)
|
||||
new-cfg (assoc cfg :server-url url)]
|
||||
(config/save-config! new-cfg)
|
||||
(if json?
|
||||
(output/print-json {:server-url url})
|
||||
(output/print-success (str "Server URL set to: " url)))
|
||||
0))
|
||||
|
||||
;; Default: show config
|
||||
(let [cfg (config/load-config)]
|
||||
(if json?
|
||||
(output/print-json cfg)
|
||||
(do
|
||||
(output/print-info "Current configuration:")
|
||||
(output/print-info "")
|
||||
(doseq [[k v] (sort-by key cfg)]
|
||||
(if (map? v)
|
||||
(do
|
||||
(output/print-info (str " " (name k) ":"))
|
||||
(doseq [[k2 v2] (sort-by key v)]
|
||||
(output/print-info (str " " (name k2) ": " (pr-str v2)))))
|
||||
(output/print-info (str " " (name k) ": " (pr-str v)))))))
|
||||
0))))
|
||||
@@ -0,0 +1,182 @@
|
||||
(ns ajet.chat.cli.config
|
||||
"Configuration management for the CLI client.
|
||||
|
||||
Config directory: ~/.config/ajet-chat/
|
||||
Files:
|
||||
config.edn — server URL, default community, preferences
|
||||
session.edn — session token + user info
|
||||
state.edn — last community, last channel per community"
|
||||
(:require [clojure.edn :as edn]
|
||||
[clojure.java.io :as io]
|
||||
[clojure.string :as str])
|
||||
(:import [java.time Instant]))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Paths
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(def ^:private config-dir-path
|
||||
"Path to the config directory."
|
||||
(str (System/getProperty "user.home") "/.config/ajet-chat"))
|
||||
|
||||
(defn- config-file ^java.io.File [filename]
|
||||
(io/file config-dir-path filename))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Directory management
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn ensure-config-dir!
|
||||
"Create the config directory if it does not exist. Returns the path."
|
||||
[]
|
||||
(let [dir (io/file config-dir-path)]
|
||||
(when-not (.exists dir)
|
||||
(.mkdirs dir))
|
||||
config-dir-path))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Generic EDN read/write helpers
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- read-edn-file
|
||||
"Read an EDN file. Returns nil if the file does not exist or is empty."
|
||||
[filename]
|
||||
(let [f (config-file filename)]
|
||||
(when (.exists f)
|
||||
(let [content (slurp f)]
|
||||
(when-not (str/blank? content)
|
||||
(edn/read-string content))))))
|
||||
|
||||
(defn- write-edn-file!
|
||||
"Write data as EDN to a file. Creates the config directory if needed."
|
||||
[filename data]
|
||||
(ensure-config-dir!)
|
||||
(spit (config-file filename) (pr-str data)))
|
||||
|
||||
(defn- delete-file!
|
||||
"Delete a file if it exists."
|
||||
[filename]
|
||||
(let [f (config-file filename)]
|
||||
(when (.exists f)
|
||||
(.delete f))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Session management
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn load-session
|
||||
"Load session.edn. Returns nil if missing or expired.
|
||||
|
||||
Session shape:
|
||||
{:token \"base64url-session-token\"
|
||||
:user-id \"uuid\"
|
||||
:username \"alice\"
|
||||
:expires-at \"2026-03-19T...\"}"
|
||||
[]
|
||||
(when-let [session (read-edn-file "session.edn")]
|
||||
(let [expires-at (:expires-at session)]
|
||||
(if (and expires-at
|
||||
(try
|
||||
(.isBefore (Instant/parse expires-at) (Instant/now))
|
||||
(catch Exception _ false)))
|
||||
nil
|
||||
session))))
|
||||
|
||||
(defn save-session!
|
||||
"Write session data to session.edn.
|
||||
|
||||
data should contain :token, :user-id, :username, and optionally :expires-at."
|
||||
[data]
|
||||
(write-edn-file! "session.edn" data))
|
||||
|
||||
(defn clear-session!
|
||||
"Delete session.edn, effectively logging out."
|
||||
[]
|
||||
(delete-file! "session.edn"))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Config management
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(def ^:private default-config
|
||||
{:server-url "http://localhost:3000"
|
||||
:default-community nil
|
||||
:tui {:theme :dark
|
||||
:image-viewer :timg
|
||||
:mouse true
|
||||
:timestamps :relative
|
||||
:notifications :bell}})
|
||||
|
||||
(defn load-config
|
||||
"Load config.edn, merged with defaults. Returns defaults if file missing."
|
||||
[]
|
||||
(let [file-config (read-edn-file "config.edn")]
|
||||
(if file-config
|
||||
(merge-with (fn [a b]
|
||||
(if (and (map? a) (map? b))
|
||||
(merge a b)
|
||||
b))
|
||||
default-config file-config)
|
||||
default-config)))
|
||||
|
||||
(defn save-config!
|
||||
"Write config data to config.edn."
|
||||
[data]
|
||||
(write-edn-file! "config.edn" data))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; State management
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn load-state
|
||||
"Load state.edn (last community, last channel per community).
|
||||
|
||||
State shape:
|
||||
{:last-community \"uuid\"
|
||||
:last-channels {\"community-uuid\" \"channel-uuid\"}}"
|
||||
[]
|
||||
(or (read-edn-file "state.edn")
|
||||
{:last-community nil
|
||||
:last-channels {}}))
|
||||
|
||||
(defn save-state!
|
||||
"Write state data to state.edn."
|
||||
[data]
|
||||
(write-edn-file! "state.edn" data))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Context builder
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn make-ctx
|
||||
"Build an API client context map from session + config.
|
||||
|
||||
Returns a ctx suitable for ajet.chat.shared.api-client functions:
|
||||
{:base-url \"http://localhost:3000\"
|
||||
:auth-token \"base64url-token\"
|
||||
:user-id \"uuid\"
|
||||
:trace-id \"uuid\"}
|
||||
|
||||
Throws ex-info with :type :ajet.chat/auth-error if no valid session."
|
||||
[]
|
||||
(let [config (load-config)
|
||||
session (load-session)]
|
||||
(when-not session
|
||||
(throw (ex-info "Not logged in"
|
||||
{:type :ajet.chat/auth-error
|
||||
:hint "Run 'ajet login' to sign in"})))
|
||||
{:base-url (:server-url config)
|
||||
:auth-token (:token session)
|
||||
:user-id (:user-id session)
|
||||
:trace-id (str (java.util.UUID/randomUUID))}))
|
||||
|
||||
(defn get-server-url
|
||||
"Get the configured server URL."
|
||||
[]
|
||||
(:server-url (load-config)))
|
||||
|
||||
(defn get-default-community
|
||||
"Get the default community slug from config, or the last-used community from state."
|
||||
[]
|
||||
(or (:default-community (load-config))
|
||||
(:last-community (load-state))))
|
||||
@@ -1,5 +1,400 @@
|
||||
(ns ajet.chat.cli.core
|
||||
"CLI client using clojure-tui.")
|
||||
"Main entry point and command dispatcher for the ajet CLI client.
|
||||
|
||||
(defn -main [& _args]
|
||||
(println "ajet-chat CLI starting..."))
|
||||
Parses args with tools.cli, dispatches to subcommands:
|
||||
login, logout, whoami, communities, channels, read, send, edit, delete,
|
||||
dms, dm, notifications, search, status, who, invite, config, tui
|
||||
|
||||
Global options:
|
||||
--json Output JSON (for scripting)
|
||||
--help Show usage help
|
||||
|
||||
Exit codes:
|
||||
0 = success
|
||||
1 = general error
|
||||
2 = usage error
|
||||
3 = auth error
|
||||
4 = permission error
|
||||
5 = not found
|
||||
130 = SIGINT"
|
||||
(:require [clojure.tools.cli :as cli]
|
||||
[ajet.chat.cli.auth :as auth]
|
||||
[ajet.chat.cli.commands :as commands]
|
||||
[ajet.chat.cli.config :as config]
|
||||
[ajet.chat.cli.output :as output]
|
||||
[ajet.chat.cli.tui :as tui])
|
||||
(:gen-class))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; CLI option specs
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(def ^:private global-options
|
||||
[["-j" "--json" "Output JSON (for scripting)"]
|
||||
["-h" "--help" "Show help"]])
|
||||
|
||||
(def ^:private login-options
|
||||
[["-t" "--token TOKEN" "Login with an API token directly"]
|
||||
["-j" "--json" "Output JSON"]
|
||||
["-h" "--help" "Show help"]])
|
||||
|
||||
(def ^:private channels-options
|
||||
[["-c" "--community SLUG" "Community slug"]
|
||||
[nil "--join CHANNEL" "Join a channel by name"]
|
||||
[nil "--leave CHANNEL" "Leave a channel by name"]
|
||||
["-j" "--json" "Output JSON"]
|
||||
["-h" "--help" "Show help"]])
|
||||
|
||||
(def ^:private read-options
|
||||
[["-c" "--community SLUG" "Community slug"]
|
||||
["-l" "--limit N" "Number of messages to fetch" :default 50 :parse-fn parse-long]
|
||||
["-b" "--before ID" "Fetch messages before this message ID"]
|
||||
["-t" "--thread ID" "Read thread replies for a message"]
|
||||
["-j" "--json" "Output JSON"]
|
||||
["-h" "--help" "Show help"]])
|
||||
|
||||
(def ^:private send-options
|
||||
[["-c" "--community SLUG" "Community slug"]
|
||||
["-s" "--stdin" "Read message from stdin"]
|
||||
["-i" "--image PATH" "Attach an image file"]
|
||||
["-j" "--json" "Output JSON"]
|
||||
["-h" "--help" "Show help"]])
|
||||
|
||||
(def ^:private edit-options
|
||||
[["-j" "--json" "Output JSON"]
|
||||
["-h" "--help" "Show help"]])
|
||||
|
||||
(def ^:private delete-options
|
||||
[["-f" "--force" "Skip confirmation"]
|
||||
["-j" "--json" "Output JSON"]
|
||||
["-h" "--help" "Show help"]])
|
||||
|
||||
(def ^:private dm-options
|
||||
[["-r" "--read" "Read DM conversation instead of sending"]
|
||||
["-j" "--json" "Output JSON"]
|
||||
["-h" "--help" "Show help"]])
|
||||
|
||||
(def ^:private notifications-options
|
||||
[["-a" "--all" "Show all notifications (not just unread)"]
|
||||
["-m" "--mark-read" "Mark all notifications as read"]
|
||||
["-j" "--json" "Output JSON"]
|
||||
["-h" "--help" "Show help"]])
|
||||
|
||||
(def ^:private search-options
|
||||
[["-c" "--community SLUG" "Community slug"]
|
||||
[nil "--channel CHANNEL" "Search in specific channel"]
|
||||
[nil "--from USER" "Search by author"]
|
||||
[nil "--type TYPE" "Filter by type (messages, channels)"]
|
||||
["-j" "--json" "Output JSON"]
|
||||
["-h" "--help" "Show help"]])
|
||||
|
||||
(def ^:private status-options
|
||||
[["-j" "--json" "Output JSON"]
|
||||
["-h" "--help" "Show help"]])
|
||||
|
||||
(def ^:private who-options
|
||||
[["-c" "--community SLUG" "Community slug"]
|
||||
["-j" "--json" "Output JSON"]
|
||||
["-h" "--help" "Show help"]])
|
||||
|
||||
(def ^:private invite-options
|
||||
[["-c" "--community SLUG" "Community slug"]
|
||||
[nil "--max-uses N" "Maximum number of invite uses" :parse-fn parse-long]
|
||||
["-j" "--json" "Output JSON"]
|
||||
["-h" "--help" "Show help"]])
|
||||
|
||||
(def ^:private config-options
|
||||
[["-j" "--json" "Output JSON"]
|
||||
["-h" "--help" "Show help"]])
|
||||
|
||||
(def ^:private tui-options
|
||||
[["-c" "--community SLUG" "Open to specific community"]
|
||||
[nil "--channel CHANNEL" "Open to specific channel"]
|
||||
["-h" "--help" "Show help"]])
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Usage / help text
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(def ^:private program-name "ajet")
|
||||
|
||||
(def ^:private usage-header
|
||||
(str program-name " - ajet chat CLI client\n"
|
||||
"\n"
|
||||
"Usage: " program-name " <command> [options] [args]\n"
|
||||
"\n"
|
||||
"Commands:\n"
|
||||
" login Login via OAuth or API token\n"
|
||||
" logout Clear session\n"
|
||||
" whoami Show current user info\n"
|
||||
" communities List communities\n"
|
||||
" channels List/join/leave channels\n"
|
||||
" read <channel> Read messages in a channel\n"
|
||||
" send <channel> Send a message\n"
|
||||
" edit <id> <text> Edit a message\n"
|
||||
" delete <id> Delete a message\n"
|
||||
" dms List DM conversations\n"
|
||||
" dm <user> <text> Send a DM\n"
|
||||
" notifications Manage notifications\n"
|
||||
" search <query> Search messages\n"
|
||||
" status [text] Show/set status\n"
|
||||
" who Show online users\n"
|
||||
" invite Manage invites (create/list/revoke)\n"
|
||||
" config Show/set configuration\n"
|
||||
" tui Launch interactive TUI\n"
|
||||
"\n"
|
||||
"Global options:\n"
|
||||
" -j, --json Output JSON (for scripting)\n"
|
||||
" -h, --help Show help\n"
|
||||
"\n"
|
||||
"Run '" program-name " <command> --help' for command-specific options."))
|
||||
|
||||
(defn- command-usage
|
||||
"Generate usage text for a specific command."
|
||||
[command options-spec description]
|
||||
(let [{:keys [summary]} (cli/parse-opts [] options-spec)]
|
||||
(str "Usage: " program-name " " command " " description "\n"
|
||||
"\n"
|
||||
"Options:\n"
|
||||
summary)))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Error handling
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- api-error->exit-code
|
||||
"Map an API error status to an exit code."
|
||||
[status]
|
||||
(cond
|
||||
(= status 401) 3
|
||||
(= status 403) 4
|
||||
(= status 404) 5
|
||||
(>= status 500) 1
|
||||
:else 1))
|
||||
|
||||
(defn- api-error->hint
|
||||
"Generate a hint for common API error statuses."
|
||||
[status]
|
||||
(case status
|
||||
401 "Run 'ajet login' to sign in"
|
||||
403 "You don't have permission for this action"
|
||||
404 nil
|
||||
429 "Too many requests. Wait a moment and try again"
|
||||
nil))
|
||||
|
||||
(defn- handle-error
|
||||
"Handle an exception and return an exit code."
|
||||
[e]
|
||||
(let [data (ex-data e)]
|
||||
(case (:type data)
|
||||
:ajet.chat/api-error
|
||||
(let [status (:status data)
|
||||
body (:body data)
|
||||
msg (or (:message body) (:error body) (.getMessage e))
|
||||
detail (or (:detail body) (:details body))
|
||||
hint (or (:hint data) (api-error->hint status))]
|
||||
(output/print-error msg detail hint)
|
||||
(api-error->exit-code status))
|
||||
|
||||
:ajet.chat/auth-error
|
||||
(do
|
||||
(output/print-error (.getMessage e)
|
||||
"No session token found. You need to authenticate first."
|
||||
(or (:hint data) "Run 'ajet login' to sign in"))
|
||||
3)
|
||||
|
||||
:ajet.chat/usage-error
|
||||
(do
|
||||
(output/print-error (.getMessage e) nil (:hint data))
|
||||
2)
|
||||
|
||||
:ajet.chat/not-found
|
||||
(do
|
||||
(output/print-error (.getMessage e) nil (:hint data))
|
||||
5)
|
||||
|
||||
:ajet.chat/validation-error
|
||||
(do
|
||||
(output/print-error (.getMessage e) (:explain data) nil)
|
||||
2)
|
||||
|
||||
:ajet.chat/cancelled
|
||||
0
|
||||
|
||||
;; Unknown ex-info
|
||||
(if data
|
||||
(do
|
||||
(output/print-error (.getMessage e) (pr-str data) nil)
|
||||
1)
|
||||
;; Regular exception
|
||||
(do
|
||||
(output/print-error (.getMessage e)
|
||||
nil
|
||||
"If this persists, check your server connection with 'ajet config'")
|
||||
1)))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Command dispatch
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- parse-and-dispatch
|
||||
"Parse command-specific options and dispatch to the handler."
|
||||
[handler-fn raw-args option-spec]
|
||||
(let [{:keys [options arguments errors summary]} (cli/parse-opts raw-args option-spec)]
|
||||
(cond
|
||||
(:help options)
|
||||
(do (println summary) 0)
|
||||
|
||||
errors
|
||||
(do
|
||||
(doseq [err errors]
|
||||
(output/print-error err))
|
||||
2)
|
||||
|
||||
:else
|
||||
(handler-fn arguments (assoc options :json? (:json options))))))
|
||||
|
||||
(defn- dispatch
|
||||
"Dispatch to the appropriate command handler."
|
||||
[command raw-args]
|
||||
(case command
|
||||
"login"
|
||||
(let [{:keys [options errors]} (cli/parse-opts raw-args login-options)]
|
||||
(cond
|
||||
errors (do (doseq [e errors] (output/print-error e)) 2)
|
||||
(:help options) (do (println (command-usage "login" login-options "[options]")) 0)
|
||||
(:token options) (auth/login-token (:token options) {:json? (:json options)})
|
||||
:else (auth/login-interactive {:json? (:json options)})))
|
||||
|
||||
"logout"
|
||||
(let [{:keys [options]} (cli/parse-opts raw-args global-options)]
|
||||
(if (:help options)
|
||||
(do (println (command-usage "logout" global-options "")) 0)
|
||||
(auth/logout {:json? (:json options)})))
|
||||
|
||||
"whoami"
|
||||
(let [{:keys [options]} (cli/parse-opts raw-args global-options)]
|
||||
(if (:help options)
|
||||
(do (println (command-usage "whoami" global-options "")) 0)
|
||||
(auth/whoami {:json? (:json options)})))
|
||||
|
||||
"communities"
|
||||
(parse-and-dispatch commands/communities raw-args global-options)
|
||||
|
||||
"channels"
|
||||
(parse-and-dispatch commands/channels raw-args channels-options)
|
||||
|
||||
"read"
|
||||
(parse-and-dispatch commands/read-messages raw-args read-options)
|
||||
|
||||
"send"
|
||||
(parse-and-dispatch commands/send-message raw-args send-options)
|
||||
|
||||
"edit"
|
||||
(parse-and-dispatch commands/edit-message raw-args edit-options)
|
||||
|
||||
"delete"
|
||||
(parse-and-dispatch commands/delete-message raw-args delete-options)
|
||||
|
||||
"dms"
|
||||
(parse-and-dispatch commands/list-dms raw-args global-options)
|
||||
|
||||
"dm"
|
||||
(parse-and-dispatch commands/send-dm raw-args dm-options)
|
||||
|
||||
"notifications"
|
||||
(parse-and-dispatch commands/notifications raw-args notifications-options)
|
||||
|
||||
"search"
|
||||
(parse-and-dispatch commands/search raw-args search-options)
|
||||
|
||||
"status"
|
||||
(parse-and-dispatch commands/set-status raw-args status-options)
|
||||
|
||||
"who"
|
||||
(parse-and-dispatch commands/who-online raw-args who-options)
|
||||
|
||||
"invite"
|
||||
(parse-and-dispatch commands/invite raw-args invite-options)
|
||||
|
||||
"config"
|
||||
(parse-and-dispatch commands/config-cmd raw-args config-options)
|
||||
|
||||
"tui"
|
||||
(let [{:keys [options errors]} (cli/parse-opts raw-args tui-options)]
|
||||
(cond
|
||||
errors (do (doseq [e errors] (output/print-error e)) 2)
|
||||
(:help options) (do (println (command-usage "tui" tui-options "[options]")) 0)
|
||||
:else (tui/launch! {:community (:community options)
|
||||
:channel (:channel options)})))
|
||||
|
||||
;; Unknown command
|
||||
(do
|
||||
(output/print-error (str "Unknown command: " command)
|
||||
nil
|
||||
(str "Run '" program-name " --help' to see available commands"))
|
||||
2)))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Main entry point
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn -main
|
||||
"Main entry point. Parses arguments and dispatches to commands."
|
||||
[& args]
|
||||
;; Register SIGINT handler
|
||||
(let [original-handler (Thread/getDefaultUncaughtExceptionHandler)]
|
||||
(.addShutdownHook (Runtime/getRuntime)
|
||||
(Thread. ^Runnable (fn []
|
||||
;; Restore terminal on shutdown (in case TUI was running)
|
||||
(print "\033[?25h\033[?1049l")
|
||||
(flush)))))
|
||||
|
||||
(let [args (vec args)]
|
||||
(if (empty? args)
|
||||
;; No arguments: show help
|
||||
(do
|
||||
(println usage-header)
|
||||
(System/exit 0))
|
||||
|
||||
;; Parse global options first to check for --help
|
||||
(let [{:keys [options arguments]} (cli/parse-opts args global-options :in-order true)]
|
||||
(cond
|
||||
(:help options)
|
||||
(do
|
||||
(println usage-header)
|
||||
(System/exit 0))
|
||||
|
||||
(empty? arguments)
|
||||
(do
|
||||
(println usage-header)
|
||||
(System/exit 0))
|
||||
|
||||
:else
|
||||
(let [command (first arguments)
|
||||
cmd-args (vec (rest arguments))
|
||||
;; Merge global --json flag into remaining args if present
|
||||
all-args (if (:json options)
|
||||
(into ["--json"] cmd-args)
|
||||
cmd-args)
|
||||
exit-code (try
|
||||
(dispatch command all-args)
|
||||
(catch clojure.lang.ExceptionInfo e
|
||||
(handle-error e))
|
||||
(catch java.net.ConnectException _e
|
||||
(output/print-error
|
||||
(str "Cannot connect to server at " (config/get-server-url))
|
||||
"The server may be down or the URL may be incorrect."
|
||||
"Check your config with 'ajet config'")
|
||||
1)
|
||||
(catch java.net.SocketTimeoutException _e
|
||||
(output/print-error
|
||||
"Request timed out"
|
||||
"The server took too long to respond."
|
||||
"Try again, or check your connection")
|
||||
1)
|
||||
(catch InterruptedException _e
|
||||
130)
|
||||
(catch Exception e
|
||||
(handle-error e)))]
|
||||
(System/exit (or exit-code 0))))))))
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
(ns ajet.chat.cli.output
|
||||
"Output formatting for the CLI client.
|
||||
|
||||
All terminal output goes through this module for consistent formatting.
|
||||
Supports human-readable and JSON output modes."
|
||||
(:require [clojure.data.json :as json]
|
||||
[clojure.string :as str]
|
||||
[ajet.chat.shared.markdown :as markdown]
|
||||
[ajet.chat.shared.mentions :as mentions])
|
||||
(:import [java.time Instant Duration ZoneId]
|
||||
[java.time.format DateTimeFormatter]))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; ANSI color helpers
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(def ^:private ansi
|
||||
{:reset "\033[0m"
|
||||
:bold "\033[1m"
|
||||
:dim "\033[2m"
|
||||
:italic "\033[3m"
|
||||
:underline "\033[4m"
|
||||
:cyan "\033[36m"
|
||||
:green "\033[32m"
|
||||
:yellow "\033[33m"
|
||||
:red "\033[31m"
|
||||
:magenta "\033[35m"
|
||||
:blue "\033[34m"
|
||||
:gray "\033[90m"
|
||||
:white "\033[37m"
|
||||
:bg-red "\033[41m"})
|
||||
|
||||
(defn- colorize [color text]
|
||||
(str (get ansi color "") text (:reset ansi)))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Timestamp formatting
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(def ^:private local-zone (ZoneId/systemDefault))
|
||||
|
||||
(def ^:private time-fmt
|
||||
(DateTimeFormatter/ofPattern "h:mm a"))
|
||||
|
||||
(def ^:private date-fmt
|
||||
(DateTimeFormatter/ofPattern "MMM d, yyyy"))
|
||||
|
||||
(def ^:private datetime-fmt
|
||||
(DateTimeFormatter/ofPattern "MMM d, yyyy h:mm a"))
|
||||
|
||||
(defn- parse-timestamp
|
||||
"Parse a timestamp string to an Instant. Handles ISO-8601 strings."
|
||||
[ts]
|
||||
(when ts
|
||||
(try
|
||||
(if (instance? Instant ts)
|
||||
ts
|
||||
(Instant/parse (str ts)))
|
||||
(catch Exception _ nil))))
|
||||
|
||||
(defn relative-time
|
||||
"Format a timestamp as a relative time string.
|
||||
Returns: \"just now\", \"5m ago\", \"2h ago\", \"yesterday\", or a date."
|
||||
[ts]
|
||||
(if-let [instant (parse-timestamp ts)]
|
||||
(let [now (Instant/now)
|
||||
duration (Duration/between instant now)
|
||||
seconds (.toSeconds duration)
|
||||
minutes (.toMinutes duration)
|
||||
hours (.toHours duration)
|
||||
days (.toDays duration)
|
||||
zdt (.atZone instant local-zone)]
|
||||
(cond
|
||||
(< seconds 60) "just now"
|
||||
(< minutes 60) (str minutes "m ago")
|
||||
(< hours 24) (str hours "h ago")
|
||||
(= days 1) "yesterday"
|
||||
(< days 7) (str days "d ago")
|
||||
:else (.format zdt date-fmt)))
|
||||
""))
|
||||
|
||||
(defn- format-time
|
||||
"Format a timestamp as a local time (e.g., '10:30 AM')."
|
||||
[ts]
|
||||
(if-let [instant (parse-timestamp ts)]
|
||||
(let [zdt (.atZone instant local-zone)]
|
||||
(.format zdt time-fmt))
|
||||
""))
|
||||
|
||||
(defn- format-datetime
|
||||
"Format a timestamp as a local date and time."
|
||||
[ts]
|
||||
(if-let [instant (parse-timestamp ts)]
|
||||
(let [zdt (.atZone instant local-zone)]
|
||||
(.format zdt datetime-fmt))
|
||||
""))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Mention rendering
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- default-mention-lookup
|
||||
"Default lookup function for mentions. Returns the ID as a fallback."
|
||||
[_type id]
|
||||
id)
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Message formatting
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- render-body
|
||||
"Render a message body with markdown and mentions for terminal display."
|
||||
[body-md & [mention-lookup]]
|
||||
(let [lookup (or mention-lookup default-mention-lookup)
|
||||
with-mentions (mentions/render body-md lookup)]
|
||||
(markdown/->ansi with-mentions)))
|
||||
|
||||
(defn- format-attachments
|
||||
"Format attachment list for display."
|
||||
[attachments]
|
||||
(when (seq attachments)
|
||||
(str/join "\n"
|
||||
(map (fn [att]
|
||||
(str " " (colorize :cyan (str "[" (:content-type att "file") ": " (:filename att) "]"))))
|
||||
attachments))))
|
||||
|
||||
(defn- format-reactions
|
||||
"Format reactions for display."
|
||||
[reactions]
|
||||
(when (seq reactions)
|
||||
(let [grouped (group-by :emoji reactions)]
|
||||
(str " "
|
||||
(str/join " "
|
||||
(map (fn [[emoji reacts]]
|
||||
(str emoji " " (count reacts)))
|
||||
grouped))))))
|
||||
|
||||
(defn print-message
|
||||
"Format and print a single message for terminal display.
|
||||
|
||||
Message shape:
|
||||
{:id, :user-id, :username, :display-name, :body-md, :created-at,
|
||||
:edited-at, :attachments, :reactions, :thread-count, :parent-id}"
|
||||
[msg & [{:keys [mention-lookup show-channel]}]]
|
||||
(let [author (or (:display-name msg) (:username msg) "unknown")
|
||||
time-str (relative-time (:created-at msg))
|
||||
edited? (:edited-at msg)
|
||||
channel (when show-channel
|
||||
(str (colorize :cyan (str "#" (:channel-name msg))) " "))
|
||||
header (str " " (colorize :bold author) " "
|
||||
(colorize :gray time-str)
|
||||
(when edited? (colorize :gray " (edited)")))
|
||||
body (render-body (:body-md msg) mention-lookup)
|
||||
indented (str/join "\n" (map #(str " " %) (str/split-lines body)))
|
||||
atts (format-attachments (:attachments msg))
|
||||
reacts (format-reactions (:reactions msg))
|
||||
thread-ct (when (and (:thread-count msg) (pos? (:thread-count msg)))
|
||||
(str " " (colorize :blue (str (:thread-count msg) " replies"))))]
|
||||
(println (str (when channel (str channel "\n")) header))
|
||||
(println indented)
|
||||
(when atts (println atts))
|
||||
(when reacts (println reacts))
|
||||
(when thread-ct (println thread-ct))))
|
||||
|
||||
(defn print-messages
|
||||
"Format and print a list of messages with grouping.
|
||||
|
||||
Messages from the same user within 5 minutes are grouped together
|
||||
(only the first gets the full header)."
|
||||
[messages & [{:keys [mention-lookup channel-name channel-topic] :as opts}]]
|
||||
(when channel-name
|
||||
(println (str (colorize :bold (str "#" channel-name))
|
||||
(when channel-topic
|
||||
(str (colorize :gray (str " -- " channel-topic))))))
|
||||
(println))
|
||||
(loop [msgs messages
|
||||
prev-author nil
|
||||
prev-time nil]
|
||||
(when (seq msgs)
|
||||
(let [msg (first msgs)
|
||||
author (or (:username msg) (:user-id msg))
|
||||
msg-time (parse-timestamp (:created-at msg))
|
||||
same-group? (and (= author prev-author)
|
||||
prev-time
|
||||
msg-time
|
||||
(< (.toMinutes (Duration/between prev-time msg-time)) 5))
|
||||
display-name (or (:display-name msg) (:username msg) "unknown")
|
||||
time-str (relative-time (:created-at msg))
|
||||
edited? (:edited-at msg)]
|
||||
(if same-group?
|
||||
;; Grouped: just the body, indented
|
||||
(let [body (render-body (:body-md msg) mention-lookup)
|
||||
indented (str/join "\n" (map #(str " " %) (str/split-lines body)))]
|
||||
(println indented)
|
||||
(when-let [atts (format-attachments (:attachments msg))]
|
||||
(println atts))
|
||||
(when-let [reacts (format-reactions (:reactions msg))]
|
||||
(println reacts)))
|
||||
;; New group: full header
|
||||
(do
|
||||
(when prev-author (println)) ;; blank line between groups
|
||||
(print-message msg opts)))
|
||||
(recur (rest msgs) author (or msg-time prev-time))))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Table formatting
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn print-table
|
||||
"Print a simple table from a list of maps.
|
||||
|
||||
columns is a vector of [header-string key-keyword] pairs.
|
||||
Example: [[\"Name\" :name] [\"Slug\" :slug] [\"Members\" :member-count]]"
|
||||
[columns rows]
|
||||
(when (seq rows)
|
||||
(let [headers (mapv first columns)
|
||||
keys (mapv second columns)
|
||||
str-rows (mapv (fn [row]
|
||||
(mapv (fn [k] (str (get row k ""))) keys))
|
||||
rows)
|
||||
all-rows (cons headers str-rows)
|
||||
col-widths (reduce (fn [widths row]
|
||||
(mapv (fn [w cell] (max w (count cell))) widths row))
|
||||
(mapv (constantly 0) headers)
|
||||
all-rows)
|
||||
fmt-row (fn [row]
|
||||
(str/join " "
|
||||
(map-indexed (fn [i cell]
|
||||
(let [w (nth col-widths i)]
|
||||
(format (str "%-" w "s") cell)))
|
||||
row)))]
|
||||
;; Header
|
||||
(println (colorize :bold (fmt-row headers)))
|
||||
;; Separator
|
||||
(println (str/join " " (map #(apply str (repeat % "-")) col-widths)))
|
||||
;; Data rows
|
||||
(doseq [row str-rows]
|
||||
(println (fmt-row row))))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Error formatting
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn print-error
|
||||
"Format and print an error message with optional hint.
|
||||
|
||||
Format:
|
||||
Error: <short description>
|
||||
|
||||
<details>
|
||||
|
||||
Hint: <actionable next step>"
|
||||
([message]
|
||||
(print-error message nil nil))
|
||||
([message details]
|
||||
(print-error message details nil))
|
||||
([message details hint]
|
||||
(binding [*out* *err*]
|
||||
(println (str (colorize :red "Error: ") message))
|
||||
(when details
|
||||
(println)
|
||||
(println (str " " details)))
|
||||
(when hint
|
||||
(println)
|
||||
(println (str (colorize :yellow "Hint: ") hint))))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; JSON output
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn print-json
|
||||
"Print data as formatted JSON. Used with --json flag."
|
||||
[data]
|
||||
(println (json/write-str data)))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Miscellaneous
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn print-success
|
||||
"Print a success message."
|
||||
[message]
|
||||
(println (str (colorize :green "OK") " " message)))
|
||||
|
||||
(defn print-info
|
||||
"Print an informational message."
|
||||
[message]
|
||||
(println message))
|
||||
|
||||
(defn confirm?
|
||||
"Ask for confirmation. Returns true if user types 'y' or 'yes'."
|
||||
[prompt]
|
||||
(print (str prompt " [y/N] "))
|
||||
(flush)
|
||||
(let [input (str/trim (or (read-line) ""))]
|
||||
(contains? #{"y" "yes" "Y" "Yes" "YES"} input)))
|
||||
@@ -0,0 +1,748 @@
|
||||
(ns ajet.chat.cli.tui
|
||||
"TUI mode — full interactive terminal application using clojure-tui Elm architecture.
|
||||
|
||||
Launched with `ajet tui`. Connects to the TUI session manager via SSE
|
||||
for real-time events, and uses the API client for data operations.
|
||||
|
||||
Architecture: clojure-tui's Elm model (init/update/view) manages all state.
|
||||
SSE events arrive via a shared LinkedBlockingQueue polled every 100ms.
|
||||
API calls are dispatched as futures that write results back to the queue.
|
||||
|
||||
Layout (4 panes via :col/:row):
|
||||
Header — app name, community name, connection status, username
|
||||
Sidebar — communities, channels, DMs with unread counts
|
||||
Content — scrollable message list + typing indicator
|
||||
Input — message composition line
|
||||
Status — keybindings and focus indicator
|
||||
|
||||
TODO: clojure-tui gaps — features that cannot be implemented with
|
||||
the current library (see cli/README.md for full details):
|
||||
|
||||
1. Mouse support (PRD 4.4) — no mouse tracking/parsing in clojure-tui
|
||||
2. Inline image rendering (PRD 4.5) — no timg/sixel/kitty support
|
||||
3. Multiline text input (PRD 4.4) — :input widget is single-line only
|
||||
4. Autocomplete dropdowns (PRD 4.4) — no @mention/#channel//cmd popups
|
||||
5. SSE client integration (PRD 4.7) — workaround: external queue + polling
|
||||
6. Terminal bell (PRD 4.8) — trivial but outside render model
|
||||
7. OSC 8 hyperlinks (PRD 4.6) — no OSC 8 in tui.ansi
|
||||
8. Spoiler text reveal (PRD 4.6) — needs per-message hidden state"
|
||||
(:require [tui.core :as tui]
|
||||
[tui.events :as ev]
|
||||
[tui.ansi :as ansi]
|
||||
[babashka.http-client :as http]
|
||||
[clojure.data.json :as json]
|
||||
[clojure.string :as str]
|
||||
[ajet.chat.cli.config :as config]
|
||||
[ajet.chat.shared.api-client :as api]
|
||||
[ajet.chat.shared.markdown :as markdown]
|
||||
[ajet.chat.shared.mentions :as mentions])
|
||||
(:import [java.io BufferedReader InputStreamReader]
|
||||
[java.net HttpURLConnection URL]
|
||||
[java.util ArrayList]
|
||||
[java.util.concurrent LinkedBlockingQueue]))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; SSE parsing (pure)
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- parse-sse-event
|
||||
"Parse accumulated SSE lines into {:event type :data parsed-json}."
|
||||
[lines]
|
||||
(when (seq lines)
|
||||
(let [result (reduce
|
||||
(fn [acc line]
|
||||
(cond
|
||||
(str/starts-with? line "event:")
|
||||
(assoc acc :event (str/trim (subs line 6)))
|
||||
(str/starts-with? line "data:")
|
||||
(update acc :data-lines conj (str/trim (subs line 5)))
|
||||
(str/starts-with? line "id:")
|
||||
(assoc acc :id (str/trim (subs line 3)))
|
||||
:else acc))
|
||||
{:event "message" :data-lines [] :id nil}
|
||||
lines)
|
||||
data-str (str/join "\n" (:data-lines result))]
|
||||
(when-not (str/blank? data-str)
|
||||
{:event (:event result)
|
||||
:data (try (json/read-str data-str :key-fn keyword)
|
||||
(catch Exception _ {:raw data-str}))
|
||||
:id (:id result)}))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; SSE background thread (writes to shared queue)
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- connect-sse
|
||||
"Open SSE connection. Returns {:connection :reader} or nil."
|
||||
[base-url token community-id]
|
||||
(let [url-str (str base-url "/tui/sse/events"
|
||||
(when community-id
|
||||
(str "?community_id=" community-id)))]
|
||||
(try
|
||||
(let [url (URL. url-str)
|
||||
conn ^HttpURLConnection (.openConnection url)]
|
||||
(.setRequestMethod conn "GET")
|
||||
(.setRequestProperty conn "Accept" "text/event-stream")
|
||||
(.setRequestProperty conn "Authorization" (str "Bearer " token))
|
||||
(.setRequestProperty conn "Cache-Control" "no-cache")
|
||||
(.setConnectTimeout conn 10000)
|
||||
(.setReadTimeout conn 0)
|
||||
(.connect conn)
|
||||
(when (= 200 (.getResponseCode conn))
|
||||
{:connection conn
|
||||
:reader (BufferedReader.
|
||||
(InputStreamReader. (.getInputStream conn)))}))
|
||||
(catch Exception _ nil))))
|
||||
|
||||
(defn- sse-read-loop!
|
||||
"Read SSE stream, put parsed events on queue. Blocks until disconnect."
|
||||
[^BufferedReader reader ^LinkedBlockingQueue queue running?]
|
||||
(try
|
||||
(loop [lines []]
|
||||
(when @running?
|
||||
(let [line (.readLine reader)]
|
||||
(if (nil? line)
|
||||
(.put queue {:type :sse :event "disconnected" :data {}})
|
||||
(if (str/blank? line)
|
||||
(do
|
||||
(when-let [event (parse-sse-event lines)]
|
||||
(.put queue {:type :sse
|
||||
:event (:event event)
|
||||
:data (:data event)}))
|
||||
(recur []))
|
||||
(recur (conj lines line)))))))
|
||||
(catch Exception _
|
||||
(.put queue {:type :sse :event "disconnected" :data {}}))))
|
||||
|
||||
(defn- start-sse-thread!
|
||||
"Start background SSE connection manager with auto-reconnect."
|
||||
[base-url token ^LinkedBlockingQueue queue running?]
|
||||
(future
|
||||
(loop [backoff 1000]
|
||||
(when @running?
|
||||
(let [conn-info (connect-sse base-url token nil)]
|
||||
(if conn-info
|
||||
(do
|
||||
(.put queue {:type :sse-status :connected true :reconnecting false})
|
||||
(sse-read-loop! (:reader conn-info) queue running?)
|
||||
(try (.close ^java.io.Closeable (:reader conn-info))
|
||||
(catch Exception _ nil))
|
||||
(when @running?
|
||||
(.put queue {:type :sse-status :connected false :reconnecting true})
|
||||
(Thread/sleep 1000)
|
||||
(recur 1000)))
|
||||
(do
|
||||
(.put queue {:type :sse-status :connected false :reconnecting true})
|
||||
(Thread/sleep (min backoff 30000))
|
||||
(recur (min (* backoff 2) 30000)))))))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Side-effect dispatchers (futures that write results to queue)
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- fire-load-data!
|
||||
"Load communities, channels, messages in background."
|
||||
[ctx ^LinkedBlockingQueue queue {:keys [community channel]}]
|
||||
(future
|
||||
(try
|
||||
(let [saved (config/load-state)
|
||||
result (api/get-communities ctx)
|
||||
comms (vec (or (:communities result) result))
|
||||
;; Resolve community
|
||||
comm-id (or (when community
|
||||
(some #(when (or (= (:slug %) community)
|
||||
(= (str (:id %)) community))
|
||||
(str (:id %)))
|
||||
comms))
|
||||
(:last-community saved)
|
||||
(when (seq comms) (str (:id (first comms)))))]
|
||||
(if comm-id
|
||||
(let [ch-result (api/get-channels ctx comm-id)
|
||||
chs (vec (or (:channels ch-result) ch-result))
|
||||
;; Resolve channel
|
||||
ch-id (or (when channel
|
||||
(some #(when (= (:name %) channel)
|
||||
(str (:id %)))
|
||||
chs))
|
||||
(get-in saved [:last-channels comm-id])
|
||||
(when (seq chs) (str (:id (first chs)))))
|
||||
msgs (when ch-id
|
||||
(let [r (api/get-messages ctx ch-id {:limit 50})]
|
||||
(vec (or (:messages r) r))))]
|
||||
(.put queue {:type :data-loaded
|
||||
:communities comms
|
||||
:channels chs
|
||||
:active-community comm-id
|
||||
:active-channel ch-id
|
||||
:messages (or msgs [])}))
|
||||
(.put queue {:type :data-loaded
|
||||
:communities comms
|
||||
:channels []
|
||||
:active-community nil
|
||||
:active-channel nil
|
||||
:messages []})))
|
||||
(catch Exception e
|
||||
(.put queue {:type :data-error :error (.getMessage e)})))))
|
||||
|
||||
(defn- fire-switch-channel!
|
||||
"Load messages for a channel + notify TUI SM."
|
||||
[ctx ch-id ^LinkedBlockingQueue queue]
|
||||
(future
|
||||
(try
|
||||
(let [result (api/get-messages ctx ch-id {:limit 50})
|
||||
msgs (vec (or (:messages result) result))]
|
||||
(.put queue {:type :channel-messages :channel-id ch-id :messages msgs}))
|
||||
(catch Exception _ nil))
|
||||
(try
|
||||
(http/request
|
||||
{:method :post
|
||||
:uri (str (:base-url ctx) "/tui/navigate")
|
||||
:headers {"Authorization" (str "Bearer " (:auth-token ctx))
|
||||
"Content-Type" "application/json"}
|
||||
:body (json/write-str {:channel-id ch-id})
|
||||
:throw false})
|
||||
(catch Exception _ nil))))
|
||||
|
||||
(defn- fire-send-message!
|
||||
[ctx channel-id text ^LinkedBlockingQueue queue]
|
||||
(future
|
||||
(try
|
||||
(api/send-message ctx channel-id {:body-md text})
|
||||
(catch Exception e
|
||||
(.put queue {:type :send-error :error (.getMessage e)})))))
|
||||
|
||||
(defn- fire-load-older!
|
||||
[ctx channel-id before-id ^LinkedBlockingQueue queue]
|
||||
(future
|
||||
(try
|
||||
(let [result (api/get-messages ctx channel-id {:limit 50 :before before-id})
|
||||
msgs (vec (or (:messages result) result))]
|
||||
(when (seq msgs)
|
||||
(.put queue {:type :older-messages :messages msgs})))
|
||||
(catch Exception _ nil))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Queue draining
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- drain-queue
|
||||
"Drain all available events from queue into a vector."
|
||||
[^LinkedBlockingQueue queue]
|
||||
(let [buf (ArrayList.)]
|
||||
(.drainTo queue buf)
|
||||
(vec buf)))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Model
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- initial-model
|
||||
[ctx session ^LinkedBlockingQueue queue overrides]
|
||||
{:ctx ctx
|
||||
:session session
|
||||
:queue queue
|
||||
:overrides overrides ;; {:community ... :channel ...} for initial load
|
||||
|
||||
:connected false
|
||||
:reconnecting false
|
||||
|
||||
;; Data
|
||||
:communities []
|
||||
:channels []
|
||||
:messages []
|
||||
:typing-users #{}
|
||||
:unread-counts {}
|
||||
|
||||
;; Navigation
|
||||
:active-community nil
|
||||
:active-channel nil
|
||||
|
||||
;; Input — TODO: Multiline input (PRD 4.4) — :input is single-line only
|
||||
:input-text ""
|
||||
|
||||
;; Focus: :input | :messages | :sidebar
|
||||
:focus :input
|
||||
|
||||
;; Layout
|
||||
:sidebar-width 22
|
||||
:scroll-offset 0
|
||||
|
||||
;; User
|
||||
:username (:username session)
|
||||
:user-id (:user-id session)
|
||||
|
||||
;; Errors
|
||||
:error-message nil
|
||||
:loading false})
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Pure model helpers
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- community-name [model]
|
||||
(or (some #(when (= (str (:id %)) (str (:active-community model)))
|
||||
(:name %))
|
||||
(:communities model))
|
||||
"ajet chat"))
|
||||
|
||||
(defn- channel-name [model]
|
||||
(or (some #(when (= (str (:id %)) (str (:active-channel model)))
|
||||
(:name %))
|
||||
(:channels model))
|
||||
"?"))
|
||||
|
||||
(defn- channel-ids [model]
|
||||
(mapv #(str (:id %)) (:channels model)))
|
||||
|
||||
(defn- text-channels [model]
|
||||
(filterv #(or (= (:type %) "text") (= (:type %) :text) (nil? (:type %)))
|
||||
(:channels model)))
|
||||
|
||||
(defn- dm-channels [model]
|
||||
(filterv #(or (= (:type %) "dm") (= (:type %) :dm)
|
||||
(= (:type %) "group-dm") (= (:type %) :group-dm))
|
||||
(:channels model)))
|
||||
|
||||
(defn- adjacent-channel [model direction]
|
||||
(let [ids (channel-ids model)
|
||||
idx (.indexOf ^java.util.List ids (str (:active-channel model)))]
|
||||
(when (seq ids)
|
||||
(nth ids (case direction
|
||||
:next (if (< idx (dec (count ids))) (inc idx) 0)
|
||||
:prev (if (pos? idx) (dec idx) (dec (count ids))))))))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; SSE event → model (pure)
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- apply-sse-event
|
||||
"Apply a single SSE event to the model. Returns {:model m :events []}."
|
||||
[model sse-event]
|
||||
(let [event-name (:event sse-event)
|
||||
data (:data sse-event)]
|
||||
(case event-name
|
||||
"init"
|
||||
{:model (merge model
|
||||
{:communities (or (:communities data) (:communities model))
|
||||
:channels (or (:channels data) (:channels model))
|
||||
:active-community (or (:active-community data) (:active-community model))
|
||||
:active-channel (or (:active-channel data) (:active-channel model))
|
||||
:unread-counts (or (:unread-counts data) (:unread-counts model))})
|
||||
:events []}
|
||||
|
||||
"message"
|
||||
(let [ch-id (or (:channel-id data) (:channel_id data))]
|
||||
;; TODO: Terminal bell (PRD 4.8) — emit \a on @mention or DM
|
||||
{:model (if (= ch-id (:active-channel model))
|
||||
(update model :messages conj data)
|
||||
(update-in model [:unread-counts (str ch-id)] (fnil inc 0)))
|
||||
:events []})
|
||||
|
||||
"message.update"
|
||||
(let [msg-id (or (:id data) (:message-id data))]
|
||||
{:model (update model :messages
|
||||
(fn [msgs] (mapv #(if (= (:id %) msg-id) (merge % data) %) msgs)))
|
||||
:events []})
|
||||
|
||||
"message.delete"
|
||||
(let [msg-id (or (:id data) (:message-id data))]
|
||||
{:model (update model :messages
|
||||
(fn [msgs] (filterv #(not= (:id %) msg-id) msgs)))
|
||||
:events []})
|
||||
|
||||
"typing"
|
||||
(let [username (or (:username data) (:user data))]
|
||||
(if (and username (not= username (:username model)))
|
||||
{:model (update model :typing-users conj username)
|
||||
:events [(ev/delayed-event 5000 {:type :clear-typing :username username})]}
|
||||
{:model model :events []}))
|
||||
|
||||
"channel.update"
|
||||
{:model (update model :channels
|
||||
(fn [chs] (mapv #(if (= (:id %) (:id data)) (merge % data) %) chs)))
|
||||
:events []}
|
||||
|
||||
;; Unknown / disconnected — ignore
|
||||
{:model model :events []})))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Queue event → model (pure, except for typing events)
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- process-queue-event
|
||||
"Process a single queue event. Returns {:model m :events []}."
|
||||
[model event]
|
||||
(case (:type event)
|
||||
:sse-status
|
||||
{:model (assoc model :connected (:connected event) :reconnecting (:reconnecting event))
|
||||
:events []}
|
||||
|
||||
:sse
|
||||
(apply-sse-event model event)
|
||||
|
||||
:data-loaded
|
||||
{:model (merge model (select-keys event [:communities :channels :active-community
|
||||
:active-channel :messages])
|
||||
{:loading false})
|
||||
:events []}
|
||||
|
||||
:data-error
|
||||
{:model (assoc model :error-message (:error event) :loading false)
|
||||
:events []}
|
||||
|
||||
:channel-messages
|
||||
{:model (if (= (:channel-id event) (:active-channel model))
|
||||
(assoc model :messages (:messages event))
|
||||
model)
|
||||
:events []}
|
||||
|
||||
:older-messages
|
||||
{:model (update model :messages #(vec (concat (:messages event) %)))
|
||||
:events []}
|
||||
|
||||
:send-error
|
||||
{:model (assoc model :error-message (:error event))
|
||||
:events []}
|
||||
|
||||
;; Unknown
|
||||
{:model model :events []}))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Update function (Elm architecture)
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- tui-update
|
||||
[{:keys [model event]}]
|
||||
(let [queue ^LinkedBlockingQueue (:queue model)
|
||||
ctx (:ctx model)]
|
||||
(cond
|
||||
;; ── Poll SSE queue ──────────────────────────────────────────────
|
||||
(= (:type event) :sse-poll)
|
||||
(let [q-events (drain-queue queue)
|
||||
{:keys [m extra-events]}
|
||||
(reduce (fn [{:keys [m extra-events]} e]
|
||||
(let [result (process-queue-event m e)]
|
||||
{:m (:model result)
|
||||
:extra-events (into extra-events (:events result))}))
|
||||
{:m model :extra-events []}
|
||||
q-events)]
|
||||
{:model m
|
||||
:events (into [(ev/delayed-event 100 {:type :sse-poll})]
|
||||
extra-events)})
|
||||
|
||||
;; ── Load initial data ───────────────────────────────────────────
|
||||
(= (:type event) :load-initial-data)
|
||||
(do
|
||||
(fire-load-data! ctx queue (:overrides model))
|
||||
{:model (assoc model :loading true)})
|
||||
|
||||
;; ── Clear typing indicator ──────────────────────────────────────
|
||||
(= (:type event) :clear-typing)
|
||||
{:model (update model :typing-users disj (:username event))}
|
||||
|
||||
;; ── Ctrl+Q / Ctrl+C → quit ─────────────────────────────────────
|
||||
(or (ev/key= event \q #{:ctrl})
|
||||
(ev/key= event \c #{:ctrl}))
|
||||
{:model model :events [(ev/quit)]}
|
||||
|
||||
;; ── Ctrl+K → search (placeholder) ──────────────────────────────
|
||||
(ev/key= event \k #{:ctrl})
|
||||
{:model model}
|
||||
|
||||
;; ── Ctrl+N → next channel ──────────────────────────────────────
|
||||
(ev/key= event \n #{:ctrl})
|
||||
(if-let [ch (adjacent-channel model :next)]
|
||||
(do
|
||||
(fire-switch-channel! ctx ch queue)
|
||||
{:model (assoc model
|
||||
:active-channel ch
|
||||
:messages []
|
||||
:scroll-offset 0
|
||||
:typing-users #{})})
|
||||
{:model model})
|
||||
|
||||
;; ── Ctrl+P → previous channel ──────────────────────────────────
|
||||
(ev/key= event \p #{:ctrl})
|
||||
(if-let [ch (adjacent-channel model :prev)]
|
||||
(do
|
||||
(fire-switch-channel! ctx ch queue)
|
||||
{:model (assoc model
|
||||
:active-channel ch
|
||||
:messages []
|
||||
:scroll-offset 0
|
||||
:typing-users #{})})
|
||||
{:model model})
|
||||
|
||||
;; ── Ctrl+E → edit (placeholder) ────────────────────────────────
|
||||
(ev/key= event \e #{:ctrl})
|
||||
{:model model}
|
||||
|
||||
;; ── Ctrl+D → delete (placeholder) ──────────────────────────────
|
||||
(ev/key= event \d #{:ctrl})
|
||||
{:model model}
|
||||
|
||||
;; ── Ctrl+R → react (placeholder) ───────────────────────────────
|
||||
(ev/key= event \r #{:ctrl})
|
||||
{:model model}
|
||||
|
||||
;; ── Ctrl+T → thread (placeholder) ──────────────────────────────
|
||||
(ev/key= event \t #{:ctrl})
|
||||
{:model model}
|
||||
|
||||
;; ── Tab → cycle focus ──────────────────────────────────────────
|
||||
(ev/key= event :tab)
|
||||
{:model (update model :focus
|
||||
{:input :messages, :messages :sidebar, :sidebar :input})}
|
||||
|
||||
;; ── Enter → send message ───────────────────────────────────────
|
||||
(ev/key= event :enter)
|
||||
(if (and (= (:focus model) :input)
|
||||
(not (str/blank? (:input-text model))))
|
||||
(do
|
||||
(fire-send-message! ctx (:active-channel model)
|
||||
(:input-text model) queue)
|
||||
{:model (assoc model :input-text "" :error-message nil)})
|
||||
{:model model})
|
||||
|
||||
;; ── Backspace ──────────────────────────────────────────────────
|
||||
(ev/key= event :backspace)
|
||||
(if (and (= (:focus model) :input)
|
||||
(pos? (count (:input-text model))))
|
||||
{:model (update model :input-text #(subs % 0 (dec (count %))))}
|
||||
{:model model})
|
||||
|
||||
;; ── Arrow up ───────────────────────────────────────────────────
|
||||
(ev/key= event :up)
|
||||
{:model (if (= (:focus model) :messages)
|
||||
(update model :scroll-offset
|
||||
#(min (inc %) (count (:messages model))))
|
||||
model)}
|
||||
|
||||
;; ── Arrow down ─────────────────────────────────────────────────
|
||||
(ev/key= event :down)
|
||||
{:model (if (= (:focus model) :messages)
|
||||
(update model :scroll-offset #(max 0 (dec %)))
|
||||
model)}
|
||||
|
||||
;; ── Page Up ────────────────────────────────────────────────────
|
||||
(ev/key= event :page-up)
|
||||
(let [new-model (update model :scroll-offset
|
||||
#(min (+ % 10) (count (:messages model))))]
|
||||
(when (and (>= (:scroll-offset new-model) (count (:messages new-model)))
|
||||
(:active-channel new-model)
|
||||
(seq (:messages new-model)))
|
||||
(fire-load-older! ctx (:active-channel new-model)
|
||||
(:id (first (:messages new-model))) queue))
|
||||
{:model new-model})
|
||||
|
||||
;; ── Page Down ──────────────────────────────────────────────────
|
||||
(ev/key= event :page-down)
|
||||
{:model (update model :scroll-offset #(max 0 (- % 10)))}
|
||||
|
||||
;; ── Escape → focus input ───────────────────────────────────────
|
||||
(ev/key= event :escape)
|
||||
{:model (assoc model :focus :input)}
|
||||
|
||||
;; ── j/k vim navigation in messages ─────────────────────────────
|
||||
(and (= (:focus model) :messages) (ev/key= event \j))
|
||||
{:model (update model :scroll-offset #(max 0 (dec %)))}
|
||||
|
||||
(and (= (:focus model) :messages) (ev/key= event \k))
|
||||
{:model (update model :scroll-offset
|
||||
#(min (inc %) (count (:messages model))))}
|
||||
|
||||
;; ── Regular character → input ──────────────────────────────────
|
||||
(and (= (:type event) :key)
|
||||
(= (:focus model) :input)
|
||||
(char? (:key event))
|
||||
(<= 32 (int (:key event)) 126))
|
||||
{:model (update model :input-text str (:key event))}
|
||||
|
||||
;; ── Default → ignore ───────────────────────────────────────────
|
||||
:else
|
||||
{:model model})))
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; View functions (pure hiccup → clojure-tui render primitives)
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- view-header
|
||||
"Header bar: app name | community name ... username status"
|
||||
[model]
|
||||
(let [comm (community-name model)
|
||||
user (:username model)
|
||||
[status-color status-text]
|
||||
(cond
|
||||
(:reconnecting model) [:yellow "reconnecting..."]
|
||||
(:connected model) [:green "connected"]
|
||||
:else [:red "disconnected"])]
|
||||
[:row {:widths [:flex :flex]}
|
||||
[:text {:bg :blue :fg :white :bold true}
|
||||
(str " ajet chat | " comm " ")]
|
||||
[:text {:bg :blue :fg :white}
|
||||
(str user " ")
|
||||
[:text {:fg status-color} status-text]
|
||||
" "]]))
|
||||
|
||||
(defn- view-sidebar-entry
|
||||
"Single channel/DM entry in sidebar."
|
||||
[ch active-channel unread-counts sw]
|
||||
(let [ch-id (str (:id ch))
|
||||
active? (= ch-id (str active-channel))
|
||||
unread (get unread-counts ch-id 0)
|
||||
is-dm? (or (= (:type ch) "dm") (= (:type ch) :dm)
|
||||
(= (:type ch) "group-dm") (= (:type ch) :group-dm))
|
||||
prefix (if is-dm? "" "#")
|
||||
raw-name (str prefix (or (:name ch) (:display-name ch) "?"))
|
||||
name-str (ansi/truncate raw-name (- sw 6))]
|
||||
(if active?
|
||||
[:text {:bold true :fg :cyan}
|
||||
"> " name-str
|
||||
(when (pos? unread) [:text {:fg :yellow} (str " " unread)])]
|
||||
[:text {}
|
||||
" " name-str
|
||||
(when (pos? unread) [:text {:fg :yellow} (str " " unread)])])))
|
||||
|
||||
(defn- view-sidebar [model]
|
||||
(let [{:keys [active-channel unread-counts sidebar-width]} model
|
||||
tchs (text-channels model)
|
||||
dchs (dm-channels model)]
|
||||
(into [:col {}
|
||||
[:text {:bold true :fg :white} "CHANNELS"]]
|
||||
(concat
|
||||
(for [ch tchs]
|
||||
(view-sidebar-entry ch active-channel unread-counts sidebar-width))
|
||||
(when (seq dchs)
|
||||
(cons [:text {} ""]
|
||||
(cons [:text {:bold true :fg :white} "DMs"]
|
||||
(for [ch dchs]
|
||||
(view-sidebar-entry ch active-channel
|
||||
unread-counts sidebar-width)))))))))
|
||||
|
||||
(defn- format-relative-time
|
||||
"Simple relative time formatter."
|
||||
[ts]
|
||||
(if (nil? ts) ""
|
||||
(try
|
||||
(let [s (str ts)]
|
||||
(if (> (count s) 16) (subs s 11 16) s))
|
||||
(catch Exception _ ""))))
|
||||
|
||||
(defn- view-message
|
||||
"Single message: author + timestamp, then body lines."
|
||||
[msg]
|
||||
;; TODO: Inline images (PRD 4.5) — show [image: file.png] placeholder only
|
||||
;; TODO: Spoiler reveal (PRD 4.6) — ||spoiler|| not hidden yet
|
||||
;; TODO: OSC 8 hyperlinks (PRD 4.6) — URLs not clickable
|
||||
(let [author (or (:display-name msg) (:username msg) (:user-id msg) "?")
|
||||
time-str (format-relative-time (or (:created-at msg) (:timestamp msg)))
|
||||
body (-> (or (:body-md msg) (:body msg) "")
|
||||
(mentions/render (fn [_type id] id))
|
||||
markdown/->ansi)
|
||||
lines (str/split-lines body)]
|
||||
(into [:col {}
|
||||
[:text {}
|
||||
[:text {:bold true} author]
|
||||
" "
|
||||
[:text {:fg :bright-black} time-str]
|
||||
(when (:edited-at msg)
|
||||
[:text {:fg :bright-black} " (edited)"])]]
|
||||
(for [line lines]
|
||||
[:text {} (str " " line)]))))
|
||||
|
||||
(defn- view-messages
|
||||
"Message list with scroll offset."
|
||||
[model]
|
||||
(let [{:keys [messages scroll-offset]} model
|
||||
n (count messages)
|
||||
visible (if (and (pos? scroll-offset) (> n 0))
|
||||
(let [end (max 0 (- n scroll-offset))
|
||||
start (max 0 (- end 50))]
|
||||
(subvec (vec messages) start end))
|
||||
messages)]
|
||||
(if (seq visible)
|
||||
(into [:col {}]
|
||||
(for [msg visible]
|
||||
(view-message msg)))
|
||||
[:text {:fg :bright-black :italic true} " No messages yet"])))
|
||||
|
||||
(defn- view-typing [model]
|
||||
(let [{:keys [typing-users]} model]
|
||||
(if (seq typing-users)
|
||||
[:text {:fg :bright-black :italic true}
|
||||
(str " " (str/join ", " typing-users) " "
|
||||
(if (= 1 (count typing-users)) "is" "are")
|
||||
" typing...")]
|
||||
[:text {} ""])))
|
||||
|
||||
;; TODO: Autocomplete dropdowns (PRD 4.4) — no @mention/#channel//cmd popups
|
||||
;; TODO: Multiline input (PRD 4.4) — :input is single-line, no Shift+Enter
|
||||
(defn- view-input [model]
|
||||
(let [ch (channel-name model)
|
||||
active? (= (:focus model) :input)]
|
||||
[:row {:widths [nil :flex]}
|
||||
[:text (if active? {:fg :cyan} {:fg :bright-black})
|
||||
(str (if active? "> " " ") "#" ch " ")]
|
||||
[:input {:value (:input-text model)
|
||||
:placeholder (when active? "Type a message...")}]]))
|
||||
|
||||
(defn- view-status-bar [model]
|
||||
(let [focus-str (str "[" (name (:focus model)) "]")
|
||||
keys-str "Ctrl+Q:quit Ctrl+K:search Ctrl+N/P:next/prev Tab:focus Enter:send"
|
||||
err (:error-message model)]
|
||||
[:text {:bg :bright-black :fg :white}
|
||||
(str " " focus-str " " keys-str
|
||||
(when err (str " | Error: " err)))]))
|
||||
|
||||
(defn- tui-view
|
||||
"Root view: header / (sidebar | content) / input / status"
|
||||
[model]
|
||||
[:col {:heights [1 :flex 1 1]}
|
||||
(view-header model)
|
||||
[:row {:widths [(:sidebar-width model) :flex] :gap 1}
|
||||
(view-sidebar model)
|
||||
[:col {:heights [:flex 1]}
|
||||
(view-messages model)
|
||||
(view-typing model)]]
|
||||
(view-input model)
|
||||
(view-status-bar model)])
|
||||
|
||||
;;; ---------------------------------------------------------------------------
|
||||
;;; Launch
|
||||
;;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn launch!
|
||||
"Launch the TUI using clojure-tui's Elm architecture.
|
||||
|
||||
Options:
|
||||
:community - community slug to open to
|
||||
:channel - channel name to open to
|
||||
|
||||
Returns exit code."
|
||||
[& [{:keys [community channel]}]]
|
||||
(let [session (config/load-session)]
|
||||
(when-not session
|
||||
(throw (ex-info "Not logged in"
|
||||
{:type :ajet.chat/auth-error
|
||||
:hint "Run 'ajet login' to sign in"})))
|
||||
(let [ctx (config/make-ctx)
|
||||
queue (LinkedBlockingQueue.)
|
||||
sse-running (atom true)
|
||||
_ (start-sse-thread! (:base-url ctx)
|
||||
(:auth-token ctx)
|
||||
queue sse-running)
|
||||
init-model (initial-model ctx session queue
|
||||
{:community community :channel channel})
|
||||
final-model (tui/run
|
||||
{:init init-model
|
||||
:update tui-update
|
||||
:view tui-view
|
||||
:init-events [{:type :load-initial-data}
|
||||
(ev/delayed-event 100 {:type :sse-poll})]})]
|
||||
;; Cleanup
|
||||
(reset! sse-running false)
|
||||
(config/save-state!
|
||||
{:last-community (str (:active-community final-model))
|
||||
:last-channels {(str (:active-community final-model))
|
||||
(str (:active-channel final-model))}})
|
||||
0)))
|
||||
@@ -1,20 +1,40 @@
|
||||
{:paths []
|
||||
:deps {}
|
||||
:aliases
|
||||
{:api {:extra-deps {ajet/chat-api {:local/root "api"}}
|
||||
:main-opts ["-m" "ajet.chat.api.core"]}
|
||||
:web-sm {:extra-deps {ajet/chat-web-sm {:local/root "web-sm"}}
|
||||
:main-opts ["-m" "ajet.chat.web.core"]}
|
||||
:tui-sm {:extra-deps {ajet/chat-tui-sm {:local/root "tui-sm"}}
|
||||
:main-opts ["-m" "ajet.chat.tui-sm.core"]}
|
||||
:cli {:extra-deps {ajet/chat-cli {:local/root "cli"}}
|
||||
:main-opts ["-m" "ajet.chat.cli.core"]}
|
||||
:auth-gw {:extra-deps {ajet/chat-auth-gw {:local/root "auth-gw"}}
|
||||
:main-opts ["-m" "ajet.chat.auth-gw.core"]}
|
||||
{;; Service deps (no :main-opts — safe to combine with :dev)
|
||||
:api {:extra-deps {ajet/chat-api {:local/root "api"}}}
|
||||
:web-sm {:extra-deps {ajet/chat-web-sm {:local/root "web-sm"}}}
|
||||
:tui-sm {:extra-deps {ajet/chat-tui-sm {:local/root "tui-sm"}}}
|
||||
:cli {:extra-deps {ajet/chat-cli {:local/root "cli"}}}
|
||||
:auth-gw {:extra-deps {ajet/chat-auth-gw {:local/root "auth-gw"}}}
|
||||
|
||||
;; Standalone runners (use: clj -M:run/api)
|
||||
:run/api {:extra-deps {ajet/chat-api {:local/root "api"}}
|
||||
:main-opts ["-m" "ajet.chat.api.core"]}
|
||||
:run/web-sm {:extra-deps {ajet/chat-web-sm {:local/root "web-sm"}}
|
||||
:main-opts ["-m" "ajet.chat.web.core"]}
|
||||
:run/tui-sm {:extra-deps {ajet/chat-tui-sm {:local/root "tui-sm"}}
|
||||
:main-opts ["-m" "ajet.chat.tui-sm.core"]}
|
||||
:run/cli {:extra-deps {ajet/chat-cli {:local/root "cli"}}
|
||||
:main-opts ["-m" "ajet.chat.cli.core"]}
|
||||
:run/auth-gw {:extra-deps {ajet/chat-auth-gw {:local/root "auth-gw"}}
|
||||
:main-opts ["-m" "ajet.chat.auth-gw.core"]}
|
||||
|
||||
;; Development — includes all service deps + nREPL tooling
|
||||
:dev {:extra-paths ["dev"]
|
||||
:extra-deps {nrepl/nrepl {:mvn/version "1.3.0"}
|
||||
:extra-deps {ajet/chat-api {:local/root "api"}
|
||||
ajet/chat-auth-gw {:local/root "auth-gw"}
|
||||
ajet/chat-web-sm {:local/root "web-sm"}
|
||||
ajet/chat-tui-sm {:local/root "tui-sm"}
|
||||
nrepl/nrepl {:mvn/version "1.3.0"}
|
||||
cider/cider-nrepl {:mvn/version "0.50.2"}
|
||||
refactor-nrepl/refactor-nrepl {:mvn/version "3.10.0"}}}
|
||||
|
||||
;; Testing
|
||||
:test/base {:extra-deps {ajet/chat-api {:local/root "api"}
|
||||
ajet/chat-auth-gw {:local/root "auth-gw"}
|
||||
ajet/chat-web-sm {:local/root "web-sm"}
|
||||
ajet/chat-tui-sm {:local/root "tui-sm"}}}
|
||||
:test/unit {:extra-paths ["test"]
|
||||
:extra-deps {lambdaisland/kaocha {:mvn/version "1.91.1392"}}
|
||||
:main-opts ["-m" "kaocha.runner" "--focus" "unit"]}
|
||||
@@ -28,4 +48,13 @@
|
||||
:test/all {:extra-paths ["test"]
|
||||
:extra-deps {lambdaisland/kaocha {:mvn/version "1.91.1392"}
|
||||
org.babashka/http-client {:mvn/version "0.4.22"}}
|
||||
:main-opts ["-m" "kaocha.runner"]}}}
|
||||
:main-opts ["-m" "kaocha.runner"]}
|
||||
:test {:extra-paths ["test"]
|
||||
:extra-deps {ajet/chat-api {:local/root "api"}
|
||||
ajet/chat-auth-gw {:local/root "auth-gw"}
|
||||
ajet/chat-web-sm {:local/root "web-sm"}
|
||||
ajet/chat-tui-sm {:local/root "tui-sm"}
|
||||
lambdaisland/kaocha {:mvn/version "1.91.1392"}
|
||||
org.babashka/http-client {:mvn/version "0.4.22"}}}
|
||||
:build {:deps {io.github.clojure/tools.build {:mvn/version "0.10.6"}}
|
||||
:ns-default build}}}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
(ns user
|
||||
"REPL development helpers. Load all services and provide start!/stop!/reset!."
|
||||
(:refer-clojure :exclude [reset!])
|
||||
(:require [clojure.tools.logging :as log]))
|
||||
|
||||
;; Lazy-load service namespaces to avoid compile errors when not all deps are on classpath
|
||||
(defn- require-ns [sym]
|
||||
(try
|
||||
(require sym)
|
||||
true
|
||||
(catch Exception _
|
||||
false)))
|
||||
|
||||
(defn start!
|
||||
"Start all services that are on the classpath."
|
||||
[]
|
||||
(log/info "Starting development services...")
|
||||
(when (require-ns 'ajet.chat.api.core)
|
||||
((resolve 'ajet.chat.api.core/start!))
|
||||
(log/info "API service started"))
|
||||
(when (require-ns 'ajet.chat.auth-gw.core)
|
||||
((resolve 'ajet.chat.auth-gw.core/start!))
|
||||
(log/info "Auth Gateway started"))
|
||||
(when (require-ns 'ajet.chat.web.core)
|
||||
((resolve 'ajet.chat.web.core/start!))
|
||||
(log/info "Web SM started"))
|
||||
(when (require-ns 'ajet.chat.tui-sm.core)
|
||||
((resolve 'ajet.chat.tui-sm.core/start!))
|
||||
(log/info "TUI SM started"))
|
||||
(log/info "All services started."))
|
||||
|
||||
(defn stop!
|
||||
"Stop all running services."
|
||||
[]
|
||||
(log/info "Stopping development services...")
|
||||
(when (find-ns 'ajet.chat.tui-sm.core)
|
||||
((resolve 'ajet.chat.tui-sm.core/stop!)))
|
||||
(when (find-ns 'ajet.chat.web.core)
|
||||
((resolve 'ajet.chat.web.core/stop!)))
|
||||
(when (find-ns 'ajet.chat.auth-gw.core)
|
||||
((resolve 'ajet.chat.auth-gw.core/stop!)))
|
||||
(when (find-ns 'ajet.chat.api.core)
|
||||
((resolve 'ajet.chat.api.core/stop!)))
|
||||
(log/info "All services stopped."))
|
||||
|
||||
(defn reset!
|
||||
"Stop, reload, and restart all services."
|
||||
[]
|
||||
(stop!)
|
||||
(start!))
|
||||
|
||||
;; Auto-start all services when the REPL loads
|
||||
(try
|
||||
(start!)
|
||||
(catch Exception e
|
||||
(log/error e "Failed to auto-start services — REPL is still available. Fix the issue and run (start!)")
|
||||
(println "\n*** AUTO-START FAILED ***")
|
||||
(println "Error:" (.getMessage e))
|
||||
(println "REPL is ready — fix the issue and run (start!) manually.\n")))
|
||||
|
||||
(comment
|
||||
;; After code changes:
|
||||
(reset!)
|
||||
;; Stop services:
|
||||
(stop!)
|
||||
|
||||
;; Start individual services:
|
||||
(require 'ajet.chat.api.core)
|
||||
(ajet.chat.api.core/start!)
|
||||
(ajet.chat.api.core/stop!)
|
||||
)
|
||||
@@ -0,0 +1,64 @@
|
||||
# docker-compose.dev.yml — infrastructure only, Clojure services run via REPL
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
ports:
|
||||
- "5432:5432"
|
||||
environment:
|
||||
POSTGRES_DB: ajet_chat
|
||||
POSTGRES_USER: ajet
|
||||
POSTGRES_PASSWORD: ajet_dev
|
||||
volumes:
|
||||
- pgdata_dev:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ajet -d ajet_chat"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
|
||||
nats:
|
||||
image: nats:2.10-alpine
|
||||
ports:
|
||||
- "4222:4222"
|
||||
- "8222:8222"
|
||||
command: ["--js", "--sd", "/data", "-m", "8222"]
|
||||
volumes:
|
||||
- natsdata_dev:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:8222/healthz"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
|
||||
minio:
|
||||
image: minio/minio:latest
|
||||
ports:
|
||||
- "9000:9000"
|
||||
- "9001:9001"
|
||||
environment:
|
||||
MINIO_ROOT_USER: minioadmin
|
||||
MINIO_ROOT_PASSWORD: minioadmin
|
||||
command: server /data --console-address ":9001"
|
||||
volumes:
|
||||
- miniodata_dev:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "mc", "ready", "local"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
|
||||
minio-init:
|
||||
image: minio/mc:latest
|
||||
depends_on:
|
||||
minio:
|
||||
condition: service_healthy
|
||||
entrypoint: >
|
||||
/bin/sh -c "
|
||||
mc alias set local http://minio:9000 minioadmin minioadmin;
|
||||
mc mb --ignore-existing local/ajet-chat;
|
||||
"
|
||||
|
||||
volumes:
|
||||
pgdata_dev:
|
||||
natsdata_dev:
|
||||
miniodata_dev:
|
||||
@@ -0,0 +1,281 @@
|
||||
# docker-compose.test.yml — ephemeral infra for testing
|
||||
#
|
||||
# Integration tests (infra only):
|
||||
# docker compose -f docker-compose.test.yml --profile init up -d
|
||||
#
|
||||
# E2E tests (full stack + browser):
|
||||
# docker compose -f docker-compose.test.yml --profile e2e up -d --build
|
||||
# cd e2e && npx nbb -cp src -m ajet-chat.e2e.runner
|
||||
|
||||
services:
|
||||
postgres-test:
|
||||
image: postgres:16-alpine
|
||||
ports:
|
||||
- "5433:5432"
|
||||
environment:
|
||||
POSTGRES_DB: ajet_chat_test
|
||||
POSTGRES_USER: ajet
|
||||
POSTGRES_PASSWORD: ajet_test
|
||||
tmpfs:
|
||||
- /var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ajet -d ajet_chat_test"]
|
||||
interval: 3s
|
||||
timeout: 2s
|
||||
retries: 10
|
||||
|
||||
nats-test:
|
||||
image: nats:2.10-alpine
|
||||
ports:
|
||||
- "4223:4222"
|
||||
command: ["--js", "-m", "8222"]
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:8222/healthz"]
|
||||
interval: 3s
|
||||
timeout: 2s
|
||||
retries: 10
|
||||
|
||||
minio-test:
|
||||
image: minio/minio:latest
|
||||
ports:
|
||||
- "9002:9000"
|
||||
environment:
|
||||
MINIO_ROOT_USER: minioadmin
|
||||
MINIO_ROOT_PASSWORD: minioadmin
|
||||
command: server /data
|
||||
tmpfs:
|
||||
- /data
|
||||
healthcheck:
|
||||
test: ["CMD", "mc", "ready", "local"]
|
||||
interval: 3s
|
||||
timeout: 2s
|
||||
retries: 10
|
||||
|
||||
minio-test-init:
|
||||
profiles: ["init"]
|
||||
image: minio/mc:latest
|
||||
depends_on:
|
||||
minio-test:
|
||||
condition: service_healthy
|
||||
entrypoint: >
|
||||
/bin/sh -c "
|
||||
mc alias set local http://minio-test:9000 minioadmin minioadmin;
|
||||
mc mb --ignore-existing local/ajet-chat;
|
||||
"
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Gitea — self-contained OAuth provider for E2E tests
|
||||
# --------------------------------------------------------------------------
|
||||
gitea-test:
|
||||
profiles: ["e2e"]
|
||||
image: gitea/gitea:1.22
|
||||
ports:
|
||||
- "4080:3000"
|
||||
environment:
|
||||
- GITEA__database__DB_TYPE=sqlite3
|
||||
- GITEA__security__INSTALL_LOCK=true
|
||||
- GITEA__server__ROOT_URL=http://localhost:4080
|
||||
- GITEA__server__HTTP_PORT=3000
|
||||
- GITEA__service__DISABLE_REGISTRATION=false
|
||||
- GITEA__service__REQUIRE_SIGNIN_VIEW=false
|
||||
- GITEA__oauth2__ENABLE=true
|
||||
tmpfs:
|
||||
- /data
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/v1/version"]
|
||||
interval: 3s
|
||||
timeout: 2s
|
||||
retries: 20
|
||||
start_period: 10s
|
||||
|
||||
gitea-test-init:
|
||||
profiles: ["e2e"]
|
||||
image: curlimages/curl:latest
|
||||
depends_on:
|
||||
gitea-test:
|
||||
condition: service_healthy
|
||||
entrypoint: /bin/sh
|
||||
command:
|
||||
- -c
|
||||
- |
|
||||
set -e
|
||||
GITEA=http://gitea-test:3000
|
||||
|
||||
echo "==> Creating Gitea admin user..."
|
||||
curl -sf -X POST "$$GITEA/api/v1/admin/users" \
|
||||
-H "Content-Type: application/json" \
|
||||
-u "gitea-admin:gitea-admin-pass" \
|
||||
-d '{"username":"gitea-admin","password":"gitea-admin-pass","email":"admin@test.local","must_change_password":false,"login_name":"gitea-admin","source_id":0,"visibility":"public"}' \
|
||||
|| true
|
||||
|
||||
# Gitea with INSTALL_LOCK=true uses the first registered user as admin.
|
||||
# Verify admin access, fall back to sign-up if needed:
|
||||
curl -sf -X POST "$$GITEA/api/v1/user/repos" \
|
||||
-H "Content-Type: application/json" \
|
||||
-u "gitea-admin:gitea-admin-pass" \
|
||||
-d '{"name":"test"}' \
|
||||
> /dev/null 2>&1 || {
|
||||
echo "==> Admin doesn't exist yet, creating via sign-up..."
|
||||
curl -sf -X POST "$$GITEA/user/sign_up" \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
-d "user_name=gitea-admin&password=gitea-admin-pass&retype=gitea-admin-pass&email=admin@test.local" \
|
||||
|| true
|
||||
sleep 2
|
||||
}
|
||||
|
||||
echo "==> Getting admin token..."
|
||||
TOKEN=$$(curl -sf -X POST "$$GITEA/api/v1/users/gitea-admin/tokens" \
|
||||
-u "gitea-admin:gitea-admin-pass" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name":"e2e-setup","scopes":["all"]}' | sed 's/.*"sha1":"//' | sed 's/".*//')
|
||||
echo "Token: $$TOKEN"
|
||||
|
||||
echo "==> Creating test user A..."
|
||||
curl -sf -X POST "$$GITEA/api/v1/admin/users" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: token $$TOKEN" \
|
||||
-d '{"username":"testuser-a","password":"testpass-a","email":"testuser-a@test.local","must_change_password":false}' \
|
||||
|| true
|
||||
|
||||
echo "==> Creating test user B..."
|
||||
curl -sf -X POST "$$GITEA/api/v1/admin/users" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: token $$TOKEN" \
|
||||
-d '{"username":"testuser-b","password":"testpass-b","email":"testuser-b@test.local","must_change_password":false}' \
|
||||
|| true
|
||||
|
||||
echo "==> Gitea init complete! Users: gitea-admin, testuser-a, testuser-b"
|
||||
echo "==> OAuth app will be created by the nbb test runner via Gitea API"
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# E2E profile — application service containers
|
||||
# --------------------------------------------------------------------------
|
||||
auth-gw:
|
||||
profiles: ["e2e"]
|
||||
build:
|
||||
context: .
|
||||
dockerfile: auth-gw/Dockerfile
|
||||
ports:
|
||||
- "4000:3000"
|
||||
environment:
|
||||
AJET__DB__HOST: postgres-test
|
||||
AJET__DB__PORT: 5432
|
||||
AJET__DB__DBNAME: ajet_chat_test
|
||||
AJET__DB__USER: ajet
|
||||
AJET__DB__PASSWORD: ajet_test
|
||||
AJET__SERVICES__API__HOST: api
|
||||
AJET__SERVICES__WEB_SM__HOST: web-sm
|
||||
AJET__SERVICES__TUI_SM__HOST: tui-sm
|
||||
AJET__SESSION__COOKIE_SECURE: "false"
|
||||
AJET__OAUTH__GITEA_SERVER_BASE_URL: "http://host.docker.internal:4080"
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
depends_on:
|
||||
postgres-test:
|
||||
condition: service_healthy
|
||||
api:
|
||||
condition: service_healthy
|
||||
web-sm:
|
||||
condition: service_healthy
|
||||
tui-sm:
|
||||
condition: service_healthy
|
||||
gitea-test-init:
|
||||
condition: service_completed_successfully
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 15
|
||||
start_period: 20s
|
||||
|
||||
api:
|
||||
profiles: ["e2e"]
|
||||
build:
|
||||
context: .
|
||||
dockerfile: api/Dockerfile
|
||||
ports:
|
||||
- "4001:3001"
|
||||
environment:
|
||||
AJET__DB__HOST: postgres-test
|
||||
AJET__DB__PORT: 5432
|
||||
AJET__DB__DBNAME: ajet_chat_test
|
||||
AJET__DB__USER: ajet
|
||||
AJET__DB__PASSWORD: ajet_test
|
||||
AJET__NATS__URL: nats://nats-test:4222
|
||||
AJET__MINIO__ENDPOINT: http://minio-test:9000
|
||||
AJET__MINIO__ACCESS_KEY: minioadmin
|
||||
AJET__MINIO__SECRET_KEY: minioadmin
|
||||
depends_on:
|
||||
postgres-test:
|
||||
condition: service_healthy
|
||||
nats-test:
|
||||
condition: service_healthy
|
||||
minio-test:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:3001/api/health"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 15
|
||||
start_period: 20s
|
||||
|
||||
web-sm:
|
||||
profiles: ["e2e"]
|
||||
build:
|
||||
context: .
|
||||
dockerfile: web-sm/Dockerfile
|
||||
ports:
|
||||
- "4002:3002"
|
||||
environment:
|
||||
AJET__API__BASE_URL: http://api:3001
|
||||
AJET__NATS__URL: nats://nats-test:4222
|
||||
depends_on:
|
||||
nats-test:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:3002/web/health"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 15
|
||||
start_period: 20s
|
||||
|
||||
tui-sm:
|
||||
profiles: ["e2e"]
|
||||
build:
|
||||
context: .
|
||||
dockerfile: tui-sm/Dockerfile
|
||||
ports:
|
||||
- "4003:3003"
|
||||
environment:
|
||||
AJET__API__BASE_URL: http://api:3001
|
||||
AJET__NATS__URL: nats://nats-test:4222
|
||||
depends_on:
|
||||
nats-test:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:3003/tui/health"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 15
|
||||
start_period: 20s
|
||||
|
||||
test-runner:
|
||||
profiles: ["e2e-docker"]
|
||||
build:
|
||||
context: .
|
||||
dockerfile: e2e/Dockerfile
|
||||
environment:
|
||||
AJET_TEST_BASE_URL: http://auth-gw:3000
|
||||
AJET_TEST_DB_HOST: postgres-test
|
||||
AJET_TEST_DB_PORT: 5432
|
||||
AJET_TEST_DB_DBNAME: ajet_chat_test
|
||||
AJET_TEST_DB_USER: ajet
|
||||
AJET_TEST_DB_PASSWORD: ajet_test
|
||||
AJET_TEST_NATS_URL: nats://nats-test:4222
|
||||
AJET_TEST_MINIO_ENDPOINT: http://minio-test:9000
|
||||
AJET_TEST_MINIO_ACCESS_KEY: minioadmin
|
||||
AJET_TEST_MINIO_SECRET_KEY: minioadmin
|
||||
depends_on:
|
||||
auth-gw:
|
||||
condition: service_healthy
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
# docker-compose.yml — production full stack with nginx TLS termination
|
||||
services:
|
||||
nginx:
|
||||
image: nginx:1.27-alpine
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./nginx/certs:/etc/nginx/certs:ro
|
||||
depends_on:
|
||||
- auth-gw
|
||||
restart: unless-stopped
|
||||
|
||||
auth-gw:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: auth-gw/Dockerfile
|
||||
environment:
|
||||
AJET__DB__HOST: postgres
|
||||
AJET__DB__PASSWORD: ${AJET__DB__PASSWORD}
|
||||
AJET__OAUTH__GITHUB__CLIENT_ID: ${GITHUB_CLIENT_ID}
|
||||
AJET__OAUTH__GITHUB__CLIENT_SECRET: ${GITHUB_CLIENT_SECRET}
|
||||
AJET__SERVICES__API__HOST: api
|
||||
AJET__SERVICES__WEB_SM__HOST: web-sm
|
||||
AJET__SERVICES__TUI_SM__HOST: tui-sm
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: api/Dockerfile
|
||||
environment:
|
||||
AJET__DB__HOST: postgres
|
||||
AJET__DB__PASSWORD: ${AJET__DB__PASSWORD}
|
||||
AJET__NATS__URL: nats://nats:4222
|
||||
AJET__MINIO__ENDPOINT: http://minio:9000
|
||||
AJET__MINIO__ACCESS_KEY: ${MINIO_ACCESS_KEY}
|
||||
AJET__MINIO__SECRET_KEY: ${MINIO_SECRET_KEY}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
nats:
|
||||
condition: service_healthy
|
||||
minio:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
web-sm:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: web-sm/Dockerfile
|
||||
environment:
|
||||
AJET__API__BASE_URL: http://api:3001
|
||||
AJET__NATS__URL: nats://nats:4222
|
||||
depends_on:
|
||||
nats:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
tui-sm:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: tui-sm/Dockerfile
|
||||
environment:
|
||||
AJET__API__BASE_URL: http://api:3001
|
||||
AJET__NATS__URL: nats://nats:4222
|
||||
depends_on:
|
||||
nats:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_DB: ajet_chat
|
||||
POSTGRES_USER: ajet
|
||||
POSTGRES_PASSWORD: ${AJET__DB__PASSWORD}
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ajet -d ajet_chat"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
|
||||
nats:
|
||||
image: nats:2.10-alpine
|
||||
command: ["--js", "--sd", "/data", "-m", "8222"]
|
||||
volumes:
|
||||
- natsdata:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:8222/healthz"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
|
||||
minio:
|
||||
image: minio/minio:latest
|
||||
environment:
|
||||
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY}
|
||||
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY}
|
||||
command: server /data
|
||||
volumes:
|
||||
- miniodata:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "mc", "ready", "local"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
natsdata:
|
||||
miniodata:
|
||||
+13
-8
@@ -6,15 +6,16 @@ Product Requirements Documents for ajet-chat v1.
|
||||
|
||||
| Module | PRD | Test Cases | Status |
|
||||
|--------|-----|------------|--------|
|
||||
| [shared](shared.md) | DB, EventBus, API Client, Schemas, Mentions, Markdown | 60 | v1 |
|
||||
| [api](api.md) | REST API: 15 endpoint groups, full CRUD | 95 | v1 |
|
||||
| [auth-gw](auth-gw.md) | OAuth, session/token validation, reverse proxy | 40 | v1 |
|
||||
| [web-sm](web-sm.md) | Browser UI: Datastar SSE, Discord layout | 57 | v1 |
|
||||
| [tui-sm](tui-sm.md) | TUI session manager: SSE JSON events | 30 | v1 |
|
||||
| [cli](cli.md) | CLI commands + Rich TUI (clojure-tui) | 50 | v1 |
|
||||
| [shared](shared.md) | DB, EventBus, API Client, Schemas, Mentions, Markdown, Config, Logging, Storage | 92 | v1 |
|
||||
| [api](api.md) | REST API: 15 endpoint groups, full CRUD, health check, ban/mute | 129 | v1 |
|
||||
| [auth-gw](auth-gw.md) | OAuth, session/token validation, reverse proxy, CORS, audit logging | 40 | v1 |
|
||||
| [web-sm](web-sm.md) | Browser UI: Datastar SSE, Discord layout, emoji picker, toasts | 57 | v1 |
|
||||
| [tui-sm](tui-sm.md) | TUI session manager: SSE JSON events, backpressure | 30 | v1 |
|
||||
| [cli](cli.md) | CLI commands + Rich TUI (clojure-tui), bbin packaging, exit codes | 79 | v1 |
|
||||
| [infrastructure](infrastructure.md) | Docker Compose (dev/test/prod), NATS JetStream, MinIO, nginx | 10 | v1 |
|
||||
| [mobile](mobile.md) | Deferred — PWA recommended for v1 | 0 | v2+ |
|
||||
|
||||
**Total test cases: ~332**
|
||||
**Total test cases: 437**
|
||||
|
||||
## Key Product Decisions
|
||||
|
||||
@@ -26,7 +27,9 @@ Product Requirements Documents for ajet-chat v1.
|
||||
- **Mention storage** — `@<user:uuid>` / `@<here>` / `#<channel:uuid>` in DB, rendered at display time
|
||||
- **1-hour edit window** — messages can only be edited within 1 hour of creation
|
||||
- **Images + paste** — clipboard paste and upload, no arbitrary file types in v1
|
||||
- **OAuth-only auth** — GitHub + Gitea + generic OIDC (for self-hosters)
|
||||
- **OAuth auth** — GitHub + Gitea + generic OIDC (future: email-based auth via magic link/OTP)
|
||||
- **DB-stored OAuth providers** — OAuth provider config stored in `oauth_providers` table, manageable at runtime via admin API
|
||||
- **Admin setup wizard** — multi-step first-deployment bootstrap: configure providers, admin OAuth login, create first community
|
||||
- **3-tier roles** — Owner / Admin / Member (no custom roles in v1)
|
||||
- **Invite links + direct invites** — admins generate links or invite by user ID
|
||||
- **Incoming webhooks** — external services POST to channel (outgoing deferred)
|
||||
@@ -36,3 +39,5 @@ Product Requirements Documents for ajet-chat v1.
|
||||
- **Paginated messages** — "Load older" button, cursor-based pagination
|
||||
- **Rich TUI** — split panes, inline images (timg/sixel), markdown rendering, mouse support
|
||||
- **Full CLI** — all operations scriptable, JSON output, stdin piping
|
||||
- **Tailwind CSS** — dark theme, utility-first styling in Hiccup
|
||||
- **Ban/mute enforcement** — bans permanent until lifted, mutes time-limited with auto-expiry
|
||||
|
||||
+186
@@ -47,6 +47,10 @@ Auth Gateway → API Service → PostgreSQL
|
||||
016-create-webhooks.up.sql
|
||||
017-create-invites.up.sql
|
||||
018-add-search-indexes.up.sql
|
||||
019-create-bans.up.sql
|
||||
020-create-mutes.up.sql
|
||||
021-create-oauth-providers.up.sql
|
||||
022-create-system-settings.up.sql
|
||||
```
|
||||
|
||||
### 3.2 Tables
|
||||
@@ -75,8 +79,41 @@ idx_messages_search ON messages USING GIN(to_tsvector('english', body_md))
|
||||
idx_notifications_user_unread ON notifications(user_id, created_at) WHERE read = false
|
||||
idx_channel_members_user ON channel_members(user_id)
|
||||
idx_community_members_user ON community_members(user_id)
|
||||
|
||||
-- Ban/mute enforcement
|
||||
bans (community_id uuid FK, user_id uuid FK, reason text, banned_by uuid FK, created_at timestamptz, PK(community_id, user_id))
|
||||
mutes (community_id uuid FK, user_id uuid FK, expires_at timestamptz, muted_by uuid FK, created_at timestamptz, PK(community_id, user_id))
|
||||
idx_mutes_expires ON mutes(expires_at) WHERE expires_at IS NOT NULL
|
||||
|
||||
-- OAuth providers (runtime-configurable)
|
||||
oauth_providers (id uuid PK, provider_type text CHECK(github/gitea/oidc), name text, client_id text, client_secret_encrypted text, base_url text NULL, issuer_url text NULL, enabled boolean DEFAULT true, created_at timestamptz, updated_at timestamptz)
|
||||
idx_oauth_providers_type ON oauth_providers(provider_type)
|
||||
|
||||
-- System settings (key-value for deployment-wide config)
|
||||
system_settings (key text PK, value jsonb, updated_at timestamptz)
|
||||
```
|
||||
|
||||
### 3.3 Ban & Mute Enforcement
|
||||
|
||||
**Bans:**
|
||||
- Ban record in `bans` table prevents user from:
|
||||
- Sending messages in any channel of the community
|
||||
- Joining channels
|
||||
- Accepting invites to the community
|
||||
- Banned user is removed from all channels and community membership on ban
|
||||
- Ban check runs in middleware for all community-scoped API endpoints
|
||||
- Bans are permanent until explicitly lifted by Admin+
|
||||
|
||||
**Mutes:**
|
||||
- Mute record in `mutes` table with `expires_at` timestamp
|
||||
- Muted user cannot:
|
||||
- Send messages (POST to message endpoints returns 403)
|
||||
- Add reactions
|
||||
- Send typing indicators
|
||||
- Muted user CAN still read messages and channels
|
||||
- Expired mutes are ignored (no cleanup needed — checked on read)
|
||||
- Duration specified as interval: `10m`, `1h`, `24h`, `7d`
|
||||
|
||||
## 4. API Endpoints
|
||||
|
||||
### 4.1 Communities
|
||||
@@ -590,6 +627,43 @@ Updates `channel_members.last_read_message_id`. Used by SMs to calculate unread
|
||||
|
||||
---
|
||||
|
||||
### 4.16 Admin: OAuth Provider Management
|
||||
|
||||
| Method | Path | Description | Auth |
|
||||
|--------|------|-------------|------|
|
||||
| GET | `/api/admin/oauth-providers` | List all OAuth providers | Owner |
|
||||
| POST | `/api/admin/oauth-providers` | Create OAuth provider | Owner |
|
||||
| PUT | `/api/admin/oauth-providers/:id` | Update OAuth provider | Owner |
|
||||
| DELETE | `/api/admin/oauth-providers/:id` | Delete OAuth provider | Owner |
|
||||
|
||||
**POST /api/admin/oauth-providers**
|
||||
```
|
||||
Request: {"provider_type": "github", "name": "GitHub", "client_id": "...", "client_secret": "...", "enabled": true}
|
||||
Response: {"id": "uuid", "provider_type": "github", "name": "GitHub", "client_id": "...", "enabled": true, "created_at": "..."}
|
||||
Note: client_secret is encrypted at rest and never returned in responses.
|
||||
```
|
||||
|
||||
**PUT /api/admin/oauth-providers/:id**
|
||||
```
|
||||
Request: {"name": "GitHub Org", "client_id": "...", "client_secret": "...", "enabled": false}
|
||||
Response: {"id": "uuid", "provider_type": "github", "name": "GitHub Org", ...}
|
||||
Note: Omitting client_secret from the request leaves it unchanged.
|
||||
```
|
||||
|
||||
**Test Cases:**
|
||||
|
||||
| ID | Test | Description |
|
||||
|----|------|-------------|
|
||||
| AOP-T1 | Create OAuth provider | POST creates provider, returns without client_secret |
|
||||
| AOP-T2 | List providers | GET returns all providers without secrets |
|
||||
| AOP-T3 | Update provider | PUT updates name/enabled, secret unchanged if omitted |
|
||||
| AOP-T4 | Delete provider | DELETE removes provider |
|
||||
| AOP-T5 | Non-owner access | Non-owner user returns 403 |
|
||||
| AOP-T6 | Invalid provider_type | POST with unknown type returns 422 |
|
||||
| AOP-T7 | Duplicate provider_type | Allowed (multiple GitHub providers for different orgs) |
|
||||
|
||||
---
|
||||
|
||||
## 5. Cross-Cutting Concerns
|
||||
|
||||
### 5.1 Error Response Format
|
||||
@@ -609,3 +683,115 @@ Updates `channel_members.last_read_message_id`. Used by SMs to calculate unread
|
||||
### 5.4 Audit Trail (P2)
|
||||
- Log all admin actions (kick, ban, role change, channel delete) with actor + target + timestamp
|
||||
- Queryable by Owner via future admin API
|
||||
|
||||
---
|
||||
|
||||
## 6. Service Configuration
|
||||
|
||||
### 6.1 Config Shape
|
||||
|
||||
```clojure
|
||||
{:server {:host "0.0.0.0" :port 3001}
|
||||
:db {:host "localhost" :port 5432 :dbname "ajet_chat"
|
||||
:user "ajet" :password "..." :pool-size 10
|
||||
:migrations {:enabled true :location "migrations"}}
|
||||
:nats {:url "nats://localhost:4222"
|
||||
:stream-name "ajet-events"
|
||||
:publish-timeout-ms 5000}
|
||||
:minio {:endpoint "http://localhost:9000"
|
||||
:access-key "minioadmin" :secret-key "minioadmin"
|
||||
:bucket "ajet-chat"}
|
||||
:limits {:max-message-length 4000 ;; characters
|
||||
:max-upload-size 10485760 ;; 10MB in bytes
|
||||
:edit-window-minutes 60
|
||||
:default-page-size 50
|
||||
:max-page-size 100}}
|
||||
```
|
||||
|
||||
### 6.2 Middleware Pipeline
|
||||
|
||||
Requests flow through middleware in this order:
|
||||
|
||||
```
|
||||
1. Ring defaults (params, cookies, multipart)
|
||||
2. Request logging (method, path, start time)
|
||||
3. Exception handler (catch-all → 500 JSON error)
|
||||
4. Trace ID extraction (X-Trace-Id → MDC)
|
||||
5. User context extraction (X-User-Id, X-User-Role, X-Community-Id → request map)
|
||||
6. Ban check (community-scoped: reject if user is banned)
|
||||
7. Mute check (write endpoints: reject if user is muted)
|
||||
8. Reitit routing → handler
|
||||
9. Response logging (status, duration)
|
||||
```
|
||||
|
||||
### 6.3 Startup / Shutdown Sequence
|
||||
|
||||
**Startup:**
|
||||
```
|
||||
1. Load config (EDN + env vars)
|
||||
2. Create DB connection pool (HikariCP)
|
||||
3. Run Migratus migrations (if enabled)
|
||||
4. Connect to NATS
|
||||
5. Connect to MinIO, ensure bucket exists
|
||||
6. Start http-kit server
|
||||
7. Log "API service started on port {port}"
|
||||
```
|
||||
|
||||
**Shutdown (graceful):**
|
||||
```
|
||||
1. Stop accepting new HTTP connections
|
||||
2. Wait for in-flight requests (max 30s)
|
||||
3. Close NATS connection
|
||||
4. Close DB connection pool
|
||||
5. Log "API service stopped"
|
||||
```
|
||||
|
||||
### 6.4 Health Check
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/health` | None | Service health status |
|
||||
|
||||
**Response (200):**
|
||||
```json
|
||||
{"status": "ok", "checks": {"db": "ok", "nats": "ok", "minio": "ok"}}
|
||||
```
|
||||
|
||||
**Response (503 — degraded):**
|
||||
```json
|
||||
{"status": "degraded", "checks": {"db": "ok", "nats": "error", "minio": "ok"}}
|
||||
```
|
||||
|
||||
**Test Cases:**
|
||||
|
||||
| ID | Test | Description |
|
||||
|----|------|-------------|
|
||||
| HLT-T1 | Health check all up | Returns 200 with all checks "ok" |
|
||||
| HLT-T2 | Health check DB down | Returns 503 with db check "error" |
|
||||
| HLT-T3 | Health check NATS down | Returns 503 with nats check "error" |
|
||||
| HLT-T4 | Health check MinIO down | Returns 503 with minio check "error" |
|
||||
|
||||
---
|
||||
|
||||
## 7. Migration SQL (Outlines)
|
||||
|
||||
### 7.1 Migration Naming Convention
|
||||
|
||||
```
|
||||
{NNN}-{description}.up.sql — forward migration
|
||||
{NNN}-{description}.down.sql — rollback migration
|
||||
```
|
||||
|
||||
Migrations are sequential and must be applied in order. Each migration is idempotent — re-running a completed migration is a no-op (handled by Migratus tracking table).
|
||||
|
||||
### 7.2 Key Migration Notes
|
||||
|
||||
- **001-create-users:** `users` table + unique index on `username` + unique index on `email`
|
||||
- **006-create-channels:** `channels` table, `community_id` is nullable (DMs have NULL)
|
||||
- **008-create-messages:** `messages` table + composite index `(channel_id, created_at)` for pagination
|
||||
- **010-create-reactions:** Composite PK `(message_id, user_id, emoji)` — one reaction per user per emoji per message
|
||||
- **018-add-search-indexes:** `to_tsvector('english', body_md)` GIN index on `messages` for full-text search
|
||||
- **019-create-bans:** `bans` table with composite PK `(community_id, user_id)`
|
||||
- **020-create-mutes:** `mutes` table with composite PK `(community_id, user_id)`, index on `expires_at`
|
||||
- **021-create-oauth-providers:** `oauth_providers` table for runtime-configurable OAuth providers
|
||||
- **022-create-system-settings:** `system_settings` key-value table for deployment-wide settings (e.g., `setup_completed`)
|
||||
|
||||
+181
-31
@@ -31,9 +31,13 @@ Client → (nginx TLS, prod) → Auth Gateway → API Service
|
||||
| `GET /tui/sse/*` | TUI SM | Session | SSE streams for TUI clients |
|
||||
| `POST /tui/*` | TUI SM | Session | TUI client signals |
|
||||
| `POST /api/webhooks/*/incoming` | API | Webhook Token | Incoming webhooks (bypass session auth) |
|
||||
| `GET /auth/login` | Self | None | Login page |
|
||||
| `GET /auth/login` | Self | None | Login page (OAuth provider buttons) |
|
||||
| `GET /auth/callback/:provider` | Self | None | OAuth callback |
|
||||
| `POST /auth/logout` | Self | Session | Logout (destroy session) |
|
||||
| `GET /setup/providers` | Self | None | Setup wizard step 1: configure OAuth providers |
|
||||
| `POST /setup/providers` | Self | None | Setup wizard step 1: save OAuth provider config |
|
||||
| `GET /setup/community` | Self | Session | Setup wizard step 3: create first community |
|
||||
| `POST /setup/community` | Self | Session | Setup wizard step 3: submit community creation |
|
||||
| `GET /invite/:code` | Self | None | Invite landing page → redirect to login if needed |
|
||||
| `GET /health` | Self | None | Health check |
|
||||
|
||||
@@ -75,20 +79,23 @@ For `Authorization: Bearer <token>` requests to `/api/*`:
|
||||
|
||||
**Supported providers:** GitHub, Gitea, Generic OIDC
|
||||
|
||||
**Provider storage:** OAuth providers are stored in the `oauth_providers` DB table and are configurable at runtime via admin endpoints (`/api/admin/oauth-providers`). On first startup, if the `oauth_providers` table is empty, any providers configured via env vars (`:oauth` config) are auto-migrated to the DB.
|
||||
|
||||
```
|
||||
1. User visits /auth/login
|
||||
2. Page shows provider buttons (GitHub, Gitea, or configured OIDC)
|
||||
3. User clicks provider → redirect to provider's authorize URL
|
||||
4. Provider redirects to /auth/callback/:provider with code
|
||||
5. Auth GW exchanges code for access token
|
||||
6. Auth GW fetches user profile from provider
|
||||
7. Look up oauth_accounts by (provider, provider_user_id):
|
||||
2. Auth GW loads enabled providers from oauth_providers table
|
||||
3. Page shows provider buttons for each enabled provider
|
||||
4. User clicks provider → redirect to provider's authorize URL
|
||||
5. Provider redirects to /auth/callback/:provider with code
|
||||
6. Auth GW exchanges code for access token
|
||||
7. Auth GW fetches user profile from provider
|
||||
8. Look up oauth_accounts by (provider, provider_user_id):
|
||||
a. EXISTS: load user, create session
|
||||
b. NOT EXISTS: create user + oauth_account, create session
|
||||
8. Set session cookie, redirect to / (or to pending invite if present)
|
||||
9. Set session cookie, redirect to / (or to pending invite if present)
|
||||
```
|
||||
|
||||
**OAuth config shape:**
|
||||
**OAuth config shape (fallback — auto-migrated to DB on first startup):**
|
||||
```clojure
|
||||
{:oauth
|
||||
{:github {:client-id "..." :client-secret "..." :enabled true}
|
||||
@@ -96,23 +103,39 @@ For `Authorization: Bearer <token>` requests to `/api/*`:
|
||||
:oidc {:client-id "..." :client-secret "..." :issuer-url "https://auth.example.com" :enabled false}}}
|
||||
```
|
||||
|
||||
After migration, provider config is read exclusively from the DB. The `:oauth` config key serves only as a seed for initial deployment and is ignored once providers exist in the DB.
|
||||
|
||||
**Generic OIDC:** Uses `.well-known/openid-configuration` discovery. Requires `openid`, `profile`, `email` scopes.
|
||||
|
||||
### 4.4 First-User Bootstrap
|
||||
### 4.4 Admin Setup Wizard (First-Deployment Bootstrap)
|
||||
|
||||
The setup wizard is a multi-step flow handled by Auth GW for first-time deployment. It activates when the `system_settings` table indicates setup is incomplete (no `setup_completed` flag).
|
||||
|
||||
```
|
||||
1. User hits /auth/login
|
||||
2. Auth GW checks: any users in DB?
|
||||
- No users: show "Create your community" flow after OAuth
|
||||
- Has users: normal login
|
||||
3. After first OAuth login:
|
||||
a. Create user from OAuth profile
|
||||
b. Redirect to /setup (community creation wizard on Web SM)
|
||||
c. Web SM shows: community name input, slug auto-generated
|
||||
d. POST creates community (user becomes owner, #general created)
|
||||
e. Redirect to /app
|
||||
Step 1 — Configure OAuth Providers (no auth required):
|
||||
1. User hits any route on a fresh deployment
|
||||
2. Auth GW checks system_settings: setup_completed?
|
||||
- Not completed: redirect to /setup/providers
|
||||
- Completed: normal login flow
|
||||
3. /setup/providers shows a form to configure at least one OAuth provider
|
||||
(provider type, client ID, client secret, base URL / issuer URL)
|
||||
4. Admin submits provider config → saved to oauth_providers table
|
||||
5. Auth GW redirects to /auth/login with the newly configured provider(s)
|
||||
|
||||
Step 2 — Admin Login via OAuth:
|
||||
6. Admin logs in via one of the just-configured OAuth providers
|
||||
7. First user is created with admin/owner privileges
|
||||
|
||||
Step 3 — Create First Community:
|
||||
8. After login, redirect to /setup/community (rendered by Auth GW, not Web SM)
|
||||
9. Admin enters community name, slug auto-generated
|
||||
10. POST creates community (user becomes owner, #general created)
|
||||
11. system_settings.setup_completed = true
|
||||
12. Redirect to /app
|
||||
```
|
||||
|
||||
Subsequent community creation (by already-authenticated users) uses the Web SM `/setup` page.
|
||||
|
||||
### 4.5 Invite Flow
|
||||
|
||||
```
|
||||
@@ -149,6 +172,7 @@ For `Authorization: Bearer <token>` requests to `/api/*`:
|
||||
|
||||
| Endpoint Pattern | Limit | Window |
|
||||
|-----------------|-------|--------|
|
||||
| `POST /auth/login` | 10 | 1 min per IP |
|
||||
| `POST /auth/callback/*` | 10 | 1 min per IP |
|
||||
| `POST /api/*` | 60 | 1 min per user |
|
||||
| `GET /api/*` | 120 | 1 min per user |
|
||||
@@ -205,13 +229,18 @@ Attributes:
|
||||
| AUTH-T17 | New user first login | Creates user + oauth_account + session |
|
||||
| AUTH-T18 | OAuth state parameter | CSRF protection via state param validated on callback |
|
||||
|
||||
### 8.4 First-User Bootstrap
|
||||
### 8.4 Admin Setup Wizard
|
||||
|
||||
| ID | Test | Description |
|
||||
|----|------|-------------|
|
||||
| AUTH-T19 | Empty DB shows setup flow | /auth/login with 0 users shows setup message |
|
||||
| AUTH-T20 | First user becomes owner | After OAuth + community creation, user has owner role |
|
||||
| AUTH-T21 | Subsequent users see normal login | With users in DB, normal login page shown |
|
||||
| AUTH-T19 | Fresh deploy redirects to setup | Any route with setup_completed=false redirects to /setup/providers |
|
||||
| AUTH-T20 | Step 1: configure OAuth provider | POST /setup/providers saves provider to oauth_providers table |
|
||||
| AUTH-T21 | Step 1: requires at least one provider | POST /setup/providers with empty config returns 422 |
|
||||
| AUTH-T22a | Step 2: login via configured provider | After provider setup, /auth/login shows newly configured provider |
|
||||
| AUTH-T22b | Step 3: first user becomes owner | After OAuth + community creation, user has owner role |
|
||||
| AUTH-T22c | Setup completed flag set | After community creation, system_settings.setup_completed = true |
|
||||
| AUTH-T22d | Subsequent users see normal login | With setup_completed=true, normal login page shown |
|
||||
| AUTH-T22e | Setup routes blocked after completion | /setup/providers returns 403 when setup_completed=true |
|
||||
|
||||
### 8.5 Invite Flow
|
||||
|
||||
@@ -222,7 +251,7 @@ Attributes:
|
||||
| AUTH-T24 | Exhausted invite | Shows error page |
|
||||
| AUTH-T25 | Already-member invite | Accepts gracefully, redirects to community |
|
||||
|
||||
### 8.6 Reverse Proxy
|
||||
### 8.7 Reverse Proxy
|
||||
|
||||
| ID | Test | Description |
|
||||
|----|------|-------------|
|
||||
@@ -234,7 +263,7 @@ Attributes:
|
||||
| AUTH-T31 | Headers injected | X-User-Id, X-Trace-Id present on proxied request |
|
||||
| AUTH-T32 | Original auth headers stripped | Client cannot forge X-User-Id |
|
||||
|
||||
### 8.7 Rate Limiting
|
||||
### 8.8 Rate Limiting
|
||||
|
||||
| ID | Test | Description |
|
||||
|----|------|-------------|
|
||||
@@ -243,14 +272,14 @@ Attributes:
|
||||
| AUTH-T35 | Rate limit per-user | Different users have independent limits |
|
||||
| AUTH-T36 | Rate limit per-IP for auth | OAuth callback rate limited by IP |
|
||||
|
||||
### 8.8 Logout
|
||||
### 8.9 Logout
|
||||
|
||||
| ID | Test | Description |
|
||||
|----|------|-------------|
|
||||
| AUTH-T37 | Logout destroys session | POST /auth/logout deletes session from DB, clears cookie |
|
||||
| AUTH-T38 | Logout with invalid session | Returns 200 (idempotent), clears cookie |
|
||||
|
||||
### 8.9 Health Check
|
||||
### 8.10 Health Check
|
||||
|
||||
| ID | Test | Description |
|
||||
|----|------|-------------|
|
||||
@@ -259,7 +288,113 @@ Attributes:
|
||||
|
||||
---
|
||||
|
||||
## 9. Login Page UI Mock (Hiccup rendered by Auth GW)
|
||||
## 9. Service Configuration
|
||||
|
||||
### 9.1 Config Shape
|
||||
|
||||
```clojure
|
||||
{:server {:host "0.0.0.0" :port 3000}
|
||||
:db {:host "localhost" :port 5432 :dbname "ajet_chat"
|
||||
:user "ajet" :password "..." :pool-size 5}
|
||||
:oauth {:github {:client-id "..." :client-secret "..." :enabled true}
|
||||
:gitea {:client-id "..." :client-secret "..." :base-url "https://gitea.example.com" :enabled false}
|
||||
:oidc {:client-id "..." :client-secret "..." :issuer-url "https://auth.example.com" :enabled false}}
|
||||
;; ↑ Fallback seed only — auto-migrated to oauth_providers DB table on first startup.
|
||||
;; Ignored once providers exist in DB. Manage providers via admin API after setup.
|
||||
:services {:api {:host "localhost" :port 3001}
|
||||
:web-sm {:host "localhost" :port 3002}
|
||||
:tui-sm {:host "localhost" :port 3003}}
|
||||
:session {:ttl-days 30
|
||||
:cookie-name "ajet_session"
|
||||
:cookie-secure true} ;; false in dev
|
||||
:rate-limit {:enabled true}
|
||||
:cors {:allowed-origins ["https://chat.example.com"]
|
||||
:allowed-methods [:get :post :put :delete :options]
|
||||
:allowed-headers ["Content-Type" "Authorization" "X-Trace-Id"]
|
||||
:max-age 86400}}
|
||||
```
|
||||
|
||||
### 9.2 CORS Configuration
|
||||
|
||||
CORS headers applied to all responses:
|
||||
|
||||
```
|
||||
Access-Control-Allow-Origin: <configured origin or request Origin if in allowed list>
|
||||
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
|
||||
Access-Control-Allow-Headers: Content-Type, Authorization, X-Trace-Id
|
||||
Access-Control-Allow-Credentials: true
|
||||
Access-Control-Max-Age: 86400
|
||||
```
|
||||
|
||||
- **Dev mode:** Allow `http://localhost:*` origins
|
||||
- **Prod mode:** Strict origin whitelist from config
|
||||
- **Preflight requests:** `OPTIONS` handled and returned immediately (no proxy)
|
||||
|
||||
### 9.3 Audit Logging
|
||||
|
||||
**What's logged:**
|
||||
- All admin actions: kick, ban, mute, role change, channel delete, webhook create/delete, invite create/revoke
|
||||
- Authentication events: login success, login failure, logout, session expiry
|
||||
- Rate limit violations
|
||||
|
||||
**Audit log table (in PostgreSQL, written by Auth GW):**
|
||||
```sql
|
||||
audit_log (
|
||||
id uuid PK,
|
||||
actor_id uuid FK→users NULL, -- NULL for unauthenticated events
|
||||
action text, -- 'login', 'kick', 'ban', 'channel.delete', etc.
|
||||
target_type text NULL, -- 'user', 'channel', 'community', etc.
|
||||
target_id uuid NULL,
|
||||
community_id uuid NULL,
|
||||
ip_address inet,
|
||||
metadata jsonb, -- extra context (reason, duration, etc.)
|
||||
created_at timestamptz
|
||||
)
|
||||
idx_audit_log_actor ON audit_log(actor_id, created_at)
|
||||
idx_audit_log_community ON audit_log(community_id, created_at)
|
||||
```
|
||||
|
||||
**Note:** Auth GW writes audit logs directly to PG (it has DB access). API sends audit-worthy events to Auth GW via NATS subject `chat.audit` — Auth GW subscribes and persists them.
|
||||
|
||||
### 9.4 Middleware Pipeline
|
||||
|
||||
```
|
||||
1. CORS headers (preflight short-circuit)
|
||||
2. Request ID generation (X-Trace-Id if not present)
|
||||
3. Request logging
|
||||
4. Rate limiting (per-IP for auth, per-user for API)
|
||||
5. Route matching
|
||||
6. Auth endpoints → handle directly (OAuth, login page, logout)
|
||||
7. Health check → handle directly
|
||||
8. Webhook endpoints → webhook token validation → proxy to API
|
||||
9. All other → session/token validation → header injection → proxy to target
|
||||
10. Response logging (status, duration)
|
||||
```
|
||||
|
||||
### 9.5 Startup / Shutdown Sequence
|
||||
|
||||
**Startup:**
|
||||
```
|
||||
1. Load config (EDN + env vars)
|
||||
2. Create DB connection pool (HikariCP)
|
||||
3. Auto-migrate OAuth providers from :oauth config to oauth_providers table (if table is empty)
|
||||
4. Load enabled OAuth providers from DB
|
||||
5. Initialize rate limiter (in-memory atom)
|
||||
6. Start http-kit server
|
||||
7. Log "Auth Gateway started on port {port}"
|
||||
```
|
||||
|
||||
**Shutdown (graceful):**
|
||||
```
|
||||
1. Stop accepting new connections
|
||||
2. Wait for in-flight requests (max 30s)
|
||||
3. Close DB connection pool
|
||||
4. Log "Auth Gateway stopped"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Login Page UI Mock (Hiccup rendered by Auth GW)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────┐
|
||||
@@ -271,13 +406,13 @@ Attributes:
|
||||
│ Sign in to continue │
|
||||
│ │
|
||||
│ ┌──────────────────────────┐ │
|
||||
│ │ ◉ Continue with GitHub │ │
|
||||
│ │ ◉ Continue with GitHub │ │ ← providers loaded from DB
|
||||
│ └──────────────────────────┘ │
|
||||
│ ┌──────────────────────────┐ │
|
||||
│ │ ◉ Continue with Gitea │ │
|
||||
│ └──────────────────────────┘ │
|
||||
│ ┌──────────────────────────┐ │
|
||||
│ │ ◉ Continue with SSO │ │ ← only if OIDC configured
|
||||
│ │ ◉ Continue with SSO │ │ ← only if OIDC provider in DB
|
||||
│ └──────────────────────────┘ │
|
||||
│ │
|
||||
│ ─── or accepting invite ─── │ ← only if invite code present
|
||||
@@ -285,3 +420,18 @@ Attributes:
|
||||
│ │
|
||||
└──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Error Pages
|
||||
|
||||
Auth GW renders simple HTML error pages for:
|
||||
|
||||
| Status | Page | When |
|
||||
|--------|------|------|
|
||||
| 401 | Unauthorized | Invalid/expired session (web requests redirect to `/auth/login` instead) |
|
||||
| 403 | Forbidden | Valid session but insufficient permission |
|
||||
| 404 | Not Found | Unknown route |
|
||||
| 429 | Rate Limited | Too many requests — shows retry countdown |
|
||||
| 502 | Bad Gateway | Target service unreachable |
|
||||
| 503 | Service Unavailable | Auth GW degraded (DB down) |
|
||||
|
||||
+100
-9
@@ -310,9 +310,100 @@ Last-Event-ID: <id> (for reconnection)
|
||||
- Unread counts in sidebar (same as web)
|
||||
- Notification list accessible via slash command `/notifications`
|
||||
|
||||
## 5. Test Cases
|
||||
## 5. Distribution & Packaging
|
||||
|
||||
### 5.1 CLI Authentication
|
||||
### 5.1 bbin Packaging
|
||||
|
||||
**Build:** Compile to an uberjar, then distribute via bbin (Babashka binary installer).
|
||||
|
||||
```bash
|
||||
# Build uberjar
|
||||
clj -T:build uber # produces target/ajet-chat-cli.jar
|
||||
|
||||
# Install locally via bbin
|
||||
bbin install . --as ajet # installs 'ajet' command from local project
|
||||
|
||||
# Install from remote (for users)
|
||||
bbin install io.github.ajet/ajet-chat-cli --as ajet
|
||||
```
|
||||
|
||||
**Binary:** The `ajet` command is a shell wrapper that invokes `java -jar` (or `bb` if Babashka-compatible). First run may be slow due to JVM startup; subsequent runs benefit from Drip or CDS caching.
|
||||
|
||||
### 5.2 Babashka Compatibility
|
||||
|
||||
**Goal:** CLI mode should be Babashka-compatible for fast startup. TUI mode requires JVM (clojure-tui dependency).
|
||||
|
||||
**Constraints for Babashka compatibility:**
|
||||
- No `deftype` / `defrecord` (use maps + protocols sparingly)
|
||||
- No `gen-class`
|
||||
- Use `babashka.http-client` (not `clj-http`)
|
||||
- Use `clojure.data.json` (bb-compatible)
|
||||
- Avoid Java interop beyond what bb supports
|
||||
- All CLI commands (non-TUI) target < 100ms startup via bb
|
||||
|
||||
**Fallback:** If Babashka compatibility proves too restrictive, ship as JVM uberjar with CDS (Class Data Sharing) for faster startup.
|
||||
|
||||
### 5.3 Exit Codes
|
||||
|
||||
| Code | Meaning |
|
||||
|------|---------|
|
||||
| 0 | Success |
|
||||
| 1 | General error (API error, unexpected failure) |
|
||||
| 2 | Usage error (bad arguments, unknown command) |
|
||||
| 3 | Authentication error (not logged in, token expired) |
|
||||
| 4 | Permission error (403 from API) |
|
||||
| 5 | Not found (404 from API — channel, message, user doesn't exist) |
|
||||
| 130 | Interrupted (Ctrl+C / SIGINT) |
|
||||
|
||||
### 5.4 Error Message UX
|
||||
|
||||
All errors follow this format:
|
||||
```
|
||||
Error: <short description>
|
||||
|
||||
<details or suggestion>
|
||||
|
||||
Hint: <actionable next step>
|
||||
```
|
||||
|
||||
**Examples:**
|
||||
```
|
||||
Error: Not logged in
|
||||
|
||||
No session token found. You need to authenticate first.
|
||||
|
||||
Hint: Run 'ajet login' to sign in
|
||||
```
|
||||
|
||||
```
|
||||
Error: Channel not found: #nonexistent
|
||||
|
||||
The channel doesn't exist or you don't have access.
|
||||
|
||||
Hint: Run 'ajet channels' to see available channels
|
||||
```
|
||||
|
||||
```
|
||||
Error: Edit window expired
|
||||
|
||||
Messages can only be edited within 1 hour of creation.
|
||||
This message was sent 3 hours ago.
|
||||
```
|
||||
|
||||
### 5.5 Offline Behavior
|
||||
|
||||
| Scenario | Behavior |
|
||||
|----------|----------|
|
||||
| Server unreachable | `Error: Cannot connect to server at chat.example.com` + hint to check config |
|
||||
| Timeout (> 10s) | `Error: Request timed out` + hint to retry |
|
||||
| TUI SSE disconnects | Status bar shows "Reconnecting..." + auto-retry with backoff |
|
||||
| TUI SSE reconnects | Catches up on missed events, no user action needed |
|
||||
|
||||
---
|
||||
|
||||
## 6. Test Cases
|
||||
|
||||
### 6.1 CLI Authentication
|
||||
|
||||
| ID | Test | Description |
|
||||
|----|------|-------------|
|
||||
@@ -323,7 +414,7 @@ Last-Event-ID: <id> (for reconnection)
|
||||
| CLI-T5 | Expired token | Commands return clear "session expired, run ajet login" message |
|
||||
| CLI-T6 | No config exists | First run creates config dir and prompts for server URL |
|
||||
|
||||
### 5.2 CLI Commands
|
||||
### 6.2 CLI Commands
|
||||
|
||||
| ID | Test | Description |
|
||||
|----|------|-------------|
|
||||
@@ -351,7 +442,7 @@ Last-Event-ID: <id> (for reconnection)
|
||||
| CLI-T28 | Unknown command | Prints help with suggestion |
|
||||
| CLI-T29 | No arguments | Prints usage/help |
|
||||
|
||||
### 5.3 TUI Launch & Layout
|
||||
### 6.3 TUI Launch & Layout
|
||||
|
||||
| ID | Test | Description |
|
||||
|----|------|-------------|
|
||||
@@ -362,7 +453,7 @@ Last-Event-ID: <id> (for reconnection)
|
||||
| TUI-T5 | Online indicators | Online users have green dot in DM list |
|
||||
| TUI-T6 | Status bar | Shows keybindings and connection status |
|
||||
|
||||
### 5.4 TUI Navigation
|
||||
### 6.4 TUI Navigation
|
||||
|
||||
| ID | Test | Description |
|
||||
|----|------|-------------|
|
||||
@@ -377,7 +468,7 @@ Last-Event-ID: <id> (for reconnection)
|
||||
| TUI-T15 | Esc closes panels | Thread panel or search closes on Esc |
|
||||
| TUI-T16 | Community switch | Click community in sidebar → channels update |
|
||||
|
||||
### 5.5 TUI Messaging
|
||||
### 6.5 TUI Messaging
|
||||
|
||||
| ID | Test | Description |
|
||||
|----|------|-------------|
|
||||
@@ -393,7 +484,7 @@ Last-Event-ID: <id> (for reconnection)
|
||||
| TUI-T26 | Reply in thread | Type in thread input → reply sent |
|
||||
| TUI-T27 | Image paste | Not supported in TUI (CLI `--image` flag instead) |
|
||||
|
||||
### 5.6 TUI Real-Time
|
||||
### 6.6 TUI Real-Time
|
||||
|
||||
| ID | Test | Description |
|
||||
|----|------|-------------|
|
||||
@@ -404,7 +495,7 @@ Last-Event-ID: <id> (for reconnection)
|
||||
| TUI-T32 | SSE reconnect | Connection lost → "Reconnecting..." → auto-reconnects |
|
||||
| TUI-T33 | Bell notification | Terminal bell on @mention or DM |
|
||||
|
||||
### 5.7 TUI Rendering
|
||||
### 6.7 TUI Rendering
|
||||
|
||||
| ID | Test | Description |
|
||||
|----|------|-------------|
|
||||
@@ -422,7 +513,7 @@ Last-Event-ID: <id> (for reconnection)
|
||||
| TUI-T45 | Long message wrapping | Long messages wrap correctly within pane width |
|
||||
| TUI-T46 | Terminal resize | Layout reflows on terminal resize event |
|
||||
|
||||
### 5.8 TUI Error Handling
|
||||
### 6.8 TUI Error Handling
|
||||
|
||||
| ID | Test | Description |
|
||||
|----|------|-------------|
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user